diff --git a/CLAUDE.md b/CLAUDE.md index 5cd2e44..99260d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -439,13 +439,17 @@ 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) + translate-analytics-args.test.ts # install id (deviceId) and trigger sent with translation calls (E-01, E-05) 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) login-password-disabled.test.ts # popup password form hidden when flag is unset + login-analytics.test.ts # user_logged-in source, login_failed source + reason (E-03) + save-login-prompt.test.ts # "Login to save" prompt viewed once per word view, clicked (E-03) language-state.test.ts # round-1 rules: dark gate, own language, save row, plan limit, deep link language-catalog.test.ts # one RTL list, picker names, bundled Vazirmatn own-language.test.ts # translate request with / without a server profile; language store @@ -454,6 +458,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..603b44f 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) { @@ -106,8 +115,13 @@ chrome.runtime.onMessage.addListener(function (request, _sender, sendResponse) { const left = screen.width / 2 - width / 2; const top = screen.height / 2 - height / 2; + // The login source rides on the hash route's query (E-03). + const query = request.source + ? `?source=${encodeURIComponent(request.source)}` + : ""; + chrome.windows.create({ - url: chrome.runtime.getURL("popup.html") + "#/login", + url: chrome.runtime.getURL("popup.html") + "#/login" + query, type: "popup", width: width, height: height, 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/src/common/services/translate.service.ts b/src/common/services/translate.service.ts index 33e4163..2860ab3 100644 --- a/src/common/services/translate.service.ts +++ b/src/common/services/translate.service.ts @@ -12,6 +12,18 @@ import { resolveTranslationLanguage } from "../language/state"; import { languageName } from "../language/catalog"; import type { LanguageState } from "../language/types"; import { toSupportedLanguageCode } from "../helper/default-language"; +import { getInstallId } from "../helper/install-id"; + +/** + * What asked for a translation (E-05). Sent as `trigger` so the server's + * `translation_requested` event can split requests by source (B-03). + */ +export type TranslationTrigger = + | "caption" + | "word" + | "selection" + | "definition" + | "detail"; // Cache interface for translation results interface TranslationCache { @@ -36,6 +48,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 +149,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(); @@ -145,7 +181,26 @@ export class TranslateService { }); } - async fetchSimpleTranslation(text: string | string[], context: string = "") { + /** + * Analytics args for calls that fire a server event (E-01, E-05). `userId` + * stays for old servers; `deviceId` is the install's analytics id, read + * from storage on each call so it is never empty while the id loads and + * follows the new id after logout. + */ + private async analyticsArgs(trigger?: TranslationTrigger) { + const deviceId = await getInstallId(); + return { + userId: authentication.user?.id, + ...(deviceId ? { deviceId } : {}), + ...(trigger ? { trigger } : {}), + }; + } + + async fetchSimpleTranslation( + text: string | string[], + context: string = "", + trigger?: TranslationTrigger + ) { // Clean expired cache entries this.cleanExpiredCache(); @@ -160,8 +215,14 @@ 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 analytics = await this.analyticsArgs(trigger); const result = await withAuthRetry(() => functionProvider.run({ name: "translateWithContext", @@ -173,23 +234,29 @@ export class TranslateService { phrase: text, context: context || "", // For the server-side translation_requested analytics event. - userId: authentication.user?.id, + ...analytics, }, }) ); // 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; } } - async fetchDetailedTranslation(text: string, context: string = "") { + async fetchDetailedTranslation( + text: string, + context: string = "", + trigger: TranslationTrigger = "detail" + ) { // Clean expired cache entries this.cleanExpiredCache(); @@ -204,8 +271,14 @@ 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 analytics = await this.analyticsArgs(trigger); const data = await withAuthRetry(() => functionProvider.run({ name: "translateWithContext", @@ -217,7 +290,7 @@ export class TranslateService { phrase: text, context: context || "", // For the server-side translation_requested analytics event. - userId: authentication.user?.id, + ...analytics, }, }) ); @@ -229,11 +302,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; } } @@ -250,6 +325,7 @@ export class TranslateService { history?: { role: "user" | "assistant"; text: string }[]; }): Promise { const targetLanguage = await this.requireLanguageTitle(); + const { deviceId } = await this.analyticsArgs(); return withAuthRetry(() => functionProvider.run({ name: "translationAdvice", @@ -262,6 +338,7 @@ export class TranslateService { sourceLanguage: "auto", targetLanguage, ...this.translationLanguageArg(), + ...(deviceId ? { deviceId } : {}), }, }) ); diff --git a/src/common/types/messaging.ts b/src/common/types/messaging.ts index c80cd62..8ae1fa1 100644 --- a/src/common/types/messaging.ts +++ b/src/common/types/messaging.ts @@ -74,11 +74,21 @@ export class StoreUserTokenMessage implements BaseMessage { } } +/** + * Where a login started (E-03). The background puts it on the login window's + * URL (`popup.html#/login?source=…`) so LoginView can send it with + * `user_logged-in` and `login_failed`. A login opened from the toolbar popup + * itself has no source on the URL and counts as "popup". + */ +export type LoginSource = "save_prompt" | "practice" | "popup"; + export class OpenLoginWindowMessage implements BaseMessage { type: string; + source?: LoginSource; - constructor() { + constructor(source?: LoginSource) { this.type = MESSAGE_TYPE.OPEN_LOGIN_WINDOW; + if (source) this.source = source; } static is(message: any): message is OpenLoginWindowMessage { diff --git a/src/console-crane/components/SaveWordSectionV2.vue b/src/console-crane/components/SaveWordSectionV2.vue index 2f0b85a..e85b2ce 100644 --- a/src/console-crane/components/SaveWordSectionV2.vue +++ b/src/console-crane/components/SaveWordSectionV2.vue @@ -115,6 +115,7 @@ import { getSubturtleDashboardUrlWithToken, } from "../../common/static/global"; import { TranslateService } from "../../common/services/translate.service"; +import { getInstallId } from "../../common/helper/install-id"; import { PhraseType, PhraseBundleType } from "../../common/types/phrase.type"; import { useDefaultBundleStore } from "../../stores/default-bundle"; import { useProfileStore } from "../../stores/profile"; @@ -546,11 +547,14 @@ async function savePhrase(kind?: "saved" | "savedInstead") { return; } + // The install's analytics id, for the server's first-save event (E-01). + const deviceId = await getInstallId(); const result = await functionProvider .run({ name: "createPhrase", args: { ...phraseArgs(), + ...(deviceId ? { deviceId } : {}), bundleIds: toAdd, // Round 1 (E-07): the language it is saved to. Only with language // profiles; an old server resolves it itself (B-25). diff --git a/src/console-crane/modules/practice-config/index.vue b/src/console-crane/modules/practice-config/index.vue index 395e9b9..18b48ce 100644 --- a/src/console-crane/modules/practice-config/index.vue +++ b/src/console-crane/modules/practice-config/index.vue @@ -324,7 +324,7 @@ function upgrade() { /** Logged out — open the login window; the isLogin watch advances the page. */ function handleLoginRequest() { analytic.track("practice-now_login-clicked"); - sendMessage(new OpenLoginWindowMessage()); + sendMessage(new OpenLoginWindowMessage("practice")); } function start() { diff --git a/src/console-crane/modules/word-detail/index.vue b/src/console-crane/modules/word-detail/index.vue index e4e45a8..978d042 100644 --- a/src/console-crane/modules/word-detail/index.vue +++ b/src/console-crane/modules/word-detail/index.vue @@ -249,7 +249,7 @@