<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Zenovay</title>
    <description>The latest articles on DEV Community by Zenovay (@zenovay).</description>
    <link>https://dev.to/zenovay</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3772909%2F8887f0bd-adb2-4162-a1ab-3e20ca842724.png</url>
      <title>DEV Community: Zenovay</title>
      <link>https://dev.to/zenovay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zenovay"/>
    <language>en</language>
    <item>
      <title>The "Privacy-First" Mirage: Why Your Analytics Hash is Still Fingerprinting</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Thu, 16 Apr 2026 19:12:02 +0000</pubDate>
      <link>https://dev.to/zenovay/the-privacy-first-mirage-why-your-analytics-hash-is-still-fingerprinting-44ac</link>
      <guid>https://dev.to/zenovay/the-privacy-first-mirage-why-your-analytics-hash-is-still-fingerprinting-44ac</guid>
      <description>&lt;p&gt;"Privacy-first" has become the favorite marketing buzzword for every new analytics tool. But as developers, we shouldn't trust the landing page we should trust the implementation.&lt;/p&gt;

&lt;p&gt;I've been building &lt;a href="https://zenovay.com" rel="noopener noreferrer"&gt;Zenovay&lt;/a&gt;, a lean alternative to the GA4 nightmare, and I spent a lot of time auditing how "privacy-friendly" tools actually handle visitor identity. What I found was a lot of "Fingerprinting Lite" masquerading as privacy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap: User Agent Persistence
&lt;/h2&gt;

&lt;p&gt;Most indie analytics tools use a hash to identify unique visitors without storing an IP. You'll often see logic similar to this in their backends (let's call this the "GhostlyX" approach):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The problematic approach&lt;/span&gt;
&lt;span class="nv"&gt;$visitorHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ip&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$ua&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$site&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$today&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.key'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issue is the &lt;code&gt;$ua&lt;/code&gt; (User Agent). By including the User Agent in the hash, you are effectively &lt;strong&gt;fingerprinting the user's device&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If a user moves from their home Wi-Fi to a 5G network, their IP changes but their User Agent stays identical. The tool can still link those sessions. This creates a persistent identifier that survives network changes, which is exactly what "privacy-first" is supposed to prevent.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Cleaner Approach: Daily Rotating Salts
&lt;/h2&gt;

&lt;p&gt;When we built the tracking engine for Zenovay, we decided: if the data is supposed to be anonymous, it should be &lt;em&gt;actually&lt;/em&gt; anonymous. No fingerprints. No persistence beyond 24 hours.&lt;/p&gt;

&lt;p&gt;We use the &lt;strong&gt;Web Crypto API&lt;/strong&gt; (available natively in edge environments like Cloudflare Workers) to process the IP the millisecond it hits our server.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Implementation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hashIPForVisitor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;websiteId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Daily salt ensures no long-term tracking&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// YYYY-MM-DD&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// We combine IP, Website ID, and the daily salt.&lt;/span&gt;
    &lt;span class="c1"&gt;// NOTICE: No User Agent. No device fingerprinting.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;websiteId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hashBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hashArray&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hashBuffer&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hashArray&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this is technically superior:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero Persistence&lt;/strong&gt; Because we include &lt;code&gt;${today}&lt;/code&gt;, the hash for the same visitor changes at midnight. We have no way of "following" a user across multiple days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No Fingerprinting&lt;/strong&gt; By omitting the User Agent, we ensure we're only measuring "a connection at a specific time," not "a specific device."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database Integrity&lt;/strong&gt; In our Supabase schema, &lt;code&gt;ip_address&lt;/code&gt; is hard-coded to &lt;code&gt;null&lt;/code&gt;. The raw IP never touches the disk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visitorHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hashIPForVisitor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientIP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;website&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hits&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;website_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;website&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ip_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;visitorHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Raw IP is never stored&lt;/span&gt;
    &lt;span class="na"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Architecture: Cloudflare + Supabase + Next.js
&lt;/h2&gt;

&lt;p&gt;Building on a modern edge stack allowed us to keep the entire tracking script under &lt;strong&gt;1KB&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Workers&lt;/strong&gt; Handle incoming requests and hash at the edge, before any data leaves the user's network hop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase (PostgreSQL)&lt;/strong&gt; Stores hashed events. No bloated tracking data means queries stay fast even at millions of rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js (App Router)&lt;/strong&gt; Powers the dashboard, connecting hits to real-time Stripe revenue attribution.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Privacy Marketing vs. Privacy Engineering
&lt;/h2&gt;

&lt;p&gt;The difference comes down to transparency. If a tool doesn't tell you exactly how they generate visitor IDs, they're probably fingerprinting you.&lt;/p&gt;

&lt;p&gt;Real privacy isn't about obfuscating data it's about making sure sensitive data is &lt;strong&gt;gone before it ever hits your database&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I'm bootstrapping Zenovay to prove that you can get world-class business insights and revenue attribution without turning your users into a product.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;How are you handling PII in your tracking stacks? Are you using the Web Crypto API or sticking to traditional server-side hashing? Drop it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>react</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Why I Ditched GA4 for a Custom Next.js + Supabase Analytics Stack</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Tue, 14 Apr 2026 19:38:04 +0000</pubDate>
      <link>https://dev.to/zenovay/why-i-ditched-ga4-for-a-custom-nextjs-supabase-analytics-stack-5foc</link>
      <guid>https://dev.to/zenovay/why-i-ditched-ga4-for-a-custom-nextjs-supabase-analytics-stack-5foc</guid>
      <description>&lt;h2&gt;
  
  
  Why I Ditched GA4 for a Custom Next.js + Supabase Analytics Stack
&lt;/h2&gt;

&lt;p&gt;Let's be honest: &lt;strong&gt;GA4 is a nightmare for developers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's built for enterprise marketing teams. For those of us building micro-SaaS or lean startups, it feels like flying a Boeing 747 just to go to the grocery store. I spent more time debugging my tracking than building my product. So, I simplified everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem: "Tracking Debt"
&lt;/h3&gt;

&lt;p&gt;Most projects end up with a mess of scripts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GA4&lt;/strong&gt; for traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clarity&lt;/strong&gt; for heatmaps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe&lt;/strong&gt; for the actual money.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each script adds weight to your LCP and complicates your privacy policy. Worst of all: these tools don't talk to each other. You see a spike in traffic but have no idea if those users actually paid.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Technical Stack
&lt;/h3&gt;

&lt;p&gt;I wanted something fast, serverless-first, and scalable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; Next.js (App Router)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; Supabase (PostgreSQL)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; TypeScript&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure:&lt;/strong&gt; Cloudflare&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Solution: Contextual Analytics
&lt;/h3&gt;

&lt;p&gt;I built a custom dashboard to answer three questions in one place:&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Technical Health vs. Revenue
&lt;/h4&gt;

&lt;p&gt;One thing GA4 misses is the link between &lt;strong&gt;Core Web Vitals&lt;/strong&gt; and &lt;strong&gt;Revenue&lt;/strong&gt;. By measuring LCP and CLS directly within user sessions, I can see if a slow deployment actually cost me money in real-time.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. The Stripe Link
&lt;/h4&gt;

&lt;p&gt;Client-side event tracking often fails. By connecting directly to the &lt;strong&gt;Stripe API&lt;/strong&gt;, I see the &lt;strong&gt;Real Lifetime Value (LTV)&lt;/strong&gt; of a traffic source. If a Reddit post brings in 1,000 visitors but $0, and a niche newsletter brings in 10 visitors and 5 customers, I need to know that instantly.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. The "Single Script" Philosophy
&lt;/h4&gt;

&lt;p&gt;I replaced the bloat with one lightweight script. No manual event configuration for every button. It captures the interaction layer automatically for heatmaps and replays.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simple implementation&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Analytics&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-custom-tracker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Track&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Analytics&lt;/span&gt; 
    &lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;your_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; 
    &lt;span class="nx"&gt;trackRevenue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; 
    &lt;span class="nx"&gt;captureHeatmaps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; 
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;We've reached a point where analytics tools are more complex than the products we build. If you're feeling overwhelmed by GA4, you're not alone.&lt;/p&gt;

&lt;p&gt;The real question isn't which tool has the most features — it's which tool actually answers your questions. For micro-SaaS, that usually means: who visited, did they pay, and did my latest deploy break anything? A custom stack built on Next.js and Supabase can answer all three in one place, without the overhead of stitching together five different dashboards.&lt;/p&gt;

&lt;p&gt;What's your biggest pain point with modern analytics? Do you prefer specialized tools or a consolidated view?&lt;/p&gt;

&lt;p&gt;More about this implementation: &lt;a href="https://docs.zenovay.com/introduction/" rel="noopener noreferrer"&gt;Zenovay Introduction&lt;/a&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>devchallenge</category>
      <category>webdev</category>
      <category>analytics</category>
    </item>
    <item>
      <title>94% of my traffic shows as direct. Here's what I found</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Mon, 06 Apr 2026 21:21:10 +0000</pubDate>
      <link>https://dev.to/zenovay/94-of-my-traffic-shows-as-direct-heres-what-i-found-47gl</link>
      <guid>https://dev.to/zenovay/94-of-my-traffic-shows-as-direct-heres-what-i-found-47gl</guid>
      <description>&lt;p&gt;I have been tracking my own website zenovay.com for about 30 days now. Here is what the channel breakdown looks like:&lt;/p&gt;

&lt;p&gt;Direct: 402&lt;br&gt;
Organic Search: 24&lt;br&gt;
Referral: 2&lt;br&gt;
Organic Social: 1&lt;br&gt;
Paid Search: 1&lt;br&gt;
Paid Social: 1&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe162sj25m5x8t628md93.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe162sj25m5x8t628md93.png" alt=" " width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;94% Direct. on a site I was actively promoting on Reddit, X, Indie Hackers, and a bunch of Slack and Discord communities during that same period. that felt way too high so I started poking around.&lt;/p&gt;

&lt;p&gt;First thing I realized is dark social is eating my attribution alive. Every link I dropped in slack channels, Discord servers, DMs, private newsletters, none of that carries a referrer header. It all gets dumped into direct. I estimate at least half of that direct bucket is actually community traffic that just can't be attributed properly. Which means I have no idea which community is actually driving results and which ones I am wasting time in.&lt;/p&gt;

&lt;p&gt;Second thing that jumped out was singapore showing up as one of my top countries with 50 visits. I have zero audience there. Never promoted there. Never even thought about that market.&lt;/p&gt;

&lt;p&gt;Pulled up the session data and it was obvious. Single pageview visits, all under 5 seconds, same Chrome Windows combo. Bots or crawlers running from Singapore based infrastructure. Probably inflating my numbers by 12%. I would have never noticed if I hadnt looked at the geo data and sessions together.&lt;/p&gt;

&lt;p&gt;Third thing was kind of an accident. While I was digging through all this I noticed my performance had spiked on a couple of days. Out of curiosity I cross referenced those dates with my cohort retention data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa7n9uqg4rd1pyz5az728.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa7n9uqg4rd1pyz5az728.png" alt=" " width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Mar 9 cohort that signed up during that performance spike had a 2.2% week 1 retention. The Mar 2 cohort when everything was normal had 26.3%. Same product, same onboarding, same everything. The only difference was that half the Mar 9 users were probably staring at a blank screen and bouncing before the page even rendered.&lt;/p&gt;

&lt;p&gt;I would have spent weeks trying to figure out why that cohort churned. Blaming the onboarding, the copy, the pricing. Turns out it was just a slow page.&lt;/p&gt;

&lt;p&gt;The thing that bugs me most is that in most setups these metrics live on completely different screens. Your traffic data is in one tool, your performance data is somewhere else, your retention is in a third place. You would have to manually line up the dates to even notice the correlation. Most people never would.&lt;/p&gt;

&lt;p&gt;Anyway three things I am taking away from this:&lt;/p&gt;

&lt;p&gt;Direct over 30% is not a channel report, it is a data quality problem. If you are not investigating what is hiding in there you are making decisions on incomplete data.&lt;/p&gt;

&lt;p&gt;Bot traffic from cloud regions like Singapore will quietly inflate everything if you dont filter it. Especially on smaller sites where a few dozen fake sessions actually move the percentages.&lt;/p&gt;

&lt;p&gt;Performance and retention need to be visible together. If your performance drops and your retention drops the same week and you can't see both on one screen, you will blame the wrong thing every time.&lt;/p&gt;

&lt;p&gt;Curious what your Direct percentage looks like. Anyone else tried to actually break down what is actually hiding in there?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>learning</category>
      <category>datascience</category>
      <category>marketing</category>
    </item>
    <item>
      <title>Stop asking users for "steps to reproduce". just watch their session break in real time</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Sat, 04 Apr 2026 15:47:58 +0000</pubDate>
      <link>https://dev.to/zenovay/stop-asking-users-for-steps-to-reproduce-just-watch-their-session-break-in-real-time-1e83</link>
      <guid>https://dev.to/zenovay/stop-asking-users-for-steps-to-reproduce-just-watch-their-session-break-in-real-time-1e83</guid>
      <description>&lt;p&gt;The absolute worst message you can get from a user is "the checkout is broken" with zero additional context. no browser info, no console logs, nothing.&lt;/p&gt;

&lt;p&gt;You end up staring at ga4 trying to figure out where the drop off happened but aggregate data is completely useless for debugging a frontend issue. you cant see if it was an api timeout, a hidden element or just a user doing something weird.&lt;/p&gt;

&lt;p&gt;We were dealing with this exact headache a few weeks ago. we had users bouncing at the pricing page and no idea why.&lt;/p&gt;

&lt;p&gt;Instead of guessing we started using session recordings combined with error tracking. we literally watched the playbacks of users getting stuck and saw exactly what was triggered. it turned out to be a clunky layout bug on specific mobile viewports that we missed. fixed it in 10 minutes and conversions went up.&lt;/p&gt;

&lt;p&gt;This is exactly why we built zenovay. if you look at our sidebar we specifically put "Errors" and "Sessions" right next to each other. the goal was to stop switching between an analytics tool, a heatmap tool and an error monitoring tool.&lt;/p&gt;

&lt;p&gt;We just wanted a simple dashboard where you see an error spike, click it and watch the exact session of the user who triggered it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6fh2ibta1kzyz7a2wrzz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6fh2ibta1kzyz7a2wrzz.png" alt=" " width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Right now we are a tiny bootstrapped team of developers trying to build an alternative to the massive google monopoly. it is really tough. we have fewer than 200 users and we are trying to get honest feedback from other devs.&lt;/p&gt;

&lt;p&gt;We have a free tier that gives you the core tracking. if any of you are willing to drop our script into a side project and test out the error tracking and session replays, let me know. ill happily upgrade your account to the pro plan for a few months in exchange for some honest, constructive roasting of our ui and workflow.&lt;/p&gt;

&lt;p&gt;This isnt about self promotion. its about taking on monopoly power and building a tool that actually saves developers time. we are grateful for any feedback.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuqu2ohq2em4cnrfpoeup.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuqu2ohq2em4cnrfpoeup.png" alt=" " width="676" height="1237"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>analytics</category>
      <category>showdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Zenovay vs PostHog: marketing analytics vs product analytics (and why it matters)</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Thu, 02 Apr 2026 06:55:28 +0000</pubDate>
      <link>https://dev.to/zenovay/zenovay-vs-posthog-marketing-analytics-vs-product-analytics-and-why-it-matters-1f8p</link>
      <guid>https://dev.to/zenovay/zenovay-vs-posthog-marketing-analytics-vs-product-analytics-and-why-it-matters-1f8p</guid>
      <description>&lt;p&gt;PostHog and Zenovay get compared a lot, but they are solving fundamentally different problems.&lt;/p&gt;

&lt;p&gt;PostHog answers: "Which features retain users? Where do people drop off in the onboarding flow? Does variant B of the signup page convert better?"&lt;/p&gt;

&lt;p&gt;Zenovay answers: "Which marketing channels bring paying customers? How do visitors from Twitter behave differently from organic visitors? Which blog post generated the most revenue?"&lt;/p&gt;

&lt;p&gt;Product analytics vs marketing analytics. Engineering teams vs founders and marketers. If you pick the wrong one, you will be frustrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where PostHog is genuinely better
&lt;/h2&gt;

&lt;p&gt;PostHog has a generous free tier: 1 million events per month. For early-stage startups with engineering resources, that is real value.&lt;/p&gt;

&lt;p&gt;Feature flags and A/B testing are built in. You can roll out features gradually, test variants, and measure impact without a separate tool. Zenovay does not offer these because they are product tools, not marketing tools.&lt;/p&gt;

&lt;p&gt;PostHog is open source under MIT license. You can self-host, audit the code, and contribute. Their developer community is one of the most engaged in the analytics space.&lt;/p&gt;

&lt;p&gt;If your primary questions are about product engagement, user retention, and feature adoption, PostHog is purpose-built for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Zenovay fills a different gap
&lt;/h2&gt;

&lt;p&gt;Zenovay is built for the question most founders actually ask every morning: "Where are my customers coming from and which channels are worth my time?"&lt;/p&gt;

&lt;p&gt;Connect Stripe and see revenue attribution automatically. No event instrumentation required. No engineering time to set up custom properties. Add one script tag and you have answers in 2 minutes.&lt;/p&gt;

&lt;p&gt;Zenovay also includes heatmaps and session replay. PostHog has these too (via their toolbar), but Zenovay is EU-hosted in Germany by default with cookieless tracking. PostHog Cloud defaults to US hosting, with EU as an option on specific plans.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup gap
&lt;/h2&gt;

&lt;p&gt;This is where the difference hits hardest in practice.&lt;/p&gt;

&lt;p&gt;PostHog is powerful but requires engineering investment. You need to plan your event taxonomy, instrument events in your codebase, configure properties, and build dashboards. For a team with dedicated engineers, that is fine. For a solo founder or small marketing team, it is a barrier.&lt;/p&gt;

&lt;p&gt;Zenovay is designed for non-engineers. One script tag. Connect Stripe. See revenue data. The tradeoff is less customization, but for most marketing questions, the defaults are enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Zenovay Pro ($20/mo)&lt;/th&gt;
&lt;th&gt;PostHog (free to usage-based)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Focus&lt;/td&gt;
&lt;td&gt;Marketing analytics&lt;/td&gt;
&lt;td&gt;Product analytics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revenue attribution&lt;/td&gt;
&lt;td&gt;Built in (Stripe)&lt;/td&gt;
&lt;td&gt;Not built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heatmaps&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Included (toolbar)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session replay&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature flags&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A/B testing&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;2 minutes&lt;/td&gt;
&lt;td&gt;Hours to days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EU hosting&lt;/td&gt;
&lt;td&gt;Default&lt;/td&gt;
&lt;td&gt;US default, EU option&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open source&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (MIT)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pricing&lt;/td&gt;
&lt;td&gt;Flat tiers&lt;/td&gt;
&lt;td&gt;Usage-based&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Can you use both?
&lt;/h2&gt;

&lt;p&gt;Yes, and many teams do. PostHog for product analytics, feature flags, and experiments. Zenovay for marketing analytics, revenue attribution, and heatmaps. They complement each other because they answer different questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  My recommendation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose PostHog if&lt;/strong&gt; you need product analytics (funnels, retention, cohorts), feature flags and A/B testing are essential, you have engineering resources for setup, or open source and self-hosting matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Zenovay if&lt;/strong&gt; you need marketing analytics and revenue attribution, you want actionable data in 2 minutes without engineering, EU hosting and privacy compliance are priorities, or you prefer predictable pricing over usage-based billing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I built Zenovay. PostHog is a great tool for product teams. If product analytics is your primary need, use PostHog. If marketing analytics and revenue attribution are what you need, &lt;a href="https://zenovay.com" rel="noopener noreferrer"&gt;try Zenovay&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>analytics</category>
      <category>saas</category>
      <category>startup</category>
    </item>
    <item>
      <title>Launched my project a few weeks ago, traffic was okay but my retention was a disaster</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Thu, 12 Mar 2026 15:20:12 +0000</pubDate>
      <link>https://dev.to/zenovay/launched-my-project-a-few-weeks-ago-traffic-was-okay-but-my-retention-was-a-disaster-425i</link>
      <guid>https://dev.to/zenovay/launched-my-project-a-few-weeks-ago-traffic-was-okay-but-my-retention-was-a-disaster-425i</guid>
      <description>&lt;p&gt;The first few days actually looked promising. got some decent initial traction from X and Reddit (first slide). was hyped to see people actually clicking through and exploring. &lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcqsvr6tsz1ag1932z1zn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcqsvr6tsz1ag1932z1zn.png" alt="Referrer" width="800" height="494"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But then I checked the cohort retention and it was a bloodbath. Most of my Feb cohorts had a week 1 retention of about 2-4%. I spent a few nights just staring at the screen, thinking the product was useless or that I’d built something nobody wanted. Honestly was already preparing to sunset the project or do a massive pivot. &lt;br&gt;
 &lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyhreqaa52kcmfmam17x6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyhreqaa52kcmfmam17x6.png" alt="Retention" width="800" height="529"&gt;&lt;/a&gt;&lt;br&gt;
Before giving up I looked into the performance metrics. Realized my LCP was spiking like crazy (check the graph below). Basically half of those users were staring at a blank loading screen for nearly 10 seconds and bounced before even seeing the UI. &lt;br&gt;
 &lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0lo7xiuep20gp9xwxty5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0lo7xiuep20gp9xwxty5.png" alt="Performance" width="800" height="526"&gt;&lt;/a&gt;&lt;br&gt;
Spent the last few days optimizing DB queries and fixing some caching issues. Difference is night and day now. Stupid mistake to make during a launch, but I guess that’s part of the learning curve. &lt;br&gt;
Lesson learned: Don’t just track who is coming to your site, track if the site is actually loading for them lol. &lt;br&gt;
Anyone else had a launch "fail" for purely technical reasons? &lt;/p&gt;

</description>
      <category>saas</category>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Zenovay vs Plausible: both privacy-first, but different depth</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Thu, 12 Mar 2026 07:18:33 +0000</pubDate>
      <link>https://dev.to/zenovay/zenovay-vs-plausible-both-privacy-first-but-different-depth-35b3</link>
      <guid>https://dev.to/zenovay/zenovay-vs-plausible-both-privacy-first-but-different-depth-35b3</guid>
      <description>&lt;p&gt;Plausible is one of the tools I respect most in the analytics space. They have been championing privacy-first analytics since before it was trendy, their dashboard is genuinely beautiful, and their tracking script is the smallest in the industry at roughly 1KB.&lt;/p&gt;

&lt;p&gt;Both Zenovay and Plausible are EU-hosted, cookieless, and GDPR compliant without consent banners. So the privacy question is settled. They are essentially equal on that front.&lt;/p&gt;

&lt;p&gt;The real question is: do you need more than traffic counts?&lt;/p&gt;

&lt;h2&gt;
  
  
  What Plausible does perfectly
&lt;/h2&gt;

&lt;p&gt;Plausible is intentionally simple. One page. All the stats you need. Visitors, sources, top pages, locations, devices. No learning curve, no feature bloat.&lt;/p&gt;

&lt;p&gt;It is also fully open source under AGPL. You can audit the code, self-host the community edition, and contribute. That transparency matters, and it is something closed-source tools like Zenovay cannot offer.&lt;/p&gt;

&lt;p&gt;At $9/month for 10K pageviews, it is the most affordable serious analytics tool available. For bloggers, personal sites, and small projects that just need "how many people visited and where did they come from?", Plausible is genuinely hard to beat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the paths diverge
&lt;/h2&gt;

&lt;p&gt;Plausible shows you that 500 visitors came from Twitter yesterday. Zenovay shows you that those 500 Twitter visitors generated $1,200 in revenue through Stripe, while the 300 visitors from your blog post generated $2,800.&lt;/p&gt;

&lt;p&gt;That is the core difference: traffic data vs revenue data.&lt;/p&gt;

&lt;p&gt;Zenovay also includes heatmaps and session replay. You can see where visitors click, how far they scroll, and watch full session recordings. Plausible intentionally does not include any behavior analytics because it conflicts with their minimalist philosophy.&lt;/p&gt;

&lt;p&gt;Both approaches are valid. It depends on what you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feature gap
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Zenovay Pro ($20/mo)&lt;/th&gt;
&lt;th&gt;Plausible ($9-69/mo)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Privacy/EU hosting&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookieless&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revenue attribution&lt;/td&gt;
&lt;td&gt;Yes (Stripe)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heatmaps&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session replay&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open source&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (AGPL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosting&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Community edition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Script size&lt;/td&gt;
&lt;td&gt;~5KB&lt;/td&gt;
&lt;td&gt;~1KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;White-label&lt;/td&gt;
&lt;td&gt;Scale plan&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agency features&lt;/td&gt;
&lt;td&gt;Multi-client dashboards&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The pricing math
&lt;/h2&gt;

&lt;p&gt;Plausible starts at $9/month. Zenovay Pro is $20/month. On the surface, Plausible is cheaper.&lt;/p&gt;

&lt;p&gt;But if you outgrow simple traffic stats and need heatmaps, you add Hotjar ($80+/month). If you need revenue attribution, you add custom GA4 configuration or another tool. Suddenly your "simple" stack costs $100+/month.&lt;/p&gt;

&lt;p&gt;Zenovay Pro at $20/month includes everything. The question is whether you will need those features eventually.&lt;/p&gt;

&lt;h2&gt;
  
  
  My honest recommendation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stay with Plausible if&lt;/strong&gt; simple traffic stats are genuinely all you need, open source and self-hosting matter to your team, you want the absolute smallest tracking script, or budget is tight and basic analytics are enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consider Zenovay if&lt;/strong&gt; you need to know which channels bring paying customers (not just visitors), heatmaps and session replay would help you improve your site, you manage multiple client websites and need agency features, or you want a platform that grows with you instead of switching tools later.&lt;/p&gt;

&lt;p&gt;If you are happy with Plausible, stay with it. It is an excellent tool. Only switch if you find yourself wishing it could tell you more.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I built Zenovay. Plausible is a tool I genuinely respect. If simple, open-source analytics is what you need, use Plausible. If you need more depth while keeping privacy, &lt;a href="https://zenovay.com" rel="noopener noreferrer"&gt;try Zenovay&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>analytics</category>
      <category>privacy</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why I stopped paying $80/mo for Hotjar (and what I use instead)</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Wed, 11 Mar 2026 12:52:35 +0000</pubDate>
      <link>https://dev.to/zenovay/why-i-stopped-paying-80mo-for-hotjar-and-what-i-use-instead-854</link>
      <guid>https://dev.to/zenovay/why-i-stopped-paying-80mo-for-hotjar-and-what-i-use-instead-854</guid>
      <description>&lt;p&gt;For years, my analytics stack looked like this: Google Analytics for traffic data, Hotjar for heatmaps and session recordings. Two tools. Two scripts on every page. Two bills. Two dashboards to check every morning.&lt;/p&gt;

&lt;p&gt;Then I realized something obvious: there is no reason these should be separate products.&lt;/p&gt;

&lt;p&gt;Hotjar is excellent at what it does. Their heatmaps are mature, their session recordings work well, and their surveys are genuinely useful for UX research. But Hotjar cannot tell you where your traffic comes from, which pages convert, or which marketing channels bring revenue. For that, you still need a separate analytics tool.&lt;/p&gt;

&lt;p&gt;Zenovay combines both. Analytics, heatmaps, session replay, and revenue attribution in one platform for $20/month. Here is how they actually compare.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Hotjar does well
&lt;/h2&gt;

&lt;p&gt;Hotjar pioneered the heatmap category and it shows. Their click, scroll, and movement maps are polished. Session recordings are reliable. Rage click detection automatically surfaces frustrated users.&lt;/p&gt;

&lt;p&gt;But the real differentiator is surveys and feedback widgets. If your primary job is UX research, collecting qualitative user feedback directly on your site, Hotjar has built specific tools for that. Inline feedback widgets, on-page surveys, and interview recruitment are features Zenovay does not have.&lt;/p&gt;

&lt;p&gt;If qualitative UX research is your main workflow, Hotjar is purpose-built for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost problem
&lt;/h2&gt;

&lt;p&gt;Here is where the math breaks down.&lt;/p&gt;

&lt;p&gt;Hotjar Business starts at $80/month for 500 daily sessions. Scale is $171+/month. These prices are just for heatmaps, recordings, and surveys. You still need Google Analytics (free but complex) or another analytics tool alongside it.&lt;/p&gt;

&lt;p&gt;So your real stack cost looks like:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GA4&lt;/td&gt;
&lt;td&gt;Traffic analytics&lt;/td&gt;
&lt;td&gt;Free (but hours of setup)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hotjar Business&lt;/td&gt;
&lt;td&gt;Heatmaps + recordings&lt;/td&gt;
&lt;td&gt;$80+/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consent banner tool&lt;/td&gt;
&lt;td&gt;GDPR compliance&lt;/td&gt;
&lt;td&gt;$10-50/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$90-130/mo + setup time&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zenovay Pro includes analytics, heatmaps, session replay, and revenue attribution for $20/month. No separate analytics tool needed. No consent banner needed because it is cookieless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Zenovay Pro ($20/mo)&lt;/th&gt;
&lt;th&gt;Hotjar Business ($80/mo)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Web analytics&lt;/td&gt;
&lt;td&gt;Full dashboard&lt;/td&gt;
&lt;td&gt;Not available (need GA4)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revenue attribution&lt;/td&gt;
&lt;td&gt;Built in (Stripe)&lt;/td&gt;
&lt;td&gt;Not available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heatmaps&lt;/td&gt;
&lt;td&gt;Click, scroll, movement&lt;/td&gt;
&lt;td&gt;Click, scroll, movement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session replay&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rage click detection&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Surveys&lt;/td&gt;
&lt;td&gt;Not available&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feedback widgets&lt;/td&gt;
&lt;td&gt;Not available&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GDPR compliance&lt;/td&gt;
&lt;td&gt;EU-hosted, cookieless&lt;/td&gt;
&lt;td&gt;US-owned (Contentsquare)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;White-label&lt;/td&gt;
&lt;td&gt;Scale plan&lt;/td&gt;
&lt;td&gt;Not available&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The revenue attribution gap
&lt;/h2&gt;

&lt;p&gt;This is the feature that made me switch.&lt;/p&gt;

&lt;p&gt;Hotjar can show you that users are rage-clicking on your pricing page. That is useful. But it cannot tell you whether the people who came from your latest LinkedIn campaign spent more time on that page than organic visitors, or whether they converted to paying customers at a higher rate.&lt;/p&gt;

&lt;p&gt;Zenovay connects to Stripe and maps revenue back to channels, campaigns, and individual pages. You do not just see behavior. You see which behavior leads to money.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to choose Hotjar, when to choose Zenovay
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Hotjar if&lt;/strong&gt; UX research is your primary job, you need built-in surveys and feedback widgets, your team only needs heatmaps and already has analytics covered, or qualitative user research is your main workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Zenovay if&lt;/strong&gt; you want to replace both Hotjar and GA4 with one tool, revenue attribution matters to your business, you need EU hosting for GDPR compliance, or you would rather pay $20/month instead of $80+/month for heatmaps that also include full analytics.&lt;/p&gt;

&lt;p&gt;You can install Zenovay in 2 minutes alongside Hotjar, compare the heatmap data, and then decide whether to drop Hotjar and GA4.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I built Zenovay. This comparison is honest. Hotjar surveys and feedback widgets are features we do not have. If those matter to you, Hotjar is the right tool. If you want analytics + heatmaps + attribution in one place, &lt;a href="https://zenovay.com" rel="noopener noreferrer"&gt;try Zenovay&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>analytics</category>
      <category>ux</category>
      <category>saas</category>
    </item>
    <item>
      <title>Zenovay vs Google Analytics: I switched after 8 years. Here's my honest take.</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Tue, 10 Mar 2026 12:39:30 +0000</pubDate>
      <link>https://dev.to/zenovay/zenovay-vs-google-analytics-i-switched-after-8-years-heres-my-honest-take-54lc</link>
      <guid>https://dev.to/zenovay/zenovay-vs-google-analytics-i-switched-after-8-years-heres-my-honest-take-54lc</guid>
      <description>&lt;p&gt;I ran Google Analytics on every project since 2018. When GA4 replaced Universal Analytics, I stuck with it. Learned the new UI. Rebuilt my reports. Figured out the event model.&lt;/p&gt;

&lt;p&gt;Then last year I installed Zenovay on one of my production sites as an experiment. Two months later, GA4 was gone from that project. Three months later, it was gone from all of them.&lt;/p&gt;

&lt;p&gt;This is not a hit piece on GA4. It is a genuinely good tool for certain use cases. But for the way I work, the tradeoff stopped making sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup gap
&lt;/h2&gt;

&lt;p&gt;GA4 setup is a project. Create a property. Configure a data stream. Install gtag.js or set up Google Tag Manager. Define custom events for the things you actually care about. Configure conversions. Set data retention. If you have EU users, add a consent management platform and configure Consent Mode v2. If you want raw data access, connect BigQuery.&lt;/p&gt;

&lt;p&gt;For someone who has done it before, that is a day of work. For a founder doing it for the first time, it can take a week of Googling.&lt;/p&gt;

&lt;p&gt;Zenovay is one script tag in your HTML head. I timed it once: 1 minute and 40 seconds from signup to seeing live data. No tag manager. No event configuration for the basics. No consent banner needed because it is cookieless by default.&lt;/p&gt;

&lt;p&gt;That gap matters more than people think. Every hour you spend configuring analytics is an hour you are not building product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The daily experience
&lt;/h2&gt;

&lt;p&gt;This is where I felt the difference most.&lt;/p&gt;

&lt;p&gt;With GA4, answering "how many people visited my pricing page yesterday" requires clicking into Explore, creating a free-form report, adding dimensions and metrics, and filtering. There is a reason "GA4 tutorial" has tens of thousands of monthly searches.&lt;/p&gt;

&lt;p&gt;Zenovay gives you one dashboard. When you log in, the answer is right there: visitors, top pages, referral sources, devices, locations on a real-time 3D globe. No report builder. No configuration. Just the data.&lt;/p&gt;

&lt;p&gt;For teams that need custom funnels, cohort analysis, or complex multi-touch attribution across Google Ads campaigns, GA4 has more raw power. But for the daily question of "what is working on my website right now?", Zenovay answers it faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The privacy problem GA4 cannot solve
&lt;/h2&gt;

&lt;p&gt;GA4 uses cookies. In the EU, that means a consent banner. When visitors reject cookies, and research suggests 30-60% do, those visitors disappear from your data completely.&lt;/p&gt;

&lt;p&gt;Your analytics dashboard shows 1,000 visitors. The real number might be 2,000. You are making business decisions on half the picture.&lt;/p&gt;

&lt;p&gt;Multiple EU data protection authorities in Austria and France have ruled against standard GA4 implementations. You can make it compliant with Consent Mode v2, server-side tagging, IP anonymization, and a data processing agreement. But that is significant work, and you still lose the visitors who reject cookies.&lt;/p&gt;

&lt;p&gt;Zenovay is cookieless by default. No consent banner needed. EU-hosted in Germany. You see 100% of your visitors.&lt;/p&gt;

&lt;p&gt;For any business with European traffic, this is not a minor difference. It is a data quality difference that affects every decision you make.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where GA4 still wins
&lt;/h2&gt;

&lt;p&gt;I want to be honest about what you give up by switching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google Ads integration.&lt;/strong&gt; If you run significant Google Ads spend and rely on the analytics-to-ads pipeline for audience building and remarketing, GA4 is essentially irreplaceable. The native integration is deep, and Zenovay cannot match it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BigQuery access.&lt;/strong&gt; The ability to run SQL queries on your raw analytics data is powerful. If your team does custom analysis beyond what any dashboard provides, this matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is free.&lt;/strong&gt; For most use cases, GA4 costs nothing. If your budget is literally zero, GA4 is hard to beat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ecosystem.&lt;/strong&gt; Millions of users. Thousands of tutorials. Every marketing hire already knows it. Switching has a real training cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Zenovay wins
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Revenue attribution.&lt;/strong&gt; Connect Stripe and see which channels, campaigns, and pages bring paying customers. Not just "traffic went up 20%" but "Twitter brought 40 visitors who generated $2,300 in revenue." GA4 can do something similar, but it requires custom dimensions, conversion events, and significant configuration. Zenovay does it out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Heatmaps and session replay included.&lt;/strong&gt; Most GA4 users also pay for Hotjar ($80+/month) to see where people click and watch session recordings. Zenovay includes both. One tool instead of two. One bill instead of two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GDPR compliance without the headache.&lt;/strong&gt; EU-hosted. Cookieless. No consent banners. No DPA rulings to worry about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2-minute setup.&lt;/strong&gt; One script tag. Connect Stripe. See revenue attribution immediately. Your marketing team will actually use it because they do not need a data analyst to find the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pricing math
&lt;/h2&gt;

&lt;p&gt;GA4 is free but most teams also pay for Hotjar ($80+/month) for heatmaps, and spend hours configuring attribution. Zenovay Pro is $20/month and includes analytics, heatmaps, session replay, and revenue attribution.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Zenovay Pro&lt;/th&gt;
&lt;th&gt;GA4 + Hotjar&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Free (GA4)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heatmaps&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;$80+/mo (Hotjar)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session replay&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;$80+/mo (Hotjar)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revenue attribution&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Hours of config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EU hosting&lt;/td&gt;
&lt;td&gt;Default&lt;/td&gt;
&lt;td&gt;Not available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$20/mo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$80+/mo + time&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  My recommendation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stay with GA4 if&lt;/strong&gt; Google Ads is your primary channel, you have a data team that does custom analysis in BigQuery, or your entire organization is trained on GA4 and switching cost is high.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try Zenovay if&lt;/strong&gt; you want to know which channels bring paying customers (not just traffic), you are tired of maintaining GA4 + Hotjar + consent banners, or you need GDPR compliance that actually works without cutting your data in half.&lt;/p&gt;

&lt;p&gt;You can run both in parallel. Install Zenovay in 2 minutes alongside GA4, compare the data for a month, then decide.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to/zenovay/i-tested-8-analytics-tools-heres-what-they-all-get-wrong-12i8"&gt;I tested 8 analytics tools. Here's what they all get wrong.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Full disclosure: I built Zenovay. But I wrote this comparison the way I would want to read it, with honest acknowledgment of where GA4 is better. If GA4 fits your needs, use it. If it does not, &lt;a href="https://zenovay.com" rel="noopener noreferrer"&gt;give Zenovay a try&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>analytics</category>
      <category>privacy</category>
      <category>saas</category>
    </item>
    <item>
      <title>I tested 8 analytics tools. Here's what they all get wrong.</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Sat, 21 Feb 2026 15:03:30 +0000</pubDate>
      <link>https://dev.to/zenovay/i-tested-8-analytics-tools-heres-what-they-all-get-wrong-4l23</link>
      <guid>https://dev.to/zenovay/i-tested-8-analytics-tools-heres-what-they-all-get-wrong-4l23</guid>
      <description>&lt;p&gt;I spent the last 6 months deep in web analytics while building my own platform. Along the way I tested GA4, Hotjar, Mixpanel, Amplitude, Plausible, Fathom, PostHog, and Matomo.&lt;/p&gt;

&lt;p&gt;Every single one forces you into a tradeoff that shouldn't exist. Here's the short version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GA4&lt;/strong&gt; — Free, but requires a certification to find basic data. Filtering for a single page takes 6 clicks. Reports are sampled above 10M events with error rates up to 30%. Cookie consent banners make 40-60% of your EU traffic invisible. The "free" tool effectively requires BigQuery for anything useful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hotjar&lt;/strong&gt; — Great heatmaps, but the script adds ~830ms to your page load and ~0.5MB to page weight. The free plan records 35 sessions per day. Full Business tier across all products costs $922/mo. Session recordings break on Next.js and React apps when CSS filenames change between deploys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mixpanel&lt;/strong&gt; — Powerful product analytics, but 10M events/month costs ~$2,500/mo. One HN user reported getting downgraded from 20M free events to 10K when switching to a paid plan. Client-side tracking loses 30-50% of users to ad blockers per their own docs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Amplitude&lt;/strong&gt; — Similar power, similar problems. Growth plan starts at ~$30K/year. The UI has so many options that teams spend more time navigating the tool than getting insights. Requires 25K+ monthly users minimum for accurate advanced analytics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plausible&lt;/strong&gt; — Beautiful and simple. But resets visitor identity daily, so one person visiting 5 days counts as 5 uniques. No heatmaps, no session replay, no funnels on the self-hosted version. Self-hosted also lacks the bot detection the cloud version uses — one dev saw their stats jump from 200 to 5,000 visitors/day after switching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fathom&lt;/strong&gt; — Clean and privacy-first. But custom events can't send metadata. No goal attribution with UTM parameters. No funnels, no entry/exit pages, no scroll depth. Completely closed-source with no self-hosting option. Shows you what happened, never why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostHog&lt;/strong&gt; — Tries to do everything. The JS SDK is 52KB+. Self-hosting requires ClickHouse, Kafka, Redis, PostgreSQL, Zookeeper, and MinIO running simultaneously. One dev ran up a $10K bill in half a day. Another needed 64GB RAM and still couldn't get it working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Matomo&lt;/strong&gt; — The oldest open-source option. MySQL-based architecture means reports can take hours to generate. UI barely evolved in 10+ years. Heatmaps, session replay, funnels, and A/B testing are all premium plugins at €1,200+/year even for self-hosted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern nobody talks about:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every tool falls into one of two camps. Simple counters (Plausible, Fathom) that show what happened but not why. Or enterprise platforms (GA4, Amplitude, PostHog) that can answer deep questions but require weeks of learning and dedicated engineering.&lt;/p&gt;

&lt;p&gt;Nothing sits in the middle: simple to start, powerful when you need it, privacy-first without sacrificing conversion tracking.&lt;/p&gt;

&lt;p&gt;That gap is exactly why I built &lt;a href="https://zenovay.com" rel="noopener noreferrer"&gt;Zenovay&lt;/a&gt;. One dashboard with analytics, heatmaps, session replay, AI insights, and revenue attribution. No cookies. EU-hosted. $20/mo.&lt;/p&gt;

&lt;p&gt;Not saying it's perfect yet. But the tradeoff between simple and powerful shouldn't be a tradeoff at all.&lt;/p&gt;

&lt;p&gt;What analytics tool are you currently using, and what's the one thing that frustrates you most about it?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>analytics</category>
      <category>privacy</category>
      <category>saas</category>
    </item>
    <item>
      <title>I tested 8 analytics tools. Here's what they all get wrong.</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Thu, 19 Feb 2026 20:10:55 +0000</pubDate>
      <link>https://dev.to/zenovay/i-tested-8-analytics-tools-heres-what-they-all-get-wrong-12i8</link>
      <guid>https://dev.to/zenovay/i-tested-8-analytics-tools-heres-what-they-all-get-wrong-12i8</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxsl4h5vyjm6xbyfnlqrz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxsl4h5vyjm6xbyfnlqrz.jpg" alt=" " width="800" height="450"&gt;&lt;/a&gt;I spent the last 6 months deep in web analytics while building my own platform. Along the way I tested GA4, Hotjar, Mixpanel, Amplitude, Plausible, Fathom, PostHog, and Matomo.&lt;/p&gt;

&lt;p&gt;Every single one forces you into a tradeoff that shouldn't exist. Here's the short version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GA4&lt;/strong&gt; — Free, but requires a certification to find basic data. Filtering for a single page takes 6 clicks. Reports are sampled above 10M events with error rates up to 30%. Cookie consent banners make 40-60% of your EU traffic invisible. The "free" tool effectively requires BigQuery for anything useful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hotjar&lt;/strong&gt; — Great heatmaps, but the script adds ~830ms to your page load and ~0.5MB to page weight. The free plan records 35 sessions per day. Full Business tier across all products costs $922/mo. Session recordings break on Next.js and React apps when CSS filenames change between deploys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mixpanel&lt;/strong&gt; — Powerful product analytics, but 10M events/month costs ~$2,500/mo. One HN user reported getting downgraded from 20M free events to 10K when switching to a paid plan. Client-side tracking loses 30-50% of users to ad blockers per their own docs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Amplitude&lt;/strong&gt; — Similar power, similar problems. Growth plan starts at ~$30K/year. The UI has so many options that teams spend more time navigating the tool than getting insights. Requires 25K+ monthly users minimum for accurate advanced analytics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plausible&lt;/strong&gt; — Beautiful and simple. But resets visitor identity daily, so one person visiting 5 days counts as 5 uniques. No heatmaps, no session replay, no funnels on the self-hosted version. Self-hosted also lacks the bot detection the cloud version uses, one dev saw their stats jump from 200 to 5,000 visitors/day after switching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fathom&lt;/strong&gt; — Clean and privacy-first. But custom events can't send metadata. No goal attribution with UTM parameters. No funnels, no entry/exit pages, no scroll depth. Completely closed-source with no self-hosting option. Shows you what happened, never why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostHog&lt;/strong&gt; — Tries to do everything. The JS SDK is 52KB+. Self-hosting requires ClickHouse, Kafka, Redis, PostgreSQL, Zookeeper, and MinIO running simultaneously. One dev ran up a $10K bill in half a day. Another needed 64GB RAM and still couldn't get it working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Matomo&lt;/strong&gt; — The oldest open-source option. MySQL-based architecture means reports can take hours to generate. UI barely evolved in 10+ years. Heatmaps, session replay, funnels, and A/B testing are all premium plugins at $1,200+/year even for self-hosted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern nobody talks about:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every tool falls into one of two camps. Simple counters (Plausible, Fathom) that show what happened but not why. Or enterprise platforms (GA4, Amplitude, PostHog) that can answer deep questions but require weeks of learning and dedicated engineering.&lt;/p&gt;

&lt;p&gt;Nothing sits in the middle: simple to start, powerful when you need it, privacy-first without sacrificing conversion tracking.&lt;/p&gt;

&lt;p&gt;That gap is exactly why I built Zenovay. One dashboard with analytics, heatmaps, session replay, AI insights, and revenue attribution. No cookies. EU-hosted. $20/mo.&lt;/p&gt;

&lt;p&gt;Not saying it's perfect yet. But the tradeoff between simple and powerful shouldn't be a tradeoff at all.&lt;/p&gt;

&lt;p&gt;What analytics tool are you currently using, and what's the one thing that frustrates you most about it?&lt;/p&gt;




&lt;p&gt;zenovay.com if you want to see what we're building.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>analytics</category>
      <category>privacy</category>
      <category>saas</category>
    </item>
    <item>
      <title>How We Built a Real-Time Analytics Platform on Cloudflare Workers (Architecture Deep-Dive)</title>
      <dc:creator>Zenovay</dc:creator>
      <pubDate>Tue, 17 Feb 2026 20:41:05 +0000</pubDate>
      <link>https://dev.to/zenovay/how-we-built-a-real-time-analytics-platform-on-cloudflare-workers-architecture-deep-dive-5h3h</link>
      <guid>https://dev.to/zenovay/how-we-built-a-real-time-analytics-platform-on-cloudflare-workers-architecture-deep-dive-5h3h</guid>
      <description>&lt;p&gt;Most analytics platforms follow a familiar pattern: events land on a centralized server, get queued, and eventually show up in a dashboard minutes later. When we built zenovay.com, we wanted something fundamentally different. Sub-100ms event ingestion globally, real-time dashboards with live visitor counts, and zero cookies. This article walks through the architecture that makes that work.&lt;/p&gt;

&lt;p&gt;This is not a product walkthrough. It is a technical deep-dive into the design decisions, tradeoffs, and production lessons of running an analytics platform on Cloudflare Workers.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Why Edge-First?
&lt;/h2&gt;

&lt;p&gt;Traditional analytics architectures have a structural problem: the event producer (the visitor's browser) and the event consumer (the analytics server) are physically far apart.&lt;/p&gt;

&lt;p&gt;Consider a visitor in Tokyo hitting a site tracked by an analytics service hosted in us-east-1. The tracking pixel request has to cross the Pacific Ocean, hit a load balancer, wait for an available process (or suffer a cold start), write to a database, and return a response. Round-trip latency: 300-800ms. During that time, the browser is potentially blocking on the request, degrading the user experience of the site being tracked.&lt;/p&gt;

&lt;p&gt;The edge-first approach eliminates this entirely. With Cloudflare Workers, your event ingestion code runs in 300+ data centers worldwide. That visitor in Tokyo hits a Worker in Tokyo. The response comes back in single-digit milliseconds. No cold starts (Workers use V8 isolates, not containers), no cross-ocean roundtrips, no load balancer hop.&lt;/p&gt;

&lt;p&gt;The numbers matter because analytics tracking scripts run on every page load of every tracked site. If your tracking script adds 500ms of latency to page loads, you are degrading the Core Web Vitals of every site that uses your product. At the edge, the overhead drops to effectively zero.&lt;/p&gt;

&lt;p&gt;But edge-first introduces its own set of problems. You cannot just INSERT INTO postgres from 300 data centers simultaneously, you need session identification without centralized state, and you need to get real-time data back to dashboards despite your event source being globally distributed. The rest of this article covers how we solved each of these.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The Architecture
&lt;/h2&gt;

&lt;p&gt;Here is the high-level data flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser (tracking script)
    |
    v
Cloudflare Worker (Hono.js)  &amp;lt;-- Edge: event validation, bot detection, geo lookup
    |
    ├──&amp;gt; Cloudflare KV          &amp;lt;-- Hot data: deduplication, rate limits, live counts
    ├──&amp;gt; Workers Analytics Engine &amp;lt;-- High-cardinality event stream (dual-write)
    └──&amp;gt; Supabase (PostgreSQL)   &amp;lt;-- Persistent storage: visitors, page_views, analytics
            |
            v
        Supabase Realtime (WebSocket)
            |
            v
        Dashboard (Next.js)      &amp;lt;-- Live updates via subscription
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API is a single Cloudflare Worker running Hono.js, an ultra-fast web framework designed for edge runtimes. Hono gives us express-style route handlers with middleware chaining, but with zero Node.js dependencies and sub-millisecond router overhead.&lt;/p&gt;

&lt;p&gt;The Worker handles everything: tracking event ingestion, authentication (JWT validation via Supabase), analytics queries, billing (Stripe), and cron jobs (daily aggregation, retention cleanup). It is a monolith at the edge, and that is intentional. V8 isolates are cheap, and co-locating concerns eliminates inter-service latency.&lt;/p&gt;

&lt;p&gt;Here is a simplified version of the tracking route handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Hono&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hono&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createSupabaseClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../services/supabase&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Hono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Bindings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/:trackingCode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trackingCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;trackingCode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Bot detection (User-Agent + Cloudflare Bot Management signals)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cfData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cfData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;botManagement&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;verifiedBot&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;isBot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_agent&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bot traffic blocked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Rate limiting via KV (60 req/10s burst, 5000 req/hr sustained)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clientIP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CF-Connecting-IP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rateLimitKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`tracking_ratelimit:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;clientIP&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ... KV-based sliding window check ...&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Geolocation from Cloudflare's edge network (free, no API call)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;geoData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;country_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cfData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cfData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cfData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cfData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// 4. Deduplication via KV (5-second window per session+URL)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dedupeKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`dedupe:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;websiteId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CACHE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="c1"&gt;// 5. Write to Supabase + dual-write to Analytics Engine&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSupabaseClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_SERVICE_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visitors&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;visitorRecord&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 6. Cache the response for deduplication&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CACHE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things to note:&lt;/p&gt;

&lt;p&gt;Custom Supabase client. We do not use the official @supabase/supabase-js SDK at runtime. It pulls in too many Node.js dependencies that do not work in the Workers runtime. Instead, we wrote a lightweight PostgrestBuilder class that wraps fetch() calls to Supabase's PostgREST API directly. Same chainable .from().select().eq() syntax, but ~50KB smaller and fully edge-compatible.&lt;/p&gt;

&lt;p&gt;Geolocation for free. Cloudflare attaches geolocation data to every request via the cf object, including country, city, latitude, longitude, timezone, and ASN. No external API call needed. This is one of the biggest advantages of running on Workers: you get production-grade geolocation at zero cost and zero latency.&lt;/p&gt;

&lt;p&gt;Three KV namespaces. We separate concerns across dedicated KV namespaces: CACHE for real-time data and deduplication, RATE_LIMIT for rate limiting state, and SECURITY for IP reputation scoring. This prevents hot keys in one concern from affecting read latency in another.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Session Identification Without Cookies
&lt;/h2&gt;

&lt;p&gt;Privacy-focused analytics means no cookies and no fingerprinting. But you still need to know whether two page views came from the same visitor. Here is how we handle it.&lt;/p&gt;

&lt;p&gt;The tracking script generates two IDs on the client side:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;session_id: A crypto.randomUUID() stored in sessionStorage. Dies when the tab closes. Represents a single browsing session.&lt;/li&gt;
&lt;li&gt;visitor_id: A crypto.randomUUID() stored in localStorage. Persists for 365 days. Represents a returning visitor across sessions.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Client-side tracking script (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zv_sid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zv_sid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;})();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visitorId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zv_vid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zv_vid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach has important tradeoffs:&lt;/p&gt;

&lt;p&gt;No cross-device tracking. A visitor on their phone and their laptop shows as two different visitors. This is by design. Cross-device tracking requires either login-based identity or invasive fingerprinting, and we chose to avoid both.&lt;/p&gt;

&lt;p&gt;No cross-browser tracking. Different browsers on the same device get different visitor_id values because localStorage is browser-scoped. Again, by design.&lt;/p&gt;

&lt;p&gt;Private browsing breaks session continuity. Incognito mode clears localStorage on close, so every incognito session is a "new" visitor. This slightly inflates unique visitor counts but maintains privacy.&lt;/p&gt;

&lt;p&gt;The accuracy tradeoff is acceptable. In practice, the slight overcounting of unique visitors (estimated 5-15% depending on the audience) is a worthwhile trade for not setting any cookies and not doing any fingerprinting. Site owners get accurate trend data, they can see whether traffic is going up or down, which pages perform best, which countries their visitors come from, without compromising visitor privacy.&lt;/p&gt;

&lt;p&gt;For session linking on the server side, when a tracking event arrives, the Worker looks for an existing visitor record matching either the session_id or the visitor_id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Server-side session resolution (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;existingVisitors&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visitors&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id, visited_at, visitor_id, session_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;website_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;websiteId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visited_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;twentyFourHoursAgo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`session_id.eq.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,visitor_id.eq.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visitor_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visited_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ascending&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Prefer session_id match; fall back to visitor_id for cross-subdomain continuity&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;existingVisitors&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;existingVisitors&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visitor_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visitor_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The OR query eliminates a common N+1 pattern where you would first query by session_id, then separately by visitor_id if no match was found. One query, two potential match paths.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Batching Writes and Managing Database Load
&lt;/h2&gt;

&lt;p&gt;A naive implementation would write every tracking event directly to PostgreSQL. At scale, this falls apart quickly. Here is what happens:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Connection exhaustion. Cloudflare Workers can spawn thousands of isolates simultaneously. Each one opening a direct Postgres connection would overwhelm connection limits.&lt;/li&gt;
&lt;li&gt;Write amplification. A single visitor session generates many events: initial pageview, heartbeats every 30 seconds, scroll depth updates, page navigation, exit event. Writing each one as a separate INSERT is wasteful.&lt;/li&gt;
&lt;li&gt;Hot rows. Updating a visitor record on every heartbeat creates lock contention on the same row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our approach combines several strategies:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Upsert instead of insert. Instead of creating a new record for every event, we upsert on session_id. The first event for a session creates the visitor record, subsequent events (heartbeats, page navigations) update it in place:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Session continuation: UPDATE existing record&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visitors&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;visited_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;page_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;trackingData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;time_on_page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trackingData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time_on_page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time_on_page&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;scroll_depth_percentage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trackingData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scroll_depth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scroll_depth&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;had_interaction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;trackingData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;had_interaction&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;had_interaction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;existingVisitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the Math.max calls. This prevents race conditions where a late-arriving heartbeat with older data could overwrite newer values.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;KV-based deduplication. Before writing to Supabase, we check Cloudflare KV for a recent write with the same session_id + URL combination. The dedup window is 5 seconds with a TTL, so KV entries clean themselves up:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dedupeKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`dedupe:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;websiteId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CACHE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// Return cached response&lt;/span&gt;

&lt;span class="c1"&gt;// ... process and write ...&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CACHE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Conditional ML recalculation. Each visitor gets a real-time value score (a weighted prediction based on country, device, browser, session behavior). Recalculating this on every heartbeat is expensive. We only recalculate when there is a meaningful signal change:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shouldRecalculate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heartbeat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;           &lt;span class="c1"&gt;// Always recalc on pageviews&lt;/span&gt;
  &lt;span class="nx"&gt;heartbeatCount&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;        &lt;span class="c1"&gt;// Every 5th heartbeat&lt;/span&gt;
  &lt;span class="nx"&gt;scrollDelta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;               &lt;span class="c1"&gt;// Significant scroll change&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hasInteraction&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hadPrior&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// First interaction detected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reduces ML computation by roughly 80% on active sessions while keeping scores fresh.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Dual-write to Workers Analytics Engine. For high-cardinality queries (unique visitors by country over 90 days, for example), Supabase can be slow. We dual-write every event to Cloudflare's Workers Analytics Engine (WAE), which is designed for exactly this workload:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;writeTrackingEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AnalyticsEngineDataset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;WAETrackingEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;trackingCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeDataPoint&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;indexes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trackingCode&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;blobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deviceType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visitorId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isBot&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;human&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;doubles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loadTimeMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollDepth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viewportWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viewportHeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pagesViewed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valueScore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WAE writes are fire-and-forget with no backpressure. Cloudflare handles the buffering internally. Queries use SQL via their API, and they are fast even over billions of events because WAE uses columnar storage under the hood.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Real-Time Dashboard
&lt;/h2&gt;

&lt;p&gt;The dashboard needs to show live data: visitors currently on the site, a 3D globe with real-time markers, and analytics that update without page refresh. We use a two-layer approach.&lt;/p&gt;

&lt;p&gt;Layer 1: KV for instant counts. When a visitor arrives or leaves, the Worker updates a KV counter. The dashboard reads this counter for the live visitor badge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In the tracking handler - update live count&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visitorKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`visitor:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;websiteId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CACHE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;visitorKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;geoData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;trackingData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;value_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;valueScore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;last_seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// Auto-expires after 5 minutes of inactivity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;KV reads are globally fast (sub-5ms) because they are served from Cloudflare's edge cache. The 5-minute TTL means inactive visitors automatically disappear without any cleanup logic.&lt;/p&gt;

&lt;p&gt;Layer 2: Supabase Realtime for detailed updates. For the full visitor list, the globe visualization, and the activity stream, the dashboard subscribes to Supabase Realtime channels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Dashboard component (simplified)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Subscribe to new visitors for this website&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`visitors:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;websiteId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgres_changes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visitors&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`website_id=eq.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;websiteId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// New visitor arrived - add marker to globe, update sidebar&lt;/span&gt;
      &lt;span class="nf"&gt;addGlobeMarker&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;value_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supabase Realtime uses WebSockets under the hood, backed by PostgreSQL's LISTEN/NOTIFY. When the Worker inserts a new visitor record, PostgreSQL fires a notification, Supabase picks it up and pushes it through the WebSocket to all subscribed dashboard clients. Latency from write to dashboard update is typically under 500ms.&lt;/p&gt;

&lt;p&gt;The 3D globe itself uses Mapbox GL JS with custom markers. Each new visitor gets a marker at their geographic coordinates, color-coded by value score (green for premium visitors, blue for medium, gray for low). Markers animate in and fade out based on session activity.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Performance Results
&lt;/h2&gt;

&lt;p&gt;After running this architecture in production, here are the numbers:&lt;/p&gt;

&lt;p&gt;Event ingestion P95: 47ms globally. This includes JSON parsing, bot detection, geolocation lookup (from the cf object, so free), KV dedup check, Supabase write, and response serialization. The Supabase write is the long pole at ~30-40ms, but it happens asynchronously relative to the response in most cases.&lt;/p&gt;

&lt;p&gt;Event ingestion P99: 89ms. The tail latency comes from cold KV reads (first read after a key expires) and occasional Supabase connection resets.&lt;/p&gt;

&lt;p&gt;Dashboard real-time latency: ~400ms. From the moment a visitor lands on a tracked site to the moment their marker appears on the dashboard globe. This includes Worker processing (~50ms), Supabase write (~40ms), PostgreSQL NOTIFY propagation (~100ms), WebSocket delivery (~100ms), and client-side rendering (~100ms).&lt;/p&gt;

&lt;p&gt;Zero cold starts. V8 isolates spin up in under 5ms compared to 200-2000ms for container-based serverless. In practice, Workers are almost always warm because analytics tracking generates continuous traffic across all data centers.&lt;/p&gt;

&lt;p&gt;Rate limiting accuracy: 99.7%. KV-based rate limiting has eventual consistency, so there is a small window where a burst can exceed limits. We accept this tradeoff because the alternative (centralized rate limiting) would add 50-100ms of latency to every request.&lt;/p&gt;

&lt;p&gt;For comparison, a traditional setup with a Node.js server in us-east-1, writing to RDS PostgreSQL, would see P95 latencies of 200-600ms for visitors outside North America, with cold starts adding 500ms+ after periods of low traffic.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Lessons Learned
&lt;/h2&gt;

&lt;p&gt;What worked well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hono.js is excellent for Workers. Express-style ergonomics with zero Node.js runtime overhead. The middleware chain pattern (CORS, IP blocklist, rate limit, bot protection, handler) is clean and composable.&lt;/li&gt;
&lt;li&gt;Cloudflare's cf object for geolocation. Eliminating the external GeoIP API call removed ~100ms and an external dependency from every request. The data quality is comparable to MaxMind.&lt;/li&gt;
&lt;li&gt;KV for deduplication and rate limiting. The global consistency model (write at one edge, readable everywhere within 60s) is perfect for these use cases. We do not need strict consistency. If a dedup check misses in a 60-second window, the worst case is one duplicate write.&lt;/li&gt;
&lt;li&gt;Supabase as the persistence layer. PostgreSQL is boring in the best way. RLS policies, real-time subscriptions, and a REST API that works from edge runtimes without an SDK.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What was harder than expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Custom Supabase client. The official SDK does not work in Workers due to Node.js dependencies (node:crypto, node:events). Writing a compatible PostgREST client from scratch took a week and introduced subtle bugs around .maybeSingle() behavior and error code parsing.&lt;/li&gt;
&lt;li&gt;KV write limits. Cloudflare KV has a 1,000 writes/second limit per namespace. Our rate limiter was writing on every request, quickly hitting this limit. We fixed it by only writing to KV when the request count is within 90% of the limit or when there are active violations, reducing writes by ~95%.&lt;/li&gt;
&lt;li&gt;Connection resets to Supabase. Workers do not keep persistent HTTP connections between invocations. Every Supabase call opens a new TCP+TLS connection. At high traffic, this creates connection churn. Supabase handles it well, but P99 latency spikes during connection storms.&lt;/li&gt;
&lt;li&gt;Time zone handling at the edge. JavaScript Date in Workers always uses UTC. Converting to the visitor's local timezone for "business hours" detection requires the timezone string from the cf object, and it is not always present.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What we would change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Workers Analytics Engine from day one. We added WAE as a dual-write later, but it should have been the primary analytics store from the start. SQL queries over WAE are faster than aggregating raw visitor rows in PostgreSQL for any non-trivial time range.&lt;/li&gt;
&lt;li&gt;Durable Objects for live counts. We started with KV for live visitor counting, but KV's eventual consistency means the count can be stale by up to 60 seconds. Durable Objects provide strong consistency for counters, we are migrating to this now.&lt;/li&gt;
&lt;li&gt;Fewer Supabase queries per tracking request. The current flow makes 3-5 Supabase calls per event (website lookup, existing visitor check, upsert, page view insert, goal detection). Batching these into a single RPC call or using Supabase Edge Functions co-located with the database would cut latency significantly.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Building analytics at the edge is not just about speed, it changes what is architecturally possible. When your ingestion latency is under 50ms, you can show visitors on a live 3D globe as they arrive. When there are no cold starts, your tracking script never degrades the sites it monitors. When you run in 300+ locations, geolocation comes free.&lt;/p&gt;

&lt;p&gt;The stack (Cloudflare Workers + Hono.js + Supabase + KV) is production-ready for this workload. The rough edges are real (custom SDK clients, KV write limits, connection churn), but they are solvable problems, not architectural dead ends.&lt;/p&gt;

&lt;p&gt;If you are building something that needs global sub-100ms response times with real-time downstream updates, this pattern works. The event source runs at the edge, hot state lives in KV, cold state lives in PostgreSQL, and WebSockets bridge the gap to the client.&lt;/p&gt;

&lt;p&gt;This architecture powers zenovay.com, the real-time analytics platform we built for privacy-focused website analytics with 3D globe visualization.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>analytics</category>
      <category>architecture</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
