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-dataand 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.