Your logs are useless at 2am.
The 4-line change that makes every production bug findable.
console.log got you to launch. It will not get you through your first 2am outage. Here is how to make production logs you can actually search, in an afternoon.
I'm a senior backend tech lead and I run Belmo, a small European PaaS. I'll mention it once near the end with a clear disclosure. Everything before that works on any platform you deploy to.
It's 2:14am. A customer emailed that checkout is broken. You open your hosting dashboard, click into the logs, and you are staring at 4,000 lines that look like this:
processing
processing
done
Error: undefined is not a function
processing
done
Which request failed? Which user? What were they buying? You have no idea. The logs technically recorded the crash, and they are completely useless for finding out why. This is the gap nobody warns you about between "my app runs" and "I can operate my app."
I have watched founders ship genuinely good products and then spend three hours bisecting a bug that a single well-formed log line would have answered in thirty seconds. The fix is small. You can do it tonight. Let me show you what changes.
Why console.log("here") stops working the moment you have users
When you build locally, you have one request at a time, in order, on your screen. console.log("got here") and console.log(user) are enough because your brain holds the context. You know which request you just triggered.
Production destroys that assumption in three ways.
Requests interleave. Ten users hit your server in the same second, and their log lines shuffle together like two decks of cards. A line that says processing tells you nothing because you cannot tell whose request it belongs to.
You cannot search free text reliably. console.log("user " + id + " checkout failed") produces a sentence. To find all checkout failures you grep for "checkout failed", and then you miss the three places where you wrote "checkout error" or "payment failed" instead. Humans are inconsistent. Search needs structure.
You lose the context that mattered. The crash line says TypeError: cannot read property 'total' of undefined. Useful context (which user, which cart, which payment provider, which request) lived in variables that went out of scope the instant the function returned. The log captured the symptom and threw away the diagnosis.
Structured logging fixes all three by changing one thing: instead of logging sentences, you log objects.
The actual change: log JSON, not prose
Here is a checkout handler with the logging most apps ship with:
app.post("/checkout", async (req, res) => {
console.log("checkout started");
try {
const order = await createOrder(req.body);
console.log("order created");
res.json(order);
} catch (err) {
console.log("checkout failed", err);
res.status(500).send("error");
}
});
And here is the same handler logging structured objects. The code barely changes:
app.post("/checkout", async (req, res) => {
const log = req.log; // we'll wire this up in a second
log.info({ event: "checkout_started", userId: req.user.id });
try {
const order = await createOrder(req.body);
log.info({ event: "order_created", userId: req.user.id, orderId: order.id, amount: order.total });
res.json(order);
} catch (err) {
log.error({ event: "checkout_failed", userId: req.user.id, err: err.message, stack: err.stack });
res.status(500).send("error");
}
});
Now a single line in production looks like this:
{"level":"error","time":1718800000,"event":"checkout_failed","userId":"u_8812","err":"cannot read property 'total' of undefined","stack":"...","reqId":"a1b2c3"}
That line answers the 2am question by itself. Who? u_8812. What broke? checkout_failed. Why? The error message and stack. And because every field is a named key, you can search event:checkout_failed and get every checkout failure across your whole history, no matter how you phrased the surrounding code.
In Node, you do not write the JSON formatting yourself. Pino does it and is fast enough that you will not notice it:
const pino = require("pino");
const logger = pino();
logger.info({ event: "server_started", port: 3000 });
In Python, the standard library plus one formatter gets you there:
import logging, json_log_formatter
formatter = json_log_formatter.JSONFormatter()
handler = logging.StreamHandler()
handler.setFormatter(formatter)
log = logging.getLogger("app")
log.addHandler(handler)
log.setLevel(logging.INFO)
log.info("checkout_failed", extra={"user_id": "u_8812", "amount": 49.0})
The shift in your head is the whole game: a log line is not a message to a human, it is a row in a database you are appending to one event at a time. Write it like a row.
The one field that changes everything: a request ID
Structured fields are good. A request ID is what turns "good logs" into "I can replay any user's exact journey." The idea: generate one random ID at the start of every incoming request, attach it to every log line produced while handling that request, and return it in an error response.
In Express, that is a five-line middleware:
const { randomUUID } = require("crypto");
app.use((req, res, next) => {
const reqId = req.headers["x-request-id"] || randomUUID();
req.log = logger.child({ reqId });
res.setHeader("x-request-id", reqId);
next();
});
logger.child({ reqId }) creates a logger that stamps reqId onto every line automatically, so you never have to remember to pass it. Now when checkout fails, you grep one value:
reqId:a1b2c3
and you get the complete ordered story of that one request: started, validated cart, called Stripe, Stripe timed out, retried, failed. Every line, in order, with nothing from the other nine users mixed in. The first time you do this during a real incident, the difference is the kind you remember.
Return the ID to the user too. When someone emails "checkout is broken," your error page can say "reference: a1b2c3," they paste it, and you go straight to the exact request. I built this into a client project at Ringover years ago and support resolution time on backend errors dropped enough that it became a standard part of every handoff I do now.
Pick log levels like you mean them
Most apps log everything at one level, which means the level field is dead weight. Use it. A simple, defensible policy:
error is for things that need a human eventually. A request failed, a payment did not go through, a background job gave up. If you would want to know it happened, it is an error.
warn is for recovered problems. A retry succeeded on the second attempt, a cache missed, a deprecated endpoint got hit. Nothing is broken, but a pattern of these is a signal.
info is for business events you would want to count. Orders created, users signed up, emails sent. This is the layer you build dashboards from later.
debug is for the firehose you turn on only when hunting something specific, and keep off in production by default.
The reason to be disciplined: once levels mean something, you can set production to info and up, keep your logs cheap and readable, and flip to debug for ten minutes when you actually need it. A log volume you can afford to read is a log volume you will actually read.
Three things people log that they should not
Structured logging makes it dangerously easy to log entire objects, so two quick guardrails.
Never log secrets or full request bodies. log.info(req.body) will eventually write a password, a card number, or an API key into your logs in plaintext, and your logs are not encrypted the way your database is. Log the fields you need by name. If you must log an object that might contain secrets, redact first. Pino supports this directly:
const logger = pino({
redact: ["req.headers.authorization", "*.password", "*.token"],
});
Do not log inside tight loops without thinking. A log.debug inside a loop that runs 50,000 times per request will quietly become your biggest line-item and your slowest endpoint. Log the summary, not every iteration.
And do not log personal data you would not want in a support tool. Logs get read by more people than your database does. An email address tied to an order ID is usually fine. A full home address in a log line is a GDPR conversation you do not want to have.
What I built, and why
After the tenth time I set up the same pino config, the same request-ID middleware, and the same "alert me when these structured logs start showing a pattern" rules for a client, I built Belmo so I would stop doing it by hand. It is a small European PaaS (data centers in Germany and Oregon, GDPR compliant), and the part that grew out of exactly this article is the log monitoring: because your logs are structured, it can watch for patterns like a checkout_failed rate that suddenly climbs, a retry loop hammering one endpoint, or a background job that silently stopped emitting its info line, and ping you on Telegram before a customer emails you at 2am.
Honest limitation: that pattern detection is only as good as your logging. If you log unstructured prose, no platform (mine included) can do much with it. The structure has to come from your code first. The platform reads what you write. So even if you never touch Belmo, the work in this article is the work that matters, and it pays off on Render, Fly, a bare VPS, or anywhere else.
What to do tonight, regardless of platform
You do not need to refactor your whole app. Do these in order and stop whenever you run out of evening.
- Add a JSON logger (pino for Node, json_log_formatter or structlog for Python) and replace
console.logwithlogger.infoin your three or four most important handlers. Not all of them. The ones that touch money or auth. - Add the request-ID middleware. This is the highest-leverage five lines in this whole piece. Generate an ID per request, attach it with a child logger, set it as a response header.
- Convert your error logs to objects with at minimum
event,userId,err.message, anderr.stack. These are the lines you will read during an actual incident, so spend your effort here. - Set a redact list for
authorization,password,token, and anything that looks like a secret, before you accidentally log one. - Set production log level to
infoand confirmdebugis off. Read ten lines of your real production output and ask: if this were the only thing I had at 2am, could I find the user and the cause? If not, add the missing field now. - Return the request ID in your error responses and put it on your error page. Free support superpower.
- If you have any background jobs or crons, make each one log a structured
infoline on success. A job that normally logs and suddenly goes quiet is the easiest failure in the world to detect, but only if it was logging in the first place.
One honest open question
The thing I still go back and forth on: how much to log at info in a small app. Log too little and your "business events" layer is too thin to spot trends. Log too much and the signal drowns and your bill climbs. For a side project doing a few thousand requests a day, I lean toward logging every state change that touches money or a user account, and almost nothing else. But I have seen people run lean info logging and regret it the first time they wanted to answer "how many checkouts did we even attempt last Tuesday" and could not.
Where do you draw that line in your own app? I would like to hear what you log at info and what you have decided is not worth the row. That is usually where the interesting disagreements are.
Related reads on this blog: Telegram alerts for any production app: a 5-minute setup (no SaaS, no signup, just curl). · Stop sending every alert to Slack. The 4-channel routing matrix I built after 18 months of being paged at the wrong time. · I deployed 12 vibe-coded apps to production. The same 6 things broke every single time.
Logs that page you first.
Belmo's AI log monitoring reads your structured logs, spots patterns like a climbing checkout_failed rate or a retry loop, and explains them on Telegram in plain English.
Quick answers
What is structured logging?
Writing each log line as an object with named fields instead of a sentence. A line like {"event":"checkout_failed","userId":"u_8812","err":"..."} can be searched by field, so event:checkout_failed finds every checkout failure no matter how the surrounding code was worded.
What is a request ID and why does it matter?
A random ID generated at the start of each request and attached to every log line produced while handling it. Searching for one ID gives you the complete, ordered story of that request, with nothing from other users mixed in. Return it in error responses so a customer can paste it to support.
Which log levels should I use?
error for things a human needs to look at, warn for problems that recovered, info for business events you would want to count, and debug for detail you only turn on while hunting a bug. Run production at info and above.
What should I never put in logs?
Secrets, full request bodies and personal data you would not want in a support tool. Logging req.body will eventually write a password, card number or API key in plaintext. Log fields by name and configure a redact list, which Pino supports directly.
Does structured logging slow my app down?
Not noticeably with a fast logger like Pino. The expensive mistake is logging inside tight loops, where one debug line can run tens of thousands of times per request. Log the summary, not every iteration.