From b7ddfd6d79fddc89a9d116244c7c4dca5634633a Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Fri, 16 Oct 2026 18:55:49 +0300 Subject: [PATCH 1/5] feat(analytics): E-02 track installs and updates and open the welcome page - chrome.runtime.onInstalled now tracks extension_installed (reason install) or extension_updated (with previous_version) on the install's analytics id. The id is created when missing, with the same key and format as plugins/mixpanel.ts (new helper common/helper/install-id.ts). - The service worker has no window or document, so mixpanel-browser is not loaded there. The events go to Mixpanel's /track endpoint with fetch, through MIXPANEL_API_HOST, in the same shape mixpanel-browser sends. A failed request never throws. - On install it opens https://subturtle.app/?install_id=&utm_source=extension&utm_medium=install for the landing page (D-01). Unpacked builds (local dev, CI e2e) skip the tab, so they do not open the site or count fake installs. - Tests: tests/install-tracking.test.ts. Work list: subturtle-doc docs/tech/analytics-fixes-implementation.md Pairs with landing D-01 (codebridger/subturtle-dashboard-app#144). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + src/background.ts | 9 ++ src/common/helper/install-id.ts | 57 +++++++++++ src/common/helper/install-tracking.ts | 125 ++++++++++++++++++++++++ tests/install-tracking.test.ts | 132 ++++++++++++++++++++++++++ 5 files changed, 324 insertions(+) create mode 100644 src/common/helper/install-id.ts create mode 100644 src/common/helper/install-tracking.ts create mode 100644 tests/install-tracking.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5cd2e44..4bfef37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -439,6 +439,7 @@ tests/ console-crane-bridge.test.ts # window CustomEvent emit/listen contracts console-crane-store.test.ts # toggleConsoleCrane / goBack / canGoBack / isOnMainPage translate.service.test.ts # cache hit/miss + 24h TTL eviction (vi.useFakeTimers) + install-tracking.test.ts # onInstalled: extension_installed / extension_updated + welcome page (E-02) settings-host.test.ts # nibbleDisabledDomains normalize / toggle language-detection.test.ts # RTL detection, title lookup, supported codes selection-popup.test.ts # @mousedown.prevent.stop regression 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" + ); + }); +}); From 7c0a3b27403286fe22480d973f0fb9616cd7ae7a Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Fri, 16 Oct 2026 19:05:10 +0300 Subject: [PATCH 2/5] perf(translation): E-04 E-06 share in-flight translations and translate each caption once - E-04: TranslateService keeps a map of requests in flight by cache key. An identical call made before the first one answers waits for it and gets the same answer, so only one request goes out. The entry is removed when the request settles; a failure reaches every waiting caller and is never cached. - E-06: new src/subtitle/helpers/caption-translation-gate.ts. With "translate whole caption" on, the Netflix and support-template caption components wait about 150 ms for the caption to settle, then send it only when the joined text differs from the caption already translated on screen. A failed caption can be sent again. Turning the option on still translates at once. - Tests: in-flight cases in tests/translate.service.test.ts, new tests/caption-translation-gate.test.ts. Work list: subturtle-doc docs/tech/analytics-fixes-implementation.md Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + src/common/services/translate.service.ts | 38 +++++++++ .../_support-template/components/Subtitle.vue | 39 +++++++-- .../helpers/caption-translation-gate.ts | 71 ++++++++++++++++ .../web_netflix/components/Subtitle.vue | 37 +++++++-- tests/caption-translation-gate.test.ts | 83 +++++++++++++++++++ tests/translate.service.test.ts | 69 +++++++++++++++ 7 files changed, 322 insertions(+), 16 deletions(-) create mode 100644 src/subtitle/helpers/caption-translation-gate.ts create mode 100644 tests/caption-translation-gate.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4bfef37..92f7a49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -443,6 +443,7 @@ tests/ settings-host.test.ts # nibbleDisabledDomains normalize / toggle language-detection.test.ts # RTL detection, title lookup, supported codes selection-popup.test.ts # @mousedown.prevent.stop regression + caption-translation-gate.test.ts # whole-caption translation: settle ~150 ms, skip the caption already sent (E-06); in-flight sharing is in translate.service.test.ts (E-04) nibble-surface.test.ts # bridge-driven hide/show translate-card.test.ts # popup translate input flow login-password.test.ts # popup password form (ENABLE_PASSWORD_AUTH=true) diff --git a/src/common/services/translate.service.ts b/src/common/services/translate.service.ts index 33e4163..93918f6 100644 --- a/src/common/services/translate.service.ts +++ b/src/common/services/translate.service.ts @@ -36,6 +36,8 @@ export class TranslateService { // Cache for translation results private translationCache: TranslationCache = {}; private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + // Requests on their way, by cache key (E-04). + private inFlight = new Map>(); /** * The learner's own language code, the one translations are written in. @@ -135,6 +137,28 @@ export class TranslateService { }; } + /** + * E-04: register a request as in flight, so an identical call made before + * it answers waits for it instead of sending a second request. The entry is + * removed when the request settles, so a failure is never reused or cached. + */ + private startInFlight(cacheKey: string) { + let resolve!: (value: any) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // Nobody may be waiting on it: a failure here must not be "unhandled". + promise.catch(() => {}); + this.inFlight.set(cacheKey, promise); + const settle = () => this.inFlight.delete(cacheKey); + return { + resolve: (value: any) => (settle(), resolve(value)), + reject: (reason: unknown) => (settle(), reject(reason)), + }; + } + // Clean expired cache entries private cleanExpiredCache(): void { const now = Date.now(); @@ -160,6 +184,11 @@ export class TranslateService { return cachedResult; } + // The same request is already on its way: share it (E-04). + const pending = this.inFlight.get(cacheKey); + if (pending) return pending; + const inFlight = this.startInFlight(cacheKey); + // If not cached, fetch from API try { const result = await withAuthRetry(() => @@ -180,11 +209,13 @@ export class TranslateService { // Cache the result this.cacheResult(cacheKey, result); + inFlight.resolve(result); return result; } catch (error) { // Log error but don't cache failed requests console.error("Translation error:", error); + inFlight.reject(error); throw error; } } @@ -204,6 +235,11 @@ export class TranslateService { return cachedResult; } + // The same request is already on its way: share it (E-04). + const pending = this.inFlight.get(cacheKey); + if (pending) return pending; + const inFlight = this.startInFlight(cacheKey); + // If not cached, fetch from API try { const data = await withAuthRetry(() => @@ -229,11 +265,13 @@ export class TranslateService { // Cache the result this.cacheResult(cacheKey, data); + inFlight.resolve(data); return data; } catch (error) { // Log error but don't cache failed requests console.error("Detailed translation error:", error); + inFlight.reject(error); throw error; } } diff --git a/src/subtitle/_support-template/components/Subtitle.vue b/src/subtitle/_support-template/components/Subtitle.vue index 2639cdc..c6946fb 100644 --- a/src/subtitle/_support-template/components/Subtitle.vue +++ b/src/subtitle/_support-template/components/Subtitle.vue @@ -69,10 +69,14 @@ diff --git a/src/popup/helper/login-analytics.ts b/src/popup/helper/login-analytics.ts new file mode 100644 index 0000000..374c660 --- /dev/null +++ b/src/popup/helper/login-analytics.ts @@ -0,0 +1,36 @@ +/** + * Login funnel analytics helpers (E-03), used by LoginView. + * + * `user_logged-in` and `login_failed` carry `source` (where the login started) + * and, for a failure, `reason`. The funnel is + * save-login-prompt_viewed → save-login-prompt_clicked → user_logged-in + * (source save_prompt) → phrase_saved. + */ +import type { LoginSource } from "../../common/types/messaging"; + +export type LoginFailReason = "window_closed" | "google_error" | "server_error"; + +const LOGIN_SOURCES: readonly LoginSource[] = ["save_prompt", "practice", "popup"]; + +/** + * The `source` query on the login window's route. Anything missing or unknown + * is "popup": the login view opened straight from the toolbar popup. + */ +export function loginSourceFrom(value: unknown): LoginSource { + return LOGIN_SOURCES.includes(value as LoginSource) + ? (value as LoginSource) + : "popup"; +} + +/** + * Why the Google step failed. chrome.identity reports a closed or cancelled + * consent window as `chrome.runtime.lastError` "The user did not approve + * access."; everything else (no token in the redirect, a page that could not + * load) is a Google-side error. + */ +export function googleFailReason(err: unknown): LoginFailReason { + const message = String((err as any)?.message ?? err ?? ""); + return /did not approve|cancel|closed/i.test(message) + ? "window_closed" + : "google_error"; +} diff --git a/src/popup/views/IntroView.vue b/src/popup/views/IntroView.vue index bbf0ee8..8226c2e 100644 --- a/src/popup/views/IntroView.vue +++ b/src/popup/views/IntroView.vue @@ -64,7 +64,7 @@ import { useRouter } from "vue-router"; const router = useRouter(); function openLogin() { - chrome.runtime.sendMessage(new OpenLoginWindowMessage()); + chrome.runtime.sendMessage(new OpenLoginWindowMessage("popup")); } const slides = [ diff --git a/src/popup/views/LoginView.vue b/src/popup/views/LoginView.vue index 7918087..6d1972e 100644 --- a/src/popup/views/LoginView.vue +++ b/src/popup/views/LoginView.vue @@ -131,6 +131,7 @@