diff --git a/README.md b/README.md index b685778..3cc6ee7 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,13 @@ inference when available, falling back to WASM otherwise. The desktop app bundles Chromium via Electron and sets the same isolation headers on its `app://` protocol. +## Telemetry + +By default, Rescript reports anonymous usage stats so we can tell how many people actually +use it and which features are worth the maintenance. You +can turn it off in **Settings → Privacy → Anonymous usage stats**, which stops +it immediately and permanently on that install. + ## License Copyright (c) 2026 Wassim Gharbi and Rescript contributors. diff --git a/components/Editor.tsx b/components/Editor.tsx index e119524..1def70b 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -7,6 +7,7 @@ import { getCutRanges, isWordCutOut } from "@/lib/edits"; import { extractAudio, getFFmpeg } from "@/lib/ffmpeg"; import { VAD_SAMPLE_RATE } from "@/lib/vad"; import { isElectron } from "@/lib/platform"; +import { trackEvent } from "@/lib/telemetry"; import { useIsDesktopLayout } from "@/hooks/useIsDesktopLayout"; import { useTranscriber } from "@/hooks/useTranscriber"; import TopBar from "./TopBar"; @@ -135,6 +136,11 @@ export default function Editor() { const [modeTransitioning, setModeTransitioning] = useState(false); const wasIdle = useRef(status === "idle"); + // Daily-active signal. trackEvent guards against Strict Mode's double effect. + useEffect(() => { + trackEvent("app_opened"); + }, []); + // Processing pipeline: load ffmpeg -> extract audio -> (maybe) transcribe. // Restored projects already have words; they only need PCM for the waveform. const startedFor = useRef(null); diff --git a/components/ExportDialog.tsx b/components/ExportDialog.tsx index 7ba9241..c2c59a0 100644 --- a/components/ExportDialog.tsx +++ b/components/ExportDialog.tsx @@ -10,6 +10,7 @@ import { X, } from "lucide-react"; import { useEditorStore } from "@/lib/store"; +import { trackEvent } from "@/lib/telemetry"; import { formatTime, getEditedDuration, getKeepRanges } from "@/lib/edits"; import { exportAudio, @@ -190,6 +191,11 @@ export default function ExportDialog() { const prev = useEditorStore.getState().exportUrl; if (prev) URL.revokeObjectURL(prev); setExportUrl(URL.createObjectURL(blob)); + trackEvent("export_completed", { + kind: activeTab, + format: activeTab === "audio" ? audioFormat : videoFormat, + ...(activeTab === "audio" ? {} : { resolution }), + }); } catch (err) { setError(err instanceof Error ? err.message : "Export failed."); } finally { @@ -221,6 +227,7 @@ export default function ExportDialog() { speakers, }); setError(null); + trackEvent("export_completed", { kind, format }); } catch (err) { setError(err instanceof Error ? err.message : "Export failed."); } diff --git a/components/SettingsMenu.tsx b/components/SettingsMenu.tsx index 4a72685..b7bd93d 100644 --- a/components/SettingsMenu.tsx +++ b/components/SettingsMenu.tsx @@ -11,6 +11,7 @@ import { X_PROFILE_URL, } from "./SocialLinks"; import { useAppearance } from "@/hooks/useAppearance"; +import { useTelemetryPref } from "@/hooks/useTelemetryPref"; import Popover, { PopoverContent, PopoverTrigger } from "./Popover"; import type { Appearance } from "@/lib/theme"; @@ -33,6 +34,7 @@ export default function SettingsMenu() { const [open, setOpen] = useState(false); const panelId = useId(); const { appearance, setAppearance } = useAppearance(); + const { enabled: telemetry, setEnabled: setTelemetry } = useTelemetryPref(); return ( -
+
{MENU_LINKS.map(({ label, href, Icon }) => ( ))}
+ +
+

+ Privacy +

+ +
+ diff --git a/hooks/useTelemetryPref.ts b/hooks/useTelemetryPref.ts new file mode 100644 index 0000000..80ac558 --- /dev/null +++ b/hooks/useTelemetryPref.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { isTelemetryEnabled, setTelemetryEnabled } from "@/lib/telemetry"; + +/** + * Anonymous usage telemetry preference (default on, opt-out). Safe to read in + * the initializer because the whole editor tree is client-only (`ssr: false`). + */ +export function useTelemetryPref() { + const [enabled, setEnabledState] = useState(() => isTelemetryEnabled()); + + const setEnabled = useCallback((next: boolean) => { + setTelemetryEnabled(next); + setEnabledState(next); + }, []); + + return { enabled, setEnabled }; +} diff --git a/hooks/useTranscriber.ts b/hooks/useTranscriber.ts index 7c1ae43..d86a254 100644 --- a/hooks/useTranscriber.ts +++ b/hooks/useTranscriber.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from "react"; import { isModelId } from "@/lib/models"; import { useEditorStore } from "@/lib/store"; +import { trackEvent } from "@/lib/telemetry"; import type { WorkerResponse } from "@/lib/types"; let activeWorker: Worker | null = null; @@ -58,6 +59,12 @@ export function useTranscriber() { s.setWords(msg.words); s.setStatus("ready"); s.setPartialText(""); + // Which model and language actually get used, to prioritise backends. + // Nothing about the media itself — not its length, not the text. + trackEvent("transcription_completed", { + model, + language: transcriptLanguage, + }); break; case "error": s.setError(msg.message); diff --git a/lib/store.ts b/lib/store.ts index 6387cc5..8293dc5 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -28,6 +28,7 @@ import { } from "./edits"; import { isModelId, loadModelPreference, saveModelPreference } from "./models"; import { isTranscriptSource, type TranscriptSource } from "./source"; +import { trackEvent } from "./telemetry"; import { DEFAULT_TRANSCRIPT_LANGUAGE, isTranscriptLanguage, @@ -371,6 +372,12 @@ export const useEditorStore = create((set, get) => ({ audio: null, duration: 0, }); + // Funnel step between opening the app and getting a transcript. `kind` and + // `source` are fixed vocabulary — nothing derived from the file itself. + trackEvent("project_created", { + kind, + source: imported ? "import" : "asr", + }); }, openProject: async (id) => { diff --git a/lib/telemetry.ts b/lib/telemetry.ts new file mode 100644 index 0000000..2550544 --- /dev/null +++ b/lib/telemetry.ts @@ -0,0 +1,131 @@ +"use client"; + +import { isElectron } from "./platform"; + +/** + * Anonymous usage telemetry — how many installs are active, and how far they get + * through the pipeline. Deliberately not an analytics SDK and deliberately not + * an account: + * + * - The only identifier is a random UUID this module generates for itself. It is + * not derived from the machine, the network, or the person, and clearing site + * data resets it. + * - Nothing about the user's media is ever sent — no filenames, no durations, no + * transcript text. The `props` below are fixed vocabulary (model id, export + * format) chosen so that no field can carry user content. + * - Fire-and-forget. Nothing awaits these calls, failures are swallowed, and + * offline is a silent no-op, so the editor behaves identically with telemetry + * on, off, or unreachable. That matters: the app is meant to work with the + * network cable pulled. + * + * Opt-out lives in Settings and is honoured before any of this runs. + */ + +const ENDPOINT = + process.env.NEXT_PUBLIC_TELEMETRY_ENDPOINT ?? + "https://www.getrescript.com/api/telemetry"; + +const INSTALL_ID_KEY = "rescript.installId"; +const ENABLED_KEY = "rescript.telemetry"; + +export type TelemetryEvent = + | "app_opened" + | "project_created" + | "transcription_completed" + | "export_completed"; + +type Props = Record; + +/** localStorage throws in private-mode Safari and when storage is full. */ +function read(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function write(key: string, value: string) { + try { + localStorage.setItem(key, value); + } catch { + // Preference just won't persist; not worth surfacing. + } +} + +/** Opt-out, so an unset value means enabled. */ +export function isTelemetryEnabled(): boolean { + if (typeof window === "undefined") return false; + return read(ENABLED_KEY) !== "off"; +} + +export function setTelemetryEnabled(enabled: boolean) { + write(ENABLED_KEY, enabled ? "on" : "off"); +} + +function installId(): string | null { + const existing = read(INSTALL_ID_KEY); + if (existing) return existing; + // No crypto.randomUUID on http:// origins or very old browsers. Rather than + // fall back to a weaker id, skip reporting — a missing install is a better + // failure than a colliding one. + if (typeof crypto?.randomUUID !== "function") return null; + const id = crypto.randomUUID(); + write(INSTALL_ID_KEY, id); + // If persisting failed the id is per-session, which inflates install counts + // slightly. Accepted: the alternative is fingerprinting. + return id; +} + +function platform(): "macos" | "windows" | "linux" | "unknown" { + const ua = navigator.userAgent; + if (/mac os x|macintosh/i.test(ua)) return "macos"; + if (/windows/i.test(ua)) return "windows"; + if (/linux|x11|cros/i.test(ua)) return "linux"; + return "unknown"; +} + +/** Guards `app_opened` against React remounts and Strict Mode double-effects. */ +let openedReported = false; + +export function trackEvent(event: TelemetryEvent, props?: Props) { + if (typeof window === "undefined") return; + if (event === "app_opened") { + if (openedReported) return; + openedReported = true; + } + if (!isTelemetryEnabled()) return; + + const id = installId(); + if (!id) return; + + const body = JSON.stringify({ + installId: id, + event, + version: process.env.NEXT_PUBLIC_APP_VERSION, + platform: platform(), + surface: isElectron ? "desktop" : "web", + props: props ?? null, + }); + + if (process.env.NODE_ENV !== "production") { + console.debug("[telemetry]", event, props ?? {}); + return; + } + + try { + void fetch(ENDPOINT, { + method: "POST", + // Survives the export-then-close case, where the tab may go away before + // the request settles. + keepalive: true, + mode: "cors", + // No cookies, ever — this endpoint has nothing to authenticate. + credentials: "omit", + headers: { "Content-Type": "application/json" }, + body, + }).catch(() => {}); + } catch { + // Offline, blocked by an extension, or CSP — all fine, all ignored. + } +} diff --git a/next.config.ts b/next.config.ts index 5defa72..129b69b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,13 @@ +import { readFileSync } from "node:fs"; import type { NextConfig } from "next"; +// Telemetry reports which version is in use, so the client needs the version at +// build time. Read from package.json rather than duplicated in a constant that +// `npm version` would silently leave stale. +const { version } = JSON.parse( + readFileSync(new URL("./package.json", import.meta.url), "utf8") +) as { version: string }; + // STATIC_EXPORT=1 emits the static bundle shipped to both targets: the web app // at app.getrescript.com (served by Vercel, which sends the cross-origin // isolation headers from vercel.json) and the Electron shell (which sets them @@ -9,6 +17,9 @@ const isExport = process.env.STATIC_EXPORT === "1"; const nextConfig: NextConfig = { reactStrictMode: true, + // Inlined into the client bundle at build time (both targets are static, so + // there is no runtime env to read this from). + env: { NEXT_PUBLIC_APP_VERSION: version }, // parakeet.js ships as raw ESM from src/; transpile for the worker bundle. transpilePackages: ["parakeet.js"], ...(isExport