Capture API errors at the request boundary, keep enough context to locate the failed attempt, and verify the fix with the same reproduction. For a gaming service, the useful question is concrete: can you explain why one match-join attempt failed after the next release has already shipped?
Short answer: pair an error record with four evidence fields: a server-generated request ID, a stable operation name, a release identifier, and a bounded error code. Use counts to spot changes and individual records to investigate them. The TypeScript example below shows that boundary for a Node.js API, including what happens if writing the evidence fails.
Keep the evidence. Drop the chatter.
A one-person SaaS has a revenue-per-hour constraint. A weekly release rhythm leaves little room for investigating ten near-identical stack traces with no deployment context. The goal here is a short path from a player's support report to a reproducible failure, followed by a regression check. More collected text does not automatically shorten that path.
How do you capture API errors with enough evidence to resolve them?
Start with a hypothetical player who reports that joining a match failed. This is an illustrative scenario, not a production incident or a benchmark. A count of failed requests tells you there is a problem, but it doesn't identify the attempt. A raw exception may describe the failure while saying nothing about which release produced it. Keeping both signals serves different questions. The metrics concepts in the References describe metrics as measurements captured at runtime; use those aggregates to establish scope, then follow a request identifier into the individual evidence.
The 4 fields have deliberately different jobs. requestId identifies one attempt and is returned to the caller. operation identifies the bounded action, such as match.join. release identifies the deployed code. errorCode gives a stable grouping key, such as API_UNEXPECTED, without turning arbitrary exception messages into categories. Add an event timestamp and HTTP status to make the record usable on a timeline. Those are event metadata, separate from the four correlation and grouping fields.
This changes the collection choice. Retain a small failure record for each unexpected error within an explicit retention window, subject to storage capacity. Avoid recording every request body just because it might help later. Match-join payloads and authentication material do not belong in the default error envelope. If investigation requires additional context, define the specific allowed fields and their retention before collecting them.
There is a cost to this restraint: the minimal record won't explain every failure. That is an honest boundary. It should tell you which code and operation to reproduce, while a separately controlled diagnostic path can retain a sanitized stack when needed.
Build the smallest request boundary
The example uses the standard HTTP server interface and an append-only local file as an inspectable teaching sink. All values are illustrative. Save it as a TypeScript module and run it with your project's TypeScript tooling and Node type declarations. It has one deliberate failure so you can exercise the complete capture path before connecting game logic.
import { randomUUID } from "node:crypto"; import { appendFile } from "node:fs/promises"; import { createServer } from "node:http"; type FailureEvent = { requestId: string; operation: "match.join"; release: string; errorCode: "API_UNEXPECTED"; observedAt: string; status: 500; }; const release = process.env.RELEASE_ID ?? "local-example"; async function retain(event: FailureEvent): Promise<void> { await appendFile("api-errors.ndjson", JSON.stringify(event) + "\n"); } async function joinMatch(): Promise<{ joined: true }> { // Replace this deliberate failure with the actual match operation. throw new Error("Synthetic match-join failure"); } const server = createServer(async (req, res) => { const requestId = randomUUID(); res.setHeader("x-request-id", requestId); res.setHeader("content-type", "application/json"); if (req.method !== "POST" || req.url !== "/match/join") { res.writeHead(404); res.end(JSON.stringify({ error: "NOT_FOUND", requestId })); return; } try { const result = await joinMatch(); res.writeHead(200); res.end(JSON.stringify({ ...result, requestId })); } catch { const event: FailureEvent = { requestId, operation: "match.join", release, errorCode: "API_UNEXPECTED", observedAt: new Date().toISOString(), status: 500, }; try { await retain(event); } catch { // Keep sink failures separate from the application error stream. console.error(JSON.stringify({ kind: "error_sink_failure", requestId, release, })); } res.writeHead(500); res.end(JSON.stringify({ error: event.errorCode, requestId })); } }); server.listen(3000); The boundary owns the response.
It awaits the operation and catches its rejected promise before sending headers; adapting this pattern to streaming responses needs a separate policy for failures after headers have been sent. Node.js documents different error propagation mechanisms, so this request-level catch should not be treated as coverage for detached tasks, event-emitter errors, or failures elsewhere in the process.
The example deliberately excludes exception messages and request bodies from the retained envelope. That keeps the demonstration's collection boundary small. It also means API_UNEXPECTED is a coarse bucket. Introduce additional codes only when they correspond to actionable failure categories, with an explicit mapping in application code. Copying an arbitrary upstream message into errorCode defeats that purpose.
Notice the second catch.
If the file write fails, the caller still receives an error response with its request ID, but the primary evidence has not been retained. The separate sink-failure record makes that loss visible through another output channel. It is not a durability guarantee. A local file also needs rotation, storage monitoring, and a plan for collection before the process or host disappears.
One more trade-off: awaiting the write puts storage latency on the error response path. This is acceptable for demonstrating the ordering. A production design needs a bounded write deadline and an explicit choice about what happens when evidence cannot be accepted.
Turn a captured attempt into a verified fix
Exercise the deliberate failure first. Send a POST to the example's match-join endpoint, take the request ID from the response, and locate the corresponding file record. Verify that the ID matches, the operation is match.join, and the release is the value you intended. In deployment, populate RELEASE_ID from the release artifact; the local fallback is only for the demonstration. Reusing that fallback across deployments would erase the distinction the field is supposed to preserve.
Then make the failure useful. Replace the deliberate throw with the game operation and reproduce a controlled failure in a test environment. Use the retained release and operation to identify the code path, inspect permitted diagnostics, and write a regression test around the cause. A player retry is a new attempt with a new request ID. If several attempts belong to one business action, model that relationship separately instead of reusing an attempt ID and losing the sequence.
Test the unhappy path too. Inject a rejecting evidence sink and check that the response still ends, while the sink failure becomes visible. Check that neither response includes internal exception details. For the success path, verify that no failure event is emitted. These checks exercise observable behavior; asserting only that a logging function was called leaves the player-facing result untested.
Resolution needs evidence.
After a fix ships, reproduce the original condition against the new release, verify the expected response, and compare failure counts over a defined observation window. Quiet logs alone are weak evidence if collection is broken or traffic has stopped. Pair the failure count with request volume and the health of the collection path before deciding that the issue is resolved. That keeps signal quality, rather than the size of the log archive, as the decision axis.
What I would change at scale
As a design choice, I would move file handling behind an evidence-sink interface once rotation, delivery, and search start consuming time needed to ship weekly. Outsource the undifferentiated collection work when doing so improves revenue per hour, while keeping the event contract and collection policy owned by the application. The selection criterion is whether an incident can be reconstructed within the required retention window.
Keep aggregate dimensions bounded: operation, release, and error code are candidates; a unique request ID belongs in individual records. Set limits on retained records, collection queues, and diagnostic payloads. If capacity forces sampling, document which evidence may be missing and preserve enough aggregate information to understand that gap.
The four-field contract is a starting point, not complete forensic coverage. A match-join investigation that crosses service boundaries will need propagated context and compatible retention across those services. Add that when the investigation requires it. For the first release, make one failed attempt traceable, make evidence loss visible, and make the fix repeatable.
References
The sources below support the distinction between aggregate measurements and request evidence, and the runtime interfaces used in the example.
Further reading
- OpenTelemetry metrics concepts: https://opentelemetry.io/docs/concepts/signals/metrics/
- Node.js error propagation and handling: https://nodejs.org/api/errors.html
- Node.js HTTP server and response interfaces: https://nodejs.org/api/http.html
- Node.js file system promises: https://nodejs.org/api/fs.html#promises-api
- Node.js random UUID generation: https://nodejs.org/api/crypto.html#cryptorandomuuidoptions
