What to Log and What Never To

Log decisions and failures with the ids needed to investigate, never credentials or personal data, and treat volume as cost.

Three judgements decide whether a log line is worth keeping.

What to log

Decisions and failures. A decision is a branch somebody will question later: a refund declined, a discount applied, a user downgraded. A failure is anything that did not complete. Both need the identifier that lets you find the record afterwards. Boundary crossings earn one line too: an outbound call that failed, a queued job that gave up.

What never to log

Passwords, plaintext or hashed. Tokens, API keys and session ids, which are credentials themselves. Card numbers. Whole request bodies and whole user objects, because they contain all of the above. Personal data beyond a stable id, because logs get copied into systems with their own access rules and retention, and people with no business reading it can read it there.

Volume is money

Log platforms bill by ingested volume. One debug line inside a loop over ten thousand rows is ten thousand lines, every run. Noise also costs attention: the more lines, the longer it takes to find the one that mattered.

What trips people up

Log a count, not a line per item. One record saying 2,314 rows were skipped is more useful than 2,314 records.

Example

Example · php
// src/Logger/RedactingProcessor.php
declare(strict_types=1);

namespace App\Logger;

use Monolog\Attribute\AsMonologProcessor;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;

#[AsMonologProcessor]
final class RedactingProcessor implements ProcessorInterface
{
    private const SENSITIVE = ['password', 'plain_password', 'token', 'api_key', 'authorization'];

    public function __invoke(LogRecord $record): LogRecord
    {
        $context = $record->context;

        foreach ($context as $key => $value) {
            if (in_array(strtolower((string) $key), self::SENSITIVE, true)) {
                $context[$key] = '[redacted]';
            }
        }

        // LogRecord::$context is readonly; with() returns a copy.
        return $record->with(context: $context);
    }
}

// Context ['email' => '[email protected]', 'token' => 'sk_live_9f', 'order' => 'A-1187']
// Output: ['email' => '[email protected]', 'token' => '[redacted]', 'order' => 'A-1187']

When to use it

  • Answer why a specific customer was refused, weeks later, from the log alone.
  • Cut a log bill that grew faster than traffic did.
  • Pass a security review that asks what personal data your logs retain.
  • Stop a crawler hitting missing pages from filling production logs.

More examples

One summary line instead of a line per row

Reach for this in any batch job. The aggregate answers the question people actually ask, and the bill stops scaling with the input size. Keep per-row detail at debug behind a verbosity flag if you need it while investigating.

Example · php
// Noisy: one record per row, every import, forever.
foreach ($rows as $row) {
    $this->logger->info('Row skipped', ['row' => $row]);
}

// Useful: count while you work, log the decision once.
$skipped = 0;

foreach ($rows as $row) {
    if (!$this->isValid($row)) {
        ++$skipped;
    }
}

$this->logger->info('Import finished', [
    'file' => $fileName,
    'rows' => count($rows),
    'skipped' => $skipped,
]);

Logging a failed login without the credentials

A stable internal id is enough to trace an incident and carries no personal data. The commented call is the one that turns a log store into a credential store, and a debug level does not make it safe.

Example · php
// The identifier that lets you investigate, and nothing more.
$this->logger->warning('Login rejected', [
    'user_id' => $userId,          // not the email, not the password
    'reason' => 'bad_credentials',
    'attempts' => $attempts,
]);

// Never written, in any environment:
// $this->logger->debug('Login attempt', ['password' => $plain]);

Discussion

  • Be the first to comment on this lesson.