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);
+ });
+});