Tutorial 15 of 21 · for app developers

Google Analytics behind a consent banner

This continues the shop app from 330. You will add a cookie consent banner, load Google Analytics only after the analytics category is accepted and keep the measurement id in configuration so each deployment sets its own. No <script> tag is written anywhere.

Before you start

Straight from crates.io. No Node, no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Add the banner library

The banner is a library, so it arrives the way Chart.js did in 090:

$ fsr add shop/app vanilla-cookieconsent@3.1.0
added     vanilla-cookieconsent        vanilla-cookieconsent/vanilla-cookieconsent.bundle.mjs  23538 bytes

$ fsr types shop/app
types     vanilla-cookieconsent        vanilla-cookieconsent 3.1.0
kept      react
kept      react-dom

The import map gains vanilla-cookieconsent and the package's own declarations land under app/types/. Its stylesheet is a file to serve like any other: copy dist/cookieconsent.css from the package into app/styles/ and the host links it.

Declare the id

Google Analytics needs a measurement id and the id is different in production than on your machine. Declare it under [public] in config/app.toml with the value development uses:

toml
[public]
analytics_id = ""

Every key under [public] is readable in a loader as ctx.config.<key>, typed from the value written here. An empty string is what development runs with and it is what turns analytics off there.

Seed the browser with it

The browser needs the id and a loader is how data reaches a page. Add app/routes/layout.loader.ts so every route carries it:

ts
import type { Ctx } from "@snapfire/fsr";

export async function load({ config }: Ctx) {
  return { analyticsId: config.analytics_id };
}

export const store = ({ data }: { data: { analyticsId: string } }) => ({
  "site/analytics": data.analyticsId,
});

store seeds the store the islands share, from 020. Name the key once in app/src/store.ts:

ts
import { key } from "@snapfire/fsr-client/store";

export const analytics = key<string>("site/analytics");

Write the analytics module

Google's snippet is four lines of inline script. As a module it is a function that reads the id from the store, builds the queue the same way and appends the loader script:

ts
import { get } from "@snapfire/fsr-client/store";

import { analytics } from "./store";

declare global {
  interface Window {
    dataLayer?: unknown[];
  }
}

export function loadAnalytics() {
  const id = get(analytics);
  if (!id || window.dataLayer) {
    return;
  }
  const dataLayer: unknown[] = [];
  window.dataLayer = dataLayer;
  // gtag.js reads only `Arguments` entries off the queue and skips arrays.
  function gtag(..._: unknown[]) {
    dataLayer.push(arguments);
  }
  gtag("js", new Date());
  gtag("config", id);
  const script = document.createElement("script");
  script.async = true;
  script.src = `https://www.googletagmanager.com/gtag/js?id=${id}`;
  document.head.append(script);
}

Save it as app/src/analytics.ts. Two things in it matter:

  • The first guard returns when there is no id, so development never loads gtag.js. It also returns when dataLayer already exists, so a second call does nothing.
  • The shim pushes arguments, as Google's snippet does. gtag.js checks each queue entry for that type and ignores an array, so a shim written with a rest parameter queues calls that never fire and no page view is sent.

One module runs the banner and decides what loads. It knows nothing about Google beyond the function it calls:

ts
import { acceptedCategory, run } from "vanilla-cookieconsent";

import { loadAnalytics } from "./analytics";

function onConsent() {
  if (acceptedCategory("analytics")) {
    loadAnalytics();
  }
}

run({
  onConsent,
  onChange: onConsent,
  categories: {
    necessary: { enabled: true, readOnly: true },
    analytics: {},
  },
  language: {
    default: "en",
    translations: {
      en: {
        consentModal: {
          title: "Cookies",
          description: "We use cookies to measure traffic.",
          acceptAllBtn: "Accept all",
          acceptNecessaryBtn: "Reject all",
        },
        preferencesModal: {
          title: "Preferences",
          acceptAllBtn: "Accept all",
          acceptNecessaryBtn: "Reject all",
          savePreferencesBtn: "Save",
          sections: [
            { title: "Necessary", description: "Needed for the site to work.", linkedCategory: "necessary" },
            { title: "Analytics", description: "Anonymous traffic measurement.", linkedCategory: "analytics" },
          ],
        },
      },
    },
  },
});

Save it as app/src/consent.ts. onConsent runs when a visitor answers the banner and again on a later visit once the stored answer is read. onChange runs when they change the answer in the preferences dialog, so someone who accepts later is measured from then on.

The usual way to gate a tag is to write it as type="text/plain" with a category attribute and let the banner library rewrite it once the category is accepted. That exists because the tag was HTML. Here there is no tag, so the banner's callback is the whole mechanism.

Import it from the entry

app/src/main.ts is the one script the document loads. Import the consent module for its effect:

ts
import { boot, enableNavigation } from "@snapfire/fsr-client";
import { registerIslands } from "@generated/islands.js";

import "./consent";

registerIslands();

boot();
enableNavigation();

Build it

$ fsr build shop/app
typecheck tsc 7.0.2 from cache, clean

The generated Config interface has one field, analytics_id: string, because that is what [public] declared. Misspell the read in the loader and the build says so:

$ fsr build shop/app
typecheck: tsc 7.0.2 from cache, 1 error
routes/layout.loader.ts(4,32): error TS2551: Property 'analytic_id' does not exist on type 'Config'. Did you mean 'analytics_id'?

Misspell the declaration instead, so the loader reads a key no configuration sets and fsr doctor reports it:

$ fsr doctor shop/app
ctx.config   a body reads `ctx.config.analytics_id` while `[public]` does not declare `analytics_id`, so it always answers null
             declare `analytics_id` under `[public]` in app.toml and set it per deployment in an overlay
doctor       1 of 14 checks found something

Set it per deployment

The configuration is a ladder of files chosen by environment variables: app.toml, then <RELEASE_ENV>.toml, then <APP_ENV>.toml, then <APP_REGION>.toml. APP_ENV defaults to local, so a config/local.toml overrides app.toml on your machine:

toml
[public]
analytics_id = "G-XXXXXXXXXX"

Boot the host and the report lists both files and the value in force:

$ fsr serve shop/app
config    shop/config/app.toml
          shop/config/local.toml
public    analytics_id           "G-XXXXXXXXXX"

The page's store seed carries it, {"site/analytics":"G-XXXXXXXXXX"}. Accepting the banner loads gtag.js with that id. A production deployment writes its own value into its own rung and nothing in the application changes.

A [public] value reaches the browser, which is what the section is named for. A key that must stay on the server is not a [public] value.

Next up: 096. Write your islands in Vue.

Built with SnapFire FSR. Pure Rust runtime, zero Node.js on the server.

Proudly Created by Excerion Sun LLC