Skip to content
Open
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
69 changes: 69 additions & 0 deletions analytics.js
Original file line number Diff line number Diff line change
@@ -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.
}
}
49 changes: 49 additions & 0 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
};

Expand All @@ -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.
Expand Down Expand Up @@ -316,20 +345,27 @@ 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;
state.running = false;
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;
Expand Down Expand Up @@ -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;
Expand All @@ -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).
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down