console.log('user logged in', userId) is fine when there is one process and one terminal. The moment there are two services, three instances of each, and a log aggregator collecting all of it into one place, that same line becomes a string sitting in a pile of other strings, and the only way to find it again is grep and hope.
Structured logging is not a bigger word for logging more. It is logging in a shape a machine can filter, group, and query, instead of a shape only a human eyeballing a terminal can read.
What "unstructured" actually costs you
A typical unstructured log line looks like this:
2026-03-14 09:12:01 INFO user logged in userId=8842 from 203.0.113.4
Readable, sure. But ask it a real question: how many logins came from this IP in the last hour, across all three instances of the auth service. There is no field called ip to filter on, there is a string that happens to contain an IP address somewhere after the word from. Every query against logs like this becomes a regex, and every regex is one log format change away from silently missing everything.
Multiply that by every service you run, each with its own slightly different log phrasing, and log aggregation stops being useful right around the time you actually need it, during an incident, at 2 a.m., under pressure.
What structured logging looks like instead
Same event, structured:
{
"timestamp": "2026-03-14T09:12:01.402Z",
"level": "info",
"service": "auth",
"instance": "auth-7f9c2",
"message": "user logged in",
"userId": 8842,
"ip": "203.0.113.4",
"requestId": "9c1d2e3f"
}
Now "how many logins from this IP in the last hour" is a real query: filter service:auth AND ip:203.0.113.4 AND message:"user logged in", no regex, no guessing at format. Every field is addressable because every field is actually a field, not a substring.
The fields that matter most
Not every field needs to be there every time, but a few show up in almost every useful log line:
timestamp, in a consistent format, ideally ISO 8601 with milliseconds. Without it, ordering events across services during an incident is guesswork.
level, so error and warn can be filtered separately from routine info noise, and alerting can watch one field instead of parsing text for the word "error".
service and instance, so a log aggregator pulling from twenty running processes can tell you which one actually produced a given line.
requestId (or traceId), generated once at the edge of a request and passed through every service that touches it. This is the single field that turns a pile of unrelated log lines into a story you can follow end to end.
message, a short human-readable description, kept separate from the structured fields around it rather than having the fields baked into the sentence.
Doing it in code
A raw console.log gives you none of this by default. A small logging library fixes that with almost no extra code:
// logger.js
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: { service: 'auth', instance: process.env.HOSTNAME },
timestamp: pino.stdTimeFunctions.isoTime,
});
module.exports = logger;
// wherever a request is handled
const logger = require('./logger');
app.use((req, res, next) => {
req.log = logger.child({ requestId: req.headers['x-request-id'] || crypto.randomUUID() });
next();
});
app.post('/login', (req, res) => {
// ...
req.log.info({ userId: user.id, ip: req.ip }, 'user logged in');
res.sendStatus(200);
});
logger.child() is the important part. It creates a logger that automatically stamps every line with the request's requestId, so nothing downstream in that request has to remember to pass it along by hand. Every log call inside that request already carries the field that ties it back to everything else that happened during it.
The requestId is what makes multi-service logs useful
A single request in a real system usually touches more than one service: an API gateway, an auth service, maybe a database proxy, maybe a queue. Without a shared requestId generated once and forwarded through every hop (usually as a header like x-request-id), each service's logs are an isolated island. With it, a single filter across your aggregator, requestId:9c1d2e3f, reconstructs the entire path a request took, across every service, in order, in one view.
This is the difference structured logging actually buys you. It is not about making individual log lines prettier, it is about making an incident across five services searchable as one story instead of five separate greps that you have to correlate by hand, by timestamp, while something is on fire.
What to avoid
Do not log secrets. Passwords, tokens, full credit card numbers, anything with compliance implications, should never land in a structured field either, it is just as searchable and just as leaked if the log store is ever exposed.
Do not over-nest. A field three levels deep in JSON is harder to query in most log tools than a flat field with a longer name. user.address.city works, but a flatter userCity is often easier to filter on depending on your aggregator.
Do not log everything at info. If every line is info, level stops being a useful filter. Reserve warn for things that need attention but did not break anything, and error for things that did.
Takeaway
console.log is fine for one process on your own machine. The moment logs need to be searched, filtered, or correlated across more than one running instance, they need to be data, not sentences. A structured logger with a consistent service, level, timestamp, and a requestId that travels with the request costs a few lines of setup and turns "grep and hope" into an actual query, right when you need that query the most.
Top comments (0)