?

Day 0148 minPreface+60 XP

Event Ingestion at Scale.

System Design Mastery Series — Analytics Pipeline Week

4 stages22 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#

Last week, we built search systems that help users find information. But how do we know what users are actually searching for? How do we track which results they click? How do we measure if our search is actually helping?

The visibility problem
Your e-commerce platform is live
  • 50,000 users browsing right now
  • Searches happening every second
  • Products being viewed, added to cart, purchased
  • Errors occurring somewhere
  • Performance varying across regions
But you have no idea
  • What's the conversion rate right now?
  • Which products are trending?
  • Where are users dropping off?
  • Is the new feature helping or hurting?
  • Why did revenue drop 20% this morning?

Without events, you're flying blind.

This is why event ingestion matters.

Today, we'll learn to capture every meaningful action in your system — reliably, at scale, without losing data or blocking your application.

ExplainerThe whole lesson: event ingestion at scale

Without events you're flying blind, so every meaningful action becomes an immutable event with a unique event_id, a type named domain.entity.action, a UTC timestamp and context, under a schema that only evolves backward-compatibly (optional fields or defaults). A lightweight collector stamps received_time, validates against the schema registry (invalid events go to a dead letter queue), enriches with geolocation and user agent, and writes asynchronously to Kafka topics by category (orders kept 90 days, payments indefinitely), which Flink, Spark and an S3 lake read and can replay. Writing synchronously to a database makes the API as slow as the database; Kafka buffers spikes, the collector sheds page views past 90% queue capacity but never orders or payments, and at-least-once delivery is made safe by event_id dedup.

Part I · Stage 1 of 4 · 16 min

Part I: Foundations#

Chapter 1: What Is Event Ingestion?#

1.1 The Simple Definition#

Event ingestion is the process of capturing, validating, and storing events from various sources into a system that can process and analyze them.

Events are facts about what happened

An event is an immutable record that something occurred:

json11 lines
{
  "event_id": "evt_abc123",
  "event_type": "product.viewed",
  "timestamp": "2024-01-15T10:30:00Z",
  "user_id": "user_456",
  "data": {
    "product_id": "prod_789",
    "source": "search_results",
    "position": 3
  }
}
Key properties
  • Immutable: Once recorded, never changed
  • Timestamped: When did it happen?
  • Typed: What kind of event?
  • Contextual: Who, what, where, how?
  • Unique: Can be deduplicated

1.2 Why Events, Not Logs?#

Logs vs events

LOGS (unstructured)

text1 line
"2024-01-15 10:30:00 INFO User 456 viewed product 789 from search"

Problems:

  • Parsing is fragile (regex hell)
  • Schema changes break parsers
  • Missing context (what search query?)
  • Hard to aggregate

EVENTS (structured)

json8 lines
{
  "event_type": "product.viewed",
  "user_id": "user_456",
  "product_id": "prod_789",
  "source": "search",
  "search_query": "running shoes",
  "position": 3
}

Benefits:

  • Schema-aware (validation possible)
  • Self-describing
  • Easy to query and aggregate
  • Evolves safely with versioning

1.3 The Ingestion Challenge#

Scale of event ingestion
Stage Volume What it needs
Small startup 10,000 events/day Simple logging to database · Query with SQL
Growing company 10,000,000 events/day Need streaming pipeline · Batch processing for analytics
Large platform 1,000,000,000+ events/day Distributed ingestion · Real-time + batch processing · Multi-region replication · Complex event routing
The challenge isn't just volume — it's:
  • Never losing events (durability)
  • Never blocking producers (availability)
  • Handling spikes (elasticity)
  • Maintaining order (where needed)
  • Enabling replay (reprocessing)

Chapter 2: Event Schema Design#

Your turn · checkpoint 1not answered

Before the schema: what must every event carry?

A phone buffers events while offline, uploads them two days later, and resends one batch after a timeout. Analysts want to group by when things happened. Which fields must every event carry so the pipeline can drop the duplicates, put each event in the right hour, and still read it after the schema changes?

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

One strong answer

  • A client-generated event_id: the resent batch carries the same IDs, so the copies can be dropped. A server timestamp can't do this, because it differs on every send.
  • event_time and received_time: when it happened (set on the device) and when the server got it. Group by the first; the gap between them shows how late the event is. Device clocks can be wrong, so you need both.
  • event_type and event_version: what happened, and which schema version to read it with.
  • Context (user, session, device): enough to make the event useful without joining other data.

2.1 Anatomy of a Good Event#

pythonEventType · 205 lines
# schemas/event_schema.py

"""
Event schema design principles.

A well-designed event is:
- Self-describing (contains its own context)
- Immutable (never modified after creation)
- Uniquely identifiable (for deduplication)
- Timestamped (event time vs processing time)
- Versioned (for schema evolution)
"""

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


class EventType(Enum):
    """Categorized event types."""
    # User actions
    PAGE_VIEWED = "page.viewed"
    PRODUCT_VIEWED = "product.viewed"
    PRODUCT_SEARCHED = "product.searched"
    CART_UPDATED = "cart.updated"
    ORDER_PLACED = "order.placed"
    
    # System events
    ERROR_OCCURRED = "error.occurred"
    PERFORMANCE_MEASURED = "performance.measured"
    
    # Business events
    PAYMENT_PROCESSED = "payment.processed"
    SHIPMENT_UPDATED = "shipment.updated"


@dataclass
class EventMetadata:
    """
    Standard metadata for all events.
    
    This is the "envelope" that wraps every event.
    """
    # Identity
    event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    event_type: str = ""
    event_version: str = "1.0"
    
    # Timing
    event_time: datetime = field(default_factory=lambda: datetime.now(timezone.utc))  # When it happened
    received_time: Optional[datetime] = None  # When server received it
    
    # Source
    source_service: str = ""  # Which service emitted this
    source_instance: str = ""  # Which instance
    
    # Correlation
    correlation_id: Optional[str] = None  # For tracing across services
    causation_id: Optional[str] = None  # What event caused this
    
    # User context
    user_id: Optional[str] = None
    session_id: Optional[str] = None
    device_id: Optional[str] = None


@dataclass
class DeviceContext:
    """Device and client information."""
    platform: str = ""  # web, ios, android
    app_version: str = ""
    os_name: str = ""
    os_version: str = ""
    browser: str = ""
    screen_resolution: str = ""
    timezone: str = ""
    language: str = ""


@dataclass
class LocationContext:
    """Geographic context (if available)."""
    country: str = ""
    region: str = ""
    city: str = ""
    ip_address: str = ""  # Often hashed for privacy


@dataclass
class BaseEvent:
    """
    Base class for all events.
    
    Every event has:
    1. Metadata (envelope)
    2. Device context
    3. Location context
    4. Event-specific payload
    """
    metadata: EventMetadata
    device: DeviceContext
    location: LocationContext
    payload: Dict[str, Any] = field(default_factory=dict)
    
    def to_dict(self) -> Dict[str, Any]:
        """Serialize event to dictionary."""
        return {
            "event_id": self.metadata.event_id,
            "event_type": self.metadata.event_type,
            "event_version": self.metadata.event_version,
            "event_time": self.metadata.event_time.isoformat(),
            "received_time": (
                self.metadata.received_time.isoformat()
                if self.metadata.received_time else None
            ),
            "source": {
                "service": self.metadata.source_service,
                "instance": self.metadata.source_instance
            },
            "correlation": {
                "correlation_id": self.metadata.correlation_id,
                "causation_id": self.metadata.causation_id
            },
            "user": {
                "user_id": self.metadata.user_id,
                "session_id": self.metadata.session_id,
                "device_id": self.metadata.device_id
            },
            "device": {
                "platform": self.device.platform,
                "app_version": self.device.app_version,
                "os": f"{self.device.os_name} {self.device.os_version}",
                "browser": self.device.browser
            },
            "location": {
                "country": self.location.country,
                "city": self.location.city
            },
            "payload": self.payload
        }


# =============================================================================
# Specific Event Types
# =============================================================================

@dataclass
class ProductViewedEvent(BaseEvent):
    """User viewed a product page."""
    
    def __post_init__(self):
        self.metadata.event_type = EventType.PRODUCT_VIEWED.value
        # Validate required payload fields
        required = ["product_id"]
        for field in required:
            if field not in self.payload:
                raise ValueError(f"Missing required field: {field}")


@dataclass  
class OrderPlacedEvent(BaseEvent):
    """User placed an order."""
    
    def __post_init__(self):
        self.metadata.event_type = EventType.ORDER_PLACED.value
        required = ["order_id", "total_amount", "currency", "items"]
        for field in required:
            if field not in self.payload:
                raise ValueError(f"Missing required field: {field}")


# =============================================================================
# Example Usage
# =============================================================================

def create_product_viewed_event(
    user_id: str,
    product_id: str,
    source: str,
    position: int = None
) -> ProductViewedEvent:
    """Factory function to create product viewed event."""
    
    return ProductViewedEvent(
        metadata=EventMetadata(
            user_id=user_id,
            source_service="web-frontend"
        ),
        device=DeviceContext(
            platform="web",
            browser="Chrome 120"
        ),
        location=LocationContext(
            country="US",
            city="San Francisco"
        ),
        payload={
            "product_id": product_id,
            "source": source,
            "position": position,
            "referrer": "search_results"
        }
    )
Your turn · checkpoint 2not answered

Four schema changes: which ones are safe?

Someone proposes four changes to product.viewed: add an optional source field, remove product_id, change position from an integer to a string, and rename user_id to customer_id. Rank them from safe to most dangerous, and say what each one breaks.

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

One strong answer

  • Add an optional field (safe): old events simply lack it, and readers fall back to a default.
  • Remove a field (dangerous): any consumer that expects product_id breaks on new events.
  • Change a type (dangerous): parsers and aggregations built for integers fail on strings.
  • Rename (very dangerous): it is a removal and an addition at once, so readers of the old name and readers of the new name both break. Add the new field as optional instead, and keep the old one.

2.2 Schema Evolution#

pythonSchemaCompatibility · 164 lines
# schemas/schema_evolution.py

"""
Schema evolution strategies.

Events are immutable, but schemas evolve. We need to handle:
1. Adding new fields (safe)
2. Removing fields (dangerous)
3. Changing field types (dangerous)
4. Renaming fields (very dangerous)
"""

from dataclasses import dataclass
from typing import Dict, Any, Optional
from enum import Enum


class SchemaCompatibility(Enum):
    """Schema compatibility modes."""
    BACKWARD = "backward"  # New schema can read old data
    FORWARD = "forward"    # Old schema can read new data
    FULL = "full"          # Both directions
    NONE = "none"          # No compatibility guaranteed


@dataclass
class SchemaVersion:
    """Tracks schema versions."""
    event_type: str
    version: str
    schema: Dict[str, Any]
    compatibility: SchemaCompatibility


class SchemaRegistry:
    """
    Manages event schema versions.
    
    In production, use:
    - Confluent Schema Registry (with Kafka)
    - AWS Glue Schema Registry
    - Custom implementation
    """
    
    def __init__(self):
        self.schemas: Dict[str, Dict[str, SchemaVersion]] = {}
    
    def register_schema(
        self,
        event_type: str,
        version: str,
        schema: Dict[str, Any],
        compatibility: SchemaCompatibility = SchemaCompatibility.BACKWARD
    ) -> SchemaVersion:
        """Register a new schema version."""
        
        if event_type not in self.schemas:
            self.schemas[event_type] = {}
        
        # Check compatibility with previous version
        if self.schemas[event_type]:
            latest = self._get_latest_version(event_type)
            if not self._check_compatibility(latest.schema, schema, compatibility):
                raise SchemaIncompatibleError(
                    f"Schema {version} is not {compatibility.value} compatible "
                    f"with {latest.version}"
                )
        
        schema_version = SchemaVersion(
            event_type=event_type,
            version=version,
            schema=schema,
            compatibility=compatibility
        )
        
        self.schemas[event_type][version] = schema_version
        return schema_version
    
    def _get_latest_version(self, event_type: str) -> SchemaVersion:
        """Get the latest registered schema version."""
        versions = sorted(self.schemas[event_type].keys())
        return self.schemas[event_type][versions[-1]]
    
    def _check_compatibility(
        self,
        old_schema: Dict,
        new_schema: Dict,
        mode: SchemaCompatibility
    ) -> bool:
        """Check if schemas are compatible."""
        
        old_fields = set(old_schema.get("required", []))
        new_fields = set(new_schema.get("required", []))
        
        if mode == SchemaCompatibility.BACKWARD:
            # New schema can read old data
            # New required fields are NOT allowed
            new_required = new_fields - old_fields
            return len(new_required) == 0
        
        elif mode == SchemaCompatibility.FORWARD:
            # Old schema can read new data
            # Removing required fields is NOT allowed
            removed_required = old_fields - new_fields
            return len(removed_required) == 0
        
        elif mode == SchemaCompatibility.FULL:
            # Both directions
            return old_fields == new_fields
        
        return True  # NONE compatibility


# =============================================================================
# Schema Evolution Examples
# =============================================================================

SCHEMA_V1 = {
    "type": "object",
    "required": ["event_id", "event_type", "user_id", "product_id"],
    "properties": {
        "event_id": {"type": "string"},
        "event_type": {"type": "string"},
        "user_id": {"type": "string"},
        "product_id": {"type": "string"},
        "timestamp": {"type": "string", "format": "date-time"}
    }
}

# SAFE: Adding optional field
SCHEMA_V2 = {
    "type": "object",
    "required": ["event_id", "event_type", "user_id", "product_id"],
    "properties": {
        "event_id": {"type": "string"},
        "event_type": {"type": "string"},
        "user_id": {"type": "string"},
        "product_id": {"type": "string"},
        "timestamp": {"type": "string", "format": "date-time"},
        "source": {"type": "string"}  # NEW: optional field
    }
}

# DANGEROUS: Adding required field (breaks backward compatibility)
SCHEMA_V3_BAD = {
    "type": "object",
    "required": ["event_id", "event_type", "user_id", "product_id", "session_id"],
    "properties": {
        # ... session_id is now required, old events don't have it
    }
}

# SAFE WAY: Add with default
SCHEMA_V3_GOOD = {
    "type": "object",
    "required": ["event_id", "event_type", "user_id", "product_id"],
    "properties": {
        "event_id": {"type": "string"},
        "event_type": {"type": "string"},
        "user_id": {"type": "string"},
        "product_id": {"type": "string"},
        "session_id": {"type": "string", "default": "unknown"}  # Optional with default
    }
}

2.3 Event Naming Conventions#

Good event names

Good event names are:

  • Hierarchical (domain.entity.action)
  • Past tense (something happened)
  • Specific but not too granular
Naming convention: domain.entity.action — examples
  • User events
    • user.signed_up
    • user.logged_in
    • user.logged_out
    • user.password_changed
    • user.profile_updated
  • Product events
    • product.viewed
    • product.searched
    • product.added_to_cart
    • product.removed_from_cart
    • product.wishlisted
  • Order events
    • order.placed
    • order.confirmed
    • order.shipped
    • order.delivered
    • order.cancelled
    • order.refunded
  • Payment events
    • payment.initiated
    • payment.succeeded
    • payment.failed
    • payment.refunded
  • System events
    • system.error_occurred
    • system.performance_measured
    • system.health_checked

Chapter 3: Ingestion Architecture#

3.1 The Ingestion Pipeline#

DiagramEvent ingestion pipeline
%% title: Event ingestion pipeline
flowchart TD
  subgraph SRC["Data sources"]
    direction TB
    WB@{ icon: "sd:browser", label: "Web Browser" }
    MA@{ icon: "sd:phone", label: "Mobile Apps" }
    BS@{ icon: "sd:server", label: "Backend Services" }
    IOT@{ icon: "sd:cpu", label: "IoT Devices" }
    TP@{ icon: "sd:cloud", label: "Third Party" }
  end
  subgraph COLL["Collection layer"]
    subgraph API["Event Collection API"]
      direction TB
      E1["/events (single)"]:::edge
      E2["/batch (bulk)"]:::edge
      E3["/health (status)"]:::edge
    end
    VL["Validation Layer<br/>schema validation · required fields check<br/>type coercion · size limits<br/>timestamp normalization · deduplication check"]:::core
    API --> VL
    VL --> VE(["Valid Events"]):::ok
    VL --> IE(["Invalid Events"]):::bad
  end
  SRC --> API
  subgraph KAF["Streaming layer (Kafka)"]
    RAW[["raw_events<br/>(all events)"]]:::queue
    VAL[["validated_events<br/>(clean data)"]]:::queue
    DLQ[["dead_letter_events<br/>(failures)"]]:::queue
  end
  VE --> VAL
  IE --> DLQ
  subgraph PROC["Processing layer"]
    direction TB
    RT@{ icon: "tech:flink", label: "Real-time Processor (Flink)" }
    BP@{ icon: "tech:spark", label: "Batch Processor (Spark)" }
    RL@{ icon: "tech:s3", label: "Raw Lake Storage (S3)" }
  end
  KAF --> PROC
  class WB,MA,IOT client
  class BS,RT,BP service
  class TP external
  class RL store
ExplainerEvent collection: validate, route, dead-letter

A batch of 500 events is validated event by event: the 497 valid ones go to Kafka in one batch, the 3 invalid ones to events.dead_letter, and the response reports both. A retried batch carries the same client-generated event_ids, so its duplicates are dropped; keying by user_id keeps one user's events in one partition, in order.

3.2 Collection API Design#

pythonEventRequest · 328 lines
# ingestion/collection_api.py

"""
Event Collection API.

High-performance API for receiving events from clients.
"""

from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from datetime import datetime, timezone
import asyncio
import logging

logger = logging.getLogger(__name__)

app = FastAPI(title="Event Collection API")


# =============================================================================
# Request/Response Models
# =============================================================================

class EventRequest(BaseModel):
    """Single event submission."""
    event_id: str = Field(..., description="Unique event identifier")
    event_type: str = Field(..., description="Event type (e.g., product.viewed)")
    event_time: datetime = Field(..., description="When the event occurred")
    user_id: Optional[str] = None
    session_id: Optional[str] = None
    payload: Dict[str, Any] = Field(default_factory=dict)
    
    class Config:
        json_schema_extra = {
            "example": {
                "event_id": "evt_abc123",
                "event_type": "product.viewed",
                "event_time": "2024-01-15T10:30:00Z",
                "user_id": "user_456",
                "payload": {
                    "product_id": "prod_789",
                    "source": "search"
                }
            }
        }


class BatchEventRequest(BaseModel):
    """Batch event submission."""
    events: List[EventRequest] = Field(..., max_length=1000)


class EventResponse(BaseModel):
    """Response for event submission."""
    status: str
    event_id: str
    received_at: datetime


class BatchEventResponse(BaseModel):
    """Response for batch submission."""
    status: str
    accepted: int
    rejected: int
    errors: List[Dict[str, str]] = []


# =============================================================================
# Collection Service
# =============================================================================

class EventCollector:
    """
    Collects and forwards events to Kafka.
    """
    
    def __init__(self, kafka_producer, validator, enricher):
        self.producer = kafka_producer
        self.validator = validator
        self.enricher = enricher
        
        # Metrics
        self.events_received = 0
        self.events_accepted = 0
        self.events_rejected = 0
    
    async def collect_event(
        self,
        event: EventRequest,
        request_context: Dict[str, Any]
    ) -> EventResponse:
        """
        Process a single event.
        
        Steps:
        1. Add server-side metadata
        2. Validate against schema
        3. Enrich with additional context
        4. Send to Kafka
        """
        
        self.events_received += 1
        received_at = datetime.now(timezone.utc)
        
        # Build full event (mode="json" turns datetimes into ISO strings)
        full_event = {
            **event.model_dump(mode="json"),
            "received_time": received_at.isoformat(),
            "server_context": {
                "collector_instance": request_context.get("instance_id"),
                "client_ip": request_context.get("client_ip"),
                "user_agent": request_context.get("user_agent")
            }
        }
        
        # Validate
        validation_result = await self.validator.validate(full_event)
        if not validation_result.is_valid:
            self.events_rejected += 1
            # Send to dead letter queue
            await self._send_to_dlq(full_event, validation_result.errors)
            raise HTTPException(
                status_code=400,
                detail={
                    "error": "Validation failed",
                    "details": validation_result.errors
                }
            )
        
        # Enrich
        enriched_event = await self.enricher.enrich(full_event)
        
        # Send to Kafka
        await self._send_to_kafka(enriched_event)
        
        self.events_accepted += 1
        
        return EventResponse(
            status="accepted",
            event_id=event.event_id,
            received_at=received_at
        )
    
    async def collect_batch(
        self,
        batch: BatchEventRequest,
        request_context: Dict[str, Any]
    ) -> BatchEventResponse:
        """
        Process a batch of events.
        
        For efficiency, we:
        1. Validate all events first
        2. Send valid events in a single Kafka batch
        3. Send invalid events to DLQ
        """
        
        received_at = datetime.now(timezone.utc)
        accepted = []
        rejected = []
        errors = []
        
        for event in batch.events:
            full_event = {
                **event.model_dump(mode="json"),
                "received_time": received_at.isoformat(),
                "server_context": {
                    "collector_instance": request_context.get("instance_id"),
                    "client_ip": request_context.get("client_ip")
                }
            }
            
            validation_result = await self.validator.validate(full_event)
            
            if validation_result.is_valid:
                enriched = await self.enricher.enrich(full_event)
                accepted.append(enriched)
            else:
                rejected.append(full_event)
                errors.append({
                    "event_id": event.event_id,
                    "error": validation_result.errors[0]
                })
        
        # Batch send to Kafka
        if accepted:
            await self._send_batch_to_kafka(accepted)
        
        # Batch send to DLQ
        if rejected:
            await self._send_batch_to_dlq(rejected)
        
        self.events_received += len(batch.events)
        self.events_accepted += len(accepted)
        self.events_rejected += len(rejected)
        
        return BatchEventResponse(
            status="processed",
            accepted=len(accepted),
            rejected=len(rejected),
            errors=errors[:10]  # Limit error details
        )
    
    async def _send_to_kafka(self, event: Dict):
        """Send single event to Kafka."""
        topic = self._get_topic(event["event_type"])
        key = event.get("user_id") or event["event_id"]
        
        await self.producer.send(
            topic=topic,
            key=key,
            value=event
        )
    
    async def _send_batch_to_kafka(self, events: List[Dict]):
        """Send batch of events to Kafka."""
        
        # Group by topic
        by_topic = {}
        for event in events:
            topic = self._get_topic(event["event_type"])
            if topic not in by_topic:
                by_topic[topic] = []
            by_topic[topic].append(event)
        
        # Send each topic batch
        for topic, topic_events in by_topic.items():
            await self.producer.send_batch(topic, topic_events)
    
    async def _send_to_dlq(self, event: Dict, errors: List[str]):
        """Send failed event to dead letter queue."""
        await self.producer.send(
            topic="events.dead_letter",
            value={
                "original_event": event,
                "errors": errors,
                "failed_at": datetime.now(timezone.utc).isoformat()
            }
        )
    
    async def _send_batch_to_dlq(self, events: List[Dict]):
        """Send batch of failed events to DLQ."""
        for event in events:
            await self._send_to_dlq(event, ["Batch validation failed"])
    
    def _get_topic(self, event_type: str) -> str:
        """Route event to appropriate Kafka topic."""
        
        # Route by event category
        if event_type.startswith("user."):
            return "events.user"
        elif event_type.startswith("product."):
            return "events.product"
        elif event_type.startswith("order."):
            return "events.order"
        elif event_type.startswith("payment."):
            return "events.payment"
        else:
            return "events.general"


# =============================================================================
# API Endpoints
# =============================================================================

# Global collector instance (initialized on startup)
collector: EventCollector = None


@app.post("/v1/events", response_model=EventResponse)
async def collect_single_event(
    event: EventRequest,
    request: Request,
    background_tasks: BackgroundTasks
):
    """
    Collect a single event.
    
    Use this for real-time event tracking where immediate
    confirmation is needed.
    """
    
    context = {
        "instance_id": "collector-1",
        "client_ip": request.client.host,
        "user_agent": request.headers.get("user-agent")
    }
    
    return await collector.collect_event(event, context)


@app.post("/v1/events/batch", response_model=BatchEventResponse)
async def collect_batch_events(
    batch: BatchEventRequest,
    request: Request
):
    """
    Collect a batch of events.
    
    Use this for:
    - Mobile apps sending buffered events
    - Backend services with high event volume
    - Periodic sync of offline events
    
    Maximum 1000 events per batch.
    """
    
    context = {
        "instance_id": "collector-1",
        "client_ip": request.client.host
    }
    
    return await collector.collect_batch(batch, context)


@app.get("/health")
async def health_check():
    """Health check endpoint."""
    return {
        "status": "healthy",
        "metrics": {
            "events_received": collector.events_received,
            "events_accepted": collector.events_accepted,
            "events_rejected": collector.events_rejected
        }
    }
Quick check3 questions · +5 XP each

Event design and ingestion architecture

  1. Q1/3 Your registry enforces BACKWARD compatibility. A teammate registers a new schema version that makes session_id a required field. What happens?

    Answer & explanation

    Answer: C

    Under BACKWARD, the new schema must still read old data, and old events have no session_id, so a new required field breaks that. Adding fields is only safe when they are optional with a default, which is exactly the SCHEMA_V3_GOOD fix.

  2. Q2/3 A mobile SDK hits a network timeout and resends a batch the server had in fact already received. What lets the pipeline avoid counting those events twice?

    Answer & explanation

    Answer: A

    Each event carries a unique ID created on the client, so a retried copy has the same ID and can be dropped. received_time is different for each send, so it makes duplicates look like distinct events rather than revealing them.

  3. Q3/3 A client posts 500 events to /v1/events/batch and 3 of them fail validation. How does the collection API respond?

    Answer & explanation

    Answer: D

    The batch path validates per event: valid ones are enriched and sent in one Kafka batch, invalid ones go to the DLQ, and the response lists accepted, rejected and errors. Failing the whole batch would make one bad event cost 497 good ones.

Part II · Stage 2 of 4 · 24 min

Part II: Implementation#

Chapter 4: Validation and Enrichment#

4.1 Event Validation#

pythonValidationResult · 277 lines
# ingestion/validation.py

"""
Event validation layer.

Validates events against schemas and business rules.
"""

from dataclasses import dataclass
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta, timezone
import re
import json


@dataclass
class ValidationResult:
    """Result of event validation."""
    is_valid: bool
    errors: List[str]
    warnings: List[str]


class EventValidator:
    """
    Validates events before ingestion.
    
    Validation layers:
    1. Schema validation (structure)
    2. Type validation (field types)
    3. Business rules (logical constraints)
    4. Security validation (injection, size)
    """
    
    def __init__(self, schema_registry):
        self.schema_registry = schema_registry
        
        # Limits
        self.max_event_size_bytes = 1024 * 100  # 100KB
        self.max_string_length = 10000
        self.max_array_length = 1000
        self.max_payload_depth = 10
        
        # Time bounds
        self.max_future_seconds = 300  # 5 minutes
        self.max_past_days = 30  # 30 days old
    
    async def validate(self, event: Dict[str, Any]) -> ValidationResult:
        """
        Validate an event through all validation layers.
        """
        
        errors = []
        warnings = []
        
        # Layer 1: Size validation
        size_errors = self._validate_size(event)
        errors.extend(size_errors)
        
        if errors:
            return ValidationResult(False, errors, warnings)
        
        # Layer 2: Required fields
        required_errors = self._validate_required_fields(event)
        errors.extend(required_errors)
        
        if errors:
            return ValidationResult(False, errors, warnings)
        
        # Layer 3: Schema validation
        schema_errors = self._validate_schema(event)
        errors.extend(schema_errors)
        
        # Layer 4: Timestamp validation
        time_errors, time_warnings = self._validate_timestamp(event)
        errors.extend(time_errors)
        warnings.extend(time_warnings)
        
        # Layer 5: Business rules
        business_errors = self._validate_business_rules(event)
        errors.extend(business_errors)
        
        # Layer 6: Security validation
        security_errors = self._validate_security(event)
        errors.extend(security_errors)
        
        return ValidationResult(
            is_valid=len(errors) == 0,
            errors=errors,
            warnings=warnings
        )
    
    def _validate_size(self, event: Dict) -> List[str]:
        """Validate event size limits."""
        
        errors = []
        
        # Total size
        event_json = json.dumps(event)
        if len(event_json) > self.max_event_size_bytes:
            errors.append(
                f"Event size {len(event_json)} exceeds maximum "
                f"{self.max_event_size_bytes} bytes"
            )
        
        return errors
    
    def _validate_required_fields(self, event: Dict) -> List[str]:
        """Validate required fields are present."""
        
        errors = []
        required = ["event_id", "event_type", "event_time"]
        
        for field in required:
            if field not in event or event[field] is None:
                errors.append(f"Missing required field: {field}")
        
        return errors
    
    def _validate_schema(self, event: Dict) -> List[str]:
        """Validate against registered schema."""
        
        errors = []
        event_type = event.get("event_type")
        
        if not event_type:
            return errors  # Already caught in required fields
        
        # Get schema for this event type
        schema = self.schema_registry.get_schema(event_type)
        
        if not schema:
            # Unknown event type - warning but allow
            return []
        
        # Validate payload against schema
        payload = event.get("payload", {})
        
        for field, field_schema in schema.get("properties", {}).items():
            if field in schema.get("required", []):
                if field not in payload:
                    errors.append(f"Missing required payload field: {field}")
            
            if field in payload:
                field_errors = self._validate_field_type(
                    field, payload[field], field_schema
                )
                errors.extend(field_errors)
        
        return errors
    
    def _validate_field_type(
        self,
        field_name: str,
        value: Any,
        schema: Dict
    ) -> List[str]:
        """Validate a single field against its schema."""
        
        errors = []
        expected_type = schema.get("type")
        
        type_mapping = {
            "string": str,
            "integer": int,
            "number": (int, float),
            "boolean": bool,
            "array": list,
            "object": dict
        }
        
        if expected_type in type_mapping:
            expected = type_mapping[expected_type]
            if not isinstance(value, expected):
                errors.append(
                    f"Field '{field_name}' expected {expected_type}, "
                    f"got {type(value).__name__}"
                )
        
        return errors
    
    def _validate_timestamp(self, event: Dict) -> tuple:
        """Validate event timestamp is reasonable."""
        
        errors = []
        warnings = []
        
        event_time_str = event.get("event_time")
        if not event_time_str:
            return errors, warnings
        
        try:
            if isinstance(event_time_str, str):
                event_time = datetime.fromisoformat(
                    event_time_str.replace("Z", "+00:00")
                )
            else:
                event_time = event_time_str
            
            # Normalize to timezone-aware UTC (convert offsets; treat naive as UTC)
            if event_time.tzinfo is not None:
                event_time = event_time.astimezone(timezone.utc)
            else:
                event_time = event_time.replace(tzinfo=timezone.utc)
            
            now = datetime.now(timezone.utc)
            
            # Check future timestamps
            max_future = now + timedelta(seconds=self.max_future_seconds)
            if event_time > max_future:
                errors.append(
                    f"Event time {event_time} is too far in the future"
                )
            
            # Check old timestamps
            max_past = now - timedelta(days=self.max_past_days)
            if event_time < max_past:
                warnings.append(
                    f"Event time {event_time} is more than "
                    f"{self.max_past_days} days old"
                )
        
        except Exception as e:
            errors.append(f"Invalid event_time format: {e}")
        
        return errors, warnings
    
    def _validate_business_rules(self, event: Dict) -> List[str]:
        """Validate business-specific rules."""
        
        errors = []
        event_type = event.get("event_type")
        payload = event.get("payload", {})
        
        # Example: Order events must have positive amounts
        if event_type == "order.placed":
            amount = payload.get("total_amount", 0)
            if amount <= 0:
                errors.append("Order total_amount must be positive")
        
        # Example: Product views must have product_id
        if event_type == "product.viewed":
            if not payload.get("product_id"):
                errors.append("product.viewed requires product_id")
        
        return errors
    
    def _validate_security(self, event: Dict) -> List[str]:
        """Validate for security concerns."""
        
        errors = []
        
        # Check for potential injection in string fields
        dangerous_patterns = [
            r"<script",
            r"javascript:",
            r"data:text/html",
            r"on\w+\s*=",  # onclick, onerror, etc.
        ]
        
        def check_strings(obj, path=""):
            if isinstance(obj, str):
                for pattern in dangerous_patterns:
                    if re.search(pattern, obj, re.IGNORECASE):
                        errors.append(
                            f"Potentially dangerous content in {path}"
                        )
            elif isinstance(obj, dict):
                for key, value in obj.items():
                    check_strings(value, f"{path}.{key}")
            elif isinstance(obj, list):
                for i, item in enumerate(obj):
                    check_strings(item, f"{path}[{i}]")
        
        check_strings(event)
        
        return errors

4.2 Event Enrichment#

pythonGeoLocation · 204 lines
# ingestion/enrichment.py

"""
Event enrichment layer.

Adds additional context to events before storage.
"""

from dataclasses import dataclass
from typing import Dict, Any, Optional
from datetime import datetime
import hashlib
import ipaddress


@dataclass
class GeoLocation:
    """Geographic location data."""
    country: str
    country_code: str
    region: str
    city: str
    latitude: float
    longitude: float
    timezone: str


class EventEnricher:
    """
    Enriches events with additional context.
    
    Enrichment types:
    1. IP geolocation
    2. User agent parsing
    3. User profile data
    4. Product catalog data
    5. Session reconstruction
    6. Derived fields
    """
    
    def __init__(
        self,
        geo_service,
        user_service,
        product_service,
        session_service
    ):
        self.geo = geo_service
        self.users = user_service
        self.products = product_service
        self.sessions = session_service
    
    async def enrich(self, event: Dict[str, Any]) -> Dict[str, Any]:
        """
        Enrich an event with additional context.
        """
        
        enriched = {**event}
        enriched["enrichment"] = {}
        
        # Enrich with geolocation
        if ip := event.get("server_context", {}).get("client_ip"):
            geo = await self._enrich_geo(ip)
            if geo:
                enriched["enrichment"]["geo"] = geo
        
        # Enrich with user data
        if user_id := event.get("user_id"):
            user_data = await self._enrich_user(user_id)
            if user_data:
                enriched["enrichment"]["user"] = user_data
        
        # Enrich with product data
        if product_id := event.get("payload", {}).get("product_id"):
            product_data = await self._enrich_product(product_id)
            if product_data:
                enriched["enrichment"]["product"] = product_data
        
        # Enrich with session data
        if session_id := event.get("session_id"):
            session_data = await self._enrich_session(session_id)
            if session_data:
                enriched["enrichment"]["session"] = session_data
        
        # Add derived fields
        enriched["derived"] = self._derive_fields(enriched)
        
        return enriched
    
    async def _enrich_geo(self, ip: str) -> Optional[Dict]:
        """Look up geolocation from IP."""
        
        try:
            # Skip private/loopback IPs (172.16-31.x only, not all 172.x)
            addr = ipaddress.ip_address(ip)
            if addr.is_private or addr.is_loopback:
                return None
            
            location = await self.geo.lookup(ip)
            
            if location:
                return {
                    "country": location.country,
                    "country_code": location.country_code,
                    "region": location.region,
                    "city": location.city,
                    "timezone": location.timezone
                }
        except Exception:
            pass
        
        return None
    
    async def _enrich_user(self, user_id: str) -> Optional[Dict]:
        """Look up user profile data."""
        
        try:
            user = await self.users.get_profile(user_id)
            
            if user:
                return {
                    "segment": user.segment,
                    "tier": user.tier,
                    "signup_date": user.signup_date.isoformat(),
                    "lifetime_value": user.ltv,
                    "is_subscriber": user.is_subscriber
                }
        except Exception:
            pass
        
        return None
    
    async def _enrich_product(self, product_id: str) -> Optional[Dict]:
        """Look up product catalog data."""
        
        try:
            product = await self.products.get_product(product_id)
            
            if product:
                return {
                    "category": product.category,
                    "subcategory": product.subcategory,
                    "brand": product.brand,
                    "price": product.price,
                    "is_on_sale": product.is_on_sale
                }
        except Exception:
            pass
        
        return None
    
    async def _enrich_session(self, session_id: str) -> Optional[Dict]:
        """Look up session data."""
        
        try:
            session = await self.sessions.get_session(session_id)
            
            if session:
                return {
                    "session_start": session.start_time.isoformat(),
                    "page_views": session.page_view_count,
                    "is_new_session": session.is_first_session,
                    "entry_page": session.entry_page,
                    "traffic_source": session.traffic_source
                }
        except Exception:
            pass
        
        return None
    
    def _derive_fields(self, event: Dict) -> Dict:
        """Derive additional fields from event data."""
        
        derived = {}
        
        # Derive time dimensions
        event_time = event.get("event_time")
        if event_time:
            if isinstance(event_time, str):
                dt = datetime.fromisoformat(event_time.replace("Z", "+00:00"))
            else:
                dt = event_time
            
            derived["time_dimensions"] = {
                "year": dt.year,
                "month": dt.month,
                "day": dt.day,
                "hour": dt.hour,
                "day_of_week": dt.weekday(),
                "is_weekend": dt.weekday() >= 5,
                "quarter": (dt.month - 1) // 3 + 1
            }
        
        # Derive event category
        event_type = event.get("event_type", "")
        derived["event_category"] = event_type.split(".")[0] if "." in event_type else "unknown"
        
        # Hash sensitive fields for analytics
        if user_id := event.get("user_id"):
            derived["user_id_hash"] = hashlib.sha256(
                user_id.encode()
            ).hexdigest()[:16]
        
        return derived
Quick check2 questions · +5 XP each

Validation and enrichment

  1. Q1/2 A user was offline for weeks, and their phone now uploads events whose event_time is 40 days ago. What does this validator do with them?

    Answer & explanation

    Answer: B

    Old timestamps only produce a warning because real offline usage causes them, so dropping them would lose genuine data. Timestamps more than 5 minutes in the future are the hard error, since those point to a broken clock or bad data.

  2. Q2/2 During a spike the user-profile service starts timing out. What happens to incoming events in this enricher?

    Answer & explanation

    Answer: C

    Every enrichment lookup is wrapped in try/except and returns None on failure, so enrichment is best-effort. Making ingestion depend on a downstream lookup would turn one slow service into lost or blocked events, which goes against "never block producers".

Chapter 5: Kafka as the Event Backbone#

Your turn · checkpoint 3not answered

Design the topics before you read them

You are laying out Kafka for this pipeline. Do all events go into one topic or several? Which key do producers use? And should payment events be kept as long as product views?

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

One strong answer

  • One topic per category (user, product, order, payment): each can be scaled, consumed and retained independently.
  • Key by user_id: Kafka keeps order only within a partition, so one key per user keeps each user's journey in order.
  • Retention follows reprocessing needs: raw events for 7 days, user and product events for 30, orders for 90, payments indefinitely. Financial events are the ones you may have to replay or audit years later.
  • A dead letter topic holds failed events for 30 days, so they can be investigated and replayed.

5.1 Kafka Topic Design#

pythonCleanupPolicy · 163 lines
# ingestion/kafka_config.py

"""
Kafka configuration for event ingestion.

Topic design principles:
1. Partition by user_id for user-centric ordering
2. Separate topics by event category for independent scaling
3. Retention based on reprocessing needs
4. Compaction for lookup topics
"""

from dataclasses import dataclass
from typing import Dict, List
from enum import Enum


class CleanupPolicy(Enum):
    DELETE = "delete"
    COMPACT = "compact"
    COMPACT_DELETE = "compact,delete"


@dataclass
class TopicConfig:
    """Kafka topic configuration."""
    name: str
    partitions: int
    replication_factor: int
    retention_ms: int  # -1 for infinite
    cleanup_policy: CleanupPolicy
    min_insync_replicas: int = 2
    
    def to_dict(self) -> Dict:
        return {
            "num.partitions": self.partitions,
            "replication.factor": self.replication_factor,
            "retention.ms": self.retention_ms,
            "cleanup.policy": self.cleanup_policy.value,
            "min.insync.replicas": self.min_insync_replicas
        }


# =============================================================================
# Topic Definitions
# =============================================================================

RETENTION_7_DAYS = 7 * 24 * 60 * 60 * 1000
RETENTION_30_DAYS = 30 * 24 * 60 * 60 * 1000
RETENTION_90_DAYS = 90 * 24 * 60 * 60 * 1000
RETENTION_INFINITE = -1

TOPICS = {
    # Raw events (all events, short retention)
    "events.raw": TopicConfig(
        name="events.raw",
        partitions=64,
        replication_factor=3,
        retention_ms=RETENTION_7_DAYS,
        cleanup_policy=CleanupPolicy.DELETE
    ),
    
    # User events (user actions, longer retention)
    "events.user": TopicConfig(
        name="events.user",
        partitions=32,
        replication_factor=3,
        retention_ms=RETENTION_30_DAYS,
        cleanup_policy=CleanupPolicy.DELETE
    ),
    
    # Product events (views, searches, carts)
    "events.product": TopicConfig(
        name="events.product",
        partitions=32,
        replication_factor=3,
        retention_ms=RETENTION_30_DAYS,
        cleanup_policy=CleanupPolicy.DELETE
    ),
    
    # Order events (transactions, keep longer)
    "events.order": TopicConfig(
        name="events.order",
        partitions=16,
        replication_factor=3,
        retention_ms=RETENTION_90_DAYS,
        cleanup_policy=CleanupPolicy.DELETE
    ),
    
    # Payment events (financial, keep indefinitely)
    "events.payment": TopicConfig(
        name="events.payment",
        partitions=16,
        replication_factor=3,
        retention_ms=RETENTION_INFINITE,
        cleanup_policy=CleanupPolicy.DELETE
    ),
    
    # Dead letter queue (failed events)
    "events.dead_letter": TopicConfig(
        name="events.dead_letter",
        partitions=8,
        replication_factor=3,
        retention_ms=RETENTION_30_DAYS,
        cleanup_policy=CleanupPolicy.DELETE
    ),
    
    # Aggregated metrics (compacted for current state)
    "metrics.realtime": TopicConfig(
        name="metrics.realtime",
        partitions=16,
        replication_factor=3,
        retention_ms=RETENTION_INFINITE,
        cleanup_policy=CleanupPolicy.COMPACT
    )
}


# =============================================================================
# Partitioning Strategy
# =============================================================================

class PartitionStrategy:
    """
    Determines which partition an event goes to.
    
    Goals:
    1. Events for same user go to same partition (ordering)
    2. Even distribution across partitions (load balancing)
    3. Consistent assignment (same key always same partition)
    """
    
    @staticmethod
    def get_partition_key(event: Dict) -> str:
        """
        Get the partition key for an event.
        
        Strategy:
        - User events: partition by user_id
        - Anonymous events: partition by session_id or device_id
        - System events: partition by source service
        """
        
        event_type = event.get("event_type", "")
        
        # User-centric events
        if event_type.startswith(("user.", "order.", "payment.")):
            return event.get("user_id") or event.get("session_id") or "anonymous"
        
        # Product events - partition by product for aggregation
        if event_type.startswith("product."):
            # Use user_id if available for user journey analysis
            if user_id := event.get("user_id"):
                return user_id
            # Fall back to product_id for product analytics
            return event.get("payload", {}).get("product_id", "unknown")
        
        # System events - partition by service
        if event_type.startswith("system."):
            return event.get("server_context", {}).get("collector_instance", "default")
        
        # Default: use event_id for even distribution
        return event.get("event_id", "unknown")
WorksheetSizing the topics for the dialogue's 100 million events a day
Value
Events per day (the interviewer's figure in Chapter 11) 100 million
Average rate 100 million ÷ 86,400 s ≈ ~1,160 events/s
Peak-to-average ratio (assumed) 3× → ~3,500 events/s
Average size after enrichment (assumed) 1 KB → ~100 GB a day
events.raw: 7 days × replication factor 3 100 GB × 7 × 3 ≈ ~2.1 TB of broker disk
Category topics: 30 days × 3, if each event lands in one (assumed) 100 GB × 30 × 3 ≈ ~9 TB
Per partition at peak, 64 partitions in events.raw 3,500 ÷ 64 ≈ ~55 events/s
Headroom under the collector's global limit of 50,000/s 50,000 ÷ 3,500 ≈ ~14×

Conclusion: at this volume throughput is the easy part. Retention times replication decides the disk, and 64 partitions are there for consumer parallelism and growth, not because one partition couldn't keep up.

5.2 Idempotent and Transactional Kafka Producers#

pythonProducerConfig · 274 lines
# ingestion/kafka_producer.py

"""
High-performance Kafka producer for event ingestion.

Features:
- Batching for throughput
- Idempotent writes (no duplicates from retries)
- Async sending with callbacks
- Compression
- Retries with backoff
"""

from typing import Dict, Any, List, Optional, Callable
from dataclasses import dataclass
import asyncio
import json
import logging
from datetime import datetime

logger = logging.getLogger(__name__)


@dataclass
class ProducerConfig:
    """Kafka producer configuration."""
    bootstrap_servers: List[str]
    client_id: str = "event-collector"
    
    # Batching
    batch_size: int = 16384  # 16KB
    linger_ms: int = 5  # Wait up to 5ms for batch
    buffer_memory: int = 33554432  # 32MB
    
    # Reliability
    acks: str = "all"  # Wait for all in-sync replicas (with min.insync.replicas=2)
    retries: int = 5
    retry_backoff_ms: int = 100
    enable_idempotence: bool = True  # No duplicates from producer retries
    
    # Compression
    compression_type: str = "lz4"
    
    # Timeouts
    request_timeout_ms: int = 30000
    delivery_timeout_ms: int = 120000


class EventProducer:
    """
    Async Kafka producer for events.
    """
    
    def __init__(self, config: ProducerConfig):
        self.config = config
        self.producer = None
        
        # Metrics
        self.messages_sent = 0
        self.messages_failed = 0
        self.bytes_sent = 0
    
    async def start(self):
        """Initialize the Kafka producer."""
        
        from aiokafka import AIOKafkaProducer
        
        self.producer = AIOKafkaProducer(
            bootstrap_servers=self.config.bootstrap_servers,
            client_id=self.config.client_id,
            acks=self.config.acks,
            enable_idempotence=self.config.enable_idempotence,
            compression_type=self.config.compression_type,
            linger_ms=self.config.linger_ms,
            max_batch_size=self.config.batch_size,
            value_serializer=lambda v: json.dumps(v).encode('utf-8'),
            key_serializer=lambda k: k.encode('utf-8') if k else None
        )
        
        await self.producer.start()
        logger.info("Kafka producer started")
    
    async def stop(self):
        """Shutdown the producer gracefully."""
        if self.producer:
            await self.producer.stop()
            logger.info("Kafka producer stopped")
    
    async def send(
        self,
        topic: str,
        value: Dict[str, Any],
        key: str = None,
        headers: Dict[str, str] = None
    ) -> bool:
        """
        Send a single event to Kafka.
        
        Args:
            topic: Target topic
            value: Event data
            key: Partition key
            headers: Optional headers
        
        Returns:
            True if sent successfully
        """
        
        try:
            # Convert headers to Kafka format
            kafka_headers = None
            if headers:
                kafka_headers = [
                    (k, v.encode('utf-8')) for k, v in headers.items()
                ]
            
            # Send and wait for acknowledgment
            result = await self.producer.send_and_wait(
                topic=topic,
                value=value,
                key=key,
                headers=kafka_headers
            )
            
            self.messages_sent += 1
            self.bytes_sent += len(json.dumps(value))
            
            logger.debug(
                f"Event sent to {topic}:{result.partition}@{result.offset}"
            )
            
            return True
            
        except Exception as e:
            self.messages_failed += 1
            logger.error(f"Failed to send event to {topic}: {e}")
            raise
    
    async def send_batch(
        self,
        topic: str,
        events: List[Dict[str, Any]],
        key_extractor: Callable[[Dict], str] = None
    ) -> int:
        """
        Send a batch of events efficiently.
        
        Args:
            topic: Target topic
            events: List of events
            key_extractor: Function to extract key from event
        
        Returns:
            Number of successfully sent events
        """
        
        if not events:
            return 0
        
        # Create batch of futures
        futures = []
        
        for event in events:
            key = None
            if key_extractor:
                key = key_extractor(event)
            elif "user_id" in event:
                key = event["user_id"]
            
            future = await self.producer.send(
                topic=topic,
                value=event,
                key=key
            )
            futures.append(future)
        
        # Wait for all to complete
        success_count = 0
        for i, future in enumerate(futures):
            try:
                await future
                success_count += 1
            except Exception as e:
                logger.error(f"Failed to send event {i}: {e}")
                self.messages_failed += 1
        
        self.messages_sent += success_count
        
        return success_count
    
    async def flush(self):
        """Flush any pending messages."""
        if self.producer:
            await self.producer.flush()
    
    def get_metrics(self) -> Dict[str, int]:
        """Get producer metrics."""
        return {
            "messages_sent": self.messages_sent,
            "messages_failed": self.messages_failed,
            "bytes_sent": self.bytes_sent
        }


# =============================================================================
# Transaction Support (Exactly-Once)
# =============================================================================

class TransactionalEventProducer(EventProducer):
    """
    Kafka producer with transaction support for exactly-once semantics.
    
    Use when you need to:
    1. Send events atomically (all or nothing)
    2. Coordinate with other Kafka operations
    3. Ensure exactly-once delivery to read_committed Kafka consumers
    """
    
    def __init__(self, config: ProducerConfig, transactional_id: str):
        super().__init__(config)
        self.transactional_id = transactional_id
    
    async def start(self):
        """Initialize transactional producer."""
        
        from aiokafka import AIOKafkaProducer
        
        self.producer = AIOKafkaProducer(
            bootstrap_servers=self.config.bootstrap_servers,
            client_id=self.config.client_id,
            transactional_id=self.transactional_id,
            acks=self.config.acks,
            enable_idempotence=True,  # Required for transactions
            compression_type=self.config.compression_type
        )
        
        # start() also initializes transactions for this transactional_id;
        # each send_transactional() call begins its own transaction
        await self.producer.start()
        
        logger.info(f"Transactional producer started: {self.transactional_id}")
    
    async def send_transactional(
        self,
        events: List[tuple]  # List of (topic, event) tuples
    ) -> bool:
        """
        Send multiple events in a single transaction.
        
        Either all events are delivered or none are.
        """
        
        try:
            # Begin transaction
            await self.producer.begin_transaction()
            
            # Send all events
            for topic, event in events:
                await self.producer.send(topic, value=event)
            
            # Commit transaction
            await self.producer.commit_transaction()
            
            self.messages_sent += len(events)
            return True
            
        except Exception as e:
            logger.error(f"Transaction failed: {e}")
            
            # Abort transaction
            await self.producer.abort_transaction()
            
            self.messages_failed += len(events)
            return False

Chapter 6: Handling Ingestion Spikes#

Your turn · checkpoint 4not answered

How much spike can the collector take?

A mobile app has 10 million daily users sending 50 events each (the app in Practice Problem 1). What is the average event rate? The collector's global limit is 50,000 events a second: at what multiple of average traffic does it start refusing events, and what does it refuse?

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

One strong answer

  • Average: 10 million × 50 = 500 million a day; ÷ 86,400 s ≈ ~5,800 events/s.
  • Limit: 50,000 ÷ 5,800 ≈ 8.6× the average. A spike bigger than that hits the global rate check.
  • What it refuses: the global check rejects any event once the current second's budget is spent, orders included. That is why load shedding starts earlier, when the queue is more than 90% full, and drops only page views, product views and performance telemetry while it keeps orders, payments and sign-ups.

6.1 Backpressure and Rate Limiting#

pythonBackpressureConfig · 265 lines
# ingestion/backpressure.py

"""
Backpressure handling for event ingestion.

When ingestion rate exceeds processing capacity:
1. Buffer events temporarily
2. Apply rate limiting
3. Shed load gracefully
4. Alert operators
"""

from dataclasses import dataclass
from typing import Dict, Any, Optional
from datetime import datetime, timedelta, timezone
import asyncio
from collections import deque
import logging

logger = logging.getLogger(__name__)


@dataclass
class BackpressureConfig:
    """Backpressure configuration."""
    
    # Queue limits
    max_queue_size: int = 100000
    warning_queue_size: int = 50000
    
    # Rate limits
    max_events_per_second: int = 50000
    max_events_per_client: int = 1000  # Per second
    
    # Timeouts
    queue_timeout_seconds: int = 30
    
    # Load shedding
    enable_load_shedding: bool = True
    shed_threshold: float = 0.9  # Shed when queue > 90% full


class BackpressureManager:
    """
    Manages backpressure for event ingestion.
    """
    
    def __init__(self, config: BackpressureConfig):
        self.config = config
        
        # Event queue
        self.queue = asyncio.Queue(maxsize=config.max_queue_size)
        
        # Rate tracking (per-second counters)
        self.global_event_count = 0
        self.client_event_counts: Dict[str, int] = {}
        self.last_reset = datetime.now(timezone.utc)
        
        # Status
        self.is_shedding = False
        self.events_shed = 0
    
    async def accept_event(
        self,
        event: Dict[str, Any],
        client_id: str
    ) -> bool:
        """
        Try to accept an event for processing.
        
        Returns True if accepted, False if rejected.
        """
        
        # Check client rate limit
        if not self._check_client_rate(client_id):
            logger.warning(f"Client {client_id} exceeded rate limit")
            return False
        
        # Check global rate limit
        if not self._check_global_rate():
            logger.warning("Global rate limit exceeded")
            return False
        
        # Check queue capacity
        queue_usage = self.queue.qsize() / self.config.max_queue_size
        
        # Load shedding
        if self.config.enable_load_shedding and queue_usage > self.config.shed_threshold:
            if not self.is_shedding:
                self.is_shedding = True
                logger.warning(f"Entering load shedding mode. Queue: {queue_usage:.1%}")
            
            # Shed non-critical events
            if self._should_shed(event):
                self.events_shed += 1
                return False
        else:
            if self.is_shedding:
                self.is_shedding = False
                logger.info("Exiting load shedding mode")
        
        # Try to queue event
        try:
            self.queue.put_nowait(event)
            self._record_event(client_id)
            return True
        except asyncio.QueueFull:
            logger.error("Event queue full, rejecting event")
            return False
    
    async def get_event(self, timeout: float = None) -> Optional[Dict]:
        """Get next event from queue."""
        
        try:
            if timeout:
                return await asyncio.wait_for(
                    self.queue.get(),
                    timeout=timeout
                )
            return await self.queue.get()
        except asyncio.TimeoutError:
            return None
    
    def _check_client_rate(self, client_id: str) -> bool:
        """Check if client is within rate limit."""
        
        self._reset_if_needed()
        
        count = self.client_event_counts.get(client_id, 0)
        return count < self.config.max_events_per_client
    
    def _check_global_rate(self) -> bool:
        """Check if global rate is within limit."""
        
        # Events accepted in the current one-second window
        self._reset_if_needed()
        return self.global_event_count < self.config.max_events_per_second
    
    def _record_event(self, client_id: str):
        """Record event for rate tracking."""
        
        self.global_event_count += 1
        
        if client_id not in self.client_event_counts:
            self.client_event_counts[client_id] = 0
        self.client_event_counts[client_id] += 1
    
    def _reset_if_needed(self):
        """Reset per-second counters if needed."""
        
        now = datetime.now(timezone.utc)
        if (now - self.last_reset).total_seconds() >= 1:
            self.client_event_counts.clear()
            self.global_event_count = 0
            self.last_reset = now
    
    def _should_shed(self, event: Dict) -> bool:
        """Determine if event should be shed during overload."""
        
        event_type = event.get("event_type", "")
        
        # Never shed critical events
        critical_types = [
            "order.placed",
            "payment.processed",
            "user.signed_up"
        ]
        
        if any(event_type.startswith(t) for t in critical_types):
            return False
        
        # Shed less important events
        shedable_types = [
            "page.viewed",
            "product.viewed",
            "system.performance"
        ]
        
        return any(event_type.startswith(t) for t in shedable_types)
    
    def get_status(self) -> Dict:
        """Get backpressure status."""
        
        return {
            "queue_size": self.queue.qsize(),
            "queue_capacity": self.config.max_queue_size,
            "queue_usage": self.queue.qsize() / self.config.max_queue_size,
            "is_shedding": self.is_shedding,
            "events_shed": self.events_shed,
            "recent_rate": self.global_event_count
        }


# =============================================================================
# Adaptive Rate Limiting
# =============================================================================

class AdaptiveRateLimiter:
    """
    Adaptive rate limiter that adjusts based on system health.
    
    When downstream is healthy: allow more traffic
    When downstream is struggling: reduce limits
    """
    
    def __init__(
        self,
        initial_rate: int = 10000,
        min_rate: int = 1000,
        max_rate: int = 100000
    ):
        self.current_rate = initial_rate
        self.min_rate = min_rate
        self.max_rate = max_rate
        
        # Sliding window for health tracking
        self.success_count = 0
        self.failure_count = 0
        self.last_adjustment = datetime.now(timezone.utc)
    
    def record_success(self):
        """Record successful event processing."""
        self.success_count += 1
        self._maybe_adjust()
    
    def record_failure(self):
        """Record failed event processing."""
        self.failure_count += 1
        self._maybe_adjust()
    
    def _maybe_adjust(self):
        """Adjust rate limit based on health."""
        
        now = datetime.now(timezone.utc)
        if (now - self.last_adjustment).total_seconds() < 10:
            return  # Adjust every 10 seconds
        
        total = self.success_count + self.failure_count
        if total < 100:
            return  # Need enough samples
        
        failure_rate = self.failure_count / total
        
        if failure_rate < 0.01:  # <1% failures, increase limit
            self.current_rate = min(
                self.max_rate,
                int(self.current_rate * 1.1)
            )
            logger.info(f"Increased rate limit to {self.current_rate}")
        
        elif failure_rate > 0.05:  # >5% failures, decrease limit
            self.current_rate = max(
                self.min_rate,
                int(self.current_rate * 0.8)
            )
            logger.warning(f"Decreased rate limit to {self.current_rate}")
        
        # Reset counters
        self.success_count = 0
        self.failure_count = 0
        self.last_adjustment = now
    
    def get_current_limit(self) -> int:
        """Get current rate limit."""
        return self.current_rate

6.2 Multi-Region Ingestion#

DiagramMulti-region event ingestion
%% title: Multi-region event ingestion
flowchart TD
  U@{ icon: "sd:users", label: "Global users" }
  U --> C1["US East<br/>Collector"]:::edge
  U --> C2["EU West<br/>Collector"]:::edge
  U --> C3["AP South<br/>Collector"]:::edge
  C1 --> K1@{ icon: "tech:kafka", label: "Regional<br/>Kafka" }
  C2 --> K2@{ icon: "tech:kafka", label: "Regional<br/>Kafka" }
  C3 --> K3@{ icon: "tech:kafka", label: "Regional<br/>Kafka" }
  K1 --> MM["MirrorMaker<br/>(Replicator)"]:::service
  K2 --> MM
  K3 --> MM
  MM ==> CK[["Central Kafka<br/>(US East)"]]:::core
  CK --> F@{ icon: "tech:flink", label: "Flink<br/>Stream" }
  CK --> SP@{ icon: "tech:spark", label: "Spark<br/>Batch" }
  CK --> S3@{ icon: "tech:s3", label: "S3<br/>Lake" }
  class U client
  class K1,K2,K3 queue
  class F,SP service
  class S3 store

Benefits

  • Low latency for users (collect locally)
  • Fault tolerance (region can fail)
  • Compliance (regional data can stay in region, if it is not mirrored centrally)
  • Central analytics (aggregate view)

Challenges

  • Cross-region replication lag
  • Ordering across regions
  • Cost of data transfer
  • Schema consistency
pythonRegion · 61 lines
# ingestion/multi_region.py

"""
Multi-region event ingestion architecture.

For global applications:
1. Collect events in each region
2. Aggregate to central location
3. Handle cross-region replication
"""

from dataclasses import dataclass
from typing import Dict, List
from enum import Enum


class Region(Enum):
    US_EAST = "us-east-1"
    US_WEST = "us-west-2"
    EU_WEST = "eu-west-1"
    AP_SOUTH = "ap-south-1"
    AP_NORTHEAST = "ap-northeast-1"


@dataclass
class RegionalConfig:
    """Configuration for a regional collector."""
    region: Region
    kafka_brokers: List[str]
    is_primary: bool = False


class MultiRegionRouter:
    """
    Routes events to appropriate regional collector.
    """
    
    def __init__(self, regional_configs: Dict[Region, RegionalConfig]):
        self.configs = regional_configs
    
    def get_collector_for_user(self, user_region: str) -> RegionalConfig:
        """Get the nearest collector for a user."""
        
        # Map user regions to our regions
        region_mapping = {
            "US": Region.US_EAST,
            "CA": Region.US_EAST,
            "MX": Region.US_EAST,
            "BR": Region.US_EAST,
            "GB": Region.EU_WEST,
            "DE": Region.EU_WEST,
            "FR": Region.EU_WEST,
            "IN": Region.AP_SOUTH,
            "SG": Region.AP_SOUTH,
            "JP": Region.AP_NORTHEAST,
            "KR": Region.AP_NORTHEAST,
            "AU": Region.AP_NORTHEAST,
        }
        
        region = region_mapping.get(user_region, Region.US_EAST)
        return self.configs.get(region, self.configs[Region.US_EAST])
Quick check3 questions · +5 XP each

Kafka backbone and ingestion spikes

  1. Q1/3 Analysts need each user's events in order to rebuild their journey. Which partition key should the producer use for user events?

    Answer & explanation

    Answer: B

    Kafka only guarantees order within a partition, so a user's events must share a key to stay in order. Keying by event_id spreads load evenly but scatters one user's events across partitions, which loses their order.

  2. Q2/3 During a flash sale the collector's queue reaches 95% of capacity. What does the backpressure manager do?

    Answer & explanation

    Answer: D

    Above the 90% shed threshold it enters load-shedding mode and drops only low-value telemetry. Critical types like order.placed and payments are never shed, because losing a few page views is acceptable and losing revenue events is not.

  3. Q3/3 A broker writes a message but the ack is lost, so the producer retries the send. Which producer setting stops a duplicate copy from appearing in the partition?

    Answer & explanation

    Answer: C

    With idempotence enabled, the broker recognizes the retried send and discards the duplicate. acks="all" is the tempting answer, but it only makes the write durable across replicas. It does nothing to stop a retry from writing the message twice.

Part III · Stage 3 of 4 · 3 min

Part III: Real-World Application#

Chapter 7: Case Studies#

7.1 Case Study: Segment#

Segment's event ingestion
Scale
  • Very high event volume
  • Many thousands of customers
  • Low-latency ingestion
Architecture
  • Edge collection: Lightweight tracking API and SDKs
  • Validation: Schema enforcement per source
  • Routing: Events routed to hundreds of integrations
  • Storage: Raw events stored in S3
Key decisions

Typical of such a pipeline; not all of these are published by Segment.

  • Schema registry for each source
  • Deduplication on a client-set messageId at ingestion; failed deliveries to destinations are retried (at-least-once)
  • Customer-specific rate limits
  • Real-time + batch destinations
Lessons
  • Schema validation prevents downstream issues
  • Rate limiting per customer is essential
  • Idempotency keys enable replay

7.2 Case Study: Uber#

Uber's event ingestion
Scale
  • Trillions of Kafka messages/day
  • Multiple petabytes of data/day (per Uber's engineering blog)
  • Multi-region deployment
Architecture
  • Apache Kafka as backbone
  • Schema registry (Apache Avro)
  • Flink for real-time processing
  • HDFS for raw storage
Key decisions
  • Avro for schema evolution
  • Kafka clusters per region
  • Tiered storage (local broker disk + remote storage)
  • Sampling is a common option for very high-volume events
Lessons
  • Schema evolution is critical at scale
  • Regional clusters reduce latency
  • Not all events need same retention

Chapter 8: Common Mistakes#

8.1 Schema Design Mistakes#

Schema design mistakes
Mistake 1: No Event ID

Wrong:

json1 line
{"type": "viewed", "product": "123"}

Problem:

  • Can't deduplicate
  • Can't track event through pipeline
  • Can't correlate with other systems

Right:

json5 lines
{
  "event_id": "evt_abc123",
  "event_type": "product.viewed",
  "product_id": "123"
}
Mistake 2: Mutable Events

Wrong:

python3 lines
# Updating existing event
event["status"] = "processed"
save(event)

Problem:

  • Breaks immutability
  • Can't replay from any point
  • Audit trail lost

Right:

python5 lines
# Emit new event
emit({
  "event_type": "event.processed",
  "original_event_id": event["event_id"]
})
Mistake 3: Inconsistent Timestamps

Wrong:

text3 lines
{"timestamp": "Jan 15, 2024"}
{"timestamp": 1705314600}
{"timestamp": "2024-01-15T10:30:00"}

Problem:

  • Parsing nightmare
  • Timezone confusion
  • Ordering issues

Right:

text1 line
{"event_time": "2024-01-15T10:30:00Z"}  # Always ISO 8601 UTC
Your turn · checkpoint 5not answered

What breaks in a bad hour?

Two handlers. The first validates, enriches and writes every event to the database before it returns "OK". The second catches any processing error, logs "Failed" and moves on. During an hour when the database is slow and some events are malformed, what goes wrong in each?

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

One strong answer

  • Synchronous write: the API is now as slow as the database, there is no buffer for the spike, and the database is a single point of failure. Validate, send to Kafka asynchronously, and return.
  • Log and move on: every failed event is lost for good, with nothing to retry and nothing to debug. Send it to a dead letter queue with the error, so it can be investigated and replayed.

8.2 Infrastructure Mistakes#

Infrastructure mistakes
Mistake 4: Synchronous Ingestion

Wrong:

pythonhandle_event · 5 lines
def handle_event(event):
    validate(event)
    enrich(event)
    save_to_database(event)  # Blocks!
    return "OK"

Problem:

  • Database slow = API slow
  • No buffering for spikes
  • Single point of failure

Right:

pythonhandle_event · 4 lines
async def handle_event(event):
    validate(event)
    await kafka.send(event)  # Async, buffered
    return "OK"
Mistake 5: No Dead Letter Queue

Wrong:

python5 lines
try:
    process(event)
except:
    log.error("Failed")
    # Event lost forever!

Problem:

  • Data loss
  • No retry capability
  • No debugging

Right:

python5 lines
try:
    process(event)
except Exception as e:
    await dlq.send(event, error=str(e))
    # Can investigate and replay later
Quick check3 questions · +5 XP each

Case studies and common mistakes

  1. Q1/3 An ingestion handler validates, enriches, then writes each event to the database before returning "OK". During a traffic spike the database slows down. What happens?

    Answer & explanation

    Answer: A

    Synchronous ingestion couples the API to the database: a slow database means a slow API, and there is no buffer for spikes. Validating and then writing asynchronously to Kafka decouples the two and lets Kafka absorb the burst.

  2. Q2/3 A downstream job needs to record that an event has been processed. What is the right way to do it?

    Answer & explanation

    Answer: C

    Events are immutable facts, so changes are recorded as new events that point back to the original. Mutating the original breaks replay and loses the audit trail, which is exactly Mistake 2.

  3. Q3/3 Segment retries failed deliveries to destinations, so a destination can receive the same event more than once. What makes that safe?

    Answer & explanation

    Answer: D

    At-least-once can redeliver, but if every consumer applies events idempotently by key, a duplicate changes nothing. Rate limits and schema validation shape traffic and structure, but neither detects duplicates.

Part IV · Stage 4 of 4 · 4 min

Part IV: Interview Preparation#

Chapter 9: Interview Tips#

9.1 When to Discuss Event Ingestion#

Bring up event ingestion when
Interview prompt Angle
"Design an analytics system" Start with how events are captured
"How would you track user behavior?" Event schema and ingestion pipeline
"Design a recommendation system" Needs event data for training
"Build a real-time dashboard" Starts with event streaming
"Design a fraud detection system" Events are the input
❯ Say this

9.2 Key Phrases to Use#

Talking about event ingestion
  • "For event ingestion, I'd use Kafka as the backbone because it provides durability, replay capability, and handles backpressure naturally through consumer lag."
  • "Events should be immutable and self-describing. Each event needs a unique ID for deduplication, a timestamp for ordering, and enough context to be useful without joining other data."
  • "Schema evolution is critical. I'd use a schema registry to enforce compatibility (backward at minimum, ideally full) so we can add optional fields without breaking existing consumers."
  • "For handling spikes, I'd implement backpressure at the collection layer and use Kafka's buffering. We can shed low-priority events if needed while always accepting critical events like orders and payments."

Chapter 10: Practice Problems#

Practice · problem 1

Problem 1: Mobile App Event Collection#

Setup: Design an event collection system for a mobile app with:

  • 10M daily active users
  • Average 50 events per user per day
  • Offline support (events buffered on device)
  • Poor network conditions

Questions:

  1. How do you handle offline events that arrive days later?
  2. How do you prevent duplicate events from retries?
  3. How do you handle clock skew on mobile devices?
Click for hints
  • Use client-generated event IDs for deduplication
  • Include both client timestamp and server timestamp
  • Batch events in mobile SDK before sending
  • Accept late events with warnings

Practice · problem 2

Problem 2: High-Volume Gaming Events#

Setup: Design event ingestion for a multiplayer game:

  • 1M concurrent players
  • 100 events per second per player during gameplay
  • Need real-time leaderboards
  • Some events are critical (purchases), some are telemetry

Questions:

  1. How do you handle 100M events/second?
  2. How do you prioritize critical vs telemetry events?
  3. How do you aggregate for real-time leaderboards?
Click for hints
  • Sample telemetry events (1 in 100)
  • Separate Kafka topics by priority
  • Edge aggregation before central ingestion
  • Pre-aggregate scores in streaming layer

Chapter 11: Sample Interview Dialogue#

Transcript
Interviewer

"Design an event tracking system for our e-commerce platform."

You

"I'd start by understanding the event volume and use cases. How many events per day are we expecting, and what's the primary use case — real-time analytics, historical analysis, or both?"

Interviewer

"About 100 million events per day. We need both real-time dashboards and historical analysis."

You

"Got it. Let me design the ingestion layer first, then we can discuss processing.

For ingestion, I'd use a three-tier architecture:

First, a collection layer — lightweight API servers that receive events from web and mobile clients. These validate the schema, add server-side metadata, and immediately write to Kafka. They don't do any heavy processing.

Second, Kafka as the event backbone. I'd create topics by event category — user events, product events, order events. This lets us scale and process them independently. Kafka gives us durability and replay capability.

Third, a validation and enrichment layer — consumers that validate events against our schema registry, enrich them with additional context like geo-location, and route them to the appropriate downstream systems.

For handling spikes, Kafka naturally buffers events. If producers are faster than consumers, the lag increases but nothing is lost. We can also scale consumers horizontally."

Interviewer

"What about events from mobile devices that might be offline?"

You

"Good point. Mobile SDK should buffer events locally and send in batches when connectivity is available. Each event needs a client-generated UUID so we can deduplicate if the same batch is sent twice due to network retries.

For late-arriving events, I'd include both the client timestamp (when it happened) and server timestamp (when we received it). The processing layer can handle late events appropriately — for real-time dashboards, we might show them with a delay indicator, while for historical analysis, they're processed normally.

We should also validate that client timestamps are reasonable — not too far in the future, and not older than our retention policy allows."

Summary#

Day 1 summary: Event ingestion at scale
  • Event design
    • Immutable, timestamped facts
    • Unique ID for deduplication
    • Self-describing (schema + context)
    • Versioned for evolution
    • ISO 8601 UTC timestamps
  • Ingestion architecture
    • Collection layer — lightweight APIs
    • Validation layer — schema enforcement
    • Kafka backbone — durability + buffering
    • Enrichment layer — add context
    • Routing to downstream systems
  • Schema evolution
    • Backward compatible changes only
    • Add optional fields (safe)
    • Never remove required fields
    • Use schema registry
    • Version all events
  • Handling spikes
    • Kafka buffers naturally
    • Rate limiting per client
    • Load shedding for non-critical
    • Adaptive rate limits
    • Multi-region for global scale
  • Reliability
    • At-least-once delivery default
    • Exactly-once within Kafka via transactions (+ idempotent sinks)
    • Dead letter queue for failures
    • Idempotency keys for dedup
    • Replay capability always
◆ Key takeaways

Key Takeaways#

Event ingestion key takeaways
  1. EVENTS ARE IMMUTABLE FACTS — Never update an event — emit a new one
  2. SCHEMA IS CONTRACT — Schema registry prevents downstream breakage
  3. KAFKA IS THE BACKBONE — Durability, buffering, replay all built-in
  4. VALIDATE EARLY — Catch bad data at ingestion, not in analytics
  5. DESIGN FOR FAILURE — DLQ, retries, idempotency from day one
Golden rules
  • Every event has unique ID
  • Always use UTC timestamps
  • Never drop events silently
  • Schema evolution = backward compatible
  • Client timestamp ≠ Server timestamp

What's Next#

Tomorrow, we'll tackle Streaming vs Batch Processing — understanding when to use real-time stream processing versus batch processing, and how to architect systems that use both.

Tomorrow's preview: Streaming vs batch
Questions we'll answer
  • When do you need real-time vs batch?
  • What is Lambda Architecture?
  • What is Kappa Architecture?
  • How do you handle state in streaming?
  • When is Flink vs Spark the right choice?

We'll take the events we're now capturing and process them into useful metrics and insights.

Next: Day 2 — Streaming vs Batch Processing

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 01 · Event Ingestion at Scale
Finish Day 1Stages 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.

PhonePeBlog post · ~6 min

The Kafka Edge: Managing 100 Billion Daily Events at PhonePe

Nitish Goyal · 2023
What
How PhonePe moves about 100 billion events a day into Kafka.
Key idea
A client library writes events to local disk (BigQueue) and a lightweight Ingestor process ships them to an ingestion service, which writes to Kafka.
Why here
The ingestion tier of this lesson with a local buffer: apps keep logging even when Kafka or the network is slow.
taught here: Ingestion architecture
SegmentBlog post · ~19 min

Delivering billions of messages exactly once

Amir Abu Shareb · 2017
What
Segment's deduplication system for the events customers send to its API.
Key idea
About 0.6% of events arrive twice; workers check each message ID against embedded RocksDB within a de-duplication window before re-publishing to Kafka.
Why here
The validation and dedupe step of this lesson's pipeline: clients retry, so ingestion must drop duplicates by event ID.
taught here: Deduplication at ingest
LinkedInBlog post · ~8 min

Running Kafka At Scale

Todd Palino · 2015
What
How LinkedIn operated Kafka in 2015.
Key idea
Over 13 million messages a second ran through more than 1,100 brokers in 60+ clusters, with tiered clusters and mirroring between datacentres.
Why here
The Kafka backbone of this lesson at real scale, including how clusters are split and aggregated.
taught here: Kafka as the event backbone

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…