Skip to content
Merged
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<File | null>(null);
Expand Down
7 changes: 7 additions & 0 deletions components/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.");
}
Expand Down
27 changes: 26 additions & 1 deletion components/SettingsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 (
<Popover
Expand Down Expand Up @@ -89,7 +91,7 @@ export default function SettingsMenu() {
</div>
</section>

<section className="py-1.5">
<section className="border-b border-zinc-100 px-1.5 py-1.5 dark:border-zinc-800">
{MENU_LINKS.map(({ label, href, Icon }) => (
<a
key={label}
Expand All @@ -113,6 +115,29 @@ export default function SettingsMenu() {
</a>
))}
</section>

<section className="px-2 py-2.5">
<p className="mb-2 text-[11px] font-medium tracking-wide text-zinc-400 dark:text-zinc-500">
Privacy
</p>
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={telemetry}
onChange={(e) => setTelemetry(e.target.checked)}
className="mt-0.5 h-3.5 w-3.5 shrink-0 cursor-pointer accent-transparent"
/>
<span>
<span className="block text-[12px] text-zinc-700 dark:text-zinc-300">
Help improve the app
</span>
<span className="mt-0.5 block text-[11px] leading-snug text-zinc-400 dark:text-zinc-500">
Send anonymous usage statistics (nothing about your media).
</span>
</span>
</label>
</section>

</PopoverContent>
</div>
</Popover>
Expand Down
19 changes: 19 additions & 0 deletions hooks/useTelemetryPref.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
7 changes: 7 additions & 0 deletions hooks/useTranscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -371,6 +372,12 @@ export const useEditorStore = create<EditorState>((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) => {
Expand Down
131 changes: 131 additions & 0 deletions lib/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number | boolean>;

/** 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.
}
}
11 changes: 11 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Loading