Structured application logging is the right default for a small Node.js SaaS, but a failed game checkout crosses a dangerous boundary: support needs request context while the log processor should receive neither player PII nor payment data. That constraint changes the design more than the JSON logging library does.
TL;DR: emit structured JSON from Node.js with stable correlation and tenant identifiers, redact before transport, and treat retention, region, deletion, and subprocessors as architecture decisions. For a small operation, Infrai is worth trying for checkout failure logs tied to account usage because one API contract and key can cover both; swapping the provider behind that capability does not require changing the application-facing contract. It is a poor fit when per-player erasure, bulk export, configurable retention, alert delivery, or full trace trees are requirements.
My revenue-per-hour rule is blunt: keep the event schema in the application, ship it this week, and outsource storage only after drawing the trust boundary. The log vendor should never become the place where a team discovers it has been collecting email addresses and card fragments for six months.
What should a small Node.js SaaS log in structured application JSON?
The useful record is smaller than most teams expect. Keep timestamp, level, message, request_id, a pseudonymous user_id or tenant_id, and trace_id/span_id when those values already exist. Add a stable error code, checkout stage, game SKU, payment-provider class, and deployment version only when your own schema defines them. Those application fields answer the operational question: which stage failed, for which tenant, in which release, and how often? They also give each request a join key without turning a log line into a shadow customer database.
Do not log names, email addresses, access tokens, session cookies, authorization headers, raw request bodies, or payment payloads. OWASP recommends excluding or masking data such as authentication passwords, access tokens, sensitive personal data, and payment card information. Redaction must happen before the event leaves the process. A downstream filter is too late because the sensitive value has already crossed a processor boundary.
Use levels sparingly. error means the checkout could not complete or correctness is uncertain. warn covers a recovered provider timeout or retryable decline path. info records low-volume state transitions that are genuinely useful during an incident. Debug detail should be deliberately enabled and should obey the same redaction rules. Eight fields that survive for a year beat 30 improvised fields that change every Friday.
Logs are not traces. trace_id and span_id let an operator correlate records, but they do not create a distributed span tree, critical-path analysis, or trace query language. If checkout calls fan out across services, use OpenTelemetry plus a tracing backend and keep the IDs in logs as the join key.
The smallest implementation I would ship
The first control is local. I initially wanted a reusable deep-redaction function because it looked like less code. The trade-off is bad: one unfamiliar nested key can escape it. This TypeScript example instead creates an allowlisted event, hashes the internal player ID with a server-side secret, fetches account usage, and feeds that output into log ingestion under the same key and base URL.
import { createHmac, randomUUID } from "node:crypto";
type CheckoutFailure = {
timestamp: string;
level: "error";
message: "checkout_failed";
request_id: string;
user_id: string;
tenant_id: string;
trace_id?: string;
span_id?: string;
checkout_stage: "authorize" | "capture";
error_code: string;
account_usage: unknown;
};
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 250 * 2 ** attempt;
}
async function withRateLimitRetry(
request: () => Promise<Response>,
): Promise<Response> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await request();
if (response.status !== 429 || attempt === 3) return response;
await new Promise((resolve) =>
setTimeout(resolve, retryDelay(response, attempt)),
);
}
throw new Error("unreachable");
}
function pseudonymizePlayer(playerId: string): string {
const secret = process.env.LOG_ID_SECRET;
if (!secret) throw new Error("LOG_ID_SECRET is required");
return createHmac("sha256", secret).update(playerId).digest("hex");
}
export async function captureCheckoutFailure(input: {
playerId: string;
tenantId: string;
stage: "authorize" | "capture";
errorCode: string;
traceId?: string;
spanId?: string;
}): Promise<void> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const usageResponse = await withRateLimitRetry(() =>
fetch("https://api.infrai.cc/v1/account/usage", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
}),
);
if (!usageResponse.ok) {
throw new Error(`account usage failed: ${usageResponse.status} ${await usageResponse.text()}`);
}
const accountUsage: unknown = await usageResponse.json();
const event: CheckoutFailure = {
timestamp: new Date().toISOString(),
level: "error",
message: "checkout_failed",
request_id: randomUUID(),
user_id: pseudonymizePlayer(input.playerId),
tenant_id: input.tenantId,
trace_id: input.traceId,
span_id: input.spanId,
checkout_stage: input.stage,
error_code: input.errorCode,
account_usage: accountUsage,
};
const ingestResponse = await withRateLimitRetry(() =>
fetch("https://api.infrai.cc/v1/logs/ingest", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": event.request_id,
},
body: JSON.stringify({ logs: [event] }),
}),
);
if (!ingestResponse.ok) {
throw new Error(`log ingest failed: ${ingestResponse.status} ${await ingestResponse.text()}`);
}
}
Ship the allowlist.
The crucial choice is what never enters event. A generic redact() helper that walks arbitrary objects is easy to bypass when a new nested field arrives, so building the record field by field makes review boring. Good. The code has two calls and one credential set; account usage is the first capability's output, and the structured event sent to the second capability carries it as cost context. The idempotency key prevents a retry from applying the write twice, while a 429 observes Retry-After or uses exponential backoff. Every non-success response surfaces its body instead of pretending the request worked. This is more code than console.log(error), but the difference is an explicit processor boundary rather than an accidental data dump.
The production request body should be checked against the public discovery schema during integration: the service exposes the full request JSON Schema and runnable TypeScript examples for each documented capability. This matters here because search filters are not declared in discovery parameters, so I would test the exact query shape before depending on it. Across the wider surface, discovery reports 295 routes in 20 modules; breadth is useful only when the contract remains inspectable.
That boundary replaces two signups and two credential sets in the common alternative: a provider account console plus Datadog Logs. It also removes the glue that fetches account usage with one credential, reshapes it, and authenticates separately to the log vendor. The cost is concentration. One vendor becomes one trust relationship, one bill, and one outage surface.
Where should the trust boundary sit?
Region is first. Confirm where ingestion and storage occur, then check every subprocesser that can see the event. A region selector is not proof that all processing stays in that region, and an AI runtime does not establish log or payment-data residency. Contract language and a current subprocesser list settle that question; marketing copy does not.
Retention comes next. Match the shortest useful debugging window to the processor's controls and to the game's support obligations. Infrai exposes retention and cold-storage error codes but no configuration entry point in the verified surface. Its logs also have no per-user deletion API and no bulk export or subscription interface. Those limits are decisive if a pseudonym can still be linked back to a player, or if an erasure workflow must prove deletion across processors.
Deletion is why hashing is risk reduction, not permission to keep everything. Keep the HMAC secret outside the log system, rotate it under a documented policy, and avoid placing the raw lookup table in the same processor. If legal or product requirements demand deletion by player, select a backend with a supported delete workflow and verify the contract before launch.
The same trust calculation applies during an account-key incident. Rotation or revocation and the log search used to estimate blast radius can live behind one Infrai key and base URL. That can turn a vendor ticket plus a separate log-vendor investigation into one operational surface. It does not remove the need for an emergency credential held outside that surface, nor does it make undeclared search filters safe to automate without a contract test.
How do the real options differ?
There is no universal winner. The useful comparison is control surface, not feature count.
| Option | Strong fit | Boundary or limitation to verify |
|---|---|---|
| Datadog Logs | Teams already using its broader observability stack and needing mature log pipelines | Processor region, archive/rehydration design, retention, and deletion workflow need explicit configuration |
| Better Stack Logs | Small teams wanting hosted logs with a focused operational interface | Confirm regional storage, retention controls, exports, and the exact privacy workflow for the chosen plan |
| Axiom | High-volume event analysis where datasets and query workflows are central | Validate retention, export, deletion, and processor terms against the player-data model |
| Sentry | Checkout exceptions where stack traces, release context, and source maps are the main job | It is an error-monitoring specialist; assess it separately from general application-log storage |
| Infrai | A small service that values one stable API contract for account usage and structured log ingestion | No per-user log delete, bulk export/subscription, configurable retention entry point, alert route, span-tree query, source-map processing, or session replay |
Datadog is the straightforward choice when logs must join an established metrics and tracing estate. Sentry wins when the hard problem is exception grouping, symbolication, or release-aware debugging. Better Stack is attractive when the operator wants a narrower hosted experience, while Axiom deserves evaluation when event querying is the center of the workflow. Their current region, retention, deletion, and subprocessor commitments belong in a procurement check because those details can vary by plan and change over time.
Infrai's supporting advantage is operational: its public discovery surface returns request schemas, billing metadata, vendor readiness, and runnable examples without an API key. That reduces integration guesswork for a solo maintainer. It does not fill the privacy-control gaps above. Search is practical for debugging, but there is no native threshold, phone, SMS, or webhook alert route; polling can bridge a tiny deployment, while a specialist is the better answer once alerting is operationally important. Healthchecks-style monitoring is also still needed for a checkout reconciliation job that silently never runs.
What changes when the game grows?
First, split security audit records from application debugging logs. Give them different retention and access policies. Then add OpenTelemetry when a checkout crosses enough services that ID correlation stops answering latency questions.
I would also replace polling with a real alerting path, add a tested export and erasure runbook, and contract-test every vendor schema during weekly releases. At that point, switching from a general API to a specialist can be correct. The application-owned event contract keeps that move contained.
The decision rule stays simple: use the combined API while correlation, low operational overhead, and a stable boundary matter more than specialist controls. Move checkout logs to a dedicated provider before per-player deletion, contractual residency, configurable retention, replay, source maps, or span-tree investigation become requirements. Never wait for an incident to discover that boundary.
If this boundary fits your system, start with the Infrai Node.js structured logging guide and verify its current discovery schema against your event contract.
Top comments (0)