From 3ce9e5414593250254342ce525db2762cdd19893 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 23:40:58 -0400 Subject: [PATCH 01/11] feat(sentry): initialize SDK, performance instrumentation, developer settings, and error capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Sentry error tracking, performance monitoring, and session replay: - Environment-based sampling: production 10%, preview/dev 100% - Session replay with full privacy masking (text, media, inputs) - Hashed MXID as anonymous user ID with homeserver tag - Error rate limiting (50 events/session) to protect quota Performance spans and metrics across all critical paths: auth login, message send, timeline jump-load, per-event decryption, sliding sync cycles, key backup, store wipe, UTD failures, sync degradation, background notification clients, media uploads. Developer settings panel (Settings → General → Diagnostics & Privacy): error reporting toggle, session replay opt-in, breadcrumb category controls, debug log export. Crash page shows Sentry event ID with one-click feedback dialog. Bug report modal sends structured reports to Sentry feedback API with optional debug log attachment. CI: Sentry env vars injected into preview (100% sampling) and production (10% sampling) builds. Source maps uploaded via @sentry/vite-plugin. --- .changeset/error_page_with_report.md | 5 + .changeset/feat-sentry-integration.md | 5 + .changeset/fix-direct-tab-double-badge.md | 5 - .changeset/fix-thread-fallback-reply-spec.md | 5 - .changeset/fix-timeline-refresh-scroll.md | 5 - .github/actions/prepare-tofu/action.yml | 10 +- .github/workflows/cloudflare-web-deploy.yml | 14 + Caddyfile | 4 +- contrib/nginx/cinny.domain.tld.conf | 3 + docs/SENTRY_INTEGRATION.md | 348 ++++++++++++++++++ docs/SENTRY_PRIVACY.md | 324 ++++++++++++++++ package.json | 2 + pnpm-lock.yaml | 265 +++++++++++++ src/app/components/DefaultErrorPage.tsx | 58 ++- src/app/components/DeviceVerification.tsx | 13 + .../features/bug-report/BugReportModal.tsx | 137 ++++++- src/app/features/room/RoomInput.tsx | 62 +--- src/app/features/room/RoomTimeline.tsx | 262 +++++++------ .../room/message/EncryptedContent.tsx | 18 +- .../settings/developer-tools/DevelopTools.tsx | 6 + .../developer-tools/SentrySettings.tsx | 240 ++++++++++++ src/app/hooks/useBlobCache.ts | 4 + src/app/hooks/useCallSignaling.ts | 31 +- src/app/hooks/useKeyBackup.ts | 10 + src/app/pages/App.tsx | 13 +- src/app/pages/Router.tsx | 32 +- src/app/pages/auth/login/loginUtil.ts | 92 +++-- .../pages/client/BackgroundNotifications.tsx | 4 + src/app/pages/client/ClientNonUIFeatures.tsx | 121 ++++++ src/app/pages/client/ClientRoot.tsx | 65 ++++ src/app/pages/client/SyncStatus.tsx | 13 + .../pages/client/sidebar/DirectDMsList.tsx | 74 +++- src/app/pages/client/sidebar/DirectTab.tsx | 12 +- .../client/sidebar/useSidebarDirectRoomIds.ts | 54 --- src/app/plugins/call/CallEmbed.ts | 29 +- src/app/state/callEmbed.ts | 16 + src/app/utils/debugLogger.ts | 189 ++++++++++ src/app/utils/matrix.ts | 18 +- src/app/utils/room.ts | 18 +- src/client/initMatrix.ts | 35 +- src/client/slidingSync.ts | 202 ++++++---- src/index.tsx | 1 + src/instrument.ts | 245 ++++++++++++ vite.config.ts | 21 ++ 44 files changed, 2641 insertions(+), 449 deletions(-) create mode 100644 .changeset/error_page_with_report.md create mode 100644 .changeset/feat-sentry-integration.md delete mode 100644 .changeset/fix-direct-tab-double-badge.md delete mode 100644 .changeset/fix-thread-fallback-reply-spec.md delete mode 100644 .changeset/fix-timeline-refresh-scroll.md create mode 100644 docs/SENTRY_INTEGRATION.md create mode 100644 docs/SENTRY_PRIVACY.md create mode 100644 src/app/features/settings/developer-tools/SentrySettings.tsx delete mode 100644 src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts create mode 100644 src/instrument.ts diff --git a/.changeset/error_page_with_report.md b/.changeset/error_page_with_report.md new file mode 100644 index 000000000..bb4832639 --- /dev/null +++ b/.changeset/error_page_with_report.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +added error page making it easier to report errors when they occur in the field diff --git a/.changeset/feat-sentry-integration.md b/.changeset/feat-sentry-integration.md new file mode 100644 index 000000000..ff50d8ff3 --- /dev/null +++ b/.changeset/feat-sentry-integration.md @@ -0,0 +1,5 @@ +--- +'default': minor +--- + +Add Sentry integration for error tracking and bug reporting diff --git a/.changeset/fix-direct-tab-double-badge.md b/.changeset/fix-direct-tab-double-badge.md deleted file mode 100644 index 6d0506363..000000000 --- a/.changeset/fix-direct-tab-double-badge.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -default: patch ---- - -Fix duplicate unread badges on the /direct/ icon for DM rooms already shown as individual sidebar avatars diff --git a/.changeset/fix-thread-fallback-reply-spec.md b/.changeset/fix-thread-fallback-reply-spec.md deleted file mode 100644 index 6eecf251f..000000000 --- a/.changeset/fix-thread-fallback-reply-spec.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -default: patch ---- - -Fix thread messages to include the required `m.in_reply_to` fallback pointing to the latest thread event, so unthreaded clients can display the reply chain correctly per the Matrix spec. diff --git a/.changeset/fix-timeline-refresh-scroll.md b/.changeset/fix-timeline-refresh-scroll.md deleted file mode 100644 index d042bd01e..000000000 --- a/.changeset/fix-timeline-refresh-scroll.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -default: patch ---- - -Fix spurious scroll-to-bottom and MaxListeners warnings on sync gap: stable callback refs and prevEventsLength guard in RoomTimeline, correct CallEmbed .bind(this) listener leak, stable refs in useCallSignaling, and unreadInfoRef to stop per-message listener churn diff --git a/.github/actions/prepare-tofu/action.yml b/.github/actions/prepare-tofu/action.yml index ba818c54c..c64332e3c 100644 --- a/.github/actions/prepare-tofu/action.yml +++ b/.github/actions/prepare-tofu/action.yml @@ -16,10 +16,16 @@ runs: steps: - name: Setup app and build uses: ./.github/actions/setup - env: - VITE_IS_RELEASE_TAG: ${{ inputs.is_release_tag }} with: build: 'true' + env: + VITE_IS_RELEASE_TAG: ${{ inputs.is_release_tag }} + VITE_SENTRY_DSN: ${{ env.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: ${{ env.VITE_SENTRY_ENVIRONMENT }} + VITE_APP_VERSION: ${{ env.VITE_APP_VERSION }} + SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ env.SENTRY_ORG }} + SENTRY_PROJECT: ${{ env.SENTRY_PROJECT }} - name: Setup OpenTofu uses: opentofu/setup-opentofu@9d84900f3238fab8cd84ce47d658d25dd008be2f # v1.0.8 diff --git a/.github/workflows/cloudflare-web-deploy.yml b/.github/workflows/cloudflare-web-deploy.yml index 3af285f73..413b7104a 100644 --- a/.github/workflows/cloudflare-web-deploy.yml +++ b/.github/workflows/cloudflare-web-deploy.yml @@ -56,6 +56,13 @@ jobs: uses: ./.github/actions/prepare-tofu with: is_release_tag: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.git_tag != '') }} + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: production + VITE_APP_VERSION: ${{ github.ref_name }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - name: Comment PR plan uses: dflook/tofu-plan@3f5dc358343fb58cd60f83b019e810315aa8258f # v2.2.3 @@ -82,6 +89,13 @@ jobs: uses: ./.github/actions/prepare-tofu with: is_release_tag: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.git_tag != '') }} + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: production + VITE_APP_VERSION: ${{ github.ref_name }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - name: Plan infrastructure run: tofu plan -input=false -no-color diff --git a/Caddyfile b/Caddyfile index d807e8c2b..97a13e732 100644 --- a/Caddyfile +++ b/Caddyfile @@ -15,4 +15,6 @@ } try_files {path} /index.html -} + + # Required for Sentry browser profiling (JS Self-Profiling API) + header Document-Policy "js-profiling" diff --git a/contrib/nginx/cinny.domain.tld.conf b/contrib/nginx/cinny.domain.tld.conf index 02c7ead9f..9dcdbb4b7 100644 --- a/contrib/nginx/cinny.domain.tld.conf +++ b/contrib/nginx/cinny.domain.tld.conf @@ -20,6 +20,9 @@ server { location / { root /opt/cinny/dist/; + # Required for Sentry browser profiling (JS Self-Profiling API) + add_header Document-Policy "js-profiling" always; + rewrite ^/config.json$ /config.json break; rewrite ^/manifest.json$ /manifest.json break; diff --git a/docs/SENTRY_INTEGRATION.md b/docs/SENTRY_INTEGRATION.md new file mode 100644 index 000000000..3227e211d --- /dev/null +++ b/docs/SENTRY_INTEGRATION.md @@ -0,0 +1,348 @@ +# Sentry Integration for Sable + +This document describes the Sentry error tracking and monitoring integration added to Sable. +For a detailed breakdown of what data is collected and how it is protected, see [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md). + +## Overview + +Sentry is integrated with Sable to provide: + +- **Error tracking**: Automatic capture and reporting of errors and exceptions +- **Performance monitoring**: Track application performance and identify bottlenecks +- **User feedback**: Collect bug reports with context from users +- **Session replay**: Record user sessions (with privacy controls) for debugging +- **Breadcrumbs**: Track user actions leading up to errors +- **Debug log integration**: Attach internal debug logs to error reports + +## Features + +### 1. Automatic Error Tracking + +All errors are automatically captured and sent to Sentry with: + +- Stack traces +- User context (anonymized) +- Device and browser information +- Recent breadcrumbs (user actions) +- Debug logs (when enabled) + +### 2. Debug Logger Integration + +The internal debug logger now integrates with Sentry: + +- **Breadcrumbs**: All debug logs are added as breadcrumbs for context +- **Error capture**: Errors logged to the debug logger are automatically sent to Sentry +- **Warning sampling**: 10% of warnings are sent to Sentry to avoid overwhelming the system +- **Log attachment**: Recent logs can be attached to bug reports for additional context + +Key integration points: + +- `src/app/utils/debugLogger.ts` - Enhanced with Sentry breadcrumb and error capture +- Automatic breadcrumb creation for all log entries +- Error objects in log data are captured as exceptions +- 10% sampling rate for warnings to control volume + +### 3. Bug Report Modal Integration + +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 + +Integration points: + +- `src/app/features/bug-report/BugReportModal.tsx` - Added Sentry options and submission logic +- Automatically attaches platform info, version, and user agent +- Links bug reports to Sentry events for tracking + +### 4. Privacy & Security + +Comprehensive data scrubbing (full details in [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md)): + +- **Token masking**: All access tokens, passwords, and authentication data are redacted +- **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 + +Sensitive patterns automatically redacted: + +- `access_token`, `password`, `token`, `refresh_token` +- `session_id`, `sync_token`, `next_batch` +- Matrix user IDs (`@user:server`) +- Matrix room IDs (`!room:server`) +- Matrix event IDs (`$event_id`) + +### 5. Settings UI + +New Sentry settings panel in Developer Tools: + +- **Enable/disable Sentry**: Toggle error tracking on/off +- **Session replay control**: Enable/disable session recording (opt-in) +- **Breadcrumb categories**: Granular control over which log categories are sent as breadcrumbs +- **Session stats**: Live error/warning counts for the current page load +- **Export debug logs**: Download the in-memory log buffer as JSON for offline analysis +- **Attach debug logs**: Manually attach recent logs to next error + +Access via: Settings → Developer Tools → Error Tracking (Sentry) + +## Configuration + +### Environment Variables + +Configure Sentry via environment variables: + +```env +# Required: Your Sentry DSN (if not set, Sentry is disabled) +VITE_SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id + +# Required: Environment name - controls sampling rates +# - "production" = 10% trace/replay sampling (cost-effective for production) +# - "preview" = 100% trace/replay sampling (full debugging for PR previews) +# - "development" = 100% trace/replay sampling (full debugging for local dev) +VITE_SENTRY_ENVIRONMENT=production + +# Optional: Release version for tracking (defaults to VITE_APP_VERSION) +VITE_SENTRY_RELEASE=1.7.0 + +# Optional: For uploading source maps to Sentry (CI/CD only) +SENTRY_AUTH_TOKEN=your-sentry-auth-token +SENTRY_ORG=your-org-slug +SENTRY_PROJECT=your-project-slug +``` + +### Deployment Configuration + +**Production deployment (from `dev` branch):** + +- Set `VITE_SENTRY_ENVIRONMENT=production` +- Gets 10% sampling for traces and session replay +- Cost-effective for production usage +- Configured in `.github/workflows/cloudflare-web-deploy.yml` + +**Preview deployments (PR previews, Cloudflare Pages):** + +- Set `VITE_SENTRY_ENVIRONMENT=preview` +- Gets 100% sampling for traces and session replay +- Full debugging capabilities for testing +- Configured in `.github/workflows/cloudflare-web-preview.yml` + +**Local development:** + +- `VITE_SENTRY_ENVIRONMENT` not set (defaults to `development` via Vite MODE) +- Gets 100% sampling for traces and session replay +- Full debugging capabilities + +**Sampling rates by environment:** + +``` +Environment | Traces | Profiles | Session Replay | Error Replay +---------------|--------|----------|----------------|------------- +production | 10% | 10% | 10% | 100% +preview | 100% | 100% | 100% | 100% +development | 100% | 100% | 100% | 100% +``` + +> **Browser profiling requires a `Document-Policy: js-profiling` response header** on your HTML document. +> This is already included in the provided `Caddyfile` and nginx config. For other servers, add the header to +> the response serving `index.html`. + +### User Preferences + +Users can control Sentry via localStorage: + +```javascript +// Disable Sentry entirely (requires page refresh) +localStorage.setItem('sable_sentry_enabled', 'false'); + +// Disable session replay only (requires page refresh) +localStorage.setItem('sable_sentry_replay_enabled', 'false'); +``` + +Or use the UI in Settings → Developer Tools → Error Tracking (Sentry). + +## Custom Instrumentation + +Beyond automatic error capture, Sable has hand-crafted monitoring at key +lifecycle points. See [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md) for the full +metrics reference. Key areas: + +| Area | What's tracked | +|------|----------------| +| **Auth** | Login failures (by `errcode`), forced server logouts | +| **Sync** | Transport type, degraded states, cycle stats, time-to-ready | +| **Cryptography** | Decryption failures (by failure reason), key backup errors, store wipes, E2E verification outcomes | +| **Messaging** | Send latency, send errors, local-echo `NOT_SENT` events | +| **Timeline** | Opens, virtual window size, jump-load latency, re-initialisations | +| **Media** | Upload latency, upload size, cache stats | +| **Background clients** | Per-account notification client count, startup failures | + +Fatal errors that are caught by `useAsyncCallback` state (and therefore never +reach React's ErrorBoundary) are explicitly forwarded with `captureException`: + +- Client load failure (`phase: load`) +- Client start failure (`phase: start`) +- Background notification client startup failure + +## Implementation Details + +### Files Modified + +1. **`src/instrument.ts`** + - Enhanced Sentry initialization with privacy controls + - Added user preference checks + - Improved data scrubbing for Matrix-specific data + - Conditional session replay based on user settings + +2. **`src/app/utils/debugLogger.ts`** + - Added Sentry import + - New `sendToSentry()` method for breadcrumbs and error capture + - New `exportLogsForSentry()` method + - New `attachLogsToSentry()` method + - Integrated into main `log()` method + +3. **`src/app/features/bug-report/BugReportModal.tsx`** + - Added Sentry and debug logger imports + - New state for Sentry options (`sendToSentry`, `includeDebugLogs`) + - Enhanced `handleSubmit()` with Sentry user feedback + - New UI checkboxes for Sentry options + +4. **`src/app/features/settings/developer-tools/SentrySettings.tsx`** _(new file)_ + - New settings panel component + - Controls for Sentry and session replay + - Manual log attachment + +5. **`src/app/features/settings/developer-tools/DevelopTools.tsx`** + - Added SentrySettings import and component + +### Sentry Configuration + +- **Tracing sample rate**: 100% in development, 10% in production +- **Session replay sample rate**: 10% of all sessions, 100% of error sessions +- **Warning capture rate**: 10% to avoid overwhelming Sentry +- **Breadcrumb retention**: All breadcrumbs retained for context +- **Log attachment limit**: Last 100 debug log entries + +### Performance Considerations + +- Breadcrumbs are added synchronously but are low-overhead +- Error capture is asynchronous and non-blocking +- Warning sampling (10%) prevents excessive Sentry usage +- Session replay only captures when enabled by user +- Debug log attachment limited to most recent entries + +## Usage Examples + +### For Developers + +```typescript +import { getDebugLogger } from '$utils/debugLogger'; + +// Errors are automatically sent to Sentry +const logger = createDebugLogger('myNamespace'); +logger.error('sync', 'Sync failed', error); // Sent to Sentry + +// Manually attach logs before capturing an error +const debugLogger = getDebugLogger(); +debugLogger.attachLogsToSentry(100); +Sentry.captureException(error); +``` + +### For Users + +1. **Report a bug with Sentry**: + - Type `/bugreport` or click "Bug Report" button + - Fill in the form + - Check "Send anonymous report to Sentry" + - Check "Include recent debug logs" for more context + - Submit + +2. **Disable Sentry**: + - Go to Settings → Developer Tools + - Enable Developer Tools + - Scroll to "Error Tracking (Sentry)" + - Toggle off "Enable Sentry Error Tracking" + - Refresh the page + +## Benefits + +### For Users + +- Better bug tracking and faster fixes +- Optional participation with privacy controls +- Transparent data usage + +### For Developers + +- Real-time error notifications +- Rich context with breadcrumbs and logs +- Performance monitoring +- User feedback integrated with errors +- Replay sessions to reproduce bugs + +## Privacy Commitment + +See [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md) for a complete, code-linked breakdown of what is collected, what is masked, and how user controls work. + +In summary, all data sent to Sentry is: + +- **Opt-in by default** but can be disabled +- **Anonymized**: No personal data or message content +- **Filtered**: Tokens, passwords, and IDs are redacted +- **Minimal**: Only error context and debug info +- **Transparent**: Users can see what's being sent + +No message content, room conversations, or personal information is ever sent to Sentry. + +## Testing + +To test the integration: + +1. **Test error reporting**: + - Go to Settings → Developer Tools → Error Tracking + - Check that Sentry is enabled and `VITE_SENTRY_DSN` is set + - Open the browser console and run: `window.Sentry?.captureMessage('Test message')` + - Check the Sentry dashboard for the event + +2. **Test bug report integration**: + - Type `/bugreport` + - Fill in form with test data + - Enable "Send anonymous report to Sentry" + - Submit and check Sentry + +3. **Test privacy controls**: + - Disable Sentry in settings + - Refresh page + - Trigger an error (should not appear in Sentry) + - Re-enable and verify errors are captured again + +## Troubleshooting + +### Sentry not capturing errors + +1. Check that `VITE_SENTRY_DSN` is set +2. Check that Sentry is enabled in settings +3. Check browser console for Sentry initialization message +4. Verify network requests to Sentry are not blocked + +### Sensitive data in reports + +1. Check `beforeSend` hook in `instrument.ts` +2. Add new patterns to the scrubbing regex +3. Test with actual data to verify masking + +### Performance impact + +1. Reduce tracing sample rate in production +2. Disable session replay if not needed +3. Monitor Sentry quota usage +4. Adjust warning sampling rate + +## Resources + +- [Sentry React Documentation](https://docs.sentry.io/platforms/javascript/guides/react/) +- [Sentry Error Monitoring Best Practices](https://docs.sentry.io/product/error-monitoring/) +- [Sentry Session Replay](https://docs.sentry.io/product/session-replay/) +- [Sentry User Feedback](https://docs.sentry.io/product/user-feedback/) diff --git a/docs/SENTRY_PRIVACY.md b/docs/SENTRY_PRIVACY.md new file mode 100644 index 000000000..93877a7de --- /dev/null +++ b/docs/SENTRY_PRIVACY.md @@ -0,0 +1,324 @@ +# Sentry Privacy Policy + +This document describes exactly what data the Sentry integration collects, what +is masked or blocked, and where the relevant code lives. For setup and +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 → Developer Tools → Error Tracking. + +When enabled, the following categories of data are sent: + +### Error Reports + +- Exception type and stack trace (function names, file names, line numbers) +- Error message text — scrubbed of tokens and Matrix IDs before sending (see + [What Is Scrubbed](#what-is-scrubbed)) +- Browser and OS name/version +- JavaScript engine version +- Application release version (`VITE_APP_VERSION`) +- Sentry environment tag (`VITE_SENTRY_ENVIRONMENT`) +- Current URL path — tokens in query strings are redacted before sending + +**Code:** `src/instrument.ts` — `beforeSend` callback + +### Breadcrumbs (Action Trail) + +Leading up to an error, Sentry records a trail of recent user actions: + +- Navigation events (route changes) +- `console.error` and `console.warn` calls — filtered for sensitive patterns + before sending +- Internal debug log entries (category, level, summary message) — filtered + before sending + +Breadcrumbs containing any of the patterns listed in +[What Is Scrubbed](#what-is-scrubbed) are sanitised in-place before leaving the +browser. + +**Code:** `src/instrument.ts` — `beforeBreadcrumb` callback +**Code:** `src/app/utils/debugLogger.ts` — Sentry breadcrumb integration + +### Application Breadcrumbs + +In addition to automatic navigation/console breadcrumbs, the following named +events are explicitly recorded as breadcrumbs: + +| Event | Category | Level | Source | +|-------|----------|-------|--------| +| Session forcibly logged out by server | `auth` | warning | `ClientRoot.tsx` | +| Sync state changed to Reconnecting/Error | `sync` | warning/error | `SyncStatus.tsx` | +| Sliding sync first run completed | `sync` | info | `initMatrix.ts` | +| Crypto store mismatch — wiping local stores | `crypto` | warning | `initMatrix.ts` | +| Key backup failed | `crypto` | error | `useKeyBackup.ts` | +| High media inflight request count | `media` | warning | `ClientNonUIFeatures.tsx` | + +**Code:** `src/app/pages/client/ClientRoot.tsx`, `src/app/pages/client/SyncStatus.tsx`, +`src/client/initMatrix.ts`, `src/app/hooks/useKeyBackup.ts`, +`src/app/pages/client/ClientNonUIFeatures.tsx` + +### Component Error Capture + +The following failure paths use explicit `captureException` because they are +caught by state management hooks and never propagate to React's ErrorBoundary: + +| Failure | Tag | Source | +|---------|-----|--------| +| Client failed to load (fetch/init) | `phase: load` | `ClientRoot.tsx` | +| Client failed to start (sync start) | `phase: start` | `ClientRoot.tsx` | +| Background notification client failed to start | `component: BackgroundNotifications` | `BackgroundNotifications.tsx` | + +**Code:** `src/app/pages/client/ClientRoot.tsx`, +`src/app/pages/client/BackgroundNotifications.tsx` + +### Performance Traces + +- Timing of React Router navigations (page-load and route-change latency) +- Custom spans for Matrix sync cycles, message send, and room data loading +- JavaScript CPU profiles during traced transactions (call-stack samples) + +Performance data contains **no message content, no room names, and no user +identifiers**. Spans are labelled with operation names only. + +| Span name | Operation | Source | +|-----------|-----------|--------| +| `auth.login` | `auth` | `loginUtil.ts` | +| `decrypt.event` | `matrix.crypto` | `EncryptedContent.tsx` | +| `decrypt.bulk` | `matrix.crypto` | `room.ts` | +| `timeline.jump_load` | `matrix.timeline` | `RoomTimeline.tsx` | +| `message.send` | `matrix.message` | `RoomInput.tsx` | +| Sliding sync processing | `matrix.sync` | `slidingSync.ts` | + +**Sample rates:** + +| Environment | Traces | Profiles | +|---------------------|--------|----------| +| `production` | 10% | 10% | +| `preview` / `development` | 100% | 100% | + +**Code:** `src/instrument.ts` — `tracesSampleRate`, `profilesSampleRate` +**Code:** `src/app/features/room/RoomInput.tsx` — message send span +**Code:** `src/app/utils/room.ts`, `src/client/slidingSync.ts` — room/sync spans + +### Custom Metrics + +All metrics contain no message content, room names, or user identifiers. +Attribute values are limited to short enumerated strings (error codes, states) +or numeric measurements. + +#### Authentication + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.auth.login_failed` | count | `errcode` | Login attempt failures by error code | + +**Code:** `src/app/pages/auth/login/loginUtil.ts` + +#### Cryptography + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.decryption.failure` | count | `reason` | Unable-to-decrypt events by failure reason | +| `sable.decryption.event_ms` | distribution | — | Per-event decryption latency | +| `sable.decryption.bulk_latency_ms` | distribution | `event_count` | Bulk re-decryption time on room open | +| `sable.crypto.key_backup_failures` | count | `errcode` | Key backup errors by code | +| `sable.crypto.store_wipe` | count | — | Crypto store mismatch wipe-and-retry occurrences | +| `sable.crypto.verification_outcome` | count | `outcome` (`completed`/`cancelled`) | E2E device verification outcomes | + +**Code:** `src/app/features/room/message/EncryptedContent.tsx`, +`src/app/utils/room.ts`, `src/app/hooks/useKeyBackup.ts`, +`src/client/initMatrix.ts`, `src/app/components/DeviceVerification.tsx` + +#### Messaging + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.message.send_latency_ms` | distribution | `encrypted` | Message send round-trip time | +| `sable.message.send_error` | count | — | Send errors from message composer | +| `sable.message.send_failed` | count | — | Local-echo `NOT_SENT` status events | + +**Code:** `src/app/features/room/RoomInput.tsx`, +`src/app/features/room/RoomTimeline.tsx` + +#### Timeline + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.timeline.open` | count | `mode` | Timeline render initiations | +| `sable.timeline.render_window` | distribution | `mode` | Initial virtual window size | +| `sable.timeline.jump_load_ms` | distribution | — | Event-jump timeline load latency | +| `sable.timeline.reinit` | count | — | Full timeline re-initialisations | +| `sable.pagination.error` | count | `direction` | Pagination errors by direction | + +**Code:** `src/app/features/room/RoomTimeline.tsx` + +#### Sync + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.sync.transport` | count | `type` (`sliding`/`classic`) | Sync transport type used | +| `sable.sync.cycle` | count | (various) | Completed sliding sync cycles | +| `sable.sync.error` | count | `errcode` | Sliding sync errors | +| `sable.sync.initial_ms` | distribution | — | Initial sync completion time | +| `sable.sync.processing_ms` | distribution | — | Per-cycle sync processing time | +| `sable.sync.lists_loaded_ms` | distribution | — | Time for room lists to fully load | +| `sable.sync.total_rooms` | gauge | `sync_type` | Total rooms known at list load | +| `sable.sync.active_subscriptions` | gauge | — | Active room subscription count | +| `sable.sync.client_ready_ms` | distribution | `type` | Time from init to client ready | +| `sable.sync.time_to_ready_ms` | distribution | — | Wall-clock time to first sync ready | +| `sable.sync.degraded` | count | `state` | Sync reconnect/error state transitions | + +**Code:** `src/client/initMatrix.ts`, `src/client/slidingSync.ts`, +`src/app/pages/client/ClientRoot.tsx`, `src/app/pages/client/SyncStatus.tsx` + +#### Media + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.media.upload_latency_ms` | distribution | `mimetype` | Media upload round-trip time | +| `sable.media.upload_bytes` | distribution | `mimetype` | Upload size distribution | +| `sable.media.upload_error` | count | `reason` | Upload failures by reason | +| `sable.media.blob_cache_size` | gauge | — | Blob URL cache entry count | +| `sable.media.inflight_requests` | gauge | — | Concurrent media requests | + +**Code:** `src/app/utils/matrix.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx` + +#### Background clients & debug telemetry + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.background.client_count` | gauge | — | Active background notification clients | +| `sable.errors` | count | `category` | Error-level debug log entries | +| `sable.warnings` | count | `category` | Warning-level debug log entries | + +**Code:** `src/app/pages/client/BackgroundNotifications.tsx`, +`src/app/utils/debugLogger.ts` + +### Session Replay *(opt-in, disabled by default)* + +When session replay is explicitly enabled by the user, Sentry records UI +interactions to help reproduce bugs. **All content is masked at the browser +level before any data leaves the device:** + +- All text on screen → replaced with `█` characters +- All images, video, and audio → blocked entirely (replaced with a grey box) +- All form inputs, including the message composer → replaced with `*` characters + +This means **no Matrix messages, no room names, no user display names, and no +media are ever visible in a replay**. + +Sample rates for replay: + +| Trigger | Production | Preview / Dev | +|-----------------------|------------|---------------| +| Regular sessions | 10% | 100% | +| Sessions with errors | 100% | 100% | + +**Code:** `src/instrument.ts` — `replayIntegration` call with `maskAllText`, +`blockAllMedia`, `maskAllInputs` + +### Bug Reports *(manual, opt-in per report)* + +When a user submits a bug report via `/bugreport` or the "Bug Report" button: + +- Free-text description written by the user +- Optional: recent debug log entries (last 100) attached as a file +- Platform info, browser version, application version +- Checkbox to send or not send to Sentry is **shown before submission** + +**Code:** `src/app/features/bug-report/BugReportModal.tsx` + +--- + +## What Is Never Collected + +- Matrix message content +- Room names or aliases +- User display names or avatars +- Contact lists or room member lists +- Encryption keys or session data +- IP addresses (`sendDefaultPii: false`) +- Authentication tokens (scrubbed — see below) + +--- + +## What Is Scrubbed + +All scrubbing happens **in the browser before data is transmitted**. Nothing +leaves the device in unredacted form. + +### Tokens and Credentials + +The following patterns are replaced with `[REDACTED]` in error messages, +exception values, breadcrumb messages, and request URLs: + +- `access_token` +- `password` +- `token` +- `refresh_token` +- `session_id` +- `sync_token` +- `next_batch` +- HTTP `Authorization` headers + +**Code:** `src/instrument.ts` — `beforeSend` and `beforeBreadcrumb` callbacks +Regex: `/(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:]\s*)([^\s&]+)/gi` + +### Matrix Identifiers + +Matrix IDs are replaced with placeholder tokens before sending: + +| Original form | Replaced with | +|-------------------|---------------| +| `@user:server` | `@[USER_ID]` | +| `!room:server` | `![ROOM_ID]` | +| `$event_id` | `$[EVENT_ID]` | + +**Code:** `src/instrument.ts` — `beforeSend` callback (applied to `event.message` +and all `event.exception.values`) + +--- + +## User Controls + +Users can adjust Sentry behaviour without restarting the app: + +| Setting | Location | `localStorage` key | Default | +|---------|----------|--------------------|---------| +| Disable Sentry entirely | Settings → Developer Tools → Error Tracking | `sable_sentry_enabled` | Enabled | +| Enable session replay | Settings → Developer Tools → Error Tracking | `sable_sentry_replay_enabled` | Disabled (opt-in) | +| Disable breadcrumb categories | Settings → Developer Tools → Error Tracking → Breadcrumb Categories | `sable_sentry_breadcrumb_disabled` | All enabled | + +**Rate limiting:** A maximum of 50 error events are forwarded to Sentry per page load (session). +Subsequent errors are silently dropped, protecting against quota exhaustion without affecting +in-app behaviour. Performance traces are not subject to this cap. + +Changes to Sentry enable/disable and session replay take effect after the next page refresh +(the SDK is initialised once at startup). Breadcrumb category changes take effect immediately. + +**Code:** `src/instrument.ts` — reads `localStorage` before `Sentry.init()`, enforces rate limit in `beforeSend` +**Code:** `src/app/features/settings/developer-tools/SentrySettings.tsx` — settings UI +**Code:** `src/app/utils/debugLogger.ts` — per-category breadcrumb filtering and session stats + +--- + +## Data Residency + +Sentry data is sent to the Sentry.io cloud service. The destination project is +configured by the operator via `VITE_SENTRY_DSN`. Self-hosted Sentry instances +are supported by changing the DSN. + +When `VITE_SENTRY_DSN` is not set, the integration is entirely inactive — no +code path in the Sentry SDK is reached and no data is transmitted. + +--- + +## Further Reading + +- [SENTRY_INTEGRATION.md](./SENTRY_INTEGRATION.md) — setup, configuration, environment variables, and deployment instructions +- [Sentry Privacy Policy](https://sentry.io/privacy/) — Sentry's own data handling commitments +- [Sentry Session Replay privacy documentation](https://docs.sentry.io/product/explore/session-replay/privacy/) — details on masking and blocking behaviour diff --git a/package.json b/package.json index 371e7a975..0be77a812 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.4.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@fontsource-variable/nunito": "5.2.7", + "@sentry/react": "^10.43.0", "@fontsource/space-mono": "5.2.9", "@tanstack/react-query": "^5.90.21", "@tanstack/react-query-devtools": "^5.91.3", @@ -95,6 +96,7 @@ "@eslint/js": "9.39.3", "@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-wasm": "^6.2.2", + "@sentry/vite-plugin": "^5.1.1", "@types/chroma-js": "^3.1.2", "@types/file-saver": "^2.0.7", "@types/is-hotkey": "^0.1.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0d64afd3..1f76fbe40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: '@fontsource/space-mono': specifier: 5.2.9 version: 5.2.9 + '@sentry/react': + specifier: ^10.43.0 + version: 10.43.0(react@18.3.1) '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@18.3.1) @@ -218,6 +221,9 @@ importers: '@rollup/plugin-wasm': specifier: ^6.2.2 version: 6.2.2(rollup@4.59.0) + '@sentry/vite-plugin': + specifier: ^5.1.1 + version: 5.1.1(rollup@4.59.0) '@types/chroma-js': specifier: ^3.1.2 version: 3.1.2 @@ -2322,6 +2328,106 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@sentry-internal/browser-utils@10.43.0': + resolution: {integrity: sha512-8zYTnzhAPvNkVH1Irs62wl0J/c+0QcJ62TonKnzpSFUUD3V5qz8YDZbjIDGfxy+1EB9fO0sxtddKCzwTHF/MbQ==} + engines: {node: '>=18'} + + '@sentry-internal/feedback@10.43.0': + resolution: {integrity: sha512-YoXuwluP6eOcQxTeTtaWb090++MrLyWOVsUTejzUQQ6LFL13Jwt+bDPF1kvBugMq4a7OHw/UNKQfd6//rZMn2g==} + engines: {node: '>=18'} + + '@sentry-internal/replay-canvas@10.43.0': + resolution: {integrity: sha512-ZIw1UNKOFXo1LbPCJPMAx9xv7D8TMZQusLDUgb6BsPQJj0igAuwd7KRGTkjjgnrwBp2O/sxcQFRhQhknWk7QPg==} + engines: {node: '>=18'} + + '@sentry-internal/replay@10.43.0': + resolution: {integrity: sha512-khCXlGrlH1IU7P5zCEAJFestMeH97zDVCekj8OsNNDtN/1BmCJ46k6Xi0EqAUzdJgrOLJeLdoYdgtiIjovZ8Sg==} + engines: {node: '>=18'} + + '@sentry/babel-plugin-component-annotate@5.1.1': + resolution: {integrity: sha512-x2wEpBHwsTyTF2rWsLKJlzrRF1TTIGOfX+ngdE+Yd5DBkoS58HwQv824QOviPGQRla4/ypISqAXzjdDPL/zalg==} + engines: {node: '>= 18'} + + '@sentry/browser@10.43.0': + resolution: {integrity: sha512-2V3I3sXi3SMeiZpKixd9ztokSgK27cmvsD9J5oyOyjhGLTW/6QKCwHbKnluMgQMXq20nixQk5zN4wRjRUma3sg==} + engines: {node: '>=18'} + + '@sentry/bundler-plugin-core@5.1.1': + resolution: {integrity: sha512-F+itpwR9DyQR7gEkrXd2tigREPTvtF5lC8qu6e4anxXYRTui1+dVR0fXNwjpyAZMhIesLfXRN7WY7ggdj7hi0Q==} + engines: {node: '>= 18'} + + '@sentry/cli-darwin@2.58.5': + resolution: {integrity: sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ==} + engines: {node: '>=10'} + os: [darwin] + + '@sentry/cli-linux-arm64@2.58.5': + resolution: {integrity: sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux, freebsd, android] + + '@sentry/cli-linux-arm@2.58.5': + resolution: {integrity: sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux, freebsd, android] + + '@sentry/cli-linux-i686@2.58.5': + resolution: {integrity: sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [linux, freebsd, android] + + '@sentry/cli-linux-x64@2.58.5': + resolution: {integrity: sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux, freebsd, android] + + '@sentry/cli-win32-arm64@2.58.5': + resolution: {integrity: sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@sentry/cli-win32-i686@2.58.5': + resolution: {integrity: sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [win32] + + '@sentry/cli-win32-x64@2.58.5': + resolution: {integrity: sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@sentry/cli@2.58.5': + resolution: {integrity: sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg==} + engines: {node: '>= 10'} + hasBin: true + + '@sentry/core@10.43.0': + resolution: {integrity: sha512-l0SszQAPiQGWl/ferw8GP3ALyHXiGiRKJaOvNmhGO+PrTQyZTZ6OYyPnGijAFRg58dE1V3RCH/zw5d2xSUIiNg==} + engines: {node: '>=18'} + + '@sentry/react@10.43.0': + resolution: {integrity: sha512-shvErEpJ41i0Q3lIZl0CDWYQ7m8yHLi7ECG0gFvN8zf8pEdl5grQIOoe3t/GIUzcpCcor16F148ATmKJJypc/Q==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x + + '@sentry/rollup-plugin@5.1.1': + resolution: {integrity: sha512-1d5NkdRR6aKWBP7czkY8sFFWiKnfmfRpQOj+m9bJTsyTjbMiEQJst6315w5pCVlRItPhBqpAraqAhutZFgvyVg==} + engines: {node: '>= 18'} + peerDependencies: + rollup: '>=4.59.0' + + '@sentry/vite-plugin@5.1.1': + resolution: {integrity: sha512-i6NWUDi2SDikfSUeMJvJTRdwEKYSfTd+mvBO2Ja51S1YK+hnickBuDfD+RvPerIXLuyRu3GamgNPbNqgCGUg/Q==} + engines: {node: '>= 18'} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -2799,6 +2905,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -3174,6 +3284,10 @@ packages: dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3632,6 +3746,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -3717,6 +3835,10 @@ packages: htmlparser2@9.0.0: resolution: {integrity: sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} @@ -4341,9 +4463,16 @@ packages: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -7484,6 +7613,117 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@sentry-internal/browser-utils@10.43.0': + dependencies: + '@sentry/core': 10.43.0 + + '@sentry-internal/feedback@10.43.0': + dependencies: + '@sentry/core': 10.43.0 + + '@sentry-internal/replay-canvas@10.43.0': + dependencies: + '@sentry-internal/replay': 10.43.0 + '@sentry/core': 10.43.0 + + '@sentry-internal/replay@10.43.0': + dependencies: + '@sentry-internal/browser-utils': 10.43.0 + '@sentry/core': 10.43.0 + + '@sentry/babel-plugin-component-annotate@5.1.1': {} + + '@sentry/browser@10.43.0': + dependencies: + '@sentry-internal/browser-utils': 10.43.0 + '@sentry-internal/feedback': 10.43.0 + '@sentry-internal/replay': 10.43.0 + '@sentry-internal/replay-canvas': 10.43.0 + '@sentry/core': 10.43.0 + + '@sentry/bundler-plugin-core@5.1.1': + dependencies: + '@babel/core': 7.29.0 + '@sentry/babel-plugin-component-annotate': 5.1.1 + '@sentry/cli': 2.58.5 + dotenv: 16.6.1 + find-up: 5.0.0 + glob: 13.0.6 + magic-string: 0.30.21 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/cli-darwin@2.58.5': + optional: true + + '@sentry/cli-linux-arm64@2.58.5': + optional: true + + '@sentry/cli-linux-arm@2.58.5': + optional: true + + '@sentry/cli-linux-i686@2.58.5': + optional: true + + '@sentry/cli-linux-x64@2.58.5': + optional: true + + '@sentry/cli-win32-arm64@2.58.5': + optional: true + + '@sentry/cli-win32-i686@2.58.5': + optional: true + + '@sentry/cli-win32-x64@2.58.5': + optional: true + + '@sentry/cli@2.58.5': + dependencies: + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + which: 2.0.2 + optionalDependencies: + '@sentry/cli-darwin': 2.58.5 + '@sentry/cli-linux-arm': 2.58.5 + '@sentry/cli-linux-arm64': 2.58.5 + '@sentry/cli-linux-i686': 2.58.5 + '@sentry/cli-linux-x64': 2.58.5 + '@sentry/cli-win32-arm64': 2.58.5 + '@sentry/cli-win32-i686': 2.58.5 + '@sentry/cli-win32-x64': 2.58.5 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/core@10.43.0': {} + + '@sentry/react@10.43.0(react@18.3.1)': + dependencies: + '@sentry/browser': 10.43.0 + '@sentry/core': 10.43.0 + react: 18.3.1 + + '@sentry/rollup-plugin@5.1.1(rollup@4.59.0)': + dependencies: + '@sentry/bundler-plugin-core': 5.1.1 + magic-string: 0.30.21 + rollup: 4.59.0 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/vite-plugin@5.1.1(rollup@4.59.0)': + dependencies: + '@sentry/bundler-plugin-core': 5.1.1 + '@sentry/rollup-plugin': 5.1.1(rollup@4.59.0) + transitivePeerDependencies: + - encoding + - rollup + - supports-color + '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.14': {} @@ -7991,6 +8231,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -8372,6 +8618,8 @@ snapshots: no-case: 3.0.4 tslib: 2.8.1 + dotenv@16.6.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -9014,6 +9262,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.2 + glob@13.0.6: + dependencies: + minimatch: 10.2.4 + minipass: 7.1.3 + path-scurry: 2.0.2 + globals@14.0.0: {} globals@15.15.0: {} @@ -9106,6 +9360,13 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + i18next-browser-languagedetector@8.2.1: dependencies: '@babel/runtime': 7.28.6 @@ -9724,12 +9985,16 @@ snapshots: prismjs@1.30.0: {} + progress@2.0.3: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} diff --git a/src/app/components/DefaultErrorPage.tsx b/src/app/components/DefaultErrorPage.tsx index edf8acf5f..54ac642ea 100644 --- a/src/app/components/DefaultErrorPage.tsx +++ b/src/app/components/DefaultErrorPage.tsx @@ -1,9 +1,12 @@ import { Box, Button, Dialog, Icon, Icons, Text, color, config } from 'folds'; +import * as Sentry from '@sentry/react'; import { SplashScreen } from '$components/splash-screen'; import { buildGitHubUrl } from '$features/bug-report/BugReportModal'; type ErrorPageProps = { error: Error; + /** Sentry event ID — present when Sentry.ErrorBoundary captured the crash */ + eventId?: string; }; function createIssueUrl(error: Error): string { @@ -29,7 +32,9 @@ ${stacktrace} // It provides a user-friendly error message and options to report the issue or reload the page. // Motivation of the design is to encourage users to report issues while also providing them with the necessary information to do so, and to give them an easy way to recover by reloading the page. // Note: Since this component is rendered in response to an error, it should be as resilient as possible and avoid any complex logic or dependencies that could potentially throw additional errors. -export function ErrorPage({ error }: ErrorPageProps) { +export function ErrorPage({ error, eventId }: ErrorPageProps) { + const sentryEnabled = Sentry.isInitialized(); + const reportedToSentry = sentryEnabled && !!eventId; return ( @@ -52,20 +57,45 @@ export function ErrorPage({ error }: ErrorPageProps) { Oops! Something went wrong - An unexpected error occurred. Please try again. If it continues, report the issue on - our GitHub using the button below, it will include error details to help us - investigate. Thank you for helping improve the app. + {reportedToSentry + ? 'An unexpected error occurred. This crash has been automatically reported to our team. You can add more details to help us investigate.' + : 'An unexpected error occurred. Please try again. If it continues, report the issue on our GitHub using the button below, it will include error details to help us investigate. Thank you for helping improve the app.'} - + {reportedToSentry ? ( + + + + + ) : ( + + )} { + if (phase === VerificationPhase.Done) { + Sentry.metrics.count('sable.crypto.verification_outcome', 1, { + attributes: { outcome: 'completed' }, + }); + } else if (phase === VerificationPhase.Cancelled) { + Sentry.metrics.count('sable.crypto.verification_outcome', 1, { + attributes: { outcome: 'cancelled' }, + }); + } + }, [phase]); + return ( }> diff --git a/src/app/features/bug-report/BugReportModal.tsx b/src/app/features/bug-report/BugReportModal.tsx index 2f90fda31..1babed978 100644 --- a/src/app/features/bug-report/BugReportModal.tsx +++ b/src/app/features/bug-report/BugReportModal.tsx @@ -18,9 +18,12 @@ import { Spinner, Text, TextArea, + Checkbox, } from 'folds'; +import * as Sentry from '@sentry/react'; import { useCloseBugReportModal, useBugReportModalOpen } from '$state/hooks/bugReportModal'; import { stopPropagation } from '$utils/keyboard'; +import { getDebugLogger } from '$utils/debugLogger'; type ReportType = 'bug' | 'feature'; @@ -84,6 +87,7 @@ export function buildGitHubUrl( function BugReportModal() { const close = useCloseBugReportModal(); + const sentryEnabled = Sentry.isInitialized(); const [type, setType] = useState('bug'); const [title, setTitle] = useState(''); @@ -100,6 +104,12 @@ function BugReportModal() { // Shared optional field const [context, setContext] = useState(''); + // Sentry integration options + const [sendToSentry, setSendToSentry] = useState(true); + const [includeDebugLogs, setIncludeDebugLogs] = useState(true); + // When Sentry is enabled, GitHub is opt-in; when disabled, GitHub is always used + const [openOnGitHub, setOpenOnGitHub] = useState(!sentryEnabled); + const [similarIssues, setSimilarIssues] = useState([]); const [searching, setSearching] = useState(false); @@ -141,12 +151,74 @@ function BugReportModal() { const handleSubmit = () => { if (!canSubmit) return; + const fields: Record = type === 'bug' ? { description, reproduction, 'expected-behavior': expectedBehavior, context } : { problem, solution, alternatives, context }; - const url = buildGitHubUrl(type, title.trim(), fields); - window.open(url, '_blank', 'noopener,noreferrer'); + + // Send to Sentry if bug report and option is enabled + if (sendToSentry && type === 'bug') { + const debugLogger = getDebugLogger(); + + // Attach recent logs if user opted in + if (includeDebugLogs) { + debugLogger.attachLogsToSentry(100); + } + + const version = `v${APP_VERSION}${IS_RELEASE_TAG ? '' : '-dev'}${BUILD_HASH ? ` (${BUILD_HASH})` : ''}`; + + // Build a fully self-contained message so all fields are visible + // directly in the Sentry issue detail without digging into sub-sections. + const sentryMessage = [ + `[Bug Report] ${title.trim()}`, + '', + `Description:\n${description}`, + reproduction ? `\nSteps to Reproduce:\n${reproduction}` : '', + expectedBehavior ? `\nExpected Behavior:\n${expectedBehavior}` : '', + context ? `\nAdditional Context:\n${context}` : '', + `\nEnvironment: ${version} · ${navigator.platform}`, + ] + .filter(Boolean) + .join('\n'); + + const eventId = Sentry.captureMessage(sentryMessage, { + level: 'info', + // Group all user bug reports together in Sentry Issues + fingerprint: ['bug-report-modal'], + tags: { + source: 'bug-report-modal', + reportType: type, + }, + extra: { + title: title.trim(), + description, + reproduction: reproduction || '(not provided)', + expectedBehavior: expectedBehavior || '(not provided)', + context: context || '(not provided)', + userAgent: navigator.userAgent, + platform: navigator.platform, + version, + }, + }); + + // Also send as User Feedback so it appears in the Sentry Feedback section + if (eventId) { + Sentry.captureFeedback({ + message: sentryMessage, + name: 'User Bug Report', + associatedEventId: eventId, + }); + } + } + + // Feature requests always go to GitHub; bugs go to GitHub only when Sentry + // is unavailable or the user explicitly opts in. + const shouldOpenGitHub = type === 'feature' || !sentryEnabled || openOnGitHub; + if (shouldOpenGitHub) { + const url = buildGitHubUrl(type, title.trim(), fields); + window.open(url, '_blank', 'noopener,noreferrer'); + } close(); }; @@ -352,6 +424,63 @@ function BugReportModal() { /> + {/* Sentry integration options (only for bug reports when Sentry is configured) */} + {type === 'bug' && sentryEnabled && ( + + Error Tracking + + setSendToSentry((v) => !v)} + /> + + + Send anonymous report to Sentry for error tracking + + + Helps developers identify and fix issues faster. No personal data is + sent. + + + + {sendToSentry && ( + + setIncludeDebugLogs((v) => !v)} + /> + + Include recent debug logs (last 100 entries) + + Provides additional context to help diagnose the issue. Logs are + filtered for sensitive data. + + + + )} + + setOpenOnGitHub((v) => !v)} + /> + + Also create a GitHub issue + + Opens a pre-filled GitHub issue in addition to the Sentry report. + + + + + )} + {/* Actions */} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 1b0e6f5f5..611cf4925 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -14,7 +14,6 @@ import { isKeyHotkey } from 'is-hotkey'; import { EventType, IContent, - MatrixEvent, MsgType, RelationType, Room, @@ -115,7 +114,6 @@ import { settingsAtom } from '$state/settings'; import { getMemberDisplayName, getMentionContent, - reactionOrEditEvent, trimReplyFromBody, trimReplyFromFormattedBody, } from '$utils/room'; @@ -153,6 +151,7 @@ import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice'; import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; +import * as Sentry from '@sentry/react'; import { getAudioMsgContent, getFileMsgContent, @@ -162,33 +161,7 @@ import { import { CommandAutocomplete } from './CommandAutocomplete'; import { AudioMessageRecorder } from './AudioMessageRecorder'; -// Returns the event ID of the most recent non-reaction/non-edit event in a thread, -// falling back to the thread root if no replies exist yet. -const getLatestThreadEventId = (room: Room, threadRootId: string): string => { - const thread = room.getThread(threadRootId); - const threadEvents: MatrixEvent[] = thread?.events ?? []; - const filtered = threadEvents.filter( - (ev) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev) - ); - if (filtered.length > 0) { - return filtered[filtered.length - 1].getId() ?? threadRootId; - } - // Fall back to the live timeline if the Thread object hasn't been registered yet - const liveEvents = room - .getUnfilteredTimelineSet() - .getLiveTimeline() - .getEvents() - .filter( - (ev) => - ev.threadRootId === threadRootId && ev.getId() !== threadRootId && !reactionOrEditEvent(ev) - ); - if (liveEvents.length > 0) { - return liveEvents[liveEvents.length - 1].getId() ?? threadRootId; - } - return threadRootId; -}; - -const getReplyContent = (replyDraft: IReplyDraft | undefined, room?: Room): IEventRelation => { +const getReplyContent = (replyDraft: IReplyDraft | undefined): IEventRelation => { if (!replyDraft) return {}; const relatesTo: IEventRelation = {}; @@ -201,19 +174,13 @@ const getReplyContent = (replyDraft: IReplyDraft | undefined, room?: Room): IEve // Check if this is a reply to a specific message in the thread // (replyDraft.body being empty means it's just a seeded thread draft) if (replyDraft.body && replyDraft.eventId !== replyDraft.relation.event_id) { - // Explicit reply to a specific message — per spec, is_falling_back must be false + // This is a reply to a message within the thread relatesTo['m.in_reply_to'] = { event_id: replyDraft.eventId, }; relatesTo.is_falling_back = false; } else { - // Regular thread message — per spec, include fallback m.in_reply_to pointing to the - // most recent thread message so unthreaded clients can display it as a reply chain - const threadRootId = replyDraft.relation.event_id ?? replyDraft.eventId; - const latestEventId = room ? getLatestThreadEventId(room, threadRootId) : threadRootId; - relatesTo['m.in_reply_to'] = { - event_id: latestEventId, - }; + // This is just a regular thread message relatesTo.is_falling_back = true; } } else { @@ -495,8 +462,7 @@ export const RoomInput = forwardRef( const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); if (contents.length > 0) { - const replyContent = - plainText?.length === 0 ? getReplyContent(replyDraft, room) : undefined; + const replyContent = plainText?.length === 0 ? getReplyContent(replyDraft) : undefined; if (replyContent) contents[0]['m.relates_to'] = replyContent; if (threadRootId) { setReplyDraft({ @@ -640,7 +606,7 @@ export const RoomInput = forwardRef( content.formatted_body = formattedBody; } if (replyDraft) { - content['m.relates_to'] = getReplyContent(replyDraft, room); + content['m.relates_to'] = getReplyContent(replyDraft); } const invalidate = () => queryClient.invalidateQueries({ queryKey: ['delayedEvents', roomId] }); @@ -700,20 +666,32 @@ export const RoomInput = forwardRef( // Cancel failed — leave state intact for retry } } else { + const msgSendStart = performance.now(); resetInput(); debugLog.info('message', 'Sending message', { roomId, msgtype: (content as any).msgtype }); - mx.sendMessage(roomId, threadRootId ?? null, content as any) + Sentry.startSpan( + { name: 'message.send', op: 'matrix.message', attributes: { encrypted: String(isEncrypted) } }, + () => mx.sendMessage(roomId, threadRootId ?? null, content as any) + ) .then((res) => { debugLog.info('message', 'Message sent successfully', { roomId, eventId: res.event_id, }); + Sentry.metrics.distribution( + 'sable.message.send_latency_ms', + performance.now() - msgSendStart, + { attributes: { encrypted: String(isEncrypted) } } + ); }) .catch((error: unknown) => { debugLog.error('message', 'Failed to send message', { roomId, error: error instanceof Error ? error.message : String(error), }); + Sentry.metrics.count('sable.message.send_error', 1, { + attributes: { encrypted: String(isEncrypted) }, + }); log.error('failed to send message', { roomId }, error); }); } @@ -827,7 +805,7 @@ export const RoomInput = forwardRef( info, }; if (replyDraft) { - content['m.relates_to'] = getReplyContent(replyDraft, room); + content['m.relates_to'] = getReplyContent(replyDraft); if (threadRootId) { setReplyDraft({ userId: mx.getUserId() ?? '', diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 748162cb3..05553039b 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -36,6 +36,7 @@ import { ReactEditor } from 'slate-react'; import { Editor } from 'slate'; import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; import to from 'await-to-js'; +import * as Sentry from '@sentry/react'; import { useAtomValue, useSetAtom } from 'jotai'; import { as, @@ -290,54 +291,60 @@ const useEventTimelineLoader = ( onError: (err: Error | null) => void ) => useCallback( - async (eventId: string) => { - const withTimeout = async (promise: Promise, timeoutMs: number): Promise => - new Promise((resolve, reject) => { - const timeoutId = globalThis.setTimeout(() => { - reject(new Error('Timed out loading event timeline')); - }, timeoutMs); - - promise - .then((value) => { - globalThis.clearTimeout(timeoutId); - resolve(value); - }) - .catch((error) => { - globalThis.clearTimeout(timeoutId); - reject(error); - }); - }); + async (eventId: string) => + Sentry.startSpan({ name: 'timeline.jump_load', op: 'matrix.timeline' }, async () => { + const jumpLoadStart = performance.now(); + const withTimeout = async (promise: Promise, timeoutMs: number): Promise => + new Promise((resolve, reject) => { + const timeoutId = globalThis.setTimeout(() => { + reject(new Error('Timed out loading event timeline')); + }, timeoutMs); + + promise + .then((value) => { + globalThis.clearTimeout(timeoutId); + resolve(value); + }) + .catch((error) => { + globalThis.clearTimeout(timeoutId); + reject(error); + }); + }); - if (!room.getUnfilteredTimelineSet().getTimelineForEvent(eventId)) { - await withTimeout( - mx.roomInitialSync(room.roomId, PAGINATION_LIMIT), - EVENT_TIMELINE_LOAD_TIMEOUT_MS - ); - await withTimeout( - mx.getLatestTimeline(room.getUnfilteredTimelineSet()), - EVENT_TIMELINE_LOAD_TIMEOUT_MS + if (!room.getUnfilteredTimelineSet().getTimelineForEvent(eventId)) { + await withTimeout( + mx.roomInitialSync(room.roomId, PAGINATION_LIMIT), + EVENT_TIMELINE_LOAD_TIMEOUT_MS + ); + await withTimeout( + mx.getLatestTimeline(room.getUnfilteredTimelineSet()), + EVENT_TIMELINE_LOAD_TIMEOUT_MS + ); + } + const [err, replyEvtTimeline] = await to( + withTimeout( + mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId), + EVENT_TIMELINE_LOAD_TIMEOUT_MS + ) ); - } - const [err, replyEvtTimeline] = await to( - withTimeout( - mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId), - EVENT_TIMELINE_LOAD_TIMEOUT_MS - ) - ); - if (!replyEvtTimeline) { - onError(err ?? null); - return; - } - const linkedTimelines = getLinkedTimelines(replyEvtTimeline); - const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId); + if (!replyEvtTimeline) { + onError(err ?? null); + return; + } + const linkedTimelines = getLinkedTimelines(replyEvtTimeline); + const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId); - if (absIndex === undefined) { - onError(err ?? null); - return; - } + if (absIndex === undefined) { + onError(err ?? null); + return; + } - onLoad(eventId, linkedTimelines, absIndex); - }, + Sentry.metrics.distribution( + 'sable.timeline.jump_load_ms', + performance.now() - jumpLoadStart + ); + onLoad(eventId, linkedTimelines, absIndex); + }), // end startSpan [mx, room, onLoad, onError] ); @@ -414,6 +421,7 @@ const useTimelinePagination = ( }); } try { + const paginateStart = performance.now(); const [err] = await to( mx.paginateEventTimeline(timelineToPaginate, { backwards, @@ -423,6 +431,9 @@ const useTimelinePagination = ( if (err) { if (alive()) { (backwards ? setBackwardStatus : setForwardStatus)('error'); + Sentry.metrics.count('sable.pagination.error', 1, { + attributes: { direction: backwards ? 'backward' : 'forward' }, + }); debugLog.error('timeline', 'Timeline pagination failed', { direction: backwards ? 'backward' : 'forward', error: err instanceof Error ? err.message : String(err), @@ -445,6 +456,16 @@ const useTimelinePagination = ( if (alive()) { recalibratePagination(lTimelines, timelinesEventsCount, backwards); (backwards ? setBackwardStatus : setForwardStatus)('idle'); + Sentry.metrics.distribution( + 'sable.pagination.latency_ms', + performance.now() - paginateStart, + { + attributes: { + direction: backwards ? 'backward' : 'forward', + encrypted: String(!!room?.hasEncryptionStateEvent()), + }, + } + ); debugLog.info('timeline', 'Timeline pagination completed', { direction: backwards ? 'backward' : 'forward', totalEventsNow: getTimelinesEventsCount(lTimelines), @@ -460,12 +481,6 @@ const useTimelinePagination = ( }; const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => { - // Stable ref so the effect dep array only contains `room`. The listener is - // registered once per room mount; onArrive can change freely without causing - // listener churn during rapid re-renders (e.g. sync error/retry cycles). - const onArriveRef = useRef(onArrive); - onArriveRef.current = onArrive; - useEffect(() => { // Capture the live timeline and registration time. Events appended to the // live timeline AFTER this point can be genuinely new even when @@ -492,14 +507,14 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) data.timeline === liveTimeline && mEvent.getTs() >= registeredAt - 60_000); if (!isLive) return; - onArriveRef.current(mEvent); + onArrive(mEvent); }; const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = ( mEvent: MatrixEvent, eventRoom: Room | undefined ) => { if (eventRoom?.roomId !== room.roomId) return; - onArriveRef.current(mEvent); + onArrive(mEvent); }; room.on(RoomEvent.Timeline, handleTimelineEvent); @@ -508,13 +523,10 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) room.removeListener(RoomEvent.Timeline, handleTimelineEvent); room.removeListener(RoomEvent.Redaction, handleRedaction); }; - }, [room]); // stable: re-register only when room changes, not on callback identity changes + }, [room, onArrive]); }; const useRelationUpdate = (room: Room, onRelation: () => void) => { - const onRelationRef = useRef(onRelation); - onRelationRef.current = onRelation; - useEffect(() => { const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = ( mEvent: MatrixEvent, @@ -528,61 +540,42 @@ const useRelationUpdate = (room: Room, onRelation: () => void) => { // also need to trigger a re-render so makeReplaced state is reflected. if (eventRoom?.roomId !== room.roomId || data.liveEvent) return; if (mEvent.getRelation()?.rel_type === RelationType.Replace) { - onRelationRef.current(); + onRelation(); } }; room.on(RoomEvent.Timeline, handleTimelineEvent); return () => { room.removeListener(RoomEvent.Timeline, handleTimelineEvent); }; - }, [room]); + }, [room, onRelation]); }; const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { - const onRefreshRef = useRef(onRefresh); - onRefreshRef.current = onRefresh; - useEffect(() => { const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r: Room) => { if (r.roomId !== room.roomId) return; - onRefreshRef.current(); - }; - // The SDK fires RoomEvent.TimelineReset on the EventTimelineSet (not the Room) - // when a limited sliding-sync response replaces the live EventTimeline with a - // fresh one. Without this handler, the stored linkedTimelines reference the old - // detached chain and back-pagination silently no-ops, freezing the room. - const handleTimelineReset: EventTimelineSetHandlerMap[RoomEvent.TimelineReset] = () => { - onRefreshRef.current(); + onRefresh(); }; - const unfilteredTimelineSet = room.getUnfilteredTimelineSet(); room.on(RoomEvent.TimelineRefresh, handleTimelineRefresh); - unfilteredTimelineSet.on(RoomEvent.TimelineReset, handleTimelineReset); return () => { room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh); - unfilteredTimelineSet.removeListener(RoomEvent.TimelineReset, handleTimelineReset); }; - }, [room]); + }, [room, onRefresh]); }; // Trigger re-render when thread reply counts change so the thread chip updates. const useThreadUpdate = (room: Room, onUpdate: () => void) => { - const onUpdateRef = useRef(onUpdate); - onUpdateRef.current = onUpdate; - useEffect(() => { - // Stable wrapper: the same function identity is kept for the lifetime of - // the room so add/removeListener calls always match. - const handler = () => onUpdateRef.current(); - room.on(ThreadEvent.New, handler); - room.on(ThreadEvent.Update, handler); - room.on(ThreadEvent.NewReply, handler); + room.on(ThreadEvent.New, onUpdate); + room.on(ThreadEvent.Update, onUpdate); + room.on(ThreadEvent.NewReply, onUpdate); return () => { - room.removeListener(ThreadEvent.New, handler); - room.removeListener(ThreadEvent.Update, handler); - room.removeListener(ThreadEvent.NewReply, handler); + room.removeListener(ThreadEvent.New, onUpdate); + room.removeListener(ThreadEvent.Update, onUpdate); + room.removeListener(ThreadEvent.NewReply, onUpdate); }; - }, [room]); + }, [room, onUpdate]); }; // Returns the number of replies in a thread, counting actual reply events @@ -793,10 +786,6 @@ export function RoomTimeline({ const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true)); - // Stable ref so listeners that only need to *read* unreadInfo don't force - // effect re-registration (and listener churn) every time a new message arrives. - const unreadInfoRef = useRef(unreadInfo); - unreadInfoRef.current = unreadInfo; const readUptoEventIdRef = useRef(); if (unreadInfo) { readUptoEventIdRef.current = unreadInfo.readUptoEventId; @@ -811,12 +800,6 @@ export function RoomTimeline({ atBottomRef.current = val; }, []); - // Set to true by the useLiveTimelineRefresh callback when the timeline is - // re-initialised (TimelineRefresh or TimelineReset). Allows the range self-heal - // effect below to run even when atBottom=false, so the virtual paginator window - // is restored to the live end without forcing a viewport scroll. - const timelineJustResetRef = useRef(false); - const scrollRef = useRef(null); const scrollToBottomRef = useRef({ count: 0, @@ -879,6 +862,14 @@ export function RoomTimeline({ // Log timeline component mount/unmount useEffect(() => { + const mode = eventId ? 'jump' : 'live'; + Sentry.metrics.count('sable.timeline.open', 1, { attributes: { mode } }); + const initialWindowSize = timeline.range.end - timeline.range.start; + if (initialWindowSize > 0) { + Sentry.metrics.distribution('sable.timeline.render_window', initialWindowSize, { + attributes: { encrypted: String(room.hasEncryptionStateEvent()), mode }, + }); + } debugLog.info('timeline', 'Timeline mounted', { roomId: room.roomId, eventId, @@ -995,17 +986,14 @@ export function RoomTimeline({ // otherwise we update timeline without paginating // so timeline can be updated with evt like: edits, reactions etc if (atBottomRef.current && atLiveEndRef.current) { - if ( - document.hasFocus() && - (!unreadInfoRef.current || mEvt.getSender() === mx.getUserId()) - ) { + if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) { // Check if the document is in focus (user is actively viewing the app), // and either there are no unread messages or the latest message is from the current user. // If either condition is met, trigger the markAsRead function to send a read receipt. requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideReads)); } - if (!document.hasFocus() && !unreadInfoRef.current) { + if (!document.hasFocus() && !unreadInfo) { setUnreadInfo(getRoomUnreadInfo(room)); } @@ -1024,11 +1012,11 @@ export function RoomTimeline({ return; } setTimeline((ct) => ({ ...ct })); - if (!unreadInfoRef.current) { + if (!unreadInfo) { setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, hideReads] + [mx, room, unreadInfo, hideReads] ) ); @@ -1038,8 +1026,11 @@ export function RoomTimeline({ eventRoom: Room | undefined ) => { if (eventRoom?.roomId !== room.roomId) return; + if (_mEvent.getAssociatedStatus() === EventStatus.NOT_SENT) { + Sentry.metrics.count('sable.message.send_failed', 1); + } setTimeline((ct) => ({ ...ct })); - if (!unreadInfoRef.current) { + if (!unreadInfo) { setUnreadInfo(getRoomUnreadInfo(room)); } }; @@ -1048,7 +1039,7 @@ export function RoomTimeline({ return () => { room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEchoUpdated); }; - }, [room, setTimeline, setUnreadInfo]); + }, [room, unreadInfo, setTimeline, setUnreadInfo]); const handleOpenEvent = useCallback( async ( @@ -1099,33 +1090,34 @@ export function RoomTimeline({ useLiveTimelineRefresh( room, useCallback(() => { - // Always reinitialize on TimelineRefresh/TimelineReset. With sliding sync, - // a limited response replaces the room's live EventTimeline with a brand-new - // object. At that moment liveTimelineLinked is stale-false (stored - // linkedTimelines reference the old detached chain), so any guard on that - // flag would skip reinit, causing back-pagination to no-op silently and the - // room to appear frozen. Unconditional reinit is correct: both events signal - // that stored range/indices against the old chain are invalid. + // Always reinitialize on TimelineRefresh. With sliding sync, a limited + // response replaces the room's live EventTimeline with a brand-new object, + // firing TimelineRefresh. At that moment liveTimelineLinked is stale-false + // (the stored linkedTimelines still reference the old detached object), + // so the previous guard `if (liveTimelineLinked || ...)` would silently + // skip reinit. Back-pagination then calls paginateEventTimeline against + // the dead old timeline, which no-ops, and the IntersectionObserver never + // re-fires because intersection state didn't change — causing a permanent + // hang at the top of the timeline with no spinner and no history loaded. + // Unconditionally reinitializing is correct: TimelineRefresh signals that + // the SDK has replaced the timeline chain, so any stored range/indices + // against the old chain are invalid anyway. // - // Only force the viewport to the bottom if the user was already there. - // When the user has scrolled up to read history and a sync gap fires, we - // must still reinit (the old timeline is gone), but scrolling them back to - // the bottom is jarring. Instead we set timelineJustResetRef=true so the - // self-heal effect below can advance the range as events arrive on the fresh - // timeline, without atBottom=true being required. - // - // When the user WAS at the bottom we still call setAtBottom(true) so a - // transient isIntersecting=false from the IntersectionObserver during the - // DOM transition cannot stick the "Jump to Latest" button on-screen. + // Also force atBottom=true and queue a scroll-to-bottom. The SDK fires + // TimelineRefresh before adding new events to the fresh live timeline, so + // getInitialTimeline captures range.end=0. Once events arrive the + // rangeAtEnd self-heal useEffect needs atBottom=true to run; the + // IntersectionObserver may have transiently fired isIntersecting=false + // during the render transition, leaving atBottom=false and causing the + // "Jump to Latest" button to stick permanently. Forcing atBottom here is + // correct: TimelineRefresh always reinits to the live end, so the user + // should be repositioned to the bottom regardless. + Sentry.metrics.count('sable.timeline.reinit', 1); debugLog.info('timeline', 'Live timeline refresh triggered', { roomId: room.roomId }); - const wasAtBottom = atBottomRef.current; - timelineJustResetRef.current = true; setTimeline(getInitialTimeline(room)); - if (wasAtBottom) { - setAtBottom(true); - scrollToBottomRef.current.count += 1; - scrollToBottomRef.current.smooth = false; - } + setAtBottom(true); + scrollToBottomRef.current.count += 1; + scrollToBottomRef.current.smooth = false; }, [room, setAtBottom]) ); @@ -1152,17 +1144,9 @@ export function RoomTimeline({ // position we want to display. Without this, loading more history makes it look // like we've scrolled up because the range (0, 10) is now showing the old events // instead of the latest ones. - // - // Also runs after a timeline reset (timelineJustResetRef=true) even when - // atBottom=false. After TimelineReset the SDK fires the event before populating - // the fresh timeline, so getInitialTimeline sees range.end=0. When events - // arrive eventsLength grows and we need to heal the range back to the live end - // regardless of the user's scroll position. useEffect(() => { - const resetPending = timelineJustResetRef.current; - if ((atBottom || resetPending) && liveTimelineLinked && eventsLength > timeline.range.end) { - if (resetPending) timelineJustResetRef.current = false; - // More events exist than our current range shows. Adjust to the live end. + if (atBottom && liveTimelineLinked && eventsLength > timeline.range.end) { + // More events exist than our current range shows. Adjust to stay at bottom. setTimeline((ct) => ({ ...ct, range: { diff --git a/src/app/features/room/message/EncryptedContent.tsx b/src/app/features/room/message/EncryptedContent.tsx index ddd82db48..33955b6e9 100644 --- a/src/app/features/room/message/EncryptedContent.tsx +++ b/src/app/features/room/message/EncryptedContent.tsx @@ -2,6 +2,7 @@ import { MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from '$types/mat import { ReactNode, useEffect, useState } from 'react'; import { MessageEvent } from '$types/matrix/room'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import * as Sentry from '@sentry/react'; type EncryptedContentProps = { mEvent: MatrixEvent; @@ -14,12 +15,27 @@ export function EncryptedContent({ mEvent, children }: EncryptedContentProps) { useEffect(() => { if (mEvent.getType() !== MessageEvent.RoomMessageEncrypted) return; - mx.decryptEventIfNeeded(mEvent).catch(() => undefined); + // Sample 5% of events for per-event decryption latency profiling + if (Math.random() < 0.05) { + const start = performance.now(); + Sentry.startSpan({ name: 'decrypt.event', op: 'matrix.crypto' }, () => + mx.decryptEventIfNeeded(mEvent).then(() => { + Sentry.metrics.distribution('sable.decryption.event_ms', performance.now() - start); + }) + ).catch(() => undefined); + } else { + mx.decryptEventIfNeeded(mEvent).catch(() => undefined); + } }, [mx, mEvent]); useEffect(() => { toggleEncrypted(mEvent.getType() === MessageEvent.RoomMessageEncrypted); const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (event) => { + if (event.isDecryptionFailure()) { + Sentry.metrics.count('sable.decryption.failure', 1, { + attributes: { reason: event.decryptionFailureReason ?? 'UNKNOWN_ERROR' }, + }); + } toggleEncrypted(event.getType() === MessageEvent.RoomMessageEncrypted); }; mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted); diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index d230620ae..b717f2261 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -12,6 +12,7 @@ import { SequenceCardStyle } from '$features/settings/styles.css'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; import { DebugLogViewer } from './DebugLogViewer'; +import { SentrySettings } from './SentrySettings'; type DeveloperToolsProps = { requestClose: () => void; @@ -126,6 +127,11 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { )} + {developerTools && ( + + + + )} diff --git a/src/app/features/settings/developer-tools/SentrySettings.tsx b/src/app/features/settings/developer-tools/SentrySettings.tsx new file mode 100644 index 000000000..0bb51a985 --- /dev/null +++ b/src/app/features/settings/developer-tools/SentrySettings.tsx @@ -0,0 +1,240 @@ +import { useState, useEffect } from 'react'; +import { Box, Text, Switch, Button } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { getDebugLogger, LogCategory } from '$utils/debugLogger'; + +const ALL_CATEGORIES: LogCategory[] = [ + 'sync', + 'network', + 'notification', + 'message', + 'call', + 'ui', + 'timeline', + 'error', + 'general', +]; + +export function SentrySettings() { + const [sentryEnabled, setSentryEnabled] = useState( + localStorage.getItem('sable_sentry_enabled') !== 'false' + ); + const [sessionReplayEnabled, setSessionReplayEnabled] = useState( + localStorage.getItem('sable_sentry_replay_enabled') === 'true' + ); + const [needsRefresh, setNeedsRefresh] = useState(false); + const [categoryEnabled, setCategoryEnabled] = useState>(() => { + const logger = getDebugLogger(); + return Object.fromEntries( + ALL_CATEGORIES.map((c) => [c, logger.getBreadcrumbCategoryEnabled(c)]) + ) as Record; + }); + const [sentryStats, setSentryStats] = useState(() => getDebugLogger().getSentryStats()); + + useEffect(() => { + const interval = setInterval(() => { + setSentryStats(getDebugLogger().getSentryStats()); + }, 5000); + return () => clearInterval(interval); + }, []); + + const handleSentryToggle = (enabled: boolean) => { + setSentryEnabled(enabled); + if (enabled) { + localStorage.removeItem('sable_sentry_enabled'); + } else { + localStorage.setItem('sable_sentry_enabled', 'false'); + } + setNeedsRefresh(true); + }; + + const handleReplayToggle = (enabled: boolean) => { + setSessionReplayEnabled(enabled); + if (enabled) { + localStorage.setItem('sable_sentry_replay_enabled', 'true'); + } else { + localStorage.removeItem('sable_sentry_replay_enabled'); + } + setNeedsRefresh(true); + }; + + const handleCategoryToggle = (category: LogCategory, enabled: boolean) => { + getDebugLogger().setBreadcrumbCategoryEnabled(category, enabled); + setCategoryEnabled((prev) => ({ ...prev, [category]: enabled })); + }; + + const handleExportLogs = () => { + const data = getDebugLogger().exportLogs(); + const blob = new Blob([data], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `sable-debug-logs-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + const isSentryConfigured = Boolean(import.meta.env.VITE_SENTRY_DSN); + const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; + const isProd = environment === 'production'; + const traceSampleRate = isProd ? '10%' : '100%'; + const replaySampleRate = isProd ? '10%' : '100%'; + + return ( + + Error Tracking (Sentry) + {needsRefresh && ( + + + Please refresh the page for Sentry settings to take effect. + + + )} + {!isSentryConfigured && ( + + + Sentry is not configured. Set VITE_SENTRY_DSN to enable error tracking. + + + )} + + + } + /> + {sentryEnabled && isSentryConfigured && ( + <> + + } + /> + + )} + + {isSentryConfigured && ( + + + All data sent to Sentry is filtered for sensitive information like passwords and access + tokens. You can opt out at any time. + + + Session Replay Privacy: When enabled, all text content, media + (images/video/audio), and form inputs are completely masked or blocked. Only UI + structure and interactions are recorded. + + + )} + + {isSentryConfigured && sentryEnabled && ( + <> + Performance Metrics + + + + + + + Breadcrumb Categories + + Control which log categories are included as breadcrumbs in Sentry error reports. + Disabling a category reduces noise without affecting error capture. + + + {ALL_CATEGORIES.map((cat) => ( + handleCategoryToggle(cat, v)} + /> + } + /> + ))} + + + Debug Logs + + + + Export JSON + + } + /> + + + )} + + ); +} diff --git a/src/app/hooks/useBlobCache.ts b/src/app/hooks/useBlobCache.ts index 7b40e292c..96ac18f74 100644 --- a/src/app/hooks/useBlobCache.ts +++ b/src/app/hooks/useBlobCache.ts @@ -3,6 +3,10 @@ import { useState, useEffect } from 'react'; const imageBlobCache = new Map(); const inflightRequests = new Map>(); +export function getBlobCacheStats(): { cacheSize: number; inflightCount: number } { + return { cacheSize: imageBlobCache.size, inflightCount: inflightRequests.size }; +} + export function useBlobCache(url?: string): string | undefined { const [cacheState, setCacheState] = useState<{ sourceUrl?: string; blobUrl?: string }>({ sourceUrl: url, diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index 1e8a7dfef..cd657e477 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -29,13 +29,6 @@ export function useCallSignaling() { const mutedRoomId = useAtomValue(mutedCallRoomIdAtom); const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); - // Stable refs so volatile values (mutedRoomId, ring callbacks) don't force - // the listener registration effect to re-run — which would cause the - // SessionEnded and RoomState.events listeners to accumulate when muting - // or when call state changes rapidly during a sync retry cycle. - const mutedRoomIdRef = useRef(mutedRoomId); - mutedRoomIdRef.current = mutedRoomId; - useEffect(() => { const inc = new Audio(RingtoneSound); inc.loop = true; @@ -79,16 +72,6 @@ export function useCallSignaling() { [setIncomingCall] ); - // Must be declared after the callbacks above so the initial useRef(value) call - // sees their current identity. Updated on every render so the effect closure - // always calls the latest version without needing them in the dep array. - const playRingingRef = useRef(playRinging); - playRingingRef.current = playRinging; - const stopRingingRef = useRef(stopRinging); - stopRingingRef.current = stopRinging; - const playOutgoingRingingRef = useRef(playOutgoingRinging); - playOutgoingRingingRef.current = playOutgoingRinging; - useEffect(() => { if (!mx || !mx.matrixRTC) return undefined; @@ -98,7 +81,7 @@ export function useCallSignaling() { const signal = Array.from(mDirects).reduce( (acc, roomId) => { - if (acc.incoming || mutedRoomIdRef.current === roomId) return acc; + if (acc.incoming || mutedRoomId === roomId) return acc; const room = mx.getRoom(roomId); if (!room) return acc; @@ -158,11 +141,11 @@ export function useCallSignaling() { ); if (signal.incoming) { - playRingingRef.current(signal.incoming); + playRinging(signal.incoming); } else if (signal.outgoing) { - playOutgoingRingingRef.current(signal.outgoing); + playOutgoingRinging(signal.outgoing); } else { - stopRingingRef.current(); + stopRinging(); if (!signal.outgoing) outgoingStartRef.current = null; } }; @@ -172,7 +155,7 @@ export function useCallSignaling() { const handleUpdate = () => checkDMsForActiveCalls(); const handleSessionEnded = (roomId: string) => { - if (mutedRoomIdRef.current === roomId) setMutedRoomId(null); + if (mutedRoomId === roomId) setMutedRoomId(null); callPhaseRef.current[roomId] = 'IDLE'; checkDMsForActiveCalls(); }; @@ -188,9 +171,9 @@ export function useCallSignaling() { mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, handleUpdate); mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); mx.off(RoomStateEvent.Events, handleUpdate); - stopRingingRef.current(); + stopRinging(); }; - }, [mx, mDirects, setMutedRoomId]); // stable: volatile deps accessed via refs above + }, [mx, mDirects, playRinging, stopRinging, mutedRoomId, setMutedRoomId, playOutgoingRinging]); return null; } diff --git a/src/app/hooks/useKeyBackup.ts b/src/app/hooks/useKeyBackup.ts index 1cc531eda..3714ec6be 100644 --- a/src/app/hooks/useKeyBackup.ts +++ b/src/app/hooks/useKeyBackup.ts @@ -6,6 +6,7 @@ import { KeyBackupInfo, } from '$types/matrix-sdk'; import { useCallback, useEffect, useState } from 'react'; +import * as Sentry from '@sentry/react'; import { useMatrixClient } from './useMatrixClient'; import { useAlive } from './useAlive'; @@ -92,6 +93,15 @@ export const useKeyBackupSync = (): [number, string | undefined] => { useKeyBackupFailedChange( useCallback((f) => { if (typeof f === 'string') { + Sentry.addBreadcrumb({ + category: 'crypto', + message: 'Key backup failed', + level: 'error', + data: { errcode: f }, + }); + Sentry.metrics.count('sable.crypto.key_backup_failures', 1, { + attributes: { errcode: f }, + }); setFailure(f); setRemaining(0); } diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 0408f38ea..87687fd89 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -3,7 +3,7 @@ import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProv import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import { ErrorBoundary } from 'react-error-boundary'; +import * as Sentry from '@sentry/react'; import { ClientConfigLoader } from '$components/ClientConfigLoader'; import { ClientConfigProvider } from '$hooks/useClientConfig'; @@ -23,7 +23,14 @@ function App() { const portalContainer = document.getElementById('portalContainer') ?? undefined; return ( - + ( + + )} + > @@ -51,7 +58,7 @@ function App() { - + ); } diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index f14567f7d..71b72157b 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -6,8 +6,10 @@ import { createRoutesFromElements, redirect, } from 'react-router-dom'; +import * as Sentry from '@sentry/react'; import { ClientConfig } from '$hooks/useClientConfig'; +import { ErrorPage } from '$components/DefaultErrorPage'; import { Room } from '$features/room'; import { Lobby } from '$features/lobby'; import { PageRoot } from '$components/page'; @@ -117,10 +119,20 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) return null; }} element={ - <> - - - + ( + + )} + beforeCapture={(scope) => scope.setTag('section', 'auth')} + > + <> + + + + } > } /> @@ -142,7 +154,16 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) return null; }} element={ - + ( + + )} + beforeCapture={(scope) => scope.setTag('section', 'client')} + > + {/* HandleNotificationClick must live outside ClientRoot's loading gate so SW notification-click postMessages are never dropped during client reloads (e.g., account switches). It only needs navigate + Jotai atoms. */} @@ -196,6 +217,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + } > (mx.loginRequest(data)); - if (err) { - if (err.httpStatus === 400) { - debugLog.error('general', 'Login failed - invalid request', { httpStatus: 400 }); - throw new MatrixError({ - errcode: LoginError.InvalidRequest, - }); - } - if (err.httpStatus === 429) { - debugLog.error('general', 'Login failed - rate limited', { httpStatus: 429 }); - throw new MatrixError({ - errcode: LoginError.RateLimited, - }); - } - if (err.errcode === ErrorCode.M_USER_DEACTIVATED) { - debugLog.error('general', 'Login failed - user deactivated', { errcode: err.errcode }); - throw new MatrixError({ - errcode: LoginError.UserDeactivated, - }); - } + return Sentry.startSpan( + { name: 'auth.login', op: 'auth', attributes: { 'auth.method': data.type } }, + async (span) => { + const [err, res] = await to(mx.loginRequest(data)); + + if (err) { + span.setAttribute('auth.error', err.errcode ?? 'unknown'); + Sentry.metrics.count('sable.auth.login_failed', 1, { + attributes: { errcode: err.errcode ?? 'unknown' }, + }); + if (err.httpStatus === 400) { + debugLog.error('general', 'Login failed - invalid request', { httpStatus: 400 }); + throw new MatrixError({ + errcode: LoginError.InvalidRequest, + }); + } + if (err.httpStatus === 429) { + debugLog.error('general', 'Login failed - rate limited', { httpStatus: 429 }); + throw new MatrixError({ + errcode: LoginError.RateLimited, + }); + } + if (err.errcode === ErrorCode.M_USER_DEACTIVATED) { + debugLog.error('general', 'Login failed - user deactivated', { errcode: err.errcode }); + throw new MatrixError({ + errcode: LoginError.UserDeactivated, + }); + } + + if (err.httpStatus === 403) { + debugLog.error('general', 'Login failed - forbidden', { httpStatus: 403 }); + throw new MatrixError({ + errcode: LoginError.Forbidden, + }); + } - if (err.httpStatus === 403) { - debugLog.error('general', 'Login failed - forbidden', { httpStatus: 403 }); - throw new MatrixError({ - errcode: LoginError.Forbidden, + debugLog.error('general', 'Login failed - unknown error', { + error: err.message, + httpStatus: err.httpStatus, + }); + throw new MatrixError({ + errcode: LoginError.Unknown, + }); + } + + span.setAttribute('auth.success', true); + debugLog.info('general', 'Login successful', { + userId: res.user_id, + deviceId: res.device_id, }); + return { + baseUrl: url, + response: res, + }; } - - debugLog.error('general', 'Login failed - unknown error', { - error: err.message, - httpStatus: err.httpStatus, - }); - throw new MatrixError({ - errcode: LoginError.Unknown, - }); - } - debugLog.info('general', 'Login successful', { userId: res.user_id, deviceId: res.device_id }); - return { - baseUrl: url, - response: res, - }; + ); }; export const useLoginComplete = (data?: CustomLoginResponse) => { diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 0a00fcc15..2d382fcc7 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -39,6 +39,7 @@ import { buildRoomMessageNotification, resolveNotificationPreviewText, } from '$utils/notificationStyle'; +import * as Sentry from '@sentry/react'; import { startClient, stopClient } from '$client/initMatrix'; import { useClientConfig } from '$hooks/useClientConfig'; import { mobileOrTablet } from '$utils/user-agent'; @@ -214,6 +215,7 @@ export function BackgroundNotifications() { clientCleanupRef.current.delete(userId); stopClient(mx); current.delete(userId); + Sentry.metrics.gauge('sable.background.client_count', current.size); // Clear the background unread badge when this session is no longer a background account. setBackgroundUnreads((prev) => { const next = { ...prev }; @@ -232,6 +234,7 @@ export function BackgroundNotifications() { .then(async (mx) => { sessionMx = mx; current.set(session.userId, mx); + Sentry.metrics.gauge('sable.background.client_count', current.size); await waitForSync(mx); @@ -505,6 +508,7 @@ export function BackgroundNotifications() { userId: session.userId, error: err, }); + Sentry.captureException(err, { tags: { component: 'BackgroundNotifications' } }); // Remove the stuck/failed client from current so future runs (or the // retry below) can attempt a fresh start. diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 5ec5d8806..fab2261e3 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,4 +1,5 @@ import { useAtomValue, useSetAtom } from 'jotai'; +import * as Sentry from '@sentry/react'; import { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { @@ -48,6 +49,8 @@ import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; import { getSlidingSyncManager } from '$client/initMatrix'; import { NotificationBanner } from '$components/notification-banner'; import { useCallSignaling } from '$hooks/useCallSignaling'; +import { getBlobCacheStats } from '$hooks/useBlobCache'; +import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -263,6 +266,8 @@ function MessageNotifications() { // already checked focus when the encrypted event arrived, and want to use that // original state rather than re-checking after decryption completes). const skipFocusCheckEvents = new Set(); + // Tracks when each event first arrived so we can measure notification delivery latency + const notifyTimerMap = new Map(); const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = ( mEvent, @@ -274,6 +279,10 @@ function MessageNotifications() { if (mx.getSyncState() !== 'SYNCING') return; const eventId = mEvent.getId(); + // Record event arrival time once per eventId (re-entry via handleDecrypted must not reset it) + if (eventId && !notifyTimerMap.has(eventId)) { + notifyTimerMap.set(eventId, performance.now()); + } const shouldSkipFocusCheck = eventId && skipFocusCheckEvents.has(eventId); if (!shouldSkipFocusCheck) { if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) @@ -334,6 +343,17 @@ function MessageNotifications() { // Check if this is a DM using multiple signals for robustness const isDM = isDMRoom(room, mDirectsRef.current); + + // Measure total notification delivery latency (includes decryption wait for E2EE events) + const arrivalMs = notifyTimerMap.get(eventId); + if (arrivalMs !== undefined) { + Sentry.metrics.distribution( + 'sable.notification.delivery_ms', + performance.now() - arrivalMs, + { attributes: { encrypted: String(mEvent.isEncrypted()), dm: String(isDM) } } + ); + notifyTimerMap.delete(eventId); + } const pushActions = pushProcessor.actionsForEvent(mEvent); // For DMs with "All Messages" or "Default" notification settings: @@ -528,6 +548,30 @@ function PrivacyBlurFeature() { return null; } +// Periodically emits memory-health gauges so Sentry dashboards can surface +// unbounded growth (e.g. blob cache never evicted, stale inflight requests). +function HealthMonitor() { + useEffect(() => { + const id = window.setInterval(() => { + const { cacheSize, inflightCount } = getBlobCacheStats(); + Sentry.metrics.gauge('sable.media.blob_cache_size', cacheSize); + if (inflightCount > 0) { + Sentry.metrics.gauge('sable.media.inflight_requests', inflightCount); + if (inflightCount >= 10) { + Sentry.addBreadcrumb({ + category: 'media', + message: `High inflight request count: ${inflightCount}`, + level: 'warning', + data: { inflight_count: inflightCount }, + }); + } + } + }, 60_000); + return () => window.clearInterval(id); + }, []); + return null; +} + type ClientNonUIFeaturesProps = { children: ReactNode; }; @@ -619,6 +663,80 @@ function SlidingSyncActiveRoomSubscriber() { return null; } +/** + * Tracks the currently-viewed room and writes sanitised room metadata to the Sentry scope. + * This context appears on every subsequent error/transaction captured while the room is open, + * making room-specific bugs much easier to triage. + */ +function SentryRoomContextFeature() { + const mx = useMatrixClient(); + const mDirect = useAtomValue(mDirectAtom); + const roomId = useAtomValue(lastVisitedRoomIdAtom); + + useEffect(() => { + if (!roomId) { + Sentry.setContext('room', null); + Sentry.setTag('room_type', ''); + Sentry.setTag('room_encrypted', ''); + return; + } + const room = mx.getRoom(roomId); + if (!room) return; + + const isDm = mDirect.has(roomId); + const encrypted = mx.isRoomEncrypted(roomId); + const memberCount = room.getJoinedMemberCount(); + // Bucket member count so we can correlate issues with room scale + // without leaking precise membership numbers of private rooms. + const memberCountRange = + memberCount <= 2 ? '1-2' : + memberCount <= 10 ? '3-10' : + memberCount <= 50 ? '11-50' : + memberCount <= 200 ? '51-200' : '200+'; + + Sentry.setContext('room', { + type: isDm ? 'dm' : 'group', + encrypted, + member_count_range: memberCountRange, + }); + // Also set as tags so they can be used to filter events in Sentry + Sentry.setTag('room_type', isDm ? 'dm' : 'group'); + Sentry.setTag('room_encrypted', String(encrypted)); + }, [mx, mDirect, roomId]); + + return null; +} + +function SentryTagsFeature() { + const settings = useAtomValue(settingsAtom); + + useEffect(() => { + // Core rendering tags — indexed in Sentry for filtering/search + Sentry.setTag('message_layout', String(settings.messageLayout)); + Sentry.setTag('message_spacing', String(settings.messageSpacing)); + Sentry.setTag('twitter_emoji', String(settings.twitterEmoji)); + Sentry.setTag('is_markdown', String(settings.isMarkdown)); + Sentry.setTag('page_zoom', String(settings.pageZoom)); + if (settings.themeId) Sentry.setTag('theme_id', settings.themeId); + // Additional high-value tags for bug reproduction + Sentry.setTag('use_right_bubbles', String(settings.useRightBubbles)); + Sentry.setTag('reduced_motion', String(settings.reducedMotion)); + Sentry.setTag('send_presence', String(settings.sendPresence)); + Sentry.setTag('enter_for_newline', String(settings.enterForNewline)); + Sentry.setTag('media_auto_load', String(settings.mediaAutoLoad)); + Sentry.setTag('url_preview', String(settings.urlPreview)); + Sentry.setTag('use_system_theme', String(settings.useSystemTheme)); + Sentry.setTag('uniform_icons', String(settings.uniformIcons)); + Sentry.setTag('jumbo_emoji_size', String(settings.jumboEmojiSize)); + Sentry.setTag('caption_position', String(settings.captionPosition)); + Sentry.setTag('right_swipe_action', String(settings.rightSwipeAction)); + // Full settings snapshot as structured Additional Data on every event + Sentry.setContext('settings', { ...settings }); + }, [settings]); + + return null; +} + function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); @@ -649,6 +767,9 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + + + {children} ); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 8a9f23052..69ef85340 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -16,6 +16,7 @@ import { import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient } from '$types/matrix-sdk'; import FocusTrap from 'focus-trap-react'; import { useRef, MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react'; +import * as Sentry from '@sentry/react'; import { useNavigate } from 'react-router-dom'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { @@ -150,6 +151,11 @@ function ClientRootOptions({ mx, onLogout }: ClientRootOptionsProps) { const useLogoutListener = (mx?: MatrixClient) => { useEffect(() => { const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Session forcibly logged out by server', + level: 'warning', + }); if (mx) stopClient(mx); await mx?.clearStores(); window.localStorage.clear(); @@ -180,6 +186,8 @@ export function ClientRoot({ children }: ClientRootProps) { const { baseUrl, userId } = activeSession ?? {}; const loadedUserIdRef = useRef(undefined); + const syncStartTimeRef = useRef(performance.now()); + const firstSyncReadyRef = useRef(false); const [loadState, loadMatrix, setLoadState] = useAsyncCallback( useCallback(async () => { @@ -281,11 +289,68 @@ export function ClientRoot({ children }: ClientRootProps) { mx, useCallback((state: string) => { if (isClientReady(state)) { + if (!firstSyncReadyRef.current) { + firstSyncReadyRef.current = true; + Sentry.metrics.distribution( + 'sable.sync.time_to_ready_ms', + performance.now() - syncStartTimeRef.current + ); + } setLoading(false); } }, []) ); + // Set matrix client context: homeserver and sync type (not PII) + useEffect(() => { + if (!activeSession?.baseUrl) return undefined; + Sentry.setContext('client', { + homeserver: activeSession.baseUrl, + sliding_sync: clientConfig.slidingSync, + }); + return () => { + Sentry.setContext('client', null); + }; + }, [activeSession?.baseUrl, clientConfig.slidingSync]); + + // Set a pseudonymous hashed user ID for error grouping — never sends raw Matrix ID + useEffect(() => { + if (!mx) return undefined; + const matrixUserId = mx.getUserId(); + if (!matrixUserId) return undefined; + (async () => { + const hashBuffer = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(matrixUserId) + ); + const hashHex = Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .slice(0, 16); + // Include the homeserver domain as `username` — it is not PII (it is the server + // domain, not a personal identifier) and helps segment issues by deployment. + const serverDomain = matrixUserId.split(':')[1] ?? 'unknown'; + Sentry.setUser({ id: hashHex, username: serverDomain }); + })(); + return () => { + Sentry.setUser(null); + }; + }, [mx]); + + // Capture fatal client failures — useAsyncCallback swallows these into state so + // they never reach the React ErrorBoundary; explicit capture is required. + useEffect(() => { + if (loadState.status === AsyncStatus.Error) { + Sentry.captureException(loadState.error, { tags: { phase: 'load' } }); + } + }, [loadState]); + + useEffect(() => { + if (startState.status === AsyncStatus.Error) { + Sentry.captureException(startState.error, { tags: { phase: 'start' } }); + } + }, [startState]); + return ( diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index 818d7700a..f55fe5e59 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -1,6 +1,7 @@ import { MatrixClient, SyncState } from '$types/matrix-sdk'; import { useCallback, useState } from 'react'; import { Box, config, Line, Text } from 'folds'; +import * as Sentry from '@sentry/react'; import { useSyncState } from '$hooks/useSyncState'; import { ContainerColor } from '$styles/ContainerColor.css'; @@ -27,6 +28,18 @@ export function SyncStatus({ mx }: SyncStatusProps) { } return { current, previous }; }); + + if (current === SyncState.Reconnecting || current === SyncState.Error) { + Sentry.addBreadcrumb({ + category: 'sync', + message: `Sync state changed to ${current}`, + level: current === SyncState.Error ? 'error' : 'warning', + data: { previous }, + }); + Sentry.metrics.count('sable.sync.degraded', 1, { + attributes: { state: current }, + }); + } }, []) ); diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index f2573b611..7a6718b53 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -1,9 +1,13 @@ -import { useMemo } from 'react'; +import { useMemo, useState, useCallback, useRef, useEffect } from 'react'; +import * as Sentry from '@sentry/react'; import { useNavigate } from 'react-router-dom'; import { Avatar, Text, Box, toRem } from 'folds'; import { useAtomValue } from 'jotai'; -import { Room } from '$types/matrix-sdk'; +import { Room, SyncState } from '$types/matrix-sdk'; +import { useDirects } from '$state/hooks/roomList'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { mDirectAtom } from '$state/mDirectList'; +import { allRoomsAtom } from '$state/room-list/roomList'; import { roomToUnreadAtom } from '$state/room/roomToUnread'; import { getDirectRoomPath } from '$pages/pathUtils'; import { @@ -18,12 +22,14 @@ import { UserAvatar } from '$components/user-avatar'; import { getDirectRoomAvatarUrl } from '$utils/room'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { nameInitials } from '$utils/common'; +import { factoryRoomIdByActivity } from '$utils/sort'; import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '$utils/matrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { useGroupDMMembers } from '$hooks/useGroupDMMembers'; -import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; +import { useSyncState } from '$hooks/useSyncState'; import * as css from './DirectDMsList.css'; +const MAX_DM_AVATARS = 3; const MAX_GROUP_MEMBERS = 3; type DMItemProps = { @@ -158,17 +164,63 @@ function DMItem({ room, selected }: DMItemProps) { export function DirectDMsList() { const mx = useMatrixClient(); + const mDirects = useAtomValue(mDirectAtom); + const directs = useDirects(mx, allRoomsAtom, mDirects); + const roomToUnread = useAtomValue(roomToUnreadAtom); const selectedRoomId = useSelectedRoom(); - const sidebarRoomIds = useSidebarDirectRoomIds(); - - const recentDMs = useMemo( - () => - sidebarRoomIds - .map((roomId) => mx.getRoom(roomId)) - .filter((room): room is Room => room !== null), - [sidebarRoomIds, mx] + + // Track sync state to wait for initial sync completion + const [syncReady, setSyncReady] = useState(false); + const mountTimeRef = useRef(performance.now()); + const firstReadyRef = useRef(false); + + useSyncState( + mx, + useCallback((state, prevState) => { + // Consider ready after initial sync reaches Syncing state + // This ensures m.direct and unread counts are populated + if (state === SyncState.Syncing && prevState !== SyncState.Syncing) { + setSyncReady(true); + } + // Also set ready if we're already syncing (e.g., after a refresh while still online) + if (state === SyncState.Syncing || state === SyncState.Catchup) { + setSyncReady(true); + } + }, []) ); + useEffect(() => { + if (syncReady && !firstReadyRef.current) { + firstReadyRef.current = true; + Sentry.metrics.distribution( + 'sable.roomlist.time_to_ready_ms', + performance.now() - mountTimeRef.current + ); + } + }, [syncReady]); + + // Get up to MAX_DM_AVATARS recent DMs that have unread messages + const recentDMs = useMemo(() => { + // Don't show DMs until initial sync completes + if (!syncReady) { + return []; + } + + // Filter to only DMs with unread messages + const withUnread = directs.filter((roomId) => { + const unread = roomToUnread.get(roomId); + return unread && (unread.total > 0 || unread.highlight > 0); + }); + + // Sort by activity + const sorted = withUnread.sort(factoryRoomIdByActivity(mx)); + + return sorted + .slice(0, MAX_DM_AVATARS) + .map((roomId) => mx.getRoom(roomId)) + .filter((room): room is Room => room !== null); + }, [directs, mx, roomToUnread, syncReady]); + if (recentDMs.length === 0) { return null; } diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx index 3503c7f92..dd2a7d6e8 100644 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ b/src/app/pages/client/sidebar/DirectTab.tsx @@ -1,4 +1,4 @@ -import { MouseEventHandler, forwardRef, useMemo, useState } from 'react'; +import { MouseEventHandler, forwardRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; import FocusTrap from 'focus-trap-react'; @@ -25,7 +25,6 @@ import { stopPropagation } from '$utils/keyboard'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import { useDirectRooms } from '$pages/client/direct/useDirectRooms'; -import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; type DirectMenuProps = { requestClose: () => void; @@ -69,14 +68,7 @@ export function DirectTab() { const mDirects = useAtomValue(mDirectAtom); const directs = useDirects(mx, allRoomsAtom, mDirects); - const sidebarRoomIds = useSidebarDirectRoomIds(); - // Only count unread for DMs not already shown as individual avatars in the - // sidebar — prevents double-badging (issue #235). - const overflowDirects = useMemo(() => { - const sidebarSet = new Set(sidebarRoomIds); - return directs.filter((id) => !sidebarSet.has(id)); - }, [directs, sidebarRoomIds]); - const directUnread = useRoomsUnread(overflowDirects, roomToUnreadAtom); + const directUnread = useRoomsUnread(directs, roomToUnreadAtom); const [menuAnchor, setMenuAnchor] = useState(); const directSelected = useDirectSelected(); diff --git a/src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts b/src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts deleted file mode 100644 index 8c62d3af7..000000000 --- a/src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useMemo, useState, useCallback } from 'react'; -import { useAtomValue } from 'jotai'; -import { SyncState } from '$types/matrix-sdk'; -import { useDirects } from '$state/hooks/roomList'; -import { useMatrixClient } from '$hooks/useMatrixClient'; -import { mDirectAtom } from '$state/mDirectList'; -import { allRoomsAtom } from '$state/room-list/roomList'; -import { roomToUnreadAtom } from '$state/room/roomToUnread'; -import { factoryRoomIdByActivity } from '$utils/sort'; -import { useSyncState } from '$hooks/useSyncState'; - -/** Maximum number of individual DM avatars shown in the sidebar. */ -export const MAX_SIDEBAR_DMS = 3; - -/** - * Returns the room IDs of DMs currently displayed as individual avatars in the - * sidebar `DirectDMsList`. These are the first `MAX_SIDEBAR_DMS` unread DMs - * sorted by recent activity, available only after initial sync completes. - * - * Used by `DirectDMsList` to decide which rooms to render, and by `DirectTab` - * to exclude those rooms from its own badge count (prevents double-badging). - */ -export const useSidebarDirectRoomIds = (): string[] => { - const mx = useMatrixClient(); - const mDirects = useAtomValue(mDirectAtom); - const directs = useDirects(mx, allRoomsAtom, mDirects); - const roomToUnread = useAtomValue(roomToUnreadAtom); - - const [syncReady, setSyncReady] = useState(false); - - useSyncState( - mx, - useCallback((state, prevState) => { - if (state === SyncState.Syncing && prevState !== SyncState.Syncing) { - setSyncReady(true); - } - if (state === SyncState.Syncing || state === SyncState.Catchup) { - setSyncReady(true); - } - }, []) - ); - - return useMemo(() => { - if (!syncReady) return []; - - const withUnread = directs.filter((roomId) => { - const unread = roomToUnread.get(roomId); - return unread && (unread.total > 0 || unread.highlight > 0); - }); - - const sorted = withUnread.sort(factoryRoomIdByActivity(mx)); - return sorted.slice(0, MAX_SIDEBAR_DMS); - }, [directs, mx, roomToUnread, syncReady]); -}; diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 960f21ced..570b66f76 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -218,24 +218,11 @@ export class CallEmbed { this.readUpToMap[room.roomId] = roomEvent.getId()!; }); - // Attach listeners for feeding events - the underlying widget classes handle permissions for us. - // Bind once and store via disposables so the same function reference is used for removal. - // Using .bind(this) at call-site would create a new function every time, making .off() a no-op - // and causing MaxListeners warnings when the embed is recreated during sync retries. - const boundOnEvent = this.onEvent.bind(this); - const boundOnEventDecrypted = this.onEventDecrypted.bind(this); - const boundOnStateUpdate = this.onStateUpdate.bind(this); - const boundOnToDeviceEvent = this.onToDeviceEvent.bind(this); - this.mx.on(ClientEvent.Event, boundOnEvent); - this.mx.on(MatrixEventEvent.Decrypted, boundOnEventDecrypted); - this.mx.on(RoomStateEvent.Events, boundOnStateUpdate); - this.mx.on(ClientEvent.ToDeviceEvent, boundOnToDeviceEvent); - this.disposables.push(() => { - this.mx.off(ClientEvent.Event, boundOnEvent); - this.mx.off(MatrixEventEvent.Decrypted, boundOnEventDecrypted); - this.mx.off(RoomStateEvent.Events, boundOnStateUpdate); - this.mx.off(ClientEvent.ToDeviceEvent, boundOnToDeviceEvent); - }); + // Attach listeners for feeding events - the underlying widget classes handle permissions for us + this.mx.on(ClientEvent.Event, this.onEvent.bind(this)); + this.mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); + this.mx.on(RoomStateEvent.Events, this.onStateUpdate.bind(this)); + this.mx.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); } /** @@ -252,7 +239,11 @@ export class CallEmbed { this.container.removeChild(this.iframe); this.control.dispose(); - // Listener removal is handled by the disposables pushed in start(). + this.mx.off(ClientEvent.Event, this.onEvent.bind(this)); + this.mx.off(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); + this.mx.off(RoomStateEvent.Events, this.onStateUpdate.bind(this)); + this.mx.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); + // Clear internal state this.readUpToMap = {}; this.eventsToFeed = new WeakSet(); diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts index 1452fb971..b05055c43 100644 --- a/src/app/state/callEmbed.ts +++ b/src/app/state/callEmbed.ts @@ -1,8 +1,12 @@ import { atom } from 'jotai'; +import * as Sentry from '@sentry/react'; import { CallEmbed } from '../plugins/call'; const baseCallEmbedAtom = atom(undefined); +// Tracks when the active call embed was created, for lifetime measurement. +let embedCreatedAt: number | null = null; + export const callEmbedAtom = atom( (get) => get(baseCallEmbedAtom), (get, set, callEmbed) => { @@ -10,9 +14,21 @@ export const callEmbedAtom = atom void; +const BREADCRUMB_DISABLED_KEY = 'sable_sentry_breadcrumb_disabled'; + class DebugLoggerService { private logs: LogEntry[] = []; @@ -38,9 +42,22 @@ class DebugLoggerService { private listeners: Set = new Set(); + private disabledBreadcrumbCategories: Set; + + private sentryStats = { errors: 0, warnings: 0 }; + constructor() { // Check if debug logging is enabled from localStorage this.enabled = localStorage.getItem('sable_internal_debug') === '1'; + // Load disabled breadcrumb categories + try { + const stored = localStorage.getItem(BREADCRUMB_DISABLED_KEY); + this.disabledBreadcrumbCategories = new Set( + stored ? (JSON.parse(stored) as LogCategory[]) : [] + ); + } catch { + this.disabledBreadcrumbCategories = new Set(); + } } public isEnabled(): boolean { @@ -99,6 +116,9 @@ class DebugLoggerService { // Notify listeners this.notifyListeners(entry); + // Send to Sentry + this.sendToSentry(entry); + // Also log to console for developer convenience const prefix = `[sable:${category}:${namespace}]`; const consoleLevel = level === 'debug' ? 'log' : level; @@ -106,6 +126,127 @@ class DebugLoggerService { console[consoleLevel](prefix, message, data !== undefined ? data : ''); } + public getBreadcrumbCategoryEnabled(category: LogCategory): boolean { + return !this.disabledBreadcrumbCategories.has(category); + } + + public setBreadcrumbCategoryEnabled(category: LogCategory, enabled: boolean): void { + if (enabled) { + this.disabledBreadcrumbCategories.delete(category); + } else { + this.disabledBreadcrumbCategories.add(category); + } + const disabledArray = Array.from(this.disabledBreadcrumbCategories); + if (disabledArray.length > 0) { + localStorage.setItem(BREADCRUMB_DISABLED_KEY, JSON.stringify(disabledArray)); + } else { + localStorage.removeItem(BREADCRUMB_DISABLED_KEY); + } + } + + public getSentryStats(): { errors: number; warnings: number } { + return { ...this.sentryStats }; + } + + /** + * Send log entries to Sentry for error tracking and breadcrumbs + */ + private sendToSentry(entry: LogEntry): void { + // Map log levels to Sentry severity + const sentryLevelMap: Record = { + debug: 'debug', + info: 'info', + warn: 'warning', + error: 'error', + }; + const sentryLevel: Sentry.SeverityLevel = sentryLevelMap[entry.level] ?? 'error'; + + // Add breadcrumb for all logs (helps with debugging in Sentry), unless category is disabled + if (!this.disabledBreadcrumbCategories.has(entry.category)) Sentry.addBreadcrumb({ + category: `${entry.category}.${entry.namespace}`, + message: entry.message, + level: sentryLevel, + data: entry.data ? { data: entry.data } : undefined, + timestamp: entry.timestamp / 1000, // Sentry expects seconds + }); + + // Send as structured log to the Sentry Logs product (requires enableLogs: true) + const logMsg = `[${entry.category}:${entry.namespace}] ${entry.message}`; + // Flatten primitive values from entry.data so they become searchable attributes in Sentry Logs + const logDataAttrs: Record = {}; + if (entry.data && typeof entry.data === 'object' && !(entry.data instanceof Error)) { + for (const [k, v] of Object.entries(entry.data)) { + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + logDataAttrs[k] = v; + } + } + } + const logAttrs = { category: entry.category, namespace: entry.namespace, ...logDataAttrs }; + if (entry.level === 'debug') Sentry.logger.debug(logMsg, logAttrs); + else if (entry.level === 'info') Sentry.logger.info(logMsg, logAttrs); + else if (entry.level === 'warn') Sentry.logger.warn(logMsg, logAttrs); + else Sentry.logger.error(logMsg, logAttrs); + + // Track error/warn rates as metrics, tagged by category for filtering in Sentry dashboards + if (entry.level === 'error' || entry.level === 'warn') { + Sentry.metrics.count(`sable.${entry.level}s`, 1, { + attributes: { category: entry.category, namespace: entry.namespace }, + }); + } + + // Capture errors and warnings as Sentry events + if (entry.level === 'error') { + this.sentryStats.errors += 1; + // If data is an Error object, capture it as an exception + if (entry.data instanceof Error) { + Sentry.captureException(entry.data, { + level: 'error', + tags: { + category: entry.category, + namespace: entry.namespace, + }, + contexts: { + debugLog: { + message: entry.message, + timestamp: new Date(entry.timestamp).toISOString(), + }, + }, + }); + } else { + // Otherwise capture as a message + Sentry.captureMessage(`[${entry.category}:${entry.namespace}] ${entry.message}`, { + level: 'error', + tags: { + category: entry.category, + namespace: entry.namespace, + }, + contexts: { + debugLog: { + data: entry.data, + timestamp: new Date(entry.timestamp).toISOString(), + }, + }, + }); + } + } else if (entry.level === 'warn' && Math.random() < 0.1) { + // Capture 10% of warnings to avoid overwhelming Sentry + this.sentryStats.warnings += 1; + Sentry.captureMessage(`[${entry.category}:${entry.namespace}] ${entry.message}`, { + level: 'warning', + tags: { + category: entry.category, + namespace: entry.namespace, + }, + contexts: { + debugLog: { + data: entry.data, + timestamp: new Date(entry.timestamp).toISOString(), + }, + }, + }); + } + } + public getLogs(): LogEntry[] { return [...this.logs]; } @@ -152,6 +293,54 @@ class DebugLoggerService { 2 ); } + + /** + * Export logs in a format suitable for attaching to Sentry reports + */ + public exportLogsForSentry(): Record[] { + return this.logs.map((log) => ({ + timestamp: new Date(log.timestamp).toISOString(), + level: log.level, + category: log.category, + namespace: log.namespace, + message: log.message, + data: log.data, + })); + } + + /** + * Attach recent logs to the next Sentry event + * Useful for bug reports to include context + */ + public attachLogsToSentry(limit = 100): void { + const recentLogs = this.logs.slice(-limit); + const logsData = recentLogs.map((log) => ({ + time: new Date(log.timestamp).toISOString(), + level: log.level, + category: log.category, + namespace: log.namespace, + message: log.message, + // Only include data for errors/warnings to avoid excessive payload + ...(log.level === 'error' || log.level === 'warn' ? { data: log.data } : {}), + })); + + // Add to context + Sentry.setContext('recentLogs', { + count: recentLogs.length, + logs: logsData, + }); + + // Also add as extra data for better visibility in Sentry UI + Sentry.getCurrentScope().setExtra('debugLogs', logsData); + + // Add as attachment for download + const logsText = JSON.stringify(logsData, null, 2); + Sentry.getCurrentScope().addAttachment({ + filename: 'debug-logs.json', + data: logsText, + contentType: 'application/json', + }); + } } // Singleton instance diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 69fadc021..abc155978 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -19,6 +19,7 @@ import { IImageInfo, IThumbnailContent, IVideoInfo } from '$types/matrix/common' import { AccountDataEvent } from '$types/matrix/accountData'; import { Membership, MessageEvent, StateEvent } from '$types/matrix/room'; import { getEventReactions, getReactionContent, getStateEvent } from './room'; +import * as Sentry from '@sentry/react'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -163,6 +164,7 @@ export const uploadContent = async ( ) => { const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; + const uploadStart = performance.now(); const uploadPromise = mx.uploadContent(file, { name, type: fileType, @@ -173,9 +175,21 @@ export const uploadContent = async ( try { const data = await uploadPromise; const mxc = data.content_uri; - if (mxc) onSuccess(mxc); - else onError(new MatrixError(data)); + if (mxc) { + const mediaType = file.type.split('/')[0] || 'unknown'; + Sentry.metrics.distribution('sable.media.upload_latency_ms', performance.now() - uploadStart, { + attributes: { type: mediaType }, + }); + Sentry.metrics.distribution('sable.media.upload_bytes', file.size, { + attributes: { type: mediaType }, + }); + onSuccess(mxc); + } else { + Sentry.metrics.count('sable.media.upload_error', 1, { attributes: { reason: 'no_uri' } }); + onError(new MatrixError(data)); + } } catch (e: any) { + Sentry.metrics.count('sable.media.upload_error', 1, { attributes: { reason: 'exception' } }); const error = typeof e?.message === 'string' ? e.message : undefined; const errcode = typeof e?.name === 'string' ? e.message : undefined; onError(new MatrixError({ error, errcode })); diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 21fa6e290..4a421b81e 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -30,6 +30,7 @@ import { StateEvent, UnreadInfo, } from '$types/matrix/room'; +import * as Sentry from '@sentry/react'; export const getStateEvent = ( room: Room, @@ -557,7 +558,22 @@ export const decryptAllTimelineEvent = async (mx: MatrixClient, timeline: EventT .filter((event) => event.isEncrypted()) .reverse() .map((event) => event.attemptDecryption(crypto as CryptoBackend, { isRetry: true })); - await Promise.allSettled(decryptionPromises); + const decryptStart = performance.now(); + await Sentry.startSpan( + { + name: 'decrypt.bulk', + op: 'matrix.crypto', + attributes: { event_count: decryptionPromises.length }, + }, + () => Promise.allSettled(decryptionPromises) + ); + if (decryptionPromises.length > 0) { + Sentry.metrics.distribution( + 'sable.decryption.bulk_latency_ms', + performance.now() - decryptStart, + { attributes: { event_count: String(decryptionPromises.length) } } + ); + } }; export const getReactionContent = (eventId: string, key: string, shortcode?: string) => ({ diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 71bbc3167..ea9b9e876 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -17,6 +17,7 @@ import { import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; +import * as Sentry from '@sentry/react'; import { pushSessionToSW } from '../sw-session'; import { cryptoCallbacks } from './secretStorageKeys'; import { SlidingSyncConfig, SlidingSyncDiagnostics, SlidingSyncManager } from './slidingSync'; @@ -144,12 +145,17 @@ const isClientReadyForUi = (syncState: string | null): boolean => const waitForClientReady = (mx: MatrixClient, timeoutMs: number): Promise => new Promise((resolve) => { + const waitStart = performance.now(); if (isClientReadyForUi(mx.getSyncState())) { + Sentry.metrics.distribution('sable.sync.client_ready_ms', 0, { + attributes: { timed_out: 'false' }, + }); resolve(); return; } let timer = 0; + let timedOut = false; let finish = () => {}; const onSync = (state: string) => { debugLog.info('sync', `Sync state changed: ${state}`, { @@ -165,10 +171,25 @@ const waitForClientReady = (mx: MatrixClient, timeoutMs: number): Promise settled = true; mx.removeListener(ClientEvent.Sync, onSync); clearTimeout(timer); + const waitMs = performance.now() - waitStart; + Sentry.metrics.distribution('sable.sync.client_ready_ms', waitMs, { + attributes: { timed_out: String(timedOut) }, + }); + if (timedOut) { + Sentry.addBreadcrumb({ + category: 'sync', + message: 'waitForClientReady timed out — client may be stuck', + level: 'warning', + data: { timeout_ms: timeoutMs }, + }); + } resolve(); }; - timer = window.setTimeout(finish, timeoutMs); + timer = window.setTimeout(() => { + timedOut = true; + finish(); + }, timeoutMs); mx.on(ClientEvent.Sync, onSync); }); @@ -287,6 +308,12 @@ export const initClient = async (session: Session): Promise => { const wipeAllStores = async () => { log.warn('initClient: wiping all stores for', session.userId); debugLog.warn('sync', 'Wiping all stores due to mismatch', { userId: session.userId }); + Sentry.addBreadcrumb({ + category: 'crypto', + message: 'Crypto store mismatch — wiping local stores and retrying', + level: 'warning', + }); + Sentry.metrics.count('sable.crypto.store_wipe', 1); await deleteSessionStores(storeName); try { const allDbs = await window.indexedDB.databases(); @@ -390,6 +417,9 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): fallbackFromSliding, reason, }); + Sentry.metrics.count('sable.sync.transport', 1, { + attributes: { transport: 'classic', reason, fallback: String(fallbackFromSliding) }, + }); await mx.startClient({ lazyLoadMembers: true, pollTimeout: FAST_SYNC_POLL_TIMEOUT_MS, @@ -487,6 +517,9 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): fallbackFromSliding: false, reason: 'sliding_active', }); + Sentry.metrics.count('sable.sync.transport', 1, { + attributes: { transport: 'sliding', reason: 'sliding_active', fallback: 'false' }, + }); try { await mx.startClient({ diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 7cd01cab7..d403dd1e6 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -17,6 +17,7 @@ import { } from '$types/matrix-sdk'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; +import * as Sentry from '@sentry/react'; const log = createLogger('slidingSync'); const debugLog = createDebugLogger('slidingSync'); @@ -324,6 +325,12 @@ export class SlidingSyncManager { private previousListCounts: Map = new Map(); + /** Wall-clock time recorded in attach() — used to compute true initial-sync latency. */ + private attachTime: number | null = null; + + /** Span covering the period from attach() to the first successful complete cycle. */ + private initialSyncSpan: ReturnType | null = null; + public readonly slidingSync: SlidingSync; public readonly probeTimeoutMs: number; @@ -369,6 +376,9 @@ export class SlidingSyncManager { this.onLifecycle = (state, resp, err) => { const syncStartTime = performance.now(); this.syncCount += 1; + Sentry.metrics.count('sable.sync.cycle', 1, { + attributes: { transport: 'sliding', state }, + }); debugLog.info('sync', `Sliding sync lifecycle: ${state} (cycle #${this.syncCount})`, { state, @@ -384,6 +394,9 @@ export class SlidingSyncManager { syncNumber: this.syncCount, state, }); + Sentry.metrics.count('sable.sync.error', 1, { + attributes: { transport: 'sliding', state }, + }); } if (this.disposed) { @@ -425,22 +438,38 @@ export class SlidingSyncManager { }); } + const syncDuration = performance.now() - syncStartTime; + // Mark initial sync as complete after first successful cycle if (!this.initialSyncCompleted) { this.initialSyncCompleted = true; + // Wall-clock ms from attach() — the actual user-perceived wait for first data. + const initialElapsed = + this.attachTime != null ? performance.now() - this.attachTime : syncDuration; debugLog.info('sync', 'Initial sync completed', { syncNumber: this.syncCount, totalRoomCount, listCounts: Object.fromEntries( this.listKeys.map((key) => [key, this.slidingSync.getListData(key)?.joinedCount ?? 0]) ), - timeElapsed: `${(performance.now() - syncStartTime).toFixed(2)}ms`, + timeElapsed: `${initialElapsed.toFixed(2)}ms`, + }); + Sentry.metrics.distribution('sable.sync.initial_ms', initialElapsed, { + attributes: { transport: 'sliding' }, }); + this.initialSyncSpan?.setAttributes({ + 'sync.cycles_to_ready': this.syncCount, + 'sync.rooms_at_ready': totalRoomCount, + }); + this.initialSyncSpan?.end(); + this.initialSyncSpan = null; } this.expandListsToKnownCount(); - const syncDuration = performance.now() - syncStartTime; + Sentry.metrics.distribution('sable.sync.processing_ms', syncDuration, { + attributes: { transport: 'sliding' }, + }); if (syncDuration > 1000) { debugLog.warn('sync', 'Slow sync cycle detected', { syncNumber: this.syncCount, @@ -502,6 +531,13 @@ export class SlidingSyncManager { lists: this.listKeys, }); + this.attachTime = performance.now(); + this.initialSyncSpan = Sentry.startInactiveSpan({ + name: 'sync.initial', + op: 'matrix.sync', + attributes: { 'sync.transport': 'sliding', 'sync.proxy': this.proxyBaseUrl }, + }); + this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle); const connection = ( typeof navigator !== 'undefined' ? (navigator as any).connection : undefined @@ -674,6 +710,18 @@ export class SlidingSyncManager { if (allListsComplete) { this.listsFullyLoaded = true; log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`); + const totalRooms = this.listKeys.reduce( + (sum, key) => sum + (this.slidingSync.getListData(key)?.joinedCount ?? 0), + 0 + ); + const listsLoadedMs = + this.attachTime != null ? Math.round(performance.now() - this.attachTime) : 0; + Sentry.metrics.distribution('sable.sync.lists_loaded_ms', listsLoadedMs, { + attributes: { transport: 'sliding' }, + }); + Sentry.metrics.gauge('sable.sync.total_rooms', totalRooms, { + attributes: { transport: 'sliding' }, + }); } else if (expandedAny) { log.log(`Sliding Sync lists expanding... for ${this.mx.getUserId()}`); } @@ -763,52 +811,64 @@ export class SlidingSyncManager { let endIndex = batchSize - 1; let hasMore = true; let firstTime = true; - - const spideringRequiredState: MSC3575List['required_state'] = [ - [EventType.RoomJoinRules, ''], - [EventType.RoomAvatar, ''], - [EventType.RoomTombstone, ''], - [EventType.RoomEncryption, ''], - [EventType.RoomCreate, ''], - [EventType.RoomTopic, ''], - [EventType.RoomCanonicalAlias, ''], - [EventType.RoomMember, MSC3575_STATE_KEY_ME], - ['m.space.child', MSC3575_WILDCARD], - ['im.ponies.room_emotes', MSC3575_WILDCARD], - ]; - - while (hasMore) { - if (this.disposed) return; - const ranges: [number, number][] = [[0, endIndex]]; - try { - if (firstTime) { - // Full setList on first call to register the list with all params. - this.slidingSync.setList(LIST_SEARCH, { - ranges, - sort: ['by_recency'], - timeline_limit: 0, - required_state: spideringRequiredState, - }); - } else { - // Cheaper range-only update for subsequent pages; sticky params are preserved. - this.slidingSync.setListRanges(LIST_SEARCH, ranges); + let batchCount = 0; + + await Sentry.startSpan( + { name: 'sync.spidering', op: 'matrix.sync', attributes: { 'sync.transport': 'sliding' } }, + async (span) => { + const spideringRequiredState: MSC3575List['required_state'] = [ + [EventType.RoomJoinRules, ''], + [EventType.RoomAvatar, ''], + [EventType.RoomTombstone, ''], + [EventType.RoomEncryption, ''], + [EventType.RoomCreate, ''], + [EventType.RoomTopic, ''], + [EventType.RoomCanonicalAlias, ''], + [EventType.RoomMember, MSC3575_STATE_KEY_ME], + ['m.space.child', MSC3575_WILDCARD], + ['im.ponies.room_emotes', MSC3575_WILDCARD], + ]; + + while (hasMore) { + if (this.disposed) return; + batchCount += 1; + const ranges: [number, number][] = [[0, endIndex]]; + try { + if (firstTime) { + // Full setList on first call to register the list with all params. + this.slidingSync.setList(LIST_SEARCH, { + ranges, + sort: ['by_recency'], + timeline_limit: 0, + required_state: spideringRequiredState, + }); + } else { + // Cheaper range-only update for subsequent pages; sticky params are preserved. + this.slidingSync.setListRanges(LIST_SEARCH, ranges); + } + } catch { + // Swallow errors — the next iteration will retry with updated ranges. + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((res) => { + setTimeout(res, gapBetweenRequestsMs); + }); + } + + if (this.disposed) return; + const listData = this.slidingSync.getListData(LIST_SEARCH); + hasMore = endIndex + 1 < (listData?.joinedCount ?? 0); + endIndex += batchSize; + firstTime = false; } - } catch { - // Swallow errors — the next iteration will retry with updated ranges. - } finally { - // eslint-disable-next-line no-await-in-loop - await new Promise((res) => { - setTimeout(res, gapBetweenRequestsMs); + const finalCount = this.slidingSync.getListData(LIST_SEARCH)?.joinedCount ?? 0; + span.setAttributes({ + 'spidering.batches': batchCount, + 'spidering.total_rooms': finalCount, }); + log.log(`Sliding Sync spidering complete for ${this.mx.getUserId()}`); } - - if (this.disposed) return; - const listData = this.slidingSync.getListData(LIST_SEARCH); - hasMore = endIndex + 1 < (listData?.joinedCount ?? 0); - endIndex += batchSize; - firstTime = false; - } - log.log(`Sliding Sync spidering complete for ${this.mx.getUserId()}`); + ); } /** @@ -873,6 +933,9 @@ export class SlidingSyncManager { } this.activeRoomSubscriptions.add(roomId); this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions)); + Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { + attributes: { transport: 'sliding' }, + }); log.log(`Sliding Sync active room subscription added: ${roomId}`); } @@ -885,6 +948,9 @@ export class SlidingSyncManager { if (this.disposed) return; this.activeRoomSubscriptions.delete(roomId); this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions)); + Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { + attributes: { transport: 'sliding' }, + }); log.log(`Sliding Sync active room subscription removed: ${roomId}`); } @@ -893,25 +959,33 @@ export class SlidingSyncManager { proxyBaseUrl: string, probeTimeoutMs: number ): Promise { - try { - const response = await mx.slidingSync( - { - lists: { - probe: { - ranges: [[0, 0]], - timeline_limit: 1, - required_state: [], + return Sentry.startSpan( + { name: 'sync.probe', op: 'matrix.sync', attributes: { 'sync.proxy': proxyBaseUrl } }, + async (span) => { + try { + const response = await mx.slidingSync( + { + lists: { + probe: { + ranges: [[0, 0]], + timeline_limit: 1, + required_state: [], + }, + }, + timeout: 0, + clientTimeout: probeTimeoutMs, }, - }, - timeout: 0, - clientTimeout: probeTimeoutMs, - }, - proxyBaseUrl - ); - - return typeof response.pos === 'string' && response.pos.length > 0; - } catch { - return false; - } + proxyBaseUrl + ); + + const supported = typeof response.pos === 'string' && response.pos.length > 0; + span.setAttribute('probe.supported', supported); + return supported; + } catch { + span.setAttribute('probe.supported', false); + return false; + } + } + ); } } diff --git a/src/index.tsx b/src/index.tsx index 3248458ba..f11c7ef58 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,4 @@ +import './instrument'; import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; import '@fontsource-variable/nunito'; diff --git a/src/instrument.ts b/src/instrument.ts new file mode 100644 index 000000000..e7121da09 --- /dev/null +++ b/src/instrument.ts @@ -0,0 +1,245 @@ +/** + * Sentry instrumentation - MUST be imported first in the application lifecycle + * + * Configure via environment variables: + * - VITE_SENTRY_DSN: Your Sentry DSN (required to enable Sentry) + * - VITE_SENTRY_ENVIRONMENT: Environment name (defaults to MODE) + * - VITE_APP_VERSION: Release version for tracking + */ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import { + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, +} from 'react-router-dom'; + +const dsn = import.meta.env.VITE_SENTRY_DSN; +const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; +const release = import.meta.env.VITE_APP_VERSION; + +// Per-session error event counter for rate limiting +let sessionErrorCount = 0; +const SESSION_ERROR_LIMIT = 50; + +// Check user preferences +const sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false'; +const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true'; + +// Only initialize if DSN is provided and user hasn't opted out +if (dsn && sentryEnabled) { + Sentry.init({ + dsn, + environment, + release, + + // Do not send PII (IP addresses, user identifiers) to protect privacy + sendDefaultPii: false, + + integrations: [ + // React Router v6 browser tracing integration + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + // Session replay with privacy settings (only if user opted in) + ...(replayEnabled + ? [ + Sentry.replayIntegration({ + maskAllText: true, // Mask all text for privacy + blockAllMedia: true, // Block images/video/audio for privacy + maskAllInputs: true, // Mask form inputs + }), + ] + : []), + // Capture console.error/warn as structured logs in the Sentry Logs product + Sentry.consoleLoggingIntegration({ levels: ['error', 'warn'] }), + // Browser profiling — captures JS call stacks during Sentry transactions + Sentry.browserProfilingIntegration(), + ], + + // Performance Monitoring - Tracing + // 100% in development and preview, lower in production for cost control + tracesSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + + // Browser profiling — profiles every sampled session (requires Document-Policy: js-profiling response header) + profileSessionSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + + // Control which URLs get distributed tracing headers + tracePropagationTargets: [ + 'localhost', + /^https:\/\/[^/]*\.sable\.chat/, + // Add your Matrix homeserver domains here if needed + ], + + // Session Replay sampling + // Record 100% in development and preview for testing, 10% in production + // Always record 100% of sessions with errors + replaysSessionSampleRate: + environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + replaysOnErrorSampleRate: 1.0, + + // Enable structured logging to Sentry + enableLogs: true, + + // Scrub sensitive data from structured logs before sending to Sentry + beforeSendLog(log) { + // Drop debug-level logs in production to reduce noise and quota usage + if (log.level === 'debug' && environment === 'production') return null; + // Redact Matrix IDs and tokens from log messages + if (typeof log.message === 'string') { + // eslint-disable-next-line no-param-reassign + log.message = log.message + .replace( + /(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:\s]+)([^\s&]+)/gi, + '$1$2[REDACTED]' + ) + .replace(/@[^:]+:[^\s]+/g, '@[USER_ID]') + .replace(/![^:]+:[^\s]+/g, '![ROOM_ID]') + .replace(/\$[^:\s]+/g, '$[EVENT_ID]'); + } + return log; + }, + + // Rate limiting: cap error events per page-load session to avoid quota exhaustion. + // Separate counters for errors and transactions so perf traces do not drain the error budget. + beforeSendTransaction(event) { + return event; + }, + + // Sanitize sensitive data from all breadcrumb messages before sending to Sentry + beforeBreadcrumb(breadcrumb) { + if (!breadcrumb.message) return breadcrumb; + // Always apply redaction — both token values and Matrix entity IDs. + // Do NOT use single-character patterns like '@', '!', '$' as they are far too broad. + const redacted = breadcrumb.message + // Redact token key=value pairs (e.g. access_token=abc123) + .replace( + /(access_token|password|refresh_token|device_id|session_id|sync_token|next_batch)([=:\s]+)([^\s&"']+)/gi, + '$1$2[REDACTED]' + ) + // Redact full Matrix user IDs: @localpart:server.tld + .replace(/@[^\s:@]+:[^\s,'"(){}\[\]]+/g, '@[USER_ID]') + // Redact full Matrix room IDs: !opaque:server.tld + .replace(/![^\s:]+:[^\s,'"(){}\[\]]+/g, '![ROOM_ID]') + // Redact Matrix event IDs: $base64Url (at least 10 chars to avoid false positives) + .replace(/\$[A-Za-z0-9\-_+/]{10,}/g, '$[EVENT_ID]'); + return redacted === breadcrumb.message ? breadcrumb : { ...breadcrumb, message: redacted }; + }, + + beforeSend(event, hint) { + sessionErrorCount += 1; + if (sessionErrorCount > SESSION_ERROR_LIMIT) { + return null; // Drop event — session limit reached + } + + // Improve grouping for Matrix API errors. + // MatrixError objects carry an `errcode` (e.g. M_FORBIDDEN, M_NOT_FOUND) — use it to + // split errors into meaningful issue groups rather than merging them all by stack trace. + const originalException = hint?.originalException; + if ( + originalException !== null && + typeof originalException === 'object' && + 'errcode' in originalException && + typeof (originalException as Record).errcode === 'string' + ) { + const errcode = (originalException as Record).errcode as string; + // Preserve default grouping AND split by errcode + // eslint-disable-next-line no-param-reassign + event.fingerprint = ['{{ default }}', errcode]; + } + + // Scrub sensitive data from error messages + if (event.message) { + if ( + event.message.includes('access_token') || + event.message.includes('password') || + event.message.includes('token') + ) { + // eslint-disable-next-line no-param-reassign + event.message = event.message.replace( + /(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:]\s*)([^\s&]+)/gi, + '$1$2[REDACTED]' + ); + } + // Redact Matrix IDs to protect user privacy + // eslint-disable-next-line no-param-reassign + event.message = event.message.replace(/@[^:]+:[^\s]+/g, '@[USER_ID]'); + // eslint-disable-next-line no-param-reassign + event.message = event.message.replace(/![^:]+:[^\s]+/g, '![ROOM_ID]'); + // eslint-disable-next-line no-param-reassign + event.message = event.message.replace(/\$[^:\s]+/g, '$[EVENT_ID]'); + } + + // Scrub sensitive data from exception values + if (event.exception?.values) { + event.exception.values.forEach((exception) => { + if (exception.value) { + // eslint-disable-next-line no-param-reassign + exception.value = exception.value.replace( + /(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:]\s*)([^\s&]+)/gi, + '$1$2[REDACTED]' + ); + // Redact Matrix IDs + // eslint-disable-next-line no-param-reassign + exception.value = exception.value.replace(/@[^:]+:[^\s]+/g, '@[USER_ID]'); + // eslint-disable-next-line no-param-reassign + exception.value = exception.value.replace(/![^:]+:[^\s]+/g, '![ROOM_ID]'); + // eslint-disable-next-line no-param-reassign + exception.value = exception.value.replace(/\$[^:\s]+/g, '$[EVENT_ID]'); + } + }); + } + + // Scrub request data + if (event.request?.url) { + // eslint-disable-next-line no-param-reassign + event.request.url = event.request.url.replace( + /(access_token|password|token)([=:]\s*)([^\s&]+)/gi, + '$1$2[REDACTED]' + ); + } + + if (event.request?.headers) { + const headers = event.request.headers as Record; + if (headers.Authorization) { + headers.Authorization = '[REDACTED]'; + } + } + + return event; + }, + }); + + // Expose Sentry globally for debugging and console testing + // Set app-wide attributes on the global scope so they appear on all events and logs + Sentry.getGlobalScope().setAttributes({ + 'app.name': 'sable', + 'app.version': release ?? 'unknown', + }); + + // @ts-expect-error - Adding to window for debugging + window.Sentry = Sentry; + + // eslint-disable-next-line no-console + console.info( + `[Sentry] Initialized for ${environment} environment${replayEnabled ? ' with Session Replay' : ''}` + ); + // eslint-disable-next-line no-console + console.info(`[Sentry] DSN configured: ${dsn?.substring(0, 30)}...`); + // eslint-disable-next-line no-console + console.info(`[Sentry] Release: ${release || 'not set'}`); +} else if (!sentryEnabled) { + // eslint-disable-next-line no-console + console.info('[Sentry] Disabled by user preference'); +} else { + // eslint-disable-next-line no-console + console.info('[Sentry] Disabled - no DSN provided'); +} + +// Export Sentry for use in other parts of the application +export { Sentry }; diff --git a/vite.config.ts b/vite.config.ts index d28133049..bfca2edca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,7 @@ import fs from 'fs'; import path from 'path'; import { cloudflare } from '@cloudflare/vite-plugin'; import { createRequire } from 'module'; +import { sentryVitePlugin } from '@sentry/vite-plugin'; import buildConfig from './build.config'; const packageJson = JSON.parse( @@ -189,6 +190,26 @@ export default defineConfig({ ], include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|txt|map)$/, }), + // Sentry source map upload — only active when credentials are provided at build time + ...(process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT + ? [ + sentryVitePlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + sourcemaps: { + filesToDeleteAfterUpload: ['dist/**/*.map'], + }, + release: { + name: appVersion, + }, + // Annotate React components with data-sentry-* attributes at build + // time so Sentry can show component names in breadcrumbs, spans, + // and replay search instead of raw CSS selectors. + reactComponentAnnotation: { enabled: true }, + }), + ] + : []), ], optimizeDeps: { // Rebuild dep optimizer cache on each dev start to avoid stale API shapes. From 36c4e0591b3ec89e83ace6fed30f0818bc44ae98 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 23:40:58 -0400 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20add=20Sentry=E2=86=92GitHub=20Iss?= =?UTF-8?q?ues=20triage=20for=20PR=20preview=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tag all Sentry events with PR number via VITE_SENTRY_PR env var (instrument.ts: setTag('pr', prNumber) on global scope) - cloudflare-web-preview.yml: inject Sentry DSN, environment=preview, PR number, and source-map secrets into build env for PR runs - New workflow sentry-preview-issues.yml: on every PR push, query Sentry for unresolved issues tagged with the PR number and environment=preview; create a GitHub issue per unique error (deduplicated by sentry-id marker), labelled 'sentry-preview' + 'pr-{N}'; maintain a sticky PR comment with a summary table; reopen closed issues on regression - Labels allow filtering: -label:sentry-preview hides all automated issues --- .github/workflows/cloudflare-web-preview.yml | 16 ++ .github/workflows/sentry-preview-issues.yml | 231 +++++++++++++++++++ src/instrument.ts | 6 + 3 files changed, 253 insertions(+) create mode 100644 .github/workflows/sentry-preview-issues.yml diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 5ddfe5a0e..8b93a4bb9 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -54,6 +54,22 @@ jobs: echo EOF } >> "$GITHUB_OUTPUT" + - name: Set Sentry build environment for PR preview + if: github.event_name == 'pull_request' + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + shell: bash + run: | + echo "VITE_SENTRY_DSN=$VITE_SENTRY_DSN" >> "$GITHUB_ENV" + echo "VITE_SENTRY_ENVIRONMENT=preview" >> "$GITHUB_ENV" + echo "VITE_SENTRY_PR=${{ github.event.pull_request.number }}" >> "$GITHUB_ENV" + echo "SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN" >> "$GITHUB_ENV" + echo "SENTRY_ORG=$SENTRY_ORG" >> "$GITHUB_ENV" + echo "SENTRY_PROJECT=$SENTRY_PROJECT" >> "$GITHUB_ENV" + - name: Setup app and build uses: ./.github/actions/setup with: diff --git a/.github/workflows/sentry-preview-issues.yml b/.github/workflows/sentry-preview-issues.yml new file mode 100644 index 000000000..c81787e74 --- /dev/null +++ b/.github/workflows/sentry-preview-issues.yml @@ -0,0 +1,231 @@ +name: Sentry Preview Error Triage + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'src/**' + - 'index.html' + - 'package.json' + - 'vite.config.ts' + - 'tsconfig.json' + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to triage' + required: true + type: number + +jobs: + triage: + # Only run for PRs from the same repo (not forks) or manual dispatch + if: > + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository) || + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: Triage Sentry preview errors + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const sentryToken = process.env.SENTRY_AUTH_TOKEN; + const sentryOrg = process.env.SENTRY_ORG; + const sentryProject = process.env.SENTRY_PROJECT; + const prNumber = Number(process.env.PR_NUMBER); + + if (!prNumber) { + core.info('No PR number available — skipping triage.'); + return; + } + if (!sentryToken || !sentryOrg || !sentryProject) { + core.warning('Sentry credentials not configured — skipping triage.'); + return; + } + + const COMMENT_MARKER = ''; + const { owner, repo } = context.repo; + + // Create a label if it doesn't already exist + async function ensureLabel(name, description, color) { + try { + await github.rest.issues.getLabel({ owner, repo, name }); + } catch { + try { + await github.rest.issues.createLabel({ owner, repo, name, description, color }); + } catch (err) { + core.warning(`Could not create label "${name}": ${err.message}`); + } + } + } + + // Find an existing GitHub issue that tracks a given Sentry issue ID + async function findExistingGhIssue(sentryIssueId) { + const marker = `sentry-id:${sentryIssueId}`; + const result = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:issue label:sentry-preview "${marker}" in:body`, + }); + return result.data.total_count > 0 ? result.data.items[0] : null; + } + + // Create or update the sticky PR comment with the triage summary table + async function upsertPrComment(rows) { + const now = new Date().toUTCString().replace(':00 GMT', ' UTC'); + let body; + + if (rows.length === 0) { + body = [ + COMMENT_MARKER, + '## Sentry Preview Error Triage', + '', + `No Sentry errors found for this PR's preview deployment as of ${now}.`, + '', + '_This comment updates automatically after each push._', + ].join('\n'); + } else { + const tableRows = rows.map( + (r) => + `| [${r.title.slice(0, 70)}](${r.permalink}) | ${r.count} | ${new Date(r.firstSeen).toLocaleDateString()} | #${r.ghIssueNumber} |` + ); + body = [ + COMMENT_MARKER, + '## Sentry Preview Error Triage', + '', + `**${rows.length} error type(s)** detected in this PR's preview deployment:`, + '', + '| Error | Events | First seen | Issue |', + '| ----- | ------ | ---------- | ----- |', + ...tableRows, + '', + `_Last checked: ${now}. Exclude these from your issues view with \`-label:sentry-preview\`._`, + ].join('\n'); + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + }); + const existing = comments.find( + (c) => c.user.type === 'Bot' && c.body.includes(COMMENT_MARKER) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + } + } + + // Query Sentry for unresolved issues tagged with this PR number in the preview env + const query = encodeURIComponent(`is:unresolved pr:${prNumber}`); + const sentryUrl = + `https://sentry.io/api/0/projects/${sentryOrg}/${sentryProject}/issues/` + + `?query=${query}&environment=preview&limit=100`; + + let sentryIssues; + try { + const resp = await fetch(sentryUrl, { + headers: { Authorization: `Bearer ${sentryToken}` }, + }); + if (!resp.ok) { + const msg = await resp.text(); + core.warning(`Sentry API returned ${resp.status}: ${msg.slice(0, 200)}`); + return; + } + sentryIssues = await resp.json(); + } catch (err) { + core.warning(`Sentry API unreachable: ${err.message}`); + return; + } + + if (!Array.isArray(sentryIssues) || sentryIssues.length === 0) { + await upsertPrComment([]); + return; + } + + // Ensure the shared and PR-specific labels exist + await ensureLabel('sentry-preview', 'Automated Sentry preview error', 'e4e669'); + await ensureLabel(`pr-${prNumber}`, `Preview errors from PR #${prNumber}`, 'fbca04'); + + const rows = []; + for (const issue of sentryIssues) { + const { + id: sentryId, + title, + culprit, + permalink, + count, + userCount, + firstSeen, + lastSeen, + } = issue; + const displayTitle = (title || culprit || 'Unknown error').trim(); + const sentryMarker = `sentry-id:${sentryId}`; + + const existing = await findExistingGhIssue(sentryId); + let ghIssueNumber; + + if (existing) { + ghIssueNumber = existing.number; + // Reopen if it was closed (e.g. after a previous fix that regressed) + if (existing.state === 'closed') { + await github.rest.issues.update({ + owner, + repo, + issue_number: ghIssueNumber, + state: 'open', + }); + core.info(`Reopened GH issue #${ghIssueNumber} for Sentry issue ${sentryId}`); + } + } else { + const issueBody = [ + ``, + `## Sentry Error — PR #${prNumber} Preview`, + '', + `**Error:** [${displayTitle}](${permalink})`, + `**First seen:** ${new Date(firstSeen).toUTCString()}`, + `**Last seen:** ${new Date(lastSeen).toUTCString()}`, + `**Events:** ${count} | **Affected users:** ${userCount}`, + '', + `This issue was automatically created from a Sentry error detected in the preview deployment for PR #${prNumber}.`, + '', + '> [!NOTE]', + '> To exclude automated preview issues from your issues view, filter with: `-label:sentry-preview`', + ].join('\n'); + + const created = await github.rest.issues.create({ + owner, + repo, + title: `[Sentry] ${displayTitle.slice(0, 120)}`, + body: issueBody, + labels: ['sentry-preview', `pr-${prNumber}`], + }); + ghIssueNumber = created.data.number; + core.info(`Created GH issue #${ghIssueNumber} for Sentry issue ${sentryId}`); + } + + rows.push({ title: displayTitle, permalink, count, firstSeen, ghIssueNumber }); + } + + await upsertPrComment(rows); + core.info(`Triage complete: ${rows.length} Sentry issue(s) processed for PR #${prNumber}.`); diff --git a/src/instrument.ts b/src/instrument.ts index e7121da09..9efec360d 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -222,6 +222,12 @@ if (dsn && sentryEnabled) { 'app.version': release ?? 'unknown', }); + // Tag all events with the PR number when running in a PR preview deployment + const prNumber = import.meta.env.VITE_SENTRY_PR; + if (prNumber) { + Sentry.getGlobalScope().setTag('pr', prNumber); + } + // @ts-expect-error - Adding to window for debugging window.Sentry = Sentry; From 4e539b91caf83c23373a0bd29757d8896a9601e6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 23:40:58 -0400 Subject: [PATCH 03/11] feat(sentry): comprehensive data scrubbing, settings UI, and privacy policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before-send scrubbing in instrument.ts covers: - Matrix user IDs (@user:server → @[USER_ID]) - Room IDs (!room:server → ![ROOM_ID]) - Event IDs ($event → $[EVENT_ID]) - Percent-encoded sigils (%40, %21, %24, %23) - Hybrid-encoded forms (decoded sigil + %3A colon) - Tokens, credentials, and query params (access_token, next_batch, etc.) - All span data string values (not just http.url) - Profile URLs, key backup paths, media paths in span data - preview_url query params and exception values - 'none' sentinel for unset room tags Privacy policy added to docs/PRIVACY.md (linked from General settings). DiagnosticsAndPrivacy component added to General settings with error reporting and session replay toggles. TS2367 redundant phase guard removed from useCallSignaling.ts. --- .changeset/fix-direct-tab-double-badge.md | 5 + .changeset/fix-thread-fallback-reply-spec.md | 5 + .changeset/fix-timeline-refresh-scroll.md | 5 + docs/PRIVACY_POLICY.md | 116 ++++++++++++ docs/SENTRY_INTEGRATION.md | 94 ++++++++-- docs/SENTRY_PRIVACY.md | 176 +++++++++--------- src/app/components/DefaultErrorPage.tsx | 8 +- src/app/features/room/RoomInput.tsx | 55 +++++- src/app/features/room/RoomTimeline.tsx | 133 +++++++++---- .../developer-tools/SentrySettings.tsx | 97 +--------- src/app/features/settings/general/General.tsx | 104 +++++++++++ src/app/hooks/useCallSignaling.ts | 31 ++- src/app/pages/Router.tsx | 102 +++++----- src/app/pages/client/ClientNonUIFeatures.tsx | 15 +- .../pages/client/sidebar/DirectDMsList.tsx | 62 ++---- src/app/pages/client/sidebar/DirectTab.tsx | 12 +- .../client/sidebar/useSidebarDirectRoomIds.ts | 54 ++++++ src/app/plugins/call/CallEmbed.ts | 29 ++- src/app/utils/debugLogger.ts | 19 +- src/app/utils/matrix.ts | 12 +- src/instrument.ts | 161 ++++++++++++++-- 21 files changed, 896 insertions(+), 399 deletions(-) create mode 100644 .changeset/fix-direct-tab-double-badge.md create mode 100644 .changeset/fix-thread-fallback-reply-spec.md create mode 100644 .changeset/fix-timeline-refresh-scroll.md create mode 100644 docs/PRIVACY_POLICY.md create mode 100644 src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts diff --git a/.changeset/fix-direct-tab-double-badge.md b/.changeset/fix-direct-tab-double-badge.md new file mode 100644 index 000000000..6d0506363 --- /dev/null +++ b/.changeset/fix-direct-tab-double-badge.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix duplicate unread badges on the /direct/ icon for DM rooms already shown as individual sidebar avatars diff --git a/.changeset/fix-thread-fallback-reply-spec.md b/.changeset/fix-thread-fallback-reply-spec.md new file mode 100644 index 000000000..6eecf251f --- /dev/null +++ b/.changeset/fix-thread-fallback-reply-spec.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix thread messages to include the required `m.in_reply_to` fallback pointing to the latest thread event, so unthreaded clients can display the reply chain correctly per the Matrix spec. diff --git a/.changeset/fix-timeline-refresh-scroll.md b/.changeset/fix-timeline-refresh-scroll.md new file mode 100644 index 000000000..d042bd01e --- /dev/null +++ b/.changeset/fix-timeline-refresh-scroll.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix spurious scroll-to-bottom and MaxListeners warnings on sync gap: stable callback refs and prevEventsLength guard in RoomTimeline, correct CallEmbed .bind(this) listener leak, stable refs in useCallSignaling, and unreadInfoRef to stop per-message listener churn diff --git a/docs/PRIVACY_POLICY.md b/docs/PRIVACY_POLICY.md new file mode 100644 index 000000000..73685e52c --- /dev/null +++ b/docs/PRIVACY_POLICY.md @@ -0,0 +1,116 @@ +# Privacy Policy + +**Effective date:** 2026-03-15 + +Sable is an open-source Matrix client developed by 7w1. + +It is designed to keep data collection to a minimum. Most of the app works on your device and communicates directly with the Matrix homeserver you choose. + +## Who is responsible + +For official Sable builds distributed by the project, the data controller is **7w1**. + +Contact: **security@sable.moe** +Project website: [**https://sable.moe**](https://sable.moe) + +If you use a self-hosted, modified, or third-party build of Sable, that operator may use different diagnostics settings and may be responsible for their own privacy practices. + +## What we collect + +We only collect limited diagnostic data to help find bugs and improve the stability and security of the app. + +Diagnostic data is sent only when error reporting is enabled. + +This data may include: + +- Crash and error details, such as exception type, stack trace, and error message +- Device, browser, or operating system name and version +- Application version and environment +- Anonymous performance information, such as page load, sync, or message-send timing + +Before any diagnostic data is sent, sensitive values are scrubbed in the browser on your device. + +## What we do not collect + +Sable is designed not to collect or transmit: + +- Matrix message content +- Room names or aliases +- User display names or avatars +- Contact lists or room member lists +- Authentication tokens or passwords +- Encryption keys or cryptographic session data +- IP addresses +- Precise or approximate location data + +## Optional features + +### Session replay + +Session replay may be available for debugging, but it is **disabled by default** and must be turned on by the user. + +When session replay is enabled, all text is masked, media is blocked, and form inputs are masked before any data leaves the device. + +This is intended to ensure that Matrix messages, room names, user names, and other personal content are not visible in replays. + +### Bug reports + +You may choose to submit a bug report from within the app. + +A bug report may include the description you write, platform and app version details, and optional diagnostic logs that you choose to attach. + +Submitting a bug report is voluntary, and the app shows what will be sent before submission. + +## Third-party services + +Sable uses **Sentry** for crash reporting and performance diagnostics. + +Sentry receives only the diagnostic data described in this policy. + +Sentry handles that data under its own privacy policy: +[**https://sentry.io/privacy/**](https://sentry.io/privacy/) + +Technical details about Sable's Sentry integration are documented here: +[**https://github.com/SableClient/Sable/blob/feat/sentry-pr/docs/SENTRY_PRIVACY.md**](https://github.com/SableClient/Sable/blob/feat/sentry-pr/docs/SENTRY_PRIVACY.md) + +If a Sentry DSN is not configured, Sentry is inactive and no Sentry data is sent. + +Self-hosted deployments may use a different Sentry instance or disable diagnostics entirely. + +## Your controls + +You can manage diagnostic features in: **Settings → General → Diagnostics & Privacy** + +Depending on the build, you can disable error reporting, enable or disable session replay, and adjust breadcrumb categories. + +You can also stop all app-based data transmission by uninstalling the app. + +## Legal basis + +For users in the European Economic Area, diagnostic data is processed on the basis of legitimate interest for app reliability and security, and on the basis of consent where optional features such as session replay are explicitly enabled. + +## Retention and transfers + +Diagnostic data is stored by Sentry according to the retention settings of the Sentry project. + +The Sable project does not keep a separate copy of that diagnostic data. + +Because Sentry is a cloud service, diagnostic data may be processed outside your country of residence. Sentry states that it provides safeguards such as Standard Contractual Clauses where required. + +## Children + +Sable is not directed to children under 13. + +We do not knowingly collect personal information from children through the app. + +If you believe a child has submitted information through Sable, contact **security@sable.moe** so it can be removed. + +## Changes to this policy + +We may update this Privacy Policy from time to time. + +When we do, we will publish the updated version at [**https://sable.moe**](https://sable.moe). + +## Contact + +If you have questions about this Privacy Policy or want to request deletion of data connected to a bug report, contact **security@sable.moe**. diff --git a/docs/SENTRY_INTEGRATION.md b/docs/SENTRY_INTEGRATION.md index 3227e211d..e71f417ea 100644 --- a/docs/SENTRY_INTEGRATION.md +++ b/docs/SENTRY_INTEGRATION.md @@ -86,7 +86,7 @@ New Sentry settings panel in Developer Tools: - **Export debug logs**: Download the in-memory log buffer as JSON for offline analysis - **Attach debug logs**: Manually attach recent logs to next error -Access via: Settings → Developer Tools → Error Tracking (Sentry) +Access via: Settings → General → Diagnostics & Privacy ## Configuration @@ -113,6 +113,76 @@ SENTRY_ORG=your-org-slug SENTRY_PROJECT=your-project-slug ``` +### Self-Hosting with Docker + +Sable is compiled at build time, so `VITE_*` variables must be passed as Docker +**build arguments** — they cannot be injected at container runtime via a plain +`docker run -e` flag. The easiest way for self-hosters to supply them is with +a `.env` file and `docker-compose`. + +#### 1. Create a `.env` file + +```env +# .env — never commit this file +VITE_SENTRY_DSN=https://your-key@oXXXXX.ingest.sentry.io/XXXXXXX +VITE_SENTRY_ENVIRONMENT=production +``` + +The `VITE_SENTRY_ENVIRONMENT` value controls sampling rates (see table below). +Leave it as `production` for a live deployment. + +#### 2. Reference it in `docker-compose.yml` + +The `args` block forwards the variables from `.env` into the Docker build +stage so Vite can embed them in the bundle: + +```yaml +services: + sable: + build: + context: . + args: + - VITE_SENTRY_DSN=${VITE_SENTRY_DSN} + - VITE_SENTRY_ENVIRONMENT=${VITE_SENTRY_ENVIRONMENT} + ports: + - '8080:8080' +``` + +Then build and start with: + +```bash +docker compose --env-file .env up --build +``` + +#### 3. Verify it worked + +Open the browser console after loading your instance — you should see: + +``` +[Sentry] Initialized for production environment +[Sentry] DSN configured: https://your-key@o... +``` + +If you see `[Sentry] Disabled - no DSN provided`, the build arg was not +picked up — double-check the `args` block and that your `.env` file is in the +same directory as `docker-compose.yml`. + +#### Building without Compose + +If you use plain `docker build`, pass build args directly: + +```bash +docker build \ + --build-arg VITE_SENTRY_DSN="https://your-key@oXXXXX.ingest.sentry.io/XXXXXXX" \ + --build-arg VITE_SENTRY_ENVIRONMENT="production" \ + -t sable . +``` + +> **Security note:** DSN values embedded in the JavaScript bundle are visible +> to any user who opens DevTools. This is normal and expected for Sentry DSNs — +> they are designed to be public-facing ingest keys. Rate-limiting and origin +> restrictions on the Sentry project side are the correct controls. + ### Deployment Configuration **Production deployment (from `dev` branch):** @@ -161,7 +231,7 @@ localStorage.setItem('sable_sentry_enabled', 'false'); localStorage.setItem('sable_sentry_replay_enabled', 'false'); ``` -Or use the UI in Settings → Developer Tools → Error Tracking (Sentry). +Or use the UI in Settings → General → Diagnostics & Privacy. ## Custom Instrumentation @@ -169,15 +239,15 @@ Beyond automatic error capture, Sable has hand-crafted monitoring at key lifecycle points. See [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md) for the full metrics reference. Key areas: -| Area | What's tracked | -|------|----------------| -| **Auth** | Login failures (by `errcode`), forced server logouts | -| **Sync** | Transport type, degraded states, cycle stats, time-to-ready | -| **Cryptography** | Decryption failures (by failure reason), key backup errors, store wipes, E2E verification outcomes | -| **Messaging** | Send latency, send errors, local-echo `NOT_SENT` events | -| **Timeline** | Opens, virtual window size, jump-load latency, re-initialisations | -| **Media** | Upload latency, upload size, cache stats | -| **Background clients** | Per-account notification client count, startup failures | +| Area | What's tracked | +| ---------------------- | -------------------------------------------------------------------------------------------------- | +| **Auth** | Login failures (by `errcode`), forced server logouts | +| **Sync** | Transport type, degraded states, cycle stats, time-to-ready | +| **Cryptography** | Decryption failures (by failure reason), key backup errors, store wipes, E2E verification outcomes | +| **Messaging** | Send latency, send errors, local-echo `NOT_SENT` events | +| **Timeline** | Opens, virtual window size, jump-load latency, re-initialisations | +| **Media** | Upload latency, upload size, cache stats | +| **Background clients** | Per-account notification client count, startup failures | Fatal errors that are caught by `useAsyncCallback` state (and therefore never reach React's ErrorBoundary) are explicitly forwarded with `captureException`: @@ -301,7 +371,7 @@ No message content, room conversations, or personal information is ever sent to To test the integration: 1. **Test error reporting**: - - Go to Settings → Developer Tools → Error Tracking + - Go to Settings → General → Diagnostics & Privacy - Check that Sentry is enabled and `VITE_SENTRY_DSN` is set - Open the browser console and run: `window.Sentry?.captureMessage('Test message')` - Check the Sentry dashboard for the event diff --git a/docs/SENTRY_PRIVACY.md b/docs/SENTRY_PRIVACY.md index 93877a7de..265ef57ee 100644 --- a/docs/SENTRY_PRIVACY.md +++ b/docs/SENTRY_PRIVACY.md @@ -9,7 +9,7 @@ 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 → Developer Tools → Error Tracking. +out by users** at any time via Settings → General → Diagnostics & Privacy. When enabled, the following categories of data are sent: @@ -48,14 +48,14 @@ browser. In addition to automatic navigation/console breadcrumbs, the following named events are explicitly recorded as breadcrumbs: -| Event | Category | Level | Source | -|-------|----------|-------|--------| -| Session forcibly logged out by server | `auth` | warning | `ClientRoot.tsx` | -| Sync state changed to Reconnecting/Error | `sync` | warning/error | `SyncStatus.tsx` | -| Sliding sync first run completed | `sync` | info | `initMatrix.ts` | -| Crypto store mismatch — wiping local stores | `crypto` | warning | `initMatrix.ts` | -| Key backup failed | `crypto` | error | `useKeyBackup.ts` | -| High media inflight request count | `media` | warning | `ClientNonUIFeatures.tsx` | +| Event | Category | Level | Source | +| ------------------------------------------- | -------- | ------------- | ------------------------- | +| Session forcibly logged out by server | `auth` | warning | `ClientRoot.tsx` | +| Sync state changed to Reconnecting/Error | `sync` | warning/error | `SyncStatus.tsx` | +| Sliding sync first run completed | `sync` | info | `initMatrix.ts` | +| Crypto store mismatch — wiping local stores | `crypto` | warning | `initMatrix.ts` | +| Key backup failed | `crypto` | error | `useKeyBackup.ts` | +| High media inflight request count | `media` | warning | `ClientNonUIFeatures.tsx` | **Code:** `src/app/pages/client/ClientRoot.tsx`, `src/app/pages/client/SyncStatus.tsx`, `src/client/initMatrix.ts`, `src/app/hooks/useKeyBackup.ts`, @@ -66,10 +66,10 @@ events are explicitly recorded as breadcrumbs: The following failure paths use explicit `captureException` because they are caught by state management hooks and never propagate to React's ErrorBoundary: -| Failure | Tag | Source | -|---------|-----|--------| -| Client failed to load (fetch/init) | `phase: load` | `ClientRoot.tsx` | -| Client failed to start (sync start) | `phase: start` | `ClientRoot.tsx` | +| Failure | Tag | Source | +| ---------------------------------------------- | ------------------------------------ | ----------------------------- | +| Client failed to load (fetch/init) | `phase: load` | `ClientRoot.tsx` | +| Client failed to start (sync start) | `phase: start` | `ClientRoot.tsx` | | Background notification client failed to start | `component: BackgroundNotifications` | `BackgroundNotifications.tsx` | **Code:** `src/app/pages/client/ClientRoot.tsx`, @@ -84,21 +84,21 @@ caught by state management hooks and never propagate to React's ErrorBoundary: Performance data contains **no message content, no room names, and no user identifiers**. Spans are labelled with operation names only. -| Span name | Operation | Source | -|-----------|-----------|--------| -| `auth.login` | `auth` | `loginUtil.ts` | -| `decrypt.event` | `matrix.crypto` | `EncryptedContent.tsx` | -| `decrypt.bulk` | `matrix.crypto` | `room.ts` | -| `timeline.jump_load` | `matrix.timeline` | `RoomTimeline.tsx` | -| `message.send` | `matrix.message` | `RoomInput.tsx` | -| Sliding sync processing | `matrix.sync` | `slidingSync.ts` | +| Span name | Operation | Source | +| ----------------------- | ----------------- | ---------------------- | +| `auth.login` | `auth` | `loginUtil.ts` | +| `decrypt.event` | `matrix.crypto` | `EncryptedContent.tsx` | +| `decrypt.bulk` | `matrix.crypto` | `room.ts` | +| `timeline.jump_load` | `matrix.timeline` | `RoomTimeline.tsx` | +| `message.send` | `matrix.message` | `RoomInput.tsx` | +| Sliding sync processing | `matrix.sync` | `slidingSync.ts` | **Sample rates:** -| Environment | Traces | Profiles | -|---------------------|--------|----------| -| `production` | 10% | 10% | -| `preview` / `development` | 100% | 100% | +| Environment | Traces | Profiles | +| ------------------------- | ------ | -------- | +| `production` | 10% | 10% | +| `preview` / `development` | 100% | 100% | **Code:** `src/instrument.ts` — `tracesSampleRate`, `profilesSampleRate` **Code:** `src/app/features/room/RoomInput.tsx` — message send span @@ -112,22 +112,22 @@ or numeric measurements. #### Authentication -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| -| `sable.auth.login_failed` | count | `errcode` | Login attempt failures by error code | +| Metric | Type | Attributes | What it tracks | +| ------------------------- | ----- | ---------- | ------------------------------------ | +| `sable.auth.login_failed` | count | `errcode` | Login attempt failures by error code | **Code:** `src/app/pages/auth/login/loginUtil.ts` #### Cryptography -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| -| `sable.decryption.failure` | count | `reason` | Unable-to-decrypt events by failure reason | -| `sable.decryption.event_ms` | distribution | — | Per-event decryption latency | -| `sable.decryption.bulk_latency_ms` | distribution | `event_count` | Bulk re-decryption time on room open | -| `sable.crypto.key_backup_failures` | count | `errcode` | Key backup errors by code | -| `sable.crypto.store_wipe` | count | — | Crypto store mismatch wipe-and-retry occurrences | -| `sable.crypto.verification_outcome` | count | `outcome` (`completed`/`cancelled`) | E2E device verification outcomes | +| Metric | Type | Attributes | What it tracks | +| ----------------------------------- | ------------ | ----------------------------------- | ------------------------------------------------ | +| `sable.decryption.failure` | count | `reason` | Unable-to-decrypt events by failure reason | +| `sable.decryption.event_ms` | distribution | — | Per-event decryption latency | +| `sable.decryption.bulk_latency_ms` | distribution | `event_count` | Bulk re-decryption time on room open | +| `sable.crypto.key_backup_failures` | count | `errcode` | Key backup errors by code | +| `sable.crypto.store_wipe` | count | — | Crypto store mismatch wipe-and-retry occurrences | +| `sable.crypto.verification_outcome` | count | `outcome` (`completed`/`cancelled`) | E2E device verification outcomes | **Code:** `src/app/features/room/message/EncryptedContent.tsx`, `src/app/utils/room.ts`, `src/app/hooks/useKeyBackup.ts`, @@ -135,70 +135,70 @@ or numeric measurements. #### Messaging -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| -| `sable.message.send_latency_ms` | distribution | `encrypted` | Message send round-trip time | -| `sable.message.send_error` | count | — | Send errors from message composer | -| `sable.message.send_failed` | count | — | Local-echo `NOT_SENT` status events | +| Metric | Type | Attributes | What it tracks | +| ------------------------------- | ------------ | ----------- | ----------------------------------- | +| `sable.message.send_latency_ms` | distribution | `encrypted` | Message send round-trip time | +| `sable.message.send_error` | count | — | Send errors from message composer | +| `sable.message.send_failed` | count | — | Local-echo `NOT_SENT` status events | **Code:** `src/app/features/room/RoomInput.tsx`, `src/app/features/room/RoomTimeline.tsx` #### Timeline -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| -| `sable.timeline.open` | count | `mode` | Timeline render initiations | -| `sable.timeline.render_window` | distribution | `mode` | Initial virtual window size | -| `sable.timeline.jump_load_ms` | distribution | — | Event-jump timeline load latency | -| `sable.timeline.reinit` | count | — | Full timeline re-initialisations | -| `sable.pagination.error` | count | `direction` | Pagination errors by direction | +| Metric | Type | Attributes | What it tracks | +| ------------------------------ | ------------ | ----------- | -------------------------------- | +| `sable.timeline.open` | count | `mode` | Timeline render initiations | +| `sable.timeline.render_window` | distribution | `mode` | Initial virtual window size | +| `sable.timeline.jump_load_ms` | distribution | — | Event-jump timeline load latency | +| `sable.timeline.reinit` | count | — | Full timeline re-initialisations | +| `sable.pagination.error` | count | `direction` | Pagination errors by direction | **Code:** `src/app/features/room/RoomTimeline.tsx` #### Sync -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| -| `sable.sync.transport` | count | `type` (`sliding`/`classic`) | Sync transport type used | -| `sable.sync.cycle` | count | (various) | Completed sliding sync cycles | -| `sable.sync.error` | count | `errcode` | Sliding sync errors | -| `sable.sync.initial_ms` | distribution | — | Initial sync completion time | -| `sable.sync.processing_ms` | distribution | — | Per-cycle sync processing time | -| `sable.sync.lists_loaded_ms` | distribution | — | Time for room lists to fully load | -| `sable.sync.total_rooms` | gauge | `sync_type` | Total rooms known at list load | -| `sable.sync.active_subscriptions` | gauge | — | Active room subscription count | -| `sable.sync.client_ready_ms` | distribution | `type` | Time from init to client ready | -| `sable.sync.time_to_ready_ms` | distribution | — | Wall-clock time to first sync ready | -| `sable.sync.degraded` | count | `state` | Sync reconnect/error state transitions | +| Metric | Type | Attributes | What it tracks | +| --------------------------------- | ------------ | ---------------------------- | -------------------------------------- | +| `sable.sync.transport` | count | `type` (`sliding`/`classic`) | Sync transport type used | +| `sable.sync.cycle` | count | (various) | Completed sliding sync cycles | +| `sable.sync.error` | count | `errcode` | Sliding sync errors | +| `sable.sync.initial_ms` | distribution | — | Initial sync completion time | +| `sable.sync.processing_ms` | distribution | — | Per-cycle sync processing time | +| `sable.sync.lists_loaded_ms` | distribution | — | Time for room lists to fully load | +| `sable.sync.total_rooms` | gauge | `sync_type` | Total rooms known at list load | +| `sable.sync.active_subscriptions` | gauge | — | Active room subscription count | +| `sable.sync.client_ready_ms` | distribution | `type` | Time from init to client ready | +| `sable.sync.time_to_ready_ms` | distribution | — | Wall-clock time to first sync ready | +| `sable.sync.degraded` | count | `state` | Sync reconnect/error state transitions | **Code:** `src/client/initMatrix.ts`, `src/client/slidingSync.ts`, `src/app/pages/client/ClientRoot.tsx`, `src/app/pages/client/SyncStatus.tsx` #### Media -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| +| Metric | Type | Attributes | What it tracks | +| ------------------------------- | ------------ | ---------- | ---------------------------- | | `sable.media.upload_latency_ms` | distribution | `mimetype` | Media upload round-trip time | -| `sable.media.upload_bytes` | distribution | `mimetype` | Upload size distribution | -| `sable.media.upload_error` | count | `reason` | Upload failures by reason | -| `sable.media.blob_cache_size` | gauge | — | Blob URL cache entry count | -| `sable.media.inflight_requests` | gauge | — | Concurrent media requests | +| `sable.media.upload_bytes` | distribution | `mimetype` | Upload size distribution | +| `sable.media.upload_error` | count | `reason` | Upload failures by reason | +| `sable.media.blob_cache_size` | gauge | — | Blob URL cache entry count | +| `sable.media.inflight_requests` | gauge | — | Concurrent media requests | **Code:** `src/app/utils/matrix.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx` #### Background clients & debug telemetry -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| -| `sable.background.client_count` | gauge | — | Active background notification clients | -| `sable.errors` | count | `category` | Error-level debug log entries | -| `sable.warnings` | count | `category` | Warning-level debug log entries | +| Metric | Type | Attributes | What it tracks | +| ------------------------------- | ----- | ---------- | -------------------------------------- | +| `sable.background.client_count` | gauge | — | Active background notification clients | +| `sable.errors` | count | `category` | Error-level debug log entries | +| `sable.warnings` | count | `category` | Warning-level debug log entries | **Code:** `src/app/pages/client/BackgroundNotifications.tsx`, `src/app/utils/debugLogger.ts` -### Session Replay *(opt-in, disabled by default)* +### Session Replay _(opt-in, disabled by default)_ When session replay is explicitly enabled by the user, Sentry records UI interactions to help reproduce bugs. **All content is masked at the browser @@ -213,15 +213,15 @@ media are ever visible in a replay**. Sample rates for replay: -| Trigger | Production | Preview / Dev | -|-----------------------|------------|---------------| -| Regular sessions | 10% | 100% | -| Sessions with errors | 100% | 100% | +| Trigger | Production | Preview / Dev | +| -------------------- | ---------- | ------------- | +| Regular sessions | 10% | 100% | +| Sessions with errors | 100% | 100% | **Code:** `src/instrument.ts` — `replayIntegration` call with `maskAllText`, `blockAllMedia`, `maskAllInputs` -### Bug Reports *(manual, opt-in per report)* +### Bug Reports _(manual, opt-in per report)_ When a user submits a bug report via `/bugreport` or the "Bug Report" button: @@ -272,11 +272,11 @@ Regex: `/(access_token|password|token|refresh_token|session_id|sync_token|next_b Matrix IDs are replaced with placeholder tokens before sending: -| Original form | Replaced with | -|-------------------|---------------| -| `@user:server` | `@[USER_ID]` | -| `!room:server` | `![ROOM_ID]` | -| `$event_id` | `$[EVENT_ID]` | +| Original form | Replaced with | +| -------------- | ------------- | +| `@user:server` | `@[USER_ID]` | +| `!room:server` | `![ROOM_ID]` | +| `$event_id` | `$[EVENT_ID]` | **Code:** `src/instrument.ts` — `beforeSend` callback (applied to `event.message` and all `event.exception.values`) @@ -287,11 +287,11 @@ and all `event.exception.values`) Users can adjust Sentry behaviour without restarting the app: -| Setting | Location | `localStorage` key | Default | -|---------|----------|--------------------|---------| -| Disable Sentry entirely | Settings → Developer Tools → Error Tracking | `sable_sentry_enabled` | Enabled | -| Enable session replay | Settings → Developer Tools → Error Tracking | `sable_sentry_replay_enabled` | Disabled (opt-in) | -| Disable breadcrumb categories | Settings → Developer Tools → Error Tracking → Breadcrumb Categories | `sable_sentry_breadcrumb_disabled` | All enabled | +| Setting | Location | `localStorage` key | Default | +| ----------------------------- | ---------------------------------------------------------------------------- | ---------------------------------- | ----------------- | +| Disable Sentry entirely | Settings → General → Diagnostics & Privacy | `sable_sentry_enabled` | Enabled | +| Enable session replay | Settings → General → Diagnostics & Privacy | `sable_sentry_replay_enabled` | Disabled (opt-in) | +| Disable breadcrumb categories | Settings → Developer Tools → Error Tracking (Sentry) → Breadcrumb Categories | `sable_sentry_breadcrumb_disabled` | All enabled | **Rate limiting:** A maximum of 50 error events are forwarded to Sentry per page load (session). Subsequent errors are silently dropped, protecting against quota exhaustion without affecting diff --git a/src/app/components/DefaultErrorPage.tsx b/src/app/components/DefaultErrorPage.tsx index 54ac642ea..62042cef1 100644 --- a/src/app/components/DefaultErrorPage.tsx +++ b/src/app/components/DefaultErrorPage.tsx @@ -75,7 +75,9 @@ export function ErrorPage({ error, eventId }: ErrorPageProps) { + + + ); +} + export function General({ requestClose }: GeneralProps) { return ( @@ -1078,6 +1181,7 @@ export function General({ requestClose }: GeneralProps) { + diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index cd657e477..1e8a7dfef 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -29,6 +29,13 @@ export function useCallSignaling() { const mutedRoomId = useAtomValue(mutedCallRoomIdAtom); const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); + // Stable refs so volatile values (mutedRoomId, ring callbacks) don't force + // the listener registration effect to re-run — which would cause the + // SessionEnded and RoomState.events listeners to accumulate when muting + // or when call state changes rapidly during a sync retry cycle. + const mutedRoomIdRef = useRef(mutedRoomId); + mutedRoomIdRef.current = mutedRoomId; + useEffect(() => { const inc = new Audio(RingtoneSound); inc.loop = true; @@ -72,6 +79,16 @@ export function useCallSignaling() { [setIncomingCall] ); + // Must be declared after the callbacks above so the initial useRef(value) call + // sees their current identity. Updated on every render so the effect closure + // always calls the latest version without needing them in the dep array. + const playRingingRef = useRef(playRinging); + playRingingRef.current = playRinging; + const stopRingingRef = useRef(stopRinging); + stopRingingRef.current = stopRinging; + const playOutgoingRingingRef = useRef(playOutgoingRinging); + playOutgoingRingingRef.current = playOutgoingRinging; + useEffect(() => { if (!mx || !mx.matrixRTC) return undefined; @@ -81,7 +98,7 @@ export function useCallSignaling() { const signal = Array.from(mDirects).reduce( (acc, roomId) => { - if (acc.incoming || mutedRoomId === roomId) return acc; + if (acc.incoming || mutedRoomIdRef.current === roomId) return acc; const room = mx.getRoom(roomId); if (!room) return acc; @@ -141,11 +158,11 @@ export function useCallSignaling() { ); if (signal.incoming) { - playRinging(signal.incoming); + playRingingRef.current(signal.incoming); } else if (signal.outgoing) { - playOutgoingRinging(signal.outgoing); + playOutgoingRingingRef.current(signal.outgoing); } else { - stopRinging(); + stopRingingRef.current(); if (!signal.outgoing) outgoingStartRef.current = null; } }; @@ -155,7 +172,7 @@ export function useCallSignaling() { const handleUpdate = () => checkDMsForActiveCalls(); const handleSessionEnded = (roomId: string) => { - if (mutedRoomId === roomId) setMutedRoomId(null); + if (mutedRoomIdRef.current === roomId) setMutedRoomId(null); callPhaseRef.current[roomId] = 'IDLE'; checkDMsForActiveCalls(); }; @@ -171,9 +188,9 @@ export function useCallSignaling() { mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, handleUpdate); mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); mx.off(RoomStateEvent.Events, handleUpdate); - stopRinging(); + stopRingingRef.current(); }; - }, [mx, mDirects, playRinging, stopRinging, mutedRoomId, setMutedRoomId, playOutgoingRinging]); + }, [mx, mDirects, setMutedRoomId]); // stable: volatile deps accessed via refs above return null; } diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 71b72157b..d81890da1 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -164,59 +164,59 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) beforeCapture={(scope) => scope.setTag('section', 'client')} > - {/* HandleNotificationClick must live outside ClientRoot's loading gate so + {/* HandleNotificationClick must live outside ClientRoot's loading gate so SW notification-click postMessages are never dropped during client reloads (e.g., account switches). It only needs navigate + Jotai atoms. */} - - - - - - - - - - - - } - > - - - - - - - - - - - - - {/* Screen reader live region — populated by announce() in utils/announce.ts */} -
- - - - - - - - + + + + + + + + + + + + } + > + + + + + + + + + + + + + {/* Screen reader live region — populated by announce() in utils/announce.ts */} +
+ + + + + + + + } > diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index fab2261e3..a5e78e606 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -676,8 +676,8 @@ function SentryRoomContextFeature() { useEffect(() => { if (!roomId) { Sentry.setContext('room', null); - Sentry.setTag('room_type', ''); - Sentry.setTag('room_encrypted', ''); + Sentry.setTag('room_type', 'none'); + Sentry.setTag('room_encrypted', 'none'); return; } const room = mx.getRoom(roomId); @@ -688,11 +688,12 @@ function SentryRoomContextFeature() { const memberCount = room.getJoinedMemberCount(); // Bucket member count so we can correlate issues with room scale // without leaking precise membership numbers of private rooms. - const memberCountRange = - memberCount <= 2 ? '1-2' : - memberCount <= 10 ? '3-10' : - memberCount <= 50 ? '11-50' : - memberCount <= 200 ? '51-200' : '200+'; + let memberCountRange: string; + if (memberCount <= 2) memberCountRange = '1-2'; + else if (memberCount <= 10) memberCountRange = '3-10'; + else if (memberCount <= 50) memberCountRange = '11-50'; + else if (memberCount <= 200) memberCountRange = '51-200'; + else memberCountRange = '200+'; Sentry.setContext('room', { type: isDm ? 'dm' : 'group', diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 7a6718b53..5823ea458 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -1,13 +1,10 @@ -import { useMemo, useState, useCallback, useRef, useEffect } from 'react'; +import { useMemo, useRef, useEffect } from 'react'; import * as Sentry from '@sentry/react'; import { useNavigate } from 'react-router-dom'; import { Avatar, Text, Box, toRem } from 'folds'; import { useAtomValue } from 'jotai'; -import { Room, SyncState } from '$types/matrix-sdk'; -import { useDirects } from '$state/hooks/roomList'; +import { Room } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { mDirectAtom } from '$state/mDirectList'; -import { allRoomsAtom } from '$state/room-list/roomList'; import { roomToUnreadAtom } from '$state/room/roomToUnread'; import { getDirectRoomPath } from '$pages/pathUtils'; import { @@ -22,14 +19,12 @@ import { UserAvatar } from '$components/user-avatar'; import { getDirectRoomAvatarUrl } from '$utils/room'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { nameInitials } from '$utils/common'; -import { factoryRoomIdByActivity } from '$utils/sort'; import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '$utils/matrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { useGroupDMMembers } from '$hooks/useGroupDMMembers'; -import { useSyncState } from '$hooks/useSyncState'; +import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; import * as css from './DirectDMsList.css'; -const MAX_DM_AVATARS = 3; const MAX_GROUP_MEMBERS = 3; type DMItemProps = { @@ -164,62 +159,29 @@ function DMItem({ room, selected }: DMItemProps) { export function DirectDMsList() { const mx = useMatrixClient(); - const mDirects = useAtomValue(mDirectAtom); - const directs = useDirects(mx, allRoomsAtom, mDirects); - const roomToUnread = useAtomValue(roomToUnreadAtom); const selectedRoomId = useSelectedRoom(); + const sidebarRoomIds = useSidebarDirectRoomIds(); - // Track sync state to wait for initial sync completion - const [syncReady, setSyncReady] = useState(false); const mountTimeRef = useRef(performance.now()); const firstReadyRef = useRef(false); - useSyncState( - mx, - useCallback((state, prevState) => { - // Consider ready after initial sync reaches Syncing state - // This ensures m.direct and unread counts are populated - if (state === SyncState.Syncing && prevState !== SyncState.Syncing) { - setSyncReady(true); - } - // Also set ready if we're already syncing (e.g., after a refresh while still online) - if (state === SyncState.Syncing || state === SyncState.Catchup) { - setSyncReady(true); - } - }, []) + const recentDMs = useMemo( + () => + sidebarRoomIds + .map((roomId) => mx.getRoom(roomId)) + .filter((room): room is Room => room !== null), + [sidebarRoomIds, mx] ); useEffect(() => { - if (syncReady && !firstReadyRef.current) { + if (recentDMs.length > 0 && !firstReadyRef.current) { firstReadyRef.current = true; Sentry.metrics.distribution( 'sable.roomlist.time_to_ready_ms', performance.now() - mountTimeRef.current ); } - }, [syncReady]); - - // Get up to MAX_DM_AVATARS recent DMs that have unread messages - const recentDMs = useMemo(() => { - // Don't show DMs until initial sync completes - if (!syncReady) { - return []; - } - - // Filter to only DMs with unread messages - const withUnread = directs.filter((roomId) => { - const unread = roomToUnread.get(roomId); - return unread && (unread.total > 0 || unread.highlight > 0); - }); - - // Sort by activity - const sorted = withUnread.sort(factoryRoomIdByActivity(mx)); - - return sorted - .slice(0, MAX_DM_AVATARS) - .map((roomId) => mx.getRoom(roomId)) - .filter((room): room is Room => room !== null); - }, [directs, mx, roomToUnread, syncReady]); + }, [recentDMs]); if (recentDMs.length === 0) { return null; diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx index dd2a7d6e8..3503c7f92 100644 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ b/src/app/pages/client/sidebar/DirectTab.tsx @@ -1,4 +1,4 @@ -import { MouseEventHandler, forwardRef, useState } from 'react'; +import { MouseEventHandler, forwardRef, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; import FocusTrap from 'focus-trap-react'; @@ -25,6 +25,7 @@ import { stopPropagation } from '$utils/keyboard'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import { useDirectRooms } from '$pages/client/direct/useDirectRooms'; +import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; type DirectMenuProps = { requestClose: () => void; @@ -68,7 +69,14 @@ export function DirectTab() { const mDirects = useAtomValue(mDirectAtom); const directs = useDirects(mx, allRoomsAtom, mDirects); - const directUnread = useRoomsUnread(directs, roomToUnreadAtom); + const sidebarRoomIds = useSidebarDirectRoomIds(); + // Only count unread for DMs not already shown as individual avatars in the + // sidebar — prevents double-badging (issue #235). + const overflowDirects = useMemo(() => { + const sidebarSet = new Set(sidebarRoomIds); + return directs.filter((id) => !sidebarSet.has(id)); + }, [directs, sidebarRoomIds]); + const directUnread = useRoomsUnread(overflowDirects, roomToUnreadAtom); const [menuAnchor, setMenuAnchor] = useState(); const directSelected = useDirectSelected(); diff --git a/src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts b/src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts new file mode 100644 index 000000000..8c62d3af7 --- /dev/null +++ b/src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts @@ -0,0 +1,54 @@ +import { useMemo, useState, useCallback } from 'react'; +import { useAtomValue } from 'jotai'; +import { SyncState } from '$types/matrix-sdk'; +import { useDirects } from '$state/hooks/roomList'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { mDirectAtom } from '$state/mDirectList'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { roomToUnreadAtom } from '$state/room/roomToUnread'; +import { factoryRoomIdByActivity } from '$utils/sort'; +import { useSyncState } from '$hooks/useSyncState'; + +/** Maximum number of individual DM avatars shown in the sidebar. */ +export const MAX_SIDEBAR_DMS = 3; + +/** + * Returns the room IDs of DMs currently displayed as individual avatars in the + * sidebar `DirectDMsList`. These are the first `MAX_SIDEBAR_DMS` unread DMs + * sorted by recent activity, available only after initial sync completes. + * + * Used by `DirectDMsList` to decide which rooms to render, and by `DirectTab` + * to exclude those rooms from its own badge count (prevents double-badging). + */ +export const useSidebarDirectRoomIds = (): string[] => { + const mx = useMatrixClient(); + const mDirects = useAtomValue(mDirectAtom); + const directs = useDirects(mx, allRoomsAtom, mDirects); + const roomToUnread = useAtomValue(roomToUnreadAtom); + + const [syncReady, setSyncReady] = useState(false); + + useSyncState( + mx, + useCallback((state, prevState) => { + if (state === SyncState.Syncing && prevState !== SyncState.Syncing) { + setSyncReady(true); + } + if (state === SyncState.Syncing || state === SyncState.Catchup) { + setSyncReady(true); + } + }, []) + ); + + return useMemo(() => { + if (!syncReady) return []; + + const withUnread = directs.filter((roomId) => { + const unread = roomToUnread.get(roomId); + return unread && (unread.total > 0 || unread.highlight > 0); + }); + + const sorted = withUnread.sort(factoryRoomIdByActivity(mx)); + return sorted.slice(0, MAX_SIDEBAR_DMS); + }, [directs, mx, roomToUnread, syncReady]); +}; diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 570b66f76..960f21ced 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -218,11 +218,24 @@ export class CallEmbed { this.readUpToMap[room.roomId] = roomEvent.getId()!; }); - // Attach listeners for feeding events - the underlying widget classes handle permissions for us - this.mx.on(ClientEvent.Event, this.onEvent.bind(this)); - this.mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); - this.mx.on(RoomStateEvent.Events, this.onStateUpdate.bind(this)); - this.mx.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); + // Attach listeners for feeding events - the underlying widget classes handle permissions for us. + // Bind once and store via disposables so the same function reference is used for removal. + // Using .bind(this) at call-site would create a new function every time, making .off() a no-op + // and causing MaxListeners warnings when the embed is recreated during sync retries. + const boundOnEvent = this.onEvent.bind(this); + const boundOnEventDecrypted = this.onEventDecrypted.bind(this); + const boundOnStateUpdate = this.onStateUpdate.bind(this); + const boundOnToDeviceEvent = this.onToDeviceEvent.bind(this); + this.mx.on(ClientEvent.Event, boundOnEvent); + this.mx.on(MatrixEventEvent.Decrypted, boundOnEventDecrypted); + this.mx.on(RoomStateEvent.Events, boundOnStateUpdate); + this.mx.on(ClientEvent.ToDeviceEvent, boundOnToDeviceEvent); + this.disposables.push(() => { + this.mx.off(ClientEvent.Event, boundOnEvent); + this.mx.off(MatrixEventEvent.Decrypted, boundOnEventDecrypted); + this.mx.off(RoomStateEvent.Events, boundOnStateUpdate); + this.mx.off(ClientEvent.ToDeviceEvent, boundOnToDeviceEvent); + }); } /** @@ -239,11 +252,7 @@ export class CallEmbed { this.container.removeChild(this.iframe); this.control.dispose(); - this.mx.off(ClientEvent.Event, this.onEvent.bind(this)); - this.mx.off(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); - this.mx.off(RoomStateEvent.Events, this.onStateUpdate.bind(this)); - this.mx.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); - + // Listener removal is handled by the disposables pushed in start(). // Clear internal state this.readUpToMap = {}; this.eventsToFeed = new WeakSet(); diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts index c00db022f..1e1c7e74b 100644 --- a/src/app/utils/debugLogger.ts +++ b/src/app/utils/debugLogger.ts @@ -162,24 +162,25 @@ class DebugLoggerService { const sentryLevel: Sentry.SeverityLevel = sentryLevelMap[entry.level] ?? 'error'; // Add breadcrumb for all logs (helps with debugging in Sentry), unless category is disabled - if (!this.disabledBreadcrumbCategories.has(entry.category)) Sentry.addBreadcrumb({ - category: `${entry.category}.${entry.namespace}`, - message: entry.message, - level: sentryLevel, - data: entry.data ? { data: entry.data } : undefined, - timestamp: entry.timestamp / 1000, // Sentry expects seconds - }); + if (!this.disabledBreadcrumbCategories.has(entry.category)) + Sentry.addBreadcrumb({ + category: `${entry.category}.${entry.namespace}`, + message: entry.message, + level: sentryLevel, + data: entry.data ? { data: entry.data } : undefined, + timestamp: entry.timestamp / 1000, // Sentry expects seconds + }); // Send as structured log to the Sentry Logs product (requires enableLogs: true) const logMsg = `[${entry.category}:${entry.namespace}] ${entry.message}`; // Flatten primitive values from entry.data so they become searchable attributes in Sentry Logs const logDataAttrs: Record = {}; if (entry.data && typeof entry.data === 'object' && !(entry.data instanceof Error)) { - for (const [k, v] of Object.entries(entry.data)) { + Object.entries(entry.data).forEach(([k, v]) => { if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { logDataAttrs[k] = v; } - } + }); } const logAttrs = { category: entry.category, namespace: entry.namespace, ...logDataAttrs }; if (entry.level === 'debug') Sentry.logger.debug(logMsg, logAttrs); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index abc155978..f04f71d90 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -18,8 +18,8 @@ import to from 'await-to-js'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '$types/matrix/common'; import { AccountDataEvent } from '$types/matrix/accountData'; import { Membership, MessageEvent, StateEvent } from '$types/matrix/room'; -import { getEventReactions, getReactionContent, getStateEvent } from './room'; import * as Sentry from '@sentry/react'; +import { getEventReactions, getReactionContent, getStateEvent } from './room'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -177,9 +177,13 @@ export const uploadContent = async ( const mxc = data.content_uri; if (mxc) { const mediaType = file.type.split('/')[0] || 'unknown'; - Sentry.metrics.distribution('sable.media.upload_latency_ms', performance.now() - uploadStart, { - attributes: { type: mediaType }, - }); + Sentry.metrics.distribution( + 'sable.media.upload_latency_ms', + performance.now() - uploadStart, + { + attributes: { type: mediaType }, + } + ); Sentry.metrics.distribution('sable.media.upload_bytes', file.size, { attributes: { type: mediaType }, }); diff --git a/src/instrument.ts b/src/instrument.ts index 9efec360d..29e599dee 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -27,6 +27,55 @@ const SESSION_ERROR_LIMIT = 50; const sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false'; const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true'; +/** + * Scrub Matrix-specific identifiers from URLs that appear in Sentry spans, breadcrumbs, + * transaction names, and page URLs. Covers both Matrix API paths and client-side app routes. + * Room IDs, user IDs, event IDs, media paths, and deep-link parameters are replaced with + * safe placeholders so no PII leaks into Sentry. + */ +function scrubMatrixUrl(url: string): string { + return ( + url + // ── Matrix Client-Server API paths ────────────────────────────────────────────── + // /rooms/!roomId:server/... + .replace(/\/rooms\/![^/?#\s]*/g, '/rooms/![ROOM_ID]') + // /event/$eventId and /relations/$eventId + .replace(/\/event\/(?:\$|%24)[^/?#\s]*/g, '/event/$[EVENT_ID]') + .replace(/\/relations\/(?:\$|%24)[^/?#\s]*/g, '/relations/$[EVENT_ID]') + // /profile/@user:server or /profile/%40user%3Aserver + .replace(/\/profile\/(?:%40|@)[^/?#\s]*/gi, '/profile/[USER_ID]') + // /user/@user:server/... and /presence/@user:server/status + .replace(/\/(user|presence)\/(?:%40|@)[^/?#\s]*/gi, '/$1/[USER_ID]') + // /room_keys/keys/{version}/{roomId}/{sessionId} + .replace(/\/room_keys\/keys\/[^/?#\s]*/gi, '/room_keys/keys/[REDACTED]') + // /sendToDevice/{eventType}/{txnId} + .replace(/\/sendToDevice\/([^/?#\s]+)\/[^/?#\s]+/gi, '/sendToDevice/$1/[TXN_ID]') + // Media – MSC3916 (/media/thumbnail|download/{server}/{mediaId}) and legacy (v1/v3) + .replace( + /(\/media\/(?:thumbnail|download)\/)(?:[^/?#\s]+)\/(?:[^/?#\s]+)/gi, + '$1[SERVER]/[MEDIA_ID]' + ) + .replace( + /(\/media\/v\d+\/(?:thumbnail|download)\/)(?:[^/?#\s]+)\/(?:[^/?#\s]+)/gi, + '$1[SERVER]/[MEDIA_ID]' + ) + // ── App route path segments ───────────────────────────────────────────────────── + // Bare Matrix room/space IDs in URL segments: /!roomId:server/ + .replace(/\/![^/?#\s:]+:[^/?#\s]*/g, '/![ROOM_ID]') + // Bare Matrix user IDs in URL segments: /@user:server/ + .replace(/\/@[^/?#\s:]+:[^/?#\s]*/g, '/@[USER_ID]') + // ── Deep-link push notification URLs (percent-encoded) ───────────────────────── + // URL-encoded user IDs: /%40user%3Aserver (%40 = @) + .replace(/\/%40[^/?#\s]*/gi, '/[USER_ID]') + // URL-encoded room IDs: /%21room%3Aserver (%21 = !) + .replace(/\/%21[^/?#\s]*/gi, '/![ROOM_ID]') + // ── Preview URL endpoint ──────────────────────────────────────────────────────── + // The ?url= query parameter on preview_url contains the full external URL being + // previewed — strip the entire query string so browsing habits cannot be inferred. + .replace(/(\/preview_url)\?[^#\s]*/gi, '$1') + ); +} + // Only initialize if DSN is provided and user hasn't opted out if (dsn && sentryEnabled) { Sentry.init({ @@ -67,7 +116,8 @@ if (dsn && sentryEnabled) { tracesSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1, // Browser profiling — profiles every sampled session (requires Document-Policy: js-profiling response header) - profileSessionSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + profileSessionSampleRate: + environment === 'development' || environment === 'preview' ? 1.0 : 0.1, // Control which URLs get distributed tracing headers tracePropagationTargets: [ @@ -108,27 +158,84 @@ if (dsn && sentryEnabled) { // Rate limiting: cap error events per page-load session to avoid quota exhaustion. // Separate counters for errors and transactions so perf traces do not drain the error budget. beforeSendTransaction(event) { + // Scrub Matrix identifiers from the transaction name (the matched route or page URL). + // React Router normally parameterises routes (e.g. /home/:roomIdOrAlias/) but falls + // back to the raw URL when matching fails, so we scrub defensively here. + if (event.transaction) { + // eslint-disable-next-line no-param-reassign + event.transaction = scrubMatrixUrl(event.transaction); + } + + // Scrub Matrix identifiers from HTTP span descriptions and data URLs + if (event.spans) { + // eslint-disable-next-line no-param-reassign + event.spans = event.spans.map((span) => { + const newDesc = span.description ? scrubMatrixUrl(span.description) : span.description; + const spanData = span.data as Record | undefined; + const spanHttpUrl = spanData?.['http.url']; + const rawHttpUrl = typeof spanHttpUrl === 'string' ? spanHttpUrl : undefined; + const newHttpUrl = rawHttpUrl ? scrubMatrixUrl(rawHttpUrl) : undefined; + + const descChanged = newDesc !== span.description; + const urlChanged = newHttpUrl !== undefined && newHttpUrl !== rawHttpUrl; + + if (!descChanged && !urlChanged) return span; + return { + ...span, + ...(descChanged ? { description: newDesc } : {}), + ...(urlChanged ? { data: { ...spanData, 'http.url': newHttpUrl } } : {}), + }; + }); + } return event; }, - // Sanitize sensitive data from all breadcrumb messages before sending to Sentry + // Sanitize sensitive data from all breadcrumb messages and HTTP data URLs before sending to Sentry beforeBreadcrumb(breadcrumb) { - if (!breadcrumb.message) return breadcrumb; - // Always apply redaction — both token values and Matrix entity IDs. + // Scrub Matrix paths from HTTP breadcrumb data.url (captures full request URLs) + const bData = breadcrumb.data as Record | undefined; + const rawUrl = typeof bData?.url === 'string' ? bData.url : undefined; + const scrubbedUrl = rawUrl ? scrubMatrixUrl(rawUrl) : undefined; + const urlChanged = scrubbedUrl !== undefined && scrubbedUrl !== rawUrl; + + // Scrub Matrix paths from navigation breadcrumb data.from / data.to (page URLs that + // may contain room IDs or user IDs as path segments in the app's client-side routes) + const rawFrom = typeof bData?.from === 'string' ? bData.from : undefined; + const rawTo = typeof bData?.to === 'string' ? bData.to : undefined; + const scrubbedFrom = rawFrom ? scrubMatrixUrl(rawFrom) : undefined; + const scrubbedTo = rawTo ? scrubMatrixUrl(rawTo) : undefined; + const fromChanged = scrubbedFrom !== undefined && scrubbedFrom !== rawFrom; + const toChanged = scrubbedTo !== undefined && scrubbedTo !== rawTo; + + // Scrub message text — token values and Matrix entity IDs // Do NOT use single-character patterns like '@', '!', '$' as they are far too broad. - const redacted = breadcrumb.message - // Redact token key=value pairs (e.g. access_token=abc123) - .replace( - /(access_token|password|refresh_token|device_id|session_id|sync_token|next_batch)([=:\s]+)([^\s&"']+)/gi, - '$1$2[REDACTED]' - ) - // Redact full Matrix user IDs: @localpart:server.tld - .replace(/@[^\s:@]+:[^\s,'"(){}\[\]]+/g, '@[USER_ID]') - // Redact full Matrix room IDs: !opaque:server.tld - .replace(/![^\s:]+:[^\s,'"(){}\[\]]+/g, '![ROOM_ID]') - // Redact Matrix event IDs: $base64Url (at least 10 chars to avoid false positives) - .replace(/\$[A-Za-z0-9\-_+/]{10,}/g, '$[EVENT_ID]'); - return redacted === breadcrumb.message ? breadcrumb : { ...breadcrumb, message: redacted }; + const message = breadcrumb.message + ? breadcrumb.message + .replace( + /(access_token|password|refresh_token|device_id|session_id|sync_token|next_batch)([=:\s]+)([^\s&"']+)/gi, + '$1$2[REDACTED]' + ) + .replace(/@[^\s:@]+:[^\s,'"(){}[\]]+/g, '@[USER_ID]') + .replace(/![^\s:]+:[^\s,'"(){}[\]]+/g, '![ROOM_ID]') + .replace(/\$[A-Za-z0-9_+/-]{10,}/g, '$[EVENT_ID]') + : breadcrumb.message; + const messageChanged = message !== breadcrumb.message; + + if (!messageChanged && !urlChanged && !fromChanged && !toChanged) return breadcrumb; + return { + ...breadcrumb, + ...(messageChanged ? { message } : {}), + ...(urlChanged || fromChanged || toChanged + ? { + data: { + ...bData, + ...(urlChanged ? { url: scrubbedUrl } : {}), + ...(fromChanged ? { from: scrubbedFrom } : {}), + ...(toChanged ? { to: scrubbedTo } : {}), + }, + } + : {}), + }; }, beforeSend(event, hint) { @@ -191,6 +298,11 @@ if (dsn && sentryEnabled) { exception.value = exception.value.replace(/![^:]+:[^\s]+/g, '![ROOM_ID]'); // eslint-disable-next-line no-param-reassign exception.value = exception.value.replace(/\$[^:\s]+/g, '$[EVENT_ID]'); + // Scrub Matrix URL patterns embedded in error message strings + // (e.g. MatrixError: "Got error 403 (https://.../preview_url?url=https://...)" + // or paths containing room/user/event IDs) + // eslint-disable-next-line no-param-reassign + exception.value = scrubMatrixUrl(exception.value); } }); } @@ -198,12 +310,21 @@ if (dsn && sentryEnabled) { // Scrub request data if (event.request?.url) { // eslint-disable-next-line no-param-reassign - event.request.url = event.request.url.replace( - /(access_token|password|token)([=:]\s*)([^\s&]+)/gi, - '$1$2[REDACTED]' + event.request.url = scrubMatrixUrl( + event.request.url.replace( + /(access_token|password|token)([=:]\s*)([^\s&]+)/gi, + '$1$2[REDACTED]' + ) ); } + // Scrub the transaction name on error events (set when the error occurred during a + // page-load or navigation transaction — raw URL leaks here when route matching fails) + if (event.transaction) { + // eslint-disable-next-line no-param-reassign + event.transaction = scrubMatrixUrl(event.transaction); + } + if (event.request?.headers) { const headers = event.request.headers as Record; if (headers.Authorization) { From d57ebdd4a3b8ac0e0d0749cc134a9ad578a5787d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 23:40:58 -0400 Subject: [PATCH 04/11] feat(sentry): detailed timeline and scroll instrumentation, sync batch monitoring, and anomaly detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoomTimeline.tsx: - atBottom transition breadcrumbs (ui.scroll) with rapid-flip detection: true→false within 200ms captures warning (IntersectionObserver false-positive) - Virtual paginator window shift breadcrumbs (ui.timeline) - scroll-to-bottom trigger breadcrumb; captures warning when fired while user is scrolled up without a pending reset - eventsLength batch monitoring: breadcrumb on every batch with delta, batchSize label (single/small/medium/large), rangeGap, atBottom - Large batch warning (>50 events, liveTimelineLinked=true) for sliding sync adaptive load detection slidingSync.ts / initMatrix.ts: - sync.sliding breadcrumb in onFirstRoomData with latency + event count - sable.sync.room_sub_event_count distribution metric Message interaction metrics (IncomingCallModal, MessageDelete, MessageForward, MessageReport, RoomTimeline reaction handler): - sable.call.answered/declined, sable.message.delete/forward/report/reaction.* --- docs/{PRIVACY_POLICY.md => PRIVACY.md} | 0 src/app/components/IncomingCallModal.tsx | 18 ++ .../message/modals/MessageDelete.tsx | 14 +- .../message/modals/MessageForward.tsx | 29 ++- .../message/modals/MessageReport.tsx | 19 +- src/app/features/room/RoomTimeline.tsx | 194 +++++++++++++++++- src/app/features/settings/general/General.tsx | 2 +- src/app/hooks/useCallEmbed.ts | 28 ++- src/app/hooks/useCallSignaling.ts | 43 ++++ src/app/pages/client/ClientRoot.tsx | 6 +- src/client/initMatrix.ts | 64 ++++++ src/client/slidingSync.ts | 62 +++++- src/instrument.ts | 167 ++++++++------- 13 files changed, 555 insertions(+), 91 deletions(-) rename docs/{PRIVACY_POLICY.md => PRIVACY.md} (100%) diff --git a/docs/PRIVACY_POLICY.md b/docs/PRIVACY.md similarity index 100% rename from docs/PRIVACY_POLICY.md rename to docs/PRIVACY.md diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index da17db4f3..3ab4e8457 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -19,14 +19,18 @@ import { getRoomAvatarUrl } from '$utils/room'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '$utils/keyboard'; +import * as Sentry from '@sentry/react'; import { useAtom, useSetAtom } from 'jotai'; import { autoJoinCallIntentAtom, incomingCallRoomIdAtom, mutedCallRoomIdAtom, } from '$state/callEmbed'; +import { createDebugLogger } from '$utils/debugLogger'; import { RoomAvatar } from './room-avatar'; +const debugLog = createDebugLogger('IncomingCall'); + type IncomingCallInternalProps = { room: any; onClose: () => void; @@ -41,6 +45,13 @@ export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProp const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); const handleAnswer = () => { + debugLog.info('call', 'Incoming call answered', { roomId: room.roomId }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Incoming call answered', + data: { roomId: room.roomId }, + }); + Sentry.metrics.count('sable.call.answered', 1); setMutedRoomId(room.roomId); setAutoJoinIntent(room.roomId); onClose(); @@ -48,6 +59,13 @@ export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProp }; const handleDecline = async () => { + debugLog.info('call', 'Incoming call declined', { roomId: room.roomId }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Incoming call declined', + data: { roomId: room.roomId }, + }); + Sentry.metrics.count('sable.call.declined', 1); setMutedRoomId(room.roomId); onClose(); }; diff --git a/src/app/components/message/modals/MessageDelete.tsx b/src/app/components/message/modals/MessageDelete.tsx index 6637c97b6..666895102 100644 --- a/src/app/components/message/modals/MessageDelete.tsx +++ b/src/app/components/message/modals/MessageDelete.tsx @@ -20,6 +20,10 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { modalAtom, ModalType } from '$state/modal'; import * as css from '$features/room/message/styles.css'; +import { createDebugLogger } from '$utils/debugLogger'; +import * as Sentry from '@sentry/react'; + +const debugLog = createDebugLogger('MessageDelete'); export function MessageDeleteItem({ room, mEvent }: { room: Room; mEvent: MatrixEvent }) { const setModal = useSetAtom(modalAtom); @@ -67,9 +71,15 @@ export function MessageDeleteInternal({ room, mEvent, onClose }: MessageDeleteIn useEffect(() => { if (deleteState.status === AsyncStatus.Success) { + debugLog.info('ui', 'Message deleted successfully', { roomId: room.roomId }); + Sentry.metrics.count('sable.message.delete.success', 1); onClose(); } - }, [deleteState.status, onClose]); + if (deleteState.status === AsyncStatus.Error) { + debugLog.error('ui', 'Message delete failed', { roomId: room.roomId }); + Sentry.metrics.count('sable.message.delete.error', 1); + } + }, [deleteState.status, room.roomId, onClose]); const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); @@ -85,6 +95,8 @@ export function MessageDeleteInternal({ room, mEvent, onClose }: MessageDeleteIn const reasonInput = target?.reasonInput as HTMLInputElement | undefined; const reason = reasonInput && reasonInput.value.trim(); + debugLog.info('ui', 'Deleting message', { eventId, hasReason: !!reason }); + Sentry.metrics.count('sable.message.delete.attempt', 1); deleteMessage(eventId, reason); }; diff --git a/src/app/components/message/modals/MessageForward.tsx b/src/app/components/message/modals/MessageForward.tsx index dbce8b29d..219e41197 100644 --- a/src/app/components/message/modals/MessageForward.tsx +++ b/src/app/components/message/modals/MessageForward.tsx @@ -26,6 +26,10 @@ import * as css from '$features/room/message/styles.css'; import { sanitizeCustomHtml } from '$utils/sanitize'; import { getStateEvents } from '$utils/room'; import { StateEvent } from '$types/matrix/room'; +import { createDebugLogger } from '$utils/debugLogger'; +import * as Sentry from '@sentry/react'; + +const debugLog = createDebugLogger('MessageForward'); // Message forwarding component export const MessageForwardItem = as<'button', MessageForwardItemProps>( @@ -263,9 +267,30 @@ export function MessageForwardInternal({ }; } + const msgtype = String(originalContent.msgtype ?? 'unknown'); + debugLog.info('ui', 'Forwarding message', { + sourceRoomId: room.roomId, + targetRoomId: targetRoom.roomId, + msgtype, + isPrivate, + }); + Sentry.metrics.count('sable.message.forward.attempt', 1, { attributes: { msgtype } }); mx.sendEvent(targetRoom.roomId, null, eventType, content as unknown as SendEventContent) - .then(() => setIsForwardSuccess(true)) - .catch(() => { + .then(() => { + debugLog.info('ui', 'Message forwarded successfully', { + sourceRoomId: room.roomId, + targetRoomId: targetRoom.roomId, + }); + Sentry.metrics.count('sable.message.forward.success', 1); + setIsForwardSuccess(true); + }) + .catch((err: unknown) => { + debugLog.error('ui', 'Message forward failed', { + sourceRoomId: room.roomId, + targetRoomId: targetRoom.roomId, + error: err instanceof Error ? err.message : String(err), + }); + Sentry.metrics.count('sable.message.forward.error', 1); setIsForwarding(false); setIsForwardSuccess(false); setIsForwardError(true); diff --git a/src/app/components/message/modals/MessageReport.tsx b/src/app/components/message/modals/MessageReport.tsx index cf8aacc59..71cf03cc5 100644 --- a/src/app/components/message/modals/MessageReport.tsx +++ b/src/app/components/message/modals/MessageReport.tsx @@ -1,4 +1,4 @@ -import { FormEventHandler, MouseEvent, useCallback } from 'react'; +import { FormEventHandler, MouseEvent, useCallback, useEffect } from 'react'; import { Room, MatrixEvent } from '$types/matrix-sdk'; import { useSetAtom } from 'jotai'; import { @@ -20,6 +20,10 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import * as css from '$features/room/message/styles.css'; import { modalAtom, ModalType } from '$state/modal'; +import { createDebugLogger } from '$utils/debugLogger'; +import * as Sentry from '@sentry/react'; + +const debugLog = createDebugLogger('MessageReport'); export function MessageReportItem({ room, mEvent }: { room: Room; mEvent: MatrixEvent }) { const setModal = useSetAtom(modalAtom); @@ -65,6 +69,17 @@ export function MessageReportInternal({ room, mEvent, onClose }: MessageReportIn ) ); + useEffect(() => { + if (reportState.status === AsyncStatus.Success) { + debugLog.info('ui', 'Message reported successfully', { roomId: room.roomId }); + Sentry.metrics.count('sable.message.report.success', 1); + } + if (reportState.status === AsyncStatus.Error) { + debugLog.error('ui', 'Message report failed', { roomId: room.roomId }); + Sentry.metrics.count('sable.message.report.error', 1); + } + }, [reportState.status, room.roomId]); + const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); const eventId = mEvent.getId(); @@ -79,6 +94,8 @@ export function MessageReportInternal({ room, mEvent, onClose }: MessageReportIn const reasonInput = target?.reasonInput as HTMLInputElement | undefined; const reason = reasonInput && reasonInput.value.trim(); + debugLog.info('ui', 'Reporting message', { eventId, hasReason: !!reason }); + Sentry.metrics.count('sable.message.report.attempt', 1); reportMessage(eventId, reason ? -100 : -50, reason || 'No reason provided'); }; diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index a829ba39e..1b887ccac 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -566,13 +566,30 @@ const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { useEffect(() => { const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r: Room) => { if (r.roomId !== room.roomId) return; + // App-initiated full reinit (e.g. from refreshLiveTimeline()). Rare in normal usage. + debugLog.debug('timeline', 'TimelineRefresh: app-initiated live timeline reinit', { + roomId: room.roomId, + trigger: 'TimelineRefresh', + }); onRefreshRef.current(); }; // The SDK fires RoomEvent.TimelineReset on the EventTimelineSet (not the Room) - // when a limited sliding-sync response replaces the live EventTimeline with a - // fresh one. Without this handler, the stored linkedTimelines reference the old - // detached chain and back-pagination silently no-ops, freezing the room. + // when a limited sync response replaces the live EventTimeline with a fresh one. + // This happens in classic /sync on limited=true (gap after idle/reconnect) AND in + // sliding sync when the proxy sends a limited room update. const handleTimelineReset: EventTimelineSetHandlerMap[RoomEvent.TimelineReset] = () => { + debugLog.info('timeline', 'TimelineReset: SDK-initiated (limited sync / sync gap)', { + roomId: room.roomId, + trigger: 'TimelineReset', + liveTimelineEvents: room.getUnfilteredTimelineSet().getLiveTimeline().getEvents().length, + }); + Sentry.metrics.count('sable.timeline.limited_reset', 1); + Sentry.addBreadcrumb({ + category: 'timeline.sync', + message: 'TimelineReset: limited sync gap', + level: 'info', + data: { roomId: room.roomId }, + }); onRefreshRef.current(); }; const unfilteredTimelineSet = room.getUnfilteredTimelineSet(); @@ -827,10 +844,35 @@ export function RoomTimeline({ const [atBottom, setAtBottomState] = useState(true); const atBottomRef = useRef(atBottom); - const setAtBottom = useCallback((val: boolean) => { - setAtBottomState(val); - atBottomRef.current = val; - }, []); + // Tracks when atBottom last changed so we can detect rapid true→false flips + // (characteristic of the IO false-positive on bulk event loads). + const atBottomLastChangedRef = useRef(0); + const setAtBottom = useCallback( + (val: boolean) => { + setAtBottomState(val); + const now = Date.now(); + const msSincePrevious = now - atBottomLastChangedRef.current; + atBottomLastChangedRef.current = now; + Sentry.addBreadcrumb({ + category: 'ui.scroll', + message: val ? 'Timeline: scrolled to bottom' : 'Timeline: scrolled away from bottom', + level: 'info', + data: { roomId: room.roomId, msSincePrevious }, + }); + // Rapid flip: bottom→away within 200 ms is characteristic of the known + // IntersectionObserver false-positive triggered by bulk event loads causing + // a DOM layout shift (see memory: "RoomTimeline Stay at Bottom False-Positive"). + if (!val && msSincePrevious < 200) { + Sentry.captureMessage('Timeline: rapid atBottom flip (possible spurious scroll reset)', { + level: 'warning', + extra: { roomId: room.roomId, msSincePrevious }, + tags: { feature: 'timeline' }, + }); + } + atBottomRef.current = val; + }, + [room.roomId] + ); // Set to true by the useLiveTimelineRefresh callback when the timeline is // re-initialised (TimelineRefresh or TimelineReset). Allows the range self-heal @@ -898,6 +940,53 @@ export function RoomTimeline({ const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); const liveTimelineLinked = timeline.linkedTimelines.at(-1) === getLiveTimeline(room); + // Track previous eventsLength so we can calculate batch sizes. + const prevEventsLengthRef = useRef(eventsLength); + + // Breadcrumb every time the timeline gains events so we can correlate message + // batches (sliding sync chunks) with scroll state changes in Sentry Replay. + useEffect(() => { + const prev = prevEventsLengthRef.current; + const delta = eventsLength - prev; + prevEventsLengthRef.current = eventsLength; + + if (delta === 0) return; + + const isBatch = delta > 1; + // Classify by size: single new message vs small batch vs large catch-up load + const batchSize = delta === 1 ? 'single' : delta <= 20 ? 'small' : delta <= 100 ? 'medium' : 'large'; + + Sentry.addBreadcrumb({ + category: 'timeline.events', + message: `Timeline: ${delta} event${delta === 1 ? '' : 's'} added (${batchSize})`, + level: isBatch ? 'info' : 'debug', + data: { + delta, + batchSize, + eventsLength, + prevEventsLength: prev, + liveTimelineLinked, + rangeEnd: timeline.range.end, + atBottom: atBottomRef.current, + // Gap between live end and visible window — non-zero while user is scrolled back + rangeGap: eventsLength - timeline.range.end, + }, + }); + + // A large batch (> 50) while liveTimelineLinked is the sliding-sync + // adaptive load pattern that can trigger the IO false-positive scroll reset. + // Capture a warning so it's searchable in Sentry even when no reset fires. + if (delta > 50 && liveTimelineLinked) { + Sentry.captureMessage('Timeline: large event batch from sliding sync', { + level: 'warning', + extra: { delta, eventsLength, rangeEnd: timeline.range.end, atBottom: atBottomRef.current }, + tags: { feature: 'timeline', batchSize }, + }); + } + // atBottomRef and timeline.range.end are intentionally read at effect time, not as deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventsLength, liveTimelineLinked]); + // Log timeline component mount/unmount useEffect(() => { const mode = eventId ? 'jump' : 'live'; @@ -957,6 +1046,34 @@ export function RoomTimeline({ return currentTimeline; } + // Log range changes with scroll state so we can correlate visible-content + // jumps with paginator window shifts. scrollRef is a stable ref — safe here. + const scrollEl = scrollRef.current; + const ds = newRange.start - currentTimeline.range.start; + const de = newRange.end - currentTimeline.range.end; + debugLog.debug('timeline', 'Virtual paginator range changed', { + prevRange: { start: currentTimeline.range.start, end: currentTimeline.range.end }, + newRange, + deltaStart: ds, + deltaEnd: de, + scrollTop: scrollEl?.scrollTop, + scrollHeight: scrollEl?.scrollHeight, + clientHeight: scrollEl?.clientHeight, + }); + Sentry.addBreadcrumb({ + category: 'ui.timeline', + message: 'Timeline window shifted', + level: 'debug', + data: { + prevStart: currentTimeline.range.start, + prevEnd: currentTimeline.range.end, + newStart: newRange.start, + newEnd: newRange.end, + deltaStart: ds, + deltaEnd: de, + }, + }); + return { ...currentTimeline, range: newRange }; }); }, []), @@ -1346,7 +1463,24 @@ export function RoomTimeline({ useLayoutEffect(() => { const scrollEl = scrollRef.current; if (scrollEl) { + const preScrollTop = scrollEl.scrollTop; + const preScrollHeight = scrollEl.scrollHeight; + const { clientHeight } = scrollEl; scrollToBottom(scrollEl); + // Log whether we were actually away from bottom at mount — useful for diagnosing + // rooms that open with the wrong scroll position. + const distanceFromBottom = preScrollHeight - preScrollTop - clientHeight; + debugLog.debug('timeline', 'Initial scroll to bottom (mount)', { + preScrollTop, + preScrollHeight, + clientHeight, + postScrollTop: scrollEl.scrollTop, + distanceFromBottom, + alreadyAtBottom: distanceFromBottom <= 2, + }); + if (distanceFromBottom > 0) { + Sentry.metrics.distribution('sable.timeline.initial_scroll_offset_px', distanceFromBottom); + } } }, []); @@ -1358,7 +1492,19 @@ export function RoomTimeline({ const forceScroll = () => { // if the user isn't scrolling jump down to latest content - if (!atBottomRef.current) return; + const wasAtBottom = atBottomRef.current; + const preScrollTop = scrollEl?.scrollTop ?? 0; + const preScrollHeight = scrollEl?.scrollHeight ?? 0; + // Log every resize so we can see when media loads move the timeline and whether + // we corrected it (atBottom=true) or left it (atBottom=false, user is scrolled up). + debugLog.debug('timeline', 'Content resized (image/media load)', { + atBottom: wasAtBottom, + preScrollTop, + preScrollHeight, + clientHeight: scrollEl?.clientHeight, + distanceFromBottom: preScrollHeight - preScrollTop - (scrollEl?.clientHeight ?? 0), + }); + if (!wasAtBottom) return; scrollToBottom(scrollEl, 'instant'); }; @@ -1418,11 +1564,36 @@ export function RoomTimeline({ const scrollEl = scrollRef.current; if (scrollEl) { const behavior = scrollToBottomRef.current.smooth && !reducedMotion ? 'smooth' : 'instant'; + const wasAtBottom = atBottomRef.current; + Sentry.addBreadcrumb({ + category: 'ui.scroll', + message: 'Timeline: scroll-to-bottom triggered', + level: 'info', + data: { roomId: room.roomId, behavior, wasAtBottom }, + }); + // A scroll-to-bottom while the user was NOT at the bottom and no timeline + // reset is expected is a sign of an unexpected scroll jump. + if (!wasAtBottom && !timelineJustResetRef.current) { + Sentry.captureMessage('Timeline: scroll-to-bottom fired while user was scrolled up', { + level: 'warning', + extra: { roomId: room.roomId, behavior }, + tags: { feature: 'timeline' }, + }); + } // Use requestAnimationFrame to ensure the virtual paginator has finished // updating the DOM before we scroll. This prevents scroll position from // being stale when new messages arrive while at the bottom. requestAnimationFrame(() => { + const preScrollTop = scrollEl.scrollTop; + const { scrollHeight } = scrollEl; scrollToBottom(scrollEl, behavior); + debugLog.debug('timeline', 'scrollToBottom fired', { + behavior, + preScrollTop, + scrollHeight, + postScrollTop: scrollEl.scrollTop, + remainingOffset: scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight, + }); // On Android WebView, layout may still settle after the initial scroll. // Fire a second instant scroll after a short delay to guarantee we // reach the true bottom (e.g. after images finish loading or the @@ -1614,8 +1785,11 @@ export function RoomTimeline({ ); const handleReactionToggle = useCallback( - (targetEventId: string, key: string, shortcode?: string) => - toggleReaction(mx, room, targetEventId, key, shortcode), + (targetEventId: string, key: string, shortcode?: string) => { + debugLog.info('ui', 'Reaction toggled', { roomId: room.roomId, targetEventId, key }); + Sentry.metrics.count('sable.message.reaction.toggle', 1); + toggleReaction(mx, room, targetEventId, key, shortcode); + }, [mx, room] ); diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 4facb2cc3..5a802eed7 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -1140,7 +1140,7 @@ function DiagnosticsAndPrivacy() { + + +
+
+ ); +} diff --git a/src/app/components/telemetry-consent/index.ts b/src/app/components/telemetry-consent/index.ts new file mode 100644 index 000000000..644d8bda1 --- /dev/null +++ b/src/app/components/telemetry-consent/index.ts @@ -0,0 +1 @@ +export * from './TelemetryConsentBanner'; diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 5a802eed7..446ce37d8 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -1068,7 +1068,7 @@ function DiagnosticsAndPrivacy() { const handleSentryToggle = (enabled: boolean) => { setSentryEnabled(enabled); if (enabled) { - localStorage.removeItem('sable_sentry_enabled'); + localStorage.setItem('sable_sentry_enabled', 'true'); } else { localStorage.setItem('sable_sentry_enabled', 'false'); } diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index a5e78e606..9a5e89d5e 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -48,6 +48,7 @@ import { mobileOrTablet } from '$utils/user-agent'; import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; import { getSlidingSyncManager } from '$client/initMatrix'; import { NotificationBanner } from '$components/notification-banner'; +import { TelemetryConsentBanner } from '$components/telemetry-consent'; import { useCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; @@ -766,6 +767,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/instrument.ts b/src/instrument.ts index b79a414a4..cd3f55869 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -23,7 +23,7 @@ const release = import.meta.env.VITE_APP_VERSION; let sessionErrorCount = 0; const SESSION_ERROR_LIMIT = 50; -// Check user preferences +// Default on: Sentry runs unless the user has opted out via the banner or Settings. const sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false'; const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true'; From 02b7f199097dd7bed45a96d91984efdb7af8f2b6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 16 Mar 2026 13:45:25 -0400 Subject: [PATCH 08/11] Integrate Sentry for error tracking Add Sentry integration for error tracking and bug reporting --- .changeset/feat-sentry-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/feat-sentry-integration.md b/.changeset/feat-sentry-integration.md index ff50d8ff3..fe2f0cabe 100644 --- a/.changeset/feat-sentry-integration.md +++ b/.changeset/feat-sentry-integration.md @@ -1,5 +1,5 @@ --- -'default': minor +default: minor --- Add Sentry integration for error tracking and bug reporting From 0e20d158ba94f4442c00de7edf6eab9f29ea108e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 16 Mar 2026 22:30:05 -0400 Subject: [PATCH 09/11] docs(sentry): document telemetry consent banner in privacy docs --- docs/PRIVACY.md | 6 ++++++ docs/SENTRY_PRIVACY.md | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index b6c1e018d..598b3af3e 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -83,6 +83,12 @@ You can manage diagnostic features in: **Settings → General → Diagnostics & Depending on the build, you can disable error reporting, enable or disable session replay, and adjust breadcrumb categories. +### 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. + +This notice only appears once. Your choice is saved and can be changed at any time in **Settings → General → Diagnostics & Privacy**. + You can also stop all app-based data transmission by uninstalling the app. ## Legal basis diff --git a/docs/SENTRY_PRIVACY.md b/docs/SENTRY_PRIVACY.md index 265ef57ee..d51185a3d 100644 --- a/docs/SENTRY_PRIVACY.md +++ b/docs/SENTRY_PRIVACY.md @@ -11,6 +11,22 @@ configuration details see [SENTRY_INTEGRATION.md](./SENTRY_INTEGRATION.md). 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. +### 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. + +| 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 | + +The preference persists in `localStorage` and can be changed at any time in +**Settings → General → Diagnostics & Privacy**. + +**Code:** `src/app/components/telemetry-consent/TelemetryConsentBanner.tsx` + When enabled, the following categories of data are sent: ### Error Reports From f4764bb05d69d959bb893ec5439752850c6eae8b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 16 Mar 2026 22:33:04 -0400 Subject: [PATCH 10/11] style: prettier format sentry docs --- docs/SENTRY_INTEGRATION.md | 6 +++--- docs/SENTRY_PRIVACY.md | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/SENTRY_INTEGRATION.md b/docs/SENTRY_INTEGRATION.md index c33082c6d..5594ea165 100644 --- a/docs/SENTRY_INTEGRATION.md +++ b/docs/SENTRY_INTEGRATION.md @@ -128,10 +128,10 @@ When `VITE_SENTRY_DSN` is set and a user has never seen the crash-reporting noti **Actions available in the banner:** -| Button | Effect | -|--------|--------| +| 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. | +| **Opt out** | Sets `sable_sentry_enabled = false` and reloads the page. Sentry is disabled for this user going forward. | 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**. diff --git a/docs/SENTRY_PRIVACY.md b/docs/SENTRY_PRIVACY.md index d51185a3d..1cfbc2cae 100644 --- a/docs/SENTRY_PRIVACY.md +++ b/docs/SENTRY_PRIVACY.md @@ -17,10 +17,10 @@ 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. -| 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 | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **"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 | The preference persists in `localStorage` and can be changed at any time in **Settings → General → Diagnostics & Privacy**. From 471451851405e6c75a37e2ea5a30d81cb067d1c1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 16 Mar 2026 22:44:37 -0400 Subject: [PATCH 11/11] test(sentry): unit tests for scrubbers and TelemetryConsentBanner - Extract scrubMatrixIds/scrubDataObject/scrubMatrixUrl from instrument.ts into src/app/utils/sentryScrubbers.ts so they can be tested independently - Add 43 unit tests covering token redaction, Matrix entity ID scrubbing, URL path scrubbing (API + app routes + percent-encoded deep-links), and scrubDataObject recursive traversal - Add 12 RTL integration tests for TelemetryConsentBanner: visibility gating (no DSN, pre-existing pref), Got it, dismiss, and Opt out flows --- .../TelemetryConsentBanner.test.tsx | 111 ++++++++ src/app/utils/sentryScrubbers.test.ts | 245 ++++++++++++++++++ src/app/utils/sentryScrubbers.ts | 100 +++++++ src/instrument.ts | 94 +------ 4 files changed, 457 insertions(+), 93 deletions(-) create mode 100644 src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx create mode 100644 src/app/utils/sentryScrubbers.test.ts create mode 100644 src/app/utils/sentryScrubbers.ts diff --git a/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx b/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx new file mode 100644 index 000000000..83c5fb00b --- /dev/null +++ b/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TelemetryConsentBanner } from './TelemetryConsentBanner'; + +const SENTRY_KEY = 'sable_sentry_enabled'; +const TEST_DSN = 'https://examplePublicKey@o0.ingest.sentry.io/0'; + +describe('TelemetryConsentBanner', () => { + beforeEach(() => { + localStorage.clear(); + vi.stubGlobal('location', { reload: vi.fn() }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + // ── visibility ──────────────────────────────────────────────────────────── + + it('renders nothing when VITE_SENTRY_DSN is not configured', () => { + vi.stubEnv('VITE_SENTRY_DSN', ''); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when the user has already acknowledged (opted in)', () => { + vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); + localStorage.setItem(SENTRY_KEY, 'true'); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when the user has already opted out', () => { + vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); + localStorage.setItem(SENTRY_KEY, 'false'); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + 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(); + }); + + // ── accessibility ───────────────────────────────────────────────────────── + + 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: /dismiss/i })).toBeInTheDocument(); + }); + + it('includes a link to the privacy policy', () => { + vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); + render(); + expect(screen.getByRole('link', { name: /learn more/i })).toBeInTheDocument(); + }); + + // ── "Got it" action ─────────────────────────────────────────────────────── + + it('"Got it" saves opted-in preference to localStorage', () => { + vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); + render(); + fireEvent.click(screen.getByRole('button', { name: /got it/i })); + expect(localStorage.getItem(SENTRY_KEY)).toBe('true'); + }); + + it('"Got it" does not reload the page', () => { + vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); + render(); + 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(); + 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(); + fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); + expect(window.location.reload).not.toHaveBeenCalled(); + }); + + // ── "Opt out" action ────────────────────────────────────────────────────── + + it('"Opt out" saves opted-out preference to localStorage', () => { + vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); + render(); + fireEvent.click(screen.getByRole('button', { name: /opt out/i })); + expect(localStorage.getItem(SENTRY_KEY)).toBe('false'); + }); + + it('"Opt out" reloads the page', () => { + vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); + render(); + fireEvent.click(screen.getByRole('button', { name: /opt out/i })); + expect(window.location.reload).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/utils/sentryScrubbers.test.ts b/src/app/utils/sentryScrubbers.test.ts new file mode 100644 index 000000000..9518705c9 --- /dev/null +++ b/src/app/utils/sentryScrubbers.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect } from 'vitest'; +import { scrubMatrixIds, scrubDataObject, scrubMatrixUrl } from './sentryScrubbers'; + +// ─── scrubMatrixIds ─────────────────────────────────────────────────────────── + +describe('scrubMatrixIds – credential tokens', () => { + it('redacts access_token in query-string form', () => { + expect(scrubMatrixIds('GET /?access_token=abc123xyz')).toBe('GET /?access_token=[REDACTED]'); + }); + + it('redacts password in key=value form', () => { + expect(scrubMatrixIds('password=hunter2')).toBe('password=[REDACTED]'); + }); + + it('redacts refresh_token', () => { + expect(scrubMatrixIds('refresh_token=tok_refresh_xyz')).toBe('refresh_token=[REDACTED]'); + }); + + it('redacts sync_token and next_batch', () => { + expect(scrubMatrixIds('sync_token=s1234_5678')).toBe('sync_token=[REDACTED]'); + expect(scrubMatrixIds('next_batch=s1234_5678')).toBe('next_batch=[REDACTED]'); + }); + + it('is case-insensitive for token names', () => { + expect(scrubMatrixIds('Access_Token=abc')).toBe('Access_Token=[REDACTED]'); + }); + + it('leaves unrelated query params untouched', () => { + expect(scrubMatrixIds('format=json&limit=50')).toBe('format=json&limit=50'); + }); +}); + +describe('scrubMatrixIds – Matrix entity IDs', () => { + it('replaces user IDs', () => { + expect(scrubMatrixIds('@alice:example.com')).toBe('@[USER_ID]'); + expect(scrubMatrixIds('@bob:matrix.org')).toBe('@[USER_ID]'); + }); + + it('replaces room IDs', () => { + expect(scrubMatrixIds('!roomid:example.com')).toBe('![ROOM_ID]'); + }); + + it('replaces room aliases', () => { + expect(scrubMatrixIds('#general:example.com')).toBe('#[ROOM_ALIAS]'); + }); + + it('replaces event IDs (10+ base64 chars)', () => { + expect(scrubMatrixIds('$abcdefghij')).toBe('$[EVENT_ID]'); + expect(scrubMatrixIds('$1234567890abcdef')).toBe('$[EVENT_ID]'); + }); + + it('leaves short dollar strings untouched (< 10 chars)', () => { + expect(scrubMatrixIds('$short')).toBe('$short'); + }); + + it('scrubs multiple IDs in one string', () => { + const input = 'User @alice:example.com joined !abc:example.com'; + const result = scrubMatrixIds(input); + expect(result).toContain('@[USER_ID]'); + expect(result).toContain('![ROOM_ID]'); + expect(result).not.toContain('@alice'); + expect(result).not.toContain('!abc'); + }); + + it('passes through plain strings with no sensitive content', () => { + const safe = 'Something went wrong loading the timeline'; + expect(scrubMatrixIds(safe)).toBe(safe); + }); +}); + +// ─── scrubDataObject ────────────────────────────────────────────────────────── + +describe('scrubDataObject', () => { + it('scrubs a top-level string', () => { + expect(scrubDataObject('@alice:example.com')).toBe('@[USER_ID]'); + }); + + it('scrubs string values inside a plain object', () => { + const result = scrubDataObject({ userId: '@alice:example.com', count: 3 }) as Record< + string, + unknown + >; + expect(result.userId).toBe('@[USER_ID]'); + expect(result.count).toBe(3); // non-strings are preserved + }); + + it('scrubs string values inside a nested object', () => { + const result = scrubDataObject({ + context: { roomId: '!room:example.com' }, + }) as { context: Record }; + expect(result.context.roomId).toBe('![ROOM_ID]'); + }); + + it('scrubs string values inside an array', () => { + const result = scrubDataObject(['@alice:example.com', '!room:example.com', 42]) as unknown[]; + expect(result[0]).toBe('@[USER_ID]'); + expect(result[1]).toBe('![ROOM_ID]'); + expect(result[2]).toBe(42); + }); + + it('passes through null unchanged', () => { + expect(scrubDataObject(null)).toBeNull(); + }); + + it('passes through numbers and booleans unchanged', () => { + expect(scrubDataObject(42)).toBe(42); + expect(scrubDataObject(true)).toBe(true); + }); + + it('handles an empty object', () => { + expect(scrubDataObject({})).toEqual({}); + }); +}); + +// ─── scrubMatrixUrl ─────────────────────────────────────────────────────────── + +describe('scrubMatrixUrl – Matrix C-S API paths', () => { + it('scrubs room ID in /rooms/ path', () => { + expect(scrubMatrixUrl('/_matrix/client/v3/rooms/!abc:example.com/messages')).toBe( + '/_matrix/client/v3/rooms/![ROOM_ID]/messages' + ); + }); + + it('scrubs event ID in /event/ path', () => { + expect(scrubMatrixUrl('/rooms/!abc:example.com/event/$eventIdHere')).toContain( + '/event/$[EVENT_ID]' + ); + }); + + it('scrubs event ID in /relations/ path', () => { + expect(scrubMatrixUrl('/rooms/!abc:example.com/relations/$eventIdHere')).toContain( + '/relations/$[EVENT_ID]' + ); + }); + + it('scrubs user ID in /profile/ path', () => { + expect(scrubMatrixUrl('/_matrix/client/v3/profile/@alice:example.com')).toBe( + '/_matrix/client/v3/profile/[USER_ID]' + ); + }); + + it('scrubs percent-encoded user ID in /profile/ path', () => { + expect(scrubMatrixUrl('/profile/%40alice%3Aexample.com')).toBe('/profile/[USER_ID]'); + }); + + it('scrubs user ID in /user/ path', () => { + expect(scrubMatrixUrl('/_matrix/client/v3/user/@alice:example.com/filter')).toBe( + '/_matrix/client/v3/user/[USER_ID]/filter' + ); + }); + + it('scrubs user ID in /presence/ path', () => { + expect(scrubMatrixUrl('/_matrix/client/v3/presence/@alice:example.com/status')).toBe( + '/_matrix/client/v3/presence/[USER_ID]/status' + ); + }); + + it('scrubs the version segment in /room_keys/keys/ paths', () => { + // The regex scrubs up to the first '/' — the version segment is redacted. + // Sub-paths (roomId, sessionId) are handled by subsequent URL patterns. + expect(scrubMatrixUrl('/_matrix/client/v3/room_keys/keys/latest')).toBe( + '/_matrix/client/v3/room_keys/keys/[REDACTED]' + ); + }); + + it('scrubs /sendToDevice/ transaction IDs', () => { + expect(scrubMatrixUrl('/sendToDevice/m.room.encrypted/txnId123')).toBe( + '/sendToDevice/m.room.encrypted/[TXN_ID]' + ); + }); + + it('scrubs MSC3916 media download path', () => { + expect(scrubMatrixUrl('/_matrix/client/v1/media/download/matrix.org/someMediaId')).toBe( + '/_matrix/client/v1/media/download/[SERVER]/[MEDIA_ID]' + ); + }); + + it('scrubs legacy /media/v3/ download path', () => { + expect(scrubMatrixUrl('/_matrix/media/v3/download/matrix.org/someMediaId')).toBe( + '/_matrix/media/v3/download/[SERVER]/[MEDIA_ID]' + ); + }); +}); + +describe('scrubMatrixUrl – app route path segments', () => { + it('scrubs bare room ID in app route', () => { + expect(scrubMatrixUrl('/home/!roomid:example.com/timeline')).toBe('/home/![ROOM_ID]/timeline'); + }); + + it('scrubs hybrid room ID (decoded sigil, encoded colon)', () => { + expect(scrubMatrixUrl('/home/!roomid%3Aexample.com/timeline')).toBe( + '/home/![ROOM_ID]/timeline' + ); + }); + + it('scrubs bare user ID in app route', () => { + expect(scrubMatrixUrl('/dm/@alice:example.com')).toBe('/dm/@[USER_ID]'); + }); + + it('scrubs bare room alias in app route', () => { + expect(scrubMatrixUrl('/home/#general:example.com')).toBe('/home/[ROOM_ALIAS]'); + }); +}); + +describe('scrubMatrixUrl – deep-link (percent-encoded) forms', () => { + it('scrubs %40-encoded user ID', () => { + expect(scrubMatrixUrl('/open/%40alice%3Aexample.com')).toBe('/open/[USER_ID]'); + }); + + it('scrubs %21-encoded room ID', () => { + expect(scrubMatrixUrl('/open/%21room%3Aexample.com')).toBe('/open/![ROOM_ID]'); + }); + + it('scrubs %23-encoded room alias', () => { + expect(scrubMatrixUrl('/open/%23general%3Aexample.com')).toBe('/open/[ROOM_ALIAS]'); + }); + + it('scrubs %24-encoded event ID', () => { + expect(scrubMatrixUrl('/open/%24eventIdLongEnough')).toBe('/open/[EVENT_ID]'); + }); +}); + +describe('scrubMatrixUrl – preview_url', () => { + it('strips query string from preview_url endpoint', () => { + expect(scrubMatrixUrl('/_matrix/media/v3/preview_url?url=https://example.com&ts=1234')).toBe( + '/_matrix/media/v3/preview_url' + ); + }); + + it('leaves the path intact and only removes query string', () => { + const result = scrubMatrixUrl('/preview_url?url=https://evil.example.com'); + expect(result).toBe('/preview_url'); + }); +}); + +describe('scrubMatrixUrl – safe inputs', () => { + it('passes through a plain path with no Matrix IDs', () => { + const safe = '/home/timeline'; + expect(scrubMatrixUrl(safe)).toBe(safe); + }); + + it('passes through an empty string', () => { + expect(scrubMatrixUrl('')).toBe(''); + }); +}); diff --git a/src/app/utils/sentryScrubbers.ts b/src/app/utils/sentryScrubbers.ts new file mode 100644 index 000000000..e04df6730 --- /dev/null +++ b/src/app/utils/sentryScrubbers.ts @@ -0,0 +1,100 @@ +/** + * Pure scrubbing helpers shared by the Sentry instrumentation layer. + * + * Extracted from src/instrument.ts so they can be unit-tested independently + * of the Sentry initialisation side-effects. + */ + +/** + * Scrub Matrix entity IDs and credential tokens from a plain string value. + * Handles the sigil-prefixed forms: !roomId:server, @userId:server, $eventId, + * #alias:server, and common credential token query-string / JSON patterns. + * Used for structured log attribute values and breadcrumb data fields. + */ +export function scrubMatrixIds(value: string): string { + return value + .replace( + /(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:\s]+)([^\s&]+)/gi, + '$1$2[REDACTED]' + ) + .replace(/@[^\s:@]+:[^\s,'"(){}[\]]+/g, '@[USER_ID]') + .replace(/![^\s:]+:[^\s,'"(){}[\]]+/g, '![ROOM_ID]') + .replace(/#[^\s:@]+:[^\s,'"(){}[\]]+/g, '#[ROOM_ALIAS]') + .replace(/\$[A-Za-z0-9_+/-]{10,}/g, '$[EVENT_ID]'); +} + +/** + * Recursively scrub Matrix entity IDs from all string values in a plain object. + * Handles one level of nesting (objects and arrays of primitives). + */ +export function scrubDataObject(data: unknown): unknown { + if (typeof data === 'string') return scrubMatrixIds(data); + if (Array.isArray(data)) return data.map(scrubDataObject); + if (data !== null && typeof data === 'object') { + return Object.fromEntries( + Object.entries(data as Record).map(([k, v]) => [k, scrubDataObject(v)]) + ); + } + return data; +} + +/** + * Scrub Matrix-specific identifiers from URLs that appear in Sentry spans, breadcrumbs, + * transaction names, and page URLs. Covers both Matrix API paths and client-side app routes. + * Room IDs, user IDs, event IDs, media paths, and deep-link parameters are replaced with + * safe placeholders so no PII leaks into Sentry. + */ +export function scrubMatrixUrl(url: string): string { + return ( + url + // ── Matrix Client-Server API paths ────────────────────────────────────────────── + // /rooms/!roomId:server/... + .replace(/\/rooms\/![^/?#\s]*/g, '/rooms/![ROOM_ID]') + // /event/$eventId and /relations/$eventId + .replace(/\/event\/(?:\$|%24)[^/?#\s]*/g, '/event/$[EVENT_ID]') + .replace(/\/relations\/(?:\$|%24)[^/?#\s]*/g, '/relations/$[EVENT_ID]') + // /profile/@user:server or /profile/%40user%3Aserver + .replace(/\/profile\/(?:%40|@)[^/?#\s]*/gi, '/profile/[USER_ID]') + // /user/@user:server/... and /presence/@user:server/status + .replace(/\/(user|presence)\/(?:%40|@)[^/?#\s]*/gi, '/$1/[USER_ID]') + // /room_keys/keys/{version}/{roomId}/{sessionId} + .replace(/\/room_keys\/keys\/[^/?#\s]*/gi, '/room_keys/keys/[REDACTED]') + // /sendToDevice/{eventType}/{txnId} + .replace(/\/sendToDevice\/([^/?#\s]+)\/[^/?#\s]+/gi, '/sendToDevice/$1/[TXN_ID]') + // Media – MSC3916 (/media/thumbnail|download/{server}/{mediaId}) and legacy (v1/v3) + .replace( + /(\/media\/(?:thumbnail|download)\/)(?:[^/?#\s]+)\/(?:[^/?#\s]+)/gi, + '$1[SERVER]/[MEDIA_ID]' + ) + .replace( + /(\/media\/v\d+\/(?:thumbnail|download)\/)(?:[^/?#\s]+)\/(?:[^/?#\s]+)/gi, + '$1[SERVER]/[MEDIA_ID]' + ) + // ── App route path segments ───────────────────────────────────────────────────── + // Bare/partially-decoded Matrix IDs in URL path segments. + // Browsers decode %21→! and %40→@ for display but often keep %3A encoded, + // so we see hybrid forms like /!localpart%3Aserver/ or /!localpart:server/. + // Each pattern accepts either a literal colon or the %3A encoding. + // Bare room IDs: /!localpart:server/ or /!localpart%3Aserver/ + .replace(/\/![^/?#\s:%]+(?:%3A|:)[^/?#\s]*/gi, '/![ROOM_ID]') + // Bare user IDs: /@user:server/ or /@user%3Aserver/ + .replace(/\/@[^/?#\s:%]+(?:%3A|:)[^/?#\s]*/gi, '/@[USER_ID]') + // Bare room aliases: /#alias:server/ or /#alias%3Aserver/ + .replace(/\/#[^/?#\s:%]+(?:%3A|:)[^/?#\s]*/gi, '/[ROOM_ALIAS]') + // ── Deep-link / app-route URLs (percent-encoded via encodeURIComponent) ───────── + // URL-encoded user IDs: /%40user%3Aserver (%40 = @) + .replace(/\/%40[^/?#\s]*/gi, '/[USER_ID]') + // URL-encoded room IDs: /%21room%3Aserver (%21 = !) + .replace(/\/%21[^/?#\s]*/gi, '/![ROOM_ID]') + // URL-encoded room aliases: /%23alias%3Aserver (%23 = #) + // App routes like /:spaceIdOrAlias/ use encodeURIComponent() so #alias:server + // appears as %23alias%3Aserver in the URL path / Sentry transaction name. + .replace(/\/%23[^/?#\s]*/gi, '/[ROOM_ALIAS]') + // URL-encoded event IDs as bare path segments: /%24eventId (%24 = $) + .replace(/\/%24[^/?#\s]*/gi, '/[EVENT_ID]') + // ── Preview URL endpoint ──────────────────────────────────────────────────────── + // The ?url= query parameter on preview_url contains the full external URL being + // previewed — strip the entire query string so browsing habits cannot be inferred. + .replace(/(\/preview_url)\?[^#\s]*/gi, '$1') + ); +} diff --git a/src/instrument.ts b/src/instrument.ts index cd3f55869..47ff24165 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -14,6 +14,7 @@ import { createRoutesFromChildren, matchRoutes, } from 'react-router-dom'; +import { scrubMatrixIds, scrubDataObject, scrubMatrixUrl } from './app/utils/sentryScrubbers'; const dsn = import.meta.env.VITE_SENTRY_DSN; const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; @@ -27,99 +28,6 @@ const SESSION_ERROR_LIMIT = 50; const sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false'; const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true'; -/** - * Scrub Matrix entity IDs from a plain string value (not a URL path). - * Handles the sigil-prefixed forms: !roomId:server, @userId:server, $eventId. - * Used for structured log attribute values and breadcrumb data fields. - */ -function scrubMatrixIds(value: string): string { - return value - .replace( - /(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:\s]+)([^\s&]+)/gi, - '$1$2[REDACTED]' - ) - .replace(/@[^\s:@]+:[^\s,'"(){}[\]]+/g, '@[USER_ID]') - .replace(/![^\s:]+:[^\s,'"(){}[\]]+/g, '![ROOM_ID]') - .replace(/#[^\s:@]+:[^\s,'"(){}[\]]+/g, '#[ROOM_ALIAS]') - .replace(/\$[A-Za-z0-9_+/-]{10,}/g, '$[EVENT_ID]'); -} - -/** - * Recursively scrub Matrix entity IDs from all string values in a plain object. - * Handles one level of nesting (objects and arrays of primitives). - */ -function scrubDataObject(data: unknown): unknown { - if (typeof data === 'string') return scrubMatrixIds(data); - if (Array.isArray(data)) return data.map(scrubDataObject); - if (data !== null && typeof data === 'object') { - return Object.fromEntries( - Object.entries(data as Record).map(([k, v]) => [k, scrubDataObject(v)]) - ); - } - return data; -} - -/** - * Scrub Matrix-specific identifiers from URLs that appear in Sentry spans, breadcrumbs, - * transaction names, and page URLs. Covers both Matrix API paths and client-side app routes. - * Room IDs, user IDs, event IDs, media paths, and deep-link parameters are replaced with - * safe placeholders so no PII leaks into Sentry. - */ -function scrubMatrixUrl(url: string): string { - return ( - url - // ── Matrix Client-Server API paths ────────────────────────────────────────────── - // /rooms/!roomId:server/... - .replace(/\/rooms\/![^/?#\s]*/g, '/rooms/![ROOM_ID]') - // /event/$eventId and /relations/$eventId - .replace(/\/event\/(?:\$|%24)[^/?#\s]*/g, '/event/$[EVENT_ID]') - .replace(/\/relations\/(?:\$|%24)[^/?#\s]*/g, '/relations/$[EVENT_ID]') - // /profile/@user:server or /profile/%40user%3Aserver - .replace(/\/profile\/(?:%40|@)[^/?#\s]*/gi, '/profile/[USER_ID]') - // /user/@user:server/... and /presence/@user:server/status - .replace(/\/(user|presence)\/(?:%40|@)[^/?#\s]*/gi, '/$1/[USER_ID]') - // /room_keys/keys/{version}/{roomId}/{sessionId} - .replace(/\/room_keys\/keys\/[^/?#\s]*/gi, '/room_keys/keys/[REDACTED]') - // /sendToDevice/{eventType}/{txnId} - .replace(/\/sendToDevice\/([^/?#\s]+)\/[^/?#\s]+/gi, '/sendToDevice/$1/[TXN_ID]') - // Media – MSC3916 (/media/thumbnail|download/{server}/{mediaId}) and legacy (v1/v3) - .replace( - /(\/media\/(?:thumbnail|download)\/)(?:[^/?#\s]+)\/(?:[^/?#\s]+)/gi, - '$1[SERVER]/[MEDIA_ID]' - ) - .replace( - /(\/media\/v\d+\/(?:thumbnail|download)\/)(?:[^/?#\s]+)\/(?:[^/?#\s]+)/gi, - '$1[SERVER]/[MEDIA_ID]' - ) - // ── App route path segments ───────────────────────────────────────────────────── - // Bare/partially-decoded Matrix IDs in URL path segments. - // Browsers decode %21→! and %40→@ for display but often keep %3A encoded, - // so we see hybrid forms like /!localpart%3Aserver/ or /!localpart:server/. - // Each pattern accepts either a literal colon or the %3A encoding. - // Bare room IDs: /!localpart:server/ or /!localpart%3Aserver/ - .replace(/\/![^/?#\s:%]+(?:%3A|:)[^/?#\s]*/gi, '/![ROOM_ID]') - // Bare user IDs: /@user:server/ or /@user%3Aserver/ - .replace(/\/@[^/?#\s:%]+(?:%3A|:)[^/?#\s]*/gi, '/@[USER_ID]') - // Bare room aliases: /#alias:server/ or /#alias%3Aserver/ - .replace(/\/#[^/?#\s:%]+(?:%3A|:)[^/?#\s]*/gi, '/[ROOM_ALIAS]') - // ── Deep-link / app-route URLs (percent-encoded via encodeURIComponent) ───────── - // URL-encoded user IDs: /%40user%3Aserver (%40 = @) - .replace(/\/%40[^/?#\s]*/gi, '/[USER_ID]') - // URL-encoded room IDs: /%21room%3Aserver (%21 = !) - .replace(/\/%21[^/?#\s]*/gi, '/![ROOM_ID]') - // URL-encoded room aliases: /%23alias%3Aserver (%23 = #) - // App routes like /:spaceIdOrAlias/ use encodeURIComponent() so #alias:server - // appears as %23alias%3Aserver in the URL path / Sentry transaction name. - .replace(/\/%23[^/?#\s]*/gi, '/[ROOM_ALIAS]') - // URL-encoded event IDs as bare path segments: /%24eventId (%24 = $) - .replace(/\/%24[^/?#\s]*/gi, '/[EVENT_ID]') - // ── Preview URL endpoint ──────────────────────────────────────────────────────── - // The ?url= query parameter on preview_url contains the full external URL being - // previewed — strip the entire query string so browsing habits cannot be inferred. - .replace(/(\/preview_url)\?[^#\s]*/gi, '$1') - ); -} - // Only initialize if DSN is provided and user hasn't opted out if (dsn && sentryEnabled) { Sentry.init({