From 0654cb99a92853de7d2d7e3e2cfdd327fae5fd00 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 13 Mar 2026 19:00:31 -0400 Subject: [PATCH 01/26] feat: Add comprehensive Sentry integration with debug logger and bug report enhancements --- docs/SENTRY_INTEGRATION.md | 280 ++++++++++++++++++ .../features/bug-report/BugReportModal.tsx | 93 ++++++ .../settings/developer-tools/DevelopTools.tsx | 6 + .../developer-tools/SentrySettings.tsx | 209 +++++++++++++ src/app/utils/debugLogger.ts | 113 +++++++ src/instrument.ts | 184 ++++++++++++ 6 files changed, 885 insertions(+) create mode 100644 docs/SENTRY_INTEGRATION.md create mode 100644 src/app/features/settings/developer-tools/SentrySettings.tsx create mode 100644 src/instrument.ts diff --git a/docs/SENTRY_INTEGRATION.md b/docs/SENTRY_INTEGRATION.md new file mode 100644 index 000000000..6bdb26c1d --- /dev/null +++ b/docs/SENTRY_INTEGRATION.md @@ -0,0 +1,280 @@ +# Sentry Integration for Sable + +This document describes the Sentry error tracking and monitoring integration added to Sable. + +## Overview + +Sentry is integrated with Sable to provide: +- **Error tracking**: Automatic capture and reporting of errors and exceptions +- **Performance monitoring**: Track application performance and identify bottlenecks +- **User feedback**: Collect bug reports with context from users +- **Session replay**: Record user sessions (with privacy controls) for debugging +- **Breadcrumbs**: Track user actions leading up to errors +- **Debug log integration**: Attach internal debug logs to error reports + +## Features + +### 1. Automatic Error Tracking + +All errors are automatically captured and sent to Sentry with: +- Stack traces +- User context (anonymized) +- Device and browser information +- Recent breadcrumbs (user actions) +- Debug logs (when enabled) + +### 2. Debug Logger Integration + +The internal debug logger now integrates with Sentry: +- **Breadcrumbs**: All debug logs are added as breadcrumbs for context +- **Error capture**: Errors logged to the debug logger are automatically sent to Sentry +- **Warning sampling**: 10% of warnings are sent to Sentry to avoid overwhelming the system +- **Log attachment**: Recent logs can be attached to bug reports for additional context + +Key integration points: +- `src/app/utils/debugLogger.ts` - Enhanced with Sentry breadcrumb and error capture +- Automatic breadcrumb creation for all log entries +- Error objects in log data are captured as exceptions +- 10% sampling rate for warnings to control volume + +### 3. Bug Report Modal Integration + +The bug report modal (`/bugreport` command or "Bug Report" button) now includes: +- **Optional Sentry reporting**: Checkbox to send anonymous reports to Sentry +- **Debug log attachment**: Option to include recent debug logs (last 100 entries) +- **User feedback API**: Bug reports are sent as Sentry user feedback for better visibility +- **Privacy controls**: Users can opt-out of Sentry reporting + +Integration points: +- `src/app/features/bug-report/BugReportModal.tsx` - Added Sentry options and submission logic +- Automatically attaches platform info, version, and user agent +- Links bug reports to Sentry events for tracking + +### 4. Privacy & Security + +Comprehensive data scrubbing: +- **Token masking**: All access tokens, passwords, and authentication data are redacted +- **Matrix ID anonymization**: User IDs, room IDs, and event IDs are masked +- **Session replay privacy**: All text, media, and form inputs are masked when replay is enabled +- **request header sanitization**: Authorization headers are removed +- **User opt-out**: Users can disable Sentry entirely via settings + +Sensitive patterns automatically redacted: +- `access_token`, `password`, `token`, `refresh_token` +- `session_id`, `sync_token`, `next_batch` +- Matrix user IDs (`@user:server`) +- Matrix room IDs (`!room:server`) +- Matrix event IDs (`$event_id`) + +### 5. Settings UI + +New Sentry settings panel in Developer Tools: +- **Enable/disable Sentry**: Toggle error tracking on/off +- **Session replay control**: Enable/disable session recording +- **Test error reporting**: Send test errors to verify configuration +- **Test feedback**: Send test feedback messages +- **Attach debug logs**: Manually attach recent logs to next error + +Access via: Settings → Developer Tools → Error Tracking (Sentry) + +## Configuration + +### Environment Variables + +Configure Sentry via environment variables: + +```env +# Required: Your Sentry DSN (if not set, Sentry is disabled) +VITE_SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id + +# Optional: Environment name (defaults to MODE: development/production) +VITE_SENTRY_ENVIRONMENT=production + +# Optional: Release version for tracking (defaults to VITE_APP_VERSION) +VITE_SENTRY_RELEASE=1.7.0 +``` + +### User Preferences + +Users can control Sentry via localStorage: + +```javascript +// Disable Sentry entirely (requires page refresh) +localStorage.setItem('sable_sentry_enabled', 'false'); + +// Disable session replay only (requires page refresh) +localStorage.setItem('sable_sentry_replay_enabled', 'false'); +``` + +Or use the UI in Settings → Developer Tools → Error Tracking (Sentry). + +## 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 + - Test buttons for error reporting and feedback + - 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 + +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. + +## Future Enhancements + +Potential improvements: +- [ ] Add performance metrics to Sentry settings +- [ ] More granular control over breadcrumb categories +- [ ] Export Sentry reports as JSON for offline analysis +- [ ] Integration with internal metrics dashboard +- [ ] Automatic source map upload for better stack traces +- [ ] Custom Sentry tags from user preferences +- [ ] Rate limiting per user/session + +## Testing + +To test the integration: + +1. **Test error reporting**: + - Go to Settings → Developer Tools → Error Tracking + - Click "Send Test Error" + - Check Sentry dashboard for the error + +2. **Test feedback**: + - Click "Send Test Feedback" + - Check Sentry feedback section + +3. **Test bug report integration**: + - Type `/bugreport` + - Fill in form with test data + - Enable "Send anonymous report to Sentry" + - Submit and check Sentry + +4. **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/src/app/features/bug-report/BugReportModal.tsx b/src/app/features/bug-report/BugReportModal.tsx index 2f90fda31..4e5de9d1e 100644 --- a/src/app/features/bug-report/BugReportModal.tsx +++ b/src/app/features/bug-report/BugReportModal.tsx @@ -18,9 +18,13 @@ import { Spinner, Text, TextArea, + Checkbox, + as, } 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'; @@ -100,6 +104,10 @@ function BugReportModal() { // Shared optional field const [context, setContext] = useState(''); + // Sentry integration options + const [sendToSentry, setSendToSentry] = useState(true); + const [includeDebugLogs, setIncludeDebugLogs] = useState(true); + const [similarIssues, setSimilarIssues] = useState([]); const [searching, setSearching] = useState(false); @@ -141,10 +149,53 @@ function BugReportModal() { const handleSubmit = () => { if (!canSubmit) return; + const fields: Record = type === 'bug' ? { description, reproduction, 'expected-behavior': expectedBehavior, context } : { problem, solution, alternatives, context }; + + // 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); + } + + // Capture as Sentry user feedback + const eventId = Sentry.captureMessage(`User Bug Report: ${title.trim()}`, { + level: 'info', + tags: { + source: 'bug-report-modal', + reportType: type, + }, + contexts: { + bugReport: { + title: title.trim(), + description, + reproduction, + expectedBehavior, + context, + userAgent: navigator.userAgent, + platform: navigator.platform, + version: `v${APP_VERSION}${IS_RELEASE_TAG ? '' : '-dev'}${BUILD_HASH ? ` (${BUILD_HASH})` : ''}`, + }, + }, + }); + + // Also send as user feedback for better visibility in Sentry + if (eventId) { + Sentry.captureFeedback({ + message: `${description}\n\n${reproduction ? `**Reproduction:**\n${reproduction}\n\n` : ''}${expectedBehavior ? `**Expected:**\n${expectedBehavior}\n\n` : ''}${context ? `**Context:**\n${context}` : ''}`, + name: 'User Bug Report', + email: 'bug-report@sable.chat', + associatedEventId: eventId, + }); + } + } + const url = buildGitHubUrl(type, title.trim(), fields); window.open(url, '_blank', 'noopener,noreferrer'); close(); @@ -352,6 +403,48 @@ function BugReportModal() { /> + {/* Sentry integration options (only for bug reports) */} + {type === 'bug' && ( + + Error Tracking + + + + Send anonymous report to Sentry for error tracking + + Helps developers identify and fix issues faster. No personal data is + sent. + + + + {sendToSentry && ( + + + + Include recent debug logs (last 100 entries) + + Provides additional context to help diagnose the issue. Logs are + filtered for sensitive data. + + + + )} + + )} + {/* Actions */} + } + /> + + Send Test Feedback + + } + /> + + Attach Logs + + } + /> + + )} + + {isSentryConfigured && ( + + All data sent to Sentry is filtered for sensitive information like passwords and access + tokens. You can opt out at any time. + + )} + + ); +} diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts index 53b146818..b5ab831cf 100644 --- a/src/app/utils/debugLogger.ts +++ b/src/app/utils/debugLogger.ts @@ -5,6 +5,8 @@ * localStorage.setItem('sable_internal_debug', '1'); location.reload(); */ +import * as Sentry from '@sentry/react'; + export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export type LogCategory = @@ -99,6 +101,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 +111,80 @@ class DebugLoggerService { console[consoleLevel](prefix, message, data !== undefined ? data : ''); } + /** + * Send log entries to Sentry for error tracking and breadcrumbs + */ + private sendToSentry(entry: LogEntry): void { + // Map log levels to Sentry severity + const sentryLevel = + entry.level === 'debug' + ? 'debug' + : entry.level === 'info' + ? 'info' + : entry.level === 'warn' + ? 'warning' + : 'error'; + + // Add breadcrumb for all logs (helps with debugging in Sentry) + 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 + }); + + // Capture errors and warnings as Sentry events + if (entry.level === 'error') { + // 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 + 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 +231,40 @@ 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); + Sentry.setContext('recentLogs', { + count: recentLogs.length, + logs: 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 } : {}), + })), + }); + } } // Singleton instance diff --git a/src/instrument.ts b/src/instrument.ts new file mode 100644 index 000000000..6cd261c01 --- /dev/null +++ b/src/instrument.ts @@ -0,0 +1,184 @@ +/** + * 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; + +// Check user preferences +const sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false'; +const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') !== 'false'; + +// Only initialize if DSN is provided and user hasn't opted out +if (dsn && sentryEnabled) { + Sentry.init({ + dsn, + environment, + release, + + // Send default PII (IP addresses) for user context + // Set to false if more privacy is required + sendDefaultPii: true, + + 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 + }), + ] + : []), + ], + + // Performance Monitoring - Tracing + // 100% in development, lower in production for cost control + tracesSampleRate: environment === 'development' ? 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 10% of all sessions, 100% of sessions with errors + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + + // Enable structured logging to Sentry + enableLogs: true, + + // Filter sensitive data before sending to Sentry + beforeBreadcrumb(breadcrumb) { + // Don't send breadcrumbs containing tokens, passwords, or sensitive Matrix data + if (breadcrumb.message) { + const sensitivePatterns = [ + 'access_token', + 'password', + 'token', + 'refresh_token', + 'device_id', + 'session_id', + 'sync_token', + 'next_batch', + 'user_id', + 'room_id', + 'event_id', + '@', + '!', + '$', + ]; + if (sensitivePatterns.some((pattern) => breadcrumb.message?.toLowerCase().includes(pattern))) { + // Don't drop entirely, but sanitize + return { + ...breadcrumb, + message: breadcrumb.message.replace( + /(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:]\s*)([^\s&]+)/gi, + '$1$2[REDACTED]' + ), + }; + } + } + return breadcrumb; + }, + + beforeSend(event) { + // Scrub sensitive data from error messages + if (event.message) { + if ( + event.message.includes('access_token') || + event.message.includes('password') || + event.message.includes('token') + ) { + // eslint-disable-next-line no-param-reassign + event.message = event.message.replace( + /(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:]\s*)([^\s&]+)/gi, + '$1$2[REDACTED]' + ); + } + // Redact Matrix IDs to protect user privacy + // eslint-disable-next-line no-param-reassign + event.message = event.message.replace(/@[^:]+:[^\s]+/g, '@[USER_ID]'); + // eslint-disable-next-line no-param-reassign + event.message = event.message.replace(/![^:]+:[^\s]+/g, '![ROOM_ID]'); + // eslint-disable-next-line no-param-reassign + event.message = event.message.replace(/\$[^:\s]+/g, '$[EVENT_ID]'); + } + + // Scrub sensitive data from exception values + if (event.exception?.values) { + event.exception.values.forEach((exception) => { + if (exception.value) { + // eslint-disable-next-line no-param-reassign + exception.value = exception.value.replace( + /(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:]\s*)([^\s&]+)/gi, + '$1$2[REDACTED]' + ); + // Redact Matrix IDs + // eslint-disable-next-line no-param-reassign + exception.value = exception.value.replace(/@[^:]+:[^\s]+/g, '@[USER_ID]'); + // eslint-disable-next-line no-param-reassign + exception.value = exception.value.replace(/![^:]+:[^\s]+/g, '![ROOM_ID]'); + // eslint-disable-next-line no-param-reassign + exception.value = exception.value.replace(/\$[^:\s]+/g, '$[EVENT_ID]'); + } + }); + } + + // Scrub request data + if (event.request?.url) { + // eslint-disable-next-line no-param-reassign + event.request.url = event.request.url.replace( + /(access_token|password|token)([=:]\s*)([^\s&]+)/gi, + '$1$2[REDACTED]' + ); + } + + if (event.request?.headers) { + const headers = event.request.headers as Record; + if (headers.Authorization) { + headers.Authorization = '[REDACTED]'; + } + } + + return event; + }, + }); + + // eslint-disable-next-line no-console + console.info( + `[Sentry] Initialized for ${environment} environment${replayEnabled ? ' with Session Replay' : ''}` + ); +} 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'); +} From 8b8b56c29eae02436018623942cb1b9383f34d0b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 13 Mar 2026 19:26:17 -0400 Subject: [PATCH 02/26] fix: expose Sentry globally for console testing and debugging - Add window.Sentry assignment for browser console access - Export Sentry from instrument module for app-wide use - Allows developers to test Sentry from browser console with Sentry.captureMessage() etc --- src/instrument.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/instrument.ts b/src/instrument.ts index 6cd261c01..9c14f49c3 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -171,6 +171,10 @@ if (dsn && sentryEnabled) { }, }); + // Expose Sentry globally for debugging and console testing + // @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' : ''}` @@ -182,3 +186,6 @@ if (dsn && sentryEnabled) { // 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 }; From ac7e31b85b66e765d5e6dd5c381038ed33d9ef85 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 13 Mar 2026 19:31:45 -0400 Subject: [PATCH 03/26] feat: add comprehensive Sentry diagnostics and improved test feedback - Add DSN, environment, and release logging on Sentry initialization - Show event IDs in test button alerts for verification - Add console logging for all Sentry test operations - Add 'Show Diagnostics' button to display full configuration - Improved error handling with clearer feedback messages - Makes it easier to debug why Sentry events aren't appearing --- .../developer-tools/SentrySettings.tsx | 61 +++++++++++++++++-- src/instrument.ts | 8 +++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/app/features/settings/developer-tools/SentrySettings.tsx b/src/app/features/settings/developer-tools/SentrySettings.tsx index aafa44b5c..b5f7e176b 100644 --- a/src/app/features/settings/developer-tools/SentrySettings.tsx +++ b/src/app/features/settings/developer-tools/SentrySettings.tsx @@ -36,20 +36,26 @@ export function SentrySettings() { }; const handleTestError = () => { + // eslint-disable-next-line no-console + console.log('[Sentry Test] Sending test error...'); try { throw new Error('Test error from Sentry Settings'); } catch (error) { - Sentry.captureException(error, { + const eventId = Sentry.captureException(error, { tags: { source: 'sentry-settings-test', }, }); + // eslint-disable-next-line no-console + console.log('[Sentry Test] Error captured with eventId:', eventId); // eslint-disable-next-line no-alert - window.alert('Test error sent to Sentry!'); + window.alert(`Test error sent to Sentry!\nEvent ID: ${eventId || 'none'}`); } }; const handleSendFeedback = () => { + // eslint-disable-next-line no-console + console.log('[Sentry Test] Attaching logs and sending feedback...'); const debugLogger = getDebugLogger(); debugLogger.attachLogsToSentry(50); @@ -60,15 +66,23 @@ export function SentrySettings() { }, }); + // eslint-disable-next-line no-console + console.log('[Sentry Test] Message captured with eventId:', eventId); + if (eventId) { - Sentry.captureFeedback({ + const feedbackId = Sentry.captureFeedback({ message: 'This is a test feedback message from the Sentry Settings panel.', name: 'Test User', email: 'test@sable.chat', associatedEventId: eventId, }); + // eslint-disable-next-line no-console + console.log('[Sentry Test] Feedback captured with ID:', feedbackId); + // eslint-disable-next-line no-alert + window.alert(`Test feedback sent to Sentry!\nEvent ID: ${eventId}\nFeedback ID: ${feedbackId || 'none'}`); + } else { // eslint-disable-next-line no-alert - window.alert('Test feedback sent to Sentry!'); + window.alert('Failed to send test feedback - no event ID returned'); } }; @@ -81,6 +95,29 @@ export function SentrySettings() { ); }; + const handleShowDiagnostics = () => { + 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; + + const info = [ + `DSN: ${dsn ? `${dsn.substring(0, 30)}...` : 'NOT SET'}`, + `Environment: ${environment}`, + `Release: ${release || 'not set'}`, + `Sentry Enabled (localStorage): ${sentryEnabled}`, + `Session Replay Enabled: ${sessionReplayEnabled}`, + `Sentry SDK Available: ${typeof Sentry !== 'undefined'}`, + ``, + `To test from console, run:`, + `Sentry.captureMessage('Test message', 'info')`, + ].join('\n'); + + // eslint-disable-next-line no-console + console.log('[Sentry Diagnostics]\n' + info); + // eslint-disable-next-line no-alert + window.alert(info); + }; + const isSentryConfigured = Boolean(import.meta.env.VITE_SENTRY_DSN); return ( @@ -195,6 +232,22 @@ export function SentrySettings() { } /> + + Show Config + + } + /> )} diff --git a/src/instrument.ts b/src/instrument.ts index 9c14f49c3..5021721cb 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -179,6 +179,14 @@ if (dsn && sentryEnabled) { 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'); From 5662994546d7506261b4c06af6a6530286abb1ca Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 13 Mar 2026 19:40:38 -0400 Subject: [PATCH 04/26] fix: improve Sentry session replay masking and debug log attachments --- .../developer-tools/SentrySettings.tsx | 45 +++++++++++++++---- src/app/utils/debugLogger.ts | 32 +++++++++---- src/instrument.ts | 5 ++- 3 files changed, 62 insertions(+), 20 deletions(-) diff --git a/src/app/features/settings/developer-tools/SentrySettings.tsx b/src/app/features/settings/developer-tools/SentrySettings.tsx index b5f7e176b..b69497c76 100644 --- a/src/app/features/settings/developer-tools/SentrySettings.tsx +++ b/src/app/features/settings/developer-tools/SentrySettings.tsx @@ -37,7 +37,10 @@ export function SentrySettings() { const handleTestError = () => { // eslint-disable-next-line no-console - console.log('[Sentry Test] Sending test error...'); + console.log('[Sentry Test] Attaching logs and sending test error...'); + const debugLogger = getDebugLogger(); + debugLogger.attachLogsToSentry(50); + try { throw new Error('Test error from Sentry Settings'); } catch (error) { @@ -45,11 +48,15 @@ export function SentrySettings() { tags: { source: 'sentry-settings-test', }, + extra: { + testInfo: 'This is a test error with attached debug logs', + timestamp: new Date().toISOString(), + }, }); // eslint-disable-next-line no-console console.log('[Sentry Test] Error captured with eventId:', eventId); // eslint-disable-next-line no-alert - window.alert(`Test error sent to Sentry!\nEvent ID: ${eventId || 'none'}`); + window.alert(`Test error sent to Sentry!\nEvent ID: ${eventId || 'none'}\n\nCheck the event details in Sentry for attached debug logs.`); } }; @@ -64,6 +71,10 @@ export function SentrySettings() { tags: { source: 'sentry-settings-test', }, + extra: { + testInfo: 'This is a test feedback with attached debug logs', + timestamp: new Date().toISOString(), + }, }); // eslint-disable-next-line no-console @@ -79,7 +90,7 @@ export function SentrySettings() { // eslint-disable-next-line no-console console.log('[Sentry Test] Feedback captured with ID:', feedbackId); // eslint-disable-next-line no-alert - window.alert(`Test feedback sent to Sentry!\nEvent ID: ${eventId}\nFeedback ID: ${feedbackId || 'none'}`); + window.alert(`Test feedback sent to Sentry!\nEvent ID: ${eventId}\nFeedback ID: ${feedbackId || 'none'}\n\nCheck the event details in Sentry for attached debug logs.`); } else { // eslint-disable-next-line no-alert window.alert('Failed to send test feedback - no event ID returned'); @@ -88,10 +99,13 @@ export function SentrySettings() { const handleAttachLogs = () => { const debugLogger = getDebugLogger(); + const logCount = debugLogger.getLogs().length; debugLogger.attachLogsToSentry(100); + // eslint-disable-next-line no-console + console.log('[Sentry] Attached', Math.min(100, logCount), 'debug logs to Sentry scope'); // eslint-disable-next-line no-alert window.alert( - 'Recent logs attached to Sentry context. They will be included in the next error report.' + `Attached ${Math.min(100, logCount)} recent logs to Sentry scope.\n\nThey will be included in the next error report as:\n- Context data\n- Extra data\n- Attachment (debug-logs.json)` ); }; @@ -108,6 +122,12 @@ export function SentrySettings() { `Session Replay Enabled: ${sessionReplayEnabled}`, `Sentry SDK Available: ${typeof Sentry !== 'undefined'}`, ``, + `Session Replay Masking:`, + `- Text: ALL MASKED`, + `- Media: ALL BLOCKED`, + `- Inputs: ALL MASKED`, + `- Sample Rate: ${environment === 'development' ? '100%' : '10%'}`, + ``, `To test from console, run:`, `Sentry.captureMessage('Test message', 'info')`, ].join('\n'); @@ -119,6 +139,7 @@ export function SentrySettings() { }; const isSentryConfigured = Boolean(import.meta.env.VITE_SENTRY_DSN); + const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; return ( @@ -175,7 +196,7 @@ export function SentrySettings() { <> {isSentryConfigured && ( - - All data sent to Sentry is filtered for sensitive information like passwords and access - tokens. You can opt out at any time. - + + + All data sent to Sentry is filtered for sensitive information like passwords and access + tokens. You can opt out at any time. + + + Session Replay Privacy: When enabled, all text content, media (images/video/audio), + and form inputs are completely masked or blocked. Only UI structure and interactions are recorded. + + )} ); diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts index b5ab831cf..f62c50cf5 100644 --- a/src/app/utils/debugLogger.ts +++ b/src/app/utils/debugLogger.ts @@ -252,17 +252,31 @@ class DebugLoggerService { */ 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: 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 } : {}), - })), + 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', }); } } diff --git a/src/instrument.ts b/src/instrument.ts index 5021721cb..11ce25c3a 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -67,8 +67,9 @@ if (dsn && sentryEnabled) { ], // Session Replay sampling - // Record 10% of all sessions, 100% of sessions with errors - replaysSessionSampleRate: 0.1, + // Record 100% in development for testing, 10% in production + // Always record 100% of sessions with errors + replaysSessionSampleRate: environment === 'development' ? 1.0 : 0.1, replaysOnErrorSampleRate: 1.0, // Enable structured logging to Sentry From 40124d3afb594129c3c818b0c7d2e6560136e428 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 13 Mar 2026 19:45:42 -0400 Subject: [PATCH 05/26] feat: enable 100% Sentry sampling for preview environments - Update tracesSampleRate to 100% for development and preview - Update replaysSessionSampleRate to 100% for development and preview - Update SentrySettings UI to reflect preview environment gets 100% sampling - Improves debugging experience for Cloudflare Pages preview deployments --- .../features/settings/developer-tools/SentrySettings.tsx | 4 ++-- src/instrument.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/features/settings/developer-tools/SentrySettings.tsx b/src/app/features/settings/developer-tools/SentrySettings.tsx index b69497c76..6b9186afc 100644 --- a/src/app/features/settings/developer-tools/SentrySettings.tsx +++ b/src/app/features/settings/developer-tools/SentrySettings.tsx @@ -126,7 +126,7 @@ export function SentrySettings() { `- Text: ALL MASKED`, `- Media: ALL BLOCKED`, `- Inputs: ALL MASKED`, - `- Sample Rate: ${environment === 'development' ? '100%' : '10%'}`, + `- Sample Rate: ${environment === 'development' || environment === 'preview' ? '100%' : '10%'}`, ``, `To test from console, run:`, `Sentry.captureMessage('Test message', 'info')`, @@ -196,7 +196,7 @@ export function SentrySettings() { <> Date: Fri, 13 Mar 2026 19:49:16 -0400 Subject: [PATCH 06/26] docs: add Sentry environment configuration for deployments - Add VITE_SENTRY_ENVIRONMENT to production workflow (cloudflare-web-deploy.yml) - Configure prepare-tofu action to pass through Sentry environment variables - Update SENTRY_INTEGRATION.md with comprehensive deployment configuration: - Environment variable documentation with sampling rate details - Deployment-specific configurations for production/preview/development - Sampling rate table showing 10% for production, 100% for preview/dev - Production builds from dev branch now properly set environment=production --- .github/actions/prepare-tofu/action.yml | 7 ++++ .github/workflows/cloudflare-web-deploy.yml | 14 ++++++++ docs/SENTRY_INTEGRATION.md | 38 ++++++++++++++++++++- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.github/actions/prepare-tofu/action.yml b/.github/actions/prepare-tofu/action.yml index 409a50ee4..0a9c9f38d 100644 --- a/.github/actions/prepare-tofu/action.yml +++ b/.github/actions/prepare-tofu/action.yml @@ -14,6 +14,13 @@ runs: uses: ./.github/actions/setup with: build: 'true' + env: + 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 819851b05..23132d90b 100644 --- a/.github/workflows/cloudflare-web-deploy.yml +++ b/.github/workflows/cloudflare-web-deploy.yml @@ -49,6 +49,13 @@ jobs: - name: Prepare OpenTofu deployment uses: ./.github/actions/prepare-tofu + 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 @@ -72,6 +79,13 @@ jobs: - name: Prepare OpenTofu deployment uses: ./.github/actions/prepare-tofu + 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/docs/SENTRY_INTEGRATION.md b/docs/SENTRY_INTEGRATION.md index 6bdb26c1d..f5b059b26 100644 --- a/docs/SENTRY_INTEGRATION.md +++ b/docs/SENTRY_INTEGRATION.md @@ -87,11 +87,47 @@ Configure Sentry via environment variables: # Required: Your Sentry DSN (if not set, Sentry is disabled) VITE_SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id -# Optional: Environment name (defaults to MODE: development/production) +# Required: Environment name - controls sampling rates +# - "production" = 10% trace/replay sampling (cost-effective for production) +# - "preview" = 100% trace/replay sampling (full debugging for PR previews) +# - "development" = 100% trace/replay sampling (full debugging for local dev) VITE_SENTRY_ENVIRONMENT=production # Optional: Release version for tracking (defaults to VITE_APP_VERSION) VITE_SENTRY_RELEASE=1.7.0 + +# Optional: For uploading source maps to Sentry (CI/CD only) +SENTRY_AUTH_TOKEN=your-sentry-auth-token +SENTRY_ORG=your-org-slug +SENTRY_PROJECT=your-project-slug +``` + +### Deployment Configuration + +**Production deployment (from `dev` branch):** +- Set `VITE_SENTRY_ENVIRONMENT=production` +- Gets 10% sampling for traces and session replay +- Cost-effective for production usage +- Configured in `.github/workflows/cloudflare-web-deploy.yml` + +**Preview deployments (PR previews, Cloudflare Pages):** +- Set `VITE_SENTRY_ENVIRONMENT=preview` +- Gets 100% sampling for traces and session replay +- Full debugging capabilities for testing +- Configured in `.github/workflows/cloudflare-web-preview.yml` + +**Local development:** +- `VITE_SENTRY_ENVIRONMENT` not set (defaults to `development` via Vite MODE) +- Gets 100% sampling for traces and session replay +- Full debugging capabilities + +**Sampling rates by environment:** +``` +Environment | Traces | Session Replay | Error Replay +---------------|--------|----------------|------------- +production | 10% | 10% | 100% +preview | 100% | 100% | 100% +development | 100% | 100% | 100% ``` ### User Preferences From 75c84a3e321374bca871845bb778ed8fa2288c43 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 00:24:58 -0400 Subject: [PATCH 07/26] fix: add @sentry/react dep, fix lint and formatting issues --- docs/SENTRY_INTEGRATION.md | 17 +++++ package.json | 1 + pnpm-lock.yaml | 67 +++++++++++++++++++ .../features/bug-report/BugReportModal.tsx | 5 +- .../developer-tools/SentrySettings.tsx | 19 ++++-- src/app/utils/debugLogger.ts | 19 +++--- src/instrument.ts | 15 ++--- 7 files changed, 116 insertions(+), 27 deletions(-) diff --git a/docs/SENTRY_INTEGRATION.md b/docs/SENTRY_INTEGRATION.md index f5b059b26..221d14383 100644 --- a/docs/SENTRY_INTEGRATION.md +++ b/docs/SENTRY_INTEGRATION.md @@ -5,6 +5,7 @@ This document describes the Sentry error tracking and monitoring integration add ## 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 @@ -17,6 +18,7 @@ Sentry is integrated with Sable to provide: ### 1. Automatic Error Tracking All errors are automatically captured and sent to Sentry with: + - Stack traces - User context (anonymized) - Device and browser information @@ -26,12 +28,14 @@ All errors are automatically captured and sent to Sentry with: ### 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 @@ -40,12 +44,14 @@ Key integration points: ### 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 @@ -53,6 +59,7 @@ Integration points: ### 4. Privacy & Security Comprehensive data scrubbing: + - **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 @@ -60,6 +67,7 @@ Comprehensive data scrubbing: - **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`) @@ -69,6 +77,7 @@ Sensitive patterns automatically redacted: ### 5. Settings UI New Sentry settings panel in Developer Tools: + - **Enable/disable Sentry**: Toggle error tracking on/off - **Session replay control**: Enable/disable session recording - **Test error reporting**: Send test errors to verify configuration @@ -105,23 +114,27 @@ SENTRY_PROJECT=your-project-slug ### Deployment Configuration **Production deployment (from `dev` branch):** + - Set `VITE_SENTRY_ENVIRONMENT=production` - Gets 10% sampling for traces and session replay - Cost-effective for production usage - Configured in `.github/workflows/cloudflare-web-deploy.yml` **Preview deployments (PR previews, Cloudflare Pages):** + - Set `VITE_SENTRY_ENVIRONMENT=preview` - Gets 100% sampling for traces and session replay - Full debugging capabilities for testing - Configured in `.github/workflows/cloudflare-web-preview.yml` **Local development:** + - `VITE_SENTRY_ENVIRONMENT` not set (defaults to `development` via Vite MODE) - Gets 100% sampling for traces and session replay - Full debugging capabilities **Sampling rates by environment:** + ``` Environment | Traces | Session Replay | Error Replay ---------------|--------|----------------|------------- @@ -228,11 +241,13 @@ Sentry.captureException(error); ## 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 @@ -242,6 +257,7 @@ Sentry.captureException(error); ## Privacy Commitment 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 @@ -253,6 +269,7 @@ No message content, room conversations, or personal information is ever sent to ## Future Enhancements Potential improvements: + - [ ] Add performance metrics to Sentry settings - [ ] More granular control over breadcrumb categories - [ ] Export Sentry reports as JSON for offline analysis diff --git a/package.json b/package.json index cbdee7db2..b4573f53c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@fontsource-variable/nunito": "5.2.7", "@fontsource/space-mono": "5.2.9", + "@sentry/react": "^10.43.0", "@tanstack/react-query": "^5.90.21", "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-virtual": "^3.13.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 129484b42..0d83bcb6b 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) @@ -2322,6 +2325,36 @@ 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/browser@10.43.0': + resolution: {integrity: sha512-2V3I3sXi3SMeiZpKixd9ztokSgK27cmvsD9J5oyOyjhGLTW/6QKCwHbKnluMgQMXq20nixQk5zN4wRjRUma3sg==} + engines: {node: '>=18'} + + '@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 + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -7484,6 +7517,40 @@ 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/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/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 + '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.14': {} diff --git a/src/app/features/bug-report/BugReportModal.tsx b/src/app/features/bug-report/BugReportModal.tsx index 4e5de9d1e..d4ba3518c 100644 --- a/src/app/features/bug-report/BugReportModal.tsx +++ b/src/app/features/bug-report/BugReportModal.tsx @@ -19,7 +19,6 @@ import { Text, TextArea, Checkbox, - as, } from 'folds'; import * as Sentry from '@sentry/react'; import { useCloseBugReportModal, useBugReportModalOpen } from '$state/hooks/bugReportModal'; @@ -414,7 +413,9 @@ function BugReportModal() { onCheckedChange={setSendToSentry} /> - Send anonymous report to Sentry for error tracking + + Send anonymous report to Sentry for error tracking + Helps developers identify and fix issues faster. No personal data is sent. diff --git a/src/app/features/settings/developer-tools/SentrySettings.tsx b/src/app/features/settings/developer-tools/SentrySettings.tsx index 6b9186afc..1340461fe 100644 --- a/src/app/features/settings/developer-tools/SentrySettings.tsx +++ b/src/app/features/settings/developer-tools/SentrySettings.tsx @@ -56,7 +56,9 @@ export function SentrySettings() { // eslint-disable-next-line no-console console.log('[Sentry Test] Error captured with eventId:', eventId); // eslint-disable-next-line no-alert - window.alert(`Test error sent to Sentry!\nEvent ID: ${eventId || 'none'}\n\nCheck the event details in Sentry for attached debug logs.`); + window.alert( + `Test error sent to Sentry!\nEvent ID: ${eventId || 'none'}\n\nCheck the event details in Sentry for attached debug logs.` + ); } }; @@ -90,7 +92,9 @@ export function SentrySettings() { // eslint-disable-next-line no-console console.log('[Sentry Test] Feedback captured with ID:', feedbackId); // eslint-disable-next-line no-alert - window.alert(`Test feedback sent to Sentry!\nEvent ID: ${eventId}\nFeedback ID: ${feedbackId || 'none'}\n\nCheck the event details in Sentry for attached debug logs.`); + window.alert( + `Test feedback sent to Sentry!\nEvent ID: ${eventId}\nFeedback ID: ${feedbackId || 'none'}\n\nCheck the event details in Sentry for attached debug logs.` + ); } else { // eslint-disable-next-line no-alert window.alert('Failed to send test feedback - no event ID returned'); @@ -113,7 +117,7 @@ export function SentrySettings() { 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; - + const info = [ `DSN: ${dsn ? `${dsn.substring(0, 30)}...` : 'NOT SET'}`, `Environment: ${environment}`, @@ -124,7 +128,7 @@ export function SentrySettings() { ``, `Session Replay Masking:`, `- Text: ALL MASKED`, - `- Media: ALL BLOCKED`, + `- Media: ALL BLOCKED`, `- Inputs: ALL MASKED`, `- Sample Rate: ${environment === 'development' || environment === 'preview' ? '100%' : '10%'}`, ``, @@ -133,7 +137,7 @@ export function SentrySettings() { ].join('\n'); // eslint-disable-next-line no-console - console.log('[Sentry Diagnostics]\n' + info); + console.log(`[Sentry Diagnostics]\n${info}`); // eslint-disable-next-line no-alert window.alert(info); }; @@ -279,8 +283,9 @@ export function SentrySettings() { tokens. You can opt out at any time. - Session Replay Privacy: When enabled, all text content, media (images/video/audio), - and form inputs are completely masked or blocked. Only UI structure and interactions are recorded. + Session Replay Privacy: When enabled, all text content, media + (images/video/audio), and form inputs are completely masked or blocked. Only UI + structure and interactions are recorded. )} diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts index f62c50cf5..2cb943190 100644 --- a/src/app/utils/debugLogger.ts +++ b/src/app/utils/debugLogger.ts @@ -102,7 +102,7 @@ class DebugLoggerService { this.notifyListeners(entry); // Send to Sentry - this.sendToSentry(entry); + DebugLoggerService.sendToSentry(entry); // Also log to console for developer convenience const prefix = `[sable:${category}:${namespace}]`; @@ -114,16 +114,15 @@ class DebugLoggerService { /** * Send log entries to Sentry for error tracking and breadcrumbs */ - private sendToSentry(entry: LogEntry): void { + private static sendToSentry(entry: LogEntry): void { // Map log levels to Sentry severity - const sentryLevel = - entry.level === 'debug' - ? 'debug' - : entry.level === 'info' - ? 'info' - : entry.level === 'warn' - ? 'warning' - : 'error'; + 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) Sentry.addBreadcrumb({ diff --git a/src/instrument.ts b/src/instrument.ts index a3c5f6838..823ce1cd5 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -69,7 +69,8 @@ if (dsn && sentryEnabled) { // 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, + replaysSessionSampleRate: + environment === 'development' || environment === 'preview' ? 1.0 : 0.1, replaysOnErrorSampleRate: 1.0, // Enable structured logging to Sentry @@ -95,7 +96,9 @@ if (dsn && sentryEnabled) { '!', '$', ]; - if (sensitivePatterns.some((pattern) => breadcrumb.message?.toLowerCase().includes(pattern))) { + if ( + sensitivePatterns.some((pattern) => breadcrumb.message?.toLowerCase().includes(pattern)) + ) { // Don't drop entirely, but sanitize return { ...breadcrumb, @@ -181,13 +184,9 @@ if (dsn && sentryEnabled) { `[Sentry] Initialized for ${environment} environment${replayEnabled ? ' with Session Replay' : ''}` ); // eslint-disable-next-line no-console - console.info( - `[Sentry] DSN configured: ${dsn?.substring(0, 30)}...` - ); + console.info(`[Sentry] DSN configured: ${dsn?.substring(0, 30)}...`); // eslint-disable-next-line no-console - console.info( - `[Sentry] Release: ${release || 'not set'}` - ); + console.info(`[Sentry] Release: ${release || 'not set'}`); } else if (!sentryEnabled) { // eslint-disable-next-line no-console console.info('[Sentry] Disabled by user preference'); From 245c548d4366626c6ec3de2e83a2de572b671e39 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 00:28:48 -0400 Subject: [PATCH 08/26] fix: wire instrument.ts, fix Checkbox props, add changeset --- .changeset/feat-sentry-integration.md | 5 +++++ src/app/features/bug-report/BugReportModal.tsx | 4 ++-- src/index.tsx | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changeset/feat-sentry-integration.md 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/src/app/features/bug-report/BugReportModal.tsx b/src/app/features/bug-report/BugReportModal.tsx index d4ba3518c..d0ec587c3 100644 --- a/src/app/features/bug-report/BugReportModal.tsx +++ b/src/app/features/bug-report/BugReportModal.tsx @@ -410,7 +410,7 @@ function BugReportModal() { setSendToSentry((v) => !v)} /> @@ -432,7 +432,7 @@ function BugReportModal() { setIncludeDebugLogs((v) => !v)} /> Include recent debug logs (last 100 entries) 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'; From 7128dd19a81b6c0abaa24d74755347ab5083ff2c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 01:38:00 -0400 Subject: [PATCH 09/26] feat: complete Sentry instrumentation - metrics, traces, profiling, user context - Add browserProfilingIntegration + profilesSampleRate - Add consoleLoggingIntegration, fix sendDefaultPii: false - Wire Sentry.logger.* and error/warn count metrics in debugLogger - slidingSync: sable.sync.{cycle,error,initial_ms,processing_ms} - ClientRoot: sable.sync.time_to_ready_ms, pseudonymous SHA-256 hashed user ID - RoomInput: sable.message.{send_latency_ms,send_error} + matrix.message span - room.ts: sable.decryption.bulk_latency_ms + matrix.crypto span - EncryptedContent: sable.decryption.{event_ms,failure} (5% sampled span) - matrix.ts: sable.media.{upload_latency_ms,upload_bytes,upload_error} - RoomTimeline: sable.{pagination,timeline}.* metrics + matrix.timeline span - ClientNonUIFeatures: sable.notification.delivery_ms - DirectDMsList: sable.roomlist.time_to_ready_ms --- src/app/features/room/RoomInput.tsx | 15 ++++++- src/app/features/room/RoomTimeline.tsx | 31 +++++++++++++ .../room/message/EncryptedContent.tsx | 16 ++++++- src/app/pages/client/ClientNonUIFeatures.tsx | 18 ++++++++ src/app/pages/client/ClientRoot.tsx | 44 ++++++++++++++++--- .../pages/client/sidebar/DirectDMsList.tsx | 15 ++++++- src/app/utils/debugLogger.ts | 15 +++++++ src/app/utils/matrix.ts | 18 +++++++- src/app/utils/room.ts | 18 +++++++- src/client/slidingSync.ts | 16 ++++++- src/instrument.ts | 12 +++-- 11 files changed, 203 insertions(+), 15 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 3f7becd55..0f93c95dc 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -151,6 +151,7 @@ import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice'; import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; +import * as Sentry from '@sentry/react'; import { getAudioMsgContent, getFileMsgContent, @@ -602,20 +603,32 @@ export const RoomInput = forwardRef( // Cancel failed — leave state intact for retry } } else { + const msgSendStart = performance.now(); resetInput(); debugLog.info('message', 'Sending message', { roomId, msgtype: (content as any).msgtype }); - mx.sendMessage(roomId, content as any) + Sentry.startSpan( + { name: 'message.send', op: 'matrix.message', attributes: { encrypted: String(isEncrypted) } }, + () => mx.sendMessage(roomId, 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 6bffcfd2e..945ffe5d0 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -35,6 +35,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, @@ -286,6 +287,8 @@ const useEventTimelineLoader = ( ) => useCallback( async (eventId: string) => { + return 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(() => { @@ -331,7 +334,12 @@ const useEventTimelineLoader = ( return; } + Sentry.metrics.distribution( + 'sable.timeline.jump_load_ms', + performance.now() - jumpLoadStart + ); onLoad(eventId, linkedTimelines, absIndex); + }); // end startSpan }, [mx, room, onLoad, onError] ); @@ -409,6 +417,7 @@ const useTimelinePagination = ( }); } try { + const paginateStart = performance.now(); const [err] = await to( mx.paginateEventTimeline(timelineToPaginate, { backwards, @@ -418,6 +427,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), @@ -440,6 +452,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), @@ -708,6 +730,14 @@ export function RoomTimeline({ // Log timeline component mount/unmount useEffect(() => { + const mode = eventId ? 'jump' : 'live'; + Sentry.metrics.count('sable.timeline.open', 1, { attributes: { mode } }); + const initialWindowSize = timeline.range.end - timeline.range.start; + if (initialWindowSize > 0) { + Sentry.metrics.distribution('sable.timeline.render_window', initialWindowSize, { + attributes: { encrypted: String(room.hasEncryptionStateEvent()), mode }, + }); + } debugLog.info('timeline', 'Timeline mounted', { roomId: room.roomId, eventId, @@ -942,6 +972,7 @@ export function RoomTimeline({ // "Jump to Latest" button to stick permanently. Forcing atBottom here is // correct: TimelineRefresh always reinits to the live end, so the user // should be repositioned to the bottom regardless. + Sentry.metrics.count('sable.timeline.reinit', 1); debugLog.info('timeline', 'Live timeline refresh triggered', { roomId: room.roomId }); setTimeline(getInitialTimeline(room)); setAtBottom(true); diff --git a/src/app/features/room/message/EncryptedContent.tsx b/src/app/features/room/message/EncryptedContent.tsx index ddd82db48..f82c7f17e 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,25 @@ 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); + } toggleEncrypted(event.getType() === MessageEvent.RoomMessageEncrypted); }; mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 5ec5d8806..cf7fa7ee0 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 { @@ -263,6 +264,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 +277,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 +341,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: diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 8a9f23052..9c7140040 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 { @@ -180,6 +181,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 () => { @@ -279,13 +282,44 @@ export function ClientRoot({ children }: ClientRootProps) { useSyncState( mx, - useCallback((state: string) => { - if (isClientReady(state)) { - setLoading(false); - } - }, []) + 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 a pseudonymous hashed user ID for error grouping — never sends raw Matrix ID + useEffect(() => { + if (!mx) return; + const userId = mx.getUserId(); + if (!userId) return; + (async () => { + const hashBuffer = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(userId) + ); + const hashHex = Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .slice(0, 16); + Sentry.setUser({ id: hashHex }); + })(); + return () => { + Sentry.setUser(null); + }; + }, [mx]); + return ( diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 81d9f26d3..7a6718b53 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState, useCallback } from 'react'; +import { useMemo, useState, useCallback, useRef, useEffect } from 'react'; +import * as Sentry from '@sentry/react'; import { useNavigate } from 'react-router-dom'; import { Avatar, Text, Box, toRem } from 'folds'; import { useAtomValue } from 'jotai'; @@ -170,6 +171,8 @@ export function DirectDMsList() { // Track sync state to wait for initial sync completion const [syncReady, setSyncReady] = useState(false); + const mountTimeRef = useRef(performance.now()); + const firstReadyRef = useRef(false); useSyncState( mx, @@ -186,6 +189,16 @@ export function DirectDMsList() { }, []) ); + useEffect(() => { + if (syncReady && !firstReadyRef.current) { + firstReadyRef.current = true; + Sentry.metrics.distribution( + 'sable.roomlist.time_to_ready_ms', + performance.now() - mountTimeRef.current + ); + } + }, [syncReady]); + // Get up to MAX_DM_AVATARS recent DMs that have unread messages const recentDMs = useMemo(() => { // Don't show DMs until initial sync completes diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts index 2cb943190..097b439ce 100644 --- a/src/app/utils/debugLogger.ts +++ b/src/app/utils/debugLogger.ts @@ -133,6 +133,21 @@ class DebugLoggerService { 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}`; + const logAttrs = { category: entry.category, namespace: entry.namespace }; + 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') { // If data is an Error object, capture it as an exception diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index ea1a85147..e06dba7d3 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -18,6 +18,7 @@ import { IImageInfo, IThumbnailContent, IVideoInfo } from '$types/matrix/common' import { AccountDataEvent } from '$types/matrix/accountData'; import { Membership, MessageEvent, StateEvent } from '$types/matrix/room'; import { getEventReactions, getReactionContent, getStateEvent } from './room'; +import * as Sentry from '@sentry/react'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -162,6 +163,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, @@ -172,9 +174,21 @@ export const uploadContent = async ( try { const data = await uploadPromise; const mxc = data.content_uri; - if (mxc) onSuccess(mxc); - else onError(new MatrixError(data)); + if (mxc) { + const mediaType = file.type.split('/')[0] || 'unknown'; + Sentry.metrics.distribution('sable.media.upload_latency_ms', performance.now() - uploadStart, { + attributes: { type: mediaType }, + }); + Sentry.metrics.distribution('sable.media.upload_bytes', file.size, { + attributes: { type: mediaType }, + }); + onSuccess(mxc); + } else { + Sentry.metrics.count('sable.media.upload_error', 1, { attributes: { reason: 'no_uri' } }); + onError(new MatrixError(data)); + } } catch (e: any) { + Sentry.metrics.count('sable.media.upload_error', 1, { attributes: { reason: 'exception' } }); const error = typeof e?.message === 'string' ? e.message : undefined; const errcode = typeof e?.name === 'string' ? e.message : undefined; onError(new MatrixError({ error, errcode })); diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 21fa6e290..4a421b81e 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -30,6 +30,7 @@ import { StateEvent, UnreadInfo, } from '$types/matrix/room'; +import * as Sentry from '@sentry/react'; export const getStateEvent = ( room: Room, @@ -557,7 +558,22 @@ export const decryptAllTimelineEvent = async (mx: MatrixClient, timeline: EventT .filter((event) => event.isEncrypted()) .reverse() .map((event) => event.attemptDecryption(crypto as CryptoBackend, { isRetry: true })); - await Promise.allSettled(decryptionPromises); + const decryptStart = performance.now(); + await Sentry.startSpan( + { + name: 'decrypt.bulk', + op: 'matrix.crypto', + attributes: { event_count: decryptionPromises.length }, + }, + () => Promise.allSettled(decryptionPromises) + ); + if (decryptionPromises.length > 0) { + Sentry.metrics.distribution( + 'sable.decryption.bulk_latency_ms', + performance.now() - decryptStart, + { attributes: { event_count: String(decryptionPromises.length) } } + ); + } }; export const getReactionContent = (eventId: string, key: string, shortcode?: string) => ({ diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 7cd01cab7..32a51ad58 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -17,6 +17,7 @@ import { } from '$types/matrix-sdk'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; +import * as Sentry from '@sentry/react'; const log = createLogger('slidingSync'); const debugLog = createDebugLogger('slidingSync'); @@ -369,6 +370,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 +388,9 @@ export class SlidingSyncManager { syncNumber: this.syncCount, state, }); + Sentry.metrics.count('sable.sync.error', 1, { + attributes: { transport: 'sliding', state }, + }); } if (this.disposed) { @@ -428,19 +435,26 @@ export class SlidingSyncManager { // Mark initial sync as complete after first successful cycle if (!this.initialSyncCompleted) { this.initialSyncCompleted = true; + const initialElapsed = performance.now() - syncStartTime; 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.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, diff --git a/src/instrument.ts b/src/instrument.ts index 823ce1cd5..b2022861b 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -30,9 +30,8 @@ if (dsn && sentryEnabled) { environment, release, - // Send default PII (IP addresses) for user context - // Set to false if more privacy is required - sendDefaultPii: true, + // Do not send PII (IP addresses, user identifiers) to protect privacy + sendDefaultPii: false, integrations: [ // React Router v6 browser tracing integration @@ -53,12 +52,19 @@ if (dsn && sentryEnabled) { }), ] : []), + // 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, + // Profiling sample rate must be <= tracesSampleRate + profilesSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + // Control which URLs get distributed tracing headers tracePropagationTargets: [ 'localhost', From 9da982d00b87cb4c8c6ce014e51c7b464657f643 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 01:38:04 -0400 Subject: [PATCH 10/26] feat: improve crash page and bug report to prefer Sentry over GitHub - Replace react-error-boundary with Sentry.ErrorBoundary in App.tsx so crashes are auto-captured and an eventId is passed to the fallback - ErrorPage: when Sentry captured the crash, show "Add Details to Report" (Sentry.showReportDialog) as primary action and GitHub as secondary; fall back to GitHub-only when Sentry is not configured - BugReportModal: detect Sentry via isInitialized() at runtime - Sentry enabled + bug: submit to Sentry by default, GitHub opt-in via "Also create a GitHub issue" checkbox (default off) - Sentry disabled / feature request: always open GitHub (unchanged UX) - Error Tracking section hidden entirely when Sentry is not configured - Submit button label changes to "Submit Report" when Sentry is active --- src/app/components/DefaultErrorPage.tsx | 58 ++++++++++++++----- .../features/bug-report/BugReportModal.tsx | 33 +++++++++-- src/app/pages/App.tsx | 13 ++++- 3 files changed, 82 insertions(+), 22 deletions(-) diff --git a/src/app/components/DefaultErrorPage.tsx b/src/app/components/DefaultErrorPage.tsx index edf8acf5f..54ac642ea 100644 --- a/src/app/components/DefaultErrorPage.tsx +++ b/src/app/components/DefaultErrorPage.tsx @@ -1,9 +1,12 @@ import { Box, Button, Dialog, Icon, Icons, Text, color, config } from 'folds'; +import * as Sentry from '@sentry/react'; import { SplashScreen } from '$components/splash-screen'; import { buildGitHubUrl } from '$features/bug-report/BugReportModal'; type ErrorPageProps = { error: Error; + /** Sentry event ID — present when Sentry.ErrorBoundary captured the crash */ + eventId?: string; }; function createIssueUrl(error: Error): string { @@ -29,7 +32,9 @@ ${stacktrace} // It provides a user-friendly error message and options to report the issue or reload the page. // Motivation of the design is to encourage users to report issues while also providing them with the necessary information to do so, and to give them an easy way to recover by reloading the page. // Note: Since this component is rendered in response to an error, it should be as resilient as possible and avoid any complex logic or dependencies that could potentially throw additional errors. -export function ErrorPage({ error }: ErrorPageProps) { +export function ErrorPage({ error, eventId }: ErrorPageProps) { + const sentryEnabled = Sentry.isInitialized(); + const reportedToSentry = sentryEnabled && !!eventId; return ( @@ -52,20 +57,45 @@ export function ErrorPage({ error }: ErrorPageProps) { Oops! Something went wrong - An unexpected error occurred. Please try again. If it continues, report the issue on - our GitHub using the button below, it will include error details to help us - investigate. Thank you for helping improve the app. + {reportedToSentry + ? 'An unexpected error occurred. This crash has been automatically reported to our team. You can add more details to help us investigate.' + : 'An unexpected error occurred. Please try again. If it continues, report the issue on our GitHub using the button below, it will include error details to help us investigate. Thank you for helping improve the app.'} - + {reportedToSentry ? ( + + + + + ) : ( + + )} ('bug'); const [title, setTitle] = useState(''); @@ -106,6 +107,8 @@ function BugReportModal() { // 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); @@ -195,8 +198,13 @@ function BugReportModal() { } } - const url = buildGitHubUrl(type, title.trim(), fields); - window.open(url, '_blank', 'noopener,noreferrer'); + // 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(); }; @@ -402,8 +410,8 @@ function BugReportModal() { /> - {/* Sentry integration options (only for bug reports) */} - {type === 'bug' && ( + {/* Sentry integration options (only for bug reports when Sentry is configured) */} + {type === 'bug' && sentryEnabled && ( Error Tracking @@ -443,6 +451,19 @@ function BugReportModal() { )} + + setOpenOnGitHub((v) => !v)} + /> + + Also create a GitHub issue + + Opens a pre-filled GitHub issue in addition to the Sentry report. + + + )} @@ -459,7 +480,9 @@ function BugReportModal() { onClick={handleSubmit} after={} > - Open on GitHub + + {sentryEnabled && type === 'bug' ? 'Submit Report' : 'Open on GitHub'} + 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() { - + ); } From 84e5a3ef9ab5bfada63d2921eb3507529849224e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 01:38:07 -0400 Subject: [PATCH 11/26] chore: remove test buttons from Sentry dev tools settings Remove "Send Test Error", "Send Test Feedback", and "Attach Debug Logs" setting tiles and their handler functions from SentrySettings.tsx. Also drop the now-unused getDebugLogger import. The "Show Diagnostics" tile and the enable/replay toggles are kept. --- .../developer-tools/SentrySettings.tsx | 127 ------------------ 1 file changed, 127 deletions(-) diff --git a/src/app/features/settings/developer-tools/SentrySettings.tsx b/src/app/features/settings/developer-tools/SentrySettings.tsx index 1340461fe..8bf93e58b 100644 --- a/src/app/features/settings/developer-tools/SentrySettings.tsx +++ b/src/app/features/settings/developer-tools/SentrySettings.tsx @@ -4,7 +4,6 @@ import * as Sentry from '@sentry/react'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; import { SequenceCardStyle } from '$features/settings/styles.css'; -import { getDebugLogger } from '$utils/debugLogger'; export function SentrySettings() { const [sentryEnabled, setSentryEnabled] = useState( @@ -35,84 +34,6 @@ export function SentrySettings() { setNeedsRefresh(true); }; - const handleTestError = () => { - // eslint-disable-next-line no-console - console.log('[Sentry Test] Attaching logs and sending test error...'); - const debugLogger = getDebugLogger(); - debugLogger.attachLogsToSentry(50); - - try { - throw new Error('Test error from Sentry Settings'); - } catch (error) { - const eventId = Sentry.captureException(error, { - tags: { - source: 'sentry-settings-test', - }, - extra: { - testInfo: 'This is a test error with attached debug logs', - timestamp: new Date().toISOString(), - }, - }); - // eslint-disable-next-line no-console - console.log('[Sentry Test] Error captured with eventId:', eventId); - // eslint-disable-next-line no-alert - window.alert( - `Test error sent to Sentry!\nEvent ID: ${eventId || 'none'}\n\nCheck the event details in Sentry for attached debug logs.` - ); - } - }; - - const handleSendFeedback = () => { - // eslint-disable-next-line no-console - console.log('[Sentry Test] Attaching logs and sending feedback...'); - const debugLogger = getDebugLogger(); - debugLogger.attachLogsToSentry(50); - - const eventId = Sentry.captureMessage('Test feedback from settings', { - level: 'info', - tags: { - source: 'sentry-settings-test', - }, - extra: { - testInfo: 'This is a test feedback with attached debug logs', - timestamp: new Date().toISOString(), - }, - }); - - // eslint-disable-next-line no-console - console.log('[Sentry Test] Message captured with eventId:', eventId); - - if (eventId) { - const feedbackId = Sentry.captureFeedback({ - message: 'This is a test feedback message from the Sentry Settings panel.', - name: 'Test User', - email: 'test@sable.chat', - associatedEventId: eventId, - }); - // eslint-disable-next-line no-console - console.log('[Sentry Test] Feedback captured with ID:', feedbackId); - // eslint-disable-next-line no-alert - window.alert( - `Test feedback sent to Sentry!\nEvent ID: ${eventId}\nFeedback ID: ${feedbackId || 'none'}\n\nCheck the event details in Sentry for attached debug logs.` - ); - } else { - // eslint-disable-next-line no-alert - window.alert('Failed to send test feedback - no event ID returned'); - } - }; - - const handleAttachLogs = () => { - const debugLogger = getDebugLogger(); - const logCount = debugLogger.getLogs().length; - debugLogger.attachLogsToSentry(100); - // eslint-disable-next-line no-console - console.log('[Sentry] Attached', Math.min(100, logCount), 'debug logs to Sentry scope'); - // eslint-disable-next-line no-alert - window.alert( - `Attached ${Math.min(100, logCount)} recent logs to Sentry scope.\n\nThey will be included in the next error report as:\n- Context data\n- Extra data\n- Attachment (debug-logs.json)` - ); - }; - const handleShowDiagnostics = () => { const dsn = import.meta.env.VITE_SENTRY_DSN; const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; @@ -209,54 +130,6 @@ export function SentrySettings() { /> } /> - - Send Test Error - - } - /> - - Send Test Feedback - - } - /> - - Attach Logs - - } - /> Date: Sat, 14 Mar 2026 01:59:03 -0400 Subject: [PATCH 12/26] fix: inject VITE_SENTRY_DSN into preview deployment builds --- .github/workflows/cloudflare-web-preview.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 5ddfe5a0e..6a67e8c7f 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -58,6 +58,9 @@ jobs: uses: ./.github/actions/setup with: build: 'true' + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: preview - name: Set deploy alias id: alias From cbe5de45a4f0739f5a579523fce02dc7edb8f665 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 02:04:23 -0400 Subject: [PATCH 13/26] fix: auto-deploy to production on dev branch pushes --- .github/workflows/cloudflare-web-preview.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 6a67e8c7f..ee283d6b2 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -88,6 +88,14 @@ jobs: --preview-alias ${{ steps.alias.outputs.alias }} --message "$PREVIEW_MESSAGE" + - name: Deploy to production (dev branch only) + if: github.event_name == 'push' && github.ref_name == 'dev' + uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1 + with: + apiToken: ${{ secrets.TF_CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.TF_VAR_ACCOUNT_ID }} + command: versions deploy -c dist/wrangler.json --yes + - name: Resolve preview URL id: preview env: From 5c688aa83df0c7471d8f8283751fbd68476e4705 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 02:16:31 -0400 Subject: [PATCH 14/26] chore: remove Show Config/Diagnostics from Sentry dev tools --- .../developer-tools/SentrySettings.tsx | 49 +------------------ 1 file changed, 2 insertions(+), 47 deletions(-) diff --git a/src/app/features/settings/developer-tools/SentrySettings.tsx b/src/app/features/settings/developer-tools/SentrySettings.tsx index 8bf93e58b..4483bf810 100644 --- a/src/app/features/settings/developer-tools/SentrySettings.tsx +++ b/src/app/features/settings/developer-tools/SentrySettings.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Box, Text, Button, Switch } from 'folds'; +import { Box, Text, Switch } from 'folds'; import * as Sentry from '@sentry/react'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; @@ -34,36 +34,7 @@ export function SentrySettings() { setNeedsRefresh(true); }; - const handleShowDiagnostics = () => { - 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; - - const info = [ - `DSN: ${dsn ? `${dsn.substring(0, 30)}...` : 'NOT SET'}`, - `Environment: ${environment}`, - `Release: ${release || 'not set'}`, - `Sentry Enabled (localStorage): ${sentryEnabled}`, - `Session Replay Enabled: ${sessionReplayEnabled}`, - `Sentry SDK Available: ${typeof Sentry !== 'undefined'}`, - ``, - `Session Replay Masking:`, - `- Text: ALL MASKED`, - `- Media: ALL BLOCKED`, - `- Inputs: ALL MASKED`, - `- Sample Rate: ${environment === 'development' || environment === 'preview' ? '100%' : '10%'}`, - ``, - `To test from console, run:`, - `Sentry.captureMessage('Test message', 'info')`, - ].join('\n'); - - // eslint-disable-next-line no-console - console.log(`[Sentry Diagnostics]\n${info}`); - // eslint-disable-next-line no-alert - window.alert(info); - }; - - const isSentryConfigured = Boolean(import.meta.env.VITE_SENTRY_DSN); + const isSentryConfigured = Sentry.isInitialized(); const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; return ( @@ -130,22 +101,6 @@ export function SentrySettings() { /> } /> - - Show Config - - } - /> )} From 4579749c2764b3fb5ababb7b25922b8237336d40 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 02:18:43 -0400 Subject: [PATCH 15/26] feat: add VITE_SENTRY_SAMPLE_RATE env var for production sampling control --- .github/workflows/cloudflare-web-deploy.yml | 2 ++ .github/workflows/cloudflare-web-preview.yml | 1 - src/instrument.ts | 21 +++++++++++++++----- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cloudflare-web-deploy.yml b/.github/workflows/cloudflare-web-deploy.yml index 23132d90b..d74682dd1 100644 --- a/.github/workflows/cloudflare-web-deploy.yml +++ b/.github/workflows/cloudflare-web-deploy.yml @@ -52,6 +52,7 @@ jobs: env: VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} VITE_SENTRY_ENVIRONMENT: production + VITE_SENTRY_SAMPLE_RATE: ${{ vars.VITE_SENTRY_SAMPLE_RATE || '0.1' }} VITE_APP_VERSION: ${{ github.ref_name }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} @@ -82,6 +83,7 @@ jobs: env: VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} VITE_SENTRY_ENVIRONMENT: production + VITE_SENTRY_SAMPLE_RATE: ${{ vars.VITE_SENTRY_SAMPLE_RATE || '0.1' }} VITE_APP_VERSION: ${{ github.ref_name }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index ee283d6b2..c0f012f86 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -61,7 +61,6 @@ jobs: env: VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} VITE_SENTRY_ENVIRONMENT: preview - - name: Set deploy alias id: alias shell: bash diff --git a/src/instrument.ts b/src/instrument.ts index b2022861b..7cbd01915 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -5,6 +5,9 @@ * - 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 + * - VITE_SENTRY_SAMPLE_RATE: Production sample rate for traces, profiles, and + * session replays (0.0–1.0, default 0.1). Ignored in development/preview, + * which always sample at 100%. */ import * as Sentry from '@sentry/react'; import React from 'react'; @@ -19,6 +22,14 @@ 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; +// Production sample rate — overrideable via VITE_SENTRY_SAMPLE_RATE (0.0–1.0) +const isDevOrPreview = environment === 'development' || environment === 'preview'; +const rawSampleRate = parseFloat(import.meta.env.VITE_SENTRY_SAMPLE_RATE ?? ''); +const productionSampleRate = Number.isFinite(rawSampleRate) + ? Math.min(1, Math.max(0, rawSampleRate)) + : 0.1; +const sampleRate = isDevOrPreview ? 1.0 : productionSampleRate; + // Check user preferences const sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false'; const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') !== 'false'; @@ -60,10 +71,11 @@ if (dsn && sentryEnabled) { // Performance Monitoring - Tracing // 100% in development and preview, lower in production for cost control - tracesSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + // Production rate is set by VITE_SENTRY_SAMPLE_RATE (default 0.1) + tracesSampleRate: sampleRate, // Profiling sample rate must be <= tracesSampleRate - profilesSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + profilesSampleRate: sampleRate, // Control which URLs get distributed tracing headers tracePropagationTargets: [ @@ -73,10 +85,9 @@ if (dsn && sentryEnabled) { ], // Session Replay sampling - // Record 100% in development and preview for testing, 10% in production + // Record 100% in development and preview for testing, otherwise use VITE_SENTRY_SAMPLE_RATE // Always record 100% of sessions with errors - replaysSessionSampleRate: - environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + replaysSessionSampleRate: sampleRate, replaysOnErrorSampleRate: 1.0, // Enable structured logging to Sentry From 113449deeedd2fa7c28d807064393956df2ef46c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 02:25:25 -0400 Subject: [PATCH 16/26] fix: use profileSessionSampleRate and add Document-Policy header for browser profiling --- Caddyfile | 3 +++ src/instrument.ts | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Caddyfile b/Caddyfile index d807e8c2b..85b536790 100644 --- a/Caddyfile +++ b/Caddyfile @@ -7,6 +7,9 @@ :8080 { root * /app + # Required for Sentry browser profiling + header Document-Policy "js-profiling" + # Enable on-the-fly compression for things not pre-compressed encode zstd gzip diff --git a/src/instrument.ts b/src/instrument.ts index 7cbd01915..e305d9e25 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -74,8 +74,10 @@ if (dsn && sentryEnabled) { // Production rate is set by VITE_SENTRY_SAMPLE_RATE (default 0.1) tracesSampleRate: sampleRate, - // Profiling sample rate must be <= tracesSampleRate - profilesSampleRate: sampleRate, + // Profiling sample rate — decision made once per session + // Production rate is set by VITE_SENTRY_SAMPLE_RATE (default 0.1) + // Requires Document-Policy: js-profiling response header + profileSessionSampleRate: sampleRate, // Control which URLs get distributed tracing headers tracePropagationTargets: [ From 8de97957cee6eefafced27a754e47a6bece4acd2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 02:37:06 -0400 Subject: [PATCH 17/26] update sentry instrumentation --- src/instrument.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/instrument.ts b/src/instrument.ts index e305d9e25..b512345d8 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -67,6 +67,7 @@ if (dsn && sentryEnabled) { Sentry.consoleLoggingIntegration({ levels: ['error', 'warn'] }), // Browser profiling — captures JS call stacks during Sentry transactions Sentry.browserProfilingIntegration(), + Sentry.browserTracingIntegration(), ], // Performance Monitoring - Tracing @@ -82,7 +83,7 @@ if (dsn && sentryEnabled) { // Control which URLs get distributed tracing headers tracePropagationTargets: [ 'localhost', - /^https:\/\/[^/]*\.sable\.chat/, + /^https:\/\/[^/]*\.cloudhub\.social/, // Add your Matrix homeserver domains here if needed ], From ab7f09a7bb46859993230da3f430fda86b26dca5 Mon Sep 17 00:00:00 2001 From: Vesper Date: Fri, 13 Mar 2026 22:20:50 +0000 Subject: [PATCH 18/26] Add width limit to notification banners --- .../components/notification-banner/NotificationBanner.css.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/components/notification-banner/NotificationBanner.css.ts b/src/app/components/notification-banner/NotificationBanner.css.ts index a60db281c..4b04919ec 100644 --- a/src/app/components/notification-banner/NotificationBanner.css.ts +++ b/src/app/components/notification-banner/NotificationBanner.css.ts @@ -39,7 +39,7 @@ export const BannerContainer = style({ gap: config.space.S200, padding: config.space.S400, pointerEvents: 'none', - alignItems: 'stretch', + alignItems: 'flex-end', // On iOS, when keyboard opens, ensure banner stays visible at top of visual viewport '@supports': { @@ -67,6 +67,7 @@ export const Banner = style({ boxShadow: `0 ${toRem(8)} ${toRem(32)} rgba(0, 0, 0, 0.45), 0 ${toRem(2)} ${toRem(8)} rgba(0, 0, 0, 0.3)`, cursor: 'pointer', width: '100%', + maxWidth: '50em', animationName: slideIn, animationDuration: '260ms', animationTimingFunction: 'cubic-bezier(0.22, 0.8, 0.6, 1)', From 3b198223350dbc2c76bcef79427bf21b31cb1f90 Mon Sep 17 00:00:00 2001 From: Vesper Date: Sat, 14 Mar 2026 11:31:10 +0000 Subject: [PATCH 19/26] Add changeset --- .changeset/limit-notification-width.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/limit-notification-width.md diff --git a/.changeset/limit-notification-width.md b/.changeset/limit-notification-width.md new file mode 100644 index 000000000..24b5541ed --- /dev/null +++ b/.changeset/limit-notification-width.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Add width limit to notification banners From 6345445535597701431ce9f6c5b926c7f289d54a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 09:44:41 -0400 Subject: [PATCH 20/26] fix(timeline): don't force scroll-to-bottom on sync gap when scrolled up When a sliding-sync limited response fires RoomEvent.TimelineReset (or the app fires RoomEvent.TimelineRefresh) the useLiveTimelineRefresh callback was unconditionally calling setAtBottom(true) and incrementing scrollToBottomRef, scrolling the user to the bottom even if they had scrolled up to read history. Three coordinated changes: 1. Add RoomEvent.TimelineReset handler to useLiveTimelineRefresh. The SDK emits TimelineReset on the EventTimelineSet (not the Room) when a limited sync response replaces the live EventTimeline. Without this listener the stored linkedTimelines reference the old detached chain; back-pagination silently no-ops, freezing the room. 2. Gate the viewport scroll on atBottomRef.current (prior position). Capture wasAtBottom before calling setTimeline(getInitialTimeline). Only call setAtBottom(true) and increment scrollToBottomRef when the user was already at the bottom. When scrolled up we still reinit the timeline (the old chain is gone) but avoid the forced scroll. 3. Add timelineJustResetRef to allow the self-heal effect to run when atBottom=false after a reset. The SDK fires TimelineReset before populating the fresh timeline, so getInitialTimeline sees range.end=0. Without the atBottom guard bypass the range stays at {0,0} as events arrive, leaving the virtual paginator rendering nothing. The ref is cleared on first successful heal, after which normal atBottom-gated behaviour resumes. --- src/app/features/room/RoomTimeline.tsx | 76 +++++++++++++++++--------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 8945912bb..0733a4a3e 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -557,10 +557,20 @@ const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { if (r.roomId !== room.roomId) return; onRefresh(); }; + // The SDK fires RoomEvent.TimelineReset on the EventTimelineSet (not the Room) + // when a limited sliding-sync response replaces the live EventTimeline with a + // fresh one. Without this handler, the stored linkedTimelines reference the old + // detached chain and back-pagination silently no-ops, freezing the room. + const handleTimelineReset: EventTimelineSetHandlerMap[RoomEvent.TimelineReset] = () => { + onRefresh(); + }; + const unfilteredTimelineSet = room.getUnfilteredTimelineSet(); room.on(RoomEvent.TimelineRefresh, handleTimelineRefresh); + unfilteredTimelineSet.on(RoomEvent.TimelineReset, handleTimelineReset); return () => { room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh); + unfilteredTimelineSet.removeListener(RoomEvent.TimelineReset, handleTimelineReset); }; }, [room, onRefresh]); }; @@ -801,6 +811,12 @@ export function RoomTimeline({ atBottomRef.current = val; }, []); + // Set to true by the useLiveTimelineRefresh callback when the timeline is + // re-initialised (TimelineRefresh or TimelineReset). Allows the range self-heal + // effect below to run even when atBottom=false, so the virtual paginator window + // is restored to the live end without forcing a viewport scroll. + const timelineJustResetRef = useRef(false); + const scrollRef = useRef(null); const scrollToBottomRef = useRef({ count: 0, @@ -1088,34 +1104,34 @@ export function RoomTimeline({ useLiveTimelineRefresh( room, useCallback(() => { - // Always reinitialize on TimelineRefresh. With sliding sync, a limited - // response replaces the room's live EventTimeline with a brand-new object, - // firing TimelineRefresh. At that moment liveTimelineLinked is stale-false - // (the stored linkedTimelines still reference the old detached object), - // so the previous guard `if (liveTimelineLinked || ...)` would silently - // skip reinit. Back-pagination then calls paginateEventTimeline against - // the dead old timeline, which no-ops, and the IntersectionObserver never - // re-fires because intersection state didn't change — causing a permanent - // hang at the top of the timeline with no spinner and no history loaded. - // Unconditionally reinitializing is correct: TimelineRefresh signals that - // the SDK has replaced the timeline chain, so any stored range/indices - // against the old chain are invalid anyway. + // Always reinitialize on TimelineRefresh/TimelineReset. With sliding sync, + // a limited response replaces the room's live EventTimeline with a brand-new + // object. At that moment liveTimelineLinked is stale-false (stored + // linkedTimelines reference the old detached chain), so any guard on that + // flag would skip reinit, causing back-pagination to no-op silently and the + // room to appear frozen. Unconditional reinit is correct: both events signal + // that stored range/indices against the old chain are invalid. + // + // Only force the viewport to the bottom if the user was already there. + // When the user has scrolled up to read history and a sync gap fires, we + // must still reinit (the old timeline is gone), but scrolling them back to + // the bottom is jarring. Instead we set timelineJustResetRef=true so the + // self-heal effect below can advance the range as events arrive on the fresh + // timeline, without atBottom=true being required. // - // 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. + // 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. Sentry.metrics.count('sable.timeline.reinit', 1); debugLog.info('timeline', 'Live timeline refresh triggered', { roomId: room.roomId }); + const wasAtBottom = atBottomRef.current; + timelineJustResetRef.current = true; setTimeline(getInitialTimeline(room)); - setAtBottom(true); - scrollToBottomRef.current.count += 1; - scrollToBottomRef.current.smooth = false; + if (wasAtBottom) { + setAtBottom(true); + scrollToBottomRef.current.count += 1; + scrollToBottomRef.current.smooth = false; + } }, [room, setAtBottom]) ); @@ -1142,9 +1158,17 @@ export function RoomTimeline({ // position we want to display. Without this, loading more history makes it look // like we've scrolled up because the range (0, 10) is now showing the old events // instead of the latest ones. + // + // Also runs after a timeline reset (timelineJustResetRef=true) even when + // atBottom=false. After TimelineReset the SDK fires the event before populating + // the fresh timeline, so getInitialTimeline sees range.end=0. When events + // arrive eventsLength grows and we need to heal the range back to the live end + // regardless of the user's scroll position. useEffect(() => { - if (atBottom && liveTimelineLinked && eventsLength > timeline.range.end) { - // More events exist than our current range shows. Adjust to stay at bottom. + const resetPending = timelineJustResetRef.current; + if ((atBottom || resetPending) && liveTimelineLinked && eventsLength > timeline.range.end) { + if (resetPending) timelineJustResetRef.current = false; + // More events exist than our current range shows. Adjust to the live end. setTimeline((ct) => ({ ...ct, range: { From a737ef7bf1416d96f72af02bdba0f86d3e2b54f1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 09:49:11 -0400 Subject: [PATCH 21/26] fix(timeline): stable callback ref pattern for room event listener hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useLiveEventArrive, useRelationUpdate, useLiveTimelineRefresh, and useThreadUpdate each passed their callback argument into the useEffect dependency array. This caused the listener to be removed and re-added every time the callback identity changed (e.g. when unreadInfo toggled or when any other parent-scope value included in the useCallback deps changed). Under normal conditions React's synchronous effect cleanup guarantees one listener at a time. But during sliding-sync error/retry cycles the client emits MaxListenersExceededWarning (11 session_ended, 51 RoomState.events) confirming that listeners accumulate. When the callbacks change fast enough — or React defers cleanup in concurrent mode — multiple handleTimelineEvent instances are live simultaneously. Each one independently calls setTimeline({ range: start+1, end+1 }) for the same arriving event, producing the spurious scroll jumps reported. Fix: use the stable callback ref pattern in all four hooks. - Add a ref (onArriveRef / onRelationRef / onRefreshRef / onUpdateRef) - Assign ref.current = callback on every render (always up-to-date) - Remove the callback from the useEffect dep array (effect runs only when `room` changes) - Inside handlers, delegate via ref.current() instead of the captured closure value The event listener identity is now stable for the entire lifetime of each room mount. Listener counts stay at 1 regardless of how many re-renders occur or how many sync error/retry cycles fire. --- src/app/features/room/RoomTimeline.tsx | 48 ++++++++++++++++++-------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 0733a4a3e..bea6972b0 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -482,6 +482,12 @@ const useTimelinePagination = ( }; const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => { + // Stable ref so the effect dep array only contains `room`. The listener is + // registered once per room mount; onArrive can change freely without causing + // listener churn during rapid re-renders (e.g. sync error/retry cycles). + const onArriveRef = useRef(onArrive); + onArriveRef.current = onArrive; + useEffect(() => { // Capture the live timeline and registration time. Events appended to the // live timeline AFTER this point can be genuinely new even when @@ -508,14 +514,14 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) data.timeline === liveTimeline && mEvent.getTs() >= registeredAt - 60_000); if (!isLive) return; - onArrive(mEvent); + onArriveRef.current(mEvent); }; const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = ( mEvent: MatrixEvent, eventRoom: Room | undefined ) => { if (eventRoom?.roomId !== room.roomId) return; - onArrive(mEvent); + onArriveRef.current(mEvent); }; room.on(RoomEvent.Timeline, handleTimelineEvent); @@ -524,10 +530,13 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) room.removeListener(RoomEvent.Timeline, handleTimelineEvent); room.removeListener(RoomEvent.Redaction, handleRedaction); }; - }, [room, onArrive]); + }, [room]); // stable: re-register only when room changes, not on callback identity changes }; const useRelationUpdate = (room: Room, onRelation: () => void) => { + const onRelationRef = useRef(onRelation); + onRelationRef.current = onRelation; + useEffect(() => { const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = ( mEvent: MatrixEvent, @@ -541,28 +550,31 @@ const useRelationUpdate = (room: Room, onRelation: () => void) => { // also need to trigger a re-render so makeReplaced state is reflected. if (eventRoom?.roomId !== room.roomId || data.liveEvent) return; if (mEvent.getRelation()?.rel_type === RelationType.Replace) { - onRelation(); + onRelationRef.current(); } }; room.on(RoomEvent.Timeline, handleTimelineEvent); return () => { room.removeListener(RoomEvent.Timeline, handleTimelineEvent); }; - }, [room, onRelation]); + }, [room]); }; const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { + const onRefreshRef = useRef(onRefresh); + onRefreshRef.current = onRefresh; + useEffect(() => { const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r: Room) => { if (r.roomId !== room.roomId) return; - onRefresh(); + onRefreshRef.current(); }; // The SDK fires RoomEvent.TimelineReset on the EventTimelineSet (not the Room) // when a limited sliding-sync response replaces the live EventTimeline with a // fresh one. Without this handler, the stored linkedTimelines reference the old // detached chain and back-pagination silently no-ops, freezing the room. const handleTimelineReset: EventTimelineSetHandlerMap[RoomEvent.TimelineReset] = () => { - onRefresh(); + onRefreshRef.current(); }; const unfilteredTimelineSet = room.getUnfilteredTimelineSet(); @@ -572,21 +584,27 @@ const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh); unfilteredTimelineSet.removeListener(RoomEvent.TimelineReset, handleTimelineReset); }; - }, [room, onRefresh]); + }, [room]); }; // Trigger re-render when thread reply counts change so the thread chip updates. const useThreadUpdate = (room: Room, onUpdate: () => void) => { + const onUpdateRef = useRef(onUpdate); + onUpdateRef.current = onUpdate; + useEffect(() => { - room.on(ThreadEvent.New, onUpdate); - room.on(ThreadEvent.Update, onUpdate); - room.on(ThreadEvent.NewReply, onUpdate); + // Stable wrapper: the same function identity is kept for the lifetime of + // the room so add/removeListener calls always match. + const handler = () => onUpdateRef.current(); + room.on(ThreadEvent.New, handler); + room.on(ThreadEvent.Update, handler); + room.on(ThreadEvent.NewReply, handler); return () => { - room.removeListener(ThreadEvent.New, onUpdate); - room.removeListener(ThreadEvent.Update, onUpdate); - room.removeListener(ThreadEvent.NewReply, onUpdate); + room.removeListener(ThreadEvent.New, handler); + room.removeListener(ThreadEvent.Update, handler); + room.removeListener(ThreadEvent.NewReply, handler); }; - }, [room, onUpdate]); + }, [room]); }; // Returns the number of replies in a thread, counting actual reply events From ad448ec0040f69a263b0edb9e280a9a2c2658b2b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 11:25:27 -0400 Subject: [PATCH 22/26] style: fix prettier indentation in Sentry.startSpan callback --- src/app/features/room/RoomTimeline.tsx | 94 +++++++++++++------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index bea6972b0..74c9aee39 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -293,57 +293,57 @@ const useEventTimelineLoader = ( useCallback( async (eventId: string) => { return 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); - }); - }); + 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; + } - Sentry.metrics.distribution( - 'sable.timeline.jump_load_ms', - performance.now() - jumpLoadStart - ); - 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] From 5f631521b0ed518b39bde5f64383a2fe6d722774 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 14 Mar 2026 17:08:34 -0400 Subject: [PATCH 23/26] fix: resolve merge conflict, lint errors and formatting issues --- src/app/components/DefaultErrorPage.tsx | 8 ++++-- src/app/features/room/RoomInput.tsx | 8 ++++-- src/app/features/room/RoomTimeline.tsx | 7 +++--- src/app/pages/client/ClientRoot.tsx | 33 +++++++++++-------------- src/app/utils/matrix.ts | 12 ++++++--- 5 files changed, 38 insertions(+), 30 deletions(-) diff --git a/src/app/components/DefaultErrorPage.tsx b/src/app/components/DefaultErrorPage.tsx index 54ac642ea..62042cef1 100644 --- a/src/app/components/DefaultErrorPage.tsx +++ b/src/app/components/DefaultErrorPage.tsx @@ -75,7 +75,9 @@ export function ErrorPage({ error, eventId }: ErrorPageProps) {