Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
8.1 kB · 194 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195/** * Usage measurement: how many people arrive, what they click, and — once the * rest of this lands — whether they sign in and play. * * The one file that knows PostHog exists. Everything else calls capture() with * a name, so swapping the vendor is this file plus the key, and reading the * code tells you what leaves the browser without reading PostHog's docs. * * Nothing here identifies a player. That needs care rather than good * intentions: autocapture sends the DOM path and the visible text of whatever * was clicked, and once a player is signed in their handle is on screen in the * masthead. Anything that renders a handle, a DID or an avatar therefore * carries `ph-no-capture`, which stops autocapture at that subtree — see * `#account` in Base.astro. Adding a new place that shows who someone is means * adding that class with it. */
import type { PostHog } from "posthog-js";
/** * The project's write-only key. Public by design: it is in the built JS on * every page, and it can only send events, never read them. Committed rather * than passed in at build time for that reason. */const KEY = "phc_vmEyDZqJqKxSFuG6qGXTspqFQ6e7MtdSdBE3ipkevrVs";
/** EU region, chosen so the data never leaves it. A US key will not work here. */const HOST = "https://eu.i.posthog.com";
/** * The only hostname that reports. * * This repository is public and the key above is in it, so without this any * fork served anywhere would file its traffic into our project — and the * damage is not to the fork, it is that our numbers quietly stop being about * this site. An exact match, because the distribution answers to exactly one * name. */const SITE_HOST = "lance.blue";
/** * Mixed into a DID before it is hashed, so that what PostHog stores is not the * plain SHA-256 of a public identifier. * * Not a secret, and not pretending to be one: it ships in this bundle, so * anyone holding it and a list of DIDs can rebuild the mapping. What it buys is * that the identifier in PostHog is inert on its own — it is not the player's * DID, and it does not match the hash any other site would compute. The version * that would actually resist someone determined is an opaque id minted by the * API per account, which is a change to the API rather than to this file. */const ID_SALT = "lance.blue/analytics/v1";
/** The tab is on its way back from a provider. See signin-form.ts. */const SIGNING_IN = "lance.blue:signing-in";
/** * Whether this page should report at all. * * A committed key is a key every checkout has, so the guard cannot be "is there * a key" — it has to be "is this the site". `astro dev` is excluded by PROD, * and the hostname covers both `astro preview`, which is a production build on * a loopback address, and anyone else's deployment of this repository. */function shouldReport(): boolean { if (!import.meta.env.PROD) return false; return window.location.hostname === SITE_HOST;}
/** * The library, once it has arrived. Null until then, and null forever on a * build that does not report — which is what keeps the import below from being * a static one. */let ph: PostHog | null = null;let started = false;
/** * Start reporting. Called from the layout, so it runs on the blog's pages as * well as the app's — the blog is where a stranger arrives first, and leaving * it out would make the site look like it has no readers. * * posthog-js is 242 KB, against 83 KB for the whole app: bundled statically it * would be the largest thing the site ships, on every page, ahead of the page * itself. Imported here instead, it is a separate chunk that a build which * does not report never asks for, and that a build which does fetches after * the page is already up. Nothing awaits this — measurement must never be on * the path between a reader and the page. */export async function mountAnalytics(): Promise<void> { if (started || !shouldReport()) return; started = true;
const posthog = (await import("posthog-js")).default;
posthog.init(KEY, { api_host: HOST,
// This app is one address with screens swapped behind a hash, and the blog // is real pages. PostHog's automatic pageview cannot see either correctly: // it fires on load and on pushState, and this router navigates by // hashchange while its only pushState-family calls — replaceState, in // router.ts — are the two that deliberately do not change the screen. // Left automatic, the app reports one pageview per session and two of them // are wrong. So this file owns every pageview: one here, one per screen // change from the router. capture_pageview: false,
// The reason for choosing PostHog. Clicks, submits and input changes are // recorded without tagging each element, so a question asked in three // months can be answered against data already collected. autocapture: true,
// Off deliberately. It would film the camo editor and the match screen, it // is the line item that turns a free tier into a bill, and none of the // questions being asked need it. Turning it on is a decision, not a // default. disable_session_recording: true,
// Anonymous events still arrive; what this skips is a stored person record // for every stranger who reads one blog post. Signing in is what creates // one, in identify() below. person_profiles: "identified_only",
// Escaped errors and unhandled rejections leave the browser as $exception // events. Until this, a crash in the field was a console.error in a tab // nobody was looking at - main.ts's trap renders the recovery screen, but // nothing off-box ever heard about it. PostHog's listeners see the same // two channels the trap does, on every page the layout mounts, blog // included. Free to 100k exceptions a month, and a runaway error loop is // one page's session at a time, not the site multiplying pageviews. capture_exceptions: true, });
ph = posthog; capturePageview();}
/** * One screen change. The router calls this; nothing else should. * * The address is read here rather than passed in because hashchange has already * updated it by the time the router decides a screen change happened, and the * whole point is to record where the player ended up. */export function capturePageview(): void { ph?.capture("$pageview", { $current_url: window.location.href });}
/** A named thing that happened. Properties must not carry anything personal. */export function capture( event: string, properties?: Record<string, unknown>,): void { ph?.capture(event, properties);}
/** Salted SHA-256, hex. The DID itself never leaves the browser. */async function pseudonym(did: string): Promise<string> { const bytes = new TextEncoder().encode(`${ID_SALT}:${did}`); const digest = await crypto.subtle.digest("SHA-256", bytes); return [...new Uint8Array(digest)] .map((b) => b.toString(16).padStart(2, "0")) .join("");}
/** * What the masthead learned about the session, which is the only place the * whole site agrees on it. * * Two jobs, and they are deliberately not the same event. Identifying happens * on every page a signed-in player loads, so that their visits join up instead * of reading as a new stranger each time. Signing in is counted once, and only * when this page is the far side of a redirect the player actually started — * without that mark there is nothing here to tell a fresh sign-in apart from * the ninth page load of an old session, and the funnel would report a login * per pageview. */export async function noteSession( session: { did: string } | null,): Promise<void> { if (!ph || !session) return;
ph.identify(await pseudonym(session.did));
let started = false; try { started = sessionStorage.getItem(SIGNING_IN) !== null; sessionStorage.removeItem(SIGNING_IN); } catch { /* blocked storage: the sign-in goes uncounted, the page still works */ } if (started) capture("signed in");}