Logging Practices, Correlation IDs, and Avoiding Sensitive Data in Logs
Apply solid logging practices in PHP applications: structure log entries with stack traces and correlation IDs for traceability, and avoid ever writing sensitive data into logs while keeping diagnostics useful in production.
Good logging is what turns 'the app broke for some user, at some point, for some reason' into a five-minute investigation instead of a guessing game. This lesson covers practical PHP logging practices: using a real logging library rather than raw error_log() calls, including stack traces and structured context, threading a correlation ID through a request so every log line from it can be tied together, and — just as important — what must never end up in a log line in the first place.
Structured logging over raw error_log()
PHP's built-in error_log() function writes a single string to the configured error_log destination and is fine for quick, low-volume diagnostics, but production applications almost universally use a dedicated logging library — Monolog is the de facto standard in the PHP ecosystem — because it provides log levels (debug, info, notice, warning, error, critical, alert, emergency, following the PSR-3 LoggerInterface standard), multiple simultaneous handlers (write ERROR+ to a file, also send CRITICAL+ to Slack/PagerDuty), and structured context arrays attached to each entry rather than everything jammed into one string.
PSR-3 (the PSR-3: Logger Interface standard) is what lets application code depend only on the abstract LoggerInterface and its eight severity methods ($logger->error($message, $context), etc.) without caring which concrete logging library is wired in underneath — Monolog is the most common implementation, but any PSR-3 logger is interchangeable, which is exactly the point of coding against the interface.
Stack traces and structured context
When logging a caught Throwable, log the full picture: the message, the exception class, the file and line, and getTraceAsString() (or, with Monolog, simply passing the exception object itself in the context array under an 'exception' key, which Monolog's formatters know how to expand into a full trace). A bare message like 'Payment failed' without the exception, its class, and a trace is nearly useless six months later when you're trying to reconstruct what actually happened.
Structured context — passing an associative array of relevant data alongside the message, rather than string-concatenating everything into the message itself — is what makes logs searchable and aggregable in a log platform (Elasticsearch/Kibana, Datadog, CloudWatch Logs Insights, etc.). $logger->error('Payment failed', ['order_id' => $orderId, 'gateway' => 'stripe', 'exception' => $e]) can be filtered and grouped by order_id or gateway across millions of log lines; the equivalent baked into a single string cannot.
Correlation IDs
A correlation ID (also called a request ID or trace ID) is a unique identifier generated once per incoming request — typically at the very top of the request lifecycle, in middleware or a bootstrap file — and then attached to every single log line produced while handling that request, usually by pushing it into the logger's shared context (Monolog's pushProcessor() or a Logger::withName()-style child logger) rather than passing it manually to every single log call.
Its value becomes clear the moment a single user request touches multiple log lines, or multiple services: without a correlation ID, dozens of concurrent requests interleave in the log file and there is no reliable way to tell which 'Database query took 4.2s' line belongs to which 'Payment failed' line. With one, every log line for that request can be filtered to a single, ordered timeline with one search — and if the correlation ID is also forwarded in an outgoing header (e.g. X-Correlation-ID or the W3C 'traceparent' header) to any downstream microservice the request calls, the same ID ties together logs across service boundaries too, which is the foundation of distributed tracing.
Production-safe diagnostics and avoiding sensitive data
Logs are frequently under weaker access control than the primary database, retained longer than you'd expect, shipped to third-party log aggregation platforms, and sometimes visible to a wider set of engineers than production database access is. Because of this, logs must never contain: passwords (even hashed, and especially not plaintext, which happens more often than teams expect when an entire request payload is logged on error), API keys/tokens/secrets, full credit card numbers or CVVs, session/auth tokens, or unredacted personally identifiable information (PII) like government ID numbers — logging a masked or hashed identifier (e.g. the last 4 digits of a card, or a user's numeric ID instead of their email) is almost always sufficient for debugging and carries far less risk.
A common, dangerous pattern is logging an entire request payload or exception context blindly ($logger->error('Request failed', $_POST)) — if that payload happens to include a password field or an API token, it is now permanently in the log store. The safer approach is an explicit allowlist of fields to log, or a redaction/scrubbing processor (Monolog supports custom processors for exactly this) that strips or masks known-sensitive keys before anything is written, so a new field added to a form later can't silently start leaking into logs by default.
• Use a PSR-3 logger (typically Monolog) with proper levels and structured context arrays, not string-concatenated error_log() calls. • Log the full exception (class, message, trace, previous chain), never just a bare message. • Generate one correlation ID per request and attach it to every log line for that request, forwarding it to downstream services too. • Never log passwords, tokens, full card numbers, or raw PII -- mask, hash, or omit instead. • Prefer an explicit allowlist of fields to log over blindly dumping an entire request/context array.
Monolog with a correlation ID processor
pushProcessor() runs for every subsequent log call on this logger instance, so the correlation ID never has to be passed manually to each individual $logger->error()/info()/warning() call. Forward the same correlation ID on any outgoing HTTP call to another internal service (as a request header) so that service's logs can be correlated with this one.
<?php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler(__DIR__ . '/../var/log/app.log', Level::Info));
// Attach a correlation ID to every log record produced by this logger instance
$correlationId = $_SERVER['HTTP_X_CORRELATION_ID'] ?? bin2hex(random_bytes(8));
$logger->pushProcessor(function (array $record) use ($correlationId): array {
$record['extra']['correlation_id'] = $correlationId;
return $record;
});
$logger->info('Request started', ['path' => $_SERVER['REQUEST_URI'] ?? '']);Code Examples
Passing the exception object itself in the context array (under 'exception') lets Monolog's formatters automatically expand it into a full stack trace in the log output, rather than manually calling getTraceAsString() every time -- the standard, idiomatic way to log a caught Throwable with Monolog.
Rather than logging the raw $payload, this function explicitly picks safe fields and derives a masked card_last4 instead of the full card number -- enough to correlate a support ticket ('card ending in 4242') without ever writing the full PAN or CVV to disk, which would be both a serious security issue and, for card data specifically, a PCI-DSS compliance violation.
Common mistake: $logger->info('Payment attempt', $payload); would silently log the entire raw payload, including card_number and cvv if present, into a log file -- one of the most common real-world causes of sensitive-data-in-logs incidents.
A dedicated redaction processor acts as a safety net: it inspects every log record's context (recursively, for nested arrays) and blanks out any key matching a known-sensitive name before the handler writes it anywhere. This defends against the realistic case where a developer accidentally logs a raw array containing a password field, without requiring every single call site to remember to sanitize manually. Expected result: app.log: Login attempt {"email":"[email protected]","password":"*REDACTED*"} $data[$key] = '*REDACTED*';
<?php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('php://stderr'));
try {
$result = 10 / 0;
} catch (DivisionByZeroError $e) {
$logger->error('Arithmetic failure', [
'exception' => $e,
'class' => get_class($e),
]);
}<?php
function handlePaymentRequest(array $payload, Logger $logger, string $correlationId): void
{
// Allowlist exactly what's safe to log -- never dump $payload directly,
// since it may contain a card number or CVV.
$logger->info('Payment attempt', [
'correlation_id' => $correlationId,
'order_id' => $payload['order_id'] ?? null,
'amount' => $payload['amount'] ?? null,
'card_last4' => isset($payload['card_number'])
? substr($payload['card_number'], -4)
: null,
// deliberately NOT logging: card_number, cvv, cardholder_name
]);
}<?php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Processor\ProcessorInterface;
final class RedactSensitiveFieldsProcessor implements ProcessorInterface
{
private const SENSITIVE_KEYS = ['password', 'token', 'cvv', 'card_number', 'authorization'];
public function __invoke(array $record): array
{
$record['context'] = $this->redact($record['context']);
return $record;
}
private function redact(array $data): array
{
foreach ($data as $key => $value) {
if (in_array(strtolower((string) $key), self::SENSITIVE_KEYS, true)) {
$data[$key] = '***REDACTED***';
} elseif (is_array($value)) {
$data[$key] = $this->redact($value);
}
}
return $data;
}
}
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('/var/log/php/app.log'));
$logger->pushProcessor(new RedactSensitiveFieldsProcessor());
// Even if a developer later logs an entire request array by mistake,
// known-sensitive keys are stripped before anything hits disk.
$logger->warning('Login attempt', ['email' => '[email protected]', 'password' => 'hunter2']);Important Rules
Use a PSR-3 logger (Monolog in practice) with proper severity levels and structured context, not raw error_log() string concatenation, for anything beyond a quick local check.
Log the full Throwable -- class, message, file/line, and trace (or the previous chain) -- never just a bare message string.
Generate one correlation ID per incoming request, attach it to every log line via a processor, and forward it to downstream services on outgoing calls.
Never log passwords, tokens/secrets, full card numbers/CVVs, or raw unredacted PII -- log masked or hashed identifiers instead.
Prefer an explicit allowlist of fields to log, or an automatic redaction processor as a safety net, over logging an entire request/payload array by default.
Where this shows up
Distributed tracing platforms (Datadog APM, Honeycomb, OpenTelemetry-based setups) build directly on the correlation ID pattern, just formalized and automatically propagated across service boundaries via a trace-context header, so an engineer can search for one ID and see a single request's entire path through a dozen microservices, each one's log lines and timing, in one unified view. Compliance regimes like PCI-DSS (payment cards) and GDPR/HIPAA (personal/health data) have explicit rules against logging certain categories of raw sensitive data, making a redaction processor not just good practice but often a hard compliance requirement.
error_log('Login failed for user: ' . json_encode($_POST));
$logger->warning('Login failed', [ 'correlation_id' => $correlationId, 'email_hash' => hash('sha256', $_POST['email'] ?? ''), // password intentionally omitted ]);
Dumping $_POST (or any raw request payload) directly into a log line captures whatever fields the form happened to submit -- including the plaintext password field on a login form -- permanently, into a log file that is often retained far longer and accessed more broadly than the primary user database.
[multiple-choice] What problem does a correlation ID primarily solve? A. It encrypts log entries so only authorized readers can decode them B. It ties every log line produced while handling one request (and, when forwarded, across services) into a single traceable sequence C. It automatically redacts sensitive fields from log context D. It reduces the total size of log files by deduplicating entries
B. It ties every log line produced while handling one request (and, when forwarded, across services) into a single traceable sequence — A correlation ID, generated once per request and attached to every log line for it (and forwarded to downstream services), is what lets you filter a log platform to a single request's complete, ordered timeline -- without it, concurrent requests' log lines interleave with no reliable way to separate them.
[true-false] It's acceptable to log a hashed or masked version of sensitive data (like the last 4 digits of a card number) instead of the raw value, when debugging needs some identifying information. A. True B. False
A. True — True. Masking (e.g. showing only the last 4 digits of a card) or hashing (e.g. a SHA-256 hash of an email for correlation) preserves enough identifying signal for debugging and support while avoiding storing the actual sensitive value in a log store.
Write a function logApiError(Logger $logger, Throwable $e, string $correlationId, array $requestData): void that logs an error-level message including the exception, the correlation ID, and only the 'endpoint' and 'method' keys from $requestData -- explicitly never logging an 'authorization' key even if it is present in $requestData.
function logApiError(Logger $logger, Throwable $e, string $correlationId, array $requestData): void { $logger->error('API request failed', [ 'correlation_id' => $correlationId, 'endpoint' => $requestData['endpoint'] ?? null, 'method' => $requestData['method'] ?? null, 'exception' => $e, ]); }
Build a small context array with only the allowlisted keys plus the exception and correlation_id; do not pass $requestData through directly.