Series 8 — Part 4 of 7

Structured logging with JSONL format and correlation IDs turns a log file from a wall of text into a queryable dataset. This article covers correlation ID generation and propagation, the production allowlist pattern, rolling log rotation, and the difference between audit logs and application logs.

Correlation ID Generation and Propagation

// Generate once at the entry point (webhook handler, CLI entry, web request)
$correlationId = bin2hex(random_bytes(8));  // 16 hex chars, cryptographically random

// Propagate through the request lifecycle
define('CORRELATION_ID', $correlationId);

// Pass explicitly into services (preferred over globals)
class WhatsAppWebhookHandler
{
    public function __construct(
        private readonly StructuredLogger $logger,
        private readonly string $correlationId,
    ) {}

    public function handle(array $payload): void
    {
        $this->logger->info('webhook.received', [
            'phone'     => $payload['from'],
            'msg_type'  => $payload['type'],
        ]);
        // correlationId is in every log line automatically
    }
}

JSONL Log Format

class StructuredLogger
{
    private const PROD_ALLOWED_KEYS = [
        'matter_id', 'user_id', 'team_id', 'action', 'duration_ms',
        'http_status', 'route', 'method', 'error_code', 'intent',
        'msg_type', 'session_id', 'relay_id', 'audio_bytes',
    ];

    public function __construct(
        private readonly string $logFile,
        private readonly string $correlationId,
        private readonly bool   $isProd,
    ) {}

    public function info(string $event, array $ctx = []): void { $this->write('INFO', $event, $ctx); }
    public function warn(string $event, array $ctx = []): void { $this->write('WARN', $event, $ctx); }
    public function error(string $event, array $ctx = []): void { $this->write('ERROR', $event, $ctx); }

    private function write(string $level, string $event, array $ctx): void
    {
        $entry = [
            'ts'             => date('c'),
            'correlation_id' => $this->correlationId,
            'level'          => $level,
            'event'          => $event,
            'ctx'            => $this->isProd ? $this->allowlist($ctx) : $ctx,
        ];
        file_put_contents($this->logFile, json_encode($entry, JSON_UNESCAPED_UNICODE) . "\n", FILE_APPEND | LOCK_EX);
    }

    private function allowlist(array $ctx): array
    {
        return array_intersect_key($ctx, array_flip(self::PROD_ALLOWED_KEYS));
    }
}

Querying JSONL Logs

# Find all log entries for a correlation ID
grep '"correlation_id":"abc123"' /var/log/app/app.log | jq .

# Find all errors in the last hour
cat /var/log/app/app.log | jq 'select(.level == "ERROR" and .ts > "2026-05-22T10:00:00")'

# Count events by type
cat /var/log/app/app.log | jq -r '.event' | sort | uniq -c | sort -rn

What to Watch For

  • LOCK_EX on every write — Essential when multiple Apache worker processes are writing to the same log file concurrently. Without it, log lines from different requests interleave and corrupt each other's JSON.
  • JSON_UNESCAPED_UNICODE — Without this flag, Hindi characters are \u-escaped in the log, making them unreadable without decoding. Use it.
  • Log file vs database audit trail — Application logs are for debugging. Billing disputes and privilege questions require the database audit_log table — it has IDs, entity types, and before/after values that log files don't.