Analytics asks first in the EU, EEA, UK and Switzerland, and loads only when the page is idle
The SDK is no longer in the page's bundle: it is fetched once the page has loaded and the browser is idle, and where a visitor is asked first, not at all until they choose Allow. The answer is kept in local storage; Cookie choices in the footer asks again. The server marks those visitors' pages from Cloudflare's country, and the public page cache keeps their copy apart (_g1t_consent=1 in its key), so no visitor gets another's page. The policies name HeyCatch, Inc., the United States, PostHog as where it stores events, and its data processing addendum.
11 files+240−270/11 viewed
| 1 | + | /** | |
| 2 | + | * Asking a visitor from the EU, EEA, UK or Switzerland before site | |
| 3 | + | * analytics runs (lib/analytics-consent.ts): both answers are one click, | |
| 4 | + | * and nothing is fetched or stored for analytics until they agree. The | |
| 5 | + | * footer's "Cookie choices" asks again (components/footer.tsx). | |
| 6 | + | */ | |
| 7 | + | import { useEffect, useState, useSyncExternalStore } from "react"; | |
| 8 | + | import { Link } from "react-router"; | |
| 9 | + | ||
| 10 | + | import { analyticsHere, choice, choose, consentRequired, onChoice } from "../lib/analytics-consent"; | |
| 11 | + | ||
| 12 | + | /** Whether this visitor is asked at all, known only once the page is in the browser. */ | |
| 13 | + | export function useAsksFirst(): boolean { | |
| 14 | + | const [asks, setAsks] = useState(false); | |
| 15 | + | useEffect(() => setAsks(analyticsHere() && consentRequired()), []); | |
| 16 | + | return asks; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export function AnalyticsConsent() { | |
| 20 | + | const asks = useAsksFirst(); | |
| 21 | + | const chosen = useSyncExternalStore(onChoice, choice, () => null); | |
| 22 | + | if (!asks || chosen) return null; | |
| 23 | + | return ( | |
| 24 | + | <section | |
| 25 | + | aria-label="Analytics cookie" | |
| 26 | + | className="fixed inset-x-3 bottom-3 z-[60] mx-auto max-w-lg rounded-2xl bg-surface p-4 text-sm shadow-2xl shadow-black/50 ring-1 ring-line-strong sm:inset-x-auto sm:right-4 sm:bottom-4" | |
| 27 | + | > | |
| 28 | + | <p className="leading-6 text-fg-soft"> | |
| 29 | + | May g1t count how its pages are used? It sets one analytics cookie, and names inside your workspaces are | |
| 30 | + | removed before anything is sent.{" "} | |
| 31 | + | <Link to="/policies/privacy#how-the-site-is-used" className="text-accent hover:underline"> | |
| 32 | + | What is sent | |
| 33 | + | </Link> | |
| 34 | + | </p> | |
| 35 | + | <div className="mt-3 flex gap-2"> | |
| 36 | + | <button | |
| 37 | + | type="button" | |
| 38 | + | onClick={() => choose("yes")} | |
| 39 | + | className="flex-1 rounded-lg bg-fg px-3 py-2 font-medium text-bg transition-colors hover:bg-white" | |
| 40 | + | > | |
| 41 | + | Allow | |
| 42 | + | </button> | |
| 43 | + | <button | |
| 44 | + | type="button" | |
| 45 | + | onClick={() => choose("no")} | |
| 46 | + | className="flex-1 rounded-lg px-3 py-2 font-medium text-fg ring-1 ring-line-strong transition-colors hover:bg-raised" | |
| 47 | + | > | |
| 48 | + | No thanks | |
| 49 | + | </button> | |
| 50 | + | </div> | |
| 51 | + | </section> | |
| 52 | + | ); | |
| 53 | + | } |
| 9 | 9 | ||
| 10 | 10 | import type { User } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | + | import { useAsksFirst } from "./analytics-consent"; | |
| 12 | 13 | import { Mark } from "./logo"; | |
| 14 | + | import { choose } from "../lib/analytics-consent"; | |
| 13 | 15 | import { COMPANY, MAKER_PRODUCTS, SOCIAL, copyright, listed } from "../lib/legal"; | |
| 14 | 16 | import { type OverallState, STATUS_JSON_URL, STATUS_URL, STATUS_WORDS, type StatusReport, dotClass } from "../lib/status"; | |
| 15 | 17 | ||
| ⋯ | |||
| 181 | 183 | */ | |
| 182 | 184 | export function LegalRow({ user }: { user: User | null | undefined }) { | |
| 183 | 185 | const status = useSiteStatus(); | |
| 186 | + | const asksFirst = useAsksFirst(); | |
| 184 | 187 | const links = [ | |
| 185 | 188 | <a key="status" href={STATUS_URL} className={`inline-flex items-center gap-1.5 ${ROW_LINK}`}> | |
| 186 | 189 | <StatusDot state={status?.overall.state ?? null} /> | |
| ⋯ | |||
| 191 | 194 | {label} | |
| 192 | 195 | </Link> | |
| 193 | 196 | )), | |
| 197 | + | // Where the visitor was asked about the analytics cookie: ask again. | |
| 198 | + | ...(asksFirst | |
| 199 | + | ? [ | |
| 200 | + | <button key="consent" type="button" onClick={() => choose(null)} className={ROW_LINK}> | |
| 201 | + | Cookie choices | |
| 202 | + | </button>, | |
| 203 | + | ] | |
| 204 | + | : []), | |
| 194 | 205 | ]; | |
| 195 | 206 | const account = [ | |
| 196 | 207 | user ? ( | |
| 40 | 40 | ||
| 41 | 41 | ### How the site is used | |
| 42 | 42 | ||
| 43 | − | On g1t.sh, our analytics provider HeyCatch records page views, clicks and the page each happened on, with your browser and device type, your IP address (which it uses to estimate your country and city) and the site that sent you. We use it to learn which pages help people and where they get stuck. | |
| 43 | + | On g1t.sh, our analytics provider HeyCatch, Inc. records page views, clicks and the page each happened on, with your browser and device type, your IP address (which it uses to estimate your country and city) and the site that sent you. We use it to learn which pages help people and where they get stuck. | |
| 44 | 44 | ||
| 45 | 45 | - **On the public pages** (the home page, pricing, Explore, signing up and signing in, support, security and these policies) it receives the page's address and the text of what you click. | |
| 46 | 46 | - **Everywhere else** your browser removes names before anything is sent: an address such as `g1t.sh/acme/web/pull/12` is sent as `/:name/:name/pull/:n`, and page titles and the text, links and attributes of what you click are left out. Nothing from your repositories, issues, pull requests or chat is sent. | |
| ⋯ | |||
| 48 | 48 | - **Never**: recordings of your screen or session, what you type, or error reports. | |
| 49 | 49 | - **Query strings** are removed, except campaign tags (`utm_…`). | |
| 50 | 50 | ||
| 51 | − | If your browser sends Do Not Track, nothing is recorded. A g1t you run yourself sends nothing to HeyCatch. | |
| 51 | + | **If you're in the EU, the EEA, the UK or Switzerland, we ask first.** Until you choose **Allow**, nothing is loaded from HeyCatch, no analytics cookie is set and nothing is sent. Your answer is kept in your browser's local storage (`g1t_analytics`), and **Cookie choices** at the foot of any page asks again. Elsewhere, analytics runs without asking. If your browser sends Do Not Track, nothing is recorded anywhere. A g1t you run yourself sends nothing to HeyCatch. | |
| 52 | + | ||
| 53 | + | HeyCatch stores these events with PostHog in the United States, under its [data processing addendum](https://heycatch.ai/dpa), which includes the EU Standard Contractual Clauses. | |
| 52 | 54 | ||
| 53 | 55 | ### When you write to us | |
| 54 | 56 | ||
| ⋯ | |||
| 68 | 70 | | `g1t_ws` | Remembers which workspace you last chose, so the sidebar opens on it. | 1 year | | |
| 69 | 71 | | `g1t_seen` | Remembers when you last looked at mission control, so it can show what's new since. | 1 year | | |
| 70 | 72 | | `g1t_tz` | Your browser's time zone, so mission control's greeting and days fit your day. | 1 year | | |
| 71 | − | | `ph_…_posthog` | Set by HeyCatch's analytics on g1t.sh: a random id for your browser, so its visits count as one visitor ([above](#how-the-site-is-used)). | 1 year | | |
| 73 | + | | `ph_…_posthog` | Set by HeyCatch's analytics on g1t.sh: a random id for your browser, so its visits count as one visitor ([above](#how-the-site-is-used)). In the EU, the EEA, the UK and Switzerland, only after you allow it. | 1 year | | |
| 72 | 74 | ||
| 73 | 75 | Cloudflare may set its own security cookies (such as `__cf_bm`) to tell people from bots when g1t is under attack. | |
| 74 | 76 | ||
| 6 | 6 | | --- | --- | --- | --- | | |
| 7 | 7 | | **Cloudflare, Inc.** | Hosting and the network g1t runs on (Workers), databases (D1), storage for repositories, avatars and screenshots (Artifacts, KV and R2), queues, the sandboxes agents and checks run in (Containers), sending email (Email Service), search embeddings (Workers AI and Vectorize), the gateway hosted agents reach their model through (AI Gateway), and screenshots of deployed apps (Browser Rendering) | Everything stored on g1t, and every request to it | Global | | |
| 8 | 8 | | **Stripe, Inc.** | Payments, cards, invoices and receipts | Workspace owners' billing details, card details (entered on Stripe's page, never on g1t), and what each invoice charges | United States | | |
| 9 | − | | **HeyCatch** | Product analytics on g1t.sh: page views and clicks | The page's address (names replaced outside the public pages), the text of what is clicked on the public pages, browser and device type, IP address, the referring site, an analytics id, and a signed-in person's internal account id. Never repository content, chat, page titles inside the app, what you type, or recordings of your session | To confirm | | |
| 9 | + | | **HeyCatch, Inc.** | Product analytics on g1t.sh: page views and clicks. Visitors in the EU, EEA, UK and Switzerland are asked first | The page's address (names replaced outside the public pages), the text of what is clicked on the public pages, browser and device type, IP address, the referring site, an analytics id, and a signed-in person's internal account id. Never repository content, chat, page titles inside the app, what you type, or recordings of your session | United States | | |
| 10 | 10 | | **Anthropic, PBC** | The AI model behind g1t's hosted agents | What an agent run needs: the issue or request, the relevant code and files, the conversation so far, and tool results. Not your account details. Only when a hosted agent runs | United States | | |
| 11 | 11 | ||
| 12 | 12 | **AI Gateway logs.** Each hosted agent's model request passes through Cloudflare AI Gateway, which records it with the workspace, repository and pull request it was for, so we can bill it accurately. | |
| 13 | 13 | ||
| 14 | 14 | **Training.** Anthropic does not train its models on what g1t sends through its commercial API. | |
| 15 | 15 | ||
| 16 | + | **HeyCatch** stores analytics events with PostHog (United States) and lists its own subprocessors at [heycatch.ai/subprocessors](https://heycatch.ai/subprocessors). Its [data processing addendum](https://heycatch.ai/dpa) covers what it does for g1t, including the EU Standard Contractual Clauses for transfers to the United States. | |
| 17 | + | ||
| 16 | 18 | ## Other services g1t calls | |
| 17 | 19 | ||
| 18 | 20 | - **OSV.dev**, run by Google: the names and versions of packages in your lockfiles, to look up known vulnerabilities. Nothing that identifies you or your repository. |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { asksFirst, choice, consentRequired } from "./analytics-consent.ts"; | |
| 5 | + | ||
| 6 | + | test("visitors from the EU, EEA, UK and Switzerland are asked first", () => { | |
| 7 | + | for (const country of ["DE", "fr", "IE", "NO", "IS", "GB", "CH"]) assert.equal(asksFirst(country), true, country); | |
| 8 | + | }); | |
| 9 | + | ||
| 10 | + | test("others are not, nor is a visitor whose country is unknown", () => { | |
| 11 | + | for (const country of ["US", "CA", "AU", "JP", "BR"]) assert.equal(asksFirst(country), false, country); | |
| 12 | + | assert.equal(asksFirst(null), false); | |
| 13 | + | assert.equal(asksFirst(""), false); | |
| 14 | + | }); | |
| 15 | + | ||
| 16 | + | test("on the server nothing is chosen or required", () => { | |
| 17 | + | assert.equal(choice(), null); | |
| 18 | + | assert.equal(consentRequired(), false); | |
| 19 | + | }); |
| 1 | + | /** | |
| 2 | + | * Whether a visitor has agreed to site analytics, where the law asks | |
| 3 | + | * first: the EU and EEA, the UK and Switzerland. The server decides from | |
| 4 | + | * the visitor's country and marks the page (`<meta name="g1t-analytics" | |
| 5 | + | * content="consent">`); the choice is kept in this browser's local | |
| 6 | + | * storage. Safe to import on the server, where nothing is chosen. | |
| 7 | + | */ | |
| 8 | + | ||
| 9 | + | /** Countries whose visitors are asked before analytics runs. */ | |
| 10 | + | const ASK_FIRST = new Set([ | |
| 11 | + | // The EU. | |
| 12 | + | "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", | |
| 13 | + | "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", | |
| 14 | + | // The rest of the EEA, the UK and Switzerland. | |
| 15 | + | "IS", "LI", "NO", "GB", "CH", | |
| 16 | + | ]); | |
| 17 | + | ||
| 18 | + | /** Whether a visitor from `country` (ISO 3166 alpha-2, as Cloudflare gives it) is asked first. */ | |
| 19 | + | export function asksFirst(country: string | null | undefined): boolean { | |
| 20 | + | return !!country && ASK_FIRST.has(country.toUpperCase()); | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /** | |
| 24 | + | * Whether the visitor making `request` is asked first. Cloudflare sets the | |
| 25 | + | * country header (replacing any the visitor sent); `cf` is the same answer. | |
| 26 | + | */ | |
| 27 | + | export function visitorAsksFirst(request: Request): boolean { | |
| 28 | + | return asksFirst(request.headers.get("cf-ipcountry") ?? (request as Request & { cf?: { country?: string } }).cf?.country); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /** The installation analytics runs on; anywhere else nothing is sent or asked. */ | |
| 32 | + | export const ANALYTICS_HOST = "g1t.sh"; | |
| 33 | + | ||
| 34 | + | export function analyticsHere(): boolean { | |
| 35 | + | return typeof window !== "undefined" && window.location.hostname === ANALYTICS_HOST; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** Whether this page was marked as one whose visitor is asked first. */ | |
| 39 | + | export function consentRequired(): boolean { | |
| 40 | + | return typeof document !== "undefined" && document.querySelector('meta[name="g1t-analytics"][content="consent"]') !== null; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | export type Choice = "yes" | "no" | null; | |
| 44 | + | ||
| 45 | + | const KEY = "g1t_analytics"; | |
| 46 | + | let current: Choice | undefined; | |
| 47 | + | const listeners = new Set<() => void>(); | |
| 48 | + | ||
| 49 | + | /** What this browser chose; null until it has. */ | |
| 50 | + | export function choice(): Choice { | |
| 51 | + | if (typeof window === "undefined") return null; | |
| 52 | + | if (current === undefined) { | |
| 53 | + | try { | |
| 54 | + | const stored = window.localStorage.getItem(KEY); | |
| 55 | + | current = stored === "yes" || stored === "no" ? stored : null; | |
| 56 | + | } catch { | |
| 57 | + | current = null; | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | return current; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /** Record a choice, or null to ask again. */ | |
| 64 | + | export function choose(next: Choice) { | |
| 65 | + | current = next; | |
| 66 | + | try { | |
| 67 | + | if (next) window.localStorage.setItem(KEY, next); | |
| 68 | + | else window.localStorage.removeItem(KEY); | |
| 69 | + | } catch { | |
| 70 | + | // No storage: the choice holds for this page only. | |
| 71 | + | } | |
| 72 | + | for (const listener of listeners) listener(); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | export function onChoice(listener: () => void): () => void { | |
| 76 | + | listeners.add(listener); | |
| 77 | + | return () => listeners.delete(listener); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /** Whether analytics may run in this browser now. */ | |
| 81 | + | export function allowed(): boolean { | |
| 82 | + | return analyticsHere() && (!consentRequired() || choice() === "yes"); | |
| 83 | + | } |
| 1 | 1 | /** | |
| 2 | 2 | * Product analytics (HeyCatch), on g1t.sh only: pageviews, clicks and | |
| 3 | − | * route changes, captured from the first page. Every event passes through | |
| 4 | − | * lib/analytics-scrub.ts first, so inside the app names, text and titles | |
| 5 | − | * never leave the browser, and recordings are never sent. A signed-in | |
| 6 | − | * person is known by their account id alone (`identify` below). | |
| 3 | + | * route changes. Every event passes through lib/analytics-scrub.ts first, | |
| 4 | + | * so inside the app names, text and titles never leave the browser, and | |
| 5 | + | * recordings are never sent. A signed-in person is known by their account | |
| 6 | + | * id alone (`identify` below). | |
| 7 | 7 | * | |
| 8 | − | * Imported once, statically, by entry.client.tsx, so it runs before the | |
| 9 | − | * app does. A self-hosted g1t sends nothing. | |
| 8 | + | * The SDK is fetched once the page has loaded and the browser is idle, so | |
| 9 | + | * it never slows a page down; where a visitor is asked first | |
| 10 | + | * (lib/analytics-consent.ts) it is not fetched at all until they agree. | |
| 11 | + | * Imported by entry.client.tsx. A self-hosted g1t sends nothing. | |
| 10 | 12 | */ | |
| 11 | − | import { analytics } from "@heycatch/sdk"; | |
| 13 | + | import type { analytics as Analytics } from "@heycatch/sdk"; | |
| 12 | 14 | ||
| 15 | + | import { allowed, analyticsHere, choice, consentRequired, onChoice } from "./analytics-consent"; | |
| 13 | 16 | import { scrubEvent } from "./analytics-scrub"; | |
| 14 | 17 | ||
| 15 | − | /** The one installation analytics runs on. */ | |
| 16 | − | const HOSTED = "g1t.sh"; | |
| 18 | + | let sdk: Promise<typeof Analytics> | null = null; | |
| 19 | + | /** Who is signed in, kept for when the SDK arrives. */ | |
| 20 | + | let signedIn: string | null = null; | |
| 21 | + | ||
| 22 | + | function load(): Promise<typeof Analytics> { | |
| 23 | + | sdk ??= import("@heycatch/sdk").then(({ analytics }) => { | |
| 24 | + | analytics.init({ | |
| 25 | + | projectKey: "hck_pk_EsF6nrWNZ0tOM4cmDKA6tKBzdTr-GEW3", | |
| 26 | + | install: { framework: "react", frameworkVersion: "19", agent: "claude-code" }, | |
| 27 | + | respectDnt: true, | |
| 28 | + | beforeSend: (event) => scrubEvent(event, window.location.pathname, window.location.origin), | |
| 29 | + | }); | |
| 30 | + | if (signedIn) analytics.setIdentity(signedIn); | |
| 31 | + | return analytics; | |
| 32 | + | }); | |
| 33 | + | return sdk; | |
| 34 | + | } | |
| 17 | 35 | ||
| 18 | − | const enabled = window.location.hostname === HOSTED; | |
| 36 | + | /** After the page has loaded, when the browser has a moment. */ | |
| 37 | + | function whenIdle(run: () => void) { | |
| 38 | + | // Safari has no requestIdleCallback. | |
| 39 | + | const idle = () => (typeof window.requestIdleCallback === "function" ? window.requestIdleCallback(run, { timeout: 4000 }) : setTimeout(run, 1500)); | |
| 40 | + | if (document.readyState === "complete") idle(); | |
| 41 | + | else window.addEventListener("load", idle, { once: true }); | |
| 42 | + | } | |
| 19 | 43 | ||
| 20 | − | if (enabled) { | |
| 21 | − | analytics.init({ | |
| 22 | − | projectKey: "hck_pk_EsF6nrWNZ0tOM4cmDKA6tKBzdTr-GEW3", | |
| 23 | − | install: { framework: "react", frameworkVersion: "19", agent: "claude-code" }, | |
| 24 | − | respectDnt: true, | |
| 25 | − | beforeSend: (event) => scrubEvent(event, window.location.pathname, window.location.origin), | |
| 44 | + | if (analyticsHere()) { | |
| 45 | + | if (allowed()) whenIdle(() => void load()); | |
| 46 | + | // A choice made on the banner, or changed from the footer. | |
| 47 | + | onChoice(() => { | |
| 48 | + | if (!consentRequired()) return; | |
| 49 | + | if (choice() === "yes") void load().then((analytics) => analytics.optInCapturing()); | |
| 50 | + | else if (sdk) void sdk.then((analytics) => analytics.optOutCapturing()); | |
| 26 | 51 | }); | |
| 27 | 52 | } | |
| 28 | 53 | ||
| 29 | 54 | /** Who is signed in, by account id only; null after signing out. */ | |
| 30 | 55 | export function identify(userId: string | null, previous: string | null) { | |
| 31 | − | if (!enabled) return; | |
| 32 | − | if (userId) analytics.setIdentity(userId); | |
| 33 | − | else if (previous) analytics.resetIdentity(); | |
| 56 | + | signedIn = userId; | |
| 57 | + | if (!sdk) return; | |
| 58 | + | void sdk.then((analytics) => { | |
| 59 | + | if (userId) analytics.setIdentity(userId); | |
| 60 | + | else if (previous) analytics.resetIdentity(); | |
| 61 | + | }); | |
| 34 | 62 | } |
| 96 | 96 | { | |
| 97 | 97 | date: "2026-10-09", | |
| 98 | 98 | change: | |
| 99 | − | "Privacy Policy: g1t.sh uses HeyCatch for site analytics, with names removed outside the public pages, and one analytics cookie. Subprocessors: HeyCatch added.", | |
| 99 | + | "Privacy Policy: g1t.sh uses HeyCatch for site analytics, with names removed outside the public pages, and one analytics cookie, set in the EU, EEA, UK and Switzerland only after you allow it. Subprocessors: HeyCatch, Inc. added.", | |
| 100 | 100 | }, | |
| 101 | 101 | { | |
| 102 | 102 | date: "2026-10-06", |
| 52 | 52 | import { SpikeBanner } from "./components/spike-banner"; | |
| 53 | 53 | import { PolicyNotice } from "./components/policy-notice"; | |
| 54 | 54 | import { identify } from "./lib/analytics.client"; | |
| 55 | + | import { visitorAsksFirst } from "./lib/analytics-consent"; | |
| 56 | + | import { AnalyticsConsent } from "./components/analytics-consent"; | |
| 55 | 57 | import { readCookie } from "./lib/mission"; | |
| 56 | 58 | import { WORKSPACE_COOKIE, workspaceFor } from "./lib/workspace-choice"; | |
| 57 | 59 | import { PageMain } from "./components/landmark"; | |
| ⋯ | |||
| 106 | 108 | registrationMode(), | |
| 107 | 109 | ]); | |
| 108 | 110 | // Where this g1t lives, for clone lines, agent setup and link previews. | |
| 109 | − | return { user, shell, inviteOnly: mode !== "open", addresses: addresses() }; | |
| 111 | + | return { | |
| 112 | + | user, | |
| 113 | + | shell, | |
| 114 | + | inviteOnly: mode !== "open", | |
| 115 | + | addresses: addresses(), | |
| 116 | + | // Visitors from where the law asks first are asked before analytics runs. | |
| 117 | + | analyticsConsent: visitorAsksFirst(request), | |
| 118 | + | }; | |
| 110 | 119 | } | |
| 111 | 120 | ||
| 112 | 121 | /** | |
| ⋯ | |||
| 590 | 599 | {/* The stylesheet before everything React Router preloads, so a slow | |
| 591 | 600 | connection paints sooner (docs/research/css-shipping.md). */} | |
| 592 | 601 | <link rel="stylesheet" href={appCss} precedence="default" /> | |
| 602 | + | {root?.analyticsConsent && <meta name="g1t-analytics" content="consent" />} | |
| 593 | 603 | <Meta /> | |
| 594 | 604 | <Links nonce={nonce} /> | |
| 595 | 605 | </head> | |
| ⋯ | |||
| 620 | 630 | {user && !awaitsConfirmation(user) && ( | |
| 621 | 631 | <LiveNotifications workspace={root?.shell?.workspace?.slug ?? null} inbox={root?.shell?.inbox?.unread ?? null} /> | |
| 622 | 632 | )} | |
| 633 | + | <AnalyticsConsent /> | |
| 623 | 634 | <ScrollRestoration nonce={nonce} /> | |
| 624 | 635 | <Scripts nonce={nonce} /> | |
| 625 | 636 | </body> | |
| 3 | 3 | import { identityClient, isNamespaceShaped } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | import { addressesFor } from "../app/lib/addresses"; | |
| 6 | + | import { visitorAsksFirst } from "../app/lib/analytics-consent"; | |
| 6 | 7 | import { hardenRegistryHeaders } from "../app/lib/content-safety"; | |
| 7 | 8 | import { withSiteHeaders } from "../app/lib/page-headers"; | |
| 8 | 9 | import { finishResponse, withRequestPerf } from "../app/lib/perf.server"; | |
| ⋯ | |||
| 128 | 129 | ||
| 129 | 130 | async function servePublic(env: Env, request: Request, ctx: ExecutionContext, render: () => Promise<Response>): Promise<Response> { | |
| 130 | 131 | const cache = (caches as unknown as { default: Cache }).default; | |
| 131 | − | const key = new Request(request.url, { method: "GET" }); | |
| 132 | + | // Visitors asked about analytics first get a page that says so, kept apart. | |
| 133 | + | const keyUrl = new URL(request.url); | |
| 134 | + | if (visitorAsksFirst(request)) keyUrl.searchParams.set("_g1t_consent", "1"); | |
| 135 | + | const key = new Request(keyUrl, { method: "GET" }); | |
| 132 | 136 | const repository = PUBLIC_PROJECT.test(new URL(request.url).pathname) ? repositoryOfPage(new URL(request.url).pathname) : null; | |
| 133 | 137 | // Asked alongside the cache, so a hit waits for one indexed read at most. | |
| 134 | 138 | const [cached, visible] = await Promise.all([cache.match(key), repository ? isStillPublic(env, repository) : Promise.resolve(true)]); | |
| 185 | 185 | | --- | --- | --- | --- | | |
| 186 | 186 | | Static assets (`/assets/*`) | browser and edge | a year, immutable | hashed file names | | |
| 187 | 187 | | Avatars | edge cache | a year, immutable | by content hash | | |
| 188 | − | | Public pages for people signed out | the data centre's cache (`workers/app.ts`, `servePublic`) | fresh 30 s, then served once more while a new copy is made, up to 5 min | GET, no `g1t_session` cookie, an allowlisted path (home, pricing, explore, policies, a project's pages), status 200 or 404, no `Set-Cookie`, nothing private. Reserved first segments and workspace pages (`-`) are never kept. A project's kept page is served only after repos' `visibility` says the repository is still there and public (one indexed read, alongside the cache lookup); a repository made private or deleted is never served from any data centre's copy, and the copy is dropped. The answer says `server-timing: cache;desc="hit, Ns old"`. | | |
| 188 | + | | Public pages for people signed out | the data centre's cache (`workers/app.ts`, `servePublic`) | fresh 30 s, then served once more while a new copy is made, up to 5 min | GET, no `g1t_session` cookie, an allowlisted path (home, pricing, explore, policies, a project's pages), status 200 or 404, no `Set-Cookie`, nothing private. Reserved first segments and workspace pages (`-`) are never kept. A project's kept page is served only after repos' `visibility` says the repository is still there and public (one indexed read, alongside the cache lookup); a repository made private or deleted is never served from any data centre's copy, and the copy is dropped. Visitors from the EU, EEA, UK and Switzerland, whose page asks about the analytics cookie, get a copy of their own (the cache key gains `_g1t_consent=1`, `lib/analytics-consent.ts`). The answer says `server-timing: cache;desc="hit, Ns old"`. | | |
| 189 | 189 | | Sidebar data (projects, spend, limit, entitlements) | per isolate (`lib/cache.server.ts`) | 15 s, per person and workspace | skipped during a write and for 30 s after the person's last one; failures not kept; only settled answers kept | | |
| 190 | 190 | | Registration mode | per isolate | 60 s | | | |
| 191 | 191 | | A commit's log by hash | repos' data-centre cache | for good | history from a commit never changes. One of 100 commits or more is put together from a 16-commit read and the history kept from any of those commits, when there is one (`store.rs` `spliced_log`): a default branch that moved by a merge costs 16 commits, not 120 or 1,000 | |