diff --git a/CLAUDE.md b/CLAUDE.md index 5cd2e44..a0436d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -454,6 +454,7 @@ tests/ language-lookups.test.ts # saved-phrase lookup, bundle suggestion, last-used bundles per language language-analytics.test.ts # learning_language and target_language super properties tab-broadcast.test.ts # tab broadcast skips tabs without our content scripts quietly + install-tracking.test.ts # onInstalled: extension_installed / extension_updated + welcome page (E-02) e2e/ extension-fixture.ts # chromium.launchPersistentContext + extension load server.mjs # static fixtures HTTP server diff --git a/src/background.ts b/src/background.ts index 20a3a2f..ba80a19 100644 --- a/src/background.ts +++ b/src/background.ts @@ -9,6 +9,7 @@ import { MESSAGE_TYPE, } from "./common/types/messaging"; import { ensureStoredTargetLanguage } from "./common/helper/default-language"; +import { handleInstalled } from "./common/helper/install-tracking"; export {}; @@ -20,6 +21,14 @@ chrome.runtime.onInstalled.addListener(() => { ); }); +// Track installs and updates on the install's analytics id, and open the +// welcome page on a first install (E-02, see install-tracking.ts). +chrome.runtime.onInstalled.addListener((details) => { + handleInstalled(details).catch((e) => + log("background: install tracking failed", e) + ); +}); + function getOAuthToken() { return new Promise((resolve, reject) => { chrome.identity.getAuthToken({ interactive: true }, function (token) { diff --git a/src/common/helper/install-id.ts b/src/common/helper/install-id.ts new file mode 100644 index 0000000..cc14d3c --- /dev/null +++ b/src/common/helper/install-id.ts @@ -0,0 +1,57 @@ +/** + * The install's analytics id (A3 in the analytics work list): one random id + * per install, kept in chrome.storage.local under `analyticsAnonymousId`. + * + * Mixpanel uses it as the anonymous `distinct_id` / `$device_id` + * (plugins/mixpanel.ts), and server calls that fire an analytics event send it + * as `deviceId` (E-01) so server events land on the same person. + * + * This module has no mixpanel import, so it is safe in every context: content + * scripts, the popup and the background service worker. + */ + +export const ANON_ID_KEY = "analyticsAnonymousId"; + +/** crypto.randomUUID is missing on plain-http pages; getRandomValues is not. */ +export function newAnonymousId(): string { + const b = crypto.getRandomValues(new Uint8Array(16)); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; +} + +// One read-or-create at a time per context, so two callers on the first page +// ever do not mint two different ids. +let pending: Promise | null = null; + +/** + * Read the stored install id, creating and storing it when there is none yet. + * Not cached after it settles: logout rotates the id (resetAnalyticsIdentity), + * and the next call must see the new one. + * + * Resolves `undefined` only when there is no extension context (e.g. the + * content script was orphaned by an update); callers then send no id. + */ +export function getInstallId(): Promise { + if (pending) return pending; + pending = new Promise((resolve) => { + try { + chrome.storage.local.get(ANON_ID_KEY, (data) => { + let id = data?.[ANON_ID_KEY] as string | undefined; + if (id) return resolve(id); + id = newAnonymousId(); + try { + chrome.storage.local.set({ [ANON_ID_KEY]: id }, () => resolve(id)); + } catch { + resolve(id); + } + }); + } catch { + resolve(undefined); + } + }).finally(() => { + pending = null; + }); + return pending; +} diff --git a/src/common/helper/install-tracking.ts b/src/common/helper/install-tracking.ts new file mode 100644 index 0000000..db15d88 --- /dev/null +++ b/src/common/helper/install-tracking.ts @@ -0,0 +1,125 @@ +/** + * Install and update tracking for the background service worker (E-02). + * + * mixpanel-browser is not loaded here: it expects `window` and `document`, + * which a MV3 service worker does not have. So the two events go straight to + * Mixpanel's /track endpoint with fetch, in the same shape mixpanel-browser + * sends (base64 JSON in a `data` form field), through the same + * MIXPANEL_API_HOST proxy, on the install's analytics id. The content scripts + * use that same id as their anonymous `distinct_id` (plugins/mixpanel.ts), so + * these events join the rest of the install's events. + */ +import { getInstallId } from "./install-id"; +import { log } from "./log"; + +/** The welcome page opened on first install (landing D-01 reads install_id). */ +export const WELCOME_URL = "https://subturtle.app/"; + +export function welcomeUrl(installId: string): string { + const params = new URLSearchParams({ + install_id: installId, + utm_source: "extension", + utm_medium: "install", + }); + return `${WELCOME_URL}?${params.toString()}`; +} + +const DEFAULT_API_HOST = "https://api-js.mixpanel.com"; + +function insertId(): string { + const b = crypto.getRandomValues(new Uint8Array(12)); + return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); +} + +/** UTF-8 safe base64, the encoding mixpanel-browser 2.45 uses for `data`. */ +function base64Json(value: unknown): string { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + let bin = ""; + bytes.forEach((b) => (bin += String.fromCharCode(b))); + return btoa(bin); +} + +/** + * Send one event to Mixpanel from a context without mixpanel-browser. + * Never throws: analytics must not break install handling. + */ +export async function trackFromBackground( + event: string, + installId: string, + properties: Record = {} +): Promise { + const token = process.env.MIXPANEL_PROJECT_TOKEN; + if (!token) return; + const host = (process.env.MIXPANEL_API_HOST || DEFAULT_API_HOST).replace( + /\/+$/, + "" + ); + const manifest = chrome.runtime.getManifest(); + const payload = { + event, + properties: { + token, + // Same identity the content scripts register for an anonymous install. + distinct_id: installId, + $device_id: installId, + time: Date.now() / 1000, + $insert_id: insertId(), + app: "chrome-extension", + version: manifest.version_name || manifest.version, + ...properties, + }, + }; + try { + await fetch(`${host}/track/?ip=1`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `data=${encodeURIComponent(base64Json(payload))}`, + }); + } catch (e) { + log("background: analytics request failed", e); + } +} + +/** + * Web Store installs carry `update_url` in the manifest; an unpacked build + * (local dev, the CI e2e run, a dev zip loaded by hand) does not. + */ +function isStoreInstall(): boolean { + return "update_url" in chrome.runtime.getManifest(); +} + +/** + * chrome.runtime.onInstalled handler: `extension_installed` on a new install + * (and open the welcome page), `extension_updated` with `previous_version` on + * an update. Other reasons (a Chrome update) are ignored. + */ +export async function handleInstalled( + details: chrome.runtime.InstalledDetails +): Promise { + const { reason, previousVersion } = details; + if (reason !== "install" && reason !== "update") return; + + const installId = await getInstallId(); + if (!installId) return; + + if (reason === "install") { + // Open the page first: it must not wait on the analytics request. + // Unpacked builds skip it, so dev reloads and CI runs do not open + // subturtle.app or count fake installs on the landing page. + if (isStoreInstall()) { + try { + await chrome.tabs.create({ url: welcomeUrl(installId) }); + } catch (e) { + log("background: failed to open the welcome page", e); + } + } + await trackFromBackground("extension_installed", installId, { + reason: "install", + }); + } else { + await trackFromBackground("extension_updated", installId, { + reason: "update", + previous_version: previousVersion || null, + }); + } +} diff --git a/tests/install-tracking.test.ts b/tests/install-tracking.test.ts new file mode 100644 index 0000000..f49445d --- /dev/null +++ b/tests/install-tracking.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +import { + handleInstalled, + welcomeUrl, +} from "../src/common/helper/install-tracking"; +import { ANON_ID_KEY } from "../src/common/helper/install-id"; + +// E-02: chrome.runtime.onInstalled tracks extension_installed / +// extension_updated on the install id, and opens the welcome page on install. + +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function memoryChromeStorage() { + const mem: Record = {}; + vi.mocked(chrome.storage.local.get).mockImplementation((( + key: string, + cb: (d: any) => void + ) => cb(key in mem ? { [key]: mem[key] } : {})) as any); + vi.mocked(chrome.storage.local.set).mockImplementation((( + obj: Record, + cb?: () => void + ) => { + Object.assign(mem, obj); + cb?.(); + }) as any); + return mem; +} + +/** Decode the events posted to the mixpanel /track endpoint. */ +function sentEvents(fetchMock: ReturnType) { + return fetchMock.mock.calls.map(([url, init]) => { + const data = new URLSearchParams(init.body).get("data")!; + const bin = atob(data); + const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0)); + return { url, ...JSON.parse(new TextDecoder().decode(bytes)) }; + }); +} + +describe("handleInstalled (background)", () => { + let mem: Record; + let fetchMock: ReturnType; + let manifest: Record; + const tabsCreate = vi.fn(async (_: any) => ({})); + + beforeEach(() => { + mem = memoryChromeStorage(); + manifest = { version: "1.18.0", update_url: "https://clients2.google.com/" }; + (chrome.runtime as any).getManifest = () => manifest; + (chrome.tabs as any).create = tabsCreate; + tabsCreate.mockClear(); + fetchMock = vi.fn(async () => new Response("1")); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("MIXPANEL_PROJECT_TOKEN", "test-token"); + vi.stubEnv("MIXPANEL_API_HOST", "https://proxy.test/"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("on install: creates the install id, opens the welcome page, tracks extension_installed", async () => { + await handleInstalled({ reason: "install" } as any); + + const id = mem[ANON_ID_KEY]; + expect(id).toMatch(UUID_V4); + + expect(tabsCreate).toHaveBeenCalledWith({ + url: `https://subturtle.app/?install_id=${id}&utm_source=extension&utm_medium=install`, + }); + + const [e] = sentEvents(fetchMock); + expect(e.url).toBe("https://proxy.test/track/?ip=1"); + expect(e.event).toBe("extension_installed"); + expect(e.properties).toMatchObject({ + token: "test-token", + distinct_id: id, + $device_id: id, + app: "chrome-extension", + version: "1.18.0", + reason: "install", + }); + }); + + it("reuses an install id that already exists", async () => { + mem[ANON_ID_KEY] = "existing-id"; + await handleInstalled({ reason: "install" } as any); + expect(mem[ANON_ID_KEY]).toBe("existing-id"); + expect(sentEvents(fetchMock)[0].properties.distinct_id).toBe("existing-id"); + }); + + it("on update: tracks extension_updated with previous_version and opens nothing", async () => { + mem[ANON_ID_KEY] = "existing-id"; + await handleInstalled({ reason: "update", previousVersion: "1.17.0" } as any); + + expect(tabsCreate).not.toHaveBeenCalled(); + const [e] = sentEvents(fetchMock); + expect(e.event).toBe("extension_updated"); + expect(e.properties).toMatchObject({ + distinct_id: "existing-id", + previous_version: "1.17.0", + version: "1.18.0", + }); + }); + + it("ignores a Chrome update", async () => { + await handleInstalled({ reason: "chrome_update" } as any); + expect(fetchMock).not.toHaveBeenCalled(); + expect(tabsCreate).not.toHaveBeenCalled(); + }); + + it("an unpacked build still tracks the install but opens no tab", async () => { + delete manifest.update_url; + await handleInstalled({ reason: "install" } as any); + expect(tabsCreate).not.toHaveBeenCalled(); + expect(sentEvents(fetchMock)[0].event).toBe("extension_installed"); + }); + + it("a failed analytics request does not throw", async () => { + fetchMock.mockRejectedValue(new Error("offline")); + await expect(handleInstalled({ reason: "install" } as any)).resolves.toBeUndefined(); + expect(tabsCreate).toHaveBeenCalled(); + }); + + it("builds the welcome URL with encoded params", () => { + expect(welcomeUrl("a b")).toBe( + "https://subturtle.app/?install_id=a+b&utm_source=extension&utm_medium=install" + ); + }); +});