Logs are for machines first
console.log("something broke??") is fine while learning. In production, logs are searched, by tools, across millions of lines. So services write structured logs: one JSON object per line with consistent fields.
{"level":"info","message":"request handled","method":"GET","path":"/users","status":200,"ms":12}Now "show every request slower than 500 ms" or "count 500s by path" is a query, not an archaeology dig.
Each line carries a level: debug (development detail), info (normal events), warn (odd but survivable), error (a thing failed). Production usually records info and up, which is why LOG_LEVEL sat in the config you just built.
A micro-logger
The spread ...fields merges extra fields into the log object, giving every line the same base shape.
function logLine(level, message, fields) { return JSON.stringify({ level: level, message: message, ...fields }); } console.log(logLine("info", "request handled", { method: "GET", path: "/users", status: 200, ms: 12 })); console.log(logLine("error", "db timeout", { path: "/orders" }));
Output
{"level":"info","message":"request handled","method":"GET","path":"/users","status":200,"ms":12}
{"level":"error","message":"db timeout","path":"/orders"}level and message come first because they are the two fields every line has, so a log viewer can rely on them. The spread lands after them, which is what puts the variable per-event fields at the end of each object.
The two lines carry different fields and still parse the same way, which is the property that makes structured logging useful. A search tool can filter on level across both, and only ask about ms where it exists.
Note that the spread can overwrite. Passing { level: "debug" } in fields would win over the level argument, since later keys replace earlier ones, and a real logger guards against that by spreading first and setting the fixed fields afterward.
Real projects use a library like pino, which adds timestamps and levels efficiently and writes to standard output without blocking the event loop. The shape is exactly this, with a time field and a numeric level code.
One field is worth adding as soon as a system has more than one moving part, which is a request id. Attaching the same id to every line produced while handling one request turns a pile of interleaved lines from concurrent requests back into readable stories.
One place to catch everything
Errors will happen: bugs, dead databases, weird input nobody imagined. The question is what leaves the building. Two rules:
- Clients get the stable error shape from lesson 4-3, never a stack trace, the multi-line dump listing every function call and file path on the way to the crash. Stack traces reveal file paths, library versions, and sometimes secrets.
- Errors are handled in ONE place, not per-route. In Express that is the error-handling middleware, recognized by its four parameters:
app.use((err, req, res, next) => { logger.error({ message: err.message, path: req.url }); const { status, body } = toResponse(err); res.status(status).json(body); });
toResponse translates errors for the outside world: known error codes map to their status and message, everything unexpected collapses to a generic 500. You write it next.
What the client sees when a bug throws
A generic 500 with the stable error shape, while the full details go to your logs.
Details belong in your logs, which is where you debug. Clients get { error: { code: "internal_error", ... } } with status 500 and no internals.
Stack traces leak implementation details to attackers, including file paths, library names, and version numbers that map to known vulnerabilities. Sometimes they leak worse, since a connection string or a token can end up inside an error message.
A 400 would be wrong for a different reason, since it falsely blames the client. The 4xx and 5xx split from lesson 3-3 carries real information, and a bug in your code is a 5xx, so mislabeling it sends the caller off retrying a request that was never malformed.
Log rich, respond poor. The same error produces a detailed line for you and a deliberately boring object for them, which is the asymmetry the error handler exists to create.
Translating errors for the outside world
toResponse(err) maps known error codes to their status and keeps their message, and collapses everything else to a generic 500.
const KNOWN = { not_found: 404, invalid_input: 400, unauthorized: 401 }; function toResponse(err) { const status = KNOWN[err.code]; if (status) { return { status: status, body: { error: { code: err.code, message: err.message } } }; } return { status: 500, body: { error: { code: "internal_error", message: "something went wrong" } } }; } console.log(JSON.stringify(toResponse({ code: "not_found", message: "no user 42" }))); console.log(JSON.stringify(toResponse({ code: "db_exploded", message: "secret connection string..." })));
Output
{"status":404,"body":{"error":{"code":"not_found","message":"no user 42"}}}
{"status":500,"body":{"error":{"code":"internal_error","message":"something went wrong"}}}KNOWN[err.code] is either a status number or undefined, which is falsy, so a single if separates the two worlds. Unknown codes and missing codes both take the fallback, which is the safe direction to fail in.
The success branch reuses err.code and err.message unchanged, because those errors were written to be seen. The fallback invents nothing beyond the fixed generic strings, so nothing from an unexpected error can reach the client.
The second test is the point of the whole function. Its scary internal message never appears in the output, and it did not need to be anticipated, since anything not in KNOWN is hidden by default.
KNOWN is also documentation of your public error vocabulary. Adding a code there is a deliberate decision to expose it, and clients can code against that list, which is the API-contract thinking from lesson 4-3 applied to failures.
Note that the error handler logs before calling this, so the real message is not lost. The pattern is one function that decides what the world sees and a separate line that records what actually happened.