?

Day 0549 minPreface+60 XP

Audit Log System.

It's Friday. You've made it through one of the most intense weeks of this program.

4 stages41 code

watch it first · newThe whole lesson in a 20-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

It's Friday. You've made it through one of the most intense weeks of this program.

Let's recap what you've learned:

WEEK 3 JOURNEY: Messaging and Async Processing
  1. Day 1: Queue vs Stream
    • When to use RabbitMQ vs Kafka
    • Consumer groups, ordering, retention
    • "Choose based on consumption pattern, not popularity"
  2. Day 2: Transactional Outbox
    • The dual-write problem
    • Atomic DB + message publishing
    • "Never write to two systems without coordination"
  3. Day 3: Backpressure and Flow Control
    • Detection: lag, depth, latency
    • Response: shed, limit, scale, spill
    • "Systems must be able to say 'slow down'"
  4. Day 4: Dead Letters and Poison Pills
    • Error classification and routing
    • Quarantine before crash loops
    • "Failed messages are data, not garbage"
  5. Today, Day 5, we bring it ALL together.

Your final challenge: design a complete audit log system for a financial services company. This system must:

  • Capture every user action (compliance requirement)
  • Never lose an event (legal liability)
  • Handle 50,000 events per second at peak
  • Be queryable for investigations
  • Retain data for 7 years

This isn't a toy example. It's the kind of system that, if it fails, results in regulatory fines, lawsuits, and people losing their jobs.

Let's build it right.

ExplainerThe whole lesson: an audit log system

A financial audit log records who did what to which resource, when and from where: every action, 50,000 events a second at peak, kept 7 years. Each action and its audit event commit together through an outbox, flow through Kafka partitioned by actor_id, are never shed under load, go to a DLQ if they fail, and land in TimescaleDB (90 days), ClickHouse (1 year) and S3 Parquet (7 years). Events are append-only and hash-chained, with the latest hash checkpointed to WORM storage; clock skew is handled by sorting on ingestion time.

Part I · Stage 1 of 4 · 9 min

Part I: Foundations#

Chapter 1: What Is an Audit Log?#

1.1 The Simple Definition#

An audit log (or audit trail) is a chronological record of activities that have affected a system. It answers: who did what to which resource, when, and from where.

EVERYDAY ANALOGY: The Security Camera

Think of a building's security system:

  • Every entrance has a camera recording 24/7.
  • Every badge swipe is logged.
  • Every door access is timestamped.

If something goes wrong: "Show me who entered Room 401 last Tuesday at 3pm" → Camera footage + badge logs = answer

An audit log is the security camera for your software. Every action is recorded. Nothing is deleted. Everything is queryable.

1.2 Why Audit Logs Matter#

REGULATORY REQUIREMENTS
Financial Services (SOX, PCI-DSS)
  • All access to financial data must be logged
  • Retention: PCI-DSS requires at least 1 year of audit logs (3 months immediately available); auditors must keep SOX audit records and workpapers for 7 years (SEC Rule 2-06 of Regulation S-X), so 7 years is the usual retention target
  • Logs must be tamper-evident
Healthcare (HIPAA)
  • All access to patient records must be logged
  • Must track who viewed what, when
  • Breach notification requires audit trail
General (GDPR, CCPA)
  • Must track data access and modifications
  • Right to access: "Show me everything you know about me"
  • Right to deletion: "Prove you deleted my data"
THE COST OF FAILURE

No audit log:

  • "We don't know who accessed the data"
  • "We can't prove we were compliant"
  • "We have no evidence for the investigation"

Result:

  • Regulatory fines (millions of dollars)
  • Legal liability (lawsuits)
  • Reputation damage (customer trust)
  • Criminal charges (in extreme cases)

1.3 Audit Log vs Application Log#

These are different things:

Aspect Application Log Audit Log
Purpose Debugging, monitoring Compliance, investigation
Audience Engineers Auditors, legal, security
Retention Days to weeks Years
Mutability Can be deleted Must be immutable
Content Technical details Business actions
Schema Loose, varies Strict, standardized
Application log vs audit log

APPLICATION LOG

text3 lines
2024-01-15 10:30:45 INFO  Processing request abc123
2024-01-15 10:30:46 DEBUG SQL: SELECT * FROM users WHERE id=456
2024-01-15 10:30:46 INFO  Request completed in 142ms

AUDIT LOG

json23 lines
{
  "event_id": "evt_789xyz",
  "timestamp": "2024-01-15T10:30:45.123Z",
  "actor": {
    "user_id": "usr_456",
    "email": "alice@company.com",
    "ip_address": "192.168.1.100",
    "user_agent": "Mozilla/5.0..."
  },
  "action": "user.profile.update",
  "resource": {
    "type": "user_profile",
    "id": "usr_789"
  },
  "changes": {
    "email": {"old": "bob@old.com", "new": "bob@new.com"}
  },
  "result": "success",
  "metadata": {
    "request_id": "req_abc123",
    "session_id": "sess_def456"
  }
}

1.4 Key Terminology#

Term Definition
Actor The user or system that performed the action
Action What was done (create, read, update, delete)
Resource What was acted upon (user, account, document)
Immutability Cannot be modified after creation
Tamper-evidence Any modification is detectable
Retention How long logs are kept
Chain of custody Tracking who accessed the logs themselves

Chapter 2: Audit Log Architecture#

2.1 High-Level Design#

Here's where everything from this week comes together:

DiagramComplete audit log architecture
%% title: Complete audit log architecture
flowchart TD
  UA@{ icon: "sd:user", label: "User Action" } --> API
  subgraph APP["Application Layer"]
    API["API Server"]:::service --> AC["Audit Client"]:::core
    AC --> OB@{ icon: "sd:database", label: "Outbox<br/>(Day 2)" }
  end
  APP -->|"Transactional write<br/>(same DB transaction as business logic)"| OP["Outbox Publisher<br/>Polls outbox table → Publishes to Kafka<br/>(or CDC with Debezium) (Day 1)"]:::service
  OP --> K@{ icon: "tech:kafka", label: "Kafka: audit-events<br/>Partitioned by actor_id for ordering<br/>Retention: 7 days (before archive)<br/>Replication: 3 (durability)" }
  K --> ACN["Audit Consumer<br/>Writes to DB (hot storage)<br/>Backpressure handling (Day 3)<br/>DLQ if fails (Day 4)"]:::core
  K --> AW@{ icon: "sd:worker", label: "Archive Writer<br/>Writes to S3 (cold storage)<br/>(Day 3)" }
  ACN --> SL
  subgraph SL["Storage Layer"]
    direction TB
    HOT@{ icon: "tech:timescaledb", label: "Hot: PostgreSQL/TimescaleDB<br/>(90 days)" }
    WARM@{ icon: "tech:clickhouse", label: "Warm: ClickHouse<br/>(1 year)" }
    COLD@{ icon: "tech:s3", label: "Cold: S3 + Parquet<br/>(7 years)" }
  end
  SL --> QL
  subgraph QL["Query Layer"]
    direction TB
    Q1@{ icon: "sd:api", label: "API for investigators" }
    Q2@{ icon: "sd:dashboard", label: "Dashboard for compliance" }
    Q3@{ icon: "sd:file", label: "Export for legal" }
  end
  class UA,Q1,Q2,Q3 client
  class OB,K queue
  class AW service
  class HOT,WARM,COLD store
Your turn · checkpoint 1not answered

Your design: which of this week's tools fixes which risk?

Four things can go wrong in the audit pipeline. (1) A delete commits but its audit event never gets published. (2) The DB writer, the archiver and analytics all need every event, and a bug fix needs a replay. (3) Black Friday pushes 50,000 events a second. (4) One malformed event keeps failing in the consumer. Which day's pattern answers each?

Think it through, or jot it down. Then compare.

One strong answer

  • (1) Day 2, transactional outbox: the audit event commits in the same transaction as the delete, or neither does.
  • (2) Day 1, a stream (Kafka): several consumer groups read the same events, and retention allows replay.
  • (3) Day 3, backpressure: detect lag, batch harder and scale consumers. Shed only non-critical work, never audit events.
  • (4) Day 4, a DLQ: quarantine the event so the rest keep flowing, then fix and replay it.

2.2 Why This Architecture?#

Let's trace how each day's learning applies:

How each day's learning applies
DAY 1 — QUEUE VS STREAM: Why Kafka?

We need:

  • Ordering per actor (see actions in sequence)
  • Multiple consumers (DB writer, archiver, analytics)
  • Replay capability (reprocess if bug found)
  • High throughput (50K events/sec)

Kafka wins over RabbitMQ because: Log-based — events persist for replay · Consumer groups — multiple independent consumers · Partitioning — scale writes horizontally · Ordering — per-partition ordering by actor_id

DAY 2 — TRANSACTIONAL OUTBOX: Guaranteed Capture

The nightmare scenario: User deletes sensitive data → Delete succeeds in database → Audit event fails to publish → No record of the deletion!

Outbox pattern guarantees: Business action + audit event in same transaction · Either both happen or neither happens · Publisher sends to Kafka asynchronously

DAY 3 — BACKPRESSURE: Handling Peak Load

Black Friday scenario: Normal: 5,000 events/sec · Peak: 50,000 events/sec (10x!)

  • Without backpressure: Consumers fall behind · Lag grows to hours · Memory exhaustion · Crash, data loss
  • With backpressure: Detect lag growth · Batch aggressively · Shed non-critical (metrics, not audit!) · Scale consumers · Alert before crisis
DAY 4 — DEAD LETTERS: No Event Left Behind

Even with all precautions, some events will fail to process: Schema mismatch (old event, new consumer) · Storage temporarily unavailable · Unexpected data format

DLQ ensures: Failed events captured, not lost · Investigation possible · Replay after fix · Compliance maintained

2.3 Design Principles#

Design principles
PRINCIPLE 1: WRITE FIRST, PROCESS LATER

Never block the user action on audit processing.

  • WRONG: User clicks "delete" → Delete from DB → Write audit to Kafka (sync) → Wait for consumer to process → Return to user. If Kafka is slow, user waits. Bad UX.
  • RIGHT: User clicks "delete" → Delete from DB + Write to outbox (same transaction) → Return to user immediately → Background: outbox → Kafka → consumer → storage. User sees instant response. Audit happens async.
PRINCIPLE 2: IMMUTABILITY IS NON-NEGOTIABLE

Once written, audit events must NEVER change. Implementation:

  • No UPDATE or DELETE on audit tables
  • Append-only storage
  • Cryptographic chaining (each event references previous hash)
  • Write-once object storage (S3 Object Lock)
PRINCIPLE 3: DEFENSE IN DEPTH

Multiple layers of protection:

  • Outbox: survives app crash
  • Kafka replication: survives broker failure
  • Consumer DLQ: survives processing errors
  • Multi-storage: survives single storage failure
  • Cross-region backup: survives datacenter failure
PRINCIPLE 4: QUERY PERFORMANCE MATTERS

"Find all actions by user X on resource Y in January 2023" — must be fast, even with billions of events.

  • Proper indexing on hot storage
  • Partitioning by time and actor
  • Pre-aggregated views for common queries
  • Full-text search for investigation
ExplainerAudit log hash chain: tamper-evident, with a checkpoint

Each audit event stores the previous event's hash. Editing event #1001 makes its recomputed hash disagree with the stored one, so verification flags it. An attacker who recomputes every later hash makes the chain verify again, which is why the latest hash is also checkpointed to WORM storage: the rewritten head no longer matches it.

Chapter 3: Trade-offs and Considerations#

3.1 Storage Trade-offs#

Storage trade-offs
HOT STORAGE (0-90 days) — Options: PostgreSQL, TimescaleDB, Cassandra
Option Pros Cons
PostgreSQL/TimescaleDB Rich querying (SQL) · Familiar operations · Good indexing Scaling writes is hard · Expensive at scale
Cassandra Massive write throughput · Linear scalability Query limitations · Operational complexity

Recommendation: TimescaleDB for most cases — Time-series optimized · Automatic partitioning · Compression · Still PostgreSQL compatible

WARM STORAGE (90 days - 1 year) — Options: ClickHouse, BigQuery, Snowflake
Option Pros Cons
ClickHouse Extremely fast analytical queries · Column-oriented compression · Self-hosted option Operational burden
BigQuery/Snowflake Managed service · Scales automatically Vendor lock-in · Cost at high volume

Recommendation: ClickHouse if you have ops capacity, BigQuery/Snowflake if you want managed

COLD STORAGE (1-7 years) — Options: S3 + Parquet, Glacier
Option Pros Cons
S3 + Parquet Extremely cheap · Columnar format for analytics · Query with Athena/Presto · Object Lock for immutability —
Glacier Even cheaper Retrieval takes hours · Only for true archive

Recommendation: S3 Intelligent-Tiering + Parquet — Auto-moves to cheaper tiers · Still queryable via Athena

WorksheetWhat 7 years of audit events weigh (event size, index overhead and compression are assumptions)
Value
Normal rate (the Black Friday panel above) 5,000 events/s → 5,000 × 86,400 ≈ 432 million a day
Event size as stored JSON (assumed) 1 KB → ~432 GB a day
Kafka, 7-day retention × replication 3 (the architecture above) 432 GB × 7 × 3 ≈ ~9 TB
Hot tier, 90 days; columns + raw JSON + indexes ≈ 2× (assumed) 432 GB × 90 × 2 ≈ ~78 TB
Cold tier, 7 years (2,555 days) as Parquet, 10× compression (assumed) 432 GB × 2,555 ÷ 10 ≈ ~110 TB
Peak per partition (50,000/s over the config's 32 partitions) ~1,560 events/s per consumer, ~3 batch inserts/s at 500 a batch

Conclusion: Kafka is cheap and seven years of cold storage fits in about 110 TB. The hot tier is the hard part: 90 days is ~78 TB in a PostgreSQL-family database, which is why TimescaleDB's compression and time partitioning (or a shorter hot window) matter more than consumer count.

Your turn · checkpoint 2not answered

Trade-off: should the user wait for the audit write?

You can write each audit event straight into the audit database inside the request, or write it to the outbox and let it reach storage a few seconds later. What does each option cost the user, and what happens to the app when the audit database goes down?

Think it through, or jot it down. Then compare.

One strong answer

  • Synchronous: every request pays the audit write (+50–200 ms), and if the audit database is down, the app is down.
  • Asynchronous via the outbox: the user waits for nothing extra, and events become visible after 1–60 s. An audit outage only delays visibility.
  • Most systems choose async: the outbox already guarantees the event is captured, and investigations happen hours or days later.

3.2 Consistency vs Latency#

THE AUDIT LATENCY QUESTION

How soon must an event be visible in the audit log?

Option A: Synchronous (immediate) Option B: Asynchronous (eventual)
How Write to audit DB in the request path; user waits for audit write Write to outbox, process async; user doesn't wait
Latency +50-200ms per request +0ms to user, 1-60s visibility
Consistency Immediate Eventual
Risk Audit DB down = app down Audit may lag during issues

Option C: Hybrid — Critical actions: sync · Regular actions: async

Most systems choose: Asynchronous. Rationale:

  • Most investigations happen hours/days later
  • 1 minute delay is acceptable
  • User experience matters
  • Audit DB issues shouldn't break app

3.3 Schema Design Trade-offs#

STRUCTURED VS FLEXIBLE SCHEMA

Structured (strict schema)

js8 lines
{
  "actor_id": "required string",
  "action": "required enum",
  "resource_type": "required enum",
  "resource_id": "required string",
  "timestamp": "required datetime",
  ...
}
  • ok — Consistent, easy to query
  • ok — Schema validation catches errors
  • no — Hard to evolve
  • no — Different actions need different fields

Flexible (schemaless)

js4 lines
{
  "event_type": "string",
  "data": { /* anything */ }
}
  • ok — Easy to add new event types
  • ok — Each event type has custom fields
  • no — Hard to query consistently
  • no — No validation, garbage in

RECOMMENDATION: Structured core + flexible extension

js22 lines
{
  // Required core fields (strict)
  "event_id": "evt_abc123",
  "timestamp": "2024-01-15T10:30:00Z",
  "actor": { "id": "usr_456", "type": "user" },
  "action": "resource.update",
  "resource": { "type": "document", "id": "doc_789" },
  "result": "success",

  // Flexible extension (varies by action)
  "details": {
    "changes": { "title": { "old": "Draft", "new": "Final" } },
    "reason": "User requested update"
  },

  // Standard metadata (always present)
  "context": {
    "ip_address": "192.168.1.100",
    "request_id": "req_xyz",
    "session_id": "sess_abc"
  }
}
Quick check4 questions · +5 XP each

Audit log foundations

  1. Q1/4 A user deletes sensitive data. The DELETE commits, then publishing the audit event to Kafka fails. Which design from this week prevents a missing audit record?

    Answer & explanation

    Answer: C

    With the outbox, the delete and its audit record commit together or not at all; the publisher ships the event to Kafka later. Retries do not help if the process crashes between the two writes.

  2. Q2/4 Why does the design use Kafka rather than RabbitMQ for audit events?

    Answer & explanation

    Answer: A

    Audit events are history that several systems read at their own pace, which is the stream model. A queue would hand each event to only one consumer and then delete it.

  3. Q3/4 Why do most audit systems write asynchronously (outbox, then Kafka, then storage) instead of synchronously into the audit database on every request?

    Answer & explanation

    Answer: B

    The outbox already guarantees the event is captured, so visibility can lag safely. Coupling every request to the audit store's health is the bigger risk.

  4. Q4/4 Each stored event includes the hash of the previous event. What does that buy you?

    Answer & explanation

    Answer: B

    Hash chaining makes the log tamper-evident, not tamper-proof (and only if the latest hash is checkpointed somewhere an attacker cannot rewrite). Immutable storage such as S3 Object Lock and no UPDATE/DELETE permissions handle prevention.

Part II · Stage 2 of 4 · 21 min

Part II: Implementation#

Chapter 4: Basic Implementation#

4.1 Audit Event Schema#

pythonActionResult · 138 lines
# Audit Event Schema
# The foundation of the entire system

from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from typing import Optional, Dict, Any, List
from enum import Enum
import uuid
import json


class ActionResult(Enum):
    SUCCESS = "success"
    FAILURE = "failure"
    DENIED = "denied"   # Authorization failure


class ActorType(Enum):
    USER = "user"
    SERVICE = "service"
    SYSTEM = "system"   # Automated processes


@dataclass
class Actor:
    """Who performed the action."""
    id: str
    type: ActorType
    email: Optional[str] = None
    name: Optional[str] = None
    ip_address: Optional[str] = None
    user_agent: Optional[str] = None
    
    def to_dict(self) -> Dict:
        return {
            "id": self.id,
            "type": self.type.value,
            "email": self.email,
            "name": self.name,
            "ip_address": self.ip_address,
            "user_agent": self.user_agent
        }


@dataclass
class Resource:
    """What was acted upon."""
    type: str           # e.g., "user", "document", "account"
    id: str             # Resource identifier
    name: Optional[str] = None  # Human-readable name
    
    def to_dict(self) -> Dict:
        return {
            "type": self.type,
            "id": self.id,
            "name": self.name
        }


@dataclass
class AuditEvent:
    """
    A single audit log entry.
    
    Captures: WHO did WHAT to WHICH RESOURCE, WHEN, and HOW.
    """
    # Core required fields
    actor: Actor
    action: str              # e.g., "document.create", "user.login"
    resource: Resource
    result: ActionResult
    
    # Auto-generated
    event_id: str = field(default_factory=lambda: f"evt_{uuid.uuid4().hex[:12]}")
    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    
    # Optional details
    changes: Optional[Dict[str, Any]] = None    # For updates: old/new values
    reason: Optional[str] = None                 # Why action was taken
    error_message: Optional[str] = None          # If result is failure
    
    # Context
    request_id: Optional[str] = None
    session_id: Optional[str] = None
    correlation_id: Optional[str] = None        # For tracing across services
    
    # For tamper evidence
    previous_hash: Optional[str] = None         # Hash of previous event
    
    def to_dict(self) -> Dict:
        return {
            "event_id": self.event_id,
            "timestamp": self.timestamp.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
            "actor": self.actor.to_dict(),
            "action": self.action,
            "resource": self.resource.to_dict(),
            "result": self.result.value,
            "changes": self.changes,
            "reason": self.reason,
            "error_message": self.error_message,
            "context": {
                "request_id": self.request_id,
                "session_id": self.session_id,
                "correlation_id": self.correlation_id
            },
            "integrity": {
                "previous_hash": self.previous_hash
            }
        }
    
    def to_json(self) -> str:
        return json.dumps(self.to_dict(), default=str)


# Example usage
def example():
    event = AuditEvent(
        actor=Actor(
            id="usr_456",
            type=ActorType.USER,
            email="alice@company.com",
            ip_address="192.168.1.100"
        ),
        action="document.update",
        resource=Resource(
            type="document",
            id="doc_789",
            name="Q4 Financial Report"
        ),
        result=ActionResult.SUCCESS,
        changes={
            "title": {"old": "Draft Report", "new": "Q4 Financial Report"},
            "status": {"old": "draft", "new": "published"}
        },
        request_id="req_abc123"
    )
    
    print(event.to_json())

4.2 Simple Audit Client#

pythonSimpleAuditClient · 112 lines
# Simple Audit Client
# WARNING: Not production-ready - for learning only

from typing import Optional
from contextlib import contextmanager


class SimpleAuditClient:
    """
    Basic audit client that writes to outbox.
    Application code uses this to log audit events.
    """
    
    def __init__(self, db_session):
        self.db = db_session
    
    def log(self, event: AuditEvent) -> None:
        """
        Log an audit event.
        
        Writes to outbox table in the current transaction.
        """
        self.db.execute(
            """
            INSERT INTO audit_outbox (
                event_id, 
                event_type,
                payload, 
                created_at
            ) VALUES (%s, %s, %s, %s)
            """,
            (
                event.event_id,
                event.action,
                event.to_json(),
                event.timestamp
            )
        )
    
    @contextmanager
    def capture(
        self,
        actor: Actor,
        action: str,
        resource: Resource,
        request_id: Optional[str] = None
    ):
        """
        Context manager that automatically logs success or failure.
        
        Usage:
            with audit.capture(actor, "document.delete", resource) as event:
                delete_document(doc_id)
                event.changes = {"deleted": True}
        """
        event = AuditEvent(
            actor=actor,
            action=action,
            resource=resource,
            result=ActionResult.SUCCESS,  # Optimistic
            request_id=request_id
        )
        
        try:
            yield event
            # If we get here, action succeeded
            event.result = ActionResult.SUCCESS
        except PermissionError as e:
            event.result = ActionResult.DENIED
            event.error_message = str(e)
            raise
        except Exception as e:
            event.result = ActionResult.FAILURE
            event.error_message = str(e)
            raise
        finally:
            # Always log, regardless of outcome
            self.log(event)


# Usage in application code
async def delete_document(doc_id: str, user: User, audit: SimpleAuditClient):
    """Example: deleting a document with audit logging."""
    
    # Get document for audit trail
    doc = await db.get_document(doc_id)
    
    actor = Actor(
        id=user.id,
        type=ActorType.USER,
        email=user.email
    )
    
    resource = Resource(
        type="document",
        id=doc_id,
        name=doc.title
    )
    
    with audit.capture(actor, "document.delete", resource) as event:
        # The actual business logic
        await db.delete_document(doc_id)
        
        # Capture what was deleted
        event.changes = {
            "title": doc.title,
            "owner": doc.owner_id,
            "size_bytes": doc.size
        }
    
    # If we get here, both delete and audit succeeded
    return {"status": "deleted"}

4.3 Database Schema#

sql92 lines
-- Audit Outbox Table (for transactional outbox pattern)
CREATE TABLE audit_outbox (
    id BIGSERIAL PRIMARY KEY,
    event_id VARCHAR(64) NOT NULL UNIQUE,
    event_type VARCHAR(128) NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    published_at TIMESTAMPTZ,
    
    -- For ordering and deduplication
    CONSTRAINT audit_outbox_event_unique UNIQUE (event_id)
);

CREATE INDEX idx_audit_outbox_unpublished 
    ON audit_outbox (created_at) 
    WHERE published_at IS NULL;


-- Hot Storage: Recent audit events (90 days)
-- Using TimescaleDB for time-series optimization
CREATE TABLE audit_events (
    event_id VARCHAR(64) NOT NULL,
    timestamp TIMESTAMPTZ NOT NULL,
    
    -- Actor
    actor_id VARCHAR(64) NOT NULL,
    actor_type VARCHAR(32) NOT NULL,
    actor_email VARCHAR(256),
    actor_ip VARCHAR(45),
    
    -- Action
    action VARCHAR(128) NOT NULL,
    result VARCHAR(32) NOT NULL,
    
    -- Resource
    resource_type VARCHAR(64) NOT NULL,
    resource_id VARCHAR(64) NOT NULL,
    resource_name VARCHAR(256),
    
    -- Details (flexible)
    changes JSONB,
    error_message TEXT,
    
    -- Context
    request_id VARCHAR(64),
    session_id VARCHAR(64),
    correlation_id VARCHAR(64),
    
    -- Integrity
    previous_hash VARCHAR(64),
    event_hash VARCHAR(64),
    
    -- Full event for archive
    raw_event JSONB NOT NULL,

    -- Hypertables require the time column in every unique key
    PRIMARY KEY (event_id, timestamp)
);

-- Convert to TimescaleDB hypertable
SELECT create_hypertable('audit_events', 'timestamp');

-- Indexes for common queries
CREATE INDEX idx_audit_events_actor ON audit_events (actor_id, timestamp DESC);
CREATE INDEX idx_audit_events_resource ON audit_events (resource_type, resource_id, timestamp DESC);
CREATE INDEX idx_audit_events_action ON audit_events (action, timestamp DESC);

-- Composite index for investigation queries
CREATE INDEX idx_audit_events_investigation 
    ON audit_events (actor_id, resource_type, action, timestamp DESC);


-- DLQ for failed audit events
CREATE TABLE audit_dlq (
    id BIGSERIAL PRIMARY KEY,
    event_id VARCHAR(64) NOT NULL,
    original_payload JSONB NOT NULL,
    error_type VARCHAR(128) NOT NULL,
    error_message TEXT,
    stack_trace TEXT,
    retry_count INT NOT NULL DEFAULT 0,
    first_failed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_failed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    resolved_at TIMESTAMPTZ,
    resolution VARCHAR(32),  -- 'replayed', 'discarded', 'manual'
    
    CONSTRAINT audit_dlq_event_unique UNIQUE (event_id)
);

CREATE INDEX idx_audit_dlq_unresolved 
    ON audit_dlq (first_failed_at) 
    WHERE resolved_at IS NULL;

Chapter 5: Production-Ready Implementation#

5.1 Requirements Recap#

  1. Zero event loss — Every action must be logged
  2. High throughput — 50,000 events/sec at peak
  3. Low latency — Don't slow down user actions
  4. Immutability — Events cannot be modified
  5. Queryability — Fast investigation queries
  6. Long retention — 7 years of data
  7. Tamper evidence — Detect any modifications

5.2 Complete Production Implementation#

pythonAuditConfig · 645 lines
# Production Audit Log System
# Combines all Week 3 patterns

import asyncio
import hashlib
import json
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, List
from enum import Enum
import logging

logger = logging.getLogger(__name__)


# =============================================================================
# Configuration
# =============================================================================

@dataclass
class AuditConfig:
    """Configuration for the audit system."""
    
    # Kafka
    kafka_topic: str = "audit-events"
    kafka_partitions: int = 32
    kafka_replication: int = 3
    
    # Outbox
    outbox_poll_interval: float = 0.1  # 100ms
    outbox_batch_size: int = 1000
    
    # Consumer
    consumer_batch_size: int = 500
    consumer_batch_timeout: float = 1.0
    
    # Backpressure
    lag_warning_threshold: int = 10_000
    lag_critical_threshold: int = 100_000
    
    # DLQ
    max_retries: int = 3
    
    # Integrity
    enable_hash_chain: bool = True


# =============================================================================
# Audit Client (Application-Side)
# =============================================================================

class AuditClient:
    """
    Production audit client with transactional outbox.
    
    Usage in application code:
        async with db.transaction():
            await business_logic()
            await audit.log(event)  # Same transaction!
    """
    
    def __init__(self, db_pool, config: AuditConfig):
        self.db = db_pool
        self.config = config
        self._actor_context: Dict[str, Actor] = {}  # Per-request context
    
    def set_context(self, request_id: str, actor: Actor):
        """Set actor context for current request (call in middleware)."""
        self._actor_context[request_id] = actor
    
    def clear_context(self, request_id: str):
        """Clear context after request completes."""
        self._actor_context.pop(request_id, None)
    
    def get_actor(self, request_id: str) -> Optional[Actor]:
        """Get actor for current request."""
        return self._actor_context.get(request_id)
    
    async def log(
        self,
        action: str,
        resource: Resource,
        result: ActionResult = ActionResult.SUCCESS,
        changes: Optional[Dict] = None,
        reason: Optional[str] = None,
        error_message: Optional[str] = None,
        request_id: Optional[str] = None,
        actor: Optional[Actor] = None  # Override context
    ) -> str:
        """
        Log an audit event to the outbox.
        
        MUST be called within a database transaction to ensure
        atomicity with the business operation.
        
        Returns: event_id
        """
        # Get actor from context if not provided
        if actor is None and request_id:
            actor = self.get_actor(request_id)
        
        if actor is None:
            raise ValueError("Actor must be provided or set in context")
        
        event = AuditEvent(
            actor=actor,
            action=action,
            resource=resource,
            result=result,
            changes=changes,
            reason=reason,
            error_message=error_message,
            request_id=request_id
        )
        
        # Write to outbox
        await self.db.execute(
            """
            INSERT INTO audit_outbox (event_id, event_type, payload, created_at)
            VALUES ($1, $2, $3, $4)
            """,
            event.event_id,
            event.action,
            event.to_json(),
            event.timestamp
        )
        
        logger.debug(f"Audit event logged to outbox: {event.event_id}")
        return event.event_id
    
    async def log_success(
        self,
        action: str,
        resource: Resource,
        changes: Optional[Dict] = None,
        **kwargs
    ) -> str:
        """Convenience method for successful actions."""
        return await self.log(
            action=action,
            resource=resource,
            result=ActionResult.SUCCESS,
            changes=changes,
            **kwargs
        )
    
    async def log_failure(
        self,
        action: str,
        resource: Resource,
        error_message: str,
        **kwargs
    ) -> str:
        """Convenience method for failed actions."""
        return await self.log(
            action=action,
            resource=resource,
            result=ActionResult.FAILURE,
            error_message=error_message,
            **kwargs
        )


# =============================================================================
# Outbox Publisher (Background Process)
# =============================================================================

class OutboxPublisher:
    """
    Publishes audit events from outbox to Kafka.
    
    Day 2 pattern: Transactional Outbox
    """
    
    def __init__(
        self,
        db_pool,
        kafka_producer,
        config: AuditConfig
    ):
        self.db = db_pool
        self.kafka = kafka_producer
        self.config = config
        self._running = False
        
        # Metrics
        self.events_published = 0
        self.publish_errors = 0
    
    async def start(self):
        """Start the publisher loop."""
        self._running = True
        logger.info("Outbox publisher started")
        
        while self._running:
            try:
                count = await self._publish_batch()
                if count == 0:
                    await asyncio.sleep(self.config.outbox_poll_interval)
            except Exception as e:
                logger.error(f"Publisher error: {e}")
                self.publish_errors += 1
                await asyncio.sleep(1)  # Back off on error
    
    async def stop(self):
        """Stop the publisher."""
        self._running = False
        logger.info(f"Outbox publisher stopped. Published: {self.events_published}")
    
    async def _publish_batch(self) -> int:
        """Publish a batch of events from outbox."""
        # FOR UPDATE SKIP LOCKED only holds its row locks inside a transaction
        async with self.db.acquire() as conn, conn.transaction():
            # Fetch unpublished events
            rows = await conn.fetch(
                """
                SELECT id, event_id, event_type, payload
                FROM audit_outbox
                WHERE published_at IS NULL
                ORDER BY created_at
                LIMIT $1
                FOR UPDATE SKIP LOCKED
                """,
                self.config.outbox_batch_size
            )

            if not rows:
                return 0

            # Publish to Kafka
            published_ids = []
            for row in rows:
                try:
                    # Partition by actor_id for ordering
                    payload = json.loads(row['payload'])
                    actor_id = payload.get('actor', {}).get('id', '')
                    partition_key = actor_id.encode('utf-8')

                    await self.kafka.send_and_wait(
                        self.config.kafka_topic,
                        key=partition_key,
                        value=row['payload'].encode('utf-8')
                    )

                    published_ids.append(row['id'])
                    self.events_published += 1

                except Exception as e:
                    logger.error(f"Failed to publish event {row['event_id']}: {e}")
                    self.publish_errors += 1

            # Mark as published
            if published_ids:
                await conn.execute(
                    """
                    UPDATE audit_outbox
                    SET published_at = NOW()
                    WHERE id = ANY($1)
                    """,
                    published_ids
                )

            return len(published_ids)


# =============================================================================
# Audit Consumer (Writes to Storage)
# =============================================================================

class AuditConsumer:
    """
    Consumes audit events from Kafka and writes to storage.
    
    Day 3 pattern: Backpressure handling
    Day 4 pattern: Dead letter queue
    """
    
    def __init__(
        self,
        kafka_consumer,
        db_pool,
        dlq_handler,
        config: AuditConfig,
        metrics_client
    ):
        self.kafka = kafka_consumer
        self.db = db_pool
        self.dlq = dlq_handler
        self.config = config
        self.metrics = metrics_client
        
        self._running = False
        # NOTE: one chain per consumer only works with a single writer.
        # With N partitions/consumers, keep one chain per partition and
        # verify each chain separately.
        self._last_hash: Optional[str] = None
        
        # Metrics
        self.events_processed = 0
        self.events_failed = 0
    
    async def start(self):
        """Start consuming."""
        self._running = True
        logger.info("Audit consumer started")
        
        # Load last hash for chain integrity
        if self.config.enable_hash_chain:
            self._last_hash = await self._get_last_hash()
        
        batch = []
        batch_start = datetime.now(timezone.utc)
        
        async for msg in self.kafka:
            if not self._running:
                break
            
            batch.append(msg)
            
            # Process batch if full or timeout
            should_flush = (
                len(batch) >= self.config.consumer_batch_size or
                (datetime.now(timezone.utc) - batch_start).total_seconds() > self.config.consumer_batch_timeout
            )
            
            if should_flush and batch:
                await self._process_batch(batch)
                batch = []
                batch_start = datetime.now(timezone.utc)
    
    async def _process_batch(self, messages: List) -> None:
        """Process a batch of messages."""
        events_to_insert = []
        
        for msg in messages:
            try:
                event_dict = json.loads(msg.value.decode('utf-8'))
                
                # Add hash chain
                if self.config.enable_hash_chain:
                    event_dict['integrity']['previous_hash'] = self._last_hash
                    event_hash = self._compute_hash(event_dict)
                    event_dict['integrity']['event_hash'] = event_hash
                    self._last_hash = event_hash
                
                events_to_insert.append(event_dict)
                
            except Exception as e:
                logger.error(f"Failed to process event: {e}")
                await self.dlq.handle_failure(msg, e)
                self.events_failed += 1
        
        # Batch insert
        if events_to_insert:
            try:
                await self._batch_insert(events_to_insert)
                self.events_processed += len(events_to_insert)
                
                # Commit offsets
                await self.kafka.commit()
                
            except Exception as e:
                logger.error(f"Batch insert failed: {e}")
                # Send all to DLQ
                for msg in messages:
                    await self.dlq.handle_failure(msg, e)
                self.events_failed += len(messages)
    
    async def _batch_insert(self, events: List[Dict]) -> None:
        """Insert batch of events to database."""
        # Build batch insert
        values = []
        for e in events:
            values.append((
                e['event_id'],
                datetime.fromisoformat(e['timestamp'].replace('Z', '+00:00')),
                e['actor']['id'],
                e['actor']['type'],
                e['actor'].get('email'),
                e['actor'].get('ip_address'),
                e['action'],
                e['result'],
                e['resource']['type'],
                e['resource']['id'],
                e['resource'].get('name'),
                json.dumps(e.get('changes')) if e.get('changes') else None,
                e.get('error_message'),
                e['context'].get('request_id'),
                e['context'].get('session_id'),
                e['context'].get('correlation_id'),
                e['integrity'].get('previous_hash'),
                e['integrity'].get('event_hash'),
                json.dumps(e)
            ))
        
        await self.db.executemany(
            """
            INSERT INTO audit_events (
                event_id, timestamp, actor_id, actor_type, actor_email, actor_ip,
                action, result, resource_type, resource_id, resource_name,
                changes, error_message, request_id, session_id, correlation_id,
                previous_hash, event_hash, raw_event
            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
            ON CONFLICT (event_id, timestamp) DO NOTHING
            """,
            values
        )
    
    def _compute_hash(self, event: Dict) -> str:
        """Compute hash for tamper evidence."""
        # Canonical JSON for consistent hashing
        canonical = json.dumps(event, sort_keys=True, separators=(',', ':'))
        return hashlib.sha256(canonical.encode()).hexdigest()
    
    async def _get_last_hash(self) -> Optional[str]:
        """Get hash of last event for chain integrity."""
        row = await self.db.fetchrow(
            """
            SELECT event_hash
            FROM audit_events
            ORDER BY timestamp DESC
            LIMIT 1
            """
        )
        return row['event_hash'] if row else None


# =============================================================================
# Archive Writer (Cold Storage)
# =============================================================================

class ArchiveWriter:
    """
    Archives old audit events to S3 in Parquet format.
    Runs as a daily job.
    """
    
    def __init__(self, db_pool, s3_client, config: AuditConfig):
        self.db = db_pool
        self.s3 = s3_client
        self.config = config
    
    async def archive_day(self, date: datetime) -> Dict[str, Any]:
        """Archive all events from a specific day."""
        import pyarrow as pa
        import pyarrow.parquet as pq
        
        # Fetch events for the day
        start = date.replace(hour=0, minute=0, second=0, microsecond=0)
        end = start + timedelta(days=1)
        
        rows = await self.db.fetch(
            """
            SELECT raw_event
            FROM audit_events
            WHERE timestamp >= $1 AND timestamp < $2
            ORDER BY timestamp
            """,
            start, end
        )
        
        if not rows:
            return {"date": date.isoformat(), "events": 0, "status": "empty"}
        
        # Convert to Parquet
        events = [json.loads(row['raw_event']) for row in rows]
        table = pa.Table.from_pylist(events)
        
        # Write to S3
        key = f"audit-archive/{date.year}/{date.month:02d}/{date.day:02d}/events.parquet"
        
        buffer = pa.BufferOutputStream()
        pq.write_table(table, buffer, compression='snappy')
        
        await self.s3.put_object(
            Bucket='audit-archive',
            Key=key,
            Body=buffer.getvalue().to_pybytes(),
            # Object Lock for immutability
            ObjectLockMode='COMPLIANCE',  # GOVERNANCE can be bypassed with a special permission
            ObjectLockRetainUntilDate=datetime.now(timezone.utc) + timedelta(days=2555)  # 7 years
        )
        
        logger.info(f"Archived {len(events)} events for {date.date()}")
        
        return {
            "date": date.isoformat(),
            "events": len(events),
            "s3_key": key,
            "status": "archived"
        }


# =============================================================================
# Query Service (Investigation)
# =============================================================================

class AuditQueryService:
    """
    Query interface for audit log investigation.
    """
    
    def __init__(self, db_pool, s3_client):
        self.db = db_pool
        self.s3 = s3_client
    
    async def search(
        self,
        actor_id: Optional[str] = None,
        resource_type: Optional[str] = None,
        resource_id: Optional[str] = None,
        action: Optional[str] = None,
        start_time: Optional[datetime] = None,
        end_time: Optional[datetime] = None,
        limit: int = 100,
        offset: int = 0
    ) -> List[Dict]:
        """
        Search audit events with filters.
        """
        conditions = ["1=1"]
        params = []
        param_idx = 1
        
        if actor_id:
            conditions.append(f"actor_id = ${param_idx}")
            params.append(actor_id)
            param_idx += 1
        
        if resource_type:
            conditions.append(f"resource_type = ${param_idx}")
            params.append(resource_type)
            param_idx += 1
        
        if resource_id:
            conditions.append(f"resource_id = ${param_idx}")
            params.append(resource_id)
            param_idx += 1
        
        if action:
            conditions.append(f"action LIKE ${param_idx}")
            params.append(f"{action}%")
            param_idx += 1
        
        if start_time:
            conditions.append(f"timestamp >= ${param_idx}")
            params.append(start_time)
            param_idx += 1
        
        if end_time:
            conditions.append(f"timestamp <= ${param_idx}")
            params.append(end_time)
            param_idx += 1
        
        params.extend([limit, offset])
        
        query = f"""
            SELECT raw_event
            FROM audit_events
            WHERE {' AND '.join(conditions)}
            ORDER BY timestamp DESC
            LIMIT ${param_idx} OFFSET ${param_idx + 1}
        """
        
        rows = await self.db.fetch(query, *params)
        return [json.loads(row['raw_event']) for row in rows]
    
    async def get_actor_activity(
        self,
        actor_id: str,
        days: int = 30
    ) -> List[Dict]:
        """Get all activity for a specific actor."""
        start_time = datetime.now(timezone.utc) - timedelta(days=days)
        return await self.search(actor_id=actor_id, start_time=start_time, limit=1000)
    
    async def get_resource_history(
        self,
        resource_type: str,
        resource_id: str
    ) -> List[Dict]:
        """Get complete history of a resource."""
        return await self.search(
            resource_type=resource_type,
            resource_id=resource_id,
            limit=1000
        )
    
    async def verify_integrity(
        self,
        start_time: datetime,
        end_time: datetime
    ) -> Dict[str, Any]:
        """
        Verify hash chain integrity for a time range.
        
        Returns any gaps or inconsistencies found.
        """
        rows = await self.db.fetch(
            """
            SELECT event_id, timestamp, previous_hash, event_hash, raw_event
            FROM audit_events
            WHERE timestamp >= $1 AND timestamp <= $2
            ORDER BY timestamp
            """,
            start_time, end_time
        )
        
        issues = []
        prev_hash = None
        
        for row in rows:
            # Check chain continuity
            if prev_hash and row['previous_hash'] != prev_hash:
                issues.append({
                    "type": "chain_break",
                    "event_id": row['event_id'],
                    "expected_prev": prev_hash,
                    "actual_prev": row['previous_hash']
                })
            
            # Verify hash
            event = json.loads(row['raw_event'])
            event['integrity']['previous_hash'] = row['previous_hash']
            event['integrity'].pop('event_hash', None)  # Hash was computed before this key existed
            
            canonical = json.dumps(event, sort_keys=True, separators=(',', ':'))
            computed_hash = hashlib.sha256(canonical.encode()).hexdigest()
            
            if computed_hash != row['event_hash']:
                issues.append({
                    "type": "hash_mismatch",
                    "event_id": row['event_id'],
                    "expected_hash": computed_hash,
                    "actual_hash": row['event_hash']
                })
            
            prev_hash = row['event_hash']
        
        return {
            "verified_events": len(rows),
            "issues_found": len(issues),
            "issues": issues,
            "integrity": "valid" if not issues else "compromised"
        }

Chapter 6: Edge Cases and Error Handling#

Your turn · checkpoint 3not answered

Estimate: Kafka is down for two hours

Kafka goes down at 10:00 and comes back at 12:00. The audit pipeline runs at its normal 5,000 events a second. How many rows are waiting in the outbox at noon, and roughly how big are they at 1 KB each? What happens to the app's own queries meanwhile?

Think it through, or jot it down. Then compare.

One strong answer

  • Rows: 5,000 × 7,200 s = 36 million unpublished rows.
  • Size: about 36 GB sitting in the primary database.
  • The app feels it: queries on the outbox slow down and the table bloats, so partition the outbox by time, delete published rows promptly and alert on the unpublished count long before this.

6.1 Edge Case 1: Outbox Table Growing Too Large#

SCENARIO: Kafka is down for 2 hours, outbox fills up
Timeline
  • 10:00 — Kafka goes down
  • 10:00 — Outbox publisher can't publish
  • 10:00 — Outbox starts growing
  • 12:00 — Outbox has 36M rows
  • 12:00 — Queries on outbox slow down
  • 12:00 — Application performance degrades
SOLUTION: Outbox management

1. PARTITION BY TIME

sql7 lines
CREATE TABLE audit_outbox (...)
PARTITION BY RANGE (created_at);

-- Daily partitions
CREATE TABLE audit_outbox_2024_01_15
PARTITION OF audit_outbox
FOR VALUES FROM ('2024-01-15') TO ('2024-01-16');

2. CLEANUP PUBLISHED EVENTS

sql7 lines
DELETE FROM audit_outbox
WHERE published_at < NOW() - INTERVAL '1 hour';

-- Or move to archive
INSERT INTO audit_outbox_archive
SELECT * FROM audit_outbox
WHERE published_at < NOW() - INTERVAL '1 hour';

3. ALERT ON GROWTH

yaml4 lines
alert:
  name: "Outbox Growing"
  condition: count(audit_outbox WHERE published_at IS NULL) > 100000
  action: page_oncall

4. CIRCUIT BREAKER — If outbox > 1M unpublished: Alert critical · Consider pausing non-critical auditing · Never pause critical (auth, payments)

6.2 Edge Case 2: Consumer Falls Far Behind#

SCENARIO: Consumer lag grows to hours

This is Day 3 backpressure in action!

Detection
  • Consumer lag > 100,000 events
  • Lag growing > 1,000 events/sec
Response

1. SCALE CONSUMERS

bash3 lines
# Kafka allows consumers <= partitions
# If 32 partitions, can have up to 32 consumers
kubectl scale deployment audit-consumer --replicas=16

2. INCREASE BATCH SIZE

python2 lines
# Process more per batch
config.consumer_batch_size = 1000  # up from 500

3. PARALLEL INSERTS

python6 lines
# Multiple connections to database
await asyncio.gather(
    self._batch_insert(batch1),
    self._batch_insert(batch2),
    self._batch_insert(batch3)
)

4. ALERT BUT DON'T SHED

python6 lines
# Unlike other systems, audit events can't be dropped
# Accept temporary latency, never lose data

if lag > critical_threshold:
    alert("Audit lag critical - needs attention")
    # But keep processing, don't shed!
Your turn · checkpoint 4not answered

What breaks if two consumers share one hash chain?

To keep up, you run 16 audit consumers, but each one still keeps its own running "last hash" and links every event it writes to it, as if it were the only writer. Event 1001's previous_hash now doesn't match event 1000's hash. Was the log tampered with? What should the design have been?

Think it through, or jot it down. Then compare.

One strong answer

  • Probably not tampering: consumers writing in parallel each chain to whatever came last for them, so the stored order and the chain disagree. Events processed out of order or a crash between events cause the same break.
  • Investigate anyway: look for gaps, check the DLQ and Kafka, and preserve evidence if tampering is suspected.
  • The design: one chain per partition with a single writer each, verified separately, and the insert committed together with the offset.

6.3 Edge Case 3: Hash Chain Broken#

SCENARIO: Integrity verification finds chain break
Detection
  • Event #1000: hash = "abc123"
  • Event #1001: previous_hash = "xyz789" ← Should be "abc123"!

Possible causes:

  1. Events processed out of order
  2. Consumer crash between events
  3. Tampering (rare but serious)
  4. Bug in hash computation
INVESTIGATION STEPS

1. CHECK FOR MISSING EVENTS — look for gaps in sequence

sql3 lines
SELECT event_id FROM audit_events
WHERE timestamp BETWEEN '2024-01-15 10:00' AND '2024-01-15 10:05'
ORDER BY timestamp;

2. CHECK DLQ — missing event might be in DLQ

sql2 lines
SELECT * FROM audit_dlq
WHERE first_failed_at BETWEEN '2024-01-15 10:00' AND '2024-01-15 10:05';

3. CHECK KAFKA — see what was actually in Kafka

bash2 lines
kafka-console-consumer.sh --bootstrap-server kafka:9092 --topic audit-events \
  --partition 0 --offset 1000 --max-messages 10

4. IF TAMPERING SUSPECTED — Preserve evidence · Check database access logs · Involve security team · Do not "fix" the data

PREVENTION
  1. SINGLE WRITER PER PARTITION — Only one consumer per partition = guaranteed ordering
  2. TRANSACTIONAL COMMIT — Insert + offset commit in same transaction
  3. CHAIN RECOVERY PROCEDURE — If chain broken, add "chain_recovery" event. Documents the break, restarts chain

6.4 Edge Case 4: Clock Skew Issues#

SCENARIO: Events arrive with out-of-order timestamps
  • Server A clock: 10:00:00
  • Server B clock: 10:00:05 (5 seconds ahead)
Event from Timestamp
Server A 10:00:02
Server B 10:00:07
Server A 10:00:03
  • Stored order: A 10:00:02, A 10:00:03, B 10:00:07
  • Actual order: A 10:00:02, B (really 10:00:02, stamped 10:00:07), A 10:00:03
SOLUTIONS
  1. USE INGESTION TIME, NOT EVENT TIME — Consumer adds received_at timestamp · Sort by received_at for investigation · Keep original timestamp for reference
  2. NTP SYNC ALL SERVERS — Ensure clock drift < 100ms · Monitor clock sync as infrastructure metric
  3. INCLUDE BOTH TIMESTAMPS
    js5 lines
    {
      "event_time": "2024-01-15T10:00:07Z",  // From producer
      "ingested_at": "2024-01-15T10:00:02Z", // From consumer
      "time_drift_ms": 5000                   // Calculated
    }
  4. ALERT ON DRIFT
    python2 lines
    if abs(event_time - ingested_at) > 1000ms:
        alert("Clock drift detected on {server}")

6.5 Error Handling Matrix#

Error Impact Response Prevention
Outbox write fails Event lost! Rollback entire transaction None (by design)
Kafka down Events queue in outbox Wait and retry Multi-broker setup
Consumer crash Temporary lag Auto-restart, resume Health checks
DB insert fails Event goes to DLQ DLQ + alert Retry with backoff
Hash mismatch Integrity concern Alert, investigate Single writer
Clock skew Order confusion Use ingestion time NTP monitoring
Storage full Can't write Alert, add capacity Capacity planning
Quick check3 questions · +5 XP each

Building the audit pipeline

  1. Q1/3 The audit consumer inserts with ON CONFLICT (event_id, timestamp) DO NOTHING. Why?

    Answer & explanation

    Answer: A

    Outbox publishing and Kafka consumption can both redeliver. Keying on event_id makes the insert idempotent.

  2. Q2/3 During a traffic spike the audit consumer falls hours behind. What does the lesson say NOT to do?

    Answer & explanation

    Answer: C

    Audit events are a compliance record and must never be dropped. Scale, batch, alert, and accept temporary lag instead.

  3. Q3/3 Server B's clock runs 5 seconds ahead, so its events sort after events that actually happened later. What is the recommended fix?

    Answer & explanation

    Answer: B

    Producer clocks cannot be fully trusted. Storing both timestamps keeps the original claim while giving investigators a consistent ordering, and NTP monitoring catches drift early.

Part III · Stage 3 of 4 · 6 min

Part III: Real-World Application#

Chapter 7: How Big Tech Does It#

7.1 Case Study: Payments Audit Trail (Stripe-style, illustrative)#

Stripe's internal audit stack is not public. The design below shows how a payments company subject to PCI-DSS and SOX would typically build one.

Regulatory:

  • PCI-DSS: Log all access to cardholder data
  • SOX: Track all financial transactions
  • GDPR: Record data access and modifications

Scale:

  • Very high API volume
  • Every call potentially audited
  • Multi-year retention
DiagramPayments audit architecture (illustrative)
%% title: Payments audit architecture (illustrative)
flowchart TD
  AR@{ icon: "sd:users", label: "API Request" } --> AS["API Server"]:::core
  AS --> BL["Business Logic"]:::service
  AS --> ACL["Audit Client"]:::service
  BL --> PDB@{ icon: "sd:database", label: "Primary DB" }
  ACL --> OB@{ icon: "sd:database", label: "Outbox" }
  OB -->|"Poll / CDC"| OP["Outbox Publisher"]:::service
  OP --> K@{ icon: "tech:kafka", label: "Kafka" }
  K --> PS["Primary Storage"]:::service
  K --> AT@{ icon: "sd:chart", label: "Analytics Team" }
  K --> CE@{ icon: "sd:file", label: "Compliance Export" }
  PS --> PG@{ icon: "tech:postgresql", label: "PostgreSQL (hot)" }
  PG -->|"90 days"| BQ@{ icon: "tech:bigquery", label: "BigQuery (warm)" }
  BQ -->|"1 year"| GCS@{ icon: "tech:gcs", label: "GCS Parquet (cold)" }
  class AR client
  class PDB,PG,BQ,GCS store
  class OB,K queue
  class AT,CE service

Key decisions:

  1. Audit happens in request path — Not background - too important to lose. But: outbox pattern, so async to Kafka.
  2. Field-level tracking — Every field change recorded:
    json6 lines
    {
      "changes": {
        "amount": {"old": 1000, "new": 1500},
        "currency": {"old": "USD", "new": "USD"}
      }
    }
  3. Request correlation — Every audit event linked to API request. Can reconstruct entire request flow.
  4. PII handling — Sensitive fields tokenized or encrypted. Separate access controls for PII audit data.

7.2 Case Study: Content Access Audit (streaming service, illustrative)#

The tiers and sampling rates below are illustrative, not Netflix's published numbers.

Requirements:

  • Track who accessed which content
  • License compliance (who watched what, where)
  • Internal access to content systems

Scale:

  • Billions of streaming events per day
  • Not all need full audit (sampling for analytics)
  • But: Content system access = full audit
DiagramStreaming content audit (illustrative)
%% title: Streaming content audit (illustrative)
flowchart TD
  IU@{ icon: "sd:user", label: "Internal User" } --> CA["Content Admin"]:::service
  CA -->|"Audit captured<br/>(every action)"| K@{ icon: "tech:kafka", label: "Kafka" }
  ST@{ icon: "sd:video", label: "Streaming" } -->|"(sampled)"| K
  OS@{ icon: "sd:servers", label: "Other Services" } --> K
  K --> CN["Consumer"]:::core
  CN --> ES@{ icon: "tech:elasticsearch", label: "Elasticsearch<br/>(search)" }
  CN --> S3@{ icon: "tech:s3", label: "S3<br/>(archive)" }
  CN --> LR@{ icon: "sd:chart", label: "License<br/>Reporting" }
  class IU,ST client
  class OS,LR service
  class K queue
  class ES,S3 store

Key decisions:

  1. Tiered auditing
    • Internal admin access: 100% captured
    • Streaming views: Sampled for analytics
    • API calls: Based on sensitivity level
  2. Search-first design — Elasticsearch primary for investigation: "Who accessed title X in region Y last week?"
  3. Sampling for scale — Can't audit every stream start event (billions/day). Sample 1% for analytics, 100% for sensitive content.
  4. Geo-partitioning — Audit data stays in region (GDPR, licensing). EU data in EU storage, etc.

7.3 Case Study: Healthcare — HIPAA Audit#

Requirements:

  • Every access to patient records logged
  • "Break the glass" tracking
  • Access by role and relationship
  • 6-year retention (HIPAA's documentation rule, 45 CFR 164.316(b)(2); commonly applied to audit logs)

Special challenges:

  1. Relationship-based access
    • Dr. Smith can see patients assigned to them
    • Nurse Jones can see patients on their floor
    • Must audit: Who accessed, their role, their relationship
  2. Break the glass — Emergency override of normal access controls. Must be logged with extra detail:
    json6 lines
    {
      "action": "patient.record.view",
      "access_type": "emergency_override",
      "justification": "Patient in ER, unresponsive",
      "witness": "charge_nurse_456"
    }
  3. Minimum necessary — Auditors check: Did user access more than needed? Must log exactly which fields viewed, not just "viewed record".
  4. Patient access rights — Patients can request an accounting of disclosures (45 CFR 164.528); a per-patient report of who viewed their data is a useful extra, so be able to generate one.
DiagramHealthcare audit (HIPAA) architecture
%% title: Healthcare audit (HIPAA) architecture
flowchart TD
  EHR@{ icon: "sd:desktop", label: "EHR System" } --> AS["Audit Service"]:::core
  AS --> WORM@{ icon: "sd:object-storage", label: "WORM Storage<br/>Write-Once Read-Many<br/>(Tamper-proof)" }
  AS --> CR@{ icon: "sd:chart", label: "Compliance<br/>Reporting" }
  CR --> R1@{ icon: "sd:file", label: "Daily access reports" }
  CR --> R2@{ icon: "sd:shield", label: "Break-glass review" }
  CR --> R3@{ icon: "sd:file", label: "Patient data requests" }
  class EHR client
  class WORM store
  class CR,R1,R2,R3 service

7.4 Summary: Industry Patterns#

Company Focus Key Pattern Unique Challenge
Payments (Stripe-style) Payments Field-level tracking PCI-DSS compliance
Streaming service Content Tiered auditing Sampling at scale
Healthcare HIPAA Break-the-glass Relationship-based access
Banks SOX Dual-control logging Every transaction
Government FISMA Chain of custody Tamper evidence

Chapter 8: Common Mistakes to Avoid#

8.1 Mistake 1: Auditing After Business Logic#

Auditing after business logic

WRONG: Separate transactions

pythontransfer_money · 8 lines
async def transfer_money(from_acct, to_acct, amount):
    # Business logic
    async with db.transaction():
        await db.debit(from_acct, amount)
        await db.credit(to_acct, amount)

    # Audit separately (WRONG!)
    await audit.log(...)  # What if this fails?

Problem:

  • Money moved, but no audit trail
  • Compliance violation
  • Investigation impossible

CORRECT: Same transaction

pythontransfer_money · 16 lines
async def transfer_money(from_acct, to_acct, amount):
    async with db.transaction():
        # Business logic
        await db.debit(from_acct, amount)
        await db.credit(to_acct, amount)

        # Audit in same transaction
        await audit.log(
            action="account.transfer",
            resource=Resource(type="transfer", id=transfer_id),
            changes={
                "from_account": from_acct,
                "to_account": to_acct,
                "amount": amount
            }
        )

If audit fails, entire transfer rolls back. Atomicity guaranteed.

8.2 Mistake 2: Mutable Audit Logs#

Mutable audit logs

WRONG: UPDATE and DELETE allowed

sql8 lines
-- "Fixing" an audit record
UPDATE audit_events
SET actor_email = 'correct@email.com'
WHERE event_id = 'evt_123';

-- "Cleaning up" old events
DELETE FROM audit_events
WHERE timestamp < '2020-01-01';

Problems:

  • Tampering possible
  • Chain of custody broken
  • Legal evidence inadmissible

CORRECT: Append-only with corrections

sql15 lines
-- Never UPDATE original record
-- Instead, add correction event:

INSERT INTO audit_events (...) VALUES (
    'evt_456',  -- New event ID
    NOW(),
    'system',   -- Actor: system or admin
    'audit.correction',  -- Action type
    'audit_event',       -- Resource type
    'evt_123',           -- Reference to original
    '{"field": "actor_email", "old": "wrong@email.com", "new": "correct@email.com"}'
);

-- Original preserved, correction linked
-- Both visible in audit trail

8.3 Mistake 3: Insufficient Context#

Insufficient context

WRONG: Minimal information

json5 lines
{
    "action": "delete",
    "resource_id": "doc_123",
    "timestamp": "2024-01-15T10:00:00Z"
}

Problems:

  • Who deleted it?
  • What was deleted?
  • Why was it deleted?
  • From where?

CORRECT: Full context

json28 lines
{
    "event_id": "evt_789",
    "timestamp": "2024-01-15T10:00:00.123Z",
    "actor": {
        "id": "usr_456",
        "email": "alice@company.com",
        "role": "admin",
        "ip_address": "192.168.1.100",
        "user_agent": "Mozilla/5.0..."
    },
    "action": "document.delete",
    "resource": {
        "type": "document",
        "id": "doc_123",
        "name": "Q4 Financial Report.pdf"
    },
    "changes": {
        "title": "Q4 Financial Report.pdf",
        "size_bytes": 1048576,
        "owner": "usr_789",
        "created_at": "2024-01-01T08:00:00Z"
    },
    "context": {
        "request_id": "req_abc",
        "session_id": "sess_def",
        "justification": "Document superseded by updated version"
    }
}

Every question answerable from the event itself.

8.4 Mistake 4: No Query Optimization#

No query optimization

WRONG: Querying without indexes

sql6 lines
-- "Find all actions by user X"
SELECT * FROM audit_events
WHERE raw_event->'actor'->>'id' = 'usr_456';

-- Full table scan on billions of rows
-- Query takes hours, times out

CORRECT: Denormalized columns with indexes

sql18 lines
-- Extract commonly queried fields to columns
CREATE TABLE audit_events (
    ...
    actor_id VARCHAR(64) NOT NULL,  -- Extracted
    resource_type VARCHAR(64) NOT NULL,  -- Extracted
    ...
);

CREATE INDEX idx_audit_actor ON audit_events (actor_id, timestamp DESC);
CREATE INDEX idx_audit_resource ON audit_events (resource_type, resource_id);

-- Query uses index
SELECT * FROM audit_events
WHERE actor_id = 'usr_456'
ORDER BY timestamp DESC
LIMIT 100;

-- Returns in milliseconds

8.5 Mistake Checklist#

Before deploying audit system, verify:

Before deploying audit system, verify0/9 ready
  • Same transaction — Audit in same transaction as business logic
  • Immutable storage — No UPDATE or DELETE on audit tables
  • Full context — Who, what, when, where, why captured
  • Query performance — Indexes for common access patterns
  • Hash chain — Tamper evidence for compliance
  • DLQ handling — Failed events captured, not lost
  • Retention policy — Compliant with regulations
  • Backup strategy — Survives datacenter failure
  • Access controls — Audit log access is itself audited
Quick check3 questions · +5 XP each

Audit logs in practice

  1. Q1/3 An audit record has the wrong actor email. How should it be fixed?

    Answer & explanation

    Answer: C

    Audit logs are append-only. A linked correction keeps the full history visible and the chain of custody intact.

  2. Q2/3 A transfer runs debit and credit in one transaction, then calls audit.log after it commits. What can go wrong?

    Answer & explanation

    Answer: A

    Audit must be in the same transaction as the business change: if the audit insert fails, the transfer rolls back too.

  3. Q3/3 Investigators run WHERE raw_event->'actor'->>'id' = 'usr_456' over billions of rows, and it takes hours. What is the fix?

    Answer & explanation

    Answer: C

    Filtering inside raw JSON forces a full table scan. Denormalized, indexed columns like (actor_id, timestamp DESC) turn it into a millisecond lookup.

Part IV · Stage 4 of 4 · 12 min

Part IV: Interview Preparation#

Chapter 9: Interview Tips and Phrases#

9.1 When to Bring Up Audit Logging#

Bring up audit logging when:

  • Designing systems with compliance requirements (finance, health)
  • User data is being accessed or modified
  • Actions need to be traceable for investigation
  • System handles sensitive operations
  • Interviewer asks about security or compliance
❯ Say this

9.2 Key Phrases to Use#

Key phrases
INTRODUCING AUDIT REQUIREMENTS

"Before diving into the design, I want to clarify the audit requirements. For a financial system like this, we'll need to track every action for compliance—who did what, when, and the system must be able to prove it wasn't tampered with."

EXPLAINING THE ARCHITECTURE

"For audit logging, I'd use a transactional outbox pattern. The audit event is written to an outbox table in the same transaction as the business operation. This guarantees we never have an action without its corresponding audit trail."

DISCUSSING IMMUTABILITY

"Audit logs must be immutable—no updates, no deletes. Once written, an event is permanent. If we need to correct something, we add a correction event that references the original. This preserves the complete history and maintains evidence integrity."

ADDRESSING SCALE

"At 50,000 events per second, we need tiered storage. Hot storage in TimescaleDB for recent data—90 days—with good query performance. Then data moves to ClickHouse for the next year, and finally to S3 Parquet for long-term retention. Each tier optimized for its access pattern."

HANDLING FAILURES

"Even with best efforts, some events will fail to process. We use a dead letter queue to capture these—they're not lost, just quarantined. We alert on DLQ growth and have procedures to investigate and replay. For audit logs, we never drop data."

9.3 Questions to Ask Interviewer#

  • "What are the regulatory requirements? SOX, HIPAA, GDPR?"
  • "How long must audit data be retained?"
  • "What's the query pattern? Real-time investigation or periodic reports?"
  • "Is there a legal or compliance team that will access this data?"

9.4 Common Follow-up Questions#

Question Good Answer
"How do you ensure no audit events are lost?" "Transactional outbox—audit write is in the same DB transaction as the business logic. Either both succeed or both fail. Then async publishing to Kafka with DLQ for any processing failures."
"How do you prove logs weren't tampered with?" "Hash chaining—each event includes the hash of the previous event, so editing one means recomputing every later hash. That rewrite is only detectable if we periodically anchor the latest hash somewhere the attacker can't change (WORM storage, a signed checkpoint, a timestamping service). Plus write-once storage like S3 Object Lock."
"How do you handle sensitive data in logs?" "Tokenization or encryption for PII. Access controls so only authorized personnel can see full data. The audit log itself has an audit trail—we log who accessed the logs."
"What if the audit system itself goes down?" "Outbox pattern buffers events in the primary database. When the audit pipeline recovers, it catches up. We'd alert immediately on outage, but no data is lost because it's in the transactional outbox."

Chapter 10: Practice Problems#

Practice · problem 1

Problem 1: Healthcare Patient Access Audit#

Setup: You're designing an audit system for a hospital's electronic health records. Every access to patient data must be logged for HIPAA compliance.

Requirements:

  • 10,000 healthcare workers
  • 500,000 patients
  • Every view, edit, print of patient records audited
  • "Break the glass" emergency access tracking
  • 6-year retention
  • Patients can request report of who accessed their records

Questions:

  1. How do you capture the relationship between accessor and patient?
  2. How do you handle break-the-glass scenarios?
  3. How do you generate per-patient access reports efficiently?
Click for hints
  • Consider role-based access patterns
  • Break-the-glass needs extra justification
  • Pre-aggregate for patient-centric queries
Click for solution outline

Schema for Relationship Tracking:

json17 lines
{
  "actor": {
    "id": "dr_smith",
    "role": "physician",
    "department": "cardiology"
  },
  "access_context": {
    "relationship": "treating_physician",
    "assignment_id": "assign_123",
    "access_level": "normal"  // or "emergency_override"
  },
  "resource": {
    "type": "patient_record",
    "patient_id": "patient_456",
    "record_section": "medications"
  }
}

Break-the-Glass Handling:

python10 lines
if access_type == "emergency_override":
    event.access_context = {
        "access_level": "emergency_override",
        "justification": request.justification,  # Required
        "witness": request.witness_id,           # Required
        "auto_review": True                      # Flag for compliance
    }
    
    # Send immediate alert
    await alert_compliance_team(event)

Patient-Centric Reports:

sql15 lines
-- Materialized view for patient reports
CREATE MATERIALIZED VIEW patient_access_summary AS
SELECT 
    resource_id as patient_id,
    actor_id,
    actor_role,
    COUNT(*) as access_count,
    MIN(timestamp) as first_access,
    MAX(timestamp) as last_access
FROM audit_events
WHERE resource_type = 'patient_record'
GROUP BY resource_id, actor_id, actor_role;

-- Refresh daily
REFRESH MATERIALIZED VIEW patient_access_summary;

Practice · problem 2

Problem 2: Financial Trading Audit#

Setup: You're designing audit for a stock trading platform. Every order, modification, and cancellation must be logged with millisecond precision.

Requirements:

  • 100,000 orders per second during market hours
  • Sub-millisecond timestamp accuracy
  • Must reconstruct order book state at any point in time
  • 6-year retention (the period SEC Rule 17a-4 sets for most broker-dealer transaction records; a few records must be kept longer)
  • Real-time compliance monitoring

Questions:

  1. How do you handle the throughput requirement?
  2. How do you ensure timestamp accuracy?
  3. How do you enable point-in-time reconstruction?
Click for hints
  • Kafka partitioning for throughput
  • Hardware timestamping for accuracy
  • Event sourcing enables reconstruction
Click for solution outline

High Throughput:

text11 lines
# Kafka configuration
Partitions: 64 (parallel consumers)
Replication: 3
Batch size: Large for throughput

# Partition strategy
partition_key = symbol
# All events for one symbol land in one partition, in order,
# so its order book can be replayed. Salting the key
# (e.g. f"{symbol}:{order_id % 100}") would spread load, but one
# symbol would then span many partitions and lose that ordering.

Timestamp Accuracy:

python8 lines
# Use high-precision timestamps
import time

event = AuditEvent(
    timestamp_ns=time.time_ns(),  # Nanosecond precision
    clock_source="ntp_sync",       # Document source
    sequence_id=get_sequence(),    # Ordering within same ns
)

Point-in-Time Reconstruction:

pythonreconstruct_order_state · 14 lines
# Event sourcing model
events = [
    {"type": "order.placed", "order_id": "123", "qty": 100, ...},
    {"type": "order.partial_fill", "order_id": "123", "filled_qty": 50, ...},
    {"type": "order.modified", "order_id": "123", "new_qty": 75, ...},
    {"type": "order.cancelled", "order_id": "123", ...}
]

def reconstruct_order_state(order_id, as_of_timestamp):
    events = get_events(order_id, before=as_of_timestamp)
    state = {}
    for e in events:
        state = apply_event(state, e)
    return state

Practice · problem 3

Problem 3: Multi-Tenant SaaS Audit#

Setup: You're designing audit for a multi-tenant SaaS platform where each customer (tenant) has isolated audit data.

Requirements:

  • 1,000 tenants, varying sizes (1-10,000 users each)
  • Tenants can query their own audit logs
  • Some tenants have regulatory requirements, others don't
  • Tenant data isolation is critical

Questions:

  1. How do you ensure tenant isolation?
  2. How do you handle tenants with different retention requirements?
  3. How do you optimize costs for inactive tenants?
Click for hints
  • Partition by tenant for isolation
  • Configuration-driven retention
  • Cold storage for inactive tenants
Click for solution outline

Tenant Isolation:

sql7 lines
-- Row-level security
CREATE POLICY tenant_isolation ON audit_events
    USING (tenant_id = current_setting('app.tenant_id'));

-- Every query automatically filtered
SET app.tenant_id = 'tenant_123';
SELECT * FROM audit_events;  -- Only sees tenant_123 data

Configurable Retention:

pythonTenantAuditConfig · 13 lines
@dataclass
class TenantAuditConfig:
    tenant_id: str
    retention_days: int = 90       # Default
    require_hash_chain: bool = False
    compliance_mode: Optional[str] = None  # "hipaa", "sox", etc.

# Load per-tenant config
config = await get_tenant_config(tenant_id)

if config.compliance_mode == "hipaa":
    config.retention_days = 2190  # 6 years
    config.require_hash_chain = True

Cost Optimization:

pythonmanage_tenant_storage · 10 lines
async def manage_tenant_storage(tenant_id):
    activity = await get_tenant_activity(tenant_id, days=30)
    
    if activity.event_count == 0:
        # Inactive: move to cold storage
        await archive_tenant_to_s3(tenant_id)
        await delete_from_hot_storage(tenant_id)
    elif activity.event_count < 100:
        # Low activity: compress aggressively
        await compress_tenant_data(tenant_id)

Chapter 11: Mock Interview Dialogue#

Scenario: Design Audit System for Banking App#

Transcript
Interviewer

"We're building a mobile banking app. Design the audit logging system."

You

"Great, let me start by understanding the requirements. For banking, we're definitely looking at regulatory compliance—SOX, possibly state banking regulations. Can you confirm the retention requirements?"

Interviewer

"Yes, SOX compliance. 7-year retention. And we need to track every financial transaction and every access to customer data."

You

"Perfect. So we need to audit two categories: financial transactions like transfers and payments, and data access like viewing account details or statements. Let me design a system that handles both.

First, the core principle: audit events must be captured atomically with the business operation. If a transfer succeeds, its audit record must exist. We can't have orphaned transactions.

For the architecture, I'll use the transactional outbox pattern we discussed earlier this week:

DiagramBanking audit architecture
%% title: Banking audit architecture
flowchart TD
  MA@{ icon: "sd:phone", label: "Mobile App" } --> GW@{ icon: "sd:gateway", label: "API Gateway" }
  GW --> BS["Banking Service"]:::core
  BS --> TX["Transaction"]:::service
  BS --> AE["Audit Event"]:::service
  TX & AE -->|"Same DB Transaction"| OT@{ icon: "sd:database", label: "Outbox Table" }
  OT -.->|"async"| K@{ icon: "tech:kafka", label: "Kafka" }
  K --> AC["Audit Consumer"]:::core
  AC --> HS@{ icon: "tech:timescaledb", label: "Hot Store<br/>(TimescaleDB)" }
  AC --> CS@{ icon: "tech:s3", label: "Cold Store<br/>(S3 Parquet)" }
  class MA client
  class GW edge
  class OT,K queue
  class HS,CS store

The key here is that the audit event goes into an outbox table in the same database transaction as the banking operation. If the transaction fails, the audit is rolled back too. If it succeeds, the audit is guaranteed to be published."

Interviewer

"How do you handle the scale? We expect millions of transactions per day."

You

"Good question. Let me break down the write path:

At peak, banking apps typically see most activity between 9 AM and 5 PM. Let's assume 10 million transactions per day with 80% in an 8-hour window. That's roughly 280 transactions per second average, with peaks maybe 3-5x that, so let's design for 1,500 TPS.

For the outbox publisher, I'd use CDC with Debezium rather than polling. It reads from the PostgreSQL WAL with very low latency—events usually reach Kafka quickly after commit (Debezium documents no hard latency guarantee).

Kafka would have 32 partitions, partitioned by customer ID to maintain per-customer ordering. This lets us scale consumers horizontally.

For storage, I'd use TimescaleDB for the hot tier—it's optimized for time-series data with automatic partitioning. Events older than 90 days get archived to S3 in Parquet format, queryable via Athena for investigations."

Interviewer

"What about data that shouldn't be in logs, like account numbers?"

You

"Excellent security concern. I'd implement tokenization for sensitive fields:

python17 lines
audit_event = {
    'actor': {
        'id': 'usr_123',
        'email': tokenize('alice@bank.com')  # Reversible token
    },
    'action': 'transfer.create',
    'resource': {
        'type': 'transfer',
        'id': 'txn_456'
    },
    'details': {
        'from_account': mask('****5678'),  # Last 4 only
        'to_account': mask('****9012'),
        'amount': 1000.00,  # Amount visible (needed for compliance)
        'currency': 'USD'
    }
}

The full account numbers are stored separately in a secure vault with additional access controls. The audit log contains enough to investigate but not enough to compromise customer accounts if the logs were exposed.

Access to the detokenization service is itself audited—so we'd know who looked up the real account numbers."

Interviewer

"How do you prove the logs haven't been tampered with?"

You

"This is critical for SOX compliance. I'd implement hash chaining:

Each event includes the hash of the previous event. To tamper with event #1000, you'd have to recompute the hashes of events #1001 through present. A full rewrite like that still verifies, so the chain alone isn't enough: we periodically checkpoint the latest hash somewhere the attacker can't change, such as WORM storage or a signed checkpoint log. Any rewrite then disagrees with the checkpoint.

Additionally, once events move to cold storage in S3, I'd enable S3 Object Lock in compliance mode. This makes the files write-once-read-many (WORM) for the retention period. No one, not even the root account, can delete them or shorten the retention until it expires.

For extra assurance, those checkpoints can go to an external trusted timestamping service (RFC 3161) or even a public blockchain—though that may be overkill for most banking apps."

Interviewer

"What if the audit system itself goes down?"

You

"The transactional outbox is the safety net. If Kafka is down, events accumulate in the outbox table. When Kafka recovers, the publisher catches up. We might have some latency in the audit pipeline, but no data loss.

If the database itself is down... well, the banking operations can't happen either, so there's nothing to audit. The audit system's availability is tied to the primary database's availability, which should be very high with proper replication.

For the consumer side, any processing failures go to a dead letter queue. We monitor DLQ depth and alert if it grows. Failed events are never dropped—they wait for investigation and replay.

The DLQ also protects against poison pills—malformed events that crash the consumer. We detect these via processing timeouts and quarantine them immediately."

Week 3 Summary: Messaging and Async Processing#

Congratulations! You've completed Week 3. Let's consolidate what you've learned:

Day 1: Queue vs Stream

  • Queues (RabbitMQ): Task distribution, once-consumed
  • Streams (Kafka): Event log, multi-consumer, replay
  • Choose based on consumption pattern, not popularity

Day 2: Transactional Outbox

  • Problem: Dual-write between DB and queue
  • Solution: Write to outbox in same transaction
  • Publisher reads outbox, sends to Kafka
  • Never lose data, never have orphans

Day 3: Backpressure and Flow Control

  • Detection: Lag, depth, latency metrics
  • Response: Shed, limit, scale, spill
  • Critical: Systems must be able to say "slow down"
  • Prevents: Memory exhaustion, cascading failures

Day 4: Dead Letters and Poison Pills

  • Poison pills crash consumers repeatedly
  • DLQ quarantines failed messages
  • Classification: Validation vs transient vs poison
  • Never lose data, even on failure

Day 5: Audit Log System (Today)

  • Combined ALL patterns into complete system
  • Outbox → Kafka → Consumer → Tiered Storage
  • Immutable, tamper-evident, queryable
  • Handles compliance requirements
DiagramWeek 3 complete picture: how they connect
%% title: Week 3 complete picture: how they connect
flowchart TD
  UA@{ icon: "sd:user", label: "User Action" } --> BL["Business Logic + Audit Client"]:::core
  BL -->|"Day 2: Transactional Outbox"| OT@{ icon: "sd:database", label: "Outbox Table" }
  OT -->|"Day 1: Kafka Stream"| KT@{ icon: "tech:kafka", label: "Kafka Topic" }
  KT -->|"Day 3: Backpressure Handling"| AC["Audit Consumer"]:::core
  AC -->|"Success"| ST@{ icon: "sd:database", label: "Storage" }
  AC -->|"Failure (Day 4: DLQ)"| DLQ@{ icon: "sd:queue", label: "Dead Letter Queue" }
  DLQ --> IR["Investigation + Replay"]:::service
  class UA client
  class OT,KT queue
  class ST store
  class DLQ bad

You can now:

  • ✓ Design async systems with guaranteed delivery
  • ✓ Handle failures gracefully without data loss
  • ✓ Scale to high throughput with backpressure
  • ✓ Build compliant audit trails
  • ✓ Operate production messaging systems

Summary#

DAY 5 KEY TAKEAWAYS
CORE CONCEPT
  • Audit log: Who did what to which resource, when, from where
  • Immutable, tamper-evident, queryable
  • Combines all Week 3 patterns into production system
ARCHITECTURE
  • Transactional outbox for guaranteed capture
  • Kafka for reliable transport
  • Tiered storage: hot (days) → warm (months) → cold (years)
  • Hash chaining for tamper evidence
IMPLEMENTATION
  • Audit client writes to outbox in same transaction
  • CDC or polling publishes to Kafka
  • Consumer writes to storage with backpressure handling
  • DLQ for failed events
COMPLIANCE
  • SOX, HIPAA, GDPR have specific requirements
  • Retention periods: years, not days
  • Immutability is non-negotiable
  • Access to logs is itself audited
INTERVIEW TIPS
  • Start with "What are the regulatory requirements?"
  • Emphasize transactional outbox for guaranteed capture
  • Discuss hash chaining for tamper evidence
  • Show tiered storage for cost optimization
DEFAULT CHOICE
  • Transactional outbox + Kafka + TimescaleDB + S3
  • Hash chaining enabled
  • 90-day hot, 1-year warm, 7-year cold

Further Reading#

Official Documentation#

Engineering Blogs#

Compliance Resources#

Books#

  • Book

    "Designing Data-Intensive Applications" by Martin Kleppmann — Chapter 11

  • Book

    "Building Event-Driven Microservices" by Adam Bellemare

  • Book

    "Streaming Systems" by Tyler Akidau, Slava Chernyak and Reuven Lax

Week 3 Complete!#

You've finished the most intensive week so far. Take a moment to appreciate what you've built:

  • A mental model for async system design
  • Patterns for guaranteed message delivery
  • Strategies for handling system overload
  • A complete audit log system

Next Week Preview:

Week 4 — Caching: Beyond "Just Add Redis"

We'll dive into:

  • Cache invalidation (the hardest problem)
  • Thundering herd prevention
  • Multi-tier caching strategies
  • When NOT to cache

The patterns from this week will come up again—particularly around consistency and failure handling. Caching adds another layer of complexity that builds on everything you've learned.

Rest up. You've earned it.

this lesson in shorts · 2

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 05 · Audit Log System
Finish Day 5Stages clear as you read them (0/4 so far). Skipping ahead doesn't count: marking complete unlocks after ~9 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

GoogleBlog post · ~26 min

Transparent Logs for Skeptical Clients

Russ Cox · 2019
What
An explanation of the Merkle-tree logs behind Certificate Transparency and Go's checksum database.
Key idea
Hashing records into a Merkle tree lets anyone check that an entry is in the log and that the log was only appended to, never rewritten.
Why here
The tamper-evidence part of this lesson, beyond a simple hash chain: proofs that are small and quick to verify.
taught here: Tamper-evident logs
AmazonDocs · ~3 min

Validating CloudTrail log file integrity

What
How AWS lets customers prove their CloudTrail audit logs were not altered.
Key idea
Each log file is hashed with SHA-256, and an hourly digest file listing those hashes is signed with RSA, so modification or deletion is detectable.
Why here
A production audit log using the hash-and-sign approach this lesson designs.
taught here: Audit log integrity
PhonePeBlog post · ~11 min

Hoshō: A Database Activity Monitoring Tool

Pratik Anurag, Manisha C et al. · 2026
What
PhonePe's system for auditing human activity on its databases.
Key idea
Database audit logs stream into a pipeline that checks every query against rules, fingerprints events with SHA-256 to dedupe alerts, and keeps records for compliance.
Why here
Shows the consumer side of an audit log from this lesson: collecting, enriching and alerting on who did what, for regulators.
taught here: Audit trail for compliance

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…