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
5 changes: 5 additions & 0 deletions .changeset/sentry-opt-in-crash-reporting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Add opt-in Sentry crash reporting with a consent banner.
2 changes: 1 addition & 1 deletion docs/PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

Expand Down
16 changes: 8 additions & 8 deletions docs/SENTRY_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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:

Expand Down Expand Up @@ -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**.

Expand Down Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/SENTRY_PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TelemetryConsentBanner />);
Expand All @@ -41,18 +41,18 @@ describe('TelemetryConsentBanner', () => {
it('renders the banner when DSN is configured and no preference is saved', () => {
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
render(<TelemetryConsentBanner />);
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 ─────────────────────────────────────────────────────────

it('has both action buttons visible', () => {
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
render(<TelemetryConsentBanner />);
expect(screen.getByRole('button', { name: /got it/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /opt out/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /enable/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /no thanks/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument();
});

it('includes a link to the privacy policy', () => {
Expand All @@ -61,51 +61,35 @@ 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(<TelemetryConsentBanner />);
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(<TelemetryConsentBanner />);
fireEvent.click(screen.getByRole('button', { name: /got it/i }));
expect(window.location.reload).not.toHaveBeenCalled();
});

// ── dismiss (✕) action ────────────────────────────────────────────────────

it('dismiss button (✕) saves opted-in preference to localStorage', () => {
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
render(<TelemetryConsentBanner />);
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
expect(localStorage.getItem(SENTRY_KEY)).toBe('true');
});

it('dismiss button does not reload the page', () => {
vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN);
render(<TelemetryConsentBanner />);
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
expect(window.location.reload).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /enable/i }));
expect(window.location.reload).toHaveBeenCalledOnce();
});

// ── "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(<TelemetryConsentBanner />);
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(<TelemetryConsentBanner />);
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();
});
});
36 changes: 13 additions & 23 deletions src/app/components/telemetry-consent/TelemetryConsentBanner.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
Expand All @@ -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"
>
<div className={css.Header}>
<Icon src={Icons.Shield} size="400" />
<div className={css.HeaderText}>
<Text size="H4">Crash reporting is enabled</Text>
<Text size="H4">Help improve Sable</Text>
<Text size="T300" priority="300">
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.{' '}
<a
href="https://github.com/SableClient/Sable/blob/dev/docs/PRIVACY.md"
Expand All @@ -56,23 +56,13 @@ export function TelemetryConsentBanner() {
</a>
</Text>
</div>
<IconButton
size="300"
variant="Surface"
fill="None"
radii="300"
onClick={handleAcknowledge}
aria-label="Dismiss"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</div>
<Box className={css.Actions}>
<Button variant="Secondary" fill="Soft" size="300" radii="300" onClick={handleOptOut}>
<Text size="B300">Opt out</Text>
<Button variant="Secondary" fill="Soft" size="300" radii="300" onClick={handleDecline}>
<Text size="B300">No thanks</Text>
</Button>
<Button variant="Primary" fill="Solid" size="300" radii="300" onClick={handleAcknowledge}>
<Text size="B300">Got it</Text>
<Button variant="Primary" fill="Solid" size="300" radii="300" onClick={handleEnable}>
<Text size="B300">Enable</Text>
</Button>
</Box>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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%';
Expand Down
2 changes: 1 addition & 1 deletion src/app/features/settings/general/General.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 2 additions & 2 deletions src/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading