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

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

  1. 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_level is the answer. A service that prints no such field is the case step 4 covers.

  2. Step 2.

    Send twenty requests, then stop the process by the PID netstat reports.

    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; done
    

    Start 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'] is undefined, the fallback ran, and the service is at debug in an environment whose file says INFO.

  3. Step 3.

    Send the same twenty requests to the second instance, then compare the two files.

    wc -l -c info.log upper.log
    
      21 2551 info.log
    41 7092 upper.log
    62 9643 total

    Twenty requests, one record each at info and two at debug. That is 122 bytes per request against 349.

  4. 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.log prints { info: 21 }. A single debug record in a production file settles the question with no access to the process.

  5. 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 levels

    Three behaviours from one environment variable. pino folds case, so INFO resolves to info where the hand-rolled map in step 2 fell through to debug. An unrecognised name is not defaulted at all: the constructor throws and the process never starts.

  6. 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       23996
    powershell -Command "Stop-Process -Id 23996 -Force"
    
    netstat -ano | grep ":8791 .*LISTENING" || echo "8791 has no listener"
    
    8791 has no listener

    Use Stop-Process rather than kill. In Git Bash a background job id is not a Windows PID, so kill on 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

Sign: Every configuration file says info and the log is full of debug records.Cause: The level is looked up in a map keyed on the raw environment value. INFO, Info and a trailing space all miss, and the fallback runs. The file was never wrong; the lookup was.
Sign: Two services in one deployment read the same LOG_LEVEL and log at different levels.Cause: Case handling belongs to the library, not to the platform. pino resolves INFO to info. A hand-rolled map keyed on the raw string does not, and falls through to its own default.
Sign: A service stops starting after a level change, with no log line to say why.Cause: pino throws at construction on a name outside its set, with a message about custom levels. The syslog names warning, notice and critical are outside that set, so a value copied from an rsyslog config kills the process instead of widening it.

Thresholds

2.8 times the log bytes at debug against info, for the same twenty requests Source: measured in step 3 on 2026-09-12, node 22.23.2, one local service on 127.0.0.1

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

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.

basic6 minpublished updated Maks Verny