Audit logging is the practice of recording a tamper-evident trail of every significant action taken by users, systems, and automated processes within an application. Unlike operational logs that help engineers debug issues, audit logs serve a fundamentally different audience: security teams investigating incidents, compliance officers demonstrating regulatory adherence, and customers reviewing what happened in their account. An audit log entry answers the question “who did what, to which resource, when, and from where?” — and it must be complete, immutable, and retained for a defined period.
In SaaS platforms like the platform (a database management console), audit logging is both a product feature and a compliance requirement. Enterprise customers expect to see a detailed activity log of every team member’s actions — who created a database cluster, who changed access permissions, who deleted a backup. Regulatory frameworks like SOC 2, GDPR, and HIPAA mandate that certain categories of actions are logged, retained for specific durations, and available for review. The platform implements audit logging as an asynchronous pipeline: actions emit audit events to a buffered channel, a background worker persists them to a dedicated audit log table, and a retention policy automatically purges records beyond the configured retention window.
This topic covers audit log schema design, asynchronous logging pipelines, retention policies, compliance reporting, and the architectural patterns that make audit logging reliable without impacting application performance.
Every audit event must capture: the actor (who performed the action — user ID, service account, or system process), the action (what was done — “create”, “update”, “delete”, “login”, “export”), the resource (what was affected — resource type and ID), the timestamp (when it happened — server-side UTC, not client-supplied), the outcome (success or failure with error details), and the context (IP address, user agent, request ID, session ID). Together, these fields create a record that is independently meaningful and queryable without needing to correlate with other log sources.
Audit logging must never block or slow down the operation being audited. If the audit log database is slow or unavailable, the user’s request should still succeed — the audit record can be written asynchronously. The platform uses a buffered Go channel as an in-process queue: the request handler emits an audit event to the channel (non-blocking), and a dedicated goroutine reads from the channel and batch-inserts records into the audit table. This decouples the write path from request latency while keeping the pipeline simple and reliable.
Audit logs must be append-only. Once written, an audit record must never be modified or deleted by application code — only the retention policy should remove records, and only after the configured retention window expires. In the platform, the audit log table uses database-level constraints: the application’s database role has INSERT and SELECT privileges but not UPDATE or DELETE. This ensures that even if the application is compromised, the attacker cannot modify audit records to cover their tracks.
Compliance frameworks specify minimum retention periods — SOC 2 typically requires 1 year, HIPAA requires 6 years, and GDPR’s data minimization principle requires deleting data when it’s no longer needed. The platform implements configurable per-tenant retention: enterprise customers can set their retention window (default 90 days, up to 7 years), and a daily cleanup job deletes records older than the tenant’s configured window. The retention job runs as a separate process with DELETE privileges that the main application lacks.
Audit logs must be exportable for compliance audits. Auditors need to review all access to sensitive resources over a time period, verify that privilege escalations were authorized, and confirm that data deletions followed the proper approval workflow. The platform provides an audit log API that supports filtering by actor, action, resource type, time range, and outcome — enabling both real-time dashboards and periodic compliance reports.
The platform’s audit logging system consists of three components: the event emitter (called from request handlers and background jobs), the async writer (a goroutine that batch-inserts events), and the retention manager (a cron job that enforces retention policies). The event emitter is designed to never fail — it drops events rather than blocking the caller if the buffer is full, and it logs dropped events to the operational log for alerting.
// internal/audit/event.go
package audit
import "time"
type Event struct {
ID string `json:"id" db:"id"`
TenantID string `json:"tenant_id" db:"tenant_id"`
ActorID string `json:"actor_id" db:"actor_id"`
ActorType string `json:"actor_type" db:"actor_type"` // "user", "service", "system"
Action string `json:"action" db:"action"` // "create", "update", "delete", "login"
Resource string `json:"resource" db:"resource"` // "cluster", "backup", "user", "apikey"
ResourceID string `json:"resource_id" db:"resource_id"`
Outcome string `json:"outcome" db:"outcome"` // "success", "failure", "denied"
Detail string `json:"detail,omitempty" db:"detail"` // JSON blob with action-specific details
IPAddress string `json:"ip_address" db:"ip_address"`
UserAgent string `json:"user_agent" db:"user_agent"`
RequestID string `json:"request_id" db:"request_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// internal/audit/logger.go
package audit
import (
"context"
"time"
"go.uber.org/zap"
)
type Logger struct {
events chan Event
store Store
logger *zap.Logger
}
func NewLogger(store Store, logger *zap.Logger, bufferSize int) *Logger {
return &Logger{
events: make(chan Event, bufferSize),
store: store,
logger: logger.Named("audit"),
}
}
// Emit sends an audit event to the async pipeline.
// Never blocks — drops the event if the buffer is full.
func (l *Logger) Emit(event Event) {
event.ID = generateID()
event.CreatedAt = time.Now().UTC()
select {
case l.events <- event:
// Event queued successfully.
default:
// Buffer full — drop the event and alert via operational log.
l.logger.Error("audit event dropped: buffer full",
zap.String("action", event.Action),
zap.String("resource", event.Resource),
zap.String("actor_id", event.ActorID),
)
}
}
// Run starts the background writer. Call in a goroutine.
func (l *Logger) Run(ctx context.Context) {
batch := make([]Event, 0, 100)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case event := <-l.events:
batch = append(batch, event)
if len(batch) >= 100 {
l.flush(ctx, batch)
batch = batch[:0]
}
case <-ticker.C:
if len(batch) > 0 {
l.flush(ctx, batch)
batch = batch[:0]
}
case <-ctx.Done():
// Drain remaining events on shutdown.
for len(l.events) > 0 {
batch = append(batch, <-l.events)
}
if len(batch) > 0 {
l.flush(context.Background(), batch)
}
return
}
}
}
func (l *Logger) flush(ctx context.Context, batch []Event) {
if err := l.store.BatchInsert(ctx, batch); err != nil {
l.logger.Error("failed to flush audit events",
zap.Int("batch_size", len(batch)),
zap.Error(err),
)
}
}
// internal/api/clusters.go
func (h *ClusterHandler) DeleteCluster(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
actor := auth.ActorFromContext(ctx)
clusterID := chi.URLParam(r, "clusterID")
err := h.clusterService.Delete(ctx, clusterID)
outcome := "success"
detail := ""
if err != nil {
outcome = "failure"
detail = err.Error()
}
h.auditLogger.Emit(audit.Event{
TenantID: actor.TenantID,
ActorID: actor.UserID,
ActorType: "user",
Action: "delete",
Resource: "cluster",
ResourceID: clusterID,
Outcome: outcome,
Detail: detail,
IPAddress: extractIP(r),
UserAgent: r.UserAgent(),
RequestID: middleware.RequestIDFromContext(ctx),
})
if err != nil {
respondError(w, err)
return
}
respondNoContent(w)
}
// internal/audit/retention.go
func (r *RetentionManager) Cleanup(ctx context.Context) error {
tenants, err := r.tenantStore.ListWithRetentionPolicy(ctx)
if err != nil {
return fmt.Errorf("list tenants: %w", err)
}
for _, tenant := range tenants {
cutoff := time.Now().UTC().Add(-tenant.AuditRetention)
deleted, err := r.auditStore.DeleteBefore(ctx, tenant.ID, cutoff)
if err != nil {
r.logger.Error("retention cleanup failed",
zap.String("tenant_id", tenant.ID),
zap.Error(err),
)
continue
}
r.logger.Info("retention cleanup completed",
zap.String("tenant_id", tenant.ID),
zap.Int64("deleted_count", deleted),
zap.Time("cutoff", cutoff),
)
}
return nil
}
The platform’s audit logging powers three customer-facing features. First, the Activity Log page in the dashboard shows a filterable, paginated timeline of all actions within a tenant’s organization. Security-conscious customers use this to review who accessed what data, when API keys were rotated, and whether any unusual actions occurred during a suspected security incident.
Second, the audit log API supports SIEM (Security Information and Event Management) integration. Enterprise customers forward audit events to Splunk, Datadog, or their in-house SIEM, where they correlate the platform’s audit events with events from other systems to detect cross-system attack patterns — for example, a VPN login from an unusual geography followed by a database cluster deletion in the platform.
Third, the platform generates quarterly compliance reports by aggregating audit events: total login attempts (successful and failed), privilege changes, data export actions, and resource deletions. These reports are formatted to satisfy SOC 2 Type II audit requirements, saving customers hours of manual evidence collection during their compliance audits.
Audit logs and operational logs serve different purposes and have different requirements. Operational logs are for debugging (high volume, short retention, engineer audience), while audit logs are for compliance (lower volume, long retention, security/legal audience). Store them separately — audit events in a dedicated table with restricted access, operational logs in the standard logging pipeline. Never route audit events only to the operational log, because operational logs are typically rotated and purged on short timescales.
The audit table should be optimized for time-range queries filtered by tenant. Use a composite index on (tenant_id, created_at) for the primary query pattern. Partition the table by month for efficient retention — dropping a partition is an O(1) operation compared to deleting millions of rows. Store action-specific details in a JSON column rather than adding columns for every possible detail, keeping the schema stable across feature additions.
CREATE TABLE audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
actor_id UUID NOT NULL,
actor_type VARCHAR(20) NOT NULL,
action VARCHAR(50) NOT NULL,
resource VARCHAR(50) NOT NULL,
resource_id VARCHAR(255) NOT NULL,
outcome VARCHAR(20) NOT NULL,
detail JSONB,
ip_address INET,
user_agent TEXT,
request_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_audit_tenant_time ON audit_events (tenant_id, created_at DESC);
CREATE INDEX idx_audit_actor ON audit_events (actor_id, created_at DESC);
CREATE INDEX idx_audit_resource ON audit_events (resource, resource_id, created_at DESC);
The audit pipeline must degrade gracefully when the audit store is unavailable. The buffered channel absorbs short database outages. If the buffer fills (extended outage), events are dropped and an operational alert fires. After recovery, there will be a gap in the audit trail — the system should log the gap boundaries (first and last dropped event timestamps) so compliance teams know which period to investigate through alternative means (e.g., application logs, database query logs).
Writing audit events synchronously in the request path means every user action pays the latency cost of an audit table insert. Worse, if the audit database is slow or down, user requests fail — the audit system becomes a single point of failure for the entire application. Always write audit events asynchronously with a buffered queue. The small risk of losing a few events during a crash is vastly preferable to making every user action depend on the audit system’s availability.
For update actions, logging only that “user X updated resource Y” is insufficient for investigation. Auditors need to know what changed — the previous value and the new value. Store a diff or both states in the detail field: {"field":"retention_days","old":30,"new":90}. Without before/after data, investigators cannot determine whether a change was benign or malicious.
Never use client-supplied timestamps for audit records. A compromised or misconfigured client can backdate actions to make them appear to have happened at a different time, defeating the purpose of the audit trail. Always use server-side UTC timestamps generated at the point of event emission. Store the server clock’s timestamp, not the request’s Date header.
Many audit implementations only log successful actions. Failed actions — denied permission checks, failed login attempts, validation errors on destructive operations — are often more important for security investigation. A series of failed delete attempts followed by a successful one (after a permission change) is a classic attack pattern. Always log both successful and failed outcomes.
If some audit events record the actor as a user ID and others use an email address, correlating actions by the same user requires joining with the user table — which may have changed since the event was recorded (email updates, account merges). Always use the stable, immutable user ID as the actor identifier. Store the display name or email as supplementary context in the detail field, not as the primary actor key.