Logging, errors and knowing when it breaks
The shop is live. Now the question is how you find out it is broken, and the honest default answer is: a customer rings the shopkeeper, who rings you.
That is a real monitoring system. It is just a slow and expensive one, paid for in lost orders and a shopkeeper who stops trusting the software.
Log for the person reading at 2am
That person is you, six months from now, with no memory of this code. Write for them.
this.logger.log(`Order ${order.orderNumber} placed for user ${userId}`);
this.logger.warn(`Webhook for unknown payment ${event.providerRef}`);
this.logger.error(
`Amount mismatch on ${event.providerRef}: expected ${payment.amountPaise}, got ${event.amountPaise}`,
);
Every one carries the identifier you would search for. Order placed on its own
tells you nothing at 2am; Order KS-20260927-7F3K2A placed lets you follow that
order through every log line it touched.
Log identifiers, not identities.
private maskPhone(phone: string): string {
return phone.length <= 4 ? "****" : `******${phone.slice(-4)}`;
}
Logs are read by people, tailed over shoulders, and shipped to whatever aggregator you use, with a retention policy nobody chose. A full phone number in a log line is personal data sitting somewhere it was never meant to be. The last four digits are enough to match a log entry to a support call, which is the only reason it is there.
The same goes for email addresses, addresses and order contents. From an
orderNumber, anybody with database access can find the rest — with an audit
trail.
The levels mean something
| Level | Means | Who should see it |
|---|---|---|
error |
a human must look | alerts somebody |
warn |
unusual, handled | reviewed weekly |
log |
a business event happened | searchable |
debug |
development only | off in production |
The discipline is at the top. If error fires for things that are fine,
nobody reads error.
if (status >= HttpStatus.INTERNAL_SERVER_ERROR) {
this.logger.error(exception);
}
The exception filter logs 500s and not 4xx. A 500 is a bug in this codebase. A 404 is somebody typing a wrong URL, and logging every one buries the real failures under thousands of lines of nothing.
The amount mismatch in the payment webhook is error and deserves to be: it is
rare, it is never routine, and somebody must look.
Never show an internal message to a customer
if (status >= HttpStatus.INTERNAL_SERVER_ERROR) {
return "Something went wrong at our end. Please try again.";
}
"Cannot read properties of undefined (reading 'variantId')" is useless to a customer and useful to an attacker: it names your fields, hints at your structure, and sometimes leaks a query.
The detail goes in the log, keyed by something the customer can quote. Which is worth doing properly:
const body: ApiErrorBody = {
statusCode: status,
code: this.codeFor(exception, status),
message: this.messageFor(exception, status),
};
Add a request id to that and support becomes tractable — the customer reads you eight characters and you find the exact request.
Error tracking, which is the one thing to actually buy
Logs tell you what happened if you go looking. Error tracking tells you something happened. That difference is the whole point.
Sentry is the usual choice, and its free tier covers a shop this size. What you get that logs do not give you:
- Grouping. Four thousand occurrences of one bug shown as one issue with a count, rather than four thousand lines.
- The stack trace with source maps, so the line number is your TypeScript and not a minified bundle.
- Context — the URL, the user, the release.
- An alert the first time a new error appears, rather than the fiftieth.
- A regression flag when something you marked resolved comes back.
That last one is the one you cannot build yourself, and it is what makes a deploy safe to do on a Friday.
Two rules when you add it:
Scrub personal data before it is sent. Sentry captures request bodies by default, and a checkout body has a name, a phone number and an address in it.
Set the release to your commit SHA. Then "this started with the deploy at 16:40" is a fact rather than a guess, and rolling back is a decision rather than a hope.
Uptime checks are five minutes of work
GET https://api.kirana.example/api/health/ready every 60s
GET https://kirana.example/products every 60s
Any of the free services — UptimeRobot, Better Stack, Pingdom — will do this and text you. It is the cheapest monitoring there is and it catches the failure that matters most: the whole thing is down and nobody has noticed.
Check both halves. The front end can be perfectly healthy while the API is unreachable, and the shop is just as unusable.
Check ready, not live — live answers 200 from a process that cannot reach
its database.
What to watch, for a shop this size
Four numbers. Not a dashboard with forty.
Error rate. 5xx as a share of requests. A jump is a deploy that went wrong.
Response time at the 95th percentile. Not the average — the average hides the slow requests, and the slow ones are the ones people feel. If p95 on the catalogue goes from 200ms to 2s, something is wrong even though nothing is failing.
Orders per hour. The one that catches what technical monitoring never does. Every service green and orders at zero since 14:00 means something is broken in a way no error was raised for — a payment gateway change, a validation rule that now rejects everybody, a broken button.
Database connections in use. Approaching the limit is the warning before the outage.
That fourth one has a counterpart worth adding explicitly to this application:
async expireStale(now = new Date()): Promise<{ cancelled: string[] }>
Module 14's sweep. If that job silently stops, the shop slowly runs out of everything, because every abandoned payment keeps holding its stock. A scheduled job that fails quietly is worse than one that fails loudly, so it should log every run — including the runs that cancelled nothing — and somebody should notice if those lines stop appearing.
The audit trail you already have
model OrderEvent {
status OrderStatus
note String?
byUserId String?
createdAt DateTime @default(now())
}
Module 13's events table is monitoring, even though it was built for the shop. When somebody asks "why was this order cancelled at 4pm?", it answers — and no log retention policy deletes it after thirty days.
That is a general point worth taking: the questions you will be asked are usually about a specific record, not about aggregate behaviour. Data that lives beside the record answers those better than logs ever will.
What happens when it does break
Have this decided before you need it, because at 2am you will not be deciding well:
- Is it down or slow? Down is a rollback. Slow is an investigation.
- What changed? Almost always a deploy. Check the time against your release markers.
- Roll back first, diagnose after. Every platform in the last lesson has a one-click rollback to the previous build. Use it. A shop that is up is worth more than knowing why it went down.
- Then find out why, with the traffic already restored and no pressure.
The instinct to fix forward under pressure is how a ten-minute outage becomes two hours.
Check your work
Who you are logging for: yourself in six months, with no memory of the code.
Why every line carries an identifier: so one order can be followed through every line it touched.
Why phone numbers are masked: logs are read by people and shipped elsewhere, and the last four digits are all support needs.
Why 4xx is not logged as an error: if error fires for things that are
fine, nobody reads error.
Why internal messages never reach a customer: they are useless to the customer and useful to an attacker.
What error tracking gives that logs do not: it tells you something happened, groups it, and alerts on the first occurrence rather than the fiftieth.
Why the release should be the commit SHA: "it started with the 16:40 deploy" becomes a fact.
Why uptime checks hit ready and not live: live answers 200 from a
process that cannot reach its database.
Why orders per hour is a technical metric: every service can be green while the shop takes no money.
Why the expiry sweep needs watching: if it stops silently, the shop runs out of stock it actually has.
Why rollback comes before diagnosis: a shop that is up is worth more than knowing why it went down.
Practice
- Place an order and follow it through the logs using only its order number.
- Find a log line with personal data in it. Decide whether it should be there.
- Make the exception filter log 4xx as errors, browse a few wrong URLs, and see what the log looks like.
- Trigger a 500 and confirm what the customer sees and what the log holds.
- Add a request id to the error body and the log line, and follow one through.
- Add Sentry to the API with a free account. Trigger an error and look at what it captured — then find the personal data in it.
- Set up an uptime check on
/api/health/ready. Stop the database and wait for the alert. - Point an uptime check at
/api/health/liveinstead. Stop the database again and note what it says. - Write the query that answers "orders per hour for the last 24 hours".
- Write your own runbook for "the shop is down", in five lines, and put it where somebody else could find it.
Next: going live, and the checklist to run first.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship