diff --git a/analytics.js b/analytics.js new file mode 100644 index 0000000..37d6a55 --- /dev/null +++ b/analytics.js @@ -0,0 +1,69 @@ +// analytics.js — minimal PostHog client for the Echoly extension service worker. +// +// MV3 CSP (`script-src 'self'`) forbids loading posthog-js from a CDN, and the +// extension ships as plain files with no bundler — so this hand-rolled client +// POSTs events straight to the PostHog capture endpoint via fetch from the +// background service worker (which has the host permission). Every call is +// fire-and-forget and never throws: analytics must never break a dub session. +// +// Privacy: events are anonymous. A random distinct_id is stored in +// chrome.storage.local — no email, no Kyma key, no provider/routing detail is +// ever sent. Person profiles are disabled ($process_person_profile: false). + +const PH_KEY = "phc_sNC4sT7s3xB9Z7wjB3RFxSTKFY5By47fMxN2XVWUoWgU"; +const PH_HOST = "https://us.i.posthog.com"; +const DISTINCT_ID_STORAGE_KEY = "ph_distinct_id"; + +let cachedDistinctId = null; + +// Stable anonymous id, generated once and persisted. Falls back to an ephemeral +// id if storage is unavailable so events still land. +async function getDistinctId() { + if (cachedDistinctId) return cachedDistinctId; + try { + const got = await chrome.storage.local.get(DISTINCT_ID_STORAGE_KEY); + let id = got?.[DISTINCT_ID_STORAGE_KEY]; + if (!id) { + id = crypto.randomUUID(); + await chrome.storage.local.set({ [DISTINCT_ID_STORAGE_KEY]: id }); + } + cachedDistinctId = id; + return id; + } catch { + cachedDistinctId = cachedDistinctId || crypto.randomUUID(); + return cachedDistinctId; + } +} + +function extensionVersion() { + try { + return chrome.runtime.getManifest().version; + } catch { + return "unknown"; + } +} + +// Fire-and-forget event capture. Returns a promise but callers should `void` it. +export async function track(event, properties = {}) { + try { + const distinct_id = await getDistinctId(); + await fetch(`${PH_HOST}/i/v0/e/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + keepalive: true, + body: JSON.stringify({ + api_key: PH_KEY, + event, + distinct_id, + properties: { + ...properties, + $lib: "echoly-extension", + extension_version: extensionVersion(), + $process_person_profile: false, + }, + }), + }); + } catch { + // Swallow — analytics failures must stay invisible to the user. + } +} diff --git a/background.js b/background.js index 246fc93..7615ca4 100644 --- a/background.js +++ b/background.js @@ -6,6 +6,8 @@ // state.* is the canonical snapshot, BACKGROUND_STATE_UPDATE pushes to popup, // CONTENT_UPDATE pushes to the active YT tab. +import { track } from "./analytics.js"; + const DEFAULT_SETTINGS = { tier: "realtime", targetLanguage: "vi", @@ -179,6 +181,7 @@ const state = { apiMode: null, // "byok" | "proxy" | null signedInUser: null, // { email, tier } or null usage: null, // { standard: minutes, realtime: minutes } — v0.6.2 + sessionStartedAt: null, // epoch ms when the current dub started; null when idle ...DEFAULT_SETTINGS, }; @@ -195,6 +198,32 @@ function snapshot() { return { ...state }; } +// Common analytics props describing the current dub configuration. Voice is the +// active tier's pick. No PII, no Kyma key, no provider/routing detail. +function sessionProps() { + return { + tier: state.tier, + target_language: state.targetLanguage, + voice: state.tier === "standard" ? state.standardVoice : state.realtimeVoice, + api_mode: state.apiMode || null, + source: "youtube", + }; +} + +// Fire dub_stopped once per session (guarded by sessionStartedAt) with duration. +// Safe to call from every teardown path — only the first call after a start +// emits; subsequent calls no-op until the next dub_started. +function trackSessionEnd(reason) { + const startedAt = state.sessionStartedAt; + if (!startedAt) return; + state.sessionStartedAt = null; + void track("dub_stopped", { + ...sessionProps(), + reason, + duration_seconds: Math.round((Date.now() - startedAt) / 1000), + }); +} + function broadcastToPopup() { // Debounce: 1 broadcast per 50 ms. Popup re-renders are cheap but spamming // is wasteful while volume sliders drag. @@ -316,7 +345,9 @@ async function handleStart(settings) { state.connecting = false; state.running = true; state.status = "Translating"; + state.sessionStartedAt = Date.now(); broadcastToPopup(); + void track("dub_started", sessionProps()); return { ok: true, state: snapshot() }; } catch (err) { state.connecting = false; @@ -324,12 +355,17 @@ async function handleStart(settings) { state.errorMessage = err.message || String(err); state.status = state.errorMessage; broadcastToPopup(); + void track("dub_failed", { + ...sessionProps(), + error: (err?.message || String(err)).slice(0, 200), + }); return { ok: false, error: state.errorMessage }; } } async function handleStop() { const tabId = state.tabId; + trackSessionEnd("user_stop"); state.running = false; state.connecting = false; state.paused = false; @@ -413,6 +449,7 @@ function handleContentEvent(message) { broadcastToPopup(); } if (message.type === "CONTENT_ENDED") { + trackSessionEnd(message.reason || "content_ended"); state.running = false; state.connecting = false; state.paused = false; @@ -422,6 +459,14 @@ function handleContentEvent(message) { } } +// First install / version bump — one event so we can see install→activation. +chrome.runtime.onInstalled.addListener((details) => { + void track(details.reason === "update" ? "extension_updated" : "extension_installed", { + reason: details.reason, + previous_version: details.previousVersion || null, + }); +}); + // Popup → background → content router. chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // Cache-lookup from content script — needs a real response (not fire-and-forget). @@ -481,6 +526,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { case "STOP": sendResponse(await handleStop()); break; + case "TRACK": + void track(message.event, message.properties || {}); + sendResponse({ ok: true }); + break; case "UPDATE_SETTINGS": sendResponse(await handleUpdateSettings(message.settings)); break; diff --git a/manifest.json b/manifest.json index fea598f..7d627fb 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ "name": "Echoly — Live YouTube Translation", "short_name": "Echoly", "description": "Hear any YouTube video in your language. Live AI dubbing, 40+ language pairs. Free 30 min/month or bring your own Kyma key.", - "version": "0.6.3", + "version": "0.6.4", "minimum_chrome_version": "116", "permissions": ["activeTab", "scripting", "storage", "webRequest", "cookies"], "content_security_policy": { @@ -15,7 +15,8 @@ "https://api.kymaapi.com/*", "https://api.openai.com/*", "https://api.echolyhq.com/*", - "https://echolyhq.com/*" + "https://echolyhq.com/*", + "https://us.i.posthog.com/*" ], "icons": { "16": "icons/icon-16.png", diff --git a/popup.js b/popup.js index e51e662..a847488 100644 --- a/popup.js +++ b/popup.js @@ -441,6 +441,7 @@ chrome.runtime.onMessage.addListener((message) => { }); // Init +chrome.runtime.sendMessage({ type: "TRACK", event: "popup_opened" }).catch(() => {}); populateLanguages(); repopulateVoices(state.tier, state.tier === "standard" ? state.standardVoice : state.realtimeVoice); (async () => {