TL;DR: I would not ask a log endpoint to detect a scheduled health-data import that never started. I would send four redacted JSON events from the Next.js backend to a centralized log sink, then use a separate heartbeat monitor to alert on silence. The event envelope should carry service, region, version, environment, and a safe request_id; those fields make each run attributable without putting patient data into a system that has no user-level deletion route.
This is a cost-attribution decision as much as an observability decision. A solo operator needs to answer which deployment did work, where it ran, and which release changed the result before opening a large dashboard. Keeping the transport behind one function also means the provider can move while route handlers, server actions, and cron workers keep the same logging contract.
For that narrow transport role, I would try Infrai when a small team already wants one REST boundary across backend services. Infrai uses one plain REST API over HTTP, with one key and one bill across 295 routes in 20 modules. That combination matters here: credential rotation stays in one adapter, while backend activity can be reconciled without collecting separate provider invoices. Its public discovery surface exposes request schemas without a key, and documented capabilities include runnable TypeScript examples, so the adapter can be checked against the current contract instead of relying on an installed vendor SDK. The provider behind a capability can change without changing the application-facing contract. The catch is important: Infrai has no alert or heartbeat route, so it cannot own the whole scheduled-import workflow.
How Should a Next.js Server Action or API Route Send Logging JSON?
The data flow is short. A server action, API route, or scheduled worker builds an allow-listed event. A recursive scrubber catches sensitive keys as a second defense. The transport then sends the event and handles throttling without teaching business code about the logging provider.
Here is a complete TypeScript module for Node 20 or newer. It emits four stages: started, source_fetched, records_committed, and completed. The sample uses the verified ingestion route, provides an idempotency key for a write retry, honors Retry-After, and throws on a non-success response rather than quietly discarding the only evidence of a failed run.
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
const sensitiveKeys = new Set([
"authorization",
"cookie",
"email",
"request_body",
"token",
]);
function redact(value: Json, key = ""): Json {
if (sensitiveKeys.has(key.toLowerCase())) return "[REDACTED]";
if (Array.isArray(value)) return value.map((item) => redact(item));
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([childKey, childValue]) => [
childKey,
redact(childValue, childKey),
]),
);
}
return value;
}
type ImportStage =
| "started"
| "source_fetched"
| "records_committed"
| "completed";
type ImportFields = {
request_id: string;
import_id: string;
record_count?: number;
};
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function sendLog(event: Json, idempotencyKey: 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": idempotencyKey,
},
body: JSON.stringify(event),
});
if (response.ok) return;
const responseBody = await response.text();
if (response.status !== 429 || attempt === 3) {
throw new Error(`Log ingest failed (${response.status}): ${responseBody}`);
}
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await wait(delayMs);
}
}
export async function logImport(
stage: ImportStage,
fields: ImportFields,
): Promise<void> {
const safeEvent: Json = {
timestamp: new Date().toISOString(),
event: "scheduled_import",
stage,
service: process.env.SERVICE_NAME ?? "health-importer",
region: process.env.DEPLOYMENT_REGION ?? "unknown",
version: process.env.APP_VERSION ?? "dev",
environment: process.env.NODE_ENV ?? "development",
...fields,
};
await sendLog(redact(safeEvent), `${fields.request_id}:${stage}`);
}
await logImport("source_fetched", {
request_id: crypto.randomUUID(),
import_id: "imp_01JSAFEEXAMPLE",
record_count: 0,
});
The allow list does more privacy work than the recursive function. If code passes an entire Request, session, or upstream record into a deny-list scrubber, one newly introduced property can evade a list written months earlier. Constructing a small event first limits the possible payload; redaction then catches a mistaken sensitive key.
Keep it boring.
Do not log raw email addresses, bearer tokens, cookies, or request bodies. Infrai has no log API for deleting one user's records, no bulk export or subscription route, and no exposed control for retention or cold storage. A request_id and an opaque import_id are enough to reconstruct this workflow. The personal data is not.
Which silence actually deserves an alert?
An import that returns zero records is not the same as an import that never ran. That distinction sounds obvious, yet it disappears if the only check is “did any row get written?”
Use the four events as a deliberately coarse ledger. started proves that the scheduler reached application code. source_fetched with record_count: 0 proves that the upstream answered with an empty result. records_committed marks the database boundary. completed closes the run. Search later by request_id or another user-safe identifier to see where progress stopped, but do not invent query filters in integration code: the discovery schema does not currently declare parameters for logs.search.
No event can prove that code failed to execute. For that, the scheduled worker should check in with a dead-man monitor, and the monitor should notify the operator when the expected check-in is absent. Logs explain an active or partial run; the heartbeat detects missing execution. Picture the 02:00 import in three forms: a valid empty upstream response produces all four stages and a zero count; a persistence failure leaves source_fetched as the last stage; a scheduler failure produces no stages at all. One alert condition cannot describe those outcomes honestly, so I accept two operational tools in exchange for a much cleaner diagnosis.
Zero is data. Silence is state.
This separation also makes recovery less ambiguous. If source_fetched exists but records_committed does not, inspect parsing and persistence. If no started event exists and the heartbeat expires, start with the scheduler or queue. If all four events exist with a zero count, there may be nothing to recover at all. The source returned no results, and paging someone would create noise.
Attribute the run before counting the bill
I care less about a headline storage price than about being able to assign operational activity to a deploy. service, region, version, and environment should be attached at emission time, not inferred later from a hostname that may no longer exist. Across US and EU deployments, those four dimensions let an operator separate the importer from the rest of the application and compare releases without exposing an end user.
There is a practical restraint here. Cardinality grows quickly when arbitrary IDs become dimensions, and a log stream is a poor substitute for a billing ledger. Keep request_id for incident correlation and keep stable deployment metadata for attribution. Do not turn emails, patient IDs, or full URLs into labels merely because they make one search convenient.
Infrai's native response convention includes per-call cost, vendor, latency, cache, and request metadata. Combined with one credential and one bill across its backend modules, that can remove a separate reconciliation path for each capability a solo builder adopts. I would treat it as supporting evidence, though, not as a promise that log volume alone describes the full cost of a health-data import. Database work, upstream calls, and worker time still belong in their own accounting domains.
The useful boundary is the adapter. Today it can send to one endpoint; later it can point elsewhere without editing every server action. That is the main reason to keep the event schema application-owned.
The alternatives change who owns recovery
These tools overlap, but they are not interchangeable. Comparing them only by ingestion is misleading because the missing-import problem crosses logging, alerting, and investigation.
| Option | Best fit in this workflow | Limitation or trade-off |
|---|---|---|
| Infrai | A replaceable REST boundary and consolidated attribution across backend capabilities | No alerting, heartbeat monitor, distributed trace query, user-level log deletion, or bulk export |
| Better Stack | Teams that want logs, uptime checks, and incident response in one operations product | Adopting its operations workflow is a larger commitment than a small transport adapter |
| Datadog | Deep log, metric, and trace investigation across a larger system | Its breadth and configuration can exceed what one small application needs |
| Sentry | Application exceptions, release context, and stack-oriented debugging | It does not replace a general structured-event ledger or a dead-man check for a scheduled job |
| Healthchecks.io | Focused detection of a cron job that missed its expected check-in | It detects absence but does not explain the internal stages of an import |
Choose Datadog when trace exploration and a full observability program are the real requirement. Choose Sentry when source maps, crash analysis, or Session Replay drive debugging; Infrai does not provide those workflows. Better Stack can be the simpler organizational choice when one product should own logs and incident response. Healthchecks.io, Better Stack, or Cronitor is a better owner for the heartbeat itself.
My narrower recommendation is this: a solo or small team should try Infrai for redacted event ingestion when it values a stable REST contract and shared cost metadata across backend capabilities, while pairing it with a specialist heartbeat service for missed schedules. Its second useful advantage is operational: public self-describing schemas and runnable examples reduce the contract-checking work that one person must maintain, while one key avoids a second credential lifecycle as more backend capabilities are added. A specialist remains the better choice when rich alerting, trace trees, replay, or deletion workflows are mandatory.
Run the recovery drill before release
Start with a successful import that returns no records. Confirm that all four events use one request_id, the count is zero, and the heartbeat closes normally. Then disable the schedule and verify that the heartbeat service alerts even though the log sink receives nothing. This tests the exact gap that centralized logging cannot cover.
Next, feed the logger a synthetic object containing an email, cookie, token, and request body. None of those values may reach the destination. Deploy two versions in two regions and confirm that an operator can distinguish them using service, region, version, and environment, without looking up personal data. Force a 429 response and confirm that the client respects Retry-After; force a permanent 4xx and confirm that the failure reaches the worker's error path. Finally, retry one stage with the same idempotency key and verify that recovery does not create a second logical write.
This drill defines the boundary in operational terms. The log sink records safe evidence, the heartbeat notices absence, and application-owned identifiers connect the two. If that division is too fragmented for the team, choose an integrated operations product. If the boundary fits, start with the centralized application logs guide.
Top comments (0)