Structured Logging
Most apps write logs as plain English sentences meant for a human to read. That works fine when you have one server and you can tail (watch the end of) a file. But the moment you have many servers and millions of log lines, reading them by eye stops working. Structured logging fixes this by writing each log line as JSON (JavaScript Object Notation, a standard text format of key: value pairs that machines parse easily) so a computer can search, filter, and group your logs instantly.
Plain-text vs structured logs
A “plain-text” log is just a string. It looks friendly but a machine has to guess where each piece of information starts and ends. A “structured” log is a set of named fields (labelled values like user_id or status), so the meaning of every piece is explicit.
Here is the same event written both ways.
Plain text (before):
2026-06-15 14:03:11 INFO User 4821 logged in from 203.0.113.7 in 142ms
Structured JSON (after):
{"timestamp":"2026-06-15T14:03:11Z","level":"info","msg":"user logged in","user_id":4821,"ip":"203.0.113.7","duration_ms":142}
Both describe one login. But with JSON you can later ask precise questions like “show every log where user_id is 4821” or “show requests where duration_ms is over 1000”, and a log tool answers instantly because each field has a name.
| Aspect | Plain text | Structured (JSON) |
|---|---|---|
| Human reading one line | Easiest | Slightly noisier |
| Searching by a field | Fragile regex (pattern matching) | Exact, fast |
| Filtering by a value | Hard | Trivial |
| Grouping / counting | Manual | Built in |
| Works with ELK / Loki | Needs parsing rules | Works out of the box |
When to use this: turn on JSON logs for any service that ships its logs to a central system (ELK, Loki, CloudWatch). When NOT to: a quick local script you read once by eye does not need it. Keep plain text for those.
Adding context fields
The biggest win of structured logging is context fields — extra labelled values attached to every log line. The most valuable is a request id (a unique code generated for each incoming HTTP request) so you can follow a single user’s request across many log lines and even across services. Common context fields:
request_id— ties together all logs for one request.user_id— which user triggered the event.serviceandenv— which app and environment (e.g.prod).duration_ms— how long the work took.
The rule is simple: log the value, not a sentence. Write "user_id": 4821, not "msg": "user 4821 did a thing". That keeps the field clean and searchable.
Gotcha: never put secrets in fields. Passwords, API keys, full credit-card numbers, and session tokens must never be logged, structured or not. Once a secret hits your central log store it is effectively leaked to everyone with log access.
Library support
You rarely build JSON logs by hand. Every modern language has a logging library that does it for you. On an Ubuntu server you install these with your normal package manager.
| Language | Library | Install |
|---|---|---|
| Node.js | pino | npm install pino |
| Python | structlog | pip install structlog |
| Go | log/slog | built into Go 1.21+ |
| Java | Logback + logstash-encoder | via Maven/Gradle |
Here is a tiny, real Node.js example using pino (a fast JSON logger). Save it as app.js:
mkdir ~/log-demo && cd ~/log-demo
npm init -y
npm install pino
const pino = require('pino');
const log = pino({ base: { service: 'auth', env: 'prod' } });
log.info({ user_id: 4821, ip: '203.0.113.7', duration_ms: 142 }, 'user logged in');
Run it:
node app.js
Output:
{"level":30,"time":1750000991000,"service":"auth","env":"prod","user_id":4821,"ip":"203.0.113.7","duration_ms":142,"msg":"user logged in"}
Notice every line is one JSON object. The service and env fields appear automatically on every log because we set them once as base.
Pretty-printing during development
JSON is hard to read while you develop. The fix is to keep JSON in production but pipe it through a formatter on your laptop. With pino you use pino-pretty:
npm install -g pino-pretty
node app.js | pino-pretty
Output:
[14:03:11.000] INFO (auth/prod): user logged in
user_id: 4821
ip: "203.0.113.7"
duration_ms: 142
The stored log is still JSON; only your terminal view is prettified. Never strip the JSON in production.
Tying it to centralized logging
Structured logs are what makes a centralized logging system (one place that collects logs from all your servers) genuinely useful. In Kibana (the search UI for the ELK stack) or Grafana (the UI for Loki) you can write a query like user_id: 4821 AND level: error and get an exact answer in milliseconds, because each field is already indexed.
A common Ubuntu setup writes app logs to systemd-journald (the logging service built into modern Ubuntu) and a shipper forwards them. To confirm your app under systemd is emitting JSON, check the journal:
sudo journalctl -u myapp.service -n 1 -o cat
Output:
{"level":30,"service":"auth","env":"prod","user_id":4821,"msg":"user logged in"}
Because that line is already JSON, a shipper like Filebeat or Promtail forwards it with no fragile parsing rules. That is the whole payoff: structure at the source means zero guesswork downstream.
Best Practices
- Emit logs as JSON in production, but pretty-print only on your local machine.
- Always include a
request_idso you can trace one request end to end. - Set common fields (
service,env,version) once as a base, not per line. - Use a single consistent timestamp format (ISO 8601, e.g.
2026-06-15T14:03:11Z) and UTC. - Log values as fields, not as sentences inside
msg. - Never log secrets, passwords, tokens, or full payment details.
- Keep field names stable over time so old and new logs query the same way.