From b1e9638cd9e625edd9c445b0074073070ec0d564 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 01:39:49 -0400 Subject: [PATCH 01/25] feat(sentry): initialize Sentry SDK with environment-based sampling Add @sentry/react and wire instrument.ts into the app entry point. - Environment detection: production (10%), preview (100%), development (100%); driven by VITE_SENTRY_ENVIRONMENT env var - SessionReplay with full privacy masking (text, media, inputs) - Performance tracing and profiling enabled - Data scrubbing for Matrix tokens and user IDs - Exposes Sentry on window.Sentry for console-level debugging --- package.json | 2 + pnpm-lock.yaml | 265 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.tsx | 1 + src/instrument.ts | 220 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 488 insertions(+) create mode 100644 src/instrument.ts diff --git a/package.json b/package.json index 371e7a975..0be77a812 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.4.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@fontsource-variable/nunito": "5.2.7", + "@sentry/react": "^10.43.0", "@fontsource/space-mono": "5.2.9", "@tanstack/react-query": "^5.90.21", "@tanstack/react-query-devtools": "^5.91.3", @@ -95,6 +96,7 @@ "@eslint/js": "9.39.3", "@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-wasm": "^6.2.2", + "@sentry/vite-plugin": "^5.1.1", "@types/chroma-js": "^3.1.2", "@types/file-saver": "^2.0.7", "@types/is-hotkey": "^0.1.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0d64afd3..1f76fbe40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: '@fontsource/space-mono': specifier: 5.2.9 version: 5.2.9 + '@sentry/react': + specifier: ^10.43.0 + version: 10.43.0(react@18.3.1) '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@18.3.1) @@ -218,6 +221,9 @@ importers: '@rollup/plugin-wasm': specifier: ^6.2.2 version: 6.2.2(rollup@4.59.0) + '@sentry/vite-plugin': + specifier: ^5.1.1 + version: 5.1.1(rollup@4.59.0) '@types/chroma-js': specifier: ^3.1.2 version: 3.1.2 @@ -2322,6 +2328,106 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@sentry-internal/browser-utils@10.43.0': + resolution: {integrity: sha512-8zYTnzhAPvNkVH1Irs62wl0J/c+0QcJ62TonKnzpSFUUD3V5qz8YDZbjIDGfxy+1EB9fO0sxtddKCzwTHF/MbQ==} + engines: {node: '>=18'} + + '@sentry-internal/feedback@10.43.0': + resolution: {integrity: sha512-YoXuwluP6eOcQxTeTtaWb090++MrLyWOVsUTejzUQQ6LFL13Jwt+bDPF1kvBugMq4a7OHw/UNKQfd6//rZMn2g==} + engines: {node: '>=18'} + + '@sentry-internal/replay-canvas@10.43.0': + resolution: {integrity: sha512-ZIw1UNKOFXo1LbPCJPMAx9xv7D8TMZQusLDUgb6BsPQJj0igAuwd7KRGTkjjgnrwBp2O/sxcQFRhQhknWk7QPg==} + engines: {node: '>=18'} + + '@sentry-internal/replay@10.43.0': + resolution: {integrity: sha512-khCXlGrlH1IU7P5zCEAJFestMeH97zDVCekj8OsNNDtN/1BmCJ46k6Xi0EqAUzdJgrOLJeLdoYdgtiIjovZ8Sg==} + engines: {node: '>=18'} + + '@sentry/babel-plugin-component-annotate@5.1.1': + resolution: {integrity: sha512-x2wEpBHwsTyTF2rWsLKJlzrRF1TTIGOfX+ngdE+Yd5DBkoS58HwQv824QOviPGQRla4/ypISqAXzjdDPL/zalg==} + engines: {node: '>= 18'} + + '@sentry/browser@10.43.0': + resolution: {integrity: sha512-2V3I3sXi3SMeiZpKixd9ztokSgK27cmvsD9J5oyOyjhGLTW/6QKCwHbKnluMgQMXq20nixQk5zN4wRjRUma3sg==} + engines: {node: '>=18'} + + '@sentry/bundler-plugin-core@5.1.1': + resolution: {integrity: sha512-F+itpwR9DyQR7gEkrXd2tigREPTvtF5lC8qu6e4anxXYRTui1+dVR0fXNwjpyAZMhIesLfXRN7WY7ggdj7hi0Q==} + engines: {node: '>= 18'} + + '@sentry/cli-darwin@2.58.5': + resolution: {integrity: sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ==} + engines: {node: '>=10'} + os: [darwin] + + '@sentry/cli-linux-arm64@2.58.5': + resolution: {integrity: sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux, freebsd, android] + + '@sentry/cli-linux-arm@2.58.5': + resolution: {integrity: sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux, freebsd, android] + + '@sentry/cli-linux-i686@2.58.5': + resolution: {integrity: sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [linux, freebsd, android] + + '@sentry/cli-linux-x64@2.58.5': + resolution: {integrity: sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux, freebsd, android] + + '@sentry/cli-win32-arm64@2.58.5': + resolution: {integrity: sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@sentry/cli-win32-i686@2.58.5': + resolution: {integrity: sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [win32] + + '@sentry/cli-win32-x64@2.58.5': + resolution: {integrity: sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@sentry/cli@2.58.5': + resolution: {integrity: sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg==} + engines: {node: '>= 10'} + hasBin: true + + '@sentry/core@10.43.0': + resolution: {integrity: sha512-l0SszQAPiQGWl/ferw8GP3ALyHXiGiRKJaOvNmhGO+PrTQyZTZ6OYyPnGijAFRg58dE1V3RCH/zw5d2xSUIiNg==} + engines: {node: '>=18'} + + '@sentry/react@10.43.0': + resolution: {integrity: sha512-shvErEpJ41i0Q3lIZl0CDWYQ7m8yHLi7ECG0gFvN8zf8pEdl5grQIOoe3t/GIUzcpCcor16F148ATmKJJypc/Q==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x + + '@sentry/rollup-plugin@5.1.1': + resolution: {integrity: sha512-1d5NkdRR6aKWBP7czkY8sFFWiKnfmfRpQOj+m9bJTsyTjbMiEQJst6315w5pCVlRItPhBqpAraqAhutZFgvyVg==} + engines: {node: '>= 18'} + peerDependencies: + rollup: '>=4.59.0' + + '@sentry/vite-plugin@5.1.1': + resolution: {integrity: sha512-i6NWUDi2SDikfSUeMJvJTRdwEKYSfTd+mvBO2Ja51S1YK+hnickBuDfD+RvPerIXLuyRu3GamgNPbNqgCGUg/Q==} + engines: {node: '>= 18'} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -2799,6 +2905,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -3174,6 +3284,10 @@ packages: dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3632,6 +3746,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -3717,6 +3835,10 @@ packages: htmlparser2@9.0.0: resolution: {integrity: sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} @@ -4341,9 +4463,16 @@ packages: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -7484,6 +7613,117 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@sentry-internal/browser-utils@10.43.0': + dependencies: + '@sentry/core': 10.43.0 + + '@sentry-internal/feedback@10.43.0': + dependencies: + '@sentry/core': 10.43.0 + + '@sentry-internal/replay-canvas@10.43.0': + dependencies: + '@sentry-internal/replay': 10.43.0 + '@sentry/core': 10.43.0 + + '@sentry-internal/replay@10.43.0': + dependencies: + '@sentry-internal/browser-utils': 10.43.0 + '@sentry/core': 10.43.0 + + '@sentry/babel-plugin-component-annotate@5.1.1': {} + + '@sentry/browser@10.43.0': + dependencies: + '@sentry-internal/browser-utils': 10.43.0 + '@sentry-internal/feedback': 10.43.0 + '@sentry-internal/replay': 10.43.0 + '@sentry-internal/replay-canvas': 10.43.0 + '@sentry/core': 10.43.0 + + '@sentry/bundler-plugin-core@5.1.1': + dependencies: + '@babel/core': 7.29.0 + '@sentry/babel-plugin-component-annotate': 5.1.1 + '@sentry/cli': 2.58.5 + dotenv: 16.6.1 + find-up: 5.0.0 + glob: 13.0.6 + magic-string: 0.30.21 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/cli-darwin@2.58.5': + optional: true + + '@sentry/cli-linux-arm64@2.58.5': + optional: true + + '@sentry/cli-linux-arm@2.58.5': + optional: true + + '@sentry/cli-linux-i686@2.58.5': + optional: true + + '@sentry/cli-linux-x64@2.58.5': + optional: true + + '@sentry/cli-win32-arm64@2.58.5': + optional: true + + '@sentry/cli-win32-i686@2.58.5': + optional: true + + '@sentry/cli-win32-x64@2.58.5': + optional: true + + '@sentry/cli@2.58.5': + dependencies: + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + which: 2.0.2 + optionalDependencies: + '@sentry/cli-darwin': 2.58.5 + '@sentry/cli-linux-arm': 2.58.5 + '@sentry/cli-linux-arm64': 2.58.5 + '@sentry/cli-linux-i686': 2.58.5 + '@sentry/cli-linux-x64': 2.58.5 + '@sentry/cli-win32-arm64': 2.58.5 + '@sentry/cli-win32-i686': 2.58.5 + '@sentry/cli-win32-x64': 2.58.5 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/core@10.43.0': {} + + '@sentry/react@10.43.0(react@18.3.1)': + dependencies: + '@sentry/browser': 10.43.0 + '@sentry/core': 10.43.0 + react: 18.3.1 + + '@sentry/rollup-plugin@5.1.1(rollup@4.59.0)': + dependencies: + '@sentry/bundler-plugin-core': 5.1.1 + magic-string: 0.30.21 + rollup: 4.59.0 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/vite-plugin@5.1.1(rollup@4.59.0)': + dependencies: + '@sentry/bundler-plugin-core': 5.1.1 + '@sentry/rollup-plugin': 5.1.1(rollup@4.59.0) + transitivePeerDependencies: + - encoding + - rollup + - supports-color + '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.14': {} @@ -7991,6 +8231,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -8372,6 +8618,8 @@ snapshots: no-case: 3.0.4 tslib: 2.8.1 + dotenv@16.6.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -9014,6 +9262,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.2 + glob@13.0.6: + dependencies: + minimatch: 10.2.4 + minipass: 7.1.3 + path-scurry: 2.0.2 + globals@14.0.0: {} globals@15.15.0: {} @@ -9106,6 +9360,13 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + i18next-browser-languagedetector@8.2.1: dependencies: '@babel/runtime': 7.28.6 @@ -9724,12 +9985,16 @@ snapshots: prismjs@1.30.0: {} + progress@2.0.3: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} diff --git a/src/index.tsx b/src/index.tsx index 3248458ba..f11c7ef58 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,4 @@ +import './instrument'; import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; import '@fontsource-variable/nunito'; diff --git a/src/instrument.ts b/src/instrument.ts new file mode 100644 index 000000000..29618dca1 --- /dev/null +++ b/src/instrument.ts @@ -0,0 +1,220 @@ +/** + * Sentry instrumentation - MUST be imported first in the application lifecycle + * + * Configure via environment variables: + * - VITE_SENTRY_DSN: Your Sentry DSN (required to enable Sentry) + * - VITE_SENTRY_ENVIRONMENT: Environment name (defaults to MODE) + * - VITE_APP_VERSION: Release version for tracking + */ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import { + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, +} from 'react-router-dom'; + +const dsn = import.meta.env.VITE_SENTRY_DSN; +const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; +const release = import.meta.env.VITE_APP_VERSION; + +// Per-session error event counter for rate limiting +let sessionErrorCount = 0; +const SESSION_ERROR_LIMIT = 50; + +// Check user preferences +const sentryEnabled = localStorage.getItem('sable_sentry_enabled') !== 'false'; +const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true'; + +// Only initialize if DSN is provided and user hasn't opted out +if (dsn && sentryEnabled) { + Sentry.init({ + dsn, + environment, + release, + + // Do not send PII (IP addresses, user identifiers) to protect privacy + sendDefaultPii: false, + + integrations: [ + // React Router v6 browser tracing integration + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + // Session replay with privacy settings (only if user opted in) + ...(replayEnabled + ? [ + Sentry.replayIntegration({ + maskAllText: true, // Mask all text for privacy + blockAllMedia: true, // Block images/video/audio for privacy + maskAllInputs: true, // Mask form inputs + }), + ] + : []), + // Capture console.error/warn as structured logs in the Sentry Logs product + Sentry.consoleLoggingIntegration({ levels: ['error', 'warn'] }), + // Browser profiling — captures JS call stacks during Sentry transactions + Sentry.browserProfilingIntegration(), + ], + + // Performance Monitoring - Tracing + // 100% in development and preview, lower in production for cost control + tracesSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + + // Browser profiling — profiles every sampled session (requires Document-Policy: js-profiling response header) + profileSessionSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + + // Control which URLs get distributed tracing headers + tracePropagationTargets: [ + 'localhost', + /^https:\/\/[^/]*\.sable\.chat/, + // Add your Matrix homeserver domains here if needed + ], + + // Session Replay sampling + // Record 100% in development and preview for testing, 10% in production + // Always record 100% of sessions with errors + replaysSessionSampleRate: + environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + replaysOnErrorSampleRate: 1.0, + + // Enable structured logging to Sentry + enableLogs: true, + + // Rate limiting: cap error events per page-load session to avoid quota exhaustion. + // Separate counters for errors and transactions so perf traces do not drain the error budget. + beforeSendTransaction(event) { + return event; + }, + + // 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) { + sessionErrorCount += 1; + if (sessionErrorCount > SESSION_ERROR_LIMIT) { + return null; // Drop event — session limit reached + } + + // Scrub sensitive data from error messages + if (event.message) { + if ( + event.message.includes('access_token') || + event.message.includes('password') || + event.message.includes('token') + ) { + // eslint-disable-next-line no-param-reassign + event.message = event.message.replace( + /(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:]\s*)([^\s&]+)/gi, + '$1$2[REDACTED]' + ); + } + // Redact Matrix IDs to protect user privacy + // eslint-disable-next-line no-param-reassign + event.message = event.message.replace(/@[^:]+:[^\s]+/g, '@[USER_ID]'); + // eslint-disable-next-line no-param-reassign + event.message = event.message.replace(/![^:]+:[^\s]+/g, '![ROOM_ID]'); + // eslint-disable-next-line no-param-reassign + event.message = event.message.replace(/\$[^:\s]+/g, '$[EVENT_ID]'); + } + + // Scrub sensitive data from exception values + if (event.exception?.values) { + event.exception.values.forEach((exception) => { + if (exception.value) { + // eslint-disable-next-line no-param-reassign + exception.value = exception.value.replace( + /(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:]\s*)([^\s&]+)/gi, + '$1$2[REDACTED]' + ); + // Redact Matrix IDs + // eslint-disable-next-line no-param-reassign + exception.value = exception.value.replace(/@[^:]+:[^\s]+/g, '@[USER_ID]'); + // eslint-disable-next-line no-param-reassign + exception.value = exception.value.replace(/![^:]+:[^\s]+/g, '![ROOM_ID]'); + // eslint-disable-next-line no-param-reassign + exception.value = exception.value.replace(/\$[^:\s]+/g, '$[EVENT_ID]'); + } + }); + } + + // Scrub request data + if (event.request?.url) { + // eslint-disable-next-line no-param-reassign + event.request.url = event.request.url.replace( + /(access_token|password|token)([=:]\s*)([^\s&]+)/gi, + '$1$2[REDACTED]' + ); + } + + if (event.request?.headers) { + const headers = event.request.headers as Record; + if (headers.Authorization) { + headers.Authorization = '[REDACTED]'; + } + } + + return event; + }, + }); + + // Expose Sentry globally for debugging and console testing + // @ts-expect-error - Adding to window for debugging + window.Sentry = Sentry; + + // eslint-disable-next-line no-console + console.info( + `[Sentry] Initialized for ${environment} environment${replayEnabled ? ' with Session Replay' : ''}` + ); + // eslint-disable-next-line no-console + console.info(`[Sentry] DSN configured: ${dsn?.substring(0, 30)}...`); + // eslint-disable-next-line no-console + console.info(`[Sentry] Release: ${release || 'not set'}`); +} else if (!sentryEnabled) { + // eslint-disable-next-line no-console + console.info('[Sentry] Disabled by user preference'); +} else { + // eslint-disable-next-line no-console + console.info('[Sentry] Disabled - no DSN provided'); +} + +// Export Sentry for use in other parts of the application +export { Sentry }; From a84c227cf859ffe20f4ac9012d88b8594a218cb4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 01:39:56 -0400 Subject: [PATCH 02/25] feat(sentry): developer settings panel and debug log attachment - SentrySettings panel: capture events manually, feedback widget, session replay controls, and diagnostic test buttons - Wire SentrySettings into DevelopTools tab - debugLogger: attach buffered logs to Sentry events via breadcrumbs, extra data, and file attachment for full context on error reports --- .../settings/developer-tools/DevelopTools.tsx | 6 + .../developer-tools/SentrySettings.tsx | 240 ++++++++++++++++++ src/app/utils/debugLogger.ts | 180 +++++++++++++ 3 files changed, 426 insertions(+) create mode 100644 src/app/features/settings/developer-tools/SentrySettings.tsx diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index d230620ae..b717f2261 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -12,6 +12,7 @@ import { SequenceCardStyle } from '$features/settings/styles.css'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; import { DebugLogViewer } from './DebugLogViewer'; +import { SentrySettings } from './SentrySettings'; type DeveloperToolsProps = { requestClose: () => void; @@ -126,6 +127,11 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { )} + {developerTools && ( + + + + )} diff --git a/src/app/features/settings/developer-tools/SentrySettings.tsx b/src/app/features/settings/developer-tools/SentrySettings.tsx new file mode 100644 index 000000000..0bb51a985 --- /dev/null +++ b/src/app/features/settings/developer-tools/SentrySettings.tsx @@ -0,0 +1,240 @@ +import { useState, useEffect } from 'react'; +import { Box, Text, Switch, Button } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { getDebugLogger, LogCategory } from '$utils/debugLogger'; + +const ALL_CATEGORIES: LogCategory[] = [ + 'sync', + 'network', + 'notification', + 'message', + 'call', + 'ui', + 'timeline', + 'error', + 'general', +]; + +export function SentrySettings() { + const [sentryEnabled, setSentryEnabled] = useState( + localStorage.getItem('sable_sentry_enabled') !== 'false' + ); + const [sessionReplayEnabled, setSessionReplayEnabled] = useState( + localStorage.getItem('sable_sentry_replay_enabled') === 'true' + ); + const [needsRefresh, setNeedsRefresh] = useState(false); + const [categoryEnabled, setCategoryEnabled] = useState>(() => { + const logger = getDebugLogger(); + return Object.fromEntries( + ALL_CATEGORIES.map((c) => [c, logger.getBreadcrumbCategoryEnabled(c)]) + ) as Record; + }); + const [sentryStats, setSentryStats] = useState(() => getDebugLogger().getSentryStats()); + + useEffect(() => { + const interval = setInterval(() => { + setSentryStats(getDebugLogger().getSentryStats()); + }, 5000); + return () => clearInterval(interval); + }, []); + + const handleSentryToggle = (enabled: boolean) => { + setSentryEnabled(enabled); + if (enabled) { + localStorage.removeItem('sable_sentry_enabled'); + } else { + localStorage.setItem('sable_sentry_enabled', 'false'); + } + setNeedsRefresh(true); + }; + + const handleReplayToggle = (enabled: boolean) => { + setSessionReplayEnabled(enabled); + if (enabled) { + localStorage.setItem('sable_sentry_replay_enabled', 'true'); + } else { + localStorage.removeItem('sable_sentry_replay_enabled'); + } + setNeedsRefresh(true); + }; + + const handleCategoryToggle = (category: LogCategory, enabled: boolean) => { + getDebugLogger().setBreadcrumbCategoryEnabled(category, enabled); + setCategoryEnabled((prev) => ({ ...prev, [category]: enabled })); + }; + + const handleExportLogs = () => { + const data = getDebugLogger().exportLogs(); + const blob = new Blob([data], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `sable-debug-logs-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + const isSentryConfigured = Boolean(import.meta.env.VITE_SENTRY_DSN); + const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; + const isProd = environment === 'production'; + const traceSampleRate = isProd ? '10%' : '100%'; + const replaySampleRate = isProd ? '10%' : '100%'; + + return ( + + Error Tracking (Sentry) + {needsRefresh && ( + + + Please refresh the page for Sentry settings to take effect. + + + )} + {!isSentryConfigured && ( + + + Sentry is not configured. Set VITE_SENTRY_DSN to enable error tracking. + + + )} + + + } + /> + {sentryEnabled && isSentryConfigured && ( + <> + + } + /> + + )} + + {isSentryConfigured && ( + + + All data sent to Sentry is filtered for sensitive information like passwords and access + tokens. You can opt out at any time. + + + Session Replay Privacy: When enabled, all text content, media + (images/video/audio), and form inputs are completely masked or blocked. Only UI + structure and interactions are recorded. + + + )} + + {isSentryConfigured && sentryEnabled && ( + <> + Performance Metrics + + + + + + + Breadcrumb Categories + + Control which log categories are included as breadcrumbs in Sentry error reports. + Disabling a category reduces noise without affecting error capture. + + + {ALL_CATEGORIES.map((cat) => ( + handleCategoryToggle(cat, v)} + /> + } + /> + ))} + + + Debug Logs + + + + Export JSON + + } + /> + + + )} + + ); +} diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts index 53b146818..2ab138740 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 = @@ -29,6 +31,8 @@ export interface LogEntry { type LogListener = (entry: LogEntry) => void; +const BREADCRUMB_DISABLED_KEY = 'sable_sentry_breadcrumb_disabled'; + class DebugLoggerService { private logs: LogEntry[] = []; @@ -38,9 +42,22 @@ class DebugLoggerService { private listeners: Set = new Set(); + private disabledBreadcrumbCategories: Set; + + private sentryStats = { errors: 0, warnings: 0 }; + constructor() { // Check if debug logging is enabled from localStorage this.enabled = localStorage.getItem('sable_internal_debug') === '1'; + // Load disabled breadcrumb categories + try { + const stored = localStorage.getItem(BREADCRUMB_DISABLED_KEY); + this.disabledBreadcrumbCategories = new Set( + stored ? (JSON.parse(stored) as LogCategory[]) : [] + ); + } catch { + this.disabledBreadcrumbCategories = new Set(); + } } public isEnabled(): boolean { @@ -99,6 +116,9 @@ class DebugLoggerService { // Notify listeners this.notifyListeners(entry); + // Send to Sentry + this.sendToSentry(entry); + // Also log to console for developer convenience const prefix = `[sable:${category}:${namespace}]`; const consoleLevel = level === 'debug' ? 'log' : level; @@ -106,6 +126,118 @@ class DebugLoggerService { console[consoleLevel](prefix, message, data !== undefined ? data : ''); } + public getBreadcrumbCategoryEnabled(category: LogCategory): boolean { + return !this.disabledBreadcrumbCategories.has(category); + } + + public setBreadcrumbCategoryEnabled(category: LogCategory, enabled: boolean): void { + if (enabled) { + this.disabledBreadcrumbCategories.delete(category); + } else { + this.disabledBreadcrumbCategories.add(category); + } + const disabledArray = Array.from(this.disabledBreadcrumbCategories); + if (disabledArray.length > 0) { + localStorage.setItem(BREADCRUMB_DISABLED_KEY, JSON.stringify(disabledArray)); + } else { + localStorage.removeItem(BREADCRUMB_DISABLED_KEY); + } + } + + public getSentryStats(): { errors: number; warnings: number } { + return { ...this.sentryStats }; + } + + /** + * Send log entries to Sentry for error tracking and breadcrumbs + */ + private sendToSentry(entry: LogEntry): void { + // Map log levels to Sentry severity + const sentryLevelMap: Record = { + debug: 'debug', + info: 'info', + warn: 'warning', + error: 'error', + }; + const sentryLevel: Sentry.SeverityLevel = sentryLevelMap[entry.level] ?? 'error'; + + // Add breadcrumb for all logs (helps with debugging in Sentry), unless category is disabled + if (!this.disabledBreadcrumbCategories.has(entry.category)) Sentry.addBreadcrumb({ + category: `${entry.category}.${entry.namespace}`, + message: entry.message, + level: sentryLevel, + data: entry.data ? { data: entry.data } : undefined, + timestamp: entry.timestamp / 1000, // Sentry expects seconds + }); + + // Send as structured log to the Sentry Logs product (requires enableLogs: true) + const logMsg = `[${entry.category}:${entry.namespace}] ${entry.message}`; + 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') { + this.sentryStats.errors += 1; + // If data is an Error object, capture it as an exception + if (entry.data instanceof Error) { + Sentry.captureException(entry.data, { + level: 'error', + tags: { + category: entry.category, + namespace: entry.namespace, + }, + contexts: { + debugLog: { + message: entry.message, + timestamp: new Date(entry.timestamp).toISOString(), + }, + }, + }); + } else { + // Otherwise capture as a message + Sentry.captureMessage(`[${entry.category}:${entry.namespace}] ${entry.message}`, { + level: 'error', + tags: { + category: entry.category, + namespace: entry.namespace, + }, + contexts: { + debugLog: { + data: entry.data, + timestamp: new Date(entry.timestamp).toISOString(), + }, + }, + }); + } + } else if (entry.level === 'warn' && Math.random() < 0.1) { + // Capture 10% of warnings to avoid overwhelming Sentry + this.sentryStats.warnings += 1; + Sentry.captureMessage(`[${entry.category}:${entry.namespace}] ${entry.message}`, { + level: 'warning', + tags: { + category: entry.category, + namespace: entry.namespace, + }, + contexts: { + debugLog: { + data: entry.data, + timestamp: new Date(entry.timestamp).toISOString(), + }, + }, + }); + } + } + public getLogs(): LogEntry[] { return [...this.logs]; } @@ -152,6 +284,54 @@ class DebugLoggerService { 2 ); } + + /** + * Export logs in a format suitable for attaching to Sentry reports + */ + public exportLogsForSentry(): Record[] { + return this.logs.map((log) => ({ + timestamp: new Date(log.timestamp).toISOString(), + level: log.level, + category: log.category, + namespace: log.namespace, + message: log.message, + data: log.data, + })); + } + + /** + * Attach recent logs to the next Sentry event + * Useful for bug reports to include context + */ + public attachLogsToSentry(limit = 100): void { + const recentLogs = this.logs.slice(-limit); + const logsData = recentLogs.map((log) => ({ + time: new Date(log.timestamp).toISOString(), + level: log.level, + category: log.category, + namespace: log.namespace, + message: log.message, + // Only include data for errors/warnings to avoid excessive payload + ...(log.level === 'error' || log.level === 'warn' ? { data: log.data } : {}), + })); + + // Add to context + Sentry.setContext('recentLogs', { + count: recentLogs.length, + logs: logsData, + }); + + // Also add as extra data for better visibility in Sentry UI + Sentry.getCurrentScope().setExtra('debugLogs', logsData); + + // Add as attachment for download + const logsText = JSON.stringify(logsData, null, 2); + Sentry.getCurrentScope().addAttachment({ + filename: 'debug-logs.json', + data: logsText, + contentType: 'application/json', + }); + } } // Singleton instance From 1a19c0564dff3763f21d505145e5878bfe85f98a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 01:40:03 -0400 Subject: [PATCH 03/25] feat(sentry): crash page and bug report Sentry integration - DefaultErrorPage: show Sentry event ID, offer one-click feedback submission for in-field crash reports - BugReportModal: prefer Sentry feedback dialog over GitHub issues when Sentry is configured; include all bug report fields - App.tsx: wrap router in Sentry ErrorBoundary for automatic capture of unhandled React rendering errors --- src/app/components/DefaultErrorPage.tsx | 58 ++++++-- .../features/bug-report/BugReportModal.tsx | 137 +++++++++++++++++- src/app/pages/App.tsx | 13 +- 3 files changed, 188 insertions(+), 20 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(''); @@ -100,6 +104,12 @@ function BugReportModal() { // Shared optional field const [context, setContext] = useState(''); + // Sentry integration options + const [sendToSentry, setSendToSentry] = useState(true); + const [includeDebugLogs, setIncludeDebugLogs] = useState(true); + // When Sentry is enabled, GitHub is opt-in; when disabled, GitHub is always used + const [openOnGitHub, setOpenOnGitHub] = useState(!sentryEnabled); + const [similarIssues, setSimilarIssues] = useState([]); const [searching, setSearching] = useState(false); @@ -141,12 +151,74 @@ function BugReportModal() { const handleSubmit = () => { if (!canSubmit) return; + const fields: Record = type === 'bug' ? { description, reproduction, 'expected-behavior': expectedBehavior, context } : { problem, solution, alternatives, context }; - const url = buildGitHubUrl(type, title.trim(), fields); - window.open(url, '_blank', 'noopener,noreferrer'); + + // Send to Sentry if bug report and option is enabled + if (sendToSentry && type === 'bug') { + const debugLogger = getDebugLogger(); + + // Attach recent logs if user opted in + if (includeDebugLogs) { + debugLogger.attachLogsToSentry(100); + } + + const version = `v${APP_VERSION}${IS_RELEASE_TAG ? '' : '-dev'}${BUILD_HASH ? ` (${BUILD_HASH})` : ''}`; + + // Build a fully self-contained message so all fields are visible + // directly in the Sentry issue detail without digging into sub-sections. + const sentryMessage = [ + `[Bug Report] ${title.trim()}`, + '', + `Description:\n${description}`, + reproduction ? `\nSteps to Reproduce:\n${reproduction}` : '', + expectedBehavior ? `\nExpected Behavior:\n${expectedBehavior}` : '', + context ? `\nAdditional Context:\n${context}` : '', + `\nEnvironment: ${version} · ${navigator.platform}`, + ] + .filter(Boolean) + .join('\n'); + + const eventId = Sentry.captureMessage(sentryMessage, { + level: 'info', + // Group all user bug reports together in Sentry Issues + fingerprint: ['bug-report-modal'], + tags: { + source: 'bug-report-modal', + reportType: type, + }, + extra: { + title: title.trim(), + description, + reproduction: reproduction || '(not provided)', + expectedBehavior: expectedBehavior || '(not provided)', + context: context || '(not provided)', + userAgent: navigator.userAgent, + platform: navigator.platform, + version, + }, + }); + + // Also send as User Feedback so it appears in the Sentry Feedback section + if (eventId) { + Sentry.captureFeedback({ + message: sentryMessage, + name: 'User Bug Report', + associatedEventId: eventId, + }); + } + } + + // Feature requests always go to GitHub; bugs go to GitHub only when Sentry + // is unavailable or the user explicitly opts in. + const shouldOpenGitHub = type === 'feature' || !sentryEnabled || openOnGitHub; + if (shouldOpenGitHub) { + const url = buildGitHubUrl(type, title.trim(), fields); + window.open(url, '_blank', 'noopener,noreferrer'); + } close(); }; @@ -352,6 +424,63 @@ function BugReportModal() { /> + {/* Sentry integration options (only for bug reports when Sentry is configured) */} + {type === 'bug' && sentryEnabled && ( + + Error Tracking + + setSendToSentry((v) => !v)} + /> + + + Send anonymous report to Sentry for error tracking + + + Helps developers identify and fix issues faster. No personal data is + sent. + + + + {sendToSentry && ( + + setIncludeDebugLogs((v) => !v)} + /> + + Include recent debug logs (last 100 entries) + + Provides additional context to help diagnose the issue. Logs are + filtered for sensitive data. + + + + )} + + setOpenOnGitHub((v) => !v)} + /> + + Also create a GitHub issue + + Opens a pre-filled GitHub issue in addition to the Sentry report. + + + + + )} + {/* Actions */} diff --git a/src/app/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 df2693b6c682a22e18744b177d4009a71f4ad324 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 01:40:39 -0400 Subject: [PATCH 04/25] feat(sentry): performance instrumentation across sync and messaging Add Sentry spans and custom metrics throughout the critical paths: - ClientRoot: set user context (hashed MXID) and identify session - ClientNonUIFeatures: instrument push notification handling - DirectDMsList: DM sync performance metrics - RoomTimeline: spans for jump-load and pagination with direction and encryption attributes; sable.timeline.jump_load_ms metric - RoomInput: message send span with threading context - EncryptedContent: decryption latency span per event - matrix.ts / room.ts: Matrix API call instrumentation - slidingSync.ts: sliding sync cycle performance spans --- 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 | 39 ++++++++++++++++ src/app/pages/client/ClientRoot.tsx | 44 ++++++++++++++++--- .../pages/client/sidebar/DirectDMsList.tsx | 15 ++++++- src/app/utils/matrix.ts | 18 +++++++- src/app/utils/room.ts | 18 +++++++- src/client/slidingSync.ts | 16 ++++++- 9 files changed, 200 insertions(+), 12 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index b280cf5d5..611cf4925 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, @@ -665,20 +666,32 @@ export const RoomInput = forwardRef( // Cancel failed — leave state intact for retry } } else { + const msgSendStart = performance.now(); resetInput(); debugLog.info('message', 'Sending message', { roomId, msgtype: (content as any).msgtype }); - mx.sendMessage(roomId, threadRootId ?? null, content as any) + Sentry.startSpan( + { name: 'message.send', op: 'matrix.message', attributes: { encrypted: String(isEncrypted) } }, + () => mx.sendMessage(roomId, threadRootId ?? null, content as any) + ) .then((res) => { debugLog.info('message', 'Message sent successfully', { roomId, eventId: res.event_id, }); + Sentry.metrics.distribution( + 'sable.message.send_latency_ms', + performance.now() - msgSendStart, + { attributes: { encrypted: String(isEncrypted) } } + ); }) .catch((error: unknown) => { debugLog.error('message', 'Failed to send message', { roomId, error: error instanceof Error ? error.message : String(error), }); + Sentry.metrics.count('sable.message.send_error', 1, { + attributes: { encrypted: String(isEncrypted) }, + }); log.error('failed to send message', { roomId }, error); }); } diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 6365f851f..8945912bb 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -36,6 +36,7 @@ import { ReactEditor } from 'slate-react'; import { Editor } from 'slate'; import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; import to from 'await-to-js'; +import * as Sentry from '@sentry/react'; import { useAtomValue, useSetAtom } from 'jotai'; import { as, @@ -291,6 +292,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(() => { @@ -336,7 +339,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] ); @@ -414,6 +422,7 @@ const useTimelinePagination = ( }); } try { + const paginateStart = performance.now(); const [err] = await to( mx.paginateEventTimeline(timelineToPaginate, { backwards, @@ -423,6 +432,9 @@ const useTimelinePagination = ( if (err) { if (alive()) { (backwards ? setBackwardStatus : setForwardStatus)('error'); + Sentry.metrics.count('sable.pagination.error', 1, { + attributes: { direction: backwards ? 'backward' : 'forward' }, + }); debugLog.error('timeline', 'Timeline pagination failed', { direction: backwards ? 'backward' : 'forward', error: err instanceof Error ? err.message : String(err), @@ -445,6 +457,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), @@ -841,6 +863,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, @@ -1080,6 +1110,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..140c513a1 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: @@ -619,6 +637,26 @@ function SlidingSyncActiveRoomSubscriber() { return null; } +function SentryTagsFeature() { + const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); + const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); + const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); + const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); + const [pageZoom] = useSetting(settingsAtom, 'pageZoom'); + const [themeId] = useSetting(settingsAtom, 'themeId'); + + useEffect(() => { + Sentry.setTag('message_layout', String(messageLayout)); + Sentry.setTag('message_spacing', String(messageSpacing)); + Sentry.setTag('twitter_emoji', String(twitterEmoji)); + Sentry.setTag('is_markdown', String(isMarkdown)); + Sentry.setTag('page_zoom', String(pageZoom)); + if (themeId) Sentry.setTag('theme_id', themeId); + }, [messageLayout, messageSpacing, twitterEmoji, isMarkdown, pageZoom, themeId]); + + return null; +} + function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); @@ -649,6 +687,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} ); 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/matrix.ts b/src/app/utils/matrix.ts index 69fadc021..abc155978 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -19,6 +19,7 @@ import { IImageInfo, IThumbnailContent, IVideoInfo } from '$types/matrix/common' import { AccountDataEvent } from '$types/matrix/accountData'; import { Membership, MessageEvent, StateEvent } from '$types/matrix/room'; import { getEventReactions, getReactionContent, getStateEvent } from './room'; +import * as Sentry from '@sentry/react'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -163,6 +164,7 @@ export const uploadContent = async ( ) => { const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; + const uploadStart = performance.now(); const uploadPromise = mx.uploadContent(file, { name, type: fileType, @@ -173,9 +175,21 @@ export const uploadContent = async ( try { const data = await uploadPromise; const mxc = data.content_uri; - if (mxc) onSuccess(mxc); - else onError(new MatrixError(data)); + if (mxc) { + const mediaType = file.type.split('/')[0] || 'unknown'; + Sentry.metrics.distribution('sable.media.upload_latency_ms', performance.now() - uploadStart, { + attributes: { type: mediaType }, + }); + Sentry.metrics.distribution('sable.media.upload_bytes', file.size, { + attributes: { type: mediaType }, + }); + onSuccess(mxc); + } else { + Sentry.metrics.count('sable.media.upload_error', 1, { attributes: { reason: 'no_uri' } }); + onError(new MatrixError(data)); + } } catch (e: any) { + Sentry.metrics.count('sable.media.upload_error', 1, { attributes: { reason: 'exception' } }); const error = typeof e?.message === 'string' ? e.message : undefined; const errcode = typeof e?.name === 'string' ? e.message : undefined; onError(new MatrixError({ error, errcode })); diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 21fa6e290..4a421b81e 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -30,6 +30,7 @@ import { StateEvent, UnreadInfo, } from '$types/matrix/room'; +import * as Sentry from '@sentry/react'; export const getStateEvent = ( room: Room, @@ -557,7 +558,22 @@ export const decryptAllTimelineEvent = async (mx: MatrixClient, timeline: EventT .filter((event) => event.isEncrypted()) .reverse() .map((event) => event.attemptDecryption(crypto as CryptoBackend, { isRetry: true })); - await Promise.allSettled(decryptionPromises); + const decryptStart = performance.now(); + await Sentry.startSpan( + { + name: 'decrypt.bulk', + op: 'matrix.crypto', + attributes: { event_count: decryptionPromises.length }, + }, + () => Promise.allSettled(decryptionPromises) + ); + if (decryptionPromises.length > 0) { + Sentry.metrics.distribution( + 'sable.decryption.bulk_latency_ms', + performance.now() - decryptStart, + { attributes: { event_count: String(decryptionPromises.length) } } + ); + } }; export const getReactionContent = (eventId: string, key: string, shortcode?: string) => ({ diff --git a/src/client/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, From d9bffa62f4805e8c27bf74ff14d415279d4e79be Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 01:40:44 -0400 Subject: [PATCH 05/25] docs(sentry): integration guide and CI environment configuration - docs/SENTRY_INTEGRATION.md: comprehensive guide covering setup, environment variables, sampling rates, privacy configuration, and self-hosting considerations - cloudflare-web-deploy.yml: pass VITE_SENTRY_DSN and VITE_SENTRY_ENVIRONMENT=production to deploy workflow - prepare-tofu/action.yml: forward Sentry env vars through OpenTofu infrastructure actions - changesets for Sentry integration and improved crash page --- .changeset/error_page_with_report.md | 5 + .changeset/feat-sentry-integration.md | 5 + .github/actions/prepare-tofu/action.yml | 10 +- .github/workflows/cloudflare-web-deploy.yml | 14 + Caddyfile | 4 +- contrib/nginx/cinny.domain.tld.conf | 3 + docs/SENTRY_INTEGRATION.md | 325 ++++++++++++++++++++ docs/SENTRY_PRIVACY.md | 199 ++++++++++++ vite.config.ts | 17 + 9 files changed, 579 insertions(+), 3 deletions(-) create mode 100644 .changeset/error_page_with_report.md create mode 100644 .changeset/feat-sentry-integration.md create mode 100644 docs/SENTRY_INTEGRATION.md create mode 100644 docs/SENTRY_PRIVACY.md diff --git a/.changeset/error_page_with_report.md b/.changeset/error_page_with_report.md new file mode 100644 index 000000000..bb4832639 --- /dev/null +++ b/.changeset/error_page_with_report.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +added error page making it easier to report errors when they occur in the field diff --git a/.changeset/feat-sentry-integration.md b/.changeset/feat-sentry-integration.md new file mode 100644 index 000000000..ff50d8ff3 --- /dev/null +++ b/.changeset/feat-sentry-integration.md @@ -0,0 +1,5 @@ +--- +'default': minor +--- + +Add Sentry integration for error tracking and bug reporting diff --git a/.github/actions/prepare-tofu/action.yml b/.github/actions/prepare-tofu/action.yml index ba818c54c..c64332e3c 100644 --- a/.github/actions/prepare-tofu/action.yml +++ b/.github/actions/prepare-tofu/action.yml @@ -16,10 +16,16 @@ runs: steps: - name: Setup app and build uses: ./.github/actions/setup - env: - VITE_IS_RELEASE_TAG: ${{ inputs.is_release_tag }} with: build: 'true' + env: + VITE_IS_RELEASE_TAG: ${{ inputs.is_release_tag }} + VITE_SENTRY_DSN: ${{ env.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: ${{ env.VITE_SENTRY_ENVIRONMENT }} + VITE_APP_VERSION: ${{ env.VITE_APP_VERSION }} + SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ env.SENTRY_ORG }} + SENTRY_PROJECT: ${{ env.SENTRY_PROJECT }} - name: Setup OpenTofu uses: opentofu/setup-opentofu@9d84900f3238fab8cd84ce47d658d25dd008be2f # v1.0.8 diff --git a/.github/workflows/cloudflare-web-deploy.yml b/.github/workflows/cloudflare-web-deploy.yml index 3af285f73..413b7104a 100644 --- a/.github/workflows/cloudflare-web-deploy.yml +++ b/.github/workflows/cloudflare-web-deploy.yml @@ -56,6 +56,13 @@ jobs: uses: ./.github/actions/prepare-tofu with: is_release_tag: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.git_tag != '') }} + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: production + VITE_APP_VERSION: ${{ github.ref_name }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - name: Comment PR plan uses: dflook/tofu-plan@3f5dc358343fb58cd60f83b019e810315aa8258f # v2.2.3 @@ -82,6 +89,13 @@ jobs: uses: ./.github/actions/prepare-tofu with: is_release_tag: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.git_tag != '') }} + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: production + VITE_APP_VERSION: ${{ github.ref_name }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - name: Plan infrastructure run: tofu plan -input=false -no-color diff --git a/Caddyfile b/Caddyfile index d807e8c2b..97a13e732 100644 --- a/Caddyfile +++ b/Caddyfile @@ -15,4 +15,6 @@ } try_files {path} /index.html -} + + # Required for Sentry browser profiling (JS Self-Profiling API) + header Document-Policy "js-profiling" diff --git a/contrib/nginx/cinny.domain.tld.conf b/contrib/nginx/cinny.domain.tld.conf index 02c7ead9f..9dcdbb4b7 100644 --- a/contrib/nginx/cinny.domain.tld.conf +++ b/contrib/nginx/cinny.domain.tld.conf @@ -20,6 +20,9 @@ server { location / { root /opt/cinny/dist/; + # Required for Sentry browser profiling (JS Self-Profiling API) + add_header Document-Policy "js-profiling" always; + rewrite ^/config.json$ /config.json break; rewrite ^/manifest.json$ /manifest.json break; diff --git a/docs/SENTRY_INTEGRATION.md b/docs/SENTRY_INTEGRATION.md new file mode 100644 index 000000000..27b332e30 --- /dev/null +++ b/docs/SENTRY_INTEGRATION.md @@ -0,0 +1,325 @@ +# Sentry Integration for Sable + +This document describes the Sentry error tracking and monitoring integration added to Sable. +For a detailed breakdown of what data is collected and how it is protected, see [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md). + +## Overview + +Sentry is integrated with Sable to provide: + +- **Error tracking**: Automatic capture and reporting of errors and exceptions +- **Performance monitoring**: Track application performance and identify bottlenecks +- **User feedback**: Collect bug reports with context from users +- **Session replay**: Record user sessions (with privacy controls) for debugging +- **Breadcrumbs**: Track user actions leading up to errors +- **Debug log integration**: Attach internal debug logs to error reports + +## Features + +### 1. Automatic Error Tracking + +All errors are automatically captured and sent to Sentry with: + +- Stack traces +- User context (anonymized) +- Device and browser information +- Recent breadcrumbs (user actions) +- Debug logs (when enabled) + +### 2. Debug Logger Integration + +The internal debug logger now integrates with Sentry: + +- **Breadcrumbs**: All debug logs are added as breadcrumbs for context +- **Error capture**: Errors logged to the debug logger are automatically sent to Sentry +- **Warning sampling**: 10% of warnings are sent to Sentry to avoid overwhelming the system +- **Log attachment**: Recent logs can be attached to bug reports for additional context + +Key integration points: + +- `src/app/utils/debugLogger.ts` - Enhanced with Sentry breadcrumb and error capture +- Automatic breadcrumb creation for all log entries +- Error objects in log data are captured as exceptions +- 10% sampling rate for warnings to control volume + +### 3. Bug Report Modal Integration + +The bug report modal (`/bugreport` command or "Bug Report" button) now includes: + +- **Optional Sentry reporting**: Checkbox to send anonymous reports to Sentry +- **Debug log attachment**: Option to include recent debug logs (last 100 entries) +- **User feedback API**: Bug reports are sent as Sentry user feedback for better visibility +- **Privacy controls**: Users can opt-out of Sentry reporting + +Integration points: + +- `src/app/features/bug-report/BugReportModal.tsx` - Added Sentry options and submission logic +- Automatically attaches platform info, version, and user agent +- Links bug reports to Sentry events for tracking + +### 4. Privacy & Security + +Comprehensive data scrubbing (full details in [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md)): + +- **Token masking**: All access tokens, passwords, and authentication data are redacted +- **Matrix ID anonymization**: User IDs, room IDs, and event IDs are masked +- **Session replay privacy**: All text, media, and form inputs are masked when replay is enabled +- **request header sanitization**: Authorization headers are removed +- **User opt-out**: Users can disable Sentry entirely via settings + +Sensitive patterns automatically redacted: + +- `access_token`, `password`, `token`, `refresh_token` +- `session_id`, `sync_token`, `next_batch` +- Matrix user IDs (`@user:server`) +- Matrix room IDs (`!room:server`) +- Matrix event IDs (`$event_id`) + +### 5. Settings UI + +New Sentry settings panel in Developer Tools: + +- **Enable/disable Sentry**: Toggle error tracking on/off +- **Session replay control**: Enable/disable session recording (opt-in) +- **Breadcrumb categories**: Granular control over which log categories are sent as breadcrumbs +- **Session stats**: Live error/warning counts for the current page load +- **Export debug logs**: Download the in-memory log buffer as JSON for offline analysis +- **Attach debug logs**: Manually attach recent logs to next error + +Access via: Settings → Developer Tools → Error Tracking (Sentry) + +## Configuration + +### Environment Variables + +Configure Sentry via environment variables: + +```env +# Required: Your Sentry DSN (if not set, Sentry is disabled) +VITE_SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id + +# Required: Environment name - controls sampling rates +# - "production" = 10% trace/replay sampling (cost-effective for production) +# - "preview" = 100% trace/replay sampling (full debugging for PR previews) +# - "development" = 100% trace/replay sampling (full debugging for local dev) +VITE_SENTRY_ENVIRONMENT=production + +# Optional: Release version for tracking (defaults to VITE_APP_VERSION) +VITE_SENTRY_RELEASE=1.7.0 + +# Optional: For uploading source maps to Sentry (CI/CD only) +SENTRY_AUTH_TOKEN=your-sentry-auth-token +SENTRY_ORG=your-org-slug +SENTRY_PROJECT=your-project-slug +``` + +### Deployment Configuration + +**Production deployment (from `dev` branch):** + +- Set `VITE_SENTRY_ENVIRONMENT=production` +- Gets 10% sampling for traces and session replay +- Cost-effective for production usage +- Configured in `.github/workflows/cloudflare-web-deploy.yml` + +**Preview deployments (PR previews, Cloudflare Pages):** + +- Set `VITE_SENTRY_ENVIRONMENT=preview` +- Gets 100% sampling for traces and session replay +- Full debugging capabilities for testing +- Configured in `.github/workflows/cloudflare-web-preview.yml` + +**Local development:** + +- `VITE_SENTRY_ENVIRONMENT` not set (defaults to `development` via Vite MODE) +- Gets 100% sampling for traces and session replay +- Full debugging capabilities + +**Sampling rates by environment:** + +``` +Environment | Traces | Profiles | Session Replay | Error Replay +---------------|--------|----------|----------------|------------- +production | 10% | 10% | 10% | 100% +preview | 100% | 100% | 100% | 100% +development | 100% | 100% | 100% | 100% +``` + +> **Browser profiling requires a `Document-Policy: js-profiling` response header** on your HTML document. +> This is already included in the provided `Caddyfile` and nginx config. For other servers, add the header to +> the response serving `index.html`. + +### User Preferences + +Users can control Sentry via localStorage: + +```javascript +// Disable Sentry entirely (requires page refresh) +localStorage.setItem('sable_sentry_enabled', 'false'); + +// Disable session replay only (requires page refresh) +localStorage.setItem('sable_sentry_replay_enabled', 'false'); +``` + +Or use the UI in Settings → Developer Tools → Error Tracking (Sentry). + +## Implementation Details + +### Files Modified + +1. **`src/instrument.ts`** + - Enhanced Sentry initialization with privacy controls + - Added user preference checks + - Improved data scrubbing for Matrix-specific data + - Conditional session replay based on user settings + +2. **`src/app/utils/debugLogger.ts`** + - Added Sentry import + - New `sendToSentry()` method for breadcrumbs and error capture + - New `exportLogsForSentry()` method + - New `attachLogsToSentry()` method + - Integrated into main `log()` method + +3. **`src/app/features/bug-report/BugReportModal.tsx`** + - Added Sentry and debug logger imports + - New state for Sentry options (`sendToSentry`, `includeDebugLogs`) + - Enhanced `handleSubmit()` with Sentry user feedback + - New UI checkboxes for Sentry options + +4. **`src/app/features/settings/developer-tools/SentrySettings.tsx`** _(new file)_ + - New settings panel component + - Controls for Sentry and session replay + - Manual log attachment + +5. **`src/app/features/settings/developer-tools/DevelopTools.tsx`** + - Added SentrySettings import and component + +### Sentry Configuration + +- **Tracing sample rate**: 100% in development, 10% in production +- **Session replay sample rate**: 10% of all sessions, 100% of error sessions +- **Warning capture rate**: 10% to avoid overwhelming Sentry +- **Breadcrumb retention**: All breadcrumbs retained for context +- **Log attachment limit**: Last 100 debug log entries + +### Performance Considerations + +- Breadcrumbs are added synchronously but are low-overhead +- Error capture is asynchronous and non-blocking +- Warning sampling (10%) prevents excessive Sentry usage +- Session replay only captures when enabled by user +- Debug log attachment limited to most recent entries + +## Usage Examples + +### For Developers + +```typescript +import { getDebugLogger } from '$utils/debugLogger'; + +// Errors are automatically sent to Sentry +const logger = createDebugLogger('myNamespace'); +logger.error('sync', 'Sync failed', error); // Sent to Sentry + +// Manually attach logs before capturing an error +const debugLogger = getDebugLogger(); +debugLogger.attachLogsToSentry(100); +Sentry.captureException(error); +``` + +### For Users + +1. **Report a bug with Sentry**: + - Type `/bugreport` or click "Bug Report" button + - Fill in the form + - Check "Send anonymous report to Sentry" + - Check "Include recent debug logs" for more context + - Submit + +2. **Disable Sentry**: + - Go to Settings → Developer Tools + - Enable Developer Tools + - Scroll to "Error Tracking (Sentry)" + - Toggle off "Enable Sentry Error Tracking" + - Refresh the page + +## Benefits + +### For Users + +- Better bug tracking and faster fixes +- Optional participation with privacy controls +- Transparent data usage + +### For Developers + +- Real-time error notifications +- Rich context with breadcrumbs and logs +- Performance monitoring +- User feedback integrated with errors +- Replay sessions to reproduce bugs + +## Privacy Commitment + +See [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md) for a complete, code-linked breakdown of what is collected, what is masked, and how user controls work. + +In summary, all data sent to Sentry is: + +- **Opt-in by default** but can be disabled +- **Anonymized**: No personal data or message content +- **Filtered**: Tokens, passwords, and IDs are redacted +- **Minimal**: Only error context and debug info +- **Transparent**: Users can see what's being sent + +No message content, room conversations, or personal information is ever sent to Sentry. + +## Testing + +To test the integration: + +1. **Test error reporting**: + - Go to Settings → Developer Tools → Error Tracking + - Check that Sentry is enabled and `VITE_SENTRY_DSN` is set + - Open the browser console and run: `window.Sentry?.captureMessage('Test message')` + - Check the Sentry dashboard for the event + +2. **Test bug report integration**: + - Type `/bugreport` + - Fill in form with test data + - Enable "Send anonymous report to Sentry" + - Submit and check Sentry + +3. **Test privacy controls**: + - Disable Sentry in settings + - Refresh page + - Trigger an error (should not appear in Sentry) + - Re-enable and verify errors are captured again + +## Troubleshooting + +### Sentry not capturing errors + +1. Check that `VITE_SENTRY_DSN` is set +2. Check that Sentry is enabled in settings +3. Check browser console for Sentry initialization message +4. Verify network requests to Sentry are not blocked + +### Sensitive data in reports + +1. Check `beforeSend` hook in `instrument.ts` +2. Add new patterns to the scrubbing regex +3. Test with actual data to verify masking + +### Performance impact + +1. Reduce tracing sample rate in production +2. Disable session replay if not needed +3. Monitor Sentry quota usage +4. Adjust warning sampling rate + +## Resources + +- [Sentry React Documentation](https://docs.sentry.io/platforms/javascript/guides/react/) +- [Sentry Error Monitoring Best Practices](https://docs.sentry.io/product/error-monitoring/) +- [Sentry Session Replay](https://docs.sentry.io/product/session-replay/) +- [Sentry User Feedback](https://docs.sentry.io/product/user-feedback/) diff --git a/docs/SENTRY_PRIVACY.md b/docs/SENTRY_PRIVACY.md new file mode 100644 index 000000000..cd62bd929 --- /dev/null +++ b/docs/SENTRY_PRIVACY.md @@ -0,0 +1,199 @@ +# Sentry Privacy Policy + +This document describes exactly what data the Sentry integration collects, what +is masked or blocked, and where the relevant code lives. For setup and +configuration details see [SENTRY_INTEGRATION.md](./SENTRY_INTEGRATION.md). + +--- + +## What Is Collected + +Sentry is **disabled by default when no DSN is configured** and can be **opted +out by users** at any time via Settings → Developer Tools → Error Tracking. + +When enabled, the following categories of data are sent: + +### Error Reports + +- Exception type and stack trace (function names, file names, line numbers) +- Error message text — scrubbed of tokens and Matrix IDs before sending (see + [What Is Scrubbed](#what-is-scrubbed)) +- Browser and OS name/version +- JavaScript engine version +- Application release version (`VITE_APP_VERSION`) +- Sentry environment tag (`VITE_SENTRY_ENVIRONMENT`) +- Current URL path — tokens in query strings are redacted before sending + +**Code:** `src/instrument.ts` — `beforeSend` callback + +### Breadcrumbs (Action Trail) + +Leading up to an error, Sentry records a trail of recent user actions: + +- Navigation events (route changes) +- `console.error` and `console.warn` calls — filtered for sensitive patterns + before sending +- Internal debug log entries (category, level, summary message) — filtered + before sending + +Breadcrumbs containing any of the patterns listed in +[What Is Scrubbed](#what-is-scrubbed) are sanitised in-place before leaving the +browser. + +**Code:** `src/instrument.ts` — `beforeBreadcrumb` callback +**Code:** `src/app/utils/debugLogger.ts` — Sentry breadcrumb integration + +### Performance Traces + +- Timing of React Router navigations (page-load and route-change latency) +- Custom spans for Matrix sync cycles, message send, and room data loading +- JavaScript CPU profiles during traced transactions (call-stack samples) + +Performance data contains **no message content, no room names, and no user +identifiers**. Spans are labelled with operation names only (e.g. +`matrix.sync`, `sable.message.send`). + +**Sample rates:** + +| Environment | Traces | Profiles | +|---------------------|--------|----------| +| `production` | 10% | 10% | +| `preview` / `development` | 100% | 100% | + +**Code:** `src/instrument.ts` — `tracesSampleRate`, `profilesSampleRate` +**Code:** `src/app/features/room/RoomInput.tsx` — message send span +**Code:** `src/client/matrix.ts`, `src/client/room.ts`, `src/client/slidingSync.ts` — sync/room spans + +### Custom Metrics + +- `sable.message.send_latency` — histogram of message send round-trip time (ms) +- `sable.message.send_error` — counter incremented on send failure + +These contain no message content or identifiers. + +**Code:** `src/app/features/room/RoomInput.tsx` + +### Session Replay *(opt-in, disabled by default)* + +When session replay is explicitly enabled by the user, Sentry records UI +interactions to help reproduce bugs. **All content is masked at the browser +level before any data leaves the device:** + +- All text on screen → replaced with `█` characters +- All images, video, and audio → blocked entirely (replaced with a grey box) +- All form inputs, including the message composer → replaced with `*` characters + +This means **no Matrix messages, no room names, no user display names, and no +media are ever visible in a replay**. + +Sample rates for replay: + +| Trigger | Production | Preview / Dev | +|-----------------------|------------|---------------| +| Regular sessions | 10% | 100% | +| Sessions with errors | 100% | 100% | + +**Code:** `src/instrument.ts` — `replayIntegration` call with `maskAllText`, +`blockAllMedia`, `maskAllInputs` + +### Bug Reports *(manual, opt-in per report)* + +When a user submits a bug report via `/bugreport` or the "Bug Report" button: + +- Free-text description written by the user +- Optional: recent debug log entries (last 100) attached as a file +- Platform info, browser version, application version +- Checkbox to send or not send to Sentry is **shown before submission** + +**Code:** `src/app/features/bug-report/BugReportModal.tsx` + +--- + +## What Is Never Collected + +- Matrix message content +- Room names or aliases +- User display names or avatars +- Contact lists or room member lists +- Encryption keys or session data +- IP addresses (`sendDefaultPii: false`) +- Authentication tokens (scrubbed — see below) + +--- + +## What Is Scrubbed + +All scrubbing happens **in the browser before data is transmitted**. Nothing +leaves the device in unredacted form. + +### Tokens and Credentials + +The following patterns are replaced with `[REDACTED]` in error messages, +exception values, breadcrumb messages, and request URLs: + +- `access_token` +- `password` +- `token` +- `refresh_token` +- `session_id` +- `sync_token` +- `next_batch` +- HTTP `Authorization` headers + +**Code:** `src/instrument.ts` — `beforeSend` and `beforeBreadcrumb` callbacks +Regex: `/(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:]\s*)([^\s&]+)/gi` + +### Matrix Identifiers + +Matrix IDs are replaced with placeholder tokens before sending: + +| Original form | Replaced with | +|-------------------|---------------| +| `@user:server` | `@[USER_ID]` | +| `!room:server` | `![ROOM_ID]` | +| `$event_id` | `$[EVENT_ID]` | + +**Code:** `src/instrument.ts` — `beforeSend` callback (applied to `event.message` +and all `event.exception.values`) + +--- + +## User Controls + +Users can adjust Sentry behaviour without restarting the app: + +| Setting | Location | `localStorage` key | Default | +|---------|----------|--------------------|---------| +| Disable Sentry entirely | Settings → Developer Tools → Error Tracking | `sable_sentry_enabled` | Enabled | +| Enable session replay | Settings → Developer Tools → Error Tracking | `sable_sentry_replay_enabled` | Disabled (opt-in) | +| Disable breadcrumb categories | Settings → Developer Tools → Error Tracking → Breadcrumb Categories | `sable_sentry_breadcrumb_disabled` | All enabled | + +**Rate limiting:** A maximum of 50 error events are forwarded to Sentry per page load (session). +Subsequent errors are silently dropped, protecting against quota exhaustion without affecting +in-app behaviour. Performance traces are not subject to this cap. + +Changes to Sentry enable/disable and session replay take effect after the next page refresh +(the SDK is initialised once at startup). Breadcrumb category changes take effect immediately. + +**Code:** `src/instrument.ts` — reads `localStorage` before `Sentry.init()`, enforces rate limit in `beforeSend` +**Code:** `src/app/features/settings/developer-tools/SentrySettings.tsx` — settings UI +**Code:** `src/app/utils/debugLogger.ts` — per-category breadcrumb filtering and session stats + +--- + +## Data Residency + +Sentry data is sent to the Sentry.io cloud service. The destination project is +configured by the operator via `VITE_SENTRY_DSN`. Self-hosted Sentry instances +are supported by changing the DSN. + +When `VITE_SENTRY_DSN` is not set, the integration is entirely inactive — no +code path in the Sentry SDK is reached and no data is transmitted. + +--- + +## Further Reading + +- [SENTRY_INTEGRATION.md](./SENTRY_INTEGRATION.md) — setup, configuration, environment variables, and deployment instructions +- [Sentry Privacy Policy](https://sentry.io/privacy/) — Sentry's own data handling commitments +- [Sentry Session Replay privacy documentation](https://docs.sentry.io/product/explore/session-replay/privacy/) — details on masking and blocking behaviour diff --git a/vite.config.ts b/vite.config.ts index d28133049..bfcc9205b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,7 @@ import fs from 'fs'; import path from 'path'; import { cloudflare } from '@cloudflare/vite-plugin'; import { createRequire } from 'module'; +import { sentryVitePlugin } from '@sentry/vite-plugin'; import buildConfig from './build.config'; const packageJson = JSON.parse( @@ -189,6 +190,22 @@ export default defineConfig({ ], include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|txt|map)$/, }), + // Sentry source map upload — only active when credentials are provided at build time + ...(process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT + ? [ + sentryVitePlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + sourcemaps: { + filesToDeleteAfterUpload: ['dist/**/*.map'], + }, + release: { + name: appVersion, + }, + }), + ] + : []), ], optimizeDeps: { // Rebuild dep optimizer cache on each dev start to avoid stale API shapes. From 7766aa8a541e1ccf7899e61c53231f26624a1c6d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 02:51:03 -0400 Subject: [PATCH 06/25] feat(sentry): expand settings tags and add matrix client context --- src/app/pages/client/ClientNonUIFeatures.tsx | 36 +++++++++++++------- src/app/pages/client/ClientRoot.tsx | 12 +++++++ 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 140c513a1..ea2406761 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -638,21 +638,31 @@ function SlidingSyncActiveRoomSubscriber() { } function SentryTagsFeature() { - const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); - const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); - const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); - const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); - const [pageZoom] = useSetting(settingsAtom, 'pageZoom'); - const [themeId] = useSetting(settingsAtom, 'themeId'); + const settings = useAtomValue(settingsAtom); useEffect(() => { - Sentry.setTag('message_layout', String(messageLayout)); - Sentry.setTag('message_spacing', String(messageSpacing)); - Sentry.setTag('twitter_emoji', String(twitterEmoji)); - Sentry.setTag('is_markdown', String(isMarkdown)); - Sentry.setTag('page_zoom', String(pageZoom)); - if (themeId) Sentry.setTag('theme_id', themeId); - }, [messageLayout, messageSpacing, twitterEmoji, isMarkdown, pageZoom, themeId]); + // Core rendering tags — indexed in Sentry for filtering/search + Sentry.setTag('message_layout', String(settings.messageLayout)); + Sentry.setTag('message_spacing', String(settings.messageSpacing)); + Sentry.setTag('twitter_emoji', String(settings.twitterEmoji)); + Sentry.setTag('is_markdown', String(settings.isMarkdown)); + Sentry.setTag('page_zoom', String(settings.pageZoom)); + if (settings.themeId) Sentry.setTag('theme_id', settings.themeId); + // Additional high-value tags for bug reproduction + Sentry.setTag('use_right_bubbles', String(settings.useRightBubbles)); + Sentry.setTag('reduced_motion', String(settings.reducedMotion)); + Sentry.setTag('send_presence', String(settings.sendPresence)); + Sentry.setTag('enter_for_newline', String(settings.enterForNewline)); + Sentry.setTag('media_auto_load', String(settings.mediaAutoLoad)); + Sentry.setTag('url_preview', String(settings.urlPreview)); + Sentry.setTag('use_system_theme', String(settings.useSystemTheme)); + Sentry.setTag('uniform_icons', String(settings.uniformIcons)); + Sentry.setTag('jumbo_emoji_size', String(settings.jumboEmojiSize)); + Sentry.setTag('caption_position', String(settings.captionPosition)); + Sentry.setTag('right_swipe_action', String(settings.rightSwipeAction)); + // Full settings snapshot as structured Additional Data on every event + Sentry.setContext('settings', { ...settings }); + }, [settings]); return null; } diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 9c7140040..3073fb347 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -299,6 +299,18 @@ export function ClientRoot({ children }: ClientRootProps) { ) ); + // Set matrix client context: homeserver and sync type (not PII) + useEffect(() => { + if (!activeSession?.baseUrl) return; + Sentry.setContext('client', { + homeserver: activeSession.baseUrl, + sliding_sync: clientConfig.slidingSync, + }); + return () => { + Sentry.setContext('client', null); + }; + }, [activeSession?.baseUrl, clientConfig.slidingSync]); + // Set a pseudonymous hashed user ID for error grouping — never sends raw Matrix ID useEffect(() => { if (!mx) return; From cb5686f05d7d0ac8f12742429a57b9dc636a371b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 03:18:27 -0400 Subject: [PATCH 07/25] feat(sentry): component names, login span, scoped boundaries, structured log attrs - vite.config.ts: enable reactComponentAnnotation in sentryVitePlugin so React component names appear in breadcrumbs, spans, and replay search instead of raw minified CSS selectors - debugLogger.ts: include flattened primitive fields from entry.data as searchable attributes in Sentry.logger.* structured log calls; previously only category/namespace were passed, dropping all contextual data - instrument.ts: add beforeSendLog to scrub Matrix IDs/tokens from structured log messages in production; drop debug-level logs in production; set global scope attributes (app.name, app.version) on all events/logs - loginUtil.ts: wrap login() in Sentry.startSpan({op: auth}) to track login latency and attach auth.method/auth.error/auth.success span attributes; covers both password and SSO token login paths - Router.tsx: add scoped Sentry.ErrorBoundary with beforeCapture section tags around auth routes (section=auth) and client routes (section=client) for better error grouping and filtering in Sentry --- src/app/pages/Router.tsx | 32 ++++++++-- src/app/pages/auth/login/loginUtil.ts | 88 +++++++++++++++------------ src/app/utils/debugLogger.ts | 11 +++- src/instrument.ts | 25 ++++++++ vite.config.ts | 4 ++ 5 files changed, 115 insertions(+), 45 deletions(-) diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index f14567f7d..71b72157b 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -6,8 +6,10 @@ import { createRoutesFromElements, redirect, } from 'react-router-dom'; +import * as Sentry from '@sentry/react'; import { ClientConfig } from '$hooks/useClientConfig'; +import { ErrorPage } from '$components/DefaultErrorPage'; import { Room } from '$features/room'; import { Lobby } from '$features/lobby'; import { PageRoot } from '$components/page'; @@ -117,10 +119,20 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) return null; }} element={ - <> - - - + ( + + )} + beforeCapture={(scope) => scope.setTag('section', 'auth')} + > + <> + + + + } > } /> @@ -142,7 +154,16 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) return null; }} element={ - + ( + + )} + beforeCapture={(scope) => scope.setTag('section', 'client')} + > + {/* HandleNotificationClick must live outside ClientRoot's loading gate so SW notification-click postMessages are never dropped during client reloads (e.g., account switches). It only needs navigate + Jotai atoms. */} @@ -196,6 +217,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + } > (mx.loginRequest(data)); - if (err) { - if (err.httpStatus === 400) { - debugLog.error('general', 'Login failed - invalid request', { httpStatus: 400 }); - throw new MatrixError({ - errcode: LoginError.InvalidRequest, - }); - } - if (err.httpStatus === 429) { - debugLog.error('general', 'Login failed - rate limited', { httpStatus: 429 }); - throw new MatrixError({ - errcode: LoginError.RateLimited, - }); - } - if (err.errcode === ErrorCode.M_USER_DEACTIVATED) { - debugLog.error('general', 'Login failed - user deactivated', { errcode: err.errcode }); - throw new MatrixError({ - errcode: LoginError.UserDeactivated, - }); - } + return Sentry.startSpan( + { name: 'auth.login', op: 'auth', attributes: { 'auth.method': data.type as string } }, + async (span) => { + const [err, res] = await to(mx.loginRequest(data)); - if (err.httpStatus === 403) { - debugLog.error('general', 'Login failed - forbidden', { httpStatus: 403 }); - throw new MatrixError({ - errcode: LoginError.Forbidden, - }); - } + if (err) { + span.setAttribute('auth.error', err.errcode ?? 'unknown'); + if (err.httpStatus === 400) { + debugLog.error('general', 'Login failed - invalid request', { httpStatus: 400 }); + throw new MatrixError({ + errcode: LoginError.InvalidRequest, + }); + } + if (err.httpStatus === 429) { + debugLog.error('general', 'Login failed - rate limited', { httpStatus: 429 }); + throw new MatrixError({ + errcode: LoginError.RateLimited, + }); + } + if (err.errcode === ErrorCode.M_USER_DEACTIVATED) { + debugLog.error('general', 'Login failed - user deactivated', { errcode: err.errcode }); + throw new MatrixError({ + errcode: LoginError.UserDeactivated, + }); + } - debugLog.error('general', 'Login failed - unknown error', { - error: err.message, - httpStatus: err.httpStatus, - }); - throw new MatrixError({ - errcode: LoginError.Unknown, - }); - } - debugLog.info('general', 'Login successful', { userId: res.user_id, deviceId: res.device_id }); - return { - baseUrl: url, - response: res, - }; + if (err.httpStatus === 403) { + debugLog.error('general', 'Login failed - forbidden', { httpStatus: 403 }); + throw new MatrixError({ + errcode: LoginError.Forbidden, + }); + } + + debugLog.error('general', 'Login failed - unknown error', { + error: err.message, + httpStatus: err.httpStatus, + }); + throw new MatrixError({ + errcode: LoginError.Unknown, + }); + } + + span.setAttribute('auth.success', true); + debugLog.info('general', 'Login successful', { userId: res.user_id, deviceId: res.device_id }); + return { + baseUrl: url, + response: res, + }; + } + ); }; export const useLoginComplete = (data?: CustomLoginResponse) => { diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts index 2ab138740..c00db022f 100644 --- a/src/app/utils/debugLogger.ts +++ b/src/app/utils/debugLogger.ts @@ -172,7 +172,16 @@ class DebugLoggerService { // 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 }; + // Flatten primitive values from entry.data so they become searchable attributes in Sentry Logs + const logDataAttrs: Record = {}; + if (entry.data && typeof entry.data === 'object' && !(entry.data instanceof Error)) { + for (const [k, v] of Object.entries(entry.data)) { + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + logDataAttrs[k] = v; + } + } + } + const logAttrs = { category: entry.category, namespace: entry.namespace, ...logDataAttrs }; if (entry.level === 'debug') Sentry.logger.debug(logMsg, logAttrs); else if (entry.level === 'info') Sentry.logger.info(logMsg, logAttrs); else if (entry.level === 'warn') Sentry.logger.warn(logMsg, logAttrs); diff --git a/src/instrument.ts b/src/instrument.ts index 29618dca1..98bd08ca0 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -86,6 +86,25 @@ if (dsn && sentryEnabled) { // Enable structured logging to Sentry enableLogs: true, + // Scrub sensitive data from structured logs before sending to Sentry + beforeSendLog(log) { + // Drop debug-level logs in production to reduce noise and quota usage + if (log.level === 'debug' && environment === 'production') return null; + // Redact Matrix IDs and tokens from log messages + if (typeof log.message === 'string') { + // eslint-disable-next-line no-param-reassign + log.message = log.message + .replace( + /(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:\s]+)([^\s&]+)/gi, + '$1$2[REDACTED]' + ) + .replace(/@[^:]+:[^\s]+/g, '@[USER_ID]') + .replace(/![^:]+:[^\s]+/g, '![ROOM_ID]') + .replace(/\$[^:\s]+/g, '$[EVENT_ID]'); + } + return log; + }, + // Rate limiting: cap error events per page-load session to avoid quota exhaustion. // Separate counters for errors and transactions so perf traces do not drain the error budget. beforeSendTransaction(event) { @@ -197,6 +216,12 @@ if (dsn && sentryEnabled) { }); // Expose Sentry globally for debugging and console testing + // Set app-wide attributes on the global scope so they appear on all events and logs + Sentry.getGlobalScope().setAttributes({ + 'app.name': 'sable', + 'app.version': release ?? 'unknown', + }); + // @ts-expect-error - Adding to window for debugging window.Sentry = Sentry; diff --git a/vite.config.ts b/vite.config.ts index bfcc9205b..bfca2edca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -203,6 +203,10 @@ export default defineConfig({ release: { name: appVersion, }, + // Annotate React components with data-sentry-* attributes at build + // time so Sentry can show component names in breadcrumbs, spans, + // and replay search instead of raw CSS selectors. + reactComponentAnnotation: { enabled: true }, }), ] : []), From ff7868b8f13bc6cc98ae2642a216f257e9f0e6e9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 03:25:25 -0400 Subject: [PATCH 08/25] feat(sentry): spans and metrics for sliding sync lifecycle slidingSync.ts: - Add `sync.initial` span from attach() to first SlidingSyncState.Complete, with attributes sync.cycles_to_ready and sync.rooms_at_ready. Replaces the misleading sable.sync.initial_ms metric which previously measured only the processing time within the lifecycle callback (effectively ~0ms) rather than the actual wall-clock wait from attach() to first data. - Fix sable.sync.initial_ms to use attachTime (wall-clock from attach()) so the metric reflects real user-perceived latency. - Add sable.sync.lists_loaded_ms distribution (attach() to all lists fully loaded) and sable.sync.total_rooms gauge emitted when listsFullyLoaded first becomes true. - Add sable.sync.active_subscriptions gauge in subscribeToRoom / unsubscribeFromRoom to track how many rooms have active subscriptions. - Wrap probe() static method in Sentry.startSpan (sync.probe, op=matrix.sync) with probe.supported span attribute. - Wrap startSpidering() loop in Sentry.startSpan (sync.spidering, op=matrix.sync) with spidering.batches and spidering.total_rooms attributes. initMatrix.ts: - Add sable.sync.transport count metric in startClassicSync and sliding sync active path, with transport, reason, and fallback attributes. Allows filtering Sentry dashboards by sync transport in use. --- src/client/initMatrix.ts | 7 ++ src/client/slidingSync.ts | 184 +++++++++++++++++++++++++------------- 2 files changed, 127 insertions(+), 64 deletions(-) diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 71bbc3167..b9a5369af 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -17,6 +17,7 @@ import { import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; +import * as Sentry from '@sentry/react'; import { pushSessionToSW } from '../sw-session'; import { cryptoCallbacks } from './secretStorageKeys'; import { SlidingSyncConfig, SlidingSyncDiagnostics, SlidingSyncManager } from './slidingSync'; @@ -390,6 +391,9 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): fallbackFromSliding, reason, }); + Sentry.metrics.count('sable.sync.transport', 1, { + attributes: { transport: 'classic', reason, fallback: String(fallbackFromSliding) }, + }); await mx.startClient({ lazyLoadMembers: true, pollTimeout: FAST_SYNC_POLL_TIMEOUT_MS, @@ -487,6 +491,9 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): fallbackFromSliding: false, reason: 'sliding_active', }); + Sentry.metrics.count('sable.sync.transport', 1, { + attributes: { transport: 'sliding', reason: 'sliding_active', fallback: 'false' }, + }); try { await mx.startClient({ diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 32a51ad58..9f60bbab7 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -325,6 +325,12 @@ export class SlidingSyncManager { private previousListCounts: Map = new Map(); + /** Wall-clock time recorded in attach() — used to compute true initial-sync latency. */ + private attachTime: number | null = null; + + /** Span covering the period from attach() to the first successful complete cycle. */ + private initialSyncSpan: ReturnType | null = null; + public readonly slidingSync: SlidingSync; public readonly probeTimeoutMs: number; @@ -435,7 +441,9 @@ export class SlidingSyncManager { // Mark initial sync as complete after first successful cycle if (!this.initialSyncCompleted) { this.initialSyncCompleted = true; - const initialElapsed = performance.now() - syncStartTime; + // Wall-clock ms from attach() — the actual user-perceived wait for first data. + const initialElapsed = + this.attachTime != null ? performance.now() - this.attachTime : syncDuration; debugLog.info('sync', 'Initial sync completed', { syncNumber: this.syncCount, totalRoomCount, @@ -447,6 +455,12 @@ export class SlidingSyncManager { Sentry.metrics.distribution('sable.sync.initial_ms', initialElapsed, { attributes: { transport: 'sliding' }, }); + this.initialSyncSpan?.setAttributes({ + 'sync.cycles_to_ready': this.syncCount, + 'sync.rooms_at_ready': totalRoomCount, + }); + this.initialSyncSpan?.end(); + this.initialSyncSpan = null; } this.expandListsToKnownCount(); @@ -516,6 +530,13 @@ export class SlidingSyncManager { lists: this.listKeys, }); + this.attachTime = performance.now(); + this.initialSyncSpan = Sentry.startInactiveSpan({ + name: 'sync.initial', + op: 'matrix.sync', + attributes: { 'sync.transport': 'sliding', 'sync.proxy': this.proxyBaseUrl }, + }); + this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle); const connection = ( typeof navigator !== 'undefined' ? (navigator as any).connection : undefined @@ -688,6 +709,18 @@ export class SlidingSyncManager { if (allListsComplete) { this.listsFullyLoaded = true; log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`); + const totalRooms = this.listKeys.reduce( + (sum, key) => sum + (this.slidingSync.getListData(key)?.joinedCount ?? 0), + 0 + ); + const listsLoadedMs = + this.attachTime != null ? Math.round(performance.now() - this.attachTime) : 0; + Sentry.metrics.distribution('sable.sync.lists_loaded_ms', listsLoadedMs, { + attributes: { transport: 'sliding' }, + }); + Sentry.metrics.gauge('sable.sync.total_rooms', totalRooms, { + attributes: { transport: 'sliding' }, + }); } else if (expandedAny) { log.log(`Sliding Sync lists expanding... for ${this.mx.getUserId()}`); } @@ -777,52 +810,61 @@ export class SlidingSyncManager { let endIndex = batchSize - 1; let hasMore = true; let firstTime = true; - - const spideringRequiredState: MSC3575List['required_state'] = [ - [EventType.RoomJoinRules, ''], - [EventType.RoomAvatar, ''], - [EventType.RoomTombstone, ''], - [EventType.RoomEncryption, ''], - [EventType.RoomCreate, ''], - [EventType.RoomTopic, ''], - [EventType.RoomCanonicalAlias, ''], - [EventType.RoomMember, MSC3575_STATE_KEY_ME], - ['m.space.child', MSC3575_WILDCARD], - ['im.ponies.room_emotes', MSC3575_WILDCARD], - ]; - - while (hasMore) { - if (this.disposed) return; - const ranges: [number, number][] = [[0, endIndex]]; - try { - if (firstTime) { - // Full setList on first call to register the list with all params. - this.slidingSync.setList(LIST_SEARCH, { - ranges, - sort: ['by_recency'], - timeline_limit: 0, - required_state: spideringRequiredState, - }); - } else { - // Cheaper range-only update for subsequent pages; sticky params are preserved. - this.slidingSync.setListRanges(LIST_SEARCH, ranges); + let batchCount = 0; + + await Sentry.startSpan( + { name: 'sync.spidering', op: 'matrix.sync', attributes: { 'sync.transport': 'sliding' } }, + async (span) => { + const spideringRequiredState: MSC3575List['required_state'] = [ + [EventType.RoomJoinRules, ''], + [EventType.RoomAvatar, ''], + [EventType.RoomTombstone, ''], + [EventType.RoomEncryption, ''], + [EventType.RoomCreate, ''], + [EventType.RoomTopic, ''], + [EventType.RoomCanonicalAlias, ''], + [EventType.RoomMember, MSC3575_STATE_KEY_ME], + ['m.space.child', MSC3575_WILDCARD], + ['im.ponies.room_emotes', MSC3575_WILDCARD], + ]; + + while (hasMore) { + if (this.disposed) return; + batchCount += 1; + const ranges: [number, number][] = [[0, endIndex]]; + try { + if (firstTime) { + // Full setList on first call to register the list with all params. + this.slidingSync.setList(LIST_SEARCH, { + ranges, + sort: ['by_recency'], + timeline_limit: 0, + required_state: spideringRequiredState, + }); + } else { + // Cheaper range-only update for subsequent pages; sticky params are preserved. + this.slidingSync.setListRanges(LIST_SEARCH, ranges); + } + } catch { + // Swallow errors — the next iteration will retry with updated ranges. + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((res) => { + setTimeout(res, gapBetweenRequestsMs); + }); + } + + if (this.disposed) return; + const listData = this.slidingSync.getListData(LIST_SEARCH); + hasMore = endIndex + 1 < (listData?.joinedCount ?? 0); + endIndex += batchSize; + firstTime = false; } - } catch { - // Swallow errors — the next iteration will retry with updated ranges. - } finally { - // eslint-disable-next-line no-await-in-loop - await new Promise((res) => { - setTimeout(res, gapBetweenRequestsMs); - }); + const finalCount = this.slidingSync.getListData(LIST_SEARCH)?.joinedCount ?? 0; + span.setAttributes({ 'spidering.batches': batchCount, 'spidering.total_rooms': finalCount }); + log.log(`Sliding Sync spidering complete for ${this.mx.getUserId()}`); } - - if (this.disposed) return; - const listData = this.slidingSync.getListData(LIST_SEARCH); - hasMore = endIndex + 1 < (listData?.joinedCount ?? 0); - endIndex += batchSize; - firstTime = false; - } - log.log(`Sliding Sync spidering complete for ${this.mx.getUserId()}`); + ); } /** @@ -887,6 +929,9 @@ export class SlidingSyncManager { } this.activeRoomSubscriptions.add(roomId); this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions)); + Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { + attributes: { transport: 'sliding' }, + }); log.log(`Sliding Sync active room subscription added: ${roomId}`); } @@ -899,6 +944,9 @@ export class SlidingSyncManager { if (this.disposed) return; this.activeRoomSubscriptions.delete(roomId); this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions)); + Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { + attributes: { transport: 'sliding' }, + }); log.log(`Sliding Sync active room subscription removed: ${roomId}`); } @@ -907,25 +955,33 @@ export class SlidingSyncManager { proxyBaseUrl: string, probeTimeoutMs: number ): Promise { - try { - const response = await mx.slidingSync( - { - lists: { - probe: { - ranges: [[0, 0]], - timeline_limit: 1, - required_state: [], + return Sentry.startSpan( + { name: 'sync.probe', op: 'matrix.sync', attributes: { 'sync.proxy': proxyBaseUrl } }, + async (span) => { + try { + const response = await mx.slidingSync( + { + lists: { + probe: { + ranges: [[0, 0]], + timeline_limit: 1, + required_state: [], + }, + }, + timeout: 0, + clientTimeout: probeTimeoutMs, }, - }, - timeout: 0, - clientTimeout: probeTimeoutMs, - }, - proxyBaseUrl - ); - - return typeof response.pos === 'string' && response.pos.length > 0; - } catch { - return false; - } + proxyBaseUrl + ); + + const supported = typeof response.pos === 'string' && response.pos.length > 0; + span.setAttribute('probe.supported', supported); + return supported; + } catch { + span.setAttribute('probe.supported', false); + return false; + } + } + ); } } From 580ac534d48b412d7d64b2eed9ca0bf56cc1ac31 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 03:34:10 -0400 Subject: [PATCH 09/25] feat(sentry): orphan/leak/long-task monitoring Add Sentry metrics to surface orphaned resources, growing caches, and unexpectedly long waits before they become user-visible problems. src/app/hooks/useBlobCache.ts - Export getBlobCacheStats() returning { cacheSize, inflightCount } from the module-level Maps so callsites can observe cache growth over time. src/app/state/callEmbed.ts - Import Sentry and track embedCreatedAt when a CallEmbed is promoted. - On disposal or replacement emit sable.call.embed_lifetime_ms (tagged disposed/replaced) to detect embeds whose dispose path was skipped. src/client/initMatrix.ts - Instrument waitForClientReady: emit sable.sync.client_ready_ms distribution with timed_out attribute on every call path. - When the timeout fires add a warning-level breadcrumb so a stuck-sync state is visible in the context of related error events. src/app/pages/client/ClientNonUIFeatures.tsx - Add HealthMonitor component rendered inside ClientNonUIFeatures. 60s setInterval emits: sable.media.blob_cache_size (gauge) - cached object-URL count sable.media.inflight_requests (gauge) - inflight fetches when > 0 Adds a warning breadcrumb when inflightCount >= 10 (stuck fetches). src/app/pages/client/BackgroundNotifications.tsx - Add Sentry import. - Emit sable.background.client_count gauge after each add and remove of a background Matrix client for multi-account visibility. --- src/app/hooks/useBlobCache.ts | 4 +++ .../pages/client/BackgroundNotifications.tsx | 3 +++ src/app/pages/client/ClientNonUIFeatures.tsx | 26 +++++++++++++++++++ src/app/state/callEmbed.ts | 16 ++++++++++++ src/client/initMatrix.ts | 22 +++++++++++++++- 5 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/app/hooks/useBlobCache.ts b/src/app/hooks/useBlobCache.ts index 7b40e292c..96ac18f74 100644 --- a/src/app/hooks/useBlobCache.ts +++ b/src/app/hooks/useBlobCache.ts @@ -3,6 +3,10 @@ import { useState, useEffect } from 'react'; const imageBlobCache = new Map(); const inflightRequests = new Map>(); +export function getBlobCacheStats(): { cacheSize: number; inflightCount: number } { + return { cacheSize: imageBlobCache.size, inflightCount: inflightRequests.size }; +} + export function useBlobCache(url?: string): string | undefined { const [cacheState, setCacheState] = useState<{ sourceUrl?: string; blobUrl?: string }>({ sourceUrl: url, diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 0a00fcc15..a3d10a1ba 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -39,6 +39,7 @@ import { buildRoomMessageNotification, resolveNotificationPreviewText, } from '$utils/notificationStyle'; +import * as Sentry from '@sentry/react'; import { startClient, stopClient } from '$client/initMatrix'; import { useClientConfig } from '$hooks/useClientConfig'; import { mobileOrTablet } from '$utils/user-agent'; @@ -214,6 +215,7 @@ export function BackgroundNotifications() { clientCleanupRef.current.delete(userId); stopClient(mx); current.delete(userId); + Sentry.metrics.gauge('sable.background.client_count', current.size); // Clear the background unread badge when this session is no longer a background account. setBackgroundUnreads((prev) => { const next = { ...prev }; @@ -232,6 +234,7 @@ export function BackgroundNotifications() { .then(async (mx) => { sessionMx = mx; current.set(session.userId, mx); + Sentry.metrics.gauge('sable.background.client_count', current.size); await waitForSync(mx); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index ea2406761..bf1774385 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -49,6 +49,7 @@ import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; import { getSlidingSyncManager } from '$client/initMatrix'; import { NotificationBanner } from '$components/notification-banner'; import { useCallSignaling } from '$hooks/useCallSignaling'; +import { getBlobCacheStats } from '$hooks/useBlobCache'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -546,6 +547,30 @@ function PrivacyBlurFeature() { return null; } +// Periodically emits memory-health gauges so Sentry dashboards can surface +// unbounded growth (e.g. blob cache never evicted, stale inflight requests). +function HealthMonitor() { + useEffect(() => { + const id = window.setInterval(() => { + const { cacheSize, inflightCount } = getBlobCacheStats(); + Sentry.metrics.gauge('sable.media.blob_cache_size', cacheSize); + if (inflightCount > 0) { + Sentry.metrics.gauge('sable.media.inflight_requests', inflightCount); + if (inflightCount >= 10) { + Sentry.addBreadcrumb({ + category: 'media', + message: `High inflight request count: ${inflightCount}`, + level: 'warning', + data: { inflight_count: inflightCount }, + }); + } + } + }, 60_000); + return () => window.clearInterval(id); + }, []); + return null; +} + type ClientNonUIFeaturesProps = { children: ReactNode; }; @@ -698,6 +723,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} ); diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts index 1452fb971..b05055c43 100644 --- a/src/app/state/callEmbed.ts +++ b/src/app/state/callEmbed.ts @@ -1,8 +1,12 @@ import { atom } from 'jotai'; +import * as Sentry from '@sentry/react'; import { CallEmbed } from '../plugins/call'; const baseCallEmbedAtom = atom(undefined); +// Tracks when the active call embed was created, for lifetime measurement. +let embedCreatedAt: number | null = null; + export const callEmbedAtom = atom( (get) => get(baseCallEmbedAtom), (get, set, callEmbed) => { @@ -10,9 +14,21 @@ export const callEmbedAtom = atom const waitForClientReady = (mx: MatrixClient, timeoutMs: number): Promise => new Promise((resolve) => { + const waitStart = performance.now(); if (isClientReadyForUi(mx.getSyncState())) { + Sentry.metrics.distribution('sable.sync.client_ready_ms', 0, { + attributes: { timed_out: 'false' }, + }); resolve(); return; } let timer = 0; + let timedOut = false; let finish = () => {}; const onSync = (state: string) => { debugLog.info('sync', `Sync state changed: ${state}`, { @@ -166,10 +171,25 @@ const waitForClientReady = (mx: MatrixClient, timeoutMs: number): Promise settled = true; mx.removeListener(ClientEvent.Sync, onSync); clearTimeout(timer); + const waitMs = performance.now() - waitStart; + Sentry.metrics.distribution('sable.sync.client_ready_ms', waitMs, { + attributes: { timed_out: String(timedOut) }, + }); + if (timedOut) { + Sentry.addBreadcrumb({ + category: 'sync', + message: 'waitForClientReady timed out — client may be stuck', + level: 'warning', + data: { timeout_ms: timeoutMs }, + }); + } resolve(); }; - timer = window.setTimeout(finish, timeoutMs); + timer = window.setTimeout(() => { + timedOut = true; + finish(); + }, timeoutMs); mx.on(ClientEvent.Sync, onSync); }); From 47c6462d2374bf1d42f3ce67ff7f308adb383a30 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 03:47:08 -0400 Subject: [PATCH 10/25] feat(sentry): improve event enrichment - beforeBreadcrumb: replace overly-broad single-char patterns ('@','!','$') with precise Matrix ID regexes that always apply to every breadcrumb message, ensuring @user:server, !room:server and $eventId are consistently redacted rather than only when the broad check happened to fire - beforeSend: fingerprint MatrixError instances by their errcode so that M_FORBIDDEN, M_NOT_FOUND, M_LIMIT_EXCEEDED etc. land in distinct Sentry issues instead of all being merged under the same stack-trace group - SentryRoomContextFeature: new component that tracks lastVisitedRoomIdAtom and writes a 'room' context (type, encrypted, member_count_range) plus room_type/room_encrypted tags to the scope whenever the active room changes; these appear on every subsequent error captured while the room is open - setUser: add serverDomain as 'username' alongside the hashed user ID so issues can be segmented by homeserver without exposing personal identifiers --- src/app/pages/client/ClientNonUIFeatures.tsx | 46 +++++++++++++ src/app/pages/client/ClientRoot.tsx | 5 +- src/instrument.ts | 68 ++++++++++---------- 3 files changed, 84 insertions(+), 35 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index bf1774385..fab2261e3 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -50,6 +50,7 @@ import { getSlidingSyncManager } from '$client/initMatrix'; import { NotificationBanner } from '$components/notification-banner'; import { useCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; +import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -662,6 +663,50 @@ function SlidingSyncActiveRoomSubscriber() { return null; } +/** + * Tracks the currently-viewed room and writes sanitised room metadata to the Sentry scope. + * This context appears on every subsequent error/transaction captured while the room is open, + * making room-specific bugs much easier to triage. + */ +function SentryRoomContextFeature() { + const mx = useMatrixClient(); + const mDirect = useAtomValue(mDirectAtom); + const roomId = useAtomValue(lastVisitedRoomIdAtom); + + useEffect(() => { + if (!roomId) { + Sentry.setContext('room', null); + Sentry.setTag('room_type', ''); + Sentry.setTag('room_encrypted', ''); + return; + } + const room = mx.getRoom(roomId); + if (!room) return; + + const isDm = mDirect.has(roomId); + const encrypted = mx.isRoomEncrypted(roomId); + const memberCount = room.getJoinedMemberCount(); + // Bucket member count so we can correlate issues with room scale + // without leaking precise membership numbers of private rooms. + const memberCountRange = + memberCount <= 2 ? '1-2' : + memberCount <= 10 ? '3-10' : + memberCount <= 50 ? '11-50' : + memberCount <= 200 ? '51-200' : '200+'; + + Sentry.setContext('room', { + type: isDm ? 'dm' : 'group', + encrypted, + member_count_range: memberCountRange, + }); + // Also set as tags so they can be used to filter events in Sentry + Sentry.setTag('room_type', isDm ? 'dm' : 'group'); + Sentry.setTag('room_encrypted', String(encrypted)); + }, [mx, mDirect, roomId]); + + return null; +} + function SentryTagsFeature() { const settings = useAtomValue(settingsAtom); @@ -722,6 +767,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 3073fb347..0326e3b86 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -325,7 +325,10 @@ export function ClientRoot({ children }: ClientRootProps) { .map((b) => b.toString(16).padStart(2, '0')) .join('') .slice(0, 16); - Sentry.setUser({ id: hashHex }); + // Include the homeserver domain as `username` — it is not PII (it is the server + // domain, not a personal identifier) and helps segment issues by deployment. + const serverDomain = userId.split(':')[1] ?? 'unknown'; + Sentry.setUser({ id: hashHex, username: serverDomain }); })(); return () => { Sentry.setUser(null); diff --git a/src/instrument.ts b/src/instrument.ts index 98bd08ca0..e7121da09 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -111,48 +111,48 @@ if (dsn && sentryEnabled) { return event; }, - // Filter sensitive data before sending to Sentry + // Sanitize sensitive data from all breadcrumb messages 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; + if (!breadcrumb.message) return breadcrumb; + // Always apply redaction — both token values and Matrix entity IDs. + // Do NOT use single-character patterns like '@', '!', '$' as they are far too broad. + const redacted = breadcrumb.message + // Redact token key=value pairs (e.g. access_token=abc123) + .replace( + /(access_token|password|refresh_token|device_id|session_id|sync_token|next_batch)([=:\s]+)([^\s&"']+)/gi, + '$1$2[REDACTED]' + ) + // Redact full Matrix user IDs: @localpart:server.tld + .replace(/@[^\s:@]+:[^\s,'"(){}\[\]]+/g, '@[USER_ID]') + // Redact full Matrix room IDs: !opaque:server.tld + .replace(/![^\s:]+:[^\s,'"(){}\[\]]+/g, '![ROOM_ID]') + // Redact Matrix event IDs: $base64Url (at least 10 chars to avoid false positives) + .replace(/\$[A-Za-z0-9\-_+/]{10,}/g, '$[EVENT_ID]'); + return redacted === breadcrumb.message ? breadcrumb : { ...breadcrumb, message: redacted }; }, - beforeSend(event) { + beforeSend(event, hint) { sessionErrorCount += 1; if (sessionErrorCount > SESSION_ERROR_LIMIT) { return null; // Drop event — session limit reached } + // Improve grouping for Matrix API errors. + // MatrixError objects carry an `errcode` (e.g. M_FORBIDDEN, M_NOT_FOUND) — use it to + // split errors into meaningful issue groups rather than merging them all by stack trace. + const originalException = hint?.originalException; + if ( + originalException !== null && + typeof originalException === 'object' && + 'errcode' in originalException && + typeof (originalException as Record).errcode === 'string' + ) { + const errcode = (originalException as Record).errcode as string; + // Preserve default grouping AND split by errcode + // eslint-disable-next-line no-param-reassign + event.fingerprint = ['{{ default }}', errcode]; + } + // Scrub sensitive data from error messages if (event.message) { if ( From d5f61b328d40cbea02756dcc61b94bbc6582a8ad Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 03:54:17 -0400 Subject: [PATCH 11/25] feat(sentry): monitor sync errors, forced logouts, and key backup failures - SyncStatus: add Sentry breadcrumb + sable.sync.degraded metric count when SyncState transitions to Reconnecting (warning) or Error (error); includes previous state in breadcrumb data for context - ClientRoot useLogoutListener: capture breadcrumb before clearing stores on HttpApiEvent.SessionLoggedOut (server-side forced session expiry/revocation) so the event appears in Sentry before the page reloads and session is lost - useKeyBackup useKeyBackupSync: add error breadcrumb + sable.crypto.key_backup_failures metric (keyed by errcode) when CryptoEvent.KeyBackupFailed fires, giving visibility into E2E key backup reliability --- src/app/hooks/useKeyBackup.ts | 10 ++++++++++ src/app/pages/client/ClientRoot.tsx | 5 +++++ src/app/pages/client/SyncStatus.tsx | 13 +++++++++++++ 3 files changed, 28 insertions(+) diff --git a/src/app/hooks/useKeyBackup.ts b/src/app/hooks/useKeyBackup.ts index 1cc531eda..3714ec6be 100644 --- a/src/app/hooks/useKeyBackup.ts +++ b/src/app/hooks/useKeyBackup.ts @@ -6,6 +6,7 @@ import { KeyBackupInfo, } from '$types/matrix-sdk'; import { useCallback, useEffect, useState } from 'react'; +import * as Sentry from '@sentry/react'; import { useMatrixClient } from './useMatrixClient'; import { useAlive } from './useAlive'; @@ -92,6 +93,15 @@ export const useKeyBackupSync = (): [number, string | undefined] => { useKeyBackupFailedChange( useCallback((f) => { if (typeof f === 'string') { + Sentry.addBreadcrumb({ + category: 'crypto', + message: 'Key backup failed', + level: 'error', + data: { errcode: f }, + }); + Sentry.metrics.count('sable.crypto.key_backup_failures', 1, { + attributes: { errcode: f }, + }); setFailure(f); setRemaining(0); } diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 0326e3b86..77a68cf3f 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -151,6 +151,11 @@ function ClientRootOptions({ mx, onLogout }: ClientRootOptionsProps) { const useLogoutListener = (mx?: MatrixClient) => { useEffect(() => { const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Session forcibly logged out by server', + level: 'warning', + }); if (mx) stopClient(mx); await mx?.clearStores(); window.localStorage.clear(); diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index 818d7700a..f55fe5e59 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -1,6 +1,7 @@ import { MatrixClient, SyncState } from '$types/matrix-sdk'; import { useCallback, useState } from 'react'; import { Box, config, Line, Text } from 'folds'; +import * as Sentry from '@sentry/react'; import { useSyncState } from '$hooks/useSyncState'; import { ContainerColor } from '$styles/ContainerColor.css'; @@ -27,6 +28,18 @@ export function SyncStatus({ mx }: SyncStatusProps) { } return { current, previous }; }); + + if (current === SyncState.Reconnecting || current === SyncState.Error) { + Sentry.addBreadcrumb({ + category: 'sync', + message: `Sync state changed to ${current}`, + level: current === SyncState.Error ? 'error' : 'warning', + data: { previous }, + }); + Sentry.metrics.count('sable.sync.degraded', 1, { + attributes: { state: current }, + }); + } }, []) ); From 5af8399db2f4f9c16bd25bd23e071a5a12ebbffb Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 04:01:18 -0400 Subject: [PATCH 12/25] feat(sentry): UTD failure reason, store wipe, send failures, login failures - EncryptedContent: include event.decryptionFailureReason as an attribute on sable.decryption.failure so UTD spikes can be triaged by cause (MISSING_ENCRYPTION_KEY, UNKNOWN_MESSAGE_INDEX, SENDER_IDENTITY_VERIFICATION_VIOLATION, etc.) rather than a single bucket - initMatrix wipeAllStores: add warning breadcrumb + sable.crypto.store_wipe metric when a store mismatch forces a local db wipe-and-retry; fires for both buildClient and initRustCrypto mismatch paths - RoomTimeline handleLocalEchoUpdated: emit sable.message.send_failed count metric when a local echo transitions to EventStatus.NOT_SENT, giving visibility into outbound message delivery failures - loginUtil login(): emit sable.auth.login_failed count metric keyed by errcode alongside the existing span attribute so login pressure is visible in Metrics without querying trace data --- src/app/features/room/RoomTimeline.tsx | 3 +++ src/app/features/room/message/EncryptedContent.tsx | 4 +++- src/app/pages/auth/login/loginUtil.ts | 3 +++ src/client/initMatrix.ts | 6 ++++++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 8945912bb..7cefae80d 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1027,6 +1027,9 @@ export function RoomTimeline({ eventRoom: Room | undefined ) => { if (eventRoom?.roomId !== room.roomId) return; + if (_mEvent.getAssociatedStatus() === EventStatus.NOT_SENT) { + Sentry.metrics.count('sable.message.send_failed', 1); + } setTimeline((ct) => ({ ...ct })); if (!unreadInfo) { setUnreadInfo(getRoomUnreadInfo(room)); diff --git a/src/app/features/room/message/EncryptedContent.tsx b/src/app/features/room/message/EncryptedContent.tsx index f82c7f17e..33955b6e9 100644 --- a/src/app/features/room/message/EncryptedContent.tsx +++ b/src/app/features/room/message/EncryptedContent.tsx @@ -32,7 +32,9 @@ export function EncryptedContent({ mEvent, children }: EncryptedContentProps) { toggleEncrypted(mEvent.getType() === MessageEvent.RoomMessageEncrypted); const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (event) => { if (event.isDecryptionFailure()) { - Sentry.metrics.count('sable.decryption.failure', 1); + Sentry.metrics.count('sable.decryption.failure', 1, { + attributes: { reason: event.decryptionFailureReason ?? 'UNKNOWN_ERROR' }, + }); } toggleEncrypted(event.getType() === MessageEvent.RoomMessageEncrypted); }; diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index a032c10da..667f23cee 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -87,6 +87,9 @@ export const login = async ( if (err) { span.setAttribute('auth.error', err.errcode ?? 'unknown'); + Sentry.metrics.count('sable.auth.login_failed', 1, { + attributes: { errcode: err.errcode ?? 'unknown' }, + }); if (err.httpStatus === 400) { debugLog.error('general', 'Login failed - invalid request', { httpStatus: 400 }); throw new MatrixError({ diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 805b7bf90..ea9b9e876 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -308,6 +308,12 @@ export const initClient = async (session: Session): Promise => { const wipeAllStores = async () => { log.warn('initClient: wiping all stores for', session.userId); debugLog.warn('sync', 'Wiping all stores due to mismatch', { userId: session.userId }); + Sentry.addBreadcrumb({ + category: 'crypto', + message: 'Crypto store mismatch — wiping local stores and retrying', + level: 'warning', + }); + Sentry.metrics.count('sable.crypto.store_wipe', 1); await deleteSessionStores(storeName); try { const allDbs = await window.indexedDB.databases(); From 103b904342abda3708237090f636aa3ba9d6dfdb Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 04:06:21 -0400 Subject: [PATCH 13/25] feat(sentry): capture client load/start failures, background client errors, verification outcomes --- src/app/components/DeviceVerification.tsx | 13 +++++++++++++ src/app/pages/client/BackgroundNotifications.tsx | 1 + src/app/pages/client/ClientRoot.tsx | 14 ++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/src/app/components/DeviceVerification.tsx b/src/app/components/DeviceVerification.tsx index 642125e83..fa29ae11e 100644 --- a/src/app/components/DeviceVerification.tsx +++ b/src/app/components/DeviceVerification.tsx @@ -22,6 +22,7 @@ import { Text, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import * as Sentry from '@sentry/react'; import { useVerificationRequestPhase, useVerificationRequestReceived, @@ -246,6 +247,18 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps) await request.startVerification(VerificationMethod.Sas); }, [request]); + useEffect(() => { + if (phase === VerificationPhase.Done) { + Sentry.metrics.count('sable.crypto.verification_outcome', 1, { + attributes: { outcome: 'completed' }, + }); + } else if (phase === VerificationPhase.Cancelled) { + Sentry.metrics.count('sable.crypto.verification_outcome', 1, { + attributes: { outcome: 'cancelled' }, + }); + } + }, [phase]); + return ( }> diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index a3d10a1ba..2d382fcc7 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -508,6 +508,7 @@ export function BackgroundNotifications() { userId: session.userId, error: err, }); + Sentry.captureException(err, { tags: { component: 'BackgroundNotifications' } }); // Remove the stuck/failed client from current so future runs (or the // retry below) can attempt a fresh start. diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 77a68cf3f..7910f3308 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -340,6 +340,20 @@ export function ClientRoot({ children }: ClientRootProps) { }; }, [mx]); + // Capture fatal client failures — useAsyncCallback swallows these into state so + // they never reach the React ErrorBoundary; explicit capture is required. + useEffect(() => { + if (loadState.status === AsyncStatus.Error) { + Sentry.captureException(loadState.error, { tags: { phase: 'load' } }); + } + }, [loadState]); + + useEffect(() => { + if (startState.status === AsyncStatus.Error) { + Sentry.captureException(startState.error, { tags: { phase: 'start' } }); + } + }, [startState]); + return ( From ce3aa2ffe35b111940b890510c3d5a7c73a2ae45 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 04:17:17 -0400 Subject: [PATCH 14/25] chore(sentry): fix lint errors in PR files; expand privacy and integration docs with full metrics reference --- docs/SENTRY_INTEGRATION.md | 23 ++++ docs/SENTRY_PRIVACY.md | 143 +++++++++++++++++++++++-- src/app/features/room/RoomTimeline.tsx | 101 +++++++++-------- src/app/pages/auth/login/loginUtil.ts | 7 +- src/app/pages/client/ClientRoot.tsx | 37 +++---- 5 files changed, 229 insertions(+), 82 deletions(-) diff --git a/docs/SENTRY_INTEGRATION.md b/docs/SENTRY_INTEGRATION.md index 27b332e30..3227e211d 100644 --- a/docs/SENTRY_INTEGRATION.md +++ b/docs/SENTRY_INTEGRATION.md @@ -163,6 +163,29 @@ localStorage.setItem('sable_sentry_replay_enabled', 'false'); Or use the UI in Settings → Developer Tools → Error Tracking (Sentry). +## Custom Instrumentation + +Beyond automatic error capture, Sable has hand-crafted monitoring at key +lifecycle points. See [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md) for the full +metrics reference. Key areas: + +| Area | What's tracked | +|------|----------------| +| **Auth** | Login failures (by `errcode`), forced server logouts | +| **Sync** | Transport type, degraded states, cycle stats, time-to-ready | +| **Cryptography** | Decryption failures (by failure reason), key backup errors, store wipes, E2E verification outcomes | +| **Messaging** | Send latency, send errors, local-echo `NOT_SENT` events | +| **Timeline** | Opens, virtual window size, jump-load latency, re-initialisations | +| **Media** | Upload latency, upload size, cache stats | +| **Background clients** | Per-account notification client count, startup failures | + +Fatal errors that are caught by `useAsyncCallback` state (and therefore never +reach React's ErrorBoundary) are explicitly forwarded with `captureException`: + +- Client load failure (`phase: load`) +- Client start failure (`phase: start`) +- Background notification client startup failure + ## Implementation Details ### Files Modified diff --git a/docs/SENTRY_PRIVACY.md b/docs/SENTRY_PRIVACY.md index cd62bd929..93877a7de 100644 --- a/docs/SENTRY_PRIVACY.md +++ b/docs/SENTRY_PRIVACY.md @@ -43,6 +43,38 @@ browser. **Code:** `src/instrument.ts` — `beforeBreadcrumb` callback **Code:** `src/app/utils/debugLogger.ts` — Sentry breadcrumb integration +### Application Breadcrumbs + +In addition to automatic navigation/console breadcrumbs, the following named +events are explicitly recorded as breadcrumbs: + +| Event | Category | Level | Source | +|-------|----------|-------|--------| +| Session forcibly logged out by server | `auth` | warning | `ClientRoot.tsx` | +| Sync state changed to Reconnecting/Error | `sync` | warning/error | `SyncStatus.tsx` | +| Sliding sync first run completed | `sync` | info | `initMatrix.ts` | +| Crypto store mismatch — wiping local stores | `crypto` | warning | `initMatrix.ts` | +| Key backup failed | `crypto` | error | `useKeyBackup.ts` | +| High media inflight request count | `media` | warning | `ClientNonUIFeatures.tsx` | + +**Code:** `src/app/pages/client/ClientRoot.tsx`, `src/app/pages/client/SyncStatus.tsx`, +`src/client/initMatrix.ts`, `src/app/hooks/useKeyBackup.ts`, +`src/app/pages/client/ClientNonUIFeatures.tsx` + +### Component Error Capture + +The following failure paths use explicit `captureException` because they are +caught by state management hooks and never propagate to React's ErrorBoundary: + +| Failure | Tag | Source | +|---------|-----|--------| +| Client failed to load (fetch/init) | `phase: load` | `ClientRoot.tsx` | +| Client failed to start (sync start) | `phase: start` | `ClientRoot.tsx` | +| Background notification client failed to start | `component: BackgroundNotifications` | `BackgroundNotifications.tsx` | + +**Code:** `src/app/pages/client/ClientRoot.tsx`, +`src/app/pages/client/BackgroundNotifications.tsx` + ### Performance Traces - Timing of React Router navigations (page-load and route-change latency) @@ -50,8 +82,16 @@ browser. - JavaScript CPU profiles during traced transactions (call-stack samples) Performance data contains **no message content, no room names, and no user -identifiers**. Spans are labelled with operation names only (e.g. -`matrix.sync`, `sable.message.send`). +identifiers**. Spans are labelled with operation names only. + +| Span name | Operation | Source | +|-----------|-----------|--------| +| `auth.login` | `auth` | `loginUtil.ts` | +| `decrypt.event` | `matrix.crypto` | `EncryptedContent.tsx` | +| `decrypt.bulk` | `matrix.crypto` | `room.ts` | +| `timeline.jump_load` | `matrix.timeline` | `RoomTimeline.tsx` | +| `message.send` | `matrix.message` | `RoomInput.tsx` | +| Sliding sync processing | `matrix.sync` | `slidingSync.ts` | **Sample rates:** @@ -62,16 +102,101 @@ identifiers**. Spans are labelled with operation names only (e.g. **Code:** `src/instrument.ts` — `tracesSampleRate`, `profilesSampleRate` **Code:** `src/app/features/room/RoomInput.tsx` — message send span -**Code:** `src/client/matrix.ts`, `src/client/room.ts`, `src/client/slidingSync.ts` — sync/room spans +**Code:** `src/app/utils/room.ts`, `src/client/slidingSync.ts` — room/sync spans ### Custom Metrics -- `sable.message.send_latency` — histogram of message send round-trip time (ms) -- `sable.message.send_error` — counter incremented on send failure - -These contain no message content or identifiers. - -**Code:** `src/app/features/room/RoomInput.tsx` +All metrics contain no message content, room names, or user identifiers. +Attribute values are limited to short enumerated strings (error codes, states) +or numeric measurements. + +#### Authentication + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.auth.login_failed` | count | `errcode` | Login attempt failures by error code | + +**Code:** `src/app/pages/auth/login/loginUtil.ts` + +#### Cryptography + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.decryption.failure` | count | `reason` | Unable-to-decrypt events by failure reason | +| `sable.decryption.event_ms` | distribution | — | Per-event decryption latency | +| `sable.decryption.bulk_latency_ms` | distribution | `event_count` | Bulk re-decryption time on room open | +| `sable.crypto.key_backup_failures` | count | `errcode` | Key backup errors by code | +| `sable.crypto.store_wipe` | count | — | Crypto store mismatch wipe-and-retry occurrences | +| `sable.crypto.verification_outcome` | count | `outcome` (`completed`/`cancelled`) | E2E device verification outcomes | + +**Code:** `src/app/features/room/message/EncryptedContent.tsx`, +`src/app/utils/room.ts`, `src/app/hooks/useKeyBackup.ts`, +`src/client/initMatrix.ts`, `src/app/components/DeviceVerification.tsx` + +#### Messaging + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.message.send_latency_ms` | distribution | `encrypted` | Message send round-trip time | +| `sable.message.send_error` | count | — | Send errors from message composer | +| `sable.message.send_failed` | count | — | Local-echo `NOT_SENT` status events | + +**Code:** `src/app/features/room/RoomInput.tsx`, +`src/app/features/room/RoomTimeline.tsx` + +#### Timeline + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.timeline.open` | count | `mode` | Timeline render initiations | +| `sable.timeline.render_window` | distribution | `mode` | Initial virtual window size | +| `sable.timeline.jump_load_ms` | distribution | — | Event-jump timeline load latency | +| `sable.timeline.reinit` | count | — | Full timeline re-initialisations | +| `sable.pagination.error` | count | `direction` | Pagination errors by direction | + +**Code:** `src/app/features/room/RoomTimeline.tsx` + +#### Sync + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.sync.transport` | count | `type` (`sliding`/`classic`) | Sync transport type used | +| `sable.sync.cycle` | count | (various) | Completed sliding sync cycles | +| `sable.sync.error` | count | `errcode` | Sliding sync errors | +| `sable.sync.initial_ms` | distribution | — | Initial sync completion time | +| `sable.sync.processing_ms` | distribution | — | Per-cycle sync processing time | +| `sable.sync.lists_loaded_ms` | distribution | — | Time for room lists to fully load | +| `sable.sync.total_rooms` | gauge | `sync_type` | Total rooms known at list load | +| `sable.sync.active_subscriptions` | gauge | — | Active room subscription count | +| `sable.sync.client_ready_ms` | distribution | `type` | Time from init to client ready | +| `sable.sync.time_to_ready_ms` | distribution | — | Wall-clock time to first sync ready | +| `sable.sync.degraded` | count | `state` | Sync reconnect/error state transitions | + +**Code:** `src/client/initMatrix.ts`, `src/client/slidingSync.ts`, +`src/app/pages/client/ClientRoot.tsx`, `src/app/pages/client/SyncStatus.tsx` + +#### Media + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.media.upload_latency_ms` | distribution | `mimetype` | Media upload round-trip time | +| `sable.media.upload_bytes` | distribution | `mimetype` | Upload size distribution | +| `sable.media.upload_error` | count | `reason` | Upload failures by reason | +| `sable.media.blob_cache_size` | gauge | — | Blob URL cache entry count | +| `sable.media.inflight_requests` | gauge | — | Concurrent media requests | + +**Code:** `src/app/utils/matrix.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx` + +#### Background clients & debug telemetry + +| Metric | Type | Attributes | What it tracks | +|--------|------|-----------|----------------| +| `sable.background.client_count` | gauge | — | Active background notification clients | +| `sable.errors` | count | `category` | Error-level debug log entries | +| `sable.warnings` | count | `category` | Warning-level debug log entries | + +**Code:** `src/app/pages/client/BackgroundNotifications.tsx`, +`src/app/utils/debugLogger.ts` ### Session Replay *(opt-in, disabled by default)* diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 7cefae80d..05553039b 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -291,61 +291,60 @@ const useEventTimelineLoader = ( onError: (err: Error | null) => void ) => 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); - }); - }); + async (eventId: string) => + Sentry.startSpan({ name: 'timeline.jump_load', op: 'matrix.timeline' }, async () => { + const jumpLoadStart = performance.now(); + const withTimeout = async (promise: Promise, timeoutMs: number): Promise => + new Promise((resolve, reject) => { + const timeoutId = globalThis.setTimeout(() => { + reject(new Error('Timed out loading event timeline')); + }, timeoutMs); + + promise + .then((value) => { + globalThis.clearTimeout(timeoutId); + resolve(value); + }) + .catch((error) => { + globalThis.clearTimeout(timeoutId); + reject(error); + }); + }); - if (!room.getUnfilteredTimelineSet().getTimelineForEvent(eventId)) { - await withTimeout( - mx.roomInitialSync(room.roomId, PAGINATION_LIMIT), - EVENT_TIMELINE_LOAD_TIMEOUT_MS - ); - await withTimeout( - mx.getLatestTimeline(room.getUnfilteredTimelineSet()), - EVENT_TIMELINE_LOAD_TIMEOUT_MS + if (!room.getUnfilteredTimelineSet().getTimelineForEvent(eventId)) { + await withTimeout( + mx.roomInitialSync(room.roomId, PAGINATION_LIMIT), + EVENT_TIMELINE_LOAD_TIMEOUT_MS + ); + await withTimeout( + mx.getLatestTimeline(room.getUnfilteredTimelineSet()), + EVENT_TIMELINE_LOAD_TIMEOUT_MS + ); + } + const [err, replyEvtTimeline] = await to( + withTimeout( + mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId), + EVENT_TIMELINE_LOAD_TIMEOUT_MS + ) ); - } - const [err, replyEvtTimeline] = await to( - withTimeout( - mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId), - EVENT_TIMELINE_LOAD_TIMEOUT_MS - ) - ); - if (!replyEvtTimeline) { - onError(err ?? null); - return; - } - const linkedTimelines = getLinkedTimelines(replyEvtTimeline); - const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId); + if (!replyEvtTimeline) { + onError(err ?? null); + return; + } + const linkedTimelines = getLinkedTimelines(replyEvtTimeline); + const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId); - if (absIndex === undefined) { - onError(err ?? null); - return; - } + if (absIndex === undefined) { + onError(err ?? null); + return; + } - Sentry.metrics.distribution( - 'sable.timeline.jump_load_ms', - performance.now() - jumpLoadStart - ); - onLoad(eventId, linkedTimelines, absIndex); - }); // end startSpan - }, + Sentry.metrics.distribution( + 'sable.timeline.jump_load_ms', + performance.now() - jumpLoadStart + ); + onLoad(eventId, linkedTimelines, absIndex); + }), // end startSpan [mx, room, onLoad, onError] ); diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index 667f23cee..f14641746 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -81,7 +81,7 @@ export const login = async ( debugLog.info('general', 'Attempting login', { baseUrl: url, loginType: data.type }); return Sentry.startSpan( - { name: 'auth.login', op: 'auth', attributes: { 'auth.method': data.type as string } }, + { name: 'auth.login', op: 'auth', attributes: { 'auth.method': data.type } }, async (span) => { const [err, res] = await to(mx.loginRequest(data)); @@ -126,7 +126,10 @@ export const login = async ( } span.setAttribute('auth.success', true); - debugLog.info('general', 'Login successful', { userId: res.user_id, deviceId: res.device_id }); + debugLog.info('general', 'Login successful', { + userId: res.user_id, + deviceId: res.device_id, + }); return { baseUrl: url, response: res, diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 7910f3308..69ef85340 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -287,26 +287,23 @@ export function ClientRoot({ children }: ClientRootProps) { useSyncState( mx, - useCallback( - (state: string) => { - if (isClientReady(state)) { - if (!firstSyncReadyRef.current) { - firstSyncReadyRef.current = true; - Sentry.metrics.distribution( - 'sable.sync.time_to_ready_ms', - performance.now() - syncStartTimeRef.current - ); - } - setLoading(false); + useCallback((state: string) => { + if (isClientReady(state)) { + if (!firstSyncReadyRef.current) { + firstSyncReadyRef.current = true; + Sentry.metrics.distribution( + 'sable.sync.time_to_ready_ms', + performance.now() - syncStartTimeRef.current + ); } - }, - [] - ) + setLoading(false); + } + }, []) ); // Set matrix client context: homeserver and sync type (not PII) useEffect(() => { - if (!activeSession?.baseUrl) return; + if (!activeSession?.baseUrl) return undefined; Sentry.setContext('client', { homeserver: activeSession.baseUrl, sliding_sync: clientConfig.slidingSync, @@ -318,13 +315,13 @@ export function ClientRoot({ children }: ClientRootProps) { // 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; + if (!mx) return undefined; + const matrixUserId = mx.getUserId(); + if (!matrixUserId) return undefined; (async () => { const hashBuffer = await crypto.subtle.digest( 'SHA-256', - new TextEncoder().encode(userId) + new TextEncoder().encode(matrixUserId) ); const hashHex = Array.from(new Uint8Array(hashBuffer)) .map((b) => b.toString(16).padStart(2, '0')) @@ -332,7 +329,7 @@ export function ClientRoot({ children }: ClientRootProps) { .slice(0, 16); // Include the homeserver domain as `username` — it is not PII (it is the server // domain, not a personal identifier) and helps segment issues by deployment. - const serverDomain = userId.split(':')[1] ?? 'unknown'; + const serverDomain = matrixUserId.split(':')[1] ?? 'unknown'; Sentry.setUser({ id: hashHex, username: serverDomain }); })(); return () => { From df88263384a8ab8dc73c662ccc5650ceffef13ef Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 04:22:18 -0400 Subject: [PATCH 15/25] fix(sentry): move syncDuration declaration before use to fix TS use-before-define --- src/client/slidingSync.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 9f60bbab7..d403dd1e6 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -438,6 +438,8 @@ export class SlidingSyncManager { }); } + const syncDuration = performance.now() - syncStartTime; + // Mark initial sync as complete after first successful cycle if (!this.initialSyncCompleted) { this.initialSyncCompleted = true; @@ -465,7 +467,6 @@ export class SlidingSyncManager { this.expandListsToKnownCount(); - const syncDuration = performance.now() - syncStartTime; Sentry.metrics.distribution('sable.sync.processing_ms', syncDuration, { attributes: { transport: 'sliding' }, }); @@ -861,7 +862,10 @@ export class SlidingSyncManager { firstTime = false; } const finalCount = this.slidingSync.getListData(LIST_SEARCH)?.joinedCount ?? 0; - span.setAttributes({ 'spidering.batches': batchCount, 'spidering.total_rooms': finalCount }); + span.setAttributes({ + 'spidering.batches': batchCount, + 'spidering.total_rooms': finalCount, + }); log.log(`Sliding Sync spidering complete for ${this.mx.getUserId()}`); } ); From 99bd215d074cf893022b3766099e5adcdb4d19d4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 04:50:52 -0400 Subject: [PATCH 16/25] =?UTF-8?q?feat:=20add=20Sentry=E2=86=92GitHub=20Iss?= =?UTF-8?q?ues=20triage=20for=20PR=20preview=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tag all Sentry events with PR number via VITE_SENTRY_PR env var (instrument.ts: setTag('pr', prNumber) on global scope) - cloudflare-web-preview.yml: inject Sentry DSN, environment=preview, PR number, and source-map secrets into build env for PR runs - New workflow sentry-preview-issues.yml: on every PR push, query Sentry for unresolved issues tagged with the PR number and environment=preview; create a GitHub issue per unique error (deduplicated by sentry-id marker), labelled 'sentry-preview' + 'pr-{N}'; maintain a sticky PR comment with a summary table; reopen closed issues on regression - Labels allow filtering: -label:sentry-preview hides all automated issues --- .github/workflows/cloudflare-web-preview.yml | 16 ++ .github/workflows/sentry-preview-issues.yml | 231 +++++++++++++++++++ src/instrument.ts | 6 + 3 files changed, 253 insertions(+) create mode 100644 .github/workflows/sentry-preview-issues.yml diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 5ddfe5a0e..8b93a4bb9 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -54,6 +54,22 @@ jobs: echo EOF } >> "$GITHUB_OUTPUT" + - name: Set Sentry build environment for PR preview + if: github.event_name == 'pull_request' + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + shell: bash + run: | + echo "VITE_SENTRY_DSN=$VITE_SENTRY_DSN" >> "$GITHUB_ENV" + echo "VITE_SENTRY_ENVIRONMENT=preview" >> "$GITHUB_ENV" + echo "VITE_SENTRY_PR=${{ github.event.pull_request.number }}" >> "$GITHUB_ENV" + echo "SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN" >> "$GITHUB_ENV" + echo "SENTRY_ORG=$SENTRY_ORG" >> "$GITHUB_ENV" + echo "SENTRY_PROJECT=$SENTRY_PROJECT" >> "$GITHUB_ENV" + - name: Setup app and build uses: ./.github/actions/setup with: diff --git a/.github/workflows/sentry-preview-issues.yml b/.github/workflows/sentry-preview-issues.yml new file mode 100644 index 000000000..c81787e74 --- /dev/null +++ b/.github/workflows/sentry-preview-issues.yml @@ -0,0 +1,231 @@ +name: Sentry Preview Error Triage + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'src/**' + - 'index.html' + - 'package.json' + - 'vite.config.ts' + - 'tsconfig.json' + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to triage' + required: true + type: number + +jobs: + triage: + # Only run for PRs from the same repo (not forks) or manual dispatch + if: > + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository) || + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: Triage Sentry preview errors + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const sentryToken = process.env.SENTRY_AUTH_TOKEN; + const sentryOrg = process.env.SENTRY_ORG; + const sentryProject = process.env.SENTRY_PROJECT; + const prNumber = Number(process.env.PR_NUMBER); + + if (!prNumber) { + core.info('No PR number available — skipping triage.'); + return; + } + if (!sentryToken || !sentryOrg || !sentryProject) { + core.warning('Sentry credentials not configured — skipping triage.'); + return; + } + + const COMMENT_MARKER = ''; + const { owner, repo } = context.repo; + + // Create a label if it doesn't already exist + async function ensureLabel(name, description, color) { + try { + await github.rest.issues.getLabel({ owner, repo, name }); + } catch { + try { + await github.rest.issues.createLabel({ owner, repo, name, description, color }); + } catch (err) { + core.warning(`Could not create label "${name}": ${err.message}`); + } + } + } + + // Find an existing GitHub issue that tracks a given Sentry issue ID + async function findExistingGhIssue(sentryIssueId) { + const marker = `sentry-id:${sentryIssueId}`; + const result = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:issue label:sentry-preview "${marker}" in:body`, + }); + return result.data.total_count > 0 ? result.data.items[0] : null; + } + + // Create or update the sticky PR comment with the triage summary table + async function upsertPrComment(rows) { + const now = new Date().toUTCString().replace(':00 GMT', ' UTC'); + let body; + + if (rows.length === 0) { + body = [ + COMMENT_MARKER, + '## Sentry Preview Error Triage', + '', + `No Sentry errors found for this PR's preview deployment as of ${now}.`, + '', + '_This comment updates automatically after each push._', + ].join('\n'); + } else { + const tableRows = rows.map( + (r) => + `| [${r.title.slice(0, 70)}](${r.permalink}) | ${r.count} | ${new Date(r.firstSeen).toLocaleDateString()} | #${r.ghIssueNumber} |` + ); + body = [ + COMMENT_MARKER, + '## Sentry Preview Error Triage', + '', + `**${rows.length} error type(s)** detected in this PR's preview deployment:`, + '', + '| Error | Events | First seen | Issue |', + '| ----- | ------ | ---------- | ----- |', + ...tableRows, + '', + `_Last checked: ${now}. Exclude these from your issues view with \`-label:sentry-preview\`._`, + ].join('\n'); + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + }); + const existing = comments.find( + (c) => c.user.type === 'Bot' && c.body.includes(COMMENT_MARKER) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + } + } + + // Query Sentry for unresolved issues tagged with this PR number in the preview env + const query = encodeURIComponent(`is:unresolved pr:${prNumber}`); + const sentryUrl = + `https://sentry.io/api/0/projects/${sentryOrg}/${sentryProject}/issues/` + + `?query=${query}&environment=preview&limit=100`; + + let sentryIssues; + try { + const resp = await fetch(sentryUrl, { + headers: { Authorization: `Bearer ${sentryToken}` }, + }); + if (!resp.ok) { + const msg = await resp.text(); + core.warning(`Sentry API returned ${resp.status}: ${msg.slice(0, 200)}`); + return; + } + sentryIssues = await resp.json(); + } catch (err) { + core.warning(`Sentry API unreachable: ${err.message}`); + return; + } + + if (!Array.isArray(sentryIssues) || sentryIssues.length === 0) { + await upsertPrComment([]); + return; + } + + // Ensure the shared and PR-specific labels exist + await ensureLabel('sentry-preview', 'Automated Sentry preview error', 'e4e669'); + await ensureLabel(`pr-${prNumber}`, `Preview errors from PR #${prNumber}`, 'fbca04'); + + const rows = []; + for (const issue of sentryIssues) { + const { + id: sentryId, + title, + culprit, + permalink, + count, + userCount, + firstSeen, + lastSeen, + } = issue; + const displayTitle = (title || culprit || 'Unknown error').trim(); + const sentryMarker = `sentry-id:${sentryId}`; + + const existing = await findExistingGhIssue(sentryId); + let ghIssueNumber; + + if (existing) { + ghIssueNumber = existing.number; + // Reopen if it was closed (e.g. after a previous fix that regressed) + if (existing.state === 'closed') { + await github.rest.issues.update({ + owner, + repo, + issue_number: ghIssueNumber, + state: 'open', + }); + core.info(`Reopened GH issue #${ghIssueNumber} for Sentry issue ${sentryId}`); + } + } else { + const issueBody = [ + ``, + `## Sentry Error — PR #${prNumber} Preview`, + '', + `**Error:** [${displayTitle}](${permalink})`, + `**First seen:** ${new Date(firstSeen).toUTCString()}`, + `**Last seen:** ${new Date(lastSeen).toUTCString()}`, + `**Events:** ${count} | **Affected users:** ${userCount}`, + '', + `This issue was automatically created from a Sentry error detected in the preview deployment for PR #${prNumber}.`, + '', + '> [!NOTE]', + '> To exclude automated preview issues from your issues view, filter with: `-label:sentry-preview`', + ].join('\n'); + + const created = await github.rest.issues.create({ + owner, + repo, + title: `[Sentry] ${displayTitle.slice(0, 120)}`, + body: issueBody, + labels: ['sentry-preview', `pr-${prNumber}`], + }); + ghIssueNumber = created.data.number; + core.info(`Created GH issue #${ghIssueNumber} for Sentry issue ${sentryId}`); + } + + rows.push({ title: displayTitle, permalink, count, firstSeen, ghIssueNumber }); + } + + await upsertPrComment(rows); + core.info(`Triage complete: ${rows.length} Sentry issue(s) processed for PR #${prNumber}.`); diff --git a/src/instrument.ts b/src/instrument.ts index e7121da09..9efec360d 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -222,6 +222,12 @@ if (dsn && sentryEnabled) { 'app.version': release ?? 'unknown', }); + // Tag all events with the PR number when running in a PR preview deployment + const prNumber = import.meta.env.VITE_SENTRY_PR; + if (prNumber) { + Sentry.getGlobalScope().setTag('pr', prNumber); + } + // @ts-expect-error - Adding to window for debugging window.Sentry = Sentry; From 870a4b80a6f56ef7eb8827feb81d121e3baea1e8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 05:03:16 -0400 Subject: [PATCH 17/25] fix: lint and formatting fixes across modified files - instrument.ts: remove useless escape \[ in character class regexes - matrix.ts, RoomInput.tsx: fix @sentry/react import order (third-party must precede local imports per import-x/order) - debugLogger.ts: replace for..of with Object.entries().forEach() (no-restricted-syntax) - ClientNonUIFeatures.tsx: replace nested ternary with if/else for memberCountRange (no-nested-ternary) - SentrySettings.tsx: remove useless Fragment wrapper; replace template literal with string literal where no interpolation is needed - Prettier format: apply auto-formatting to all touched files --- docs/SENTRY_INTEGRATION.md | 18 +- docs/SENTRY_PRIVACY.md | 174 +++++++++--------- src/app/components/DefaultErrorPage.tsx | 8 +- src/app/features/room/RoomInput.tsx | 8 +- .../developer-tools/SentrySettings.tsx | 26 ++- src/app/pages/Router.tsx | 102 +++++----- src/app/pages/client/ClientNonUIFeatures.tsx | 11 +- src/app/utils/debugLogger.ts | 19 +- src/app/utils/matrix.ts | 12 +- src/instrument.ts | 7 +- 10 files changed, 199 insertions(+), 186 deletions(-) diff --git a/docs/SENTRY_INTEGRATION.md b/docs/SENTRY_INTEGRATION.md index 3227e211d..a1a08f633 100644 --- a/docs/SENTRY_INTEGRATION.md +++ b/docs/SENTRY_INTEGRATION.md @@ -169,15 +169,15 @@ Beyond automatic error capture, Sable has hand-crafted monitoring at key lifecycle points. See [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md) for the full metrics reference. Key areas: -| Area | What's tracked | -|------|----------------| -| **Auth** | Login failures (by `errcode`), forced server logouts | -| **Sync** | Transport type, degraded states, cycle stats, time-to-ready | -| **Cryptography** | Decryption failures (by failure reason), key backup errors, store wipes, E2E verification outcomes | -| **Messaging** | Send latency, send errors, local-echo `NOT_SENT` events | -| **Timeline** | Opens, virtual window size, jump-load latency, re-initialisations | -| **Media** | Upload latency, upload size, cache stats | -| **Background clients** | Per-account notification client count, startup failures | +| Area | What's tracked | +| ---------------------- | -------------------------------------------------------------------------------------------------- | +| **Auth** | Login failures (by `errcode`), forced server logouts | +| **Sync** | Transport type, degraded states, cycle stats, time-to-ready | +| **Cryptography** | Decryption failures (by failure reason), key backup errors, store wipes, E2E verification outcomes | +| **Messaging** | Send latency, send errors, local-echo `NOT_SENT` events | +| **Timeline** | Opens, virtual window size, jump-load latency, re-initialisations | +| **Media** | Upload latency, upload size, cache stats | +| **Background clients** | Per-account notification client count, startup failures | Fatal errors that are caught by `useAsyncCallback` state (and therefore never reach React's ErrorBoundary) are explicitly forwarded with `captureException`: diff --git a/docs/SENTRY_PRIVACY.md b/docs/SENTRY_PRIVACY.md index 93877a7de..f413f9a60 100644 --- a/docs/SENTRY_PRIVACY.md +++ b/docs/SENTRY_PRIVACY.md @@ -48,14 +48,14 @@ browser. In addition to automatic navigation/console breadcrumbs, the following named events are explicitly recorded as breadcrumbs: -| Event | Category | Level | Source | -|-------|----------|-------|--------| -| Session forcibly logged out by server | `auth` | warning | `ClientRoot.tsx` | -| Sync state changed to Reconnecting/Error | `sync` | warning/error | `SyncStatus.tsx` | -| Sliding sync first run completed | `sync` | info | `initMatrix.ts` | -| Crypto store mismatch — wiping local stores | `crypto` | warning | `initMatrix.ts` | -| Key backup failed | `crypto` | error | `useKeyBackup.ts` | -| High media inflight request count | `media` | warning | `ClientNonUIFeatures.tsx` | +| Event | Category | Level | Source | +| ------------------------------------------- | -------- | ------------- | ------------------------- | +| Session forcibly logged out by server | `auth` | warning | `ClientRoot.tsx` | +| Sync state changed to Reconnecting/Error | `sync` | warning/error | `SyncStatus.tsx` | +| Sliding sync first run completed | `sync` | info | `initMatrix.ts` | +| Crypto store mismatch — wiping local stores | `crypto` | warning | `initMatrix.ts` | +| Key backup failed | `crypto` | error | `useKeyBackup.ts` | +| High media inflight request count | `media` | warning | `ClientNonUIFeatures.tsx` | **Code:** `src/app/pages/client/ClientRoot.tsx`, `src/app/pages/client/SyncStatus.tsx`, `src/client/initMatrix.ts`, `src/app/hooks/useKeyBackup.ts`, @@ -66,10 +66,10 @@ events are explicitly recorded as breadcrumbs: The following failure paths use explicit `captureException` because they are caught by state management hooks and never propagate to React's ErrorBoundary: -| Failure | Tag | Source | -|---------|-----|--------| -| Client failed to load (fetch/init) | `phase: load` | `ClientRoot.tsx` | -| Client failed to start (sync start) | `phase: start` | `ClientRoot.tsx` | +| Failure | Tag | Source | +| ---------------------------------------------- | ------------------------------------ | ----------------------------- | +| Client failed to load (fetch/init) | `phase: load` | `ClientRoot.tsx` | +| Client failed to start (sync start) | `phase: start` | `ClientRoot.tsx` | | Background notification client failed to start | `component: BackgroundNotifications` | `BackgroundNotifications.tsx` | **Code:** `src/app/pages/client/ClientRoot.tsx`, @@ -84,21 +84,21 @@ caught by state management hooks and never propagate to React's ErrorBoundary: Performance data contains **no message content, no room names, and no user identifiers**. Spans are labelled with operation names only. -| Span name | Operation | Source | -|-----------|-----------|--------| -| `auth.login` | `auth` | `loginUtil.ts` | -| `decrypt.event` | `matrix.crypto` | `EncryptedContent.tsx` | -| `decrypt.bulk` | `matrix.crypto` | `room.ts` | -| `timeline.jump_load` | `matrix.timeline` | `RoomTimeline.tsx` | -| `message.send` | `matrix.message` | `RoomInput.tsx` | -| Sliding sync processing | `matrix.sync` | `slidingSync.ts` | +| Span name | Operation | Source | +| ----------------------- | ----------------- | ---------------------- | +| `auth.login` | `auth` | `loginUtil.ts` | +| `decrypt.event` | `matrix.crypto` | `EncryptedContent.tsx` | +| `decrypt.bulk` | `matrix.crypto` | `room.ts` | +| `timeline.jump_load` | `matrix.timeline` | `RoomTimeline.tsx` | +| `message.send` | `matrix.message` | `RoomInput.tsx` | +| Sliding sync processing | `matrix.sync` | `slidingSync.ts` | **Sample rates:** -| Environment | Traces | Profiles | -|---------------------|--------|----------| -| `production` | 10% | 10% | -| `preview` / `development` | 100% | 100% | +| Environment | Traces | Profiles | +| ------------------------- | ------ | -------- | +| `production` | 10% | 10% | +| `preview` / `development` | 100% | 100% | **Code:** `src/instrument.ts` — `tracesSampleRate`, `profilesSampleRate` **Code:** `src/app/features/room/RoomInput.tsx` — message send span @@ -112,22 +112,22 @@ or numeric measurements. #### Authentication -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| -| `sable.auth.login_failed` | count | `errcode` | Login attempt failures by error code | +| Metric | Type | Attributes | What it tracks | +| ------------------------- | ----- | ---------- | ------------------------------------ | +| `sable.auth.login_failed` | count | `errcode` | Login attempt failures by error code | **Code:** `src/app/pages/auth/login/loginUtil.ts` #### Cryptography -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| -| `sable.decryption.failure` | count | `reason` | Unable-to-decrypt events by failure reason | -| `sable.decryption.event_ms` | distribution | — | Per-event decryption latency | -| `sable.decryption.bulk_latency_ms` | distribution | `event_count` | Bulk re-decryption time on room open | -| `sable.crypto.key_backup_failures` | count | `errcode` | Key backup errors by code | -| `sable.crypto.store_wipe` | count | — | Crypto store mismatch wipe-and-retry occurrences | -| `sable.crypto.verification_outcome` | count | `outcome` (`completed`/`cancelled`) | E2E device verification outcomes | +| Metric | Type | Attributes | What it tracks | +| ----------------------------------- | ------------ | ----------------------------------- | ------------------------------------------------ | +| `sable.decryption.failure` | count | `reason` | Unable-to-decrypt events by failure reason | +| `sable.decryption.event_ms` | distribution | — | Per-event decryption latency | +| `sable.decryption.bulk_latency_ms` | distribution | `event_count` | Bulk re-decryption time on room open | +| `sable.crypto.key_backup_failures` | count | `errcode` | Key backup errors by code | +| `sable.crypto.store_wipe` | count | — | Crypto store mismatch wipe-and-retry occurrences | +| `sable.crypto.verification_outcome` | count | `outcome` (`completed`/`cancelled`) | E2E device verification outcomes | **Code:** `src/app/features/room/message/EncryptedContent.tsx`, `src/app/utils/room.ts`, `src/app/hooks/useKeyBackup.ts`, @@ -135,70 +135,70 @@ or numeric measurements. #### Messaging -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| -| `sable.message.send_latency_ms` | distribution | `encrypted` | Message send round-trip time | -| `sable.message.send_error` | count | — | Send errors from message composer | -| `sable.message.send_failed` | count | — | Local-echo `NOT_SENT` status events | +| Metric | Type | Attributes | What it tracks | +| ------------------------------- | ------------ | ----------- | ----------------------------------- | +| `sable.message.send_latency_ms` | distribution | `encrypted` | Message send round-trip time | +| `sable.message.send_error` | count | — | Send errors from message composer | +| `sable.message.send_failed` | count | — | Local-echo `NOT_SENT` status events | **Code:** `src/app/features/room/RoomInput.tsx`, `src/app/features/room/RoomTimeline.tsx` #### Timeline -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| -| `sable.timeline.open` | count | `mode` | Timeline render initiations | -| `sable.timeline.render_window` | distribution | `mode` | Initial virtual window size | -| `sable.timeline.jump_load_ms` | distribution | — | Event-jump timeline load latency | -| `sable.timeline.reinit` | count | — | Full timeline re-initialisations | -| `sable.pagination.error` | count | `direction` | Pagination errors by direction | +| Metric | Type | Attributes | What it tracks | +| ------------------------------ | ------------ | ----------- | -------------------------------- | +| `sable.timeline.open` | count | `mode` | Timeline render initiations | +| `sable.timeline.render_window` | distribution | `mode` | Initial virtual window size | +| `sable.timeline.jump_load_ms` | distribution | — | Event-jump timeline load latency | +| `sable.timeline.reinit` | count | — | Full timeline re-initialisations | +| `sable.pagination.error` | count | `direction` | Pagination errors by direction | **Code:** `src/app/features/room/RoomTimeline.tsx` #### Sync -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| -| `sable.sync.transport` | count | `type` (`sliding`/`classic`) | Sync transport type used | -| `sable.sync.cycle` | count | (various) | Completed sliding sync cycles | -| `sable.sync.error` | count | `errcode` | Sliding sync errors | -| `sable.sync.initial_ms` | distribution | — | Initial sync completion time | -| `sable.sync.processing_ms` | distribution | — | Per-cycle sync processing time | -| `sable.sync.lists_loaded_ms` | distribution | — | Time for room lists to fully load | -| `sable.sync.total_rooms` | gauge | `sync_type` | Total rooms known at list load | -| `sable.sync.active_subscriptions` | gauge | — | Active room subscription count | -| `sable.sync.client_ready_ms` | distribution | `type` | Time from init to client ready | -| `sable.sync.time_to_ready_ms` | distribution | — | Wall-clock time to first sync ready | -| `sable.sync.degraded` | count | `state` | Sync reconnect/error state transitions | +| Metric | Type | Attributes | What it tracks | +| --------------------------------- | ------------ | ---------------------------- | -------------------------------------- | +| `sable.sync.transport` | count | `type` (`sliding`/`classic`) | Sync transport type used | +| `sable.sync.cycle` | count | (various) | Completed sliding sync cycles | +| `sable.sync.error` | count | `errcode` | Sliding sync errors | +| `sable.sync.initial_ms` | distribution | — | Initial sync completion time | +| `sable.sync.processing_ms` | distribution | — | Per-cycle sync processing time | +| `sable.sync.lists_loaded_ms` | distribution | — | Time for room lists to fully load | +| `sable.sync.total_rooms` | gauge | `sync_type` | Total rooms known at list load | +| `sable.sync.active_subscriptions` | gauge | — | Active room subscription count | +| `sable.sync.client_ready_ms` | distribution | `type` | Time from init to client ready | +| `sable.sync.time_to_ready_ms` | distribution | — | Wall-clock time to first sync ready | +| `sable.sync.degraded` | count | `state` | Sync reconnect/error state transitions | **Code:** `src/client/initMatrix.ts`, `src/client/slidingSync.ts`, `src/app/pages/client/ClientRoot.tsx`, `src/app/pages/client/SyncStatus.tsx` #### Media -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| +| Metric | Type | Attributes | What it tracks | +| ------------------------------- | ------------ | ---------- | ---------------------------- | | `sable.media.upload_latency_ms` | distribution | `mimetype` | Media upload round-trip time | -| `sable.media.upload_bytes` | distribution | `mimetype` | Upload size distribution | -| `sable.media.upload_error` | count | `reason` | Upload failures by reason | -| `sable.media.blob_cache_size` | gauge | — | Blob URL cache entry count | -| `sable.media.inflight_requests` | gauge | — | Concurrent media requests | +| `sable.media.upload_bytes` | distribution | `mimetype` | Upload size distribution | +| `sable.media.upload_error` | count | `reason` | Upload failures by reason | +| `sable.media.blob_cache_size` | gauge | — | Blob URL cache entry count | +| `sable.media.inflight_requests` | gauge | — | Concurrent media requests | **Code:** `src/app/utils/matrix.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx` #### Background clients & debug telemetry -| Metric | Type | Attributes | What it tracks | -|--------|------|-----------|----------------| -| `sable.background.client_count` | gauge | — | Active background notification clients | -| `sable.errors` | count | `category` | Error-level debug log entries | -| `sable.warnings` | count | `category` | Warning-level debug log entries | +| Metric | Type | Attributes | What it tracks | +| ------------------------------- | ----- | ---------- | -------------------------------------- | +| `sable.background.client_count` | gauge | — | Active background notification clients | +| `sable.errors` | count | `category` | Error-level debug log entries | +| `sable.warnings` | count | `category` | Warning-level debug log entries | **Code:** `src/app/pages/client/BackgroundNotifications.tsx`, `src/app/utils/debugLogger.ts` -### Session Replay *(opt-in, disabled by default)* +### Session Replay _(opt-in, disabled by default)_ When session replay is explicitly enabled by the user, Sentry records UI interactions to help reproduce bugs. **All content is masked at the browser @@ -213,15 +213,15 @@ media are ever visible in a replay**. Sample rates for replay: -| Trigger | Production | Preview / Dev | -|-----------------------|------------|---------------| -| Regular sessions | 10% | 100% | -| Sessions with errors | 100% | 100% | +| Trigger | Production | Preview / Dev | +| -------------------- | ---------- | ------------- | +| Regular sessions | 10% | 100% | +| Sessions with errors | 100% | 100% | **Code:** `src/instrument.ts` — `replayIntegration` call with `maskAllText`, `blockAllMedia`, `maskAllInputs` -### Bug Reports *(manual, opt-in per report)* +### Bug Reports _(manual, opt-in per report)_ When a user submits a bug report via `/bugreport` or the "Bug Report" button: @@ -272,11 +272,11 @@ Regex: `/(access_token|password|token|refresh_token|session_id|sync_token|next_b Matrix IDs are replaced with placeholder tokens before sending: -| Original form | Replaced with | -|-------------------|---------------| -| `@user:server` | `@[USER_ID]` | -| `!room:server` | `![ROOM_ID]` | -| `$event_id` | `$[EVENT_ID]` | +| Original form | Replaced with | +| -------------- | ------------- | +| `@user:server` | `@[USER_ID]` | +| `!room:server` | `![ROOM_ID]` | +| `$event_id` | `$[EVENT_ID]` | **Code:** `src/instrument.ts` — `beforeSend` callback (applied to `event.message` and all `event.exception.values`) @@ -287,11 +287,11 @@ and all `event.exception.values`) Users can adjust Sentry behaviour without restarting the app: -| Setting | Location | `localStorage` key | Default | -|---------|----------|--------------------|---------| -| Disable Sentry entirely | Settings → Developer Tools → Error Tracking | `sable_sentry_enabled` | Enabled | -| Enable session replay | Settings → Developer Tools → Error Tracking | `sable_sentry_replay_enabled` | Disabled (opt-in) | -| Disable breadcrumb categories | Settings → Developer Tools → Error Tracking → Breadcrumb Categories | `sable_sentry_breadcrumb_disabled` | All enabled | +| Setting | Location | `localStorage` key | Default | +| ----------------------------- | ------------------------------------------------------------------- | ---------------------------------- | ----------------- | +| Disable Sentry entirely | Settings → Developer Tools → Error Tracking | `sable_sentry_enabled` | Enabled | +| Enable session replay | Settings → Developer Tools → Error Tracking | `sable_sentry_replay_enabled` | Disabled (opt-in) | +| Disable breadcrumb categories | Settings → Developer Tools → Error Tracking → Breadcrumb Categories | `sable_sentry_breadcrumb_disabled` | All enabled | **Rate limiting:** A maximum of 50 error events are forwarded to Sentry per page load (session). Subsequent errors are silently dropped, protecting against quota exhaustion without affecting diff --git a/src/app/components/DefaultErrorPage.tsx b/src/app/components/DefaultErrorPage.tsx index 54ac642ea..62042cef1 100644 --- a/src/app/components/DefaultErrorPage.tsx +++ b/src/app/components/DefaultErrorPage.tsx @@ -75,7 +75,9 @@ export function ErrorPage({ error, eventId }: ErrorPageProps) { + + + ); +} + export function General({ requestClose }: GeneralProps) { return ( @@ -1078,6 +1181,7 @@ export function General({ requestClose }: GeneralProps) { + From ddd1a4b8c50a06a8f708e0e86985811c036bac18 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 16:15:40 -0400 Subject: [PATCH 24/25] fix: use 'none' sentinel instead of empty string for unset Sentry room tags --- src/app/pages/client/ClientNonUIFeatures.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 7ce1161de..a5e78e606 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -676,8 +676,8 @@ function SentryRoomContextFeature() { useEffect(() => { if (!roomId) { Sentry.setContext('room', null); - Sentry.setTag('room_type', ''); - Sentry.setTag('room_encrypted', ''); + Sentry.setTag('room_type', 'none'); + Sentry.setTag('room_encrypted', 'none'); return; } const room = mx.getRoom(roomId); From 99ff314feb9594654812459adeb999b6f2519776 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 15 Mar 2026 16:27:02 -0400 Subject: [PATCH 25/25] fix: scrub preview_url query params and apply URL scrubbing to exception values - Add .replace(/(\/preview_url)\?[^#\s]*/gi, '$1') to scrubMatrixUrl() so the full external URL in ?url= is stripped from preview_url breadcrumbs and spans - Apply scrubMatrixUrl() to exception.value strings in beforeSend so embedded URLs in MatrixError messages (e.g. 'Got error 403 (/preview_url?url=etsy.com/...)') are also redacted before sending to Sentry --- src/instrument.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/instrument.ts b/src/instrument.ts index 24b2371cb..29e599dee 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -69,6 +69,10 @@ function scrubMatrixUrl(url: string): string { .replace(/\/%40[^/?#\s]*/gi, '/[USER_ID]') // URL-encoded room IDs: /%21room%3Aserver (%21 = !) .replace(/\/%21[^/?#\s]*/gi, '/![ROOM_ID]') + // ── Preview URL endpoint ──────────────────────────────────────────────────────── + // The ?url= query parameter on preview_url contains the full external URL being + // previewed — strip the entire query string so browsing habits cannot be inferred. + .replace(/(\/preview_url)\?[^#\s]*/gi, '$1') ); } @@ -294,6 +298,11 @@ if (dsn && sentryEnabled) { exception.value = exception.value.replace(/![^:]+:[^\s]+/g, '![ROOM_ID]'); // eslint-disable-next-line no-param-reassign exception.value = exception.value.replace(/\$[^:\s]+/g, '$[EVENT_ID]'); + // Scrub Matrix URL patterns embedded in error message strings + // (e.g. MatrixError: "Got error 403 (https://.../preview_url?url=https://...)" + // or paths containing room/user/event IDs) + // eslint-disable-next-line no-param-reassign + exception.value = scrubMatrixUrl(exception.value); } }); }