Skip to content
122 linesCodeBlameRaw
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. */
16const 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 */
32const 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",
36 "github", "guardrails", "home", "inbox", "insights", "integrations", "invitations", "invite", "invites", "issues",
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",
40 "security-log", "settings", "soon", "spend", "tags", "teams", "tokens", "tree", "two-factor", "u", "usage",
41 "verify", "webhooks", "workspace", "workspaces",
42]);
43
44export 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. */
50export 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. */
62function 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. */
70export 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. */
82const CHAIN_DETAIL = /(?:text|href|attr__[\w-]+)="(?:[^"\\]|\\.)*"/g;
83
84function 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. */
107const NEVER = new Set(["$snapshot", "$snapshot_items", "$exception"]);
108
109export 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 */
115export 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}