Skip to content

v1.3.0 — Event-Driven Analytics

Latest

Choose a tag to compare

@simply-mihir simply-mihir released this 20 May 04:38
· 20 commits to main since this release

v1.3.0 — Event-Driven Analytics

Decouples click analytics from the redirect hot path via Redis Streams and a background consumer. Redirects stay sub-50ms while analytics writes happen asynchronously with at-least-once delivery semantics.

Architecture

Before v1.3.0, every redirect performed a synchronous UPDATE urls SET click_count = ... inside a FastAPI BackgroundTask. This worked but coupled cold-path persistence to the hot-path request lifecycle, contended on row-level locks under bursty traffic, and made it impossible to capture per-event dimensions (device, browser, OS, referrer) without bloating the urls row.

v1.3.0 introduces a proper producer/consumer event pipeline:

sequenceDiagram
    actor User as Browser
    participant API as Redirect Handler<br/>(FastAPI)
    participant Cache as Redis Cache<br/>(cache-aside)
    participant Stream as Redis Stream<br/>(snapit:clicks)
    participant Consumer as Event Consumer<br/>(asyncio task)
    participant DB as PostgreSQL

    User->>+API: GET /abc123
    API->>+Cache: GET url:abc123
    Cache-->>-API: original URL (cache hit)
    API-->>-User: 302 Found (P50 < 50 ms)

    Note over API,Stream: BackgroundTasks — fire-and-forget
    API->>Stream: XADD short_id, ts, ua, referrer

    loop every ~1s, up to 100 events per batch
        Consumer->>+Stream: XREADGROUP snapit-consumers
        Stream-->>-Consumer: batch
        Consumer->>Consumer: parse User-Agent →<br/>device, browser, OS
        Consumer->>+DB: bulk INSERT click_events
        Consumer->>DB: UPDATE urls.click_count
        DB-->>-Consumer: commit
        Consumer->>Stream: XACK (only on success)
    end

The redirect path is now a pure read from the cache plus a non-blocking XADD. The DB writes have moved entirely off the request lifecycle and into a background consumer that batches inserts.

Added

Data model

  • click_events fact table — one row per redirect, recording short_id, occurred_at, device, browser, os, user_agent, and referrer. Composite index on (short_id, occurred_at) for fast time-series queries. Foreign-keyed to urls with ON DELETE CASCADE.

Services

  • EventProducer — thin wrapper around Redis XADD. Caps stream at 100k events via MAXLEN ~ (approximate trimming) for bounded memory. Truncates UA/referrer to 512 chars. Errors are logged but never propagate — analytics must never break the redirect.
  • EventConsumer — long-running asyncio task spawned from FastAPI's lifespan hook. Reads via XREADGROUP in 100-event batches, blocking up to 1s for new messages. Parses User-Agent into device kind (desktop / mobile / tablet / bot), browser, and OS via the user-agents library. Bulk-inserts to Postgres in a single transaction, then XACKs only after successful commit.

API

  • GET /api/analytics/{short_id} now returns five new fields alongside the existing summary:
    • by_device — counts grouped by device kind
    • by_browser — top 10 browsers
    • by_os — top 10 operating systems
    • by_referrer — top 5 referrer domains
    • recent_clicks — last 10 click events with full dimensions
  • All new fields default to empty lists — no breaking changes for existing clients.

Dependencies

  • user-agents==2.2.0 — battle-tested UA parsing library (~10k regex patterns).

Engineering decisions

Decision Rationale
Redis Streams over Pub/Sub Streams persist events; Pub/Sub drops messages with no subscriber. Survives consumer restart.
Redis Streams over Kafka Reuses existing Upstash Redis — zero new infrastructure cost. Consumer groups give Kafka-like semantics at our scale.
At-least-once over exactly-once XACK fires only after DB commit. A crash mid-batch causes redelivery; duplicate events are tolerated because the click_events insert is idempotent on (short_id, occurred_at).
Bulk INSERT via session.add_all Cuts DB round-trips ~50× vs row-by-row at moderate batch sizes.
Consumer in-process (vs separate service) Simpler ops on free-tier Render. Architecture supports separation later — same XREADGROUP flow, just running as a dedicated worker.
Aggregates still written to urls table Backwards-compatible: existing click_count and last_accessed_at keep working. Avoids breaking the frontend.
MAXLEN cap on stream Prevents unbounded growth if consumer falls behind for a sustained period. 100k events ≈ 1 week of traffic at current scale.
Per-batch error handling Bad data in one batch doesn't kill the consumer. Failed batches don't ACK and are redelivered, with backoff.

Performance impact

Metric v1.2.0 v1.3.0
Redirect P50 (cache hit) ~30 ms ~30 ms
Redirect P95 (cache hit) ~80 ms ~50 ms ⬇
Redirect work on hot path Cache lookup + DB UPDATE Cache lookup + Redis XADD
Click data captured Counter + timestamp only Counter + device + browser + OS + referrer + raw UA
Per-link analytics richness 1 dimension 5 dimensions

The P95 improvement comes from removing the synchronous UPDATE urls from the redirect path. The new XADD is roughly 5× faster than a Postgres row update over the WAN.

Operational notes

  • Consumer warmup: on first start, the consumer creates the consumer group with id="$" (start from messages arriving after group creation). Existing stream entries from before the consumer started are skipped — by design, to avoid replaying historical data on restart.
  • Graceful shutdown: lifespan's event_consumer.stop() sets a shutdown flag and waits up to 5 seconds for the current batch to finish, then cancels. In-flight events that aren't ACK'd are redelivered on next startup.
  • Scaling story: to add a second consumer, run another instance of the FastAPI service. Both will join consumer group snapit-consumers; Redis automatically load-balances messages across consumers. No code changes required.

Files changed

File Change
backend/app/models/click_event.py NEW — fact table model
backend/app/services/event_producer.py NEW — XADD producer
backend/app/services/event_consumer.py NEW — batched consumer
backend/app/schemas/url.py NEW schemas: BreakdownItem, ClickEventSummary
backend/app/services/url_service.py NEW methods: get_click_breakdown, get_recent_clicks
backend/app/routes/redirect.py REFACTORED — removed inline DB writes
backend/app/routes/shorten.py ENHANCED — enriched analytics response
backend/app/main.py UPDATED — lifespan starts/stops consumer
backend/requirements.txt ADDED — user-agents==2.2.0

Full Changelog: v1.2.0...v1.3.0