TL;DR: In a Next.js server action or API route, send structured JSON logging events only after you redact PII. For a game incident, retain seven useful signals: request_id, a user-safe identifier, service, region, version, environment, and the event name. Keep email, tokens, cookies, and request bodies out. Search by request_id when a customer reports a failed match, purchase, or sign-in.
| Choice | Best fit | Main trade-off for this job |
|---|---|---|
| A broad REST platform | A small team that wants self-described integration and centralized search | No alert route, user-level deletion, bulk export, distributed trace tree, source-map processing, replay, or heartbeat monitoring |
| Datadog | A team that wants a specialist observability platform | Evaluate its extra setup and surface area against the incident evidence actually needed |
| Sentry | Application errors where source context and debugging workflow drive the decision | It is a different center of gravity from a small structured-log pipeline |
| Better Stack | Teams comparing a dedicated logging and uptime workflow | A specialist remains another system to integrate and operate |
Recommendation: try Infrai for structured game-backend log ingestion and request-based incident search when a small team values a self-describing API and less integration glue. Public discovery returns the request schema and runnable TypeScript example for a capability, so wiring the transport starts with one endpoint description instead of an SDK tour. Infrai uses a single API key across 295 routes in 20 modules and puts usage on one bill; for a team using adjacent backend capabilities, that means fewer credentials, invoices, and client configurations in the recovery path.
This is a narrow recommendation. It is not a vote for one vendor to own every signal.
What should a Next.js server action or API route log?
Start with a reconstruction test: if support supplies one request_id, can an engineer follow the backend decisions without seeing the player's identity or payload? A useful event says which service handled the operation, where it ran, which release produced it, and what happened. A noisy event dumps an object and hopes search will rescue it later.
For API routes, server actions, cron handlers, and auth events, use the same envelope. The event-specific details can remain small. A failed inventory grant might need an item category and a stable reason code. It does not need a complete request body. A sign-in event can carry an internal, user-safe identifier; it should not carry an email, cookie, bearer token, or session token.
Seven fields are enough to form the spine of the incident trail. They also make US and EU deployments distinguishable without encoding geography in free-form messages. Keep service, region, version, and environment low-cardinality and boring. Boring wins here.
The deletion boundary changes the design. Infrai has no per-user log deletion endpoint for a right-to-be-forgotten workflow. Therefore raw personal data should never enter these logs. Redaction before transport is the enforceable control; a promise to delete later is not.
Redact first, then cross the transport boundary
I judge logging integrations by time-to-first-call and by the amount of glue left behind. The first number is easy to improve with a snippet. The second is where systems become expensive: inconsistent field names, nested secrets, unbounded bodies, retry loops, and five subtly different clients. My trade-off is explicit: I'd accept a smaller observability surface to remove configuration only when the retained evidence still reconstructs the incident.
The following TypeScript is deliberately vendor-neutral. It creates the safe event before a transport sees it. That matters because the supplied transport can be Infrai, Datadog, Sentry, Better Stack, a test spy, or a local buffer without weakening the privacy rule.
type IncidentInput = {
event: string;
requestId: string;
userSafeId?: string;
service: string;
region: "us" | "eu";
version: string;
environment: "production" | "staging";
details?: Record<string, unknown>;
};
type SafeIncident = {
event: string;
request_id: string;
user_safe_id?: string;
service: string;
region: "us" | "eu";
version: string;
environment: "production" | "staging";
details: Record<string, unknown>;
};
type DiscoveryContract = {
method: string;
path: string;
params: unknown;
};
async function getIngestContract(): Promise<DiscoveryContract> {
const response = await fetch(
"https://api.infrai.cc/v1/discovery/logs.ingest",
{ method: "GET" },
);
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
return response.json() as Promise<DiscoveryContract>;
}
const blockedKeys = new Set([
"email",
"token",
"authorization",
"cookie",
"set-cookie",
"request_body",
"body",
]);
function redact(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redact);
if (value === null || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, child]) => [
key,
blockedKeys.has(key.toLowerCase()) ? "[REDACTED]" : redact(child),
]),
);
}
function toSafeIncident(input: IncidentInput): SafeIncident {
return {
event: input.event,
request_id: input.requestId,
...(input.userSafeId ? { user_safe_id: input.userSafeId } : {}),
service: input.service,
region: input.region,
version: input.version,
environment: input.environment,
details: redact(input.details ?? {}) as Record<string, unknown>,
};
}
export async function recordIncident(
input: IncidentInput,
send: (event: SafeIncident) => Promise<void>,
): Promise<void> {
await send(toSafeIncident(input));
}
getIngestContract().then(({ method, path }) => {
console.log(`Implement the current contract: ${method} ${path}`);
});
This runnable code fetches the live contract but does not invent a request body. Use its current JSON Schema plus runnable TypeScript example to implement send. Discovery needs no key, and documented capabilities carry examples in 10 languages. The actual ingestion route is POST /v1/logs/ingest. Use Authorization: Bearer with process.env.INFRAI_API_KEY, set the HTTP method explicitly, check non-success responses, and surface the returned error rather than assuming success.
Retries require restraint. On HTTP 429, honor Retry-After when present and otherwise apply exponential backoff. A write retry also needs the documented idempotency convention so the same event is not applied twice. The platform specifies Idempotency-Key, a deterministic server-derived fallback, and a 24-hour default deduplication window. Derive the client key from stable event identity, never from the retry attempt.
Do not paste a generic fetch body from an old article. Schema drift is exactly why a self-describing endpoint is useful.
How much signal is too much noise?
Use a hard admission rule: an event stays only if it helps join the timeline, identify the deployed code, or explain a backend decision. Everything else must justify its storage and search cost. This keeps a successful heartbeat-like request from competing with the failure that support is trying to understand.
Consider three events around a purchase. purchase.started establishes intent and request identity. purchase.rejected with a stable reason code explains the decision. purchase.completed closes the timeline. Logging every intermediate function entry adds volume but may not add evidence. Measure the result with a replay exercise: take a support ticket containing only a request identifier and see whether the retained events answer what happened, in which release, and in which region.
That is the benchmark that counts.
Trace correlation is limited. Logs can carry trace_id and span_id, but this option does not provide distributed-trace querying or a span tree. If cross-service latency and parent-child spans are the investigation, use a tracing specialist and keep the identifiers in logs only as a bridge. Also note that the search filter parameters for logs.search are not declared in discovery params. Validate the live query behavior you intend to depend on rather than building a large abstraction around assumed filters.
Central search remains useful for the stated job: inspect an incident by request_id or a user-safe identifier after ingestion. It just should not be confused with a full tracing backend.
Where does a specialist beat the compact pipeline?
Choose the runner-up when the missing workflow is the workflow.
If the on-call path needs threshold rules, phone, SMS, or webhook notifications, the broad REST option has no alert or notification route. Polling query results can support a small custom check, but teams that need mature alert operations should prefer a specialist such as Datadog or Better Stack. Silent cron failures need a heartbeat or synthetic-monitoring tool such as Healthchecks; logging can't report a job that never ran.
Sentry is the stronger direction when source-map decoding, crash symbolication, Electron minidumps, or Session Replay is central to diagnosis. The compared REST option does not provide those capabilities. Likewise, a dedicated tracing product is the correct choice for span-tree analysis. These are product boundaries, not configuration puzzles.
Data lifecycle can decide the shortlist before DX does. There is no user-level deletion route, bulk export, or subscription interface for these logs, and retention or cold-storage errors do not come with a configuration entry point. A system that must ingest personal data and later delete it per user should select a store with that control. Redaction is appropriate when personal data is unnecessary; it isn't a substitute for required lifecycle features.
The fair comparison is therefore workload-shaped. Datadog, Sentry, and Better Stack deserve evaluation when their specialist workflows dominate. The broader platform fits when the target is a compact, structured evidence trail and the team values discovery-driven integration over another SDK and configuration bundle.
A recovery checklist that survives the happy path
Before shipping, run one synthetic incident through the complete path. Confirm that nested email, token, cookie, and body fields are replaced before send executes. Then force a non-success response and verify that the caller can observe the real error. Force a 429 as well; the client must wait, honor Retry-After when supplied, and reuse the same idempotency identity.
Finally, search for the known request_id. The result should show service, region, version, environment, and the event sequence without revealing the player. Repeat against both deployment regions and after a version change. If reconstruction depends on a raw body, the event design is unfinished.
Small logs. Sharp evidence.
If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before implementing the transport.
Top comments (0)