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/.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/.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/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/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 new file mode 100644 index 000000000..e71f417ea --- /dev/null +++ b/docs/SENTRY_INTEGRATION.md @@ -0,0 +1,418 @@ +# 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 → General → Diagnostics & Privacy + +## 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 +``` + +### 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):** + +- 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 → General → Diagnostics & Privacy. + +## 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 → 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 + +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..265ef57ee --- /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 → General → Diagnostics & Privacy. + +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 → 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 +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..62042cef1 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,49 @@ 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..f38cf8be2 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -133,6 +133,7 @@ import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import FocusTrap from 'focus-trap-react'; import { useQueryClient } from '@tanstack/react-query'; +import * as Sentry from '@sentry/react'; import { delayedEventsSupportedAtom, roomIdToScheduledTimeAtomFamily, @@ -700,20 +701,36 @@ 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); }); } diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 748162cb3..a829ba39e 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), @@ -879,6 +900,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, @@ -1038,6 +1067,9 @@ 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) { setUnreadInfo(getRoomUnreadInfo(room)); @@ -1114,6 +1146,17 @@ export function RoomTimeline({ // self-heal effect below can advance the range as events arrive on the fresh // timeline, without atBottom=true being required. // + // 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); + // 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. 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..542b15df7 --- /dev/null +++ b/src/app/features/settings/developer-tools/SentrySettings.tsx @@ -0,0 +1,153 @@ +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 [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 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 sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false'; + 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) + + Error reporting toggles are in Settings → General → Diagnostics & Privacy. + + {!isSentryConfigured && ( + + + Sentry is not configured. Set VITE_SENTRY_DSN to enable error tracking. + + + )} + + {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/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index fd0d9d705..4facb2cc3 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -1053,6 +1053,109 @@ export function Sync() { type GeneralProps = { requestClose: () => void; }; + +function DiagnosticsAndPrivacy() { + 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 isSentryConfigured = Boolean(import.meta.env.VITE_SENTRY_DSN); + + 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); + }; + + return ( + + Diagnostics & Privacy + {needsRefresh && ( + + + Please refresh the page for these settings to take effect. + + + )} + + + } + /> + {sentryEnabled && isSentryConfigured && ( + + } + /> + )} + + + + + + ); +} + export function General({ requestClose }: GeneralProps) { return ( @@ -1078,6 +1181,7 @@ export function General({ requestClose }: GeneralProps) { + 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/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..d81890da1 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,60 +154,70 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) return null; }} element={ - - {/* HandleNotificationClick must live outside ClientRoot's loading gate so + ( + + )} + 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. */} - - - - - - - - - - - - } - > - - - - - - - - - - - - - {/* Screen reader live region — populated by announce() in utils/announce.ts */} -
- - - - - - - - + + + + + + + + + + + + } + > + + + + + + + + + + + + + {/* Screen reader live region — populated by announce() in utils/announce.ts */} +
+ + + + + + + + + } > (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..a5e78e606 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,81 @@ 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', 'none'); + Sentry.setTag('room_encrypted', 'none'); + 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. + 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', + 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 +768,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..5823ea458 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -1,4 +1,5 @@ -import { useMemo } 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'; @@ -161,6 +162,9 @@ export function DirectDMsList() { const selectedRoomId = useSelectedRoom(); const sidebarRoomIds = useSidebarDirectRoomIds(); + const mountTimeRef = useRef(performance.now()); + const firstReadyRef = useRef(false); + const recentDMs = useMemo( () => sidebarRoomIds @@ -169,6 +173,16 @@ export function DirectDMsList() { [sidebarRoomIds, mx] ); + useEffect(() => { + if (recentDMs.length > 0 && !firstReadyRef.current) { + firstReadyRef.current = true; + Sentry.metrics.distribution( + 'sable.roomlist.time_to_ready_ms', + performance.now() - mountTimeRef.current + ); + } + }, [recentDMs]); + if (recentDMs.length === 0) { return null; } 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,128 @@ 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)) { + 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); + 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 +294,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..f04f71d90 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -18,6 +18,7 @@ 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 * as Sentry from '@sentry/react'; import { getEventReactions, getReactionContent, getStateEvent } from './room'; 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,25 @@ 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..29e599dee --- /dev/null +++ b/src/instrument.ts @@ -0,0 +1,372 @@ +/** + * 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'; + +/** + * 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({ + 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) { + // 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 and HTTP data URLs before sending to Sentry + beforeBreadcrumb(breadcrumb) { + // 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 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) { + 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 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); + } + }); + } + + // Scrub request data + if (event.request?.url) { + // eslint-disable-next-line no-param-reassign + 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) { + 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', + }); + + // 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; + + // 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.