How to check log level in production
Read the level the process resolved, not the one the configuration file asks for. Start the service, take the level out of its first log line, then count records by level in the output. One debug record in a production stream means the filter is wider than the config claims, and the volume goes with it.
Why check this
Run this after a change to an environment file or a deployment manifest, and once per environment before release sign-off. The failure it prevents is a service that runs at debug for a month while every configuration file in the repository says info.
Two things make that expensive. The same twenty requests produced 7092 bytes of log at the debug fallback against 2551 at info, so ingest and retention both move by a factor of 2.8. The debug records also carry fields info never emits, here the whole request header object, so the mistake widens what the log contains. What the stream may never hold is a separate check: Sensitive data in logs.
This procedure reads the level the running process settled on, not the configuration file. The file is an input to that resolution, and the two disagree exactly when there is a defect.
Prerequisites
- Node 22 or later, the build the numbers here came from.
- A free port.
netstat -ano | grep 8791prints nothing when 8791 is free. jqis optional. Every command below reads the log with Node.- The pino logger for step 5:
npm i pino@9.
Save this as logsvc.mjs. It writes one JSON record per line and takes its level from LOG_LEVEL. The startup record reports the level it resolved.
// logsvc.mjs Node 22, ESM. Start: LOG_LEVEL=info node logsvc.mjs
import { createServer } from 'node:http';
const LEVELS = { trace: 10, debug: 20, info: 30, warn: 40, error: 50 };
const wanted = LEVELS[process.env.LOG_LEVEL] ?? LEVELS.debug;
const name = Object.keys(LEVELS).find((k) => LEVELS[k] === wanted);
const log = (level, msg, fields = {}) => {
if (LEVELS[level] < wanted) return;
process.stdout.write(JSON.stringify({ time: new Date().toISOString(), level, msg, ...fields }) + '\n');
};
createServer((req, res) => {
const t0 = Date.now();
const id = req.headers['x-request-id'] ?? 'r' + Math.random().toString(16).slice(2, 8);
log('debug', 'request received', { req_id: id, url: req.url, headers: req.headers });
if (req.url === '/fail') {
try {
throw new Error('order 4471 has no payment method');
} catch (err) {
log('error', 'order failed', { req_id: id, err });
res.writeHead(500, { 'content-type': 'application/json' }).end('{"error":"internal"}');
log('info', 'request done', { req_id: id, status: 500, duration_ms: Date.now() - t0 });
return;
}
}
res.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}');
log('info', 'request done', { req_id: id, status: 200, duration_ms: String(Date.now() - t0) });
}).listen(8791, '127.0.0.1', () => log('info', 'service started', { port: 8791, resolved_level: name }));
Steps
- Step 1.
Start the service at the level you intend to run.
LOG_LEVEL=info node logsvc.mjs > info.log 2>&1 &Read the first record it wrote.
head -1 info.log{"time":"2026-09-12T07:31:57.417Z","level":"info","msg":"service started","port":8791,"resolved_level":"info"}resolved_levelis the answer. A service that prints no such field is the case step 4 covers. - Step 2.
Send twenty requests, then stop the process by the PID
netstatreports.for i in $(seq 1 20); do curl -s -o /dev/null -H 'Cookie: session=abc123; theme=dark' http://127.0.0.1:8791/orders; doneStart it again with the same value in upper case, the way a hand-written environment file often carries it.
LOG_LEVEL=INFO node logsvc.mjs > upper.log 2>&1 &head -1 upper.log{"time":"2026-09-12T07:32:02.628Z","level":"info","msg":"service started","port":8791,"resolved_level":"debug"}Nothing failed and nothing warned.
LEVELS['INFO']isundefined, the fallback ran, and the service is atdebugin an environment whose file saysINFO. - Step 3.
Send the same twenty requests to the second instance, then compare the two files.
wc -l -c info.log upper.log21 2551 info.log 41 7092 upper.log 62 9643 totalTwenty requests, one record each at
infoand two atdebug. That is 122 bytes per request against 349. - Step 4.
Count records by level in a file from a process you did not start. This is the check when the service prints no resolved level of its own.
node -e "const c={};for(const l of require('fs').readFileSync('upper.log','utf8').split('\n').filter(Boolean)){const o=JSON.parse(l);c[o.level]=(c[o.level]||0)+1}console.log(c)"{ info: 21, debug: 20 }The same command over
info.logprints{ info: 21 }. A singledebugrecord in a production file settles the question with no access to the process. - Step 5.
Ask the library directly, when the level never appears in the output.
node -e "const pino=require('pino');for(const v of ['info','INFO','Debug','warning']){try{const l=pino({level:v});console.log(v,'->',l.level,'debug enabled:',l.isLevelEnabled('debug'))}catch(e){console.log(v,'-> throws:',e.message)}}"info -> info debug enabled: false INFO -> info debug enabled: false Debug -> debug debug enabled: true warning -> throws: default level:warning must be included in custom levelsThree behaviours from one environment variable. pino folds case, so
INFOresolves toinfowhere the hand-rolled map in step 2 fell through todebug. An unrecognised name is not defaulted at all: the constructor throws and the process never starts. - Step 6.
Stop the service by the PID in the last column, then confirm the port is free.
netstat -ano | grep ":8791 .*LISTENING"TCP 127.0.0.1:8791 0.0.0.0:0 LISTENING 23996powershell -Command "Stop-Process -Id 23996 -Force"netstat -ano | grep ":8791 .*LISTENING" || echo "8791 has no listener"8791 has no listenerUse
Stop-Processrather thankill. In Git Bash a background job id is not a Windows PID, sokillon it reports success and leaves the server listening.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| resolved_level matches the value you deployed | The lookup hit | Nothing. Record the value in the release notes. |
| resolved_level is debug while the config says otherwise | The value was not recognised and a default applied | Fix the value in the environment, restart, then read this line again rather than the file. |
| Records below your level appear in the file | The running process is wider than the config | Treat the debug fields as exposed. Read what they carry before the file rotates away. |
| The process exits at startup naming custom levels | The library rejects the name instead of defaulting | Use the library's own spelling. warning and critical are syslog names, not pino names. |
| Only error records from a service that logs info | The resolved level is narrower than you expect | The same read, opposite direction. A silent fallback can remove records as well as add them. |
Common mistakes
Thresholds
The factor belongs to this service, whose debug record holds a header object. Measure your own before quoting a number to the people paying for ingest. A local run has no shipper, no compression and no sampling.
What to check next
- Json logging format: the same file, read for whether every record parses.
- Sensitive data in logs: the debug records are where credentials surface first.
- How to check logs for pii: the same stream, read for personal data.
- Access log format: the request log has its own filter and its own read.
FAQ
What are the logging levels, and in what order?
Five names carry almost everywhere, from wide to narrow: trace, debug, info, warn, error. pino adds fatal above error and numbers them 10 to 60. A level admits itself and everything narrower, so info keeps warn and error and drops debug.
What is the difference between info and debug?
info records what the service did: a request finished, a job ran. debug records how, inputs included. In step 3 the debug record carried the header object, cookies with it, which makes the level a privacy setting as well as a volume setting.
How do I check the level without restarting the service?
Count levels in a file the process already wrote, as in step 4. One record at a level you did not intend answers the question with no access to the host.
Should debug ever be on in production?
For a bounded window, on one instance, with a time to turn it off. The cost measured here is 2.8 times the bytes and a wider record. That is acceptable for an hour of diagnosis and not as a default.
Verified
Verified by Maks Vernynode 22.23.2pino 9.14.0curl 8.1.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
basic6 minpublished updated Maks Verny