Simple (and free) attribution tracking in 200 lines of JavaScript

9/26/2023

Kevin Galang

Can you reliably see where your sign ups are coming from? Can you segment your funnel analytics based on these acquisition sources?

These questions are key to understanding which growth channels you should scrap and which ones you should double down on.

In this post, we’ll go over a reliable way to track this data within the common scenario of where your application code (and sign up page) is within a different codebase than your marketing site (ie. Webflow, Wordpress, another code repository, etc).

The Problem: Relying on Third-Party Tracking

The easiest way to start tracking this data is by using a web analytics tool like PostHog, Amplitude, or Google Analytics. I’m a fan of the first two.

However, the reliability of browser-based third-party tracking scripts are diminishing. When using these kinds of tracking scripts, you can expect to lose a decent amount of signups for users using an ad blocker or even browsers like Brave or Arc.

For many businesses, each sign up attribution data point is valuable. A couple of use cases I’ve experienced:

  • Early stage startups need as much sign up data volume as possible when running A/B experiments.
  • Companies doing referral tracking via affiliates need to make sure they get each point to drive payouts.

The Solution: A Database Table and Some Javascript Snippets

To overcome the challenges associated with browser based tracking, implementing first party code to track visitor metadata before writing to a dedicated database table (signup_analytics) proves to be an effective solution.

Here's how it works:

  1. Database Table: Create a dedicated table in your database to record analytics data on those who’ve signed up. This table will tie each user to a signup analytics record, allowing for deeper analysis of activation rates based on acquisition referrer.

    create table signup_analytics (
        id serial primary key,
        user_id uuid references public.users(id) on delete set null,
        campaign text,
        campaign_date timestamp with time zone,
        source text,
        source_date timestamp with time zone,
        medium text,
        medium_date timestamp with time zone,
        term text,
        term_date timestamp with time zone,
        content text,
        content_date timestamp with time zone,
        referrer_url text,
        referrer_url_date timestamp with time zone,
        gclid text,
        gclid_date timestamp with time zone,
        created_at timestamp with time zone not null default now(),
        updated_at timestamp with time zone not null default now()
    );
    
  2. JavaScript Snippet for gathering visitor data: Implement a JavaScript snippet that tracks the visitor’s traffic metadata (utm parameters, google click ids, referrer url, etc) in a cookie as users navigate from one subdomain to another. For example, when users move from your marketing site (www.yourdomain.com) to the application site (app.yourdomain.com), both sites can check the same cookie for the visitor traffic metadata. This ensures that the metadata remains intact throughout the signup process.

    • NextJS Example: If you’re using NextJS for your marketing site, instantiate this context on every page.

      "use client";
      
      import {
        createContext,
        useContext,
        useMemo,
        useEffect,
        useState,
        useCallback,
      } from "react";
      import QueryString from "query-string";
      import Cookies from "js-cookie";
      import { SIGNUP_ANALYTICS_COOKIE_NAME } from "lib/config";
      
      const getCookieDomain = () => {
        if (typeof window !== "undefined") {
          let hostname = window.location.hostname;
          if (!hostname.includes("localhost")) {
            // Get root domain
            hostname =
              "." +
              hostname.split(".").reverse().splice(0, 2).reverse().join(".");
          }
          return hostname;
        }
      };
      
      const isExpired = (daysUntilExpired) => (date) => {
        const currentDate = new Date();
        const expirationDate = new Date(date);
        expirationDate.setDate(expirationDate.getDate() + daysUntilExpired);
        return currentDate > expirationDate;
      };
      
      export const AnalyticsContext = createContext();
      
      export const AnalyticsProvider = (props) => {
        const [campaign, setCampaign] = useState({ value: null, date: null });
        const [source, setSource] = useState({ value: null, date: null });
        const [medium, setMedium] = useState({ value: null, date: null });
        const [term, setTerm] = useState({ value: null, date: null });
        const [content, setContent] = useState({ value: null, date: null });
        const [gclid, setGclid] = useState({ value: null, date: null });
        const [referrerUrl, setReferrerUrl] = useState({
          value: null,
          date: null,
        });
      
        const initFromCookie = () => {
          const cookie = Cookies.get(SIGNUP_ANALYTICS_COOKIE_NAME);
          if (cookie) {
            const parsedCookie = JSON.parse(cookie);
      
            if (parsedCookie.campaign?.value) {
              setCampaign(parsedCookie.campaign);
            }
      
            if (parsedCookie.source?.value) {
              setSource(parsedCookie.source);
            }
      
            if (parsedCookie.medium?.value) {
              setMedium(parsedCookie.medium);
            }
      
            if (parsedCookie.term?.value) {
              setTerm(parsedCookie.term);
            }
      
            if (parsedCookie.content?.value) {
              setContent(parsedCookie.content);
            }
      
            if (parsedCookie.gclid?.value) {
              setGclid(parsedCookie.gclid);
            }
      
            if (parsedCookie.referrerUrl?.value) {
              setReferrerUrl(parsedCookie.referrerUrl);
            }
          }
        };
      
        const collectSignupAnalyticsFromUrl = () => {
          const currentDate = new Date();
      
          if (window.location?.search) {
            const qs = QueryString.parse(window.location.search);
      
            if (qs.utm_campaign !== null && qs.utm_campaign !== undefined) {
              setCampaign({ value: qs.utm_campaign, date: currentDate });
            }
      
            if (qs.utm_source !== null && qs.utm_source !== undefined) {
              setSource({ value: qs.utm_source, date: currentDate });
            }
      
            if (qs.utm_medium !== null && qs.utm_medium !== undefined) {
              setMedium({ value: qs.utm_medium, date: currentDate });
            }
      
            if (qs.utm_term !== null && qs.utm_term !== undefined) {
              setTerm({ value: qs.utm_term, date: currentDate });
            }
      
            if (qs.utm_content !== null && qs.utm_content !== undefined) {
              setContent({ value: qs.utm_content, date: currentDate });
            }
      
            if (qs.gclid) {
              setGclid({ value: qs.gclid, date: currentDate });
            }
          }
      
          let referrerUrl;
          if (document.referrer === "") {
            referrerUrl = undefined;
          } else {
            try {
              let parsedReferrer = new URL(document.referrer);
              referrerUrl =
                parsedReferrer.host.replace(/^www./, "") +
                parsedReferrer.pathname;
            } catch (e) {
              referrerUrl = document.referrer;
            }
          }
      
          if (referrerUrl) {
            setReferrerUrl({ value: referrerUrl, date: currentDate });
          }
        };
      
        const checkExpirations = useCallback(() => {
          // config expiration in days
          const isGclidExpired = isExpired(60);
      
          if (gclid.date && isGclidExpired(gclid.date)) {
            setGclid({ value: null, date: null });
          }
        }, [gclid.date]);
      
        const syncCookie = useCallback(() => {
          Cookies.set(
            SIGNUP_ANALYTICS_COOKIE_NAME,
            JSON.stringify({
              campaign,
              source,
              medium,
              term,
              content,
              gclid,
              referrerUrl,
            }),
            {
              domain: getCookieDomain(),
            }
          );
        }, [campaign, source, medium, term, content, gclid, referrerUrl]);
      
        const value = useMemo(
          () => ({
            campaign,
            setCampaign,
            source,
            setSource,
            medium,
            setMedium,
            term,
            setTerm,
            content,
            setContent,
            gclid,
            setGclid,
            referrerUrl,
            setReferrerUrl,
          }),
          [campaign, source, medium, term, content, gclid, referrerUrl]
        );
      
        // init from cross domain cookie
        useEffect(() => {
          initFromCookie();
          collectSignupAnalyticsFromUrl();
          checkExpirations();
        }, [checkExpirations]);
      
        useEffect(() => {
          syncCookie();
        }, [value, syncCookie]);
      
        return <AnalyticsContext.Provider value={value} {...props} />;
      };
      
      export const useAnalytics = () => {
        const context = useContext(AnalyticsContext);
        if (!context) {
          throw new Error(
            "useAnalytics must be used within a AnalyticsProvider"
          );
        }
        return context;
      };
      
  3. Saving cookie data to your database: Upon signup, submit the captured cookie data to the signup analytics table. This step completes the tracking process and enables you to analyze user acquisition accurately. You’ll want to do this in the codebase that contains the sign up page.

    Example using Supabase: This function is called whenever the user signs up successfully.

    import Cookies from "js-cookie";
    import { SIGNUP_ANALYTICS_COOKIE_NAME } from "@constants";
    import { supabase } from "@/supabase";
    
    export async function insertSignupAnalyticsFromCookie(userId: string) {
      let saObj = {};
      const cookie = Cookies.get(SIGNUP_ANALYTICS_COOKIE_NAME);
      if (cookie) {
        saObj = JSON.parse(cookie);
      }
    
      const { error } = await supabase.from("signup_analytics").insert([
        {
          user_id: userId,
          campaign: saObj?.campaign?.value,
          campaign_date: saObj?.campaign?.date,
          source: saObj?.source?.value,
          source_date: saObj?.source?.date,
          medium: saObj?.medium?.value,
          medium_date: saObj?.medium?.date,
          term: saObj?.term?.value,
          term_date: saObj?.term?.date,
          content: saObj?.content?.value,
          content_date: saObj?.content?.date,
          referrer_url: saObj?.referrerUrl?.value,
          referrer_url_date: saObj?.referrerUrl?.date,
          gclid: saObj?.gclid?.value,
          gclid_date: saObj?.gclid?.date,
        },
      ]);
    
      if (error) {
        console.error(error);
        return;
      }
    
      Cookies.remove(SIGNUP_ANALYTICS_COOKIE_NAME);
    }
    
  4. Analysis: Once you have this data, you’re all set to start reporting on your top acquisition sources and where you should double down.

    Example SQL query:

    SELECT
      t1.email AS email,
      t1.created_at,
      CASE
      WHEN t2.gclid IS NOT NULL THEN 'Google Ads'
      WHEN t2.source IS NOT NULL AND t2.source ILIKE 'facebook%' THEN 'Facebook Ads'
      WHEN t2.source IS NOT NULL AND t2.source ILIKE 'linkedin%' THEN 'LinkedIn Ads'
      WHEN t2.source IS NOT NULL AND t2.source ILIKE 'instagram%' THEN 'Instagram Ads'
      WHEN t2.source IS NOT NULL AND t2.source ILIKE 'twitter%' THEN 'Twitter Ads'
      WHEN t2.source IS NOT NULL THEN t2.source
      WHEN t2.referrer_url ILIKE 't.co%' THEN 'Twitter'
      WHEN t2.referrer_url ILIKE 'l.facebook.com%' THEN 'Facebook'
      WHEN t2.referrer_url ILIKE 'linkedin.com%' THEN 'LinkedIn'
      WHEN t2.referrer_url ILIKE 'instagram.com%' THEN 'Instagram'
      WHEN t2.referrer_url ILIKE 'pinterest.com%' THEN 'Pinterest'
      WHEN t2.referrer_url ILIKE 'tiktok.com%' THEN 'TikTok'
      WHEN t2.referrer_url ILIKE 'youtube.com%' THEN 'YouTube'
      WHEN t2.referrer_url ILIKE 'baidu.com%' THEN 'Baidu'
      WHEN t2.referrer_url ILIKE 'yahoo.com%' THEN 'Yahoo'
      WHEN t2.referrer_url ILIKE 'bing.com%' THEN 'Bing'
      WHEN t2.referrer_url IS NOT NULL AND
        SPLIT_PART(t2.referrer_url, '/', 1) IS NOT NULL
        THEN
        SPLIT_PART(t2.referrer_url, '/', 1)
      WHEN t2.referrer_url IS NOT NULL THEN 'Other Referrer'
      ELSE 'Unknown'
    END AS final_source,
      COALESCE(t2.gclid, t2.source, t2.referrer_url) AS final_source_detail,
      t2.campaign AS campaign,
      CAST(DATE_TRUNC('day', t2.campaign_date) AS DATE) AS campaign_date,
      t2.content AS content,
      CAST(DATE_TRUNC('day', t2.content_date) AS DATE) AS content_date,
      t2.gclid AS gclid,
      CAST(DATE_TRUNC('day', t2.gclid_date) AS DATE) AS gclid_date,
      t2.id AS id,
      t2.medium AS medium,
      CAST(DATE_TRUNC('day', t2.medium_date) AS DATE) AS medium_date,
      t2.referrer_url AS referrer_url,
      CAST(DATE_TRUNC('day', t2.referrer_url_date) AS DATE) AS referrer_url_date,
      t2.source AS source,
      CAST(DATE_TRUNC('day', t2.source_date) AS DATE) AS source_date,
      t2.term AS term,
      CAST(DATE_TRUNC('day', t2.term_date) AS DATE) AS term_date,
      t2.user_id AS user_id
    FROM "postgres"."public"."users" AS t1
    LEFT JOIN LATERAL (
      SELECT
        *
      FROM "postgres"."public"."signup_analytics" AS t2
      WHERE
        t1.id = t2.user_id
      ORDER BY
        t2.created_at DESC
      LIMIT 1
    ) AS t2
      ON TRUE
    ORDER BY
      t1.created_at DESC
    LIMIT 500
    

Benefits of First Party Sign Up Attribution Tracking

By implementing this in your own system and database, you can enjoy several benefits:

  • Enhanced Analysis: Having access to detailed signup referrer data allows for more accurate and precise analysis. An example is querying activation rates based on acquisition sources. What you find there would allow you to make data driven decisions on which growth channels you’d want to double down on.
  • Reliability: Relying on your own signup analytics system ensures the reliability of the captured data. You can have confidence in the accuracy of your analytics and make informed decisions based on trustworthy insights.
  • Flexibility: In the above implementation, you’re simply capturing raw data that represents traces of where the user came from. You’re still free to iterate on the logic of situations where a signup may have clicked a Google Ad and a partner’s affiliate link.

Limitations

The solution above is meant to be a simple and reliable way to start tracking. It's essential to be aware of its limitations:

  • Multitouch Tracking: The signup analytics table’s primarily focus on capturing the referrer data for the initial signup. If you require multitouch tracking to measure the impact of multiple touchpoints throughout the user journey, additional solutions/updates may be necessary.
  • Assumptions and Expirations: The cookie tracker snippet relies on assumptions made ahead of time, such as gclid (Google Click Identifier) expirations. It's important to consider these assumptions and adjust them accordingly to ensure accurate data analysis.
  • Scope of analysis: This table is only written to once the user signs up. This leads to pretty clean data. However, with just this table, you won’t be able to consider conversion rates from higher funnel steps.

Other Solutions

In addition to storing signup referral data in your own database, another reliable way to get sign up referral tracking is by using a Product analytics tool (PostHog, Amplitude) with a proxy.

This means that the browser event will be sent to a proxy like a Cloudflare function hosted at proxy.yourdomain.com, then that Cloudflare function sends the event to your analytics tool.

Since the browser sees that you’re just talking to yourdomain.com, it is more likely to bypass ad blockers.

See Segment’s guide on how they allow you to track data via a custom proxy: https://segment.com/docs/connections/sources/catalog/libraries/website/javascript/custom-proxy/

The fully first party solution described above will still be more reliable though. And it’s also easier for me to analyze it against other product metrics in the database. I often implement both.

Key Takeaways

  • Importance of Sign Up Attribution Analysis: Having access to detailed signup attribution data is critical and could unlock insights for growth opportunities.
  • Reliability: Implementing your own signup analytics system ensures the reliability and accuracy of the captured data, providing trustworthy insights for decision-making.
  • Flexibility: By capturing raw data representing user acquisition sources, you have the flexibility to iterate on the logic and adapt to different scenarios.
  • Limitations: The solution focuses on capturing initial signup referrer data and may require additional solutions for multitouch tracking and adjusting assumptions and expirations.
  • Other Solutions: Using a product analytics tool with a custom proxy can also provide reliable signup referral tracking, albeit with some trade-offs compared to a fully first-party solution.