From 9e9218bb00b300e1bf8c83eaeb4ed014bf3eb0e9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 17 Mar 2026 09:03:12 -0400 Subject: [PATCH 1/5] feat(sentry): switch crash reporting from opt-out to opt-in --- docs/PRIVACY.md | 2 +- docs/SENTRY_INTEGRATION.md | 16 ++++---- docs/SENTRY_PRIVACY.md | 14 +++---- .../TelemetryConsentBanner.test.tsx | 38 +++++++++---------- .../TelemetryConsentBanner.tsx | 26 ++++++------- .../developer-tools/SentrySettings.tsx | 2 +- src/app/features/settings/general/General.tsx | 2 +- src/instrument.ts | 4 +- 8 files changed, 52 insertions(+), 52 deletions(-) diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 598b3af3e..aa300dae4 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -85,7 +85,7 @@ Depending on the build, you can disable error reporting, enable or disable sessi ### First-time consent notice -When a build has crash reporting configured, a notice appears the first time you open Sable. It explains that anonymous crash reports are enabled and gives you the option to opt out before any diagnostic data is sent. You can also dismiss it to keep reporting enabled. +When a build has crash reporting configured, a notice appears the first time you open Sable. It explains that Sable can send anonymous crash reports to help fix bugs, and gives you the option to enable it. Dismissing the notice without enabling keeps crash reporting off. This notice only appears once. Your choice is saved and can be changed at any time in **Settings → General → Diagnostics & Privacy**. diff --git a/docs/SENTRY_INTEGRATION.md b/docs/SENTRY_INTEGRATION.md index 5594ea165..df5511e57 100644 --- a/docs/SENTRY_INTEGRATION.md +++ b/docs/SENTRY_INTEGRATION.md @@ -78,7 +78,7 @@ The bug report modal (`/bugreport` command or "Bug Report" button) now includes: - **Optional Sentry reporting**: Checkbox to send anonymous reports to Sentry - **Debug log attachment**: Option to include recent debug logs (last 100 entries) - **User feedback API**: Bug reports are sent as Sentry user feedback for better visibility -- **Privacy controls**: Users can opt-out of Sentry reporting +- **Privacy controls**: Users can opt-in to Sentry reporting Integration points: @@ -94,7 +94,7 @@ Comprehensive data scrubbing (full details in [SENTRY_PRIVACY.md](./SENTRY_PRIVA - **Matrix ID anonymization**: User IDs, room IDs, and event IDs are masked - **Session replay privacy**: All text, media, and form inputs are masked when replay is enabled - **request header sanitization**: Authorization headers are removed -- **User opt-out**: Users can disable Sentry entirely via settings +- **User opt-in**: Users can enable Sentry via settings Sensitive patterns automatically redacted: @@ -124,14 +124,14 @@ Sentry controls are split across two settings locations: ### 6. First-Login Consent Banner -When `VITE_SENTRY_DSN` is set and a user has never seen the crash-reporting notice (i.e. `sable_sentry_enabled` is absent from `localStorage`), a dismissible banner slides in from the bottom of the screen on first load. It explains that anonymous crash reports are enabled and links to the Privacy Policy. +When `VITE_SENTRY_DSN` is set and a user has never seen the crash-reporting notice (i.e. `sable_sentry_enabled` is absent from `localStorage`), a dismissible banner slides in from the bottom of the screen on first load. It explains that anonymous crash reporting is available and asks if the user wants to enable it. **Actions available in the banner:** -| Button | Effect | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| **Got it** / × (close) | Sets `sable_sentry_enabled = true` in `localStorage` and dismisses the banner with a fade-out animation. Reporting continues. | -| **Opt out** | Sets `sable_sentry_enabled = false` and reloads the page. Sentry is disabled for this user going forward. | +| Button | Effect | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **Enable** | Sets `sable_sentry_enabled = true` in `localStorage` and reloads the page so Sentry initialises. Reporting begins after reload. | +| **No thanks** / × (close) | Sets `sable_sentry_enabled = false` in `localStorage` and dismisses the banner with a fade-out animation. Sentry stays disabled. | Once the user has interacted with the banner (either action), it never appears again. The same preference can be changed later in **Settings → General → Diagnostics & Privacy**. @@ -432,7 +432,7 @@ See [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md) for a complete, code-linked breakdo In summary, all data sent to Sentry is: -- **Opt-in by default** but can be disabled +- **Off by default**: Sentry is disabled until the user explicitly opts in - **Anonymized**: No personal data or message content - **Filtered**: Tokens, passwords, and IDs are redacted - **Minimal**: Only error context and debug info diff --git a/docs/SENTRY_PRIVACY.md b/docs/SENTRY_PRIVACY.md index 1cfbc2cae..39be916ce 100644 --- a/docs/SENTRY_PRIVACY.md +++ b/docs/SENTRY_PRIVACY.md @@ -9,18 +9,18 @@ configuration details see [SENTRY_INTEGRATION.md](./SENTRY_INTEGRATION.md). ## What Is Collected Sentry is **disabled by default when no DSN is configured** and can be **opted -out by users** at any time via Settings → General → Diagnostics & Privacy. +in to by users** at any time via Settings → General → Diagnostics & Privacy. ### First-Login Consent Notice When Sentry is configured, the app shows a dismissible notice the first time a -user loads Sable. The notice explains that crash reporting is active and provides -a one-click opt-out before any data is sent. +user loads Sable. The notice explains that crash reporting is available and +provides a one-click opt-in before any data is sent. -| Action | Effect | -| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| **"Got it"** or **✕ dismiss** | Preference saved as opted-in (`sable_sentry_enabled = 'true'`); notice does not appear again | -| **"Opt out"** | Sentry disabled (`sable_sentry_enabled = 'false'`), page reloads — no Sentry data is sent for that session or any future session | +| Action | Effect | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| **"Enable"** | Sentry enabled (`sable_sentry_enabled = 'true'`), page reloads so Sentry initialises — data collection begins after reload | +| **"No thanks"** or **✕ dismiss** | Preference saved as opted-out (`sable_sentry_enabled = 'false'`); notice does not appear again; no Sentry data is ever sent | The preference persists in `localStorage` and can be changed at any time in **Settings → General → Diagnostics & Privacy**. diff --git a/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx b/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx index 83c5fb00b..896374db2 100644 --- a/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx +++ b/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx @@ -24,7 +24,7 @@ describe('TelemetryConsentBanner', () => { expect(container).toBeEmptyDOMElement(); }); - it('renders nothing when the user has already acknowledged (opted in)', () => { + it('renders nothing when the user has already opted in', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); localStorage.setItem(SENTRY_KEY, 'true'); const { container } = render(); @@ -41,8 +41,8 @@ describe('TelemetryConsentBanner', () => { it('renders the banner when DSN is configured and no preference is saved', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); render(); - expect(screen.getByRole('region', { name: /crash reporting notice/i })).toBeInTheDocument(); - expect(screen.getByText(/crash reporting is enabled/i)).toBeInTheDocument(); + expect(screen.getByRole('region', { name: /crash reporting prompt/i })).toBeInTheDocument(); + expect(screen.getByText(/help improve sable/i)).toBeInTheDocument(); }); // ── accessibility ───────────────────────────────────────────────────────── @@ -50,8 +50,8 @@ describe('TelemetryConsentBanner', () => { it('has both action buttons visible', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); render(); - expect(screen.getByRole('button', { name: /got it/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /opt out/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /enable/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /no thanks/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument(); }); @@ -61,29 +61,29 @@ describe('TelemetryConsentBanner', () => { expect(screen.getByRole('link', { name: /learn more/i })).toBeInTheDocument(); }); - // ── "Got it" action ─────────────────────────────────────────────────────── + // ── "Enable" action ─────────────────────────────────────────────────────── - it('"Got it" saves opted-in preference to localStorage', () => { + it('"Enable" saves opted-in preference to localStorage', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); render(); - fireEvent.click(screen.getByRole('button', { name: /got it/i })); + fireEvent.click(screen.getByRole('button', { name: /enable/i })); expect(localStorage.getItem(SENTRY_KEY)).toBe('true'); }); - it('"Got it" does not reload the page', () => { + it('"Enable" reloads the page so Sentry initialises', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); render(); - fireEvent.click(screen.getByRole('button', { name: /got it/i })); - expect(window.location.reload).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: /enable/i })); + expect(window.location.reload).toHaveBeenCalledOnce(); }); // ── dismiss (✕) action ──────────────────────────────────────────────────── - it('dismiss button (✕) saves opted-in preference to localStorage', () => { + it('dismiss button (✕) saves opted-out preference to localStorage', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); render(); fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); - expect(localStorage.getItem(SENTRY_KEY)).toBe('true'); + expect(localStorage.getItem(SENTRY_KEY)).toBe('false'); }); it('dismiss button does not reload the page', () => { @@ -93,19 +93,19 @@ describe('TelemetryConsentBanner', () => { expect(window.location.reload).not.toHaveBeenCalled(); }); - // ── "Opt out" action ────────────────────────────────────────────────────── + // ── "No thanks" action ──────────────────────────────────────────────────── - it('"Opt out" saves opted-out preference to localStorage', () => { + it('"No thanks" saves opted-out preference to localStorage', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); render(); - fireEvent.click(screen.getByRole('button', { name: /opt out/i })); + fireEvent.click(screen.getByRole('button', { name: /no thanks/i })); expect(localStorage.getItem(SENTRY_KEY)).toBe('false'); }); - it('"Opt out" reloads the page', () => { + it('"No thanks" does not reload the page', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); render(); - fireEvent.click(screen.getByRole('button', { name: /opt out/i })); - expect(window.location.reload).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole('button', { name: /no thanks/i })); + expect(window.location.reload).not.toHaveBeenCalled(); }); }); diff --git a/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx b/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx index 17b4fd30d..337da655b 100644 --- a/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx +++ b/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx @@ -21,15 +21,15 @@ export function TelemetryConsentBanner() { if (!visible) return null; - const handleAcknowledge = () => { + const handleEnable = () => { localStorage.setItem(SENTRY_KEY, 'true'); - setDismissing(true); - dismissTimerRef.current = setTimeout(() => setVisible(false), 220); + window.location.reload(); }; - const handleOptOut = () => { + const handleDecline = () => { localStorage.setItem(SENTRY_KEY, 'false'); - window.location.reload(); + setDismissing(true); + dismissTimerRef.current = setTimeout(() => setVisible(false), 220); }; return ( @@ -38,14 +38,14 @@ export function TelemetryConsentBanner() { className={css.Banner} data-dismissing={dismissing} role="region" - aria-label="Crash reporting notice" + aria-label="Crash reporting prompt" >
- Crash reporting is enabled + Help improve Sable - Sable sends anonymous crash reports to help us fix bugs faster. No messages, room + Optionally send anonymous crash reports to help us fix bugs faster. No messages, room names, or personal data are included.{' '}
- -
diff --git a/src/app/features/settings/developer-tools/SentrySettings.tsx b/src/app/features/settings/developer-tools/SentrySettings.tsx index 542b15df7..b0425e76f 100644 --- a/src/app/features/settings/developer-tools/SentrySettings.tsx +++ b/src/app/features/settings/developer-tools/SentrySettings.tsx @@ -50,7 +50,7 @@ export function SentrySettings() { }; const isSentryConfigured = Boolean(import.meta.env.VITE_SENTRY_DSN); - const sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false'; + const sentryEnabled = localStorage.getItem('sable_sentry_enabled') === 'true'; const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; const isProd = environment === 'production'; const traceSampleRate = isProd ? '10%' : '100%'; diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 5b0ca4c1f..0392e47da 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -1081,7 +1081,7 @@ type GeneralProps = { function DiagnosticsAndPrivacy() { const [sentryEnabled, setSentryEnabled] = useState( - localStorage.getItem('sable_sentry_enabled') !== 'false' + localStorage.getItem('sable_sentry_enabled') === 'true' ); const [sessionReplayEnabled, setSessionReplayEnabled] = useState( localStorage.getItem('sable_sentry_replay_enabled') === 'true' diff --git a/src/instrument.ts b/src/instrument.ts index 47ff24165..3c6346b1d 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -24,8 +24,8 @@ const release = import.meta.env.VITE_APP_VERSION; let sessionErrorCount = 0; const SESSION_ERROR_LIMIT = 50; -// Default on: Sentry runs unless the user has opted out via the banner or Settings. -const sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false'; +// Default off: Sentry only runs when the user has opted in via the banner or Settings. +const sentryEnabled = localStorage.getItem('sable_sentry_enabled') === 'true'; const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true'; // Only initialize if DSN is provided and user hasn't opted out From f624c8d36be0c5181004dec93230dd703caa7c04 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 17 Mar 2026 09:34:28 -0400 Subject: [PATCH 2/5] feat(sentry): make consent banner non-dismissable --- .../TelemetryConsentBanner.test.tsx | 18 +----------------- .../TelemetryConsentBanner.tsx | 12 +----------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx b/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx index 896374db2..a1030889e 100644 --- a/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx +++ b/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx @@ -52,7 +52,7 @@ describe('TelemetryConsentBanner', () => { render(); expect(screen.getByRole('button', { name: /enable/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /no thanks/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument(); }); it('includes a link to the privacy policy', () => { @@ -77,22 +77,6 @@ describe('TelemetryConsentBanner', () => { expect(window.location.reload).toHaveBeenCalledOnce(); }); - // ── dismiss (✕) action ──────────────────────────────────────────────────── - - it('dismiss button (✕) saves opted-out preference to localStorage', () => { - vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); - render(); - fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); - expect(localStorage.getItem(SENTRY_KEY)).toBe('false'); - }); - - it('dismiss button does not reload the page', () => { - vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); - render(); - fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); - expect(window.location.reload).not.toHaveBeenCalled(); - }); - // ── "No thanks" action ──────────────────────────────────────────────────── it('"No thanks" saves opted-out preference to localStorage', () => { diff --git a/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx b/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx index 337da655b..5c1e90a08 100644 --- a/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx +++ b/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { Box, Button, Icon, IconButton, Icons, Text } from 'folds'; +import { Box, Button, Icon, Icons, Text } from 'folds'; import * as css from './TelemetryConsentBanner.css'; const SENTRY_KEY = 'sable_sentry_enabled'; @@ -56,16 +56,6 @@ export function TelemetryConsentBanner() { - - -