Site analytics with HeyCatch on g1t.sh, with names removed inside the app; the privacy policy says so
The SDK (@heycatch/sdk 0.8.3, the latest stable) starts from the client entry, on g1t.sh only: a self-hosted g1t sends nothing. Every event passes through lib/analytics-scrub.ts first. The public pages go as they are; everywhere else addresses go with names replaced (/:name/:name/pull/:n), titles become "g1t", and clicks lose their text, links and attributes. Query strings keep only utm_* tags. Session recordings, error reports and heatmaps inside the app are never sent, and Do Not Track is honoured. A signed-in person is identified by account id alone, and forgotten on signing out. The page policy allows in.heycatch.ai's helper script. Not done: server-side business events (the SDK's server build needs Node APIs the Workers here do not have), and HeyCatch's one-character short links, which would take workspace addresses. The Privacy Policy gains "How the site is used" and the analytics cookie; Subprocessors lists HeyCatch, its location still to confirm. The policy promises account holders 30 days' notice of material changes, so this should not ship before that notice has run.
| 31 | 31 | | Context hub search | Off | | |
| 32 | 32 | | Deployments on `g1t.page` | Off | | |
| 33 | 33 | | Billing | Off. Nothing is charged, and no usage limit stops work. | | |
| 34 | + | | Site analytics | Off. Only g1t.sh sends page analytics (to HeyCatch, as its [privacy policy](https://g1t.sh/policies/privacy#how-the-site-is-used) describes); your installation sends none. | | |
| 34 | 35 | | Git over SSH and the `g1t` CLI | Not available yet | | |
| 35 | 36 | | Scheduled jobs | Run on their schedules inside the g1t container: webhook retries, purging deleted repositories, the packages sweep, security sweeps, audit log retention and access request summaries. Actions schedules (`on: schedule`) are not run. | | |
| 36 | 37 |
| 1 | 1 | This policy explains what information g1t collects, why, where it goes, how long we keep it, and what you can do about it. It covers g1t.sh, api.g1t.sh, mcp.g1t.sh, docs.g1t.sh, g1t.page and the agents and sandboxes g1t runs. g1t is run by Flagon, Inc. ("Flagon", "we"), which is responsible for your information. | |
| 2 | 2 | ||
| 3 | − | The short version: we collect what we need to run a git platform for you and your agents, we don't sell it, we don't advertise, we don't use your private content to train AI models, and there are no third-party trackers on g1t. | |
| 3 | + | The short version: we collect what we need to run a git platform for you and your agents, we don't sell it, we don't advertise, we don't use your private content to train AI models, and the only analytics on g1t.sh counts how pages are used without reading what is in your workspaces. | |
| 4 | 4 | ||
| 5 | 5 | ## What we collect | |
| 6 | 6 | ||
| ⋯ | |||
| 38 | 38 | ||
| 39 | 39 | Payments are handled by Stripe. **Card numbers never reach g1t.** From Stripe we keep the workspace's customer reference, the card's brand, last four digits and expiry date, whether it is a prepaid card, and a fingerprint of the card that Stripe gives us, so that each card gets only one trial. Your billing email, address and tax ID are held by Stripe. We keep the workspace's statement: usage, charges, credits, payments and invoices. | |
| 40 | 40 | ||
| 41 | + | ### How the site is used | |
| 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. | |
| 44 | + | ||
| 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 | + | - **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. | |
| 47 | + | - **If you're signed in**, it receives your account's internal id, so your visits count as one person. Not your username, email address or name. | |
| 48 | + | - **Never**: recordings of your screen or session, what you type, or error reports. | |
| 49 | + | - **Query strings** are removed, except campaign tags (`utm_…`). | |
| 50 | + | ||
| 51 | + | If your browser sends Do Not Track, nothing is recorded. A g1t you run yourself sends nothing to HeyCatch. | |
| 52 | + | ||
| 41 | 53 | ### When you write to us | |
| 42 | 54 | ||
| 43 | 55 | If you email support, security, privacy or billing, we keep the conversation, so we can help you and remember what we said. | |
| ⋯ | |||
| 48 | 60 | ||
| 49 | 61 | ## Cookies and browser storage | |
| 50 | 62 | ||
| 51 | − | g1t uses only the cookies it needs to work. There are **no analytics, advertising or third-party tracking cookies**. | |
| 63 | + | g1t uses the cookies it needs to work, and one analytics cookie. There are **no advertising or cross-site tracking cookies**. | |
| 52 | 64 | ||
| 53 | 65 | | Cookie | What it's for | How long | | |
| 54 | 66 | | --- | --- | --- | | |
| ⋯ | |||
| 56 | 68 | | `g1t_ws` | Remembers which workspace you last chose, so the sidebar opens on it. | 1 year | | |
| 57 | 69 | | `g1t_seen` | Remembers when you last looked at mission control, so it can show what's new since. | 1 year | | |
| 58 | 70 | | `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 | | |
| 59 | 72 | ||
| 60 | 73 | Cloudflare may set its own security cookies (such as `__cf_bm`) to tell people from bots when g1t is under attack. | |
| 61 | 74 | ||
| 62 | − | The site also keeps a few preferences in your browser's local storage, which never leave your device: which coding agent you set up with, how you like diffs shown, and checklist items you've dismissed. | |
| 75 | + | The site also keeps a few preferences in your browser's local storage, which never leave your device: which coding agent you set up with, how you like diffs shown, and checklist items you've dismissed. HeyCatch keeps a copy of its analytics id there too, which is sent with each analytics event. | |
| 63 | 76 | ||
| 64 | 77 | **Fonts.** g1t.sh and docs.g1t.sh serve their typefaces themselves, so loading a page asks no one else for them. | |
| 65 | 78 | ||
| ⋯ | |||
| 72 | 85 | - bill workspaces and keep the records the law requires; | |
| 73 | 86 | - send you the email g1t needs to: confirming your address, resetting your password, billing alerts and receipts, answers to requests you made, and important changes to g1t or these policies. **We don't send marketing email**; | |
| 74 | 87 | - answer you when you write to us; | |
| 75 | − | - improve g1t, using our own records of how it is used. We don't use your private content to train AI models. | |
| 88 | + | - improve g1t, using our own records of how it is used and the site analytics [above](#how-the-site-is-used). We don't use your private content to train AI models. | |
| 76 | 89 | ||
| 77 | 90 | If you're in the European Economic Area or the United Kingdom, our legal bases are: **performing our contract with you** (running the service you signed up for), **our legitimate interests** (keeping g1t secure, preventing abuse, and improving it, balanced against your rights), and **legal obligations** (such as keeping tax records). | |
| 78 | 91 | ||
| ⋯ | |||
| 82 | 95 | ||
| 83 | 96 | We share information only to run g1t, at your direction, or when the law requires it. | |
| 84 | 97 | ||
| 85 | − | - **Service providers ("subprocessors")** that run parts of g1t for us, under contracts that limit them to doing so: Cloudflare (hosting, storage, databases, sandboxes, email, search and the AI gateway), Stripe (payments) and Anthropic (the model behind hosted agents). The [subprocessors](/policies/subprocessors) page lists them and what each receives. | |
| 98 | + | - **Service providers ("subprocessors")** that run parts of g1t for us, under contracts that limit them to doing so: Cloudflare (hosting, storage, databases, sandboxes, email, search and the AI gateway), Stripe (payments), Anthropic (the model behind hosted agents) and HeyCatch (site analytics). The [subprocessors](/policies/subprocessors) page lists them and what each receives. | |
| 86 | 99 | - **Services you connect.** When you connect your own model provider, an issue tracker, error tracking or a webhook, or let your sandboxes reach a host, we send them what that connection needs, because you asked us to. | |
| 87 | 100 | - **Other people on g1t**, as your settings allow: your workspace's members, and everyone for public projects and your public profile. | |
| 88 | 101 | - **Vulnerability databases.** To find vulnerable dependencies, we send the names and versions of packages in your lockfiles to OSV.dev, an open database run by Google. Nothing else about you or your repository is sent. | |
| 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 | 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 | | |
| 10 | 11 | ||
| 11 | 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. |
| 2 | 2 | import { hydrateRoot } from "react-dom/client"; | |
| 3 | 3 | import { HydratedRouter } from "react-router/dom"; | |
| 4 | 4 | ||
| 5 | + | // Product analytics, started before the app (lib/analytics.client.ts). | |
| 6 | + | import "./lib/analytics.client"; | |
| 7 | + | ||
| 5 | 8 | startTransition(() => { | |
| 6 | 9 | hydrateRoot( | |
| 7 | 10 | document, |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { isPublicPath, scrubEvent, scrubPath, scrubUrl } from "./analytics-scrub.ts"; | |
| 5 | + | ||
| 6 | + | const ORIGIN = "https://g1t.sh"; | |
| 7 | + | ||
| 8 | + | test("the front door is sent as it is", () => { | |
| 9 | + | for (const path of ["/", "/pricing", "/explore", "/register", "/policies/privacy", "/pricing/"]) { | |
| 10 | + | assert.equal(isPublicPath(path), true, path); | |
| 11 | + | assert.equal(scrubPath(path), path); | |
| 12 | + | } | |
| 13 | + | }); | |
| 14 | + | ||
| 15 | + | test("names in other addresses are replaced, and g1t's own words kept", () => { | |
| 16 | + | assert.equal(scrubPath("/acme/web/pull/12/files"), "/:name/:name/pull/:n/files"); | |
| 17 | + | assert.equal(scrubPath("/acme/-/chat/secret-launch"), "/:name/-/chat/:name"); | |
| 18 | + | assert.equal(scrubPath("/u/sam"), "/u/:name"); | |
| 19 | + | assert.equal(scrubPath("/invite/abc123"), "/invite/:name"); | |
| 20 | + | assert.equal(scrubPath("/acme/web/blob/main/src/keys.ts"), "/:name/:name/blob/:name/:name/:name"); | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | test("a URL on the site keeps only campaign parameters; another site's is left alone", () => { | |
| 24 | + | assert.equal(scrubUrl("https://g1t.sh/reset?token=s3cret&utm_source=x", ORIGIN), "https://g1t.sh/reset?utm_source=x"); | |
| 25 | + | assert.equal(scrubUrl("https://g1t.sh/search?q=private", ORIGIN), "https://g1t.sh/search"); | |
| 26 | + | assert.equal(scrubUrl("https://g1t.sh/acme/web#L4", ORIGIN), "https://g1t.sh/:name/:name"); | |
| 27 | + | assert.equal(scrubUrl("https://news.example/acme?x=1", ORIGIN), "https://news.example/acme?x=1"); | |
| 28 | + | }); | |
| 29 | + | ||
| 30 | + | test("a click inside the app goes without its text, link, attributes or title", () => { | |
| 31 | + | const event = scrubEvent( | |
| 32 | + | { | |
| 33 | + | event: "$autocapture", | |
| 34 | + | properties: { | |
| 35 | + | $current_url: "https://g1t.sh/acme/secret/issues/3", | |
| 36 | + | $pathname: "/acme/secret/issues/3", | |
| 37 | + | $title: "Rotate the prod key · acme/secret · g1t", | |
| 38 | + | $el_text: "Rotate the prod key", | |
| 39 | + | $elements_chain: 'a.link:text="Rotate the prod key"href="/acme/secret/issues/3"nth-child="1"attr__class="link"', | |
| 40 | + | $elements: [{ tag_name: "a", $el_text: "Rotate", href: "/acme/secret", attr__title: "x", nth_child: 1 }], | |
| 41 | + | $set_once: { $initial_current_url: "https://g1t.sh/acme/secret" }, | |
| 42 | + | }, | |
| 43 | + | }, | |
| 44 | + | "/acme/secret/issues/3", | |
| 45 | + | ORIGIN, | |
| 46 | + | ); | |
| 47 | + | assert.deepEqual(event?.properties, { | |
| 48 | + | $current_url: "https://g1t.sh/:name/:name/issues/:n", | |
| 49 | + | $pathname: "/:name/:name/issues/:n", | |
| 50 | + | $title: "g1t", | |
| 51 | + | $elements_chain: 'a.link:nth-child="1"', | |
| 52 | + | $elements: [{ tag_name: "a", nth_child: 1 }], | |
| 53 | + | $set_once: { $initial_current_url: "https://g1t.sh/:name/:name" }, | |
| 54 | + | }); | |
| 55 | + | }); | |
| 56 | + | ||
| 57 | + | test("on the front door a click keeps its text", () => { | |
| 58 | + | const event = scrubEvent( | |
| 59 | + | { event: "$autocapture", properties: { $el_text: "Start for free", $title: "g1t · Your team", $pathname: "/" } }, | |
| 60 | + | "/", | |
| 61 | + | ORIGIN, | |
| 62 | + | ); | |
| 63 | + | assert.deepEqual(event?.properties, { $el_text: "Start for free", $title: "g1t · Your team", $pathname: "/" }); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test("recordings and error reports are never sent, and heatmaps only from the front door", () => { | |
| 67 | + | assert.equal(scrubEvent({ event: "$snapshot", properties: {} }, "/", ORIGIN), null); | |
| 68 | + | assert.equal(scrubEvent({ event: "$exception", properties: {} }, "/", ORIGIN), null); | |
| 69 | + | assert.equal(scrubEvent({ event: "$$heatmap", properties: {} }, "/acme/web", ORIGIN), null); | |
| 70 | + | const heatmap = scrubEvent({ event: "$$heatmap", properties: { $heatmap_data: { "https://g1t.sh/pricing?ref=mail": [1] } } }, "/pricing", ORIGIN); | |
| 71 | + | assert.deepEqual(heatmap?.properties, { $heatmap_data: { "https://g1t.sh/pricing": [1] } }); | |
| 72 | + | }); | |
| 73 | + | ||
| 74 | + | test("pages after signing in keep their address but not their text, which can show an email address", () => { | |
| 75 | + | assert.equal(scrubPath("/confirm-email"), "/confirm-email"); | |
| 76 | + | assert.equal(scrubPath("/login/two-factor"), "/login/two-factor"); | |
| 77 | + | const event = scrubEvent({ event: "$autocapture", properties: { $el_text: "Resend to sam@example.com" } }, "/confirm-email", ORIGIN); | |
| 78 | + | assert.deepEqual(event?.properties, {}); | |
| 79 | + | }); |
| 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", | |
| 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 | + | ||
| 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 | + | } |
| 1 | + | /** | |
| 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). | |
| 7 | + | * | |
| 8 | + | * Imported once, statically, by entry.client.tsx, so it runs before the | |
| 9 | + | * app does. A self-hosted g1t sends nothing. | |
| 10 | + | */ | |
| 11 | + | import { analytics } from "@heycatch/sdk"; | |
| 12 | + | ||
| 13 | + | import { scrubEvent } from "./analytics-scrub"; | |
| 14 | + | ||
| 15 | + | /** The one installation analytics runs on. */ | |
| 16 | + | const HOSTED = "g1t.sh"; | |
| 17 | + | ||
| 18 | + | const enabled = window.location.hostname === HOSTED; | |
| 19 | + | ||
| 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), | |
| 26 | + | }); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** Who is signed in, by account id only; null after signing out. */ | |
| 30 | + | 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(); | |
| 34 | + | } |
| 86 | 86 | ]; | |
| 87 | 87 | ||
| 88 | 88 | /** When any policy last changed, `YYYY-MM-DD`. */ | |
| 89 | − | export const POLICIES_UPDATED = "2026-10-06"; | |
| 89 | + | export const POLICIES_UPDATED = "2026-10-09"; | |
| 90 | 90 | ||
| 91 | 91 | /** Every change to the policies, newest first. */ | |
| 92 | 92 | export const POLICY_HISTORY: { date: string; change: string }[] = [ | |
| 93 | 93 | { | |
| 94 | + | date: "2026-10-09", | |
| 95 | + | change: | |
| 96 | + | "Privacy Policy: g1t.sh uses HeyCatch for site analytics, with names removed outside the public pages, and one analytics cookie. Subprocessors: HeyCatch added.", | |
| 97 | + | }, | |
| 98 | + | { | |
| 94 | 99 | date: "2026-10-06", | |
| 95 | 100 | change: | |
| 96 | 101 | "Privacy Policy: request logs are kept 7 days, and database history 30 days. Subprocessors: GitHub listed among the integrations you choose, in place of Slack, which g1t does not connect to.", |
| 7 | 7 | * - No other site may put g1t's pages in a frame. | |
| 8 | 8 | * - A page runs only the scripts the site served it: its own files, and the | |
| 9 | 9 | * inline scripts React and React Router write, each carrying the page's | |
| 10 | − | * nonce. Styles may be inline (highlighting and layout set them); images | |
| 11 | − | * may come from any HTTPS address (pictures in a README); requests may go | |
| 12 | − | * to any HTTPS address (the status page's summary). | |
| 10 | + | * nonce, plus Cloudflare's and HeyCatch's analytics scripts. Styles may | |
| 11 | + | * be inline (highlighting and layout set them); images may come from any | |
| 12 | + | * HTTPS address (pictures in a README); requests may go to any HTTPS | |
| 13 | + | * address (the status page's summary, analytics events). | |
| 13 | 14 | */ | |
| 14 | 15 | ||
| 15 | 16 | /** A fresh nonce for one page: 128 random bits, base64. */ | |
| ⋯ | |||
| 27 | 28 | const files = usercontent && /^http:/.test(usercontent) ? ` ${new URL(usercontent).origin}` : ""; | |
| 28 | 29 | return [ | |
| 29 | 30 | "default-src 'self'", | |
| 30 | − | // Cloudflare's Web Analytics beacon, when the zone turns it on. | |
| 31 | − | `script-src 'self' 'nonce-${nonce}' https://static.cloudflareinsights.com`, | |
| 31 | + | // Cloudflare's Web Analytics beacon, when the zone turns it on, and | |
| 32 | + | // HeyCatch's helper for product analytics (lib/analytics.client.ts). | |
| 33 | + | `script-src 'self' 'nonce-${nonce}' https://static.cloudflareinsights.com https://in.heycatch.ai`, | |
| 32 | 34 | "style-src 'self' 'unsafe-inline'", | |
| 33 | 35 | `img-src 'self' https: data: blob:${files}`, | |
| 34 | 36 | `media-src 'self' https:${files}`, | |
| 11 | 11 | Search, | |
| 12 | 12 | Settings, | |
| 13 | 13 | } from "lucide-react"; | |
| 14 | − | import { useEffect, useState } from "react"; | |
| 14 | + | import { useEffect, useRef, useState } from "react"; | |
| 15 | 15 | import { | |
| 16 | 16 | Form, | |
| 17 | 17 | isRouteErrorResponse, | |
| ⋯ | |||
| 51 | 51 | import { SiteFooter } from "./components/footer"; | |
| 52 | 52 | import { SpikeBanner } from "./components/spike-banner"; | |
| 53 | 53 | import { PolicyNotice } from "./components/policy-notice"; | |
| 54 | + | import { identify } from "./lib/analytics.client"; | |
| 54 | 55 | import { readCookie } from "./lib/mission"; | |
| 55 | 56 | import { WORKSPACE_COOKIE, workspaceFor } from "./lib/workspace-choice"; | |
| 56 | 57 | import { PageMain } from "./components/landmark"; | |
| ⋯ | |||
| 627 | 628 | } | |
| 628 | 629 | ||
| 629 | 630 | export default function App() { | |
| 631 | + | const userId = useRouteLoaderData<typeof loader>("root")?.user?.id ?? null; | |
| 632 | + | // Tell analytics who is signed in, once per change; signing out forgets them. | |
| 633 | + | const lastUserId = useRef<string | null>(null); | |
| 634 | + | useEffect(() => { | |
| 635 | + | identify(userId, lastUserId.current); | |
| 636 | + | lastUserId.current = userId; | |
| 637 | + | }, [userId]); | |
| 630 | 638 | return <Outlet />; | |
| 631 | 639 | } | |
| 632 | 640 | ||
| 15 | 15 | "dependencies": { | |
| 16 | 16 | "@g1t/contracts": "*", | |
| 17 | 17 | "@g1t/theme": "*", | |
| 18 | + | "@heycatch/sdk": "0.8.3", | |
| 18 | 19 | "class-variance-authority": "^0.7.1", | |
| 19 | 20 | "clsx": "^2.1.1", | |
| 20 | 21 | "cmdk": "^1.1.1", | |
| 21 | − | "isbot": "^5.1.36", | |
| 22 | 22 | "hast-util-to-jsx-runtime": "^2.3.6", | |
| 23 | 23 | "html-url-attributes": "^3.0.1", | |
| 24 | + | "isbot": "^5.1.36", | |
| 24 | 25 | "lucide-react": "^1.49.0", | |
| 25 | 26 | "mdast-util-find-and-replace": "^3.0.2", | |
| 26 | 27 | "radix-ui": "^1.6.7", | |
| ⋯ | |||
| 36 | 37 | "shiki": "^4.5.0", | |
| 37 | 38 | "simple-icons": "^16.34.0", | |
| 38 | 39 | "tailwind-merge": "^3.7.0", | |
| 40 | + | "unified": "^11.0.5", | |
| 39 | 41 | "unist-util-visit": "^5.1.0", | |
| 40 | − | "unified": "^11.0.5", | |
| 41 | 42 | "uqr": "^0.1.3", | |
| 42 | 43 | "vfile": "^6.0.3" | |
| 43 | 44 | }, | |
| 75 | 75 | "dependencies": { | |
| 76 | 76 | "@g1t/contracts": "*", | |
| 77 | 77 | "@g1t/theme": "*", | |
| 78 | + | "@heycatch/sdk": "0.8.3", | |
| 78 | 79 | "class-variance-authority": "^0.7.1", | |
| 79 | 80 | "clsx": "^2.1.1", | |
| 80 | 81 | "cmdk": "^1.1.1", | |
| ⋯ | |||
| 1706 | 1707 | "resolved": "apps/web", | |
| 1707 | 1708 | "link": true | |
| 1708 | 1709 | }, | |
| 1710 | + | "node_modules/@heycatch/sdk": { | |
| 1711 | + | "version": "0.8.3", | |
| 1712 | + | "resolved": "https://registry.npmjs.org/@heycatch/sdk/-/sdk-0.8.3.tgz", | |
| 1713 | + | "integrity": "sha512-7knqXfptgctAIHu5IXwd/WnivY+wwYAws2Xr0ZuARottBnbQteFbxZP7TyEDWCyz6A5+PcGlzlPxeTdq5uyGoA==", | |
| 1714 | + | "license": "MIT", | |
| 1715 | + | "dependencies": { | |
| 1716 | + | "posthog-react-native": "^4.61.1" | |
| 1717 | + | }, | |
| 1718 | + | "engines": { | |
| 1719 | + | "node": ">=22" | |
| 1720 | + | }, | |
| 1721 | + | "peerDependencies": { | |
| 1722 | + | "expo-router": "*", | |
| 1723 | + | "react": "^18.2.0 || ^19.0.0" | |
| 1724 | + | }, | |
| 1725 | + | "peerDependenciesMeta": { | |
| 1726 | + | "expo-router": { | |
| 1727 | + | "optional": true | |
| 1728 | + | }, | |
| 1729 | + | "react": { | |
| 1730 | + | "optional": true | |
| 1731 | + | } | |
| 1732 | + | } | |
| 1733 | + | }, | |
| 1709 | 1734 | "node_modules/@img/colour": { | |
| 1710 | 1735 | "version": "1.1.0", | |
| 1711 | 1736 | "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", | |
| ⋯ | |||
| 2496 | 2521 | "dev": true, | |
| 2497 | 2522 | "license": "MIT" | |
| 2498 | 2523 | }, | |
| 2524 | + | "node_modules/@posthog/core": { | |
| 2525 | + | "version": "1.57.3", | |
| 2526 | + | "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.57.3.tgz", | |
| 2527 | + | "integrity": "sha512-lag+QZE61kcYSjYN9lKwri6P4/DACOtyRsA41MSI6tdKHbSIyrPfQPkDQiPbq413AMbTXFjcN/N6bqxPhei93Q==", | |
| 2528 | + | "license": "MIT", | |
| 2529 | + | "dependencies": { | |
| 2530 | + | "@posthog/types": "^1.415.2" | |
| 2531 | + | } | |
| 2532 | + | }, | |
| 2533 | + | "node_modules/@posthog/types": { | |
| 2534 | + | "version": "1.415.2", | |
| 2535 | + | "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.415.2.tgz", | |
| 2536 | + | "integrity": "sha512-BIIHPyXcPZuNTImrR1qLqLKFw7j9o8co6LG/9OpLtpWJ3RT0y89Lx6VCtd37N9/dkr/qW7y0uPjJxAxjsALHtw==", | |
| 2537 | + | "license": "MIT" | |
| 2538 | + | }, | |
| 2499 | 2539 | "node_modules/@puppeteer/browsers": { | |
| 2500 | 2540 | "version": "2.2.4", | |
| 2501 | 2541 | "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.2.4.tgz", | |
| ⋯ | |||
| 8982 | 9022 | "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", | |
| 8983 | 9023 | "license": "MIT" | |
| 8984 | 9024 | }, | |
| 9025 | + | "node_modules/posthog-react-native": { | |
| 9026 | + | "version": "4.80.0", | |
| 9027 | + | "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.80.0.tgz", | |
| 9028 | + | "integrity": "sha512-aSCTguolb/6R01czT8MVW/lHSSdGoHHVomUE2MjQUub/+pIdw64ScwzNLUSZ9x5TU2sLAD7C1RqpGwnB6RidRw==", | |
| 9029 | + | "license": "MIT", | |
| 9030 | + | "dependencies": { | |
| 9031 | + | "@posthog/core": "^1.57.3", | |
| 9032 | + | "@posthog/types": "^1.415.2" | |
| 9033 | + | }, | |
| 9034 | + | "bin": { | |
| 9035 | + | "posthog-react-native-xcode": "tooling/posthog-xcode.sh" | |
| 9036 | + | }, | |
| 9037 | + | "peerDependencies": { | |
| 9038 | + | "@posthog/react-native-plugin": ">= 2.9.3", | |
| 9039 | + | "@react-native-async-storage/async-storage": ">=1.0.0", | |
| 9040 | + | "@react-navigation/native": ">= 5.0.0", | |
| 9041 | + | "expo-application": ">= 4.0.0", | |
| 9042 | + | "expo-device": ">= 4.0.0", | |
| 9043 | + | "expo-file-system": ">= 13.0.0", | |
| 9044 | + | "expo-localization": ">= 11.0.0", | |
| 9045 | + | "expo-updates": ">= 0.25.0", | |
| 9046 | + | "posthog-react-native-session-replay": ">= 1.6.0", | |
| 9047 | + | "react-native-device-info": ">= 10.0.0", | |
| 9048 | + | "react-native-localize": ">= 3.0.0", | |
| 9049 | + | "react-native-navigation": ">= 6.0.0", | |
| 9050 | + | "react-native-safe-area-context": ">= 4.0.0", | |
| 9051 | + | "react-native-svg": ">= 15.0.0" | |
| 9052 | + | }, | |
| 9053 | + | "peerDependenciesMeta": { | |
| 9054 | + | "@posthog/react-native-plugin": { | |
| 9055 | + | "optional": true | |
| 9056 | + | }, | |
| 9057 | + | "@react-native-async-storage/async-storage": { | |
| 9058 | + | "optional": true | |
| 9059 | + | }, | |
| 9060 | + | "@react-navigation/native": { | |
| 9061 | + | "optional": true | |
| 9062 | + | }, | |
| 9063 | + | "expo-application": { | |
| 9064 | + | "optional": true | |
| 9065 | + | }, | |
| 9066 | + | "expo-device": { | |
| 9067 | + | "optional": true | |
| 9068 | + | }, | |
| 9069 | + | "expo-file-system": { | |
| 9070 | + | "optional": true | |
| 9071 | + | }, | |
| 9072 | + | "expo-localization": { | |
| 9073 | + | "optional": true | |
| 9074 | + | }, | |
| 9075 | + | "expo-updates": { | |
| 9076 | + | "optional": true | |
| 9077 | + | }, | |
| 9078 | + | "posthog-react-native-session-replay": { | |
| 9079 | + | "optional": true | |
| 9080 | + | }, | |
| 9081 | + | "react-native-device-info": { | |
| 9082 | + | "optional": true | |
| 9083 | + | }, | |
| 9084 | + | "react-native-localize": { | |
| 9085 | + | "optional": true | |
| 9086 | + | }, | |
| 9087 | + | "react-native-navigation": { | |
| 9088 | + | "optional": true | |
| 9089 | + | }, | |
| 9090 | + | "react-native-safe-area-context": { | |
| 9091 | + | "optional": true | |
| 9092 | + | }, | |
| 9093 | + | "react-native-svg": { | |
| 9094 | + | "optional": true | |
| 9095 | + | } | |
| 9096 | + | } | |
| 9097 | + | }, | |
| 8985 | 9098 | "node_modules/prismjs": { | |
| 8986 | 9099 | "version": "1.30.0", | |
| 8987 | 9100 | "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", | |