Visual Guide | Technology

Build Your Own Analytics
& Debug It Like a Pro

A complete guide to self-hosted, cookie-free analytics with Cloudflare Workers — and the Chrome DevTools debugging masterclass that followed when nothing worked.

By Paddy Iyer  ·  PaddySpeaks.com  ·  30 min read
Scroll
Part One

Your Website, Your Data

Build a privacy-respecting analytics system you fully own — no Google, no cookies, no third-party scripts phoning home.

Every time someone visits your website, a silent transaction occurs. Their browser whispers a wealth of information — where they came from, what device they carry, which city they sit in, what language they speak. Most developers surrender this data to Google Analytics without a second thought. A free tool. A simple script tag. Done.

But "free" has a price. Google owns that data. They use it for ad targeting. Your visitors get tracked across the web. You need cookie consent banners. And increasingly, ad blockers strip Google Analytics entirely — meaning you lose 30-40% of your traffic data.

What if you could capture every dimension — geo, device, browser, referrer, screen size — without cookies, without Google, without consent banners? What if the dashboard lived on your own website, protected by your own password, with data stored in your own database?

That is exactly what we built. And this is exactly how you can too.

01 — The Architecture

Four Components. Zero Cookies.

The entire system has four pieces, each doing one job brilliantly:

🌐
Visitor
Browser
📡
1KB Script
ps.js
Edge Worker
Cloudflare
💾
SQLite
D1
📊
Your Eyes Only
Dashboard

The Tracker (ps.js) — A 1KB script on every page. Uses sendBeacon to fire a non-blocking POST with page path, referrer, screen size, language, and a session ID stored in sessionStorage (not cookies). Returns instantly. Visitors never notice.

The Worker (Cloudflare) — A serverless function at the edge. Receives the beacon, enriches it with geo data (country, city, region) from Cloudflare's network for free, parses the User-Agent for browser/OS/device, and writes to D1. Returns "ok" immediately — the database write happens in the background via ctx.waitUntil().

D1 Database — Cloudflare's SQLite-at-the-edge. A single page_views table stores everything. Free tier handles 100K writes/day — more than enough for most sites.

The Dashboard — A password-protected page on your site at /analytics/. Login hashes your password with SHA-256, sends it as a Bearer token. The Worker validates it. Chart.js renders the data. No analytics data exists in the HTML source — everything is fetched via authenticated API.

02 — What You Can Capture

Every Dimension. No Third-Party API.

All of this comes free from the browser and Cloudflare's edge network:

🌍

Country & City

Cloudflare's request.cf gives you country, city, region, and timezone — no geo IP API needed.

📱

Device Type

Mobile, Desktop, or Tablet — parsed from the User-Agent string on the server side.

🌐

Browser & OS

Chrome, Safari, Firefox, Edge — plus Windows, macOS, iOS, Android, Linux.

📐

Screen Resolution

Captured from screen.width and screen.height on the client side.

🔗

Referrer

Where they came from: Google, Twitter, LinkedIn, or a direct visit. document.referrer tells all.

📄

Page Path

Which pages are popular? location.pathname captures the exact URL path.

🌎

Language

navigator.language reveals whether visitors prefer English, Hindi, Spanish, or others.

🔄

Sessions

A random UUID in sessionStorage tracks unique sessions — no cookies, no PII, resets on tab close.

03 — The Tracker

1KB That Does Everything

The entire tracking script fits in under 1 kilobyte. Here's what it does:

// ps.js — PaddySpeaks Analytics Tracker (function () { var ENDPOINT = 'https://ps.yourdomain.com/api/v'; // Skip prerendered pages if (document.visibilityState === 'prerender') return; // Session ID — sessionStorage, not cookies var sid = sessionStorage.getItem('_ps_sid'); if (!sid) { sid = crypto.randomUUID(); sessionStorage.setItem('_ps_sid', sid); } // Payload — minimal, no PII var data = JSON.stringify({ p: location.pathname, r: document.referrer, s: screen.width + 'x' + screen.height, l: navigator.language, sid: sid }); // Send as Blob with correct Content-Type var blob = new Blob([data], { type: 'application/json' }); navigator.sendBeacon(ENDPOINT, blob); })();

The file is named ps.js, not tracker.js. The directory is /lib/, not /analytics/. Names matter when ad blockers are watching. More on this in Part 2.

Two critical details most tutorials miss:

1. Use a Blob, not a string. sendBeacon(url, string) sends with Content-Type text/plain. The server's request.json() may silently fail. Wrapping in new Blob([data], { type: 'application/json' }) ensures correct parsing.

2. sessionStorage, not cookies. The session ID resets when the tab closes. No cookie banners needed. No GDPR headaches. No PII stored.

04 — The Worker

Your Backend in 200 Lines

The Cloudflare Worker handles three endpoints:

Geo enrichment — free from Cloudflare

// The magic: Cloudflare gives you geo data for free const cf = request.cf || {}; const country = cf.country; // "US", "IN", "DE" const city = cf.city; // "San Francisco" const region = cf.region; // "California"

Non-blocking writes — return instantly

// Return "ok" immediately. DB write happens in background. ctx.waitUntil( env.DB.prepare(`INSERT INTO page_views (...) VALUES (...)`) .bind(page, referrer, country, city, browser, os, ...) .run() ); return new Response('ok');

Batch queries — one round-trip for the entire dashboard

// 11 queries in a single D1 round-trip const batch = await env.DB.batch([ env.DB.prepare(`SELECT COUNT(*) ... FROM page_views`), env.DB.prepare(`SELECT DATE(created_at), COUNT(*) ... GROUP BY date`), env.DB.prepare(`SELECT page, COUNT(*) ... GROUP BY page`), env.DB.prepare(`SELECT country, COUNT(*) ... GROUP BY country`), // ... 7 more queries ]);
05 — The Dashboard

Password-Protected. Data-Rich.

The dashboard lives at /analytics/ on your site. It's a single HTML page with a login screen.

The HTML source contains zero analytics data. Everything is fetched via authenticated API after you enter your password.

Here is the dashboard with real data — stat cards, engagement metrics, visitors over time, top pages with avg read time and scroll depth:

PaddySpeaks Analytics Dashboard v2 — stat cards showing page views, unique visitors, avg time on page, scroll depth, new vs returning visitors, visitors over time chart, and top pages with per-page read time and scroll percentage

Scrolling down: cities, device/browser/OS charts, referrers, UTM campaigns, hourly traffic, and timezones:

PaddySpeaks Analytics Dashboard v2 — cities, device/browser/OS doughnut charts, referrers, UTM campaigns, hourly traffic bar chart, and visitor timezones

When you log in, the page hashes your password with SHA-256 in the browser, sends it as a Bearer token, and the Worker compares it against the stored hash. If it matches, you get your data. If not, nothing.

The dashboard shows: visitors over time (line chart), top pages (bar chart), countries with flag emojis, cities, device breakdown (doughnut), browsers, operating systems, referrers, screen resolutions, and languages. Chart.js handles the rendering. A real-time "active now" counter refreshes every 30 seconds.

The page is marked noindex, nofollow so search engines ignore it entirely.

06 — Setup Checklist

Browser Only. No CLI Required.

Every step can be done from your browser — no terminal, no npm, no wrangler CLI.

— ⇌ —
Part Two

When Things Break

We built it. We deployed it. And nothing worked. No data, no errors, just silence. Here is how we debugged every failure — and a masterclass in Chrome DevTools.

The analytics system was live. The tracker was on every page. The Worker was deployed. The database was ready. And the dashboard showed... nothing. Not an error. Not a warning. Just empty charts staring back at us.

What followed was a debugging odyssey that touched every corner of Chrome DevTools — the Network tab, the Console, CORS errors, ad blockers, silent failures, and invisible browser guards. Every single issue taught a lesson that generalizes far beyond analytics.

This is that story, told as a practical guide you can use the next time your code silently refuses to cooperate.

07 — Opening Chrome DevTools

Your Debugging Command Center

Chrome DevTools is built into every Chrome and Edge browser. It is the single most powerful debugging tool a web developer has. Here is how to open it and what each panel does.

Three ways to open DevTools

Keyboard Shortcut

F12 on Windows/Linux
Cmd + Option + I on Mac
This is the fastest way. Memorize it.

🖱

Right-Click → Inspect

Right-click any element on the page and select "Inspect". Opens DevTools with that element selected in the Elements panel.

Menu

Chrome menu (⋮) → More ToolsDeveloper Tools. Useful if keyboard shortcuts are disabled.

The six panels you need to know

ElementsConsoleSourcesNetworkApplicationPerformance

Elements — See and edit the live HTML/CSS of the page. Right-click any element → Inspect to jump here. Change styles in real-time. Debug layout issues.

Console — Run JavaScript commands. See errors and warnings. Test API calls. Inspect variables. This is your interactive terminal.

Sources — Browse all loaded files. Set breakpoints. Step through code line by line. Find where functions are defined.

Network — See every HTTP request. Check status codes, headers, payloads, and timing. Filter by type (XHR, JS, CSS, images).

Application — Inspect cookies, localStorage, sessionStorage, IndexedDB, service workers, and cache storage.

Performance — Record page load or interaction, see flame charts, identify bottlenecks, measure FPS and memory usage.

08 — The Console: Your Interactive Terminal

Run Commands. Inspect Everything.

The Console is where you talk to the browser. Open it with F12 → Console tab (or press Esc while in any other panel to open a console drawer at the bottom). Here are the commands you'll use most:

Inspecting the page

// What's in the page title? document.title // → "PaddySpeaks — Spirituality, Vedic Wisdom..." // Is our tracker script loaded? document.querySelector('script[src*="ps.js"]') // → <script defer src="/lib/ps.js"></script> (if loaded) // → null (if missing!) // What's in sessionStorage? sessionStorage.getItem('_ps_sid') // → "a1b2c3d4-e5f6-..." (session ID) or null // Is Do Not Track enabled? navigator.doNotTrack // → "1" (enabled — tracker will silently exit!) // → null (not set — tracker runs normally)

Testing API calls directly

When the tracker isn't working, bypass it entirely and test the server:

// Test: Can the server receive data? fetch('https://ps.yourdomain.com/api/v', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ p: '/test', r: '', s: '1920x1080', l: 'en', sid: 'test-123' }) }).then(r => r.text()).then(console.log) // → "ok" (server works!) or error (server problem)

Generating a SHA-256 hash (for dashboard password)

// Generate password hash right in the browser crypto.subtle.digest('SHA-256', new TextEncoder().encode('your-password') ).then(buf => { const hash = Array.from(new Uint8Array(buf)) .map(b => b.toString(16).padStart(2, '0')).join(''); console.log(hash); }); // → "5e884898da28047151d0e56f8dc..." (64 hex chars)

Useful console methods

// Pretty-print an object console.table([{page: '/about', views: 42}, {page: '/gita', views: 108}]) // Time how long something takes console.time('fetch'); await fetch('/api/stats'); console.timeEnd('fetch'); // → fetch: 234ms // Group related logs console.group('Analytics Debug'); console.log('DNT:', navigator.doNotTrack); console.log('Session:', sessionStorage.getItem('_ps_sid')); console.groupEnd(); // Clear the console console.clear() // Copy any value to clipboard copy(document.title) // Now Ctrl+V pastes the page title

Pro tip: Press the up arrow in the Console to cycle through previous commands. Press Ctrl+L to clear. Type any variable name and press Enter to inspect it. The Console remembers everything until you close the tab.

09 — The Network Tab

See Every Request Your Browser Makes

Open DevTools → Network tab → refresh the page. Every file, image, font, API call, and beacon appears here in chronological order.

ElementsConsoleSourcesNetwork
NameStatusTypeInitiatorTime
index.html200documentOther95ms
style.css200stylesheetindex:2812ms
app.js200scriptindex:14222ms
ps.js200scriptindex:14817ms
v200pingps.js:3985ms
v(blocked)pingps.js:391ms

Understanding each column

Clicking a request — the detail panels

Click any request in the Network tab to open its detail view with these sub-tabs:

📋

Headers

See request and response headers. Check Content-Type, Access-Control-Allow-Origin (CORS), Authorization, and status codes.

📦

Payload

See what data was SENT with the request. For our tracker, this shows the JSON body: {p: "/about", r: "", s: "1920x1080"}.

💬

Preview / Response

See what data came BACK from the server. For our stats API, this shows the full JSON with views, countries, etc.

Timing

Breakdown of DNS lookup, TCP connection, TLS handshake, waiting (TTFB), and content download. Identifies where slowness lives.

Network tab power features

Checkbox: "Preserve log" Keep logs across page navigations. Essential when debugging redirects or form submissions. Checkbox: "Disable cache" Force fresh downloads. Use this when you've deployed new code but keep seeing the old version. Filter bar: "Fetch/XHR" Show only API calls. Hides images, fonts, CSS. Perfect for debugging API issues. Filter bar: type "ps.js" or "api/v" Free-text search to find specific requests instantly. Throttling dropdown: "Slow 3G" Simulate slow connections. See how your site behaves on bad networks.

Real example: ps.js loaded but no outbound request

In this screenshot, notice that ps.js loaded successfully (200) but there is no collect or v request. The tracker script ran but exited silently — this was caused by navigator.doNotTrack being enabled:

Chrome Network tab showing ps.js loaded with status 200 but no collect request — Do Not Track caused silent exit

Real example: dashboard API calls working

When the dashboard is working correctly, you see the realtime API calls returning 200 with preflight checks passing:

Chrome Network tab showing realtime API calls returning 200 with successful CORS preflight

If your request doesn't appear in the Network tab, it never left the browser. The code either didn't run, hit a guard clause and returned early, or was blocked before execution. This is the most important signal: presence means "it tried", absence means "it didn't."

10 — Elements & Application Panels

Inspect the Page. Inspect the Storage.

Elements panel: live HTML & CSS

Right-click any element on the page → Inspect. You jump straight to that element in the DOM tree. You can:

Application panel: storage inspection

DevTools → Application tab (might be behind >> overflow menu). This is where you inspect browser storage:

🍪

Cookies

See all cookies for the current domain. Check name, value, domain, expiry, and flags (HttpOnly, Secure, SameSite). We use zero cookies.

💾

Session Storage

Key-value pairs that live until the tab closes. Our tracker stores _ps_sid here — the anonymous session ID.

📦

Local Storage

Key-value pairs that persist forever (until cleared). Useful for settings, tokens, or cached data. Our dashboard stores the auth token here.

To verify the tracker session ID exists, go to Application → Session Storage → your domain and look for _ps_sid.

The Elements panel in action

Here is what the Elements panel looks like when inspecting our analytics article. Notice the DOM tree on top showing every section, and the Styles pane below showing computed CSS for the <body> element:

Chrome DevTools Elements panel showing DOM tree of the analytics article with CSS styles pane below

The Elements panel is your X-ray vision into any webpage. Right-click any element on the page → Inspect to jump straight to it in the DOM tree. Change text, toggle CSS properties, and see results instantly.

11 — ERR_BLOCKED_BY_CLIENT

The Ad Blocker Wall

Failed to load resource: net::ERR_BLOCKED_BY_CLIENT    tracker.js:1

What it means

A browser extension (usually an ad blocker like uBlock Origin or AdBlock Plus) killed the request before it reached the server. The browser's network stack was told: "don't even try."

Here is what it looks like in the Console — a red error that tells you the resource was killed before reaching the server:

Chrome Console showing Failed to load resource: net::ERR_BLOCKED_BY_CLIENT for tracker.js

And in the Network tab, the request shows as (blocked) with type ping — the ad blocker intercepted the sendBeacon call. Click the request to see the Headers panel showing "Provisional headers" — meaning the request never actually left:

Chrome Network tab showing collect request blocked by ad blocker with provisional headers and DNT:1 header visible

After renaming the endpoint from /collect to /api/v, the ad blocker STILL blocked it — this time matching on the workers.dev domain:

Chrome Network tab showing /api/v request still blocked — ad blocker matching on workers.dev domain

Ad blockers use pattern matching on URLs, domains, and request types. Here is our debugging timeline — four attempts before success:

Attempt 1: Rename the file

Changed analytics/tracker.js to lib/ps.js. Script loaded! But the beacon to /collect was still blocked.

Attempt 2: Rename the endpoint

Changed /collect to /api/v. Still blocked. The ad blocker wasn't matching the path — it was matching the domain.

Attempt 3: The domain itself

workers.dev is on every major ad blocker's domain blocklist. Any request to *.workers.dev gets killed regardless of the path.

Attempt 4: Custom domain

Added ps.yourdomain.com as a custom domain in Cloudflare. Same root domain as the website. Ad blockers don't touch it.

Ad blockers don't just block scripts. They block domains, URL patterns, and request types. The workers.dev domain is on every major blocklist. A custom subdomain of your own domain is the permanent fix.

12 — CORS Errors

When Domains Don't Trust Each Other

Access to fetch at 'https://ps.example.com/api/v' from origin 'https://example.com' has been blocked by CORS policy

What it means

Your browser is a bouncer. When paddyspeaks.com tries to send data to ps.paddyspeaks.com, the browser asks the server: "Do you allow requests from this origin?" If the server doesn't respond correctly, the request is blocked.

The CORS handshake

BrowserOPTIONS /api/v (preflight)Server
Browser200 + Allow-Origin headerServer
BrowserPOST /api/v (actual request)Server

Here is what a CORS error looks like in the Network tab. Notice the preflight (OPTIONS) returned 200, but the actual ping request shows CORS error. The Issues panel at the bottom flags it: "Ensure credentialed requests are not sent to CORS resources with origin wildcards":

Chrome Network tab showing CORS error — preflight 200 but actual request failed due to wildcard origin with credentials

And here is the full browser view — the page loaded fine, but the analytics beacon failed silently with a CORS error visible only in DevTools:

Full browser view showing Durga Suktam page with DevTools open revealing CORS error on analytics beacon

Our specific issue: ps.paddyspeaks.com is a subdomain, so cookies for .paddyspeaks.com are sent automatically. This makes it a credentialed request — and credentialed requests cannot use the wildcard * origin.

Problem

Access-Control-Allow-Origin: * with cookies = browser rejects the response. Wildcard is not allowed for credentialed requests.

Solution

Return the specific Origin header from the request, plus Access-Control-Allow-Credentials: true.

// Before (broken) { 'Access-Control-Allow-Origin': '*' } // After (works) const origin = request.headers.get('Origin'); { 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Credentials': 'true' }
13 — Console Errors Decoded

Reading the Red Text

The Console tab (F12 → Console) shows JavaScript errors. Here is what multiple errors look like stacked together — each one telling a different story:

Chrome Console showing multiple errors: ERR_BLOCKED_BY_CLIENT, SyntaxError, and another blocked resource

Read each error from top to bottom. The file and line number on the right (e.g., resume.html:610) is a clickable link — it takes you directly to the problem in the Sources panel. Here are the three errors we hit and what each one teaches:

Uncaught ReferenceError: loadDashboard is not defined

JavaScript hoisting strikes again

The function loadDashboard was called on line 432, but defined on line 473. In JavaScript, function foo() {} declarations are hoisted (available everywhere), but window.foo = function() {} assignments are NOT. They execute top-to-bottom.

Fix: Define functions before calling them. Or use function loadDashboard() {} (declaration) instead of window.loadDashboard = async function() {} (assignment).
Uncaught SyntaxError: Unexpected end of input (at resume.html:610)

A script tag inside a template literal

Chrome Console showing Uncaught SyntaxError: Unexpected end of input at resume.html:610

When we injected the tracker using sed before every </body>, it also matched </body> tags inside JavaScript template strings (backtick strings for print/export). The injected <script> tag broke the template literal's syntax.

Fix: After bulk injection, check files with template literals or inline JavaScript. Remove any tracker tags that ended up inside JS strings.
Failed to load resource: net::ERR_BLOCKED_BY_CLIENT    tracker.js:1

Same as Network tab — ad blocker

This error appears in Console when a resource is blocked by a browser extension. The Network tab shows the same info with more detail (status, type, initiator).

Fix: Rename files and endpoints to avoid ad-blocker patterns. Use a custom domain.
14 — The Silent Killers

No Error. No Warning. No Data.

The hardest bugs aren't the ones that scream. They're the ones that whisper.

Do Not Track: The invisible guard

The tracker loaded (200 in Network tab). No console errors. But no /api/v request was sent. The data was simply... missing.

The culprit: navigator.doNotTrack === '1'. Our tracker had a guard clause that silently returned if Do Not Track was enabled. Many browsers enable DNT by default. The W3C abandoned the DNT specification years ago. Even privacy-respecting tools like Plausible and Umami ignore it.

Symptom

Script loads (200), no errors, but no outbound request in Network tab. Complete silence.

Diagnosis

Run navigator.doNotTrack in Console. If it returns "1", that's your blocker. Check the script's guard clauses.

sendBeacon Content-Type: The invisible mismatch

Even after fixing DNT, the D1 database was empty. The beacon was being sent (visible in Network tab). The Worker returned "ok". But no rows appeared in the database.

The root cause: sendBeacon(url, string) sends with Content-Type text/plain. The Worker's request.json() silently failed to parse it. Since the DB write was in ctx.waitUntil(), the error was swallowed — and the response was still "ok".

// Broken: sends as text/plain navigator.sendBeacon(url, JSON.stringify(data)); // Fixed: sends as application/json var blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); navigator.sendBeacon(url, blob);

When there are no errors but nothing works, the bug is in the assumptions. Check guard clauses. Check Content-Types. Check what the server actually received versus what you think you sent.

15 — The Debugging Playbook

A Systematic Approach

The next time something doesn't work, follow these steps in order:

Quick Reference

SymptomLikely CauseFix
(blocked) in NetworkAd blockerRename files, endpoints, or use custom domain
CORS errorMissing or wrong headersReturn specific Origin + Credentials header
Function not definedJS hoisting — called before assignmentReorder code or use function declarations
SyntaxError: end of inputBroken template literal or missing bracketCheck for injected code inside JS strings
Script 200, no request sentGuard clause (DNT, prerender)Check navigator.doNotTrack, remove guard
Request 200, DB emptyContent-Type mismatchUse Blob with application/json type
Incorrect password on dashboardSecret overwritten by deployRe-set secret after each deployment
The developer who can debug is more valuable than the one who can only build. Every error message is a breadcrumb. Follow them.

You now own your analytics — every byte of visitor data sits in your database, viewed through your dashboard, protected by your password. No Google. No cookies. No consent banners.

And the next time something breaks — because it will — you know exactly where to look.

Read more at paddyspeaks.com