Adding analytics to a static website with Supabase

I wanted to know if anyone is actually looking at my personal homepage (this site). I doubt it! But still.

It’s a static website, a SvelteKit app using adapter-static that pushes up to Vercel.

It’s also a traditional “multipage app,” which simplifies analytics because each top level document load corresponds one-to-one to a page view. I just need a snippet of Javascript that runs onload and shoots off a fire-and-forget XHR POST somewhere, logging the page view. And I was wondering what the simplest possible backend for that could be.

I mean the simplest would be one of those 90’s style web counters, and that’s honestly almost good enough. All I want is a general sense of what’s going on traffic wise. But I think I can do better.

Why not use Google Analytics?

I don’t know, because it’s good to challenge the dominant player? (See: Why I use Firefox as my main browser.) Because Google is terrible at frontend development and probably has a massively slow and bloated website for viewing the data? Maybe I’m just in a DIY mood?

Frontend

On the client-side it’s super simple, it's just a fetch call (in SvelteKit this might all go in an onMount callback).

fetch('https://mykickassanalyticstracker.com/track_hit’, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    referer: document.referrer,
  ),
});

Except that browsers no longer send the path as part of the referer header (you can force them to with Referrer-Policy but support for it is going away), so we need to pass it in the post data.

Also, if the user is quickly navigating through pages we don’t want to fail to record their page view. A quick glance at https://css-tricks.com/send-an-http-request-on-page-exit/ and we see we’d want to add the “keepalive” option. Now we have this which is all we need on the client-side.

fetch('https://mykickassanalyticstracker.com/track_hit’, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    referer: document.referrer,
    path: window.location.pathname,
  ),
  keepalive: true,
});

On to implementing this so-called kick-ass analytics tracker. Let’s use Supabase for that. It’s new and cool. 😎

Backend

Just go to supabase.io and sign up and create a new free-tier project, then create your table for tracking page views. Be sure to enable Row Level Security. I called mine “hits” and it has these columns, the ones I added are all varchar:

The idea of talking to a database directly from the browser still seems a little weird to me, so I’ll only connect to the database from the new bleeding-edge Supabase Functions. Adding Row Level Security prevents any users from accessing the database directly, but you can still do it from a Function because SUPABASE_SERVICE_ROLE_KEY bypasses RLS.

Time to set up Supabase local development. This is my rough recollection of what I did.

  1. Install the supabase cli
  2. Install Docker
  3. Realize that it expects you to always be in a directory above your “supabase” directory.
  4. Do “supabase init”
  5. Do “supabase start”
  6. Create a stub function with “supabase functions new track_hit”
  7. Run it with “supabase functions serve track_hit”
  8. Verify you can call it locally with the curl command in the comments

Now we just code up some Typescript that handles a request and inserts a row in the database. Rather than explaining it here let me just paste the end result.

# ./supabase/functions/track_hit/index.ts

import { serve } from 'https://deno.land/std@0.131.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@^1.33.2'

const supabaseClient = createClient(
  Deno.env.get('SUPABASE_URL') ?? '',
  Deno.env.get('SUPABASE_ANON_KEY') ?? '',
)

supabaseClient.auth.setAuth(
  Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
);

const hostWhitelist = [
  'royalbarrel.com',
];

serve(async (req) => {
  let origin = req.headers.get('origin') ?? '';

  if (origin) {
    let url = new URL(origin);

    if (hostWhitelist.indexOf(url.host) != -1) {
      if (req.method == 'OPTIONS') {
        // These are just copied from an example on
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
        // In some situations (I don't know when) you have to reflect their own
        // domain back at them instead of just allowing "*".
        return new Response('ok', {
          headers: {
            'Access-Control-Allow-Origin': origin,
            'Access-Control-Allow-Methods': 'POST, OPTIONS, GET',
            'Access-Control-Allow-Headers': 'Content-Type, Authorization',
            'Access-Control-Max-Age': '86400',
          },
        });
      }

      let { referer, path } = await req.json();

      // Add a row
      const { data, error } = await supabaseClient
        .from('hits')
        .insert([{
          host: url.host,
          path: path,
          referer: referer,
          ua: req.headers.get('user-agent'),
        }]);

      if (error) {
        return new Response(
          JSON.stringify({message: error.message || 'Error'}), {
            status: 500,
            headers: {
              'Content-Type': 'application/json',
              'Access-Control-Allow-Origin': origin,
            },
          },
        );
      }

      return new Response(
        JSON.stringify({message: 'OK'}), {
          headers: {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': origin,
          },
        },
      );
    }
  }

  return new Response(
    JSON.stringify({message: 'Origin not allowed'}), {
      status: 403,
    },
  );
});

And deploy it with: supabase functions deploy track_hit

Back in our client code we need to update the URL to https://XXX.functions.supabase.co/track_hit and pass the anon token, the one you get from https://app.supabase.io/project/abcxyz/settings/api, by adding a header for it:

Authorization: Bearer <paste token>

Now your table gets populated and you can make up adhoc queries to analyze the traffic patterns. I started with something like this:

select count(*) from hits
where host = 'royalbarrel.com'
and created_at > '2022-05-01T00:00:00-07:00'
and created_at < '2022-06-01T00:00:00-07:00'

Unresolved questions

  1. My table has a column for IP, but at present I don’t know how to actually get the end user’s IP address. I have a question on discord about it.
  2. I was never able to see my database schema reflected locally, even after a supabase db reset there were 0 tables when browsing with pgAdmin.