# I Built My Own Analytics + AB Testing Tool in a Weekend With Claude Code.

> Source: <https://pub.towardsai.net/i-built-my-own-analytics-ab-testing-tool-in-a-weekend-with-claude-code-c67f411602f7?source=rss----98111c9905da---4>
> Published: 2026-06-18 16:01:01+00:00

I got tired of two things. Cookie banners, and paying per-seat for a dashboard nobody on my team opened twice. So I built the analytics tool I actually wanted: it watches my landing pages, runs A/B tests, replays sessions, and tells me in plain English what to fix. No banner. No per-seat bill.

This three-part series is the whole build, with real code. Part 1 is the pipeline: getting events from a stranger’s browser into my database without slowing their page down. Part 2 is experiments and replays. Part 3 is the AI layer that turns it all into advice.

Thanks for reading The GTM Hacker! Subscribe for free to receive new posts and support my work.

If you can write a <script> tag and a Postgres query, you can follow along.

Four moving parts, and only the first two run on the open internet:

```
browser → tracker snippet → ingestion server → Postgres → dashboard + AI
```

The tracker is a tiny JavaScript file that lives on your pages. The ingestion server catches what it sends. Postgres stores it. Everything else just reads. That one-directional flow is the reason the whole thing stays simple. Nothing downstream can slow down the part running in your visitor’s browser.

I run it as a Turborepo: a tracker workspace, a Hono ingestion server, a Next.js dashboard, and a Drizzle/Postgres package. You don’t need that structure to start. You need a script and an endpoint.

The tracker has one number it can’t break: under 5KB gzipped. It runs on real pages where every kilobyte is page-speed you’re stealing from someone. The moment you npm install a convenience library, you’ve blown it. So: vanilla JavaScript, no dependencies, and the recorder for session replay loads separately and lazily (more on that in Part 2).

Install is one line:

```
<script src="https://your-ingestion.up.railway.app/pp.js" data-site="site-xxxxxxxx" async></script>
```

The script figures out the rest from its own tag: the site ID off data-site, and the endpoint derived from where it was served:

``` js
var endpoint = currentScript.getAttribute('data-endpoint');if (!endpoint) {  var src = currentScript.src;  endpoint = src.substring(0, src.indexOf('/', src.indexOf('//') + 2)) + '/api/events';}
```

No cookies means no banner. So identity comes from two cheap tricks.

A session ID lives in sessionStorage. It survives clicking around the site and resets when the tab closes:

``` js
function getSessionId() {  var sid = sessionStorage.getItem('pp_sid');  if (!sid) {    sid = Math.random().toString(36).slice(2) + Date.now().toString(36);    sessionStorage.setItem('pp_sid', sid);  }  return sid;}
```

The visitor ID is a hash of boring, stable browser traits: screen size, timezone, language, a trimmed user-agent. It won’t single anyone out, and that’s the point. It’s just stable enough to tell “same person, second pageview” apart from “two different people.” Coarse, cookieless, no consent required. For conversion analytics, that trade is the right one.

Every signal, whether a pageview, a click, a scroll past 50%, or a form field abandoned, is the exact same object. One shape keeps both the sender and the receiver tiny:

```
function track(eventType, data) {  queue.push({    type: eventType,    ts: Date.now(),    visitor_id: visitorId,    session_id: sessionId,    device: getDeviceType(),    page: getPageData(),     // url, referrer, title    utm: getUtmParams(),     // source, medium, campaign...    data: data || {},  });}
```

Note it goes into a queue, not over the wire. That matters next.

Here’s the bug that bites everyone who rolls their own. A visitor converts, then closes the tab. That’s the single most valuable event you’ll ever capture, and a normal fetch dies the instant the page unloads, taking the event with it.

The fix is navigator.sendBeacon. The browser promises to deliver it even as the page goes away:

``` js
function flush() {  if (!queue.length) return;  var payload = JSON.stringify({ site_id: siteId, events: queue.splice(0) });  if (navigator.sendBeacon) {    navigator.sendBeacon(endpoint, new Blob([payload], { type: 'application/json' }));  } else {    var xhr = new XMLHttpRequest();    xhr.open('POST', endpoint, true);    xhr.setRequestHeader('Content-Type', 'application/json');    xhr.send(payload);  }}
setInterval(flush, 5000);                 // batch every 5swindow.addEventListener('beforeunload', flush);document.addEventListener('visibilitychange', function () {  if (document.visibilityState === 'hidden') flush();   // the one that fires on mobile});
```

Batch every 5 seconds so you’re not firing a request per click. Flush on the way out so you don’t lose the ending. And listen to visibilitychange, because on phones people switch apps instead of closing tabs, and beforeunload often never fires.

The ingestion server is a Hono process and the whole entrypoint is about forty lines. The only non-obvious bit is CORS: the tracker runs on domains you don’t know yet, so early on you allow all origins. The events carry no secrets, so this is fine until you start charging, at which point you tighten it to a per-site allowlist.

``` js
app.use('*', cors({ origin: (origin) => origin || '*', allowMethods: ['GET', 'POST', 'OPTIONS'] }));app.route('/api/events', eventsRoute);
```

The handler validates the envelope, flattens each nested event into flat columns, and batch-inserts. A couple of hard-won rules: drop events missing a type or session ID, clamp absurd numbers, and round anything headed for an integer column, because one fractional value will reject the whole batch, and you don’t want a single bad click to lose ninety good ones. It’s all best-effort and silent; the tracker never reads the response, so there’s nothing to say back.

Everything lands in one wide, denormalized events table. Wide on purpose: analytics queries filter and group, they almost never join, and joins are where things crawl once you’re past a few million rows.

``` js
export const events = pgTable('events', {  eventId:   uuid('event_id').primaryKey().default(sql`gen_random_uuid()`),  siteId:    text('site_id').notNull(),  visitorId: text('visitor_id').notNull(),  sessionId: text('session_id').notNull(),  eventType: text('event_type').notNull(),  url:       text('url').notNull(),  // utm_*, scroll_depth, time_on_page, device_type, ab_test_id, ab_variant...  metadata:  jsonb('metadata'),  timestamp: timestamp('timestamp', { withTimezone: true }).notNull().defaultNow(),}, (t) => [  index('idx_events_site_ts').on(t.siteId, t.timestamp),  index('idx_events_site_type_ts').on(t.siteId, t.eventType, t.timestamp),  index('idx_events_site_visitor').on(t.siteId, t.visitorId),  index('idx_events_site_utm_ts').on(t.siteId, t.utmSource, t.timestamp),]);
```

Four indexes, one for each way the dashboard slices: by time, by type over time, by visitor, by channel over time. There’s a JSONB metadata column as an escape hatch: new event types carry extra fields with no migration, and you promote a field to a real column once you’re querying it enough to want an index.

I’ll end Part 1 on the thing I’d most want someone to tell me before I started.

Do not let your dashboard query the events table directly. Put every read and write behind a handful of functions (queryAggregatedStats, queryFunnelPaths, queryDeadClicks) in one file.

It feels like pointless ceremony on day one. Here’s the payoff. Postgres handles this table happily into the tens of millions of rows. After that you’ll want ClickHouse. With the seam, that migration is rewriting four function bodies. Without it, it’s hunting down every query in your codebase and praying. Pay the small cost now.

That’s the pipeline: events leave the browser, survive the tab closing, and land in a table built to grow. In [Part 2](https://markdowntorichtext.com/part-2-experiments-and-replays.md) I’ll make it earn its keep: running A/B tests with honest statistics, and recording sessions you can actually watch back.

This post is the story. The companion docs are the spec: the full version of every snippet above, with the parts I trimmed for readability put back, written precisely enough to build from:

**How to use them:** two ways. Read a doc alongside this post for the full detail. Or drop it straight into Claude Code, say “build me this,” and let it scaffold the component, then use the doc to check what it gave you. They’re written to be unambiguous enough for either.

[I Built My Own Analytics + AB Testing Tool in a Weekend With Claude Code.](https://pub.towardsai.net/i-built-my-own-analytics-ab-testing-tool-in-a-weekend-with-claude-code-c67f411602f7) was originally published in [Towards AI](https://pub.towardsai.net) on Medium, where people are continuing the conversation by highlighting and responding to this story.
