Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge site-audit: SEO routes, analytics with consent first in Europe, security page, founder and X account, docs header; the tour opens on the pull request and leads with Code, on the real shell, with Agents and Docs working today | 1 | /** |
| 2 | * What product analytics may learn from a page, decided before an event | |
| 3 | * leaves the browser (lib/analytics.client.ts). | |
| 4 | * | |
| 5 | * The public front door (the home page, pricing, Explore, signing up and | |
| 6 | * in, support, security and the policies) is sent as it is. Everywhere else, names are replaced | |
| 7 | * with placeholders: `/acme/web/pull/12` is sent as | |
| 8 | * `/:name/:name/pull/:n`, the page title as "g1t", and a clicked element | |
| 9 | * without its text, link or attributes. Query strings keep only `utm_*`. | |
| 10 | * Session recordings and error reports are never sent, and click heatmaps | |
| 11 | * only from the front door. No Workers or browser imports, so it is tested | |
| 12 | * under Node. | |
| 13 | */ | |
| 14 | ||
| 15 | /** Paths sent as they are. */ | |
| 16 | const PUBLIC = new Set([ | |
| 17 | "/", | |
| 18 | "/pricing", | |
| 19 | "/explore", | |
| 20 | "/register", | |
| 21 | "/login", | |
| 22 | "/security", | |
| 23 | "/support", | |
| 24 | "/status", | |
| 25 | "/policies", | |
| 26 | ]); | |
| 27 | ||
| 28 | /** | |
| 29 | * Words of g1t's own addresses, kept in a scrubbed path so it still says | |
| 30 | * which kind of page it was. Anything else is a name, and is replaced. | |
| 31 | */ | |
| 32 | const ROUTE_WORDS = new Set([ | |
| 33 | "-", "about.json", "account", "actions", "activity", "agents", "applications", "archive", "audit", "billing", | |
| 34 | "blob", "branches", "browse", "bypass-requests", "chat", "checks", "code", "code-access", "commit", "commits", | |
| 35 | "compare", "confirm-email", "context", "deployments", "dm", "docs", "emails", "emoji", "entries", "explore", "files", "forgot", "gateway", | |
| g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them. | 36 | "github", "guardrails", "home", "inbox", "apps", "insights", "integrations", "invitations", "invite", "invites", "issues", |
| Merge site-audit: SEO routes, analytics with consent first in Europe, security page, founder and X account, docs header; the tour opens on the pull request and leads with Code, on the real shell, with Agents and Docs working today | 37 | "jobs", "keys", "labels", "login", "members", "memory", "merge-queue", "milestones", "new", "notifications", "overview", |
| 38 | "packages", "patterns", "people", "personal-access-tokens", "pins", "policies", "profile", "projects", "pull", | |
| 39 | "pulls", "queue", "releases", "repositories", "reset", "rules", "runners", "runs", "search", "secrets", "security", | |
| g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them. | 40 | "security-log", "settings", "soon", "spend", "tags", "teams", "today", "tokens", "tree", "two-factor", "u", "usage", |
| Merge site-audit: SEO routes, analytics with consent first in Europe, security page, founder and X account, docs header; the tour opens on the pull request and leads with Code, on the real shell, with Agents and Docs working today | 41 | "verify", "webhooks", "workspace", "workspaces", |
| 42 | ]); | |
| 43 | ||
| 44 | export function isPublicPath(pathname: string): boolean { | |
| 45 | const path = pathname.replace(/\/+$/, "") || "/"; | |
| 46 | return PUBLIC.has(path) || path.startsWith("/policies/"); | |
| 47 | } | |
| 48 | ||
| 49 | /** A path as analytics may see it. */ | |
| 50 | export function scrubPath(pathname: string): string { | |
| 51 | if (isPublicPath(pathname)) return pathname; | |
| 52 | return pathname | |
| 53 | .split("/") | |
| 54 | .map((segment) => { | |
| 55 | if (segment === "" || ROUTE_WORDS.has(segment)) return segment; | |
| 56 | return /^\d+$/.test(segment) ? ":n" : ":name"; | |
| 57 | }) | |
| 58 | .join("/"); | |
| 59 | } | |
| 60 | ||
| 61 | /** Only campaign parameters survive; everything else in a query can name something. */ | |
| 62 | function scrubSearch(search: URLSearchParams): string { | |
| 63 | const kept = new URLSearchParams(); | |
| 64 | for (const [key, value] of search) if (key.startsWith("utm_")) kept.append(key, value); | |
| 65 | const query = kept.toString(); | |
| 66 | return query ? `?${query}` : ""; | |
| 67 | } | |
| 68 | ||
| 69 | /** A URL on this site as analytics may see it; another site's is left alone. */ | |
| 70 | export function scrubUrl(value: string, origin: string): string { | |
| 71 | let url: URL; | |
| 72 | try { | |
| 73 | url = new URL(value); | |
| 74 | } catch { | |
| 75 | return value; | |
| 76 | } | |
| 77 | if (url.origin !== origin) return value; | |
| 78 | return `${url.origin}${scrubPath(url.pathname)}${scrubSearch(url.searchParams)}`; | |
| 79 | } | |
| 80 | ||
| 81 | /** Element text, links and attributes in autocapture's chain string. */ | |
| 82 | const CHAIN_DETAIL = /(?:text|href|attr__[\w-]+)="(?:[^"\\]|\\.)*"/g; | |
| 83 | ||
| 84 | function scrubValue(key: string, value: unknown, origin: string, publicPage: boolean): unknown { | |
| 85 | if (typeof value === "string") { | |
| 86 | if (!publicPage && (key === "$title" || key === "title")) return "g1t"; | |
| 87 | if (!publicPage && key === "$el_text") return undefined; | |
| 88 | if (!publicPage && key === "$elements_chain") return value.replace(CHAIN_DETAIL, ""); | |
| 89 | if (/pathname$/i.test(key) && value.startsWith("/")) return scrubPath(value); | |
| 90 | return value.startsWith(origin) ? scrubUrl(value, origin) : value; | |
| 91 | } | |
| 92 | if (Array.isArray(value)) return value.map((item) => scrubValue(key, item, origin, publicPage)); | |
| 93 | if (value && typeof value === "object") { | |
| 94 | const out: Record<string, unknown> = {}; | |
| 95 | for (const [inner, innerValue] of Object.entries(value)) { | |
| 96 | if (!publicPage && (inner === "$el_text" || inner === "href" || inner.startsWith("attr__"))) continue; | |
| 97 | const scrubbed = scrubValue(inner, innerValue, origin, publicPage); | |
| 98 | // Some objects are keyed by address, such as a heatmap's pages. | |
| 99 | if (scrubbed !== undefined) out[inner.startsWith(origin) ? scrubUrl(inner, origin) : inner] = scrubbed; | |
| 100 | } | |
| 101 | return out; | |
| 102 | } | |
| 103 | return value; | |
| 104 | } | |
| 105 | ||
| 106 | /** Events that are never sent: session recordings and error reports, which carry page content. */ | |
| 107 | const NEVER = new Set(["$snapshot", "$snapshot_items", "$exception"]); | |
| 108 | ||
| 109 | export type CapturedEvent = { event: string; properties: Record<string, unknown>; [key: string]: unknown }; | |
| 110 | ||
| 111 | /** | |
| 112 | * The event as analytics may see it, or null to drop it. `pathname` is the | |
| 113 | * page the event happened on; `origin` is this site's. | |
| 114 | */ | |
| 115 | export function scrubEvent<T extends CapturedEvent>(event: T | null, pathname: string, origin: string): T | null { | |
| 116 | if (!event || NEVER.has(event.event)) return null; | |
| 117 | const publicPage = isPublicPath(pathname); | |
| 118 | // A heatmap is keyed by the page's address, and holds where on it people clicked. | |
| 119 | if (event.event === "$$heatmap" && !publicPage) return null; | |
| 120 | const properties = scrubValue("", event.properties ?? {}, origin, publicPage) as Record<string, unknown>; | |
| 121 | return { ...event, properties }; | |
| 122 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.