Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

For interviewers and evaluators, Quick walkthrough deck: View Deck

Oculus

Oculus is a small analytics platform I built to understand how tools like PostHog and Amplitude actually work. It tracks what people do on a website (clicks, page views, signups) and lets you answer questions like "how many people who saw the pricing page ended up paying?" or "do users come back after a week?"

It's not trying to be a product. I built it to get my hands dirty with the hard parts: taking in a lot of events without dropping any, storing them so queries stay fast, and figuring out that an anonymous visitor and a signed-up user are the same person.

Stack: TypeScript, Kafka, ClickHouse, PostgreSQL, React, Docker. It runs locally with one docker compose up.

Contents

  1. What it does
  2. Screenshots
  3. How it fits together
  4. What happens to one event
  5. The main design decisions
  6. The data model
  7. Identity resolution
  8. Funnels
  9. Not losing data
  10. Performance
  11. Scaling
  12. Running it
  13. Project layout
  14. What it doesn't do

What it does

There are really only three moving parts:

  1. Collect. A snippet on the website sends an event every time someone does something.
  2. Store. The event goes into a database that's good at counting over a lot of rows.
  3. Answer. The dashboard asks a question and gets a number back.

The three kinds of question it answers are trends (volume over time), funnels (how far people get through a sequence of steps), and retention (whether they come back).

Screenshots

The dashboard is a React app that reads from the query API.

Trends — event volume over time, by hour, day, or week:

Trends

Funnels — how many people made it through each step, with a conversion window you can change:

Funnels

Retention — cohorts by the day they first showed up, shaded by how many came back:

Retention

Demo site — a fake website with the tracking snippet on it. You browse anonymously, sign up, and the earlier anonymous events get attached to your account:

Demo site

How it fits together

Four processes with a queue in the middle:

flowchart LR
    subgraph sources[Event sources]
        SDK[Browser SDK]
        SEED[Seed / load-test script]
        DEMO[Demo site]
    end

    subgraph api[API - one Express app]
        CAP["POST /capture<br/>POST /identify"]
        INS["GET /api/insights/*<br/>trend · funnel · retention"]
    end

    KAFKA[(Kafka<br/>topic: events)]
    CONSUMER[Consumer<br/>batch writer]

    CH[(ClickHouse<br/>events + identity map)]
    PG[(Postgres<br/>projects + API keys)]

    DASH[React dashboard]

    SDK --> CAP
    SEED --> CAP
    DEMO --> CAP
    CAP -->|check API key| PG
    CAP -->|hand off| KAFKA
    KAFKA --> CONSUMER
    CONSUMER -->|insert in batches| CH
    DASH --> INS
    INS -->|query| CH
Loading

The capture endpoint takes an event, does a little work, and drops it into Kafka. A separate consumer reads Kafka and writes to ClickHouse in batches. The dashboard reads insights back out of ClickHouse. Postgres just holds projects and API keys.

Process What it is Why it's on its own
API (server) Express app for capture and queries one app, two jobs
Consumer (server) reads Kafka, writes ClickHouse needs to keep running if the API restarts
Dashboard (client) React + Vite frontend static, only talks to the API
Seed (seed) fake-traffic generator doubles as the load test

What happens to one event

Someone clicks "Sign up". Here's the path it takes:

sequenceDiagram
    participant B as Browser SDK
    participant C as Capture API
    participant PG as Postgres
    participant K as Kafka
    participant W as Consumer
    participant CH as ClickHouse

    B->>C: POST /capture { event, distinct_id, ... }
    C->>PG: is this API key valid? → project_id (cached)
    C->>C: add event_id, country, browser, fixed timestamp
    C->>K: put on the queue (keyed by distinct_id)
    C-->>B: 202 Accepted, ~8ms
    Note over K,W: a moment later, in the background
    W->>K: grab a batch
    W->>CH: one bulk insert
    CH-->>W: done
    W->>K: mark the batch done (only now)
Loading

Two things here are on purpose:

The capture endpoint answers before the event is stored. It returns as soon as Kafka has the event, which is fine because nobody queries an event 200ms after it happens. That keeps the website fast even if ClickHouse is slow or down.

The consumer only marks a batch done after ClickHouse confirms the write. If it crashes in between, it re-reads the same batch next time. That ordering is the whole reason events don't get lost.

The event picks up a few fields on the way in and then never changes:

Stage What the data looks like
Browser sends event, distinct_id, properties, timestamp, sent_at
Capture adds event_id (for de-duping), project_id, country, browser, os, a trusted received_at, and a timestamp corrected for clock skew
Stored the full row, sorted on disk by (project_id, event, timestamp, event_id)

The main design decisions

Capture can't wait on the database. The capture endpoint runs on someone else's website. If it's slow, their page is slow, and they'll pull the snippet. So it does the bare minimum and hands off to Kafka. If ClickHouse is down, capture still returns 202 and the events sit in Kafka until it comes back.

Two databases, not one. Events go in ClickHouse because it's built for counting across millions of rows in one query. Projects and API keys go in Postgres because they're small, need updates, and need a unique constraint on the key. Using one database for both would mean it's bad at half the job.

Anonymous visitors have to become real people. If someone browses anonymously and then signs up, and I treat those as two people, a signup funnel reports zero conversions. That's covered in identity resolution.

The data model

erDiagram
    PROJECTS ||--o{ EVENTS : "scopes"
    PROJECTS ||--o{ PERSON_DISTINCT_IDS : "scopes"

    PROJECTS {
        uuid id PK
        text name
        text api_key UK
        timestamptz created_at
    }
    EVENTS {
        uuid event_id
        string project_id
        string event
        string distinct_id
        string properties "JSON blob"
        datetime timestamp
        string country
        string browser
        string os
    }
    PERSON_DISTINCT_IDS {
        string project_id
        string distinct_id
        string person_id
        uint8 is_identified
        uint64 version
    }
Loading
Table Where Why
projects Postgres small, gets updated, needs a unique API key
events ClickHouse append-only, huge, only ever aggregated
person_distinct_ids ClickHouse has to be joined against events inside the query, so it lives next to them

properties is a JSON string rather than real columns because I can't know in advance what each customer wants to track. One tracks plan_tier, another warehouse_id. Fixed columns for the common stuff, a JSON blob for the rest, and I pull fields out of it at query time.

Identity resolution

The problem: an anonymous visitor signs up, and their earlier activity has to follow them or funnels break.

The obvious fix is to rewrite all their old events with the new ID. That doesn't work here. ClickHouse has no cheap update, and identify() runs on every login, so I'd be constantly rewriting the biggest table in the system.

So events stay untouched. There's a small table mapping IDs to a single person_id, and queries join through it:

flowchart LR
    subgraph map[person_distinct_ids]
        M1["anon_a4f2 → person_9931"]
        M2["alice@corp.com → person_9931"]
    end
    E["events<br/>(keep original distinct_id)"] -->|join| R["everything → person_9931"]
    map --> R
Loading

Anonymous visitors who never sign up don't cost anything. With no mapping row, their distinct_id is used as the person_id directly. Only people who identify get a row.

One case I had to handle: identify() refuses to merge an anonymous ID that's already been identified as someone else. That's the shared-computer situation where one person logs out and another logs in on the same cookie. You can't undo a merge, because once two IDs share a person_id the events don't remember which human they came from. So it errs toward keeping them apart. Splitting one person into two is annoying but fixable; merging two people is not.

Funnels

A funnel asks whether the same person did A, then B, then C, in order, within some number of days.

The naive way is a stack of self-joins, which blows up fast. A user with 30 pageviews generates hundreds of pairs before you filter them. On real data it just doesn't return.

The right way is to walk each person's events once and track how far they got:

flowchart LR
    S0[waiting for A] -->|sees A| S1[waiting for B]
    S1 -->|sees B, in window| S2[waiting for C]
    S2 -->|sees C, in window| DONE[converted]
    S1 -.->|window expires| S0
    S2 -.->|window expires| S0
Loading

ClickHouse has this built in as windowFunnel(). It walks each person's events in order and returns the deepest step they reached. The events table is already sorted by timestamp, so they arrive in the order the function needs and there's no extra sort.

Retention is a different question, so it's a different query. Group people by the day they first appeared, then count how many showed up on each later day.

Not losing data

The queue does more than smooth out spikes. It separates how fast events come in from how fast ClickHouse can take them. When there's a burst, the consumer falls behind, lag goes up, and nothing else notices. Capture keeps returning in milliseconds.

Delivery is at-least-once, and duplicates get cleaned up by the storage engine:

sequenceDiagram
    participant K as Kafka
    participant W as Consumer
    participant CH as ClickHouse

    W->>K: fetch a batch
    W->>CH: insert the batch
    alt insert works
        CH-->>W: ok
        W->>K: commit offset
    else crash first
        Note over W,K: offset not committed
        W->>K: re-fetch the same batch on restart
        W->>CH: insert again, maybe a duplicate
    end
Loading

An event never gets lost. A crash can create a duplicate, but the events table is a ReplacingMergeTree keyed on event_id, so ClickHouse drops duplicate event_ids when it merges parts in the background.

I tested this by killing ClickHouse while events were flowing:

Result
Capture during the outage still 202, in 15ms
Kafka lag during the outage climbed to exactly 1,001 (the backlog)
Events sent 1,001
Rows after recovery 1,001
Distinct event_id 1,001
Lost 0
Duplicates 0

The consumer died on connection-refused, restarted, and picked up from its last committed offset.

Performance

These numbers are from one machine running everything at once (the load generator, API, Kafka, ClickHouse, Postgres), so they're a floor, not a ceiling.

The load test checks itself first. Before measuring the server, it hammers a do-nothing /ping endpoint to find out how fast the generator itself can go (about 276,000 req/sec here). As long as the real numbers are well under that, they're measuring the server and not the test. It also paces on a fixed clock so a slow server can't quietly drag the generator down with it.

Ingest (latency is per request, batches of 50–200 events):

Target Achieved p95 Lag Lost
1,000/sec 1,001/sec 13ms 0 0
5,000/sec 5,001/sec 127ms 0 0
7,000/sec 4,804/sec 23s 0 0
10,000/sec 5,788/sec 27s 0 0

It holds 5,000 events/sec and falls over just above that. Latency blows up past the limit, but it never drops anything.

Query latency over 1.19M events (warm cache):

Query p50 p95
trend (daily) 58ms 120ms
funnel (5 steps) 178ms 206ms
retention (14 days) 132ms 170ms

Run them yourself with RATE=5000 npm run loadtest and npm run bench:queries.

Scaling

The thing that surprised me: the bottleneck is capture, not storage. Consumer lag stayed at zero even when capture was maxed out, which means Kafka and ClickHouse had room to spare and the limit was Express plus the Kafka producer.

So the order to scale is:

flowchart TD
    A[One machine today] --> B{scale in this order}
    B --> C[1. Capture is stateless,<br/>run N copies behind a load balancer]
    B --> D[2. Consumers scale with<br/>Kafka partitions, one each]
    B --> E[3. ClickHouse last, shard by project_id]
Loading

Capture is stateless, so you just run more copies behind a load balancer and they all produce to the same topic. Right now capture and the query API share a process, and splitting them is the first real change I'd make.

Kafka scales by adding partitions (one consumer can read each) and brokers (with replication factor 3 you survive losing one with no data loss). It's on 3 partitions and 1 broker for local dev.

ClickHouse comes last because a single node handles billions of rows. Past a few TB you shard by project_id, and since the table is already sorted by project_id first, that's a config change and not a rewrite. The thing to watch is one giant customer landing on one shard, which you handle by placing them by hand.

I didn't build the sharding. The point was knowing where the ceiling is and how I'd get past it.

Running it

You need Docker, Node 20+, and Python 3 for the demo server.

# 1. backing services
docker compose up -d

# 2. backend (two terminals)
cd server
npm install
cp .env.example .env
npm run migrate
npm run dev          # API on http://localhost:4000
npm run consumer     # second terminal

# 3. dashboard
cd client && npm install && npm run dev    # http://localhost:5173

# 4. some fake traffic
cd seed && npm install && npm run seed

The demo site with the real tracking snippet:

python3 -m http.server 8080 --directory demo-app   # http://localhost:8080

Postgres is on host port 5433, not the usual 5432, so it doesn't clash with a Postgres you might already be running.

Project layout

server/
  src/
    routes/       capture, identify, projects, insights, health
    services/     capture, identity, trend, funnel, retention
    kafka/        producer, consumer, admin (topic + lag)
    db/           postgres + clickhouse clients, migrations
  migrations/     SQL for both databases
client/           React + Vite dashboard
seed/             traffic generator, load test, query benchmark
demo-app/         fake site with the tracking snippet (sdk.js)
docker-compose.yml

What it doesn't do

Things I left out on purpose:

  • No real auth. Projects use an API key, and the projects API itself is open. Fine on localhost, not fine anywhere else.
  • No session replay, feature flags, A/B tests, or billing. Different products.

Trade-offs I made:

  • Capture and the query API share a process. They scale differently, so a real deployment would split them.
  • De-duplication is eventual. Duplicates go away on the next merge, so a query right before a merge can still see one. Fine for analytics, not for billing.
  • API-key checks are cached for 60 seconds per instance, so a revoked key works for a little while.
  • The browser SDK drops an event if it can't send it, rather than showing the visitor an error. A real SDK would save it and retry.
  • Identity merges can't be undone, and every person-level query pays for the join.

Where it runs out of room:

  • All the benchmarks are on one machine, so they're a floor. A separate load-gen box would push the numbers higher.
  • One ClickHouse node runs out around a few TB, then you shard by project_id.
  • Kafka's retention window is the real limit on an outage. The consumer can be down until that runs out, and after that events are gone.

About

Oculus — a product analytics platform: event ingestion through Kafka into ClickHouse, with funnel, trend and retention queries

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages