From e127b8c2ce23f994338288015866e676d98161a0 Mon Sep 17 00:00:00 2001 From: Akanksha Trehun Date: Tue, 3 Nov 2026 15:03:56 +0530 Subject: [PATCH] feat(privacy): anonymous analytics switch, Do Not Track, footer credits - Settings (privacy page, linked from the footer): an "Anonymous analytics" switch saved in localStorage. When off, DatadogInit doesn't load the SDK; if it's turned off on a page where RUM already started, beforeSend drops every further event and custom events stop. - Do Not Track and Global Privacy Control keep it off, and the switch says why. - RUM: defaultPrivacyLevel "mask" (replay stays off), and trackAnonymousUser: false, so there is no persistent usr.anonymous_id; production events were carrying one. - Privacy page: the telemetry section now says exactly which coarse export details are sent, instead of claiming no export settings are. - Footer: "This site is powered by Netlify", "Security by Snyk", GitHub and license links; the Code of Conduct link points at reframe-oss/reframe. Co-Authored-By: Claude Opus 5.5 --- src/app/privacy/page.tsx | 27 ++++- src/components/AnalyticsSettings.tsx | 88 ++++++++++++++ src/components/DatadogInit.tsx | 10 +- src/components/Footer.tsx | 49 +++++++- .../__tests__/AnalyticsSettings.test.tsx | 37 ++++++ .../__tests__/DatadogInit.consent.test.tsx | 51 ++++++++ src/lib/analyticsConsent.ts | 68 +++++++++++ src/lib/telemetry.ts | 11 +- src/lib/tests/analyticsConsent.test.ts | 114 ++++++++++++++++++ 9 files changed, 446 insertions(+), 9 deletions(-) create mode 100644 src/components/AnalyticsSettings.tsx create mode 100644 src/components/__tests__/AnalyticsSettings.test.tsx create mode 100644 src/components/__tests__/DatadogInit.consent.test.tsx create mode 100644 src/lib/analyticsConsent.ts create mode 100644 src/lib/tests/analyticsConsent.test.ts diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 252c78aa5..6b3ae3150 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import Link from "next/link"; +import AnalyticsSettings from "@/components/AnalyticsSettings"; export const metadata: Metadata = { title: "Privacy Policy — Reframe", @@ -135,13 +136,29 @@ export default function PrivacyPage() { Session replay is explicitly disabled — Datadog never records or reconstructs what the editor looked like during your session. Telemetry is limited to metrics and error - events; it does not and cannot include your video or audio - files, filenames, export settings, or any content you - create or edit in the app, since that data never leaves - your browser in the first place (see Data Collection - above). + events. It never includes your video or audio files, file + names, captions, folder paths or anything you create in the + app. Export events carry only coarse details: the output + format, a resolution range (such as 1080p), size and length + ranges, whether text, music or an image overlay was added, + and whether the + export succeeded.

+

+ Settings +

+

+ Turn anonymous analytics off here. When it's off, the + Datadog script isn't loaded at all. Reframe also leaves + it off if your browser sends Do Not Track or Global Privacy + Control. +

+ +

4. Third-Party Dependencies

diff --git a/src/components/AnalyticsSettings.tsx b/src/components/AnalyticsSettings.tsx new file mode 100644 index 000000000..6666345b8 --- /dev/null +++ b/src/components/AnalyticsSettings.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useId, useState } from "react"; +import { cn } from "@/lib/utils"; +import { + analyticsAllowed, + browserDeclinesTracking, + onAnalyticsChoiceChange, + setAnalyticsChoice, +} from "@/lib/analyticsConsent"; + +/** The "Anonymous analytics" switch. Saved in this browser only. */ +export default function AnalyticsSettings() { + const labelId = useId(); + const descriptionId = useId(); + // Unknown until mounted: the page is prerendered without a browser. + const [enabled, setEnabled] = useState(null); + const [declined, setDeclined] = useState(false); + const [notice, setNotice] = useState(null); + + useEffect(() => { + const read = () => { + setDeclined(browserDeclinesTracking()); + setEnabled(analyticsAllowed()); + }; + read(); + return onAnalyticsChoiceChange(read); + }, []); + + const toggle = () => { + if (enabled === null || declined) return; + const next = !enabled; + const saved = setAnalyticsChoice(next); + setEnabled(next); + setNotice( + !saved + ? "Your browser blocked saving this choice, so it applies to this page only." + : next + ? "Anonymous analytics is on. It starts on your next visit." + : "Anonymous analytics is off. Nothing more is sent from this browser." + ); + }; + + return ( +
+
+
+

+ Anonymous analytics +

+

+ {declined + ? "Off, because your browser sends Do Not Track or Global Privacy Control. Reframe respects that." + : "Export success or failure, load times and errors, with no file names, captions or video content. Saved in this browser."} +

+
+ +
+ {notice && ( +

+ {notice} +

+ )} +
+ ); +} diff --git a/src/components/DatadogInit.tsx b/src/components/DatadogInit.tsx index c5d2ef42f..f4ff1f8c2 100644 --- a/src/components/DatadogInit.tsx +++ b/src/components/DatadogInit.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { capabilityContext, currentTelemetryEnv, registerRum, sanitizeRumEvent } from "@/lib/telemetry"; +import { analyticsAllowed } from "@/lib/analyticsConsent"; export default function DatadogInit() { useEffect(() => { @@ -11,7 +12,9 @@ export default function DatadogInit() { // null on localhost, LAN addresses and automated browsers unless // NEXT_PUBLIC_DATADOG_ENV names a non-production env (see telemetry.ts). const env = currentTelemetryEnv(); - if (!applicationId || !clientToken || !env) { + // Off in Settings, or the browser sends Do Not Track / Global Privacy + // Control: don't load the SDK at all. + if (!applicationId || !clientToken || !env || !analyticsAllowed()) { return; } @@ -36,6 +39,11 @@ export default function DatadogInit() { // session replay would visually record the editor UI, which cuts // against that claim. Metrics/errors only. sessionReplaySampleRate: 0, + // Replay is off; if it were ever enabled, text and inputs stay masked. + defaultPrivacyLevel: "mask", + // No persistent anonymous user ID (usr.anonymous_id): sessions + // can't be linked across visits. + trackAnonymousUser: false, plugins: [reactPlugin()], trackUserInteractions: true, // Auto-named click actions use the clicked element's text, which can diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index a3dd7c38a..378e04046 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -14,6 +14,7 @@ import { Lock, Mail, HeartHandshake, + Settings, } from "lucide-react"; export default function Footer() { @@ -89,11 +90,20 @@ export default function Footer() { Privacy Policy + + + Settings + + {/* Netlify's open source plan expects a publicly reachable Code of Conduct. Points at the repo file rather than an in-site route, since CODE_OF_CONDUCT.md is the canonical copy. */} -

© {new Date().getFullYear()} Reframe · MIT License

+
diff --git a/src/components/__tests__/AnalyticsSettings.test.tsx b/src/components/__tests__/AnalyticsSettings.test.tsx new file mode 100644 index 000000000..b1d9401c0 --- /dev/null +++ b/src/components/__tests__/AnalyticsSettings.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import AnalyticsSettings from "../AnalyticsSettings"; +import { ANALYTICS_STORAGE_KEY } from "@/lib/analyticsConsent"; + +const setDnt = (value: string | null) => + Object.defineProperty(navigator, "doNotTrack", { configurable: true, get: () => value }); + +beforeEach(() => { + localStorage.clear(); + setDnt(null); +}); + +describe("AnalyticsSettings", () => { + it("is an accessible switch that saves the choice in this browser", async () => { + render(); + const toggle = await screen.findByRole("switch", { name: "Anonymous analytics" }); + expect(toggle).toHaveAttribute("aria-checked", "true"); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-checked", "false"); + expect(localStorage.getItem(ANALYTICS_STORAGE_KEY)).toBe("off"); + expect(screen.getByRole("status")).toHaveTextContent(/off/i); + + fireEvent.click(toggle); + expect(localStorage.getItem(ANALYTICS_STORAGE_KEY)).toBe("on"); + }); + + it("shows off and can't be turned on when the browser sends Do Not Track", async () => { + setDnt("1"); + render(); + const toggle = await screen.findByRole("switch", { name: "Anonymous analytics" }); + expect(toggle).toHaveAttribute("aria-checked", "false"); + expect(toggle).toBeDisabled(); + expect(screen.getByText(/Do Not Track/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/__tests__/DatadogInit.consent.test.tsx b/src/components/__tests__/DatadogInit.consent.test.tsx new file mode 100644 index 000000000..5b07ace75 --- /dev/null +++ b/src/components/__tests__/DatadogInit.consent.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment-options {"url": "https://reframe-os.netlify.app/"} +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { rumInit, renderDatadogInit } from "./datadogInitHarness"; +import { setAnalyticsChoice } from "@/lib/analyticsConsent"; + +// A production build on the production host: RUM would start, unless the +// viewer or their browser says no. +beforeEach(() => { + localStorage.clear(); + Object.defineProperty(navigator, "doNotTrack", { configurable: true, get: () => null }); + Object.defineProperty(navigator, "globalPrivacyControl", { configurable: true, get: () => undefined }); + vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", "app"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_CLIENT_TOKEN", "pub0000"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_ENV", ""); + vi.stubEnv("NEXT_PUBLIC_VERCEL_ENV", ""); + vi.stubEnv("NEXT_PUBLIC_DEPLOY_CONTEXT", "production"); +}); +afterEach(() => vi.unstubAllEnvs()); + +describe("DatadogInit and the analytics choice", () => { + it("starts with session replay off and privacy level mask when allowed", async () => { + await renderDatadogInit(); + expect(rumInit).toHaveBeenCalledTimes(1); + expect(rumInit.mock.calls[0]![0]).toMatchObject({ + sessionReplaySampleRate: 0, + defaultPrivacyLevel: "mask", + enablePrivacyForActionName: true, + trackAnonymousUser: false, + }); + }); + + it("never initialises, or contacts Datadog, when turned off in Settings", async () => { + setAnalyticsChoice(false); + const requests = await renderDatadogInit(); + expect(rumInit).not.toHaveBeenCalled(); + expect(requests).toEqual([]); + }); + + it("never initialises when the browser sends Do Not Track", async () => { + Object.defineProperty(navigator, "doNotTrack", { configurable: true, get: () => "1" }); + const requests = await renderDatadogInit(); + expect(rumInit).not.toHaveBeenCalled(); + expect(requests).toEqual([]); + }); + + it("never initialises under Global Privacy Control", async () => { + Object.defineProperty(navigator, "globalPrivacyControl", { configurable: true, get: () => true }); + await renderDatadogInit(); + expect(rumInit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/analyticsConsent.ts b/src/lib/analyticsConsent.ts new file mode 100644 index 000000000..5baf97688 --- /dev/null +++ b/src/lib/analyticsConsent.ts @@ -0,0 +1,68 @@ +// Whether this browser may send anonymous telemetry (Datadog RUM). +// +// - The viewer's own choice, from Settings (the privacy page), is stored in +// localStorage under ANALYTICS_STORAGE_KEY. +// - Do Not Track and Global Privacy Control turn it off, and the toggle can't +// turn it back on: the browser-wide signal wins. +// - With no choice and no signal, it is on (anonymous, see docs/DATADOG.md). +// +// When it is off RUM never initialises; if the viewer turns it off on a page +// where RUM already started, every further event is dropped in beforeSend. + +export const ANALYTICS_STORAGE_KEY = "reframe_analytics"; +const CHANGE_EVENT = "reframe:analytics-change"; + +type Nav = Navigator & { globalPrivacyControl?: boolean; msDoNotTrack?: string }; + +/** True when the browser asks not to be tracked (Do Not Track or GPC). */ +export function browserDeclinesTracking(): boolean { + if (typeof navigator === "undefined") return false; + const nav = navigator as Nav; + const win = typeof window !== "undefined" ? (window as Window & { doNotTrack?: string }) : undefined; + const dnt = nav.doNotTrack ?? win?.doNotTrack ?? nav.msDoNotTrack; + return dnt === "1" || dnt === "yes" || nav.globalPrivacyControl === true; +} + +/** The viewer's stored choice, or null if they haven't made one. */ +export function storedAnalyticsChoice(): boolean | null { + try { + const value = localStorage.getItem(ANALYTICS_STORAGE_KEY); + if (value === "on") return true; + if (value === "off") return false; + } catch { + // storage blocked: no stored choice + } + return null; +} + +/** Whether telemetry may be sent from this browser right now. */ +export function analyticsAllowed(): boolean { + if (typeof window === "undefined") return false; + if (browserDeclinesTracking()) return false; + return storedAnalyticsChoice() ?? true; +} + +/** Saves the viewer's choice. Returns false if it couldn't be stored. */ +export function setAnalyticsChoice(enabled: boolean): boolean { + let saved = true; + try { + localStorage.setItem(ANALYTICS_STORAGE_KEY, enabled ? "on" : "off"); + } catch { + saved = false; + } + if (typeof window !== "undefined") window.dispatchEvent(new Event(CHANGE_EVENT)); + return saved; +} + +/** Calls `listener` when the choice changes in this tab or another one. */ +export function onAnalyticsChoiceChange(listener: () => void): () => void { + const onStorage = (e: StorageEvent) => { + if (e.key === ANALYTICS_STORAGE_KEY) listener(); + }; + window.addEventListener(CHANGE_EVENT, listener); + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener(CHANGE_EVENT, listener); + window.removeEventListener("storage", onStorage); + }; +} diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 5496763b8..51f0a3d41 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -1,4 +1,5 @@ import type { DatadogRum as RumPublicApi } from "@datadog/browser-rum"; +import { analyticsAllowed } from "./analyticsConsent"; // Custom Datadog RUM actions. Only coarse, non-identifying metrics are sent — // never file names, contents, captions or recipe values (Reframe is @@ -16,7 +17,9 @@ import type { DatadogRum as RumPublicApi } from "@datadog/browser-rum"; export const telemetryConfigured = () => typeof window !== "undefined" && Boolean(process.env.NEXT_PUBLIC_DATADOG_APPLICATION_ID && process.env.NEXT_PUBLIC_DATADOG_CLIENT_TOKEN) && - currentTelemetryEnv() !== null; + currentTelemetryEnv() !== null && + // The viewer's choice in Settings, Do Not Track and Global Privacy Control. + analyticsAllowed(); // ── Environment ──────────────────────────────────────────────────────────── // Local builds read .env.local, which holds the real Datadog keys. Without a @@ -308,6 +311,12 @@ type MutableRumEvent = { * place because the SDK only reads back modified fields of the same object. */ export function sanitizeRumEvent(event: MutableRumEvent): boolean { + // Turned off in Settings after RUM started on this page: send nothing more. + if (!analyticsAllowed()) return false; + // No user identity, even an anonymous one. The real switch is + // trackAnonymousUser: false in DatadogInit; this is a backstop, and the SDK + // may ignore edits to fields it doesn't let beforeSend change. + delete (event as { usr?: unknown }).usr; if (event.view) { event.view.url = stripQuery(event.view.url); event.view.referrer = stripQuery(event.view.referrer); diff --git a/src/lib/tests/analyticsConsent.test.ts b/src/lib/tests/analyticsConsent.test.ts new file mode 100644 index 000000000..067792646 --- /dev/null +++ b/src/lib/tests/analyticsConsent.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { DatadogRum } from "@datadog/browser-rum"; +import { + ANALYTICS_STORAGE_KEY, + analyticsAllowed, + browserDeclinesTracking, + onAnalyticsChoiceChange, + setAnalyticsChoice, + storedAnalyticsChoice, +} from "../analyticsConsent"; +import { registerRum, resetTelemetryForTests, sanitizeRumEvent, trackAction } from "../telemetry"; + +function setNavigator(props: { doNotTrack?: string | null; globalPrivacyControl?: boolean }) { + for (const [key, value] of Object.entries(props)) { + Object.defineProperty(navigator, key, { configurable: true, get: () => value }); + } +} + +beforeEach(() => { + localStorage.clear(); + setNavigator({ doNotTrack: null, globalPrivacyControl: undefined }); +}); +afterEach(() => { + vi.unstubAllEnvs(); + resetTelemetryForTests(); +}); + +describe("analytics consent", () => { + it("is on by default, with no stored choice", () => { + expect(storedAnalyticsChoice()).toBeNull(); + expect(analyticsAllowed()).toBe(true); + }); + + it("remembers the viewer's choice in localStorage", () => { + setAnalyticsChoice(false); + expect(localStorage.getItem(ANALYTICS_STORAGE_KEY)).toBe("off"); + expect(analyticsAllowed()).toBe(false); + setAnalyticsChoice(true); + expect(analyticsAllowed()).toBe(true); + }); + + it("Do Not Track turns it off, and a stored 'on' can't override it", () => { + setNavigator({ doNotTrack: "1" }); + expect(browserDeclinesTracking()).toBe(true); + setAnalyticsChoice(true); + expect(analyticsAllowed()).toBe(false); + }); + + it("Global Privacy Control turns it off", () => { + setNavigator({ globalPrivacyControl: true }); + expect(analyticsAllowed()).toBe(false); + }); + + it("a DNT value of 0 doesn't count as declining", () => { + setNavigator({ doNotTrack: "0" }); + expect(analyticsAllowed()).toBe(true); + }); + + it("a blocked storage read counts as no choice (on)", () => { + const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + expect(storedAnalyticsChoice()).toBeNull(); + expect(analyticsAllowed()).toBe(true); + get.mockRestore(); + }); + + it("notifies listeners when the choice changes", () => { + const listener = vi.fn(); + const stop = onAnalyticsChoiceChange(listener); + setAnalyticsChoice(false); + expect(listener).toHaveBeenCalledTimes(1); + stop(); + setAnalyticsChoice(true); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); + +describe("telemetry respects the choice", () => { + const configure = () => { + vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", "app"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_CLIENT_TOKEN", "token"); + vi.stubEnv("NEXT_PUBLIC_DATADOG_ENV", "unit-test"); + const rum = { addAction: vi.fn(), addDurationVital: vi.fn() }; + registerRum(rum as unknown as DatadogRum); + return rum; + }; + + it("sends custom events while allowed", () => { + const rum = configure(); + trackAction("export_started", { format: "mp4" }); + expect(rum.addAction).toHaveBeenCalledTimes(1); + }); + + it("sends nothing once turned off, even though RUM already started", () => { + const rum = configure(); + setAnalyticsChoice(false); + trackAction("export_started", { format: "mp4" }); + expect(rum.addAction).not.toHaveBeenCalled(); + // Automatically collected events are dropped in beforeSend. + expect(sanitizeRumEvent({ type: "view", view: { url: "https://reframe-os.netlify.app/" } })).toBe(false); + }); + + it("strips any user identity from events", () => { + const event = { type: "view", view: { url: "https://reframe-os.netlify.app/" }, usr: { anonymous_id: "abc" } }; + expect(sanitizeRumEvent(event)).toBe(true); + expect(event).not.toHaveProperty("usr"); + }); + + it("drops automatically collected events under Do Not Track", () => { + setNavigator({ doNotTrack: "1" }); + expect(sanitizeRumEvent({ type: "error", error: { message: "x" } })).toBe(false); + }); +});