Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};

Expand All @@ -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) {
Expand Down
57 changes: 57 additions & 0 deletions src/common/helper/install-id.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> | 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<string | undefined> {
if (pending) return pending;
pending = new Promise<string | undefined>((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;
}
125 changes: 125 additions & 0 deletions src/common/helper/install-tracking.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}
): Promise<void> {
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<void> {
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,
});
}
}
132 changes: 132 additions & 0 deletions tests/install-tracking.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {};
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<string, any>,
cb?: () => void
) => {
Object.assign(mem, obj);
cb?.();
}) as any);
return mem;
}

/** Decode the events posted to the mixpanel /track endpoint. */
function sentEvents(fetchMock: ReturnType<typeof vi.fn>) {
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<string, any>;
let fetchMock: ReturnType<typeof vi.fn>;
let manifest: Record<string, any>;
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"
);
});
});
Loading