Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

195 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(["/", "/pricing", "/explore", "/register", "/login", "/security", "/support", "/status", "/policies"]);
17
18/**
19 * Words of g1t's own addresses, kept in a scrubbed path so it still says
20 * which kind of page it was. Anything else is a name, and is replaced.
21 */
22const ROUTE_WORDS = new Set([
23 "-",
24 "about.json",
25 "account",
26 "actions",
27 "activity",
28 "agents",
29 "applications",
30 "archive",
31 "audit",
32 "billing",
33 "blob",
34 "branches",
35 "browse",
36 "bypass-requests",
37 "chat",
38 "checks",
39 "code",
40 "code-access",
41 "commit",
42 "commits",
43 "compare",
44 "confirm-email",
45 "context",
46 "deployments",
47 "dm",
48 "docs",
49 "emails",
50 "emoji",
51 "entries",
52 "explore",
53 "files",
54 "forgot",
55 "gateway",
56 "github",
57 "guardrails",
58 "home",
59 "inbox",
60 "apps",
61 "insights",
62 "integrations",
63 "invitations",
64 "invite",
65 "invites",
66 "issues",
67 "jobs",
68 "keys",
69 "labels",
70 "login",
71 "members",
72 "merge-queue",
73 "milestones",
74 "new",
75 "notifications",
76 "overview",
77 "packages",
78 "patterns",
79 "people",
80 "personal-access-tokens",
81 "pins",
82 "policies",
83 "profile",
84 "projects",
85 "provider-costs",
86 "pull",
87 "pulls",
88 "queue",
89 "releases",
90 "repositories",
91 "reset",
92 "rules",
93 "runners",
94 "runs",
95 "search",
96 "secrets",
97 "security",
98 "security-log",
99 "settings",
100 "soon",
101 "spend",
102 "tags",
103 "teams",
104 "today",
105 "tokens",
106 "tree",
107 "two-factor",
108 "u",
109 "usage",
110 "verify",
111 "webhooks",
112 "work",
113 "workspace",
114 "workspaces",
115]);
116
117export function isPublicPath(pathname: string): boolean {
118 const path = pathname.replace(/\/+$/, "") || "/";
119 return PUBLIC.has(path) || path.startsWith("/policies/");
120}
121
122/** A path as analytics may see it. */
123export function scrubPath(pathname: string): string {
124 if (isPublicPath(pathname)) return pathname;
125 return pathname
126 .split("/")
127 .map((segment) => {
128 if (segment === "" || ROUTE_WORDS.has(segment)) return segment;
129 return /^\d+$/.test(segment) ? ":n" : ":name";
130 })
131 .join("/");
132}
133
134/** Only campaign parameters survive; everything else in a query can name something. */
135function scrubSearch(search: URLSearchParams): string {
136 const kept = new URLSearchParams();
137 for (const [key, value] of search) if (key.startsWith("utm_")) kept.append(key, value);
138 const query = kept.toString();
139 return query ? `?${query}` : "";
140}
141
142/** A URL on this site as analytics may see it; another site's is left alone. */
143export function scrubUrl(value: string, origin: string): string {
144 let url: URL;
145 try {
146 url = new URL(value);
147 } catch {
148 return value;
149 }
150 if (url.origin !== origin) return value;
151 return `${url.origin}${scrubPath(url.pathname)}${scrubSearch(url.searchParams)}`;
152}
153
154/** Element text, links and attributes in autocapture's chain string. */
155const CHAIN_DETAIL = /(?:text|href|attr__[\w-]+)="(?:[^"\\]|\\.)*"/g;
156
157function scrubValue(key: string, value: unknown, origin: string, publicPage: boolean): unknown {
158 if (typeof value === "string") {
159 if (!publicPage && (key === "$title" || key === "title")) return "g1t";
160 if (!publicPage && key === "$el_text") return undefined;
161 if (!publicPage && key === "$elements_chain") return value.replace(CHAIN_DETAIL, "");
162 if (/pathname$/i.test(key) && value.startsWith("/")) return scrubPath(value);
163 return value.startsWith(origin) ? scrubUrl(value, origin) : value;
164 }
165 if (Array.isArray(value)) return value.map((item) => scrubValue(key, item, origin, publicPage));
166 if (value && typeof value === "object") {
167 const out: Record<string, unknown> = {};
168 for (const [inner, innerValue] of Object.entries(value)) {
169 if (!publicPage && (inner === "$el_text" || inner === "href" || inner.startsWith("attr__"))) continue;
170 const scrubbed = scrubValue(inner, innerValue, origin, publicPage);
171 // Some objects are keyed by address, such as a heatmap's pages.
172 if (scrubbed !== undefined) out[inner.startsWith(origin) ? scrubUrl(inner, origin) : inner] = scrubbed;
173 }
174 return out;
175 }
176 return value;
177}
178
179/** Events that are never sent: session recordings and error reports, which carry page content. */
180const NEVER = new Set(["$snapshot", "$snapshot_items", "$exception"]);
181
182export type CapturedEvent = { event: string; properties: Record<string, unknown>; [key: string]: unknown };
183
184/**
185 * The event as analytics may see it, or null to drop it. `pathname` is the
186 * page the event happened on; `origin` is this site's.
187 */
188export function scrubEvent<T extends CapturedEvent>(event: T | null, pathname: string, origin: string): T | null {
189 if (!event || NEVER.has(event.event)) return null;
190 const publicPage = isPublicPath(pathname);
191 // A heatmap is keyed by the page's address, and holds where on it people clicked.
192 if (event.event === "$$heatmap" && !publicPage) return null;
193 const properties = scrubValue("", event.properties ?? {}, origin, publicPage) as Record<string, unknown>;
194 return { ...event, properties };
195}