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.md b/docs/PRIVACY.md new file mode 100644 index 000000000..b6c1e018d --- /dev/null +++ b/docs/PRIVACY.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: **privacy@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/dev/docs/SENTRY_PRIVACY.md**](https://github.com/SableClient/Sable/dev/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 **privacy@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://github.com/SableClient/Sable/blob/dev/docs/PRIVACY.md**](https://github.com/SableClient/Sable/blob/dev/docs/PRIVACY.md) and/or [**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 **privacy@sable.moe**. diff --git a/docs/SENTRY_INTEGRATION.md b/docs/SENTRY_INTEGRATION.md new file mode 100644 index 000000000..1d5814983 --- /dev/null +++ b/docs/SENTRY_INTEGRATION.md @@ -0,0 +1,475 @@ +# 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 + +## Bug Fixes (found via Sentry Replay) + +Two non-Sentry bugs were found and fixed in the course of building this integration: + +### Scroll-to-bottom after list→subscription timeline expansion + +**Problem**: When a room with a single cached event (list subscription, `timeline_limit=1`) becomes +fully subscribed and the SDK delivers N new events, the `TimelineReset` fires before any events land +on the fresh timeline. The "stay at bottom" effect queues a `scrollToBottom` while the DOM is still +empty (range `end=0`). By the time real events load, the scroll has already fired against an empty +container and is a no-op — the user's view stalls mid-list. + +**Fix**: The stay-at-bottom `useEffect` now increments `scrollToBottomRef.current.count` after +calling `setTimeline(getInitialTimeline(room))`, re-queuing the scroll for after the first batch of +events arrives and the DOM has content. + +**File**: `src/app/features/room/RoomTimeline.tsx` + +### TS2367 redundant phase guard in `useCallSignaling` + +**Problem**: A `phase !== undefined` guard was always evaluating to `true` because the TypeScript +type for `phase` had no `undefined` branch at that point in the control flow. + +**Fix**: Removed the dead branch. TypeScript no longer emits a TS2367 comparison error here. + +**File**: `src/app/hooks/useCallSignaling.ts` + +--- + +## 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 + +Sentry controls are split across two settings locations: + +**Settings → General → Diagnostics & Privacy** (user-facing): + +- **Enable/disable error reporting**: Toggle Sentry error tracking on/off +- **Session replay control**: Enable/disable session recording (opt-in) +- Link to the privacy policy + +**Settings → Developer Tools → Error Tracking (Sentry)** (power-user): + +- **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 report +- **Test buttons**: Force an error, test feedback, test message capture + +## 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, initial sync latency, time-to-ready, total rooms loaded, active subscriptions | +| **Cryptography** | Decryption failures (by failure reason), key backup errors, store wipes, E2E verification outcomes, bulk decryption latency | +| **Messaging** | Send latency, send errors, local-echo `NOT_SENT` events | +| **Timeline** | Opens, virtual window size, jump-load latency, re-initialisations, `limited` sync resets, scroll offset at load, pagination errors | +| **Pagination** | Pagination latency (`sable.pagination.latency_ms`) and errors per direction | +| **Sliding sync** | Room subscription latency (`sable.sync.room_sub_latency_ms`), events per subscription batch (`sable.sync.room_sub_event_count`) | +| **Scroll / UX** | `atBottom` transitions with rapid-flip anomaly detection, scroll-to-bottom trigger warnings when user is scrolled up | +| **Calls** | `sable.call.start.attempt/error`, `sable.call.answered`, `sable.call.declined`, active/ended/timeout counters | +| **Message actions** | `sable.message.delete.*`, `sable.message.forward.*`, `sable.message.report.*`, `sable.message.reaction.toggle` | +| **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 + +### Breadcrumb categories + +All hand-crafted breadcrumbs use structured Sentry categories that appear in +the Sentry issue timeline and can be filtered in the developer settings panel. + +| Category | Where emitted | What it records | +| ----------------- | ---------------------------------------------- | ------------------------------------------------------------------------------ | +| `auth` | `ClientRoot.tsx` | Login session start, forced logout | +| `sync` | `initMatrix.ts`, `SyncStatus.tsx` | Sync state transitions, degraded states, client ready | +| `sync.sliding` | `slidingSync.ts` | First room subscription data: latency, event count | +| `timeline.sync` | `RoomTimeline.tsx` | SDK-initiated `TimelineReset` (limited sync gap) — fires before events arrive | +| `timeline.events` | `RoomTimeline.tsx` | Every `eventsLength` batch: delta, batch size label, range gap, `atBottom` | +| `ui.scroll` | `RoomTimeline.tsx` | `atBottom` true→false transitions, rapid-flip warnings, scroll-to-bottom fires | +| `ui.timeline` | `RoomTimeline.tsx` | Virtual paginator window shifts (range start/end changes) | +| `call.signal` | `useCallSignaling.ts`, `IncomingCallModal.tsx` | Call signal state changes, answer/decline | +| `crypto` | `useKeyBackup.ts` | Key backup errors | +| `media` | `ClientNonUIFeatures.tsx` | Blob cache stats on blob URL creation | + +## 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/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index da17db4f3..3ab4e8457 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -19,14 +19,18 @@ import { getRoomAvatarUrl } from '$utils/room'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '$utils/keyboard'; +import * as Sentry from '@sentry/react'; import { useAtom, useSetAtom } from 'jotai'; import { autoJoinCallIntentAtom, incomingCallRoomIdAtom, mutedCallRoomIdAtom, } from '$state/callEmbed'; +import { createDebugLogger } from '$utils/debugLogger'; import { RoomAvatar } from './room-avatar'; +const debugLog = createDebugLogger('IncomingCall'); + type IncomingCallInternalProps = { room: any; onClose: () => void; @@ -41,6 +45,13 @@ export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProp const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); const handleAnswer = () => { + debugLog.info('call', 'Incoming call answered', { roomId: room.roomId }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Incoming call answered', + data: { roomId: room.roomId }, + }); + Sentry.metrics.count('sable.call.answered', 1); setMutedRoomId(room.roomId); setAutoJoinIntent(room.roomId); onClose(); @@ -48,6 +59,13 @@ export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProp }; const handleDecline = async () => { + debugLog.info('call', 'Incoming call declined', { roomId: room.roomId }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Incoming call declined', + data: { roomId: room.roomId }, + }); + Sentry.metrics.count('sable.call.declined', 1); setMutedRoomId(room.roomId); onClose(); }; diff --git a/src/app/components/message/modals/MessageDelete.tsx b/src/app/components/message/modals/MessageDelete.tsx index 6637c97b6..666895102 100644 --- a/src/app/components/message/modals/MessageDelete.tsx +++ b/src/app/components/message/modals/MessageDelete.tsx @@ -20,6 +20,10 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { modalAtom, ModalType } from '$state/modal'; import * as css from '$features/room/message/styles.css'; +import { createDebugLogger } from '$utils/debugLogger'; +import * as Sentry from '@sentry/react'; + +const debugLog = createDebugLogger('MessageDelete'); export function MessageDeleteItem({ room, mEvent }: { room: Room; mEvent: MatrixEvent }) { const setModal = useSetAtom(modalAtom); @@ -67,9 +71,15 @@ export function MessageDeleteInternal({ room, mEvent, onClose }: MessageDeleteIn useEffect(() => { if (deleteState.status === AsyncStatus.Success) { + debugLog.info('ui', 'Message deleted successfully', { roomId: room.roomId }); + Sentry.metrics.count('sable.message.delete.success', 1); onClose(); } - }, [deleteState.status, onClose]); + if (deleteState.status === AsyncStatus.Error) { + debugLog.error('ui', 'Message delete failed', { roomId: room.roomId }); + Sentry.metrics.count('sable.message.delete.error', 1); + } + }, [deleteState.status, room.roomId, onClose]); const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); @@ -85,6 +95,8 @@ export function MessageDeleteInternal({ room, mEvent, onClose }: MessageDeleteIn const reasonInput = target?.reasonInput as HTMLInputElement | undefined; const reason = reasonInput && reasonInput.value.trim(); + debugLog.info('ui', 'Deleting message', { eventId, hasReason: !!reason }); + Sentry.metrics.count('sable.message.delete.attempt', 1); deleteMessage(eventId, reason); }; diff --git a/src/app/components/message/modals/MessageForward.tsx b/src/app/components/message/modals/MessageForward.tsx index dbce8b29d..219e41197 100644 --- a/src/app/components/message/modals/MessageForward.tsx +++ b/src/app/components/message/modals/MessageForward.tsx @@ -26,6 +26,10 @@ import * as css from '$features/room/message/styles.css'; import { sanitizeCustomHtml } from '$utils/sanitize'; import { getStateEvents } from '$utils/room'; import { StateEvent } from '$types/matrix/room'; +import { createDebugLogger } from '$utils/debugLogger'; +import * as Sentry from '@sentry/react'; + +const debugLog = createDebugLogger('MessageForward'); // Message forwarding component export const MessageForwardItem = as<'button', MessageForwardItemProps>( @@ -263,9 +267,30 @@ export function MessageForwardInternal({ }; } + const msgtype = String(originalContent.msgtype ?? 'unknown'); + debugLog.info('ui', 'Forwarding message', { + sourceRoomId: room.roomId, + targetRoomId: targetRoom.roomId, + msgtype, + isPrivate, + }); + Sentry.metrics.count('sable.message.forward.attempt', 1, { attributes: { msgtype } }); mx.sendEvent(targetRoom.roomId, null, eventType, content as unknown as SendEventContent) - .then(() => setIsForwardSuccess(true)) - .catch(() => { + .then(() => { + debugLog.info('ui', 'Message forwarded successfully', { + sourceRoomId: room.roomId, + targetRoomId: targetRoom.roomId, + }); + Sentry.metrics.count('sable.message.forward.success', 1); + setIsForwardSuccess(true); + }) + .catch((err: unknown) => { + debugLog.error('ui', 'Message forward failed', { + sourceRoomId: room.roomId, + targetRoomId: targetRoom.roomId, + error: err instanceof Error ? err.message : String(err), + }); + Sentry.metrics.count('sable.message.forward.error', 1); setIsForwarding(false); setIsForwardSuccess(false); setIsForwardError(true); diff --git a/src/app/components/message/modals/MessageReport.tsx b/src/app/components/message/modals/MessageReport.tsx index cf8aacc59..71cf03cc5 100644 --- a/src/app/components/message/modals/MessageReport.tsx +++ b/src/app/components/message/modals/MessageReport.tsx @@ -1,4 +1,4 @@ -import { FormEventHandler, MouseEvent, useCallback } from 'react'; +import { FormEventHandler, MouseEvent, useCallback, useEffect } from 'react'; import { Room, MatrixEvent } from '$types/matrix-sdk'; import { useSetAtom } from 'jotai'; import { @@ -20,6 +20,10 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import * as css from '$features/room/message/styles.css'; import { modalAtom, ModalType } from '$state/modal'; +import { createDebugLogger } from '$utils/debugLogger'; +import * as Sentry from '@sentry/react'; + +const debugLog = createDebugLogger('MessageReport'); export function MessageReportItem({ room, mEvent }: { room: Room; mEvent: MatrixEvent }) { const setModal = useSetAtom(modalAtom); @@ -65,6 +69,17 @@ export function MessageReportInternal({ room, mEvent, onClose }: MessageReportIn ) ); + useEffect(() => { + if (reportState.status === AsyncStatus.Success) { + debugLog.info('ui', 'Message reported successfully', { roomId: room.roomId }); + Sentry.metrics.count('sable.message.report.success', 1); + } + if (reportState.status === AsyncStatus.Error) { + debugLog.error('ui', 'Message report failed', { roomId: room.roomId }); + Sentry.metrics.count('sable.message.report.error', 1); + } + }, [reportState.status, room.roomId]); + const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); const eventId = mEvent.getId(); @@ -79,6 +94,8 @@ export function MessageReportInternal({ room, mEvent, onClose }: MessageReportIn const reasonInput = target?.reasonInput as HTMLInputElement | undefined; const reason = reasonInput && reasonInput.value.trim(); + debugLog.info('ui', 'Reporting message', { eventId, hasReason: !!reason }); + Sentry.metrics.count('sable.message.report.attempt', 1); reportMessage(eventId, reason ? -100 : -50, reason || 'No reason provided'); }; diff --git a/src/app/components/telemetry-consent/TelemetryConsentBanner.css.ts b/src/app/components/telemetry-consent/TelemetryConsentBanner.css.ts new file mode 100644 index 000000000..70d9ba0ba --- /dev/null +++ b/src/app/components/telemetry-consent/TelemetryConsentBanner.css.ts @@ -0,0 +1,82 @@ +import { keyframes, style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +const slideUp = keyframes({ + from: { + opacity: 0, + transform: 'translateY(100%)', + }, + to: { + opacity: 1, + transform: 'translateY(0)', + }, +}); + +const slideDown = keyframes({ + from: { + opacity: 1, + transform: 'translateY(0)', + }, + to: { + opacity: 0, + transform: 'translateY(100%)', + }, +}); + +export const Container = style({ + position: 'fixed', + bottom: 'env(safe-area-inset-bottom, 0)', + left: '50%', + transform: 'translateX(-50%)', + zIndex: 9998, + width: `min(100%, ${toRem(520)})`, + padding: config.space.S400, + pointerEvents: 'none', +}); + +export const Banner = style({ + pointerEvents: 'all', + display: 'flex', + flexDirection: 'column', + gap: config.space.S300, + backgroundColor: color.Surface.Container, + color: color.Surface.OnContainer, + border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + borderRadius: toRem(16), + padding: config.space.S400, + boxShadow: `0 ${toRem(8)} ${toRem(32)} rgba(0, 0, 0, 0.45), 0 ${toRem(2)} ${toRem(8)} rgba(0, 0, 0, 0.3)`, + animationName: slideUp, + animationDuration: '300ms', + animationTimingFunction: 'cubic-bezier(0.22, 0.8, 0.6, 1)', + animationFillMode: 'both', + + selectors: { + '&[data-dismissing=true]': { + animationName: slideDown, + animationDuration: '220ms', + animationTimingFunction: 'cubic-bezier(0.4, 0, 1, 1)', + animationFillMode: 'both', + }, + }, +}); + +export const Header = style({ + display: 'flex', + alignItems: 'flex-start', + gap: config.space.S300, +}); + +export const HeaderText = style({ + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: toRem(4), +}); + +export const Actions = style({ + display: 'flex', + gap: config.space.S200, + justifyContent: 'flex-end', + flexWrap: 'wrap', +}); diff --git a/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx b/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx new file mode 100644 index 000000000..17b4fd30d --- /dev/null +++ b/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from 'react'; +import { Box, Button, Icon, IconButton, Icons, Text } from 'folds'; +import * as css from './TelemetryConsentBanner.css'; + +const SENTRY_KEY = 'sable_sentry_enabled'; + +export function TelemetryConsentBanner() { + const isSentryConfigured = Boolean(import.meta.env.VITE_SENTRY_DSN); + const [visible, setVisible] = useState( + isSentryConfigured && localStorage.getItem(SENTRY_KEY) === null + ); + const [dismissing, setDismissing] = useState(false); + const dismissTimerRef = useRef | null>(null); + + useEffect( + () => () => { + if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current); + }, + [] + ); + + if (!visible) return null; + + const handleAcknowledge = () => { + localStorage.setItem(SENTRY_KEY, 'true'); + setDismissing(true); + dismissTimerRef.current = setTimeout(() => setVisible(false), 220); + }; + + const handleOptOut = () => { + localStorage.setItem(SENTRY_KEY, 'false'); + window.location.reload(); + }; + + return ( +
+
+
+ +
+ Crash reporting is enabled + + Sable sends anonymous crash reports to help us fix bugs faster. No messages, room + names, or personal data are included.{' '} + + Learn more + + +
+ + + +
+ + + + +
+
+ ); +} diff --git a/src/app/components/telemetry-consent/index.ts b/src/app/components/telemetry-consent/index.ts new file mode 100644 index 000000000..644d8bda1 --- /dev/null +++ b/src/app/components/telemetry-consent/index.ts @@ -0,0 +1 @@ +export * from './TelemetryConsentBanner'; diff --git a/src/app/features/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..bae02c23f 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), @@ -545,13 +566,30 @@ const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { useEffect(() => { const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r: Room) => { if (r.roomId !== room.roomId) return; + // App-initiated full reinit (e.g. from refreshLiveTimeline()). Rare in normal usage. + debugLog.debug('timeline', 'TimelineRefresh: app-initiated live timeline reinit', { + roomId: room.roomId, + trigger: 'TimelineRefresh', + }); onRefreshRef.current(); }; // The SDK fires RoomEvent.TimelineReset on the EventTimelineSet (not the Room) - // when a limited sliding-sync response replaces the live EventTimeline with a - // fresh one. Without this handler, the stored linkedTimelines reference the old - // detached chain and back-pagination silently no-ops, freezing the room. + // when a limited sync response replaces the live EventTimeline with a fresh one. + // This happens in classic /sync on limited=true (gap after idle/reconnect) AND in + // sliding sync when the proxy sends a limited room update. const handleTimelineReset: EventTimelineSetHandlerMap[RoomEvent.TimelineReset] = () => { + debugLog.info('timeline', 'TimelineReset: SDK-initiated (limited sync / sync gap)', { + roomId: room.roomId, + trigger: 'TimelineReset', + liveTimelineEvents: room.getUnfilteredTimelineSet().getLiveTimeline().getEvents().length, + }); + Sentry.metrics.count('sable.timeline.limited_reset', 1); + Sentry.addBreadcrumb({ + category: 'timeline.sync', + message: 'TimelineReset: limited sync gap', + level: 'info', + data: { roomId: room.roomId }, + }); onRefreshRef.current(); }; const unfilteredTimelineSet = room.getUnfilteredTimelineSet(); @@ -806,10 +844,35 @@ export function RoomTimeline({ const [atBottom, setAtBottomState] = useState(true); const atBottomRef = useRef(atBottom); - const setAtBottom = useCallback((val: boolean) => { - setAtBottomState(val); - atBottomRef.current = val; - }, []); + // Tracks when atBottom last changed so we can detect rapid true→false flips + // (characteristic of the IO false-positive on bulk event loads). + const atBottomLastChangedRef = useRef(0); + const setAtBottom = useCallback( + (val: boolean) => { + setAtBottomState(val); + const now = Date.now(); + const msSincePrevious = now - atBottomLastChangedRef.current; + atBottomLastChangedRef.current = now; + Sentry.addBreadcrumb({ + category: 'ui.scroll', + message: val ? 'Timeline: scrolled to bottom' : 'Timeline: scrolled away from bottom', + level: 'info', + data: { roomId: room.roomId, msSincePrevious }, + }); + // Rapid flip: bottom→away within 200 ms is characteristic of the known + // IntersectionObserver false-positive triggered by bulk event loads causing + // a DOM layout shift (see memory: "RoomTimeline Stay at Bottom False-Positive"). + if (!val && msSincePrevious < 200) { + Sentry.captureMessage('Timeline: rapid atBottom flip (possible spurious scroll reset)', { + level: 'warning', + extra: { roomId: room.roomId, msSincePrevious }, + tags: { feature: 'timeline' }, + }); + } + atBottomRef.current = val; + }, + [room.roomId] + ); // Set to true by the useLiveTimelineRefresh callback when the timeline is // re-initialised (TimelineRefresh or TimelineReset). Allows the range self-heal @@ -877,8 +940,67 @@ export function RoomTimeline({ const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); const liveTimelineLinked = timeline.linkedTimelines.at(-1) === getLiveTimeline(room); + // Track previous eventsLength so we can calculate batch sizes. + const prevEventsLengthRef = useRef(eventsLength); + + // Breadcrumb every time the timeline gains events so we can correlate message + // batches (sliding sync chunks) with scroll state changes in Sentry Replay. + useEffect(() => { + const prev = prevEventsLengthRef.current; + const delta = eventsLength - prev; + prevEventsLengthRef.current = eventsLength; + + if (delta === 0) return; + + const isBatch = delta > 1; + // Classify by size: single new message vs small batch vs large catch-up load + let batchSize: string; + if (delta === 1) batchSize = 'single'; + else if (delta <= 20) batchSize = 'small'; + else if (delta <= 100) batchSize = 'medium'; + else batchSize = 'large'; + + Sentry.addBreadcrumb({ + category: 'timeline.events', + message: `Timeline: ${delta} event${delta === 1 ? '' : 's'} added (${batchSize})`, + level: isBatch ? 'info' : 'debug', + data: { + delta, + batchSize, + eventsLength, + prevEventsLength: prev, + liveTimelineLinked, + rangeEnd: timeline.range.end, + atBottom: atBottomRef.current, + // Gap between live end and visible window — non-zero while user is scrolled back + rangeGap: eventsLength - timeline.range.end, + }, + }); + + // A large batch (> 50) while liveTimelineLinked is the sliding-sync + // adaptive load pattern that can trigger the IO false-positive scroll reset. + // Capture a warning so it's searchable in Sentry even when no reset fires. + if (delta > 50 && liveTimelineLinked) { + Sentry.captureMessage('Timeline: large event batch from sliding sync', { + level: 'warning', + extra: { delta, eventsLength, rangeEnd: timeline.range.end, atBottom: atBottomRef.current }, + tags: { feature: 'timeline', batchSize }, + }); + } + // atBottomRef and timeline.range.end are intentionally read at effect time, not as deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventsLength, liveTimelineLinked]); + // Log timeline component mount/unmount useEffect(() => { + const mode = eventId ? 'jump' : 'live'; + 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, @@ -928,6 +1050,34 @@ export function RoomTimeline({ return currentTimeline; } + // Log range changes with scroll state so we can correlate visible-content + // jumps with paginator window shifts. scrollRef is a stable ref — safe here. + const scrollEl = scrollRef.current; + const ds = newRange.start - currentTimeline.range.start; + const de = newRange.end - currentTimeline.range.end; + debugLog.debug('timeline', 'Virtual paginator range changed', { + prevRange: { start: currentTimeline.range.start, end: currentTimeline.range.end }, + newRange, + deltaStart: ds, + deltaEnd: de, + scrollTop: scrollEl?.scrollTop, + scrollHeight: scrollEl?.scrollHeight, + clientHeight: scrollEl?.clientHeight, + }); + Sentry.addBreadcrumb({ + category: 'ui.timeline', + message: 'Timeline window shifted', + level: 'debug', + data: { + prevStart: currentTimeline.range.start, + prevEnd: currentTimeline.range.end, + newStart: newRange.start, + newEnd: newRange.end, + deltaStart: ds, + deltaEnd: de, + }, + }); + return { ...currentTimeline, range: newRange }; }); }, []), @@ -1038,6 +1188,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 +1267,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. @@ -1163,6 +1327,28 @@ export function RoomTimeline({ if ((atBottom || resetPending) && liveTimelineLinked && eventsLength > timeline.range.end) { if (resetPending) timelineJustResetRef.current = false; // More events exist than our current range shows. Adjust to the live end. + // + // IMPORTANT: also queue a scroll-to-bottom here. The scroll that was queued + // during TimelineReset / initial mount fires when range.end is still 0 + // (the SDK fires Reset *before* populating the fresh timeline), so the DOM + // has no items yet and the scroll is a no-op. This second increment fires + // after setTimeline renders the full range, guaranteeing we actually land + // at the bottom once the events are visible. + const rangeGap = eventsLength - timeline.range.end; + scrollToBottomRef.current.count += 1; + scrollToBottomRef.current.smooth = false; + Sentry.addBreadcrumb({ + category: 'ui.scroll', + message: 'Timeline: stay-at-bottom range expansion + scroll', + level: 'info', + data: { + eventsLength, + prevRangeEnd: timeline.range.end, + rangeGap, + wasReset: resetPending, + atBottom, + }, + }); setTimeline((ct) => ({ ...ct, range: { @@ -1303,7 +1489,24 @@ export function RoomTimeline({ useLayoutEffect(() => { const scrollEl = scrollRef.current; if (scrollEl) { + const preScrollTop = scrollEl.scrollTop; + const preScrollHeight = scrollEl.scrollHeight; + const { clientHeight } = scrollEl; scrollToBottom(scrollEl); + // Log whether we were actually away from bottom at mount — useful for diagnosing + // rooms that open with the wrong scroll position. + const distanceFromBottom = preScrollHeight - preScrollTop - clientHeight; + debugLog.debug('timeline', 'Initial scroll to bottom (mount)', { + preScrollTop, + preScrollHeight, + clientHeight, + postScrollTop: scrollEl.scrollTop, + distanceFromBottom, + alreadyAtBottom: distanceFromBottom <= 2, + }); + if (distanceFromBottom > 0) { + Sentry.metrics.distribution('sable.timeline.initial_scroll_offset_px', distanceFromBottom); + } } }, []); @@ -1315,7 +1518,19 @@ export function RoomTimeline({ const forceScroll = () => { // if the user isn't scrolling jump down to latest content - if (!atBottomRef.current) return; + const wasAtBottom = atBottomRef.current; + const preScrollTop = scrollEl?.scrollTop ?? 0; + const preScrollHeight = scrollEl?.scrollHeight ?? 0; + // Log every resize so we can see when media loads move the timeline and whether + // we corrected it (atBottom=true) or left it (atBottom=false, user is scrolled up). + debugLog.debug('timeline', 'Content resized (image/media load)', { + atBottom: wasAtBottom, + preScrollTop, + preScrollHeight, + clientHeight: scrollEl?.clientHeight, + distanceFromBottom: preScrollHeight - preScrollTop - (scrollEl?.clientHeight ?? 0), + }); + if (!wasAtBottom) return; scrollToBottom(scrollEl, 'instant'); }; @@ -1375,11 +1590,36 @@ export function RoomTimeline({ const scrollEl = scrollRef.current; if (scrollEl) { const behavior = scrollToBottomRef.current.smooth && !reducedMotion ? 'smooth' : 'instant'; + const wasAtBottom = atBottomRef.current; + Sentry.addBreadcrumb({ + category: 'ui.scroll', + message: 'Timeline: scroll-to-bottom triggered', + level: 'info', + data: { roomId: room.roomId, behavior, wasAtBottom }, + }); + // A scroll-to-bottom while the user was NOT at the bottom and no timeline + // reset is expected is a sign of an unexpected scroll jump. + if (!wasAtBottom && !timelineJustResetRef.current) { + Sentry.captureMessage('Timeline: scroll-to-bottom fired while user was scrolled up', { + level: 'warning', + extra: { roomId: room.roomId, behavior }, + tags: { feature: 'timeline' }, + }); + } // Use requestAnimationFrame to ensure the virtual paginator has finished // updating the DOM before we scroll. This prevents scroll position from // being stale when new messages arrive while at the bottom. requestAnimationFrame(() => { + const preScrollTop = scrollEl.scrollTop; + const { scrollHeight } = scrollEl; scrollToBottom(scrollEl, behavior); + debugLog.debug('timeline', 'scrollToBottom fired', { + behavior, + preScrollTop, + scrollHeight, + postScrollTop: scrollEl.scrollTop, + remainingOffset: scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight, + }); // On Android WebView, layout may still settle after the initial scroll. // Fire a second instant scroll after a short delay to guarantee we // reach the true bottom (e.g. after images finish loading or the @@ -1392,7 +1632,7 @@ export function RoomTimeline({ }); } } - }, [scrollToBottomCount, reducedMotion]); + }, [scrollToBottomCount, reducedMotion, room.roomId]); // Remove unreadInfo on mark as read useEffect(() => { @@ -1571,8 +1811,11 @@ export function RoomTimeline({ ); const handleReactionToggle = useCallback( - (targetEventId: string, key: string, shortcode?: string) => - toggleReaction(mx, room, targetEventId, key, shortcode), + (targetEventId: string, key: string, shortcode?: string) => { + debugLog.info('ui', 'Reaction toggled', { roomId: room.roomId, targetEventId, key }); + Sentry.metrics.count('sable.message.reaction.toggle', 1); + toggleReaction(mx, room, targetEventId, key, shortcode); + }, [mx, room] ); diff --git a/src/app/features/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..446ce37d8 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.setItem('sable_sentry_enabled', 'true'); + } 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/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index e666bc3ae..4039bc70b 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -4,6 +4,7 @@ import { MatrixClient, Room } from 'matrix-js-sdk'; import { useSetAtom } from 'jotai'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; +import * as Sentry from '@sentry/react'; import { CallEmbed, ElementCallThemeKind, @@ -17,6 +18,9 @@ import { useResizeObserver } from './useResizeObserver'; import { CallControlState } from '../plugins/call/CallControlState'; import { useCallMembersChange, useCallSession } from './useCall'; import { CallPreferences } from '../state/callPreferences'; +import { createDebugLogger } from '../utils/debugLogger'; + +const debugLog = createDebugLogger('useCallEmbed'); const CallEmbedContext = createContext(undefined); @@ -69,11 +73,29 @@ export const useCallStart = (dm = false) => { (room: Room, pref?: CallPreferences) => { const container = callEmbedRef.current; if (!container) { + debugLog.error('call', 'Failed to start call — no embed container', { + roomId: room.roomId, + }); + Sentry.metrics.count('sable.call.start.error', 1, { + attributes: { reason: 'no_container' }, + }); throw new Error('Failed to start call, No embed container element found!'); } - const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref); - - setCallEmbed(callEmbed); + try { + debugLog.info('call', 'Starting call', { roomId: room.roomId, dm }); + Sentry.metrics.count('sable.call.start.attempt', 1, { attributes: { dm: String(dm) } }); + const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref); + setCallEmbed(callEmbed); + } catch (err) { + debugLog.error('call', 'Call embed creation failed', { + roomId: room.roomId, + error: err instanceof Error ? err.message : String(err), + }); + Sentry.metrics.count('sable.call.start.error', 1, { + attributes: { reason: 'embed_create_failed' }, + }); + throw err; + } }, [mx, dm, theme, setCallEmbed, callEmbedRef] ); diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index 1e8a7dfef..16620acb9 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useCallback } from 'react'; +import * as Sentry from '@sentry/react'; import { RoomStateEvent } from 'matrix-js-sdk'; import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; import { MatrixRTCSessionManagerEvents } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager'; @@ -7,6 +8,9 @@ import { mDirectAtom } from '$state/mDirectList'; import { incomingCallRoomIdAtom, mutedCallRoomIdAtom } from '$state/callEmbed'; import RingtoneSound from '$public/sound/ringtone.webm'; import { useMatrixClient } from './useMatrixClient'; +import { createDebugLogger } from '../utils/debugLogger'; + +const debugLog = createDebugLogger('CallSignaling'); type CallPhase = 'IDLE' | 'RINGING_OUT' | 'RINGING_IN' | 'ACTIVE' | 'ENDED'; @@ -121,12 +125,32 @@ export function useCallSignaling() { // being called if (remoteMembers.length > 0 && !isSelfInCall) { + if (currentPhase !== 'RINGING_IN') { + debugLog.info('call', 'Incoming call detected', { + roomId, + remoteCount: remoteMembers.length, + }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Incoming call ringing', + data: { roomId }, + }); + } callPhaseRef.current[roomId] = 'RINGING_IN'; return { ...acc, incoming: roomId }; } // multiple people no ringtone if (isSelfInCall && remoteMembers.length > 0) { + if (currentPhase !== 'ACTIVE') { + debugLog.info('call', 'Call became active', { roomId }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Call active', + data: { roomId }, + }); + Sentry.metrics.count('sable.call.active', 1); + } callPhaseRef.current[roomId] = 'ACTIVE'; return acc; } @@ -135,6 +159,15 @@ export function useCallSignaling() { if (isSelfInCall && remoteMembers.length === 0) { // Check if post call if (currentPhase === 'ACTIVE' || currentPhase === 'ENDED') { + if (currentPhase !== 'ENDED') { + debugLog.info('call', 'Call ended', { roomId }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Call ended', + data: { roomId }, + }); + Sentry.metrics.count('sable.call.ended', 1); + } callPhaseRef.current[roomId] = 'ENDED'; return acc; } @@ -144,10 +177,20 @@ export function useCallSignaling() { if (!outgoingStartRef.current) outgoingStartRef.current = now; if (now - outgoingStartRef.current < 30000) { + if (currentPhase !== 'RINGING_OUT') { + debugLog.info('call', 'Outgoing call ringing', { roomId }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Outgoing call ringing', + data: { roomId }, + }); + } callPhaseRef.current[roomId] = 'RINGING_OUT'; return { ...acc, outgoing: roomId }; } + debugLog.info('call', 'Outgoing call timed out (unanswered)', { roomId }); + Sentry.metrics.count('sable.call.timeout', 1); callPhaseRef.current[roomId] = 'ENDED'; } } 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..9a5e89d5e 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 { @@ -47,7 +48,10 @@ import { mobileOrTablet } from '$utils/user-agent'; import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; import { getSlidingSyncManager } from '$client/initMatrix'; import { NotificationBanner } from '$components/notification-banner'; +import { TelemetryConsentBanner } from '$components/telemetry-consent'; import { useCallSignaling } from '$hooks/useCallSignaling'; +import { getBlobCacheStats } from '$hooks/useBlobCache'; +import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -263,6 +267,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 +280,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 +344,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 +549,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 +664,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'); @@ -647,8 +767,12 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + + + + {children} ); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 8a9f23052..1a653e950 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 a custom attribute — 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, homeserver: 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..18809c5d2 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -4,6 +4,8 @@ import { MatrixClient, IndexedDBStore, IndexedDBCryptoStore, + SyncState, + ISyncStateData, } from '$types/matrix-sdk'; import { clearNavToActivePathStore } from '$state/navToActivePath'; @@ -17,6 +19,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'; @@ -24,6 +27,10 @@ import { SlidingSyncConfig, SlidingSyncDiagnostics, SlidingSyncManager } from '. const log = createLogger('initMatrix'); const debugLog = createDebugLogger('initMatrix'); const slidingSyncByClient = new WeakMap(); +const classicSyncObserverByClient = new WeakMap< + MatrixClient, + (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void +>(); const FAST_SYNC_POLL_TIMEOUT_MS = 10000; const SLIDING_SYNC_POLL_TIMEOUT_MS = 20000; type SyncTransport = 'classic' | 'sliding'; @@ -144,12 +151,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 +177,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 +314,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,10 +423,66 @@ 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, }); + // Attach an ongoing classic-sync observer — equivalent to SlidingSyncManager's + // onLifecycle listener. Tracks state transitions, initial-sync timing, and errors. + let classicSyncCount = 0; + const classicSyncStartMs = performance.now(); + let classicInitialSyncDone = false; + const classicSyncListener = ( + state: SyncState, + prevState: SyncState | null, + data?: ISyncStateData + ) => { + classicSyncCount += 1; + Sentry.metrics.count('sable.sync.cycle', 1, { attributes: { transport: 'classic', state } }); + debugLog.info('sync', `Classic sync state: ${state}`, { + state, + prevState: prevState ?? 'null', + syncNumber: classicSyncCount, + error: data?.error?.message, + }); + if (state === SyncState.Error || state === SyncState.Reconnecting) { + debugLog.warn('sync', `Classic sync problem: ${state}`, { + state, + prevState: prevState ?? 'null', + errorMessage: data?.error?.message, + syncNumber: classicSyncCount, + }); + Sentry.metrics.count('sable.sync.error', 1, { + attributes: { transport: 'classic', state }, + }); + Sentry.addBreadcrumb({ + category: 'sync.classic', + message: `Classic sync problem: ${state}`, + level: 'warning', + data: { state, prevState, error: data?.error?.message, syncNumber: classicSyncCount }, + }); + } + if ( + !classicInitialSyncDone && + (state === SyncState.Syncing || state === SyncState.Prepared) + ) { + classicInitialSyncDone = true; + const elapsed = performance.now() - classicSyncStartMs; + debugLog.info('sync', 'Classic sync initial ready', { + state, + syncNumber: classicSyncCount, + elapsed: `${elapsed.toFixed(0)}ms`, + }); + Sentry.metrics.distribution('sable.sync.initial_ms', elapsed, { + attributes: { transport: 'classic' }, + }); + } + }; + classicSyncObserverByClient.set(mx, classicSyncListener); + mx.on(ClientEvent.Sync, classicSyncListener); }; const shouldBootstrapClassicOnColdCache = async (): Promise => { @@ -487,6 +576,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({ @@ -509,6 +601,11 @@ export const stopClient = (mx: MatrixClient): void => { log.log('stopClient', mx.getUserId()); debugLog.info('sync', 'Stopping client', { userId: mx.getUserId() }); disposeSlidingSync(mx); + const classicSyncListener = classicSyncObserverByClient.get(mx); + if (classicSyncListener) { + mx.removeListener(ClientEvent.Sync, classicSyncListener); + classicSyncObserverByClient.delete(mx); + } mx.stopClient(); syncTransportByClient.delete(mx); }; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 7cd01cab7..a644bc2d9 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -5,6 +5,7 @@ import { ExtensionState, MatrixClient, MSC3575List, + MSC3575RoomData, MSC3575RoomSubscription, MSC3575_WILDCARD, SlidingSync, @@ -17,6 +18,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 +326,22 @@ export class SlidingSyncManager { private previousListCounts: Map = new Map(); + /** + * One-shot RoomData listeners keyed by roomId, used to measure the latency + * between subscribeToRoom() and the first data arriving for that room. + * Cleaned up automatically after first fire or on unsubscribe/dispose. + */ + private readonly pendingRoomDataListeners = new Map< + string, + (roomId: string, data: MSC3575RoomData) => void + >(); + + /** 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 +387,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 +405,9 @@ export class SlidingSyncManager { syncNumber: this.syncCount, state, }); + Sentry.metrics.count('sable.sync.error', 1, { + attributes: { transport: 'sliding', state }, + }); } if (this.disposed) { @@ -425,22 +449,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 +542,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 @@ -533,6 +580,10 @@ export class SlidingSyncManager { initialSyncCompleted: this.initialSyncCompleted, }); + // Clean up pending room-data latency listeners before marking disposed. + // SlidingSync.stop() will removeAllListeners anyway, but this keeps the Map tidy. + this.pendingRoomDataListeners.clear(); + this.disposed = true; this.slidingSync.removeListener(SlidingSyncEvent.Lifecycle, this.onLifecycle); const connection = ( @@ -674,6 +725,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 +826,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()}`); + ); } /** @@ -865,7 +940,8 @@ export class SlidingSyncManager { public subscribeToRoom(roomId: string): void { if (this.disposed) return; const room = this.mx.getRoom(roomId); - if (room && !this.mx.isRoomEncrypted(roomId)) { + const isEncrypted = this.mx.isRoomEncrypted(roomId); + if (room && !isEncrypted) { // Only use the unencrypted (lazy-load) subscription when we are certain // the room is unencrypted. Unknown rooms fall through to the safer // encrypted default. @@ -873,7 +949,58 @@ 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}`); + debugLog.info('sync', 'Room subscription requested (sliding)', { + encrypted: isEncrypted, + unknownRoom: !room, + activeSubscriptions: this.activeRoomSubscriptions.size, + syncCycle: this.syncCount, + }); + Sentry.addBreadcrumb({ + category: 'sync.sliding', + message: 'Subscribed to room (active)', + level: 'info', + data: { encrypted: isEncrypted, activeSubscriptions: this.activeRoomSubscriptions.size }, + }); + // One-shot listener: measure latency from subscription request to first room data. + // Clean up any stale listener for the same roomId first. + const existingListener = this.pendingRoomDataListeners.get(roomId); + if (existingListener) { + this.slidingSync.removeListener(SlidingSyncEvent.RoomData, existingListener); + } + const subscribeMs = performance.now(); + const onFirstRoomData = (dataRoomId: string) => { + if (dataRoomId !== roomId) return; + const latencyMs = Math.round(performance.now() - subscribeMs); + // Measure how many events landed on the live timeline as part of this + // subscription activation — this is the "page" the timeline has to absorb. + const subscribedRoom = this.mx.getRoom(roomId); + const eventCount = subscribedRoom?.getLiveTimeline().getEvents().length ?? 0; + debugLog.info('sync', 'Room subscription: first data received (sliding)', { + latencyMs, + syncCycle: this.syncCount, + eventCount, + }); + Sentry.metrics.distribution('sable.sync.room_sub_latency_ms', latencyMs, { + attributes: { transport: 'sliding' }, + }); + Sentry.metrics.distribution('sable.sync.room_sub_event_count', eventCount, { + attributes: { transport: 'sliding' }, + }); + Sentry.addBreadcrumb({ + category: 'sync.sliding', + message: `Room subscription data arrived (${eventCount} events, ${latencyMs}ms)`, + level: 'info', + data: { latencyMs, eventCount, syncCycle: this.syncCount }, + }); + this.slidingSync.removeListener(SlidingSyncEvent.RoomData, onFirstRoomData); + this.pendingRoomDataListeners.delete(roomId); + }; + this.pendingRoomDataListeners.set(roomId, onFirstRoomData); + this.slidingSync.on(SlidingSyncEvent.RoomData, onFirstRoomData); } /** @@ -883,9 +1010,22 @@ export class SlidingSyncManager { */ public unsubscribeFromRoom(roomId: string): void { if (this.disposed) return; + // Clean up any pending first-data latency listener for this room. + const pendingListener = this.pendingRoomDataListeners.get(roomId); + if (pendingListener) { + this.slidingSync.removeListener(SlidingSyncEvent.RoomData, pendingListener); + this.pendingRoomDataListeners.delete(roomId); + } 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}`); + debugLog.info('sync', 'Room subscription removed (sliding)', { + remainingSubscriptions: this.activeRoomSubscriptions.size, + syncCycle: this.syncCount, + }); } public static async probe( @@ -893,25 +1033,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..cd3f55869 --- /dev/null +++ b/src/instrument.ts @@ -0,0 +1,401 @@ +/** + * 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; + +// Default on: Sentry runs unless the user has opted out via the banner or Settings. +const sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false'; +const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true'; + +/** + * Scrub Matrix entity IDs from a plain string value (not a URL path). + * Handles the sigil-prefixed forms: !roomId:server, @userId:server, $eventId. + * Used for structured log attribute values and breadcrumb data fields. + */ +function scrubMatrixIds(value: string): string { + return value + .replace( + /(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:\s]+)([^\s&]+)/gi, + '$1$2[REDACTED]' + ) + .replace(/@[^\s:@]+:[^\s,'"(){}[\]]+/g, '@[USER_ID]') + .replace(/![^\s:]+:[^\s,'"(){}[\]]+/g, '![ROOM_ID]') + .replace(/#[^\s:@]+:[^\s,'"(){}[\]]+/g, '#[ROOM_ALIAS]') + .replace(/\$[A-Za-z0-9_+/-]{10,}/g, '$[EVENT_ID]'); +} + +/** + * Recursively scrub Matrix entity IDs from all string values in a plain object. + * Handles one level of nesting (objects and arrays of primitives). + */ +function scrubDataObject(data: unknown): unknown { + if (typeof data === 'string') return scrubMatrixIds(data); + if (Array.isArray(data)) return data.map(scrubDataObject); + if (data !== null && typeof data === 'object') { + return Object.fromEntries( + Object.entries(data as Record).map(([k, v]) => [k, scrubDataObject(v)]) + ); + } + return data; +} + +/** + * Scrub Matrix-specific identifiers from URLs that appear in Sentry spans, breadcrumbs, + * transaction names, and page URLs. Covers both Matrix API paths and client-side app routes. + * Room IDs, user IDs, event IDs, media paths, and deep-link parameters are replaced with + * safe placeholders so no PII leaks into Sentry. + */ +function scrubMatrixUrl(url: string): string { + return ( + url + // ── Matrix Client-Server API paths ────────────────────────────────────────────── + // /rooms/!roomId:server/... + .replace(/\/rooms\/![^/?#\s]*/g, '/rooms/![ROOM_ID]') + // /event/$eventId and /relations/$eventId + .replace(/\/event\/(?:\$|%24)[^/?#\s]*/g, '/event/$[EVENT_ID]') + .replace(/\/relations\/(?:\$|%24)[^/?#\s]*/g, '/relations/$[EVENT_ID]') + // /profile/@user:server or /profile/%40user%3Aserver + .replace(/\/profile\/(?:%40|@)[^/?#\s]*/gi, '/profile/[USER_ID]') + // /user/@user:server/... and /presence/@user:server/status + .replace(/\/(user|presence)\/(?:%40|@)[^/?#\s]*/gi, '/$1/[USER_ID]') + // /room_keys/keys/{version}/{roomId}/{sessionId} + .replace(/\/room_keys\/keys\/[^/?#\s]*/gi, '/room_keys/keys/[REDACTED]') + // /sendToDevice/{eventType}/{txnId} + .replace(/\/sendToDevice\/([^/?#\s]+)\/[^/?#\s]+/gi, '/sendToDevice/$1/[TXN_ID]') + // Media – MSC3916 (/media/thumbnail|download/{server}/{mediaId}) and legacy (v1/v3) + .replace( + /(\/media\/(?:thumbnail|download)\/)(?:[^/?#\s]+)\/(?:[^/?#\s]+)/gi, + '$1[SERVER]/[MEDIA_ID]' + ) + .replace( + /(\/media\/v\d+\/(?:thumbnail|download)\/)(?:[^/?#\s]+)\/(?:[^/?#\s]+)/gi, + '$1[SERVER]/[MEDIA_ID]' + ) + // ── App route path segments ───────────────────────────────────────────────────── + // Bare/partially-decoded Matrix IDs in URL path segments. + // Browsers decode %21→! and %40→@ for display but often keep %3A encoded, + // so we see hybrid forms like /!localpart%3Aserver/ or /!localpart:server/. + // Each pattern accepts either a literal colon or the %3A encoding. + // Bare room IDs: /!localpart:server/ or /!localpart%3Aserver/ + .replace(/\/![^/?#\s:%]+(?:%3A|:)[^/?#\s]*/gi, '/![ROOM_ID]') + // Bare user IDs: /@user:server/ or /@user%3Aserver/ + .replace(/\/@[^/?#\s:%]+(?:%3A|:)[^/?#\s]*/gi, '/@[USER_ID]') + // Bare room aliases: /#alias:server/ or /#alias%3Aserver/ + .replace(/\/#[^/?#\s:%]+(?:%3A|:)[^/?#\s]*/gi, '/[ROOM_ALIAS]') + // ── Deep-link / app-route URLs (percent-encoded via encodeURIComponent) ───────── + // URL-encoded user IDs: /%40user%3Aserver (%40 = @) + .replace(/\/%40[^/?#\s]*/gi, '/[USER_ID]') + // URL-encoded room IDs: /%21room%3Aserver (%21 = !) + .replace(/\/%21[^/?#\s]*/gi, '/![ROOM_ID]') + // URL-encoded room aliases: /%23alias%3Aserver (%23 = #) + // App routes like /:spaceIdOrAlias/ use encodeURIComponent() so #alias:server + // appears as %23alias%3Aserver in the URL path / Sentry transaction name. + .replace(/\/%23[^/?#\s]*/gi, '/[ROOM_ALIAS]') + // URL-encoded event IDs as bare path segments: /%24eventId (%24 = $) + .replace(/\/%24[^/?#\s]*/gi, '/[EVENT_ID]') + // ── Preview URL endpoint ──────────────────────────────────────────────────────── + // The ?url= query parameter on preview_url contains the full external URL being + // previewed — strip the entire query string so browsing habits cannot be inferred. + .replace(/(\/preview_url)\?[^#\s]*/gi, '$1') + ); +} + +// Only initialize if DSN is provided and user hasn't opted out +if (dsn && sentryEnabled) { + Sentry.init({ + 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 the log message string + if (typeof log.message === 'string') { + // eslint-disable-next-line no-param-reassign + log.message = scrubMatrixIds(log.message); + } + // Redact Matrix IDs from any string-valued log attributes (e.g. roomId, userId) + // These are flattened from the structured data object and sent as searchable attributes. + if (log.attributes && typeof log.attributes === 'object') { + // eslint-disable-next-line no-param-reassign + log.attributes = scrubDataObject(log.attributes) as typeof log.attributes; + } + 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. + // We scrub ALL string values in span.data rather than a single known key because + // Sentry / OTel HTTP instrumentation has used multiple attribute names across versions: + // http.url (OTel semconv < 1.23, Sentry classic) + // url.full (OTel semconv ≥ 1.23) + // http.target, server.address, url, etc. + // For each string value: apply URL scrubbing when the value starts with "http", + // then apply ID scrubbing to catch any remaining bare Matrix IDs. + 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 newData = spanData + ? Object.fromEntries( + Object.entries(spanData).map(([k, v]) => [ + k, + typeof v === 'string' + ? scrubMatrixIds(v.startsWith('http') ? scrubMatrixUrl(v) : v) + : v, + ]) + ) + : undefined; + + const descChanged = newDesc !== span.description; + const dataChanged = + newData !== undefined && JSON.stringify(newData) !== JSON.stringify(spanData); + + if (!descChanged && !dataChanged) return span; + return { + ...span, + ...(descChanged ? { description: newDesc } : {}), + ...(dataChanged ? { data: newData as typeof span.data } : {}), + }; + }); + } + 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 Matrix IDs from all remaining string values in the breadcrumb data object. + // debugLog passes structured data (e.g. { roomId, targetEventId }) that would otherwise + // bypass the URL-specific scrubbers above. + const scrubbedData = bData ? (scrubDataObject(bData) as Record) : undefined; + + // Scrub message text — token values and Matrix entity IDs + const message = breadcrumb.message ? scrubMatrixIds(breadcrumb.message) : breadcrumb.message; + const messageChanged = message !== breadcrumb.message; + + if (!messageChanged && !scrubbedData) return breadcrumb; + return { + ...breadcrumb, + ...(messageChanged ? { message } : {}), + ...(scrubbedData + ? { + data: { + ...scrubbedData, + ...(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 and exception values using shared helpers + if (event.message) { + // eslint-disable-next-line no-param-reassign + event.message = scrubMatrixIds(event.message); + } + + // 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 = scrubMatrixUrl(scrubMatrixIds(exception.value)); + } + }); + } + + // Scrub contexts (e.g. debugLog context from captureMessage in debugLogger.ts, + // which can carry structured data fields like roomId, targetEventId, etc.) + if (event.contexts) { + // eslint-disable-next-line no-param-reassign + event.contexts = scrubDataObject(event.contexts) as typeof event.contexts; + } + + // 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.