diff --git a/CLAUDE.md b/CLAUDE.md index 5cd2e44..035b6ec 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) + 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 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/services/translate.service.ts b/src/common/services/translate.service.ts index 33e4163..0e866a0 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 { @@ -145,7 +157,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(); @@ -162,6 +193,7 @@ export class TranslateService { // If not cached, fetch from API try { + const analytics = await this.analyticsArgs(trigger); const result = await withAuthRetry(() => functionProvider.run({ name: "translateWithContext", @@ -173,7 +205,7 @@ export class TranslateService { phrase: text, context: context || "", // For the server-side translation_requested analytics event. - userId: authentication.user?.id, + ...analytics, }, }) ); @@ -189,7 +221,11 @@ export class TranslateService { } } - async fetchDetailedTranslation(text: string, context: string = "") { + async fetchDetailedTranslation( + text: string, + context: string = "", + trigger: TranslationTrigger = "detail" + ) { // Clean expired cache entries this.cleanExpiredCache(); @@ -206,6 +242,7 @@ export class TranslateService { // If not cached, fetch from API try { + const analytics = await this.analyticsArgs(trigger); const data = await withAuthRetry(() => functionProvider.run({ name: "translateWithContext", @@ -217,7 +254,7 @@ export class TranslateService { phrase: text, context: context || "", // For the server-side translation_requested analytics event. - userId: authentication.user?.id, + ...analytics, }, }) ); @@ -250,6 +287,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 +300,7 @@ export class TranslateService { sourceLanguage: "auto", targetLanguage, ...this.translationLanguageArg(), + ...(deviceId ? { deviceId } : {}), }, }) ); 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/word-detail/index.vue b/src/console-crane/modules/word-detail/index.vue index e4e45a8..0c24609 100644 --- a/src/console-crane/modules/word-detail/index.vue +++ b/src/console-crane/modules/word-detail/index.vue @@ -733,7 +733,8 @@ async function fetchWordDetail() { wordData.value = await TranslateService.instance.fetchDetailedTranslation( cleaned, - context + context, + "detail" ); } catch (err: any) { console.error("Failed to fetch translation:", err); diff --git a/src/nibble/components/SelectionPopup.vue b/src/nibble/components/SelectionPopup.vue index a6919f5..e741561 100644 --- a/src/nibble/components/SelectionPopup.vue +++ b/src/nibble/components/SelectionPopup.vue @@ -228,7 +228,8 @@ async function onIconClick(e: MouseEvent) { try { const result = await TranslateService.instance.fetchSimpleTranslation( props.text, - props.context + props.context, + "selection" ); translation.value = String(result ?? "").trim(); mode.value = translation.value ? "translated" : "error"; diff --git a/src/plugins/mixpanel.ts b/src/plugins/mixpanel.ts index d8bd3f2..3a12208 100644 --- a/src/plugins/mixpanel.ts +++ b/src/plugins/mixpanel.ts @@ -1,4 +1,5 @@ import mixpanel, { Config } from "mixpanel-browser"; +import { ANON_ID_KEY, newAnonymousId } from "../common/helper/install-id"; /** * NO STORAGE ON THE HOST PAGE. This module runs inside content scripts, where @@ -29,16 +30,7 @@ mixpanel.register({ }); export const analytic = mixpanel; -export const ANON_ID_KEY = "analyticsAnonymousId"; - -/** crypto.randomUUID is missing on plain-http pages; getRandomValues is not. */ -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)}`; -} +export { ANON_ID_KEY }; function useAnonymousId(id: string) { // identify() may already have run (a logged-in user whose profile loaded @@ -48,10 +40,11 @@ function useAnonymousId(id: string) { } /** - * Load (or create) the per-install anonymous id. Events tracked in the few - * milliseconds before the storage read answers carry mixpanel's random - * per-page id. Two scripts creating the id at once on the first page ever - * can disagree once; the last write wins from then on. + * Load (or create) the per-install anonymous id. The key and format live in + * helper/install-id.ts, which server calls use to send it as `deviceId`. + * Events tracked in the few milliseconds before the storage read answers + * carry mixpanel's random per-page id. Two scripts creating the id at once on + * the first page ever can disagree once; the last write wins from then on. */ function applyStoredAnonymousId() { try { diff --git a/src/stores/marker.ts b/src/stores/marker.ts index 5ec065f..967c4be 100644 --- a/src/stores/marker.ts +++ b/src/stores/marker.ts @@ -273,7 +273,7 @@ export const useMarkerStore = defineStore("marker", { const _this = this; TranslateService.instance - .fetchSimpleTranslation(translatingList, this.context) + .fetchSimpleTranslation(translatingList, this.context, "word") .then((res: string) => { _this.sourceLanguage = "auto"; diff --git a/src/subtitle/_support-template/components/Subtitle.vue b/src/subtitle/_support-template/components/Subtitle.vue index 2639cdc..536671e 100644 --- a/src/subtitle/_support-template/components/Subtitle.vue +++ b/src/subtitle/_support-template/components/Subtitle.vue @@ -212,7 +212,7 @@ export default defineComponent({ this.translatedLines = []; TranslateService.instance - .fetchSimpleTranslation(translatingList) + .fetchSimpleTranslation(translatingList, "", "caption") .then((translatedText) => { this.sourceLanguage = "auto"; @@ -226,7 +226,7 @@ export default defineComponent({ let translatingList = [word]; TranslateService.instance - .fetchSimpleTranslation(translatingList) + .fetchSimpleTranslation(translatingList, "", "word") .then((translatedText) => { this.sourceLanguage = "auto"; diff --git a/src/subtitle/web_netflix/components/Subtitle.vue b/src/subtitle/web_netflix/components/Subtitle.vue index c426da7..fc335f1 100644 --- a/src/subtitle/web_netflix/components/Subtitle.vue +++ b/src/subtitle/web_netflix/components/Subtitle.vue @@ -251,7 +251,7 @@ export default defineComponent({ this.isTranslatingWholeCaption = true; TranslateService.instance - .fetchSimpleTranslation(translatingList) + .fetchSimpleTranslation(translatingList, "", "caption") .then((translationResult) => { translatingList.forEach((result, i) => { this.translatedLines.push(translationResult); diff --git a/tests/translate-analytics-args.test.ts b/tests/translate-analytics-args.test.ts new file mode 100644 index 0000000..567fb10 --- /dev/null +++ b/tests/translate-analytics-args.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { setActivePinia, createPinia } from "pinia"; + +// E-01 / E-05: translation calls send the install's analytics id as +// `deviceId` and what asked for them as `trigger`. +const auth = vi.hoisted(() => ({ + user: null as { id: string } | null, + isLogin: true, + getToken: "anon-token" as string | null, + loginAsAnonymous: vi.fn(), +})); + +vi.mock("@modular-rest/client", () => ({ + functionProvider: { run: vi.fn() }, + authentication: auth, +})); + +import { TranslateService } from "../src/common/services/translate.service"; +import { useSettingsStore } from "../src/common/store/settings"; +import { ANON_ID_KEY, getInstallId } from "../src/common/helper/install-id"; +import { functionProvider } from "@modular-rest/client"; + +const runMock = functionProvider.run as unknown as ReturnType; +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}$/; + +/** An in-memory chrome.storage.local, since the shared shim stores nothing. */ +function memoryChromeStorage() { + const mem: Record = {}; + vi.mocked(chrome.storage.local.get).mockImplementation((( + key: string, + cb: (d: any) => void + ) => setTimeout(() => cb(key in mem ? { [key]: mem[key] } : {}), 0)) as any); + vi.mocked(chrome.storage.local.set).mockImplementation((( + obj: Record, + cb?: () => void + ) => { + Object.assign(mem, obj); + setTimeout(() => cb?.(), 0); + }) as any); + return mem; +} + +function lastArgs() { + return runMock.mock.calls.at(-1)![0].args; +} + +describe("getInstallId", () => { + let mem: Record; + beforeEach(() => { + mem = memoryChromeStorage(); + }); + + it("returns the stored id", async () => { + mem[ANON_ID_KEY] = "install-1"; + expect(await getInstallId()).toBe("install-1"); + }); + + it("creates and stores one id when there is none, even for parallel callers", async () => { + const [a, b] = await Promise.all([getInstallId(), getInstallId()]); + expect(a).toMatch(UUID_V4); + expect(b).toBe(a); + expect(mem[ANON_ID_KEY]).toBe(a); + }); + + it("follows a new id written later (logout rotates it)", async () => { + mem[ANON_ID_KEY] = "before"; + expect(await getInstallId()).toBe("before"); + mem[ANON_ID_KEY] = "after"; + expect(await getInstallId()).toBe("after"); + }); + + it("resolves undefined without an extension context", async () => { + vi.mocked(chrome.storage.local.get).mockImplementation((() => { + throw new Error("Extension context invalidated."); + }) as any); + expect(await getInstallId()).toBeUndefined(); + }); +}); + +describe("TranslateService analytics args", () => { + let mem: Record; + + beforeEach(() => { + setActivePinia(createPinia()); + useSettingsStore().language = "en"; + (TranslateService.instance as any).translationCache = {}; + runMock.mockReset(); + runMock.mockResolvedValue("translated"); + mem = memoryChromeStorage(); + mem[ANON_ID_KEY] = "install-1"; + auth.user = null; + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sends deviceId and trigger with a simple translation", async () => { + await TranslateService.instance.fetchSimpleTranslation( + ["a caption"], + "", + "caption" + ); + expect(runMock.mock.calls.at(-1)![0].name).toBe("translateWithContext"); + expect(lastArgs()).toMatchObject({ + translationType: "simple", + deviceId: "install-1", + trigger: "caption", + }); + }); + + it("keeps sending userId for a signed-in user", async () => { + auth.user = { id: "user-1" }; + await TranslateService.instance.fetchSimpleTranslation("hi", "", "word"); + expect(lastArgs()).toMatchObject({ + userId: "user-1", + deviceId: "install-1", + trigger: "word", + }); + }); + + it("sends trigger 'detail' by default with a detailed translation", async () => { + runMock.mockResolvedValue({ chunks: [] }); + await TranslateService.instance.fetchDetailedTranslation("hello", "ctx"); + expect(lastArgs()).toMatchObject({ + translationType: "detailed", + deviceId: "install-1", + trigger: "detail", + }); + }); + + it("waits for a first-run id instead of sending an empty one", async () => { + delete mem[ANON_ID_KEY]; + await TranslateService.instance.fetchSimpleTranslation( + "x", + "", + "selection" + ); + expect(lastArgs().deviceId).toMatch(UUID_V4); + expect(lastArgs().deviceId).toBe(mem[ANON_ID_KEY]); + }); + + it("sends no deviceId key when there is no extension context", async () => { + vi.mocked(chrome.storage.local.get).mockImplementation((() => { + throw new Error("Extension context invalidated."); + }) as any); + await TranslateService.instance.fetchSimpleTranslation("y", "", "word"); + expect(lastArgs()).not.toHaveProperty("deviceId"); + }); + + it("sends deviceId with translationAdvice", async () => { + runMock.mockResolvedValue({ type: "reply", text: "ok" }); + await TranslateService.instance.fetchTranslationAdvice({ + phrase: "p", + context: "c", + message: "m", + }); + expect(runMock.mock.calls.at(-1)![0].name).toBe("translationAdvice"); + expect(lastArgs().deviceId).toBe("install-1"); + }); +}); diff --git a/tests/word-detail-back-nav.test.ts b/tests/word-detail-back-nav.test.ts index 2769331..a86100a 100644 --- a/tests/word-detail-back-nav.test.ts +++ b/tests/word-detail-back-nav.test.ts @@ -134,7 +134,7 @@ describe("word card ↔ flashcard preview (Back)", () => { expect(viewedCount()).toBe(2); expect(fetchDetailed).toHaveBeenCalledTimes(2); - expect(fetchDetailed).toHaveBeenLastCalledWith("give up", ""); + expect(fetchDetailed).toHaveBeenLastCalledWith("give up", "", "detail"); w.unmount(); }); });