Series 5 — Part 6 of 7

Legal SaaS logging has two distinct purposes: operational debugging and legal audit trails. They require different data, different retention periods, and different access controls. This article implements JSONL structured logging with correlation IDs, a production allowlist, and clear separation between audit and application logs.

JSONL Structured Logging

class StructuredLogger
{
    private string $correlationId;

    public function __construct(private readonly string $logPath)
    {
        $this->correlationId = bin2hex(random_bytes(8));
    }

    public function log(string $level, string $event, array $context = []): void
    {
        $entry = [
            'ts'             => date('c'),
            'correlation_id' => $this->correlationId,
            'level'          => $level,
            'event'          => $event,
            'context'        => $this->filterForProduction($context),
        ];
        file_put_contents($this->logPath, json_encode($entry) . "\n", FILE_APPEND | LOCK_EX);
    }

    // Production allowlist: only these context keys are logged
    private const ALLOWED_KEYS = [
        'matter_id', 'user_id', 'team_id', 'action', 'duration_ms',
        'http_status', 'route', 'method', 'error_code',
    ];

    private function filterForProduction(array $context): array
    {
        return array_intersect_key($context, array_flip(self::ALLOWED_KEYS));
    }
}

Audit Logs vs Application Logs

Application logs answer: "what happened technically?" They are for developers debugging issues. Audit logs answer: "who did what to which record, and when?" They are for compliance, legal proceedings, and billing disputes.

CREATE TABLE audit_log (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  team_id     INT UNSIGNED NOT NULL,
  user_id     INT UNSIGNED NOT NULL,
  action      VARCHAR(100) NOT NULL,  -- e.g. 'billing_entry.approve'
  entity_type VARCHAR(60) NOT NULL,   -- e.g. 'billing_entry'
  entity_id   BIGINT UNSIGNED NOT NULL,
  old_value   JSON DEFAULT NULL,
  new_value   JSON DEFAULT NULL,
  ip_address  VARCHAR(45) DEFAULT NULL,
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_team_entity (team_id, entity_type, entity_id)
);

The audit log is append-only. No record is ever updated or deleted from it. If you need to mark an audit entry as erroneous, add a new entry referencing the original and marking it as corrected.

Log Rotation

// logrotate.d/the legal SaaS platform
/var/log/the legal SaaS platform/*.log {
    daily
    rotate 30
    compress
    delaycompress
    missingok
    notifempty
    create 640 www-data www-data
}

Application logs rotate daily, retain for 30 days. Audit logs do not rotate — they are appended to the database indefinitely, subject to the jurisdiction-specific retention schedule.

What to Watch For

  • PII in application logs — Client names, matter descriptions, and billing amounts are PII. The production allowlist prevents them from appearing in application logs. Review the allowlist at every new feature deployment.
  • Log file permissions — Application logs should be readable by www-data and the ops user only. Never make them world-readable.
  • Correlation ID propagation — The correlation ID must be passed through every layer of the request: webhook, controller, service, and any background tasks spawned. Otherwise, you cannot trace a failure across layers.