From a084ebf45caeaffb344bc9728757f09329f93d2a Mon Sep 17 00:00:00 2001 From: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Date: Sat, 2 May 2026 03:41:42 +0530 Subject: [PATCH] =?UTF-8?q?fix(sentry):=20auto-send=20React=20events;=20co?= =?UTF-8?q?llapse=20core=E2=86=92tauri=20for=20desktop=20(#1086)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Steven Enamakel --- .github/workflows/release-staging.yml | 86 +++-- .github/workflows/release.yml | 44 ++- app/.env.example | 6 + app/src-tauri/tauri.conf.json | 4 - app/src/App.tsx | 6 +- app/src/components/ErrorFallbackScreen.tsx | 5 +- .../components/ErrorReportNotification.tsx | 218 ----------- app/src/main.tsx | 9 - app/src/services/analytics.ts | 218 ++++------- app/src/services/errorReportQueue.test.ts | 361 ------------------ app/src/services/errorReportQueue.ts | 239 ------------ app/src/utils/config.ts | 7 + app/src/utils/desktopDeepLinkListener.ts | 23 +- app/src/vite-env.d.ts | 1 + 14 files changed, 174 insertions(+), 1053 deletions(-) delete mode 100644 app/src/components/ErrorReportNotification.tsx delete mode 100644 app/src/services/errorReportQueue.test.ts delete mode 100644 app/src/services/errorReportQueue.ts diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index a6f2849ea..87e4017c3 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -280,6 +280,10 @@ jobs: CORE_MANIFEST: ${{ steps.core-paths.outputs.core_manifest }} CORE_BIN_NAME: ${{ steps.core-paths.outputs.core_bin_name }} OPENHUMAN_APP_ENV: staging + # Bake the short SHA into the CLI binary so build_release_tag() in + # src/main.rs produces openhuman@+ — matching the + # Sentry release tag used when uploading the standalone CLI symbols. + OPENHUMAN_BUILD_SHA: ${{ needs.prepare-build.outputs.short_sha }} - name: Stage sidecar for Tauri bundler shell: bash run: | @@ -331,24 +335,31 @@ jobs: run: | NODE_OPTIONS="--max-old-space-size=8192" cargo tauri build --debug -c "$TAURI_CONFIG_OVERRIDE" $MATRIX_ARGS - # Upload Rust debug info to Sentry so backend + Tauri-shell stack - # traces symbolicate in the staging Sentry project too. Frontend - # source maps are handled by sentry-vite-plugin in the build step - # above; this is the Rust half. Symbols are keyed by debug-ID, so - # it's safe to run per-matrix-target without collisions — Sentry - # merges artifacts across platforms. - - name: Upload core sidecar debug symbols to Sentry + # Upload Rust debug info to Sentry so all Rust stack traces + # symbolicate in the staging Sentry projects. Frontend source maps + # are handled by sentry-vite-plugin in the build step above; this + # is the Rust half. + # + # Two distinct binaries → two distinct Sentry projects: + # - **Tauri shell** (`app/src-tauri/target//debug`) — the + # actual desktop app users run. Core lives in-process here as a + # linked path dep, so the shell's DWARF covers core code too. + # Events all route to `openhuman-tauri` (one `sentry::init` per + # process, in `app/src-tauri/src/lib.rs::run()`). + # - **Standalone CLI** (`target//debug`) — the separate + # `openhuman-core` binary built by "Build sidecar core binary". + # Has its own `sentry::init` in `src/main.rs` and reports to + # `openhuman-core`. Currently unused at runtime in the desktop + # build but kept symbolicated for any operator running it. + # Symbols are keyed by debug-ID so cross-platform / cross-binary + # uploads don't collide — Sentry just merges them. + - name: Upload Tauri shell debug symbols to Sentry (tauri project) if: env.SENTRY_AUTH_TOKEN != '' shell: bash env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} - SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_CORE }} - # Must match the release tag the running core binary reports - # (`openhuman@+`, see `build_release_tag` in - # src/main.rs). Uses short_sha (12 chars) — see the prepare-build - # outputs comment. If this drifts, DIFs attach to a different - # release than events and stack traces stay un-symbolicated. + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_TAURI }} SENTRY_RELEASE: openhuman@${{ needs.prepare-build.outputs.version }}+${{ needs.prepare-build.outputs.short_sha }} @@ -356,26 +367,35 @@ jobs: MATRIX_TARGET: ${{ matrix.settings.target }} run: | set -euo pipefail - # Two DIF locations on staging: - # - `app/src-tauri/target//debug` — Tauri shell binary - # (which links the core as a path dep, so its DWARF covers core - # too). - # - `target//debug` — standalone `openhuman-core` CLI - # binary built by the "Build sidecar core binary" step earlier - # and published as a separate artifact. Crashes from operators - # running the standalone CLI need its DIFs to symbolicate. - # Symbols are keyed by debug-ID so cross-platform / cross-binary - # uploads don't collide — Sentry just merges them. - for dif_dir in \ - "app/src-tauri/target/${MATRIX_TARGET}/debug" \ - "target/${MATRIX_TARGET}/debug"; do - if [ -d "$dif_dir" ]; then - echo "==> Uploading symbols from $dif_dir to ${SENTRY_PROJECT}" - bash scripts/upload_sentry_symbols.sh "$VERSION" "$dif_dir" - else - echo "==> Skipping $dif_dir (not present)" - fi - done + dif_dir="app/src-tauri/target/${MATRIX_TARGET}/debug" + if [ -d "$dif_dir" ]; then + echo "==> Uploading symbols from $dif_dir to ${SENTRY_PROJECT}" + bash scripts/upload_sentry_symbols.sh "$VERSION" "$dif_dir" + else + echo "==> Skipping $dif_dir (not present)" + fi + + - name: Upload standalone core CLI debug symbols to Sentry (core project) + if: env.SENTRY_AUTH_TOKEN != '' + shell: bash + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_CORE }} + SENTRY_RELEASE: + openhuman@${{ needs.prepare-build.outputs.version }}+${{ + needs.prepare-build.outputs.short_sha }} + VERSION: ${{ needs.prepare-build.outputs.version }} + MATRIX_TARGET: ${{ matrix.settings.target }} + run: | + set -euo pipefail + dif_dir="target/${MATRIX_TARGET}/debug" + if [ -d "$dif_dir" ]; then + echo "==> Uploading symbols from $dif_dir to ${SENTRY_PROJECT}" + bash scripts/upload_sentry_symbols.sh "$VERSION" "$dif_dir" + else + echo "==> Skipping $dif_dir (not present)" + fi - name: Upload staging desktop bundles uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7aa54d449..b9e216e64 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -514,14 +514,19 @@ jobs: NODE_OPTIONS="--max-old-space-size=8192" cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" $MATRIX_ARGS fi - # Upload Rust debug info to Sentry so backend + Tauri-shell stack traces - # symbolicate in production. The frontend source maps are handled by - # sentry-vite-plugin in the build step above; this is the Rust half. - # Core sidecar and Tauri shell go to **separate Sentry projects** - # (matching their separate DSNs) so events show up in the right place. - # Symbols are keyed by debug-ID, so it's safe to run per-matrix-target - # without collisions — Sentry merges artifacts across platforms. - - name: Upload core sidecar debug symbols to Sentry + # Upload Rust debug info to Sentry so all Rust stack traces (Tauri + # shell + linked-in core) symbolicate in production. The frontend + # source maps are handled by sentry-vite-plugin in the build step + # above; this is the Rust half. + # + # Since #1061 the core lives in-process as a library linked into the + # Tauri shell binary — there is exactly one Rust process and one + # `sentry::init` call (in `app/src-tauri/src/lib.rs::run()`), so all + # Rust events route to `openhuman-tauri`. Upload DIFs to that same + # project so they actually attach to the right events. Symbols are + # keyed by debug-ID, so it's safe to run per-matrix-target without + # collisions — Sentry merges artifacts across platforms. + - name: Upload Rust debug symbols to Sentry (tauri project) if: needs.prepare-build.outputs.release_enabled == 'true' && env.SENTRY_AUTH_TOKEN != '' @@ -529,12 +534,13 @@ jobs: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} - SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_CORE }} - # Must match the release tag the running core binary reports - # (`openhuman@+`, see `build_release_tag` in - # src/main.rs). Uses short_sha (12 chars) — see the prepare-build - # outputs comment. If this drifts, DIFs attach to a different - # release than events and stack traces stay un-symbolicated. + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_TAURI }} + # Must match the release tag the running binary reports + # (`openhuman@+`, see `build_sentry_release_tag` + # in `app/src-tauri/src/lib.rs`). Uses short_sha (12 chars) — see + # the prepare-build outputs comment. If this drifts, DIFs attach + # to a different release than events and stack traces stay + # un-symbolicated. SENTRY_RELEASE: openhuman@${{ needs.prepare-build.outputs.version }}+${{ needs.prepare-build.outputs.short_sha }} @@ -545,12 +551,12 @@ jobs: # Core is linked into the Tauri binary as a path dep, so all Rust # debug info — core + shell — lives under the shell's target dir. # upload-dif scans recursively. - deps_dir="app/src-tauri/target/${MATRIX_TARGET}/release/deps" - if [ -d "$deps_dir" ]; then - echo "==> Uploading symbols from $deps_dir to ${SENTRY_PROJECT}" - bash scripts/upload_sentry_symbols.sh "$VERSION" "$deps_dir" + dif_dir="app/src-tauri/target/${MATRIX_TARGET}/release" + if [ -d "$dif_dir" ]; then + echo "==> Uploading symbols from $dif_dir to ${SENTRY_PROJECT}" + bash scripts/upload_sentry_symbols.sh "$VERSION" "$dif_dir" else - echo "==> Skipping $deps_dir (not present)" + echo "==> Skipping $dif_dir (not present)" fi # tauri-action previously uploaded non-macOS installer assets directly diff --git a/app/.env.example b/app/.env.example index 9aaa0e93e..9cf273125 100644 --- a/app/.env.example +++ b/app/.env.example @@ -37,6 +37,12 @@ VITE_SENTRY_DSN= # falls back to `openhuman@`). VITE_BUILD_SHA= +# [optional] One-shot Sentry pipeline smoke test. When `true`, the next +# `initSentry()` call dispatches a `react-sentry-smoke-test` event so you +# can confirm the DSN, source maps, and release tagging are wired +# end-to-end. Leave blank in normal builds. +# VITE_SENTRY_SMOKE_TEST=true + # [CI-only] Sentry source-map upload — set on CI to enable # `@sentry/vite-plugin`. Leave blank locally; the plugin skips when # `SENTRY_AUTH_TOKEN` is empty. diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index c59339b02..3e83cf63c 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -56,10 +56,6 @@ "libx11-6", "libgdk-pixbuf-2.0-0", "libglib2.0-0" - ], - "args": [ - "--enable-features=UseOzonePlatform", - "--ozone-platform=x11" ] } }, diff --git a/app/src/App.tsx b/app/src/App.tsx index c2340ffbc..cf39c0d4e 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -21,7 +21,6 @@ import { startWebviewNotificationsService } from './lib/webviewNotifications'; import ChatRuntimeProvider from './providers/ChatRuntimeProvider'; import CoreStateProvider, { useCoreState } from './providers/CoreStateProvider'; import SocketProvider from './providers/SocketProvider'; -import { tagErrorSource } from './services/errorReportQueue'; import { startWebviewAccountService } from './services/webviewAccountService'; import { persistor, store } from './store'; import { useAppDispatch, useAppSelector } from './store/hooks'; @@ -42,10 +41,7 @@ function App() { ( - )} - onError={(_error, componentStack, eventId) => { - tagErrorSource(eventId, 'react', componentStack); - }}> + )}> } persistor={persistor}> diff --git a/app/src/components/ErrorFallbackScreen.tsx b/app/src/components/ErrorFallbackScreen.tsx index 1397f56c3..f32360c41 100644 --- a/app/src/components/ErrorFallbackScreen.tsx +++ b/app/src/components/ErrorFallbackScreen.tsx @@ -8,8 +8,9 @@ import { openUrl } from '../utils/openUrl'; * a catastrophic React render error. Self-contained with zero dependencies * on Redux, Router, or any context provider. * - * The ErrorReportNotification lives in a separate React root, so the user - * can still review and report the error from this screen. + * Errors caught by the boundary are auto-forwarded to Sentry by the + * `Sentry.ErrorBoundary` wrapper in `App.tsx` (subject to user analytics + * consent enforced in `analytics.ts::beforeSend`). */ interface ErrorFallbackScreenProps { diff --git a/app/src/components/ErrorReportNotification.tsx b/app/src/components/ErrorReportNotification.tsx deleted file mode 100644 index 7f84041c0..000000000 --- a/app/src/components/ErrorReportNotification.tsx +++ /dev/null @@ -1,218 +0,0 @@ -/** - * ErrorReportNotification - * - * Non-blocking notification UI rendered via createPortal into its own React root. - * Subscribes to the error report queue and lets users inspect, dismiss, or - * report each error individually. - */ -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; -import { createPortal } from 'react-dom'; - -import { isAnalyticsEnabled } from '../services/analytics'; -import { - dequeueError, - getErrors, - type PendingErrorReport, - sendToSentry, - subscribe, -} from '../services/errorReportQueue'; - -const MAX_VISIBLE = 3; -const AUTO_DISMISS_MS = 30_000; - -// --------------------------------------------------------------------------- -// Single notification card -// --------------------------------------------------------------------------- - -function NotificationCard({ - report, - onDismiss, -}: { - report: PendingErrorReport; - onDismiss: (id: string) => void; -}) { - const [expanded, setExpanded] = useState(false); - const [sent, setSent] = useState(false); - const [exiting, setExiting] = useState(false); - const timerRef = useRef>(undefined); - const analyticsEnabled = isAnalyticsEnabled(); - const isDevOnly = !report.sentryEvent; - - const animateOut = useCallback( - (id: string) => { - setExiting(true); - setTimeout(() => onDismiss(id), 200); - }, - [onDismiss] - ); - - // Auto-dismiss timer - useEffect(() => { - timerRef.current = setTimeout(() => { - animateOut(report.id); - }, AUTO_DISMISS_MS); - return () => clearTimeout(timerRef.current); - }, [report.id, animateOut]); - - const handleDismiss = useCallback(() => { - clearTimeout(timerRef.current); - animateOut(report.id); - }, [report.id, animateOut]); - - const handleReport = useCallback(() => { - clearTimeout(timerRef.current); - const ok = sendToSentry(report); - if (ok) { - setSent(true); - setTimeout(() => onDismiss(report.id), 1200); - } - }, [report, onDismiss]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Escape') handleDismiss(); - if (e.key === 'Enter') setExpanded(prev => !prev); - }, - [handleDismiss] - ); - - return ( -
- {/* Header */} -
- {/* Error icon */} -
- -
- -
-
- {report.title} - {isDevOnly && ( - - DEV - - )} -
-

{report.message}

-
- - {/* Close button */} - -
- - {/* Expand toggle */} - {report.sentryEvent && ( - - )} - - {/* Expanded payload viewer */} - {expanded && report.sentryEvent && ( -
-
-            {JSON.stringify(report.sentryEvent, null, 2)}
-          
-
- )} - - {/* Actions */} -
- - - {sent ? ( - Sent - ) : isDevOnly ? ( - Console only - ) : !analyticsEnabled ? ( - - ) : ( - - )} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Main notification container -// --------------------------------------------------------------------------- - -export default function ErrorReportNotification() { - const errors = useSyncExternalStore(subscribe, getErrors, getErrors); - - const handleDismiss = useCallback((id: string) => { - dequeueError(id); - }, []); - - // Escape key dismisses topmost notification - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape' && errors.length > 0) { - dequeueError(errors[errors.length - 1].id); - } - }; - window.addEventListener('keydown', handleKey); - return () => window.removeEventListener('keydown', handleKey); - }, [errors]); - - if (errors.length === 0) return null; - - const visible = errors.slice(-MAX_VISIBLE); - const hiddenCount = errors.length - MAX_VISIBLE; - - return createPortal( -
- {visible.map(report => ( - - ))} - - {hiddenCount > 0 && ( -
- +{hiddenCount} more {hiddenCount === 1 ? 'error' : 'errors'} -
- )} -
, - document.body - ); -} diff --git a/app/src/main.tsx b/app/src/main.tsx index 7cd79de64..f98a699a4 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -5,7 +5,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; -import ErrorReportNotification from './components/ErrorReportNotification'; import './index.css'; import { getCoreStateSnapshot } from './lib/coreState/store'; import OverlayApp from './overlay/OverlayApp'; @@ -55,14 +54,6 @@ if (!isOverlayWindow) { function bootRender() { const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render({isOverlayWindow ? : }); - - if (!isOverlayWindow) { - // Mount error notification in an isolated React root so it survives App crashes. - const errorRoot = document.createElement('div'); - errorRoot.id = 'error-report-root'; - document.body.appendChild(errorRoot); - ReactDOM.createRoot(errorRoot).render(); - } } getActiveUserIdFromCore() diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index 823de3f92..f09c16bd8 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -1,220 +1,138 @@ /** * Analytics & Sentry service * - * Manages Sentry error reporting gated behind user analytics consent. - * Designed to capture ONLY: - * - Error message & stack trace - * - Device / browser metadata (via Sentry's default user-agent parsing) - * - Source file location (via source maps) + * Initializes Sentry for the React frontend with auto-send semantics: + * captured errors are sanitized in `beforeSend` and forwarded to Sentry, + * gated only by user analytics consent. * - * Explicitly strips: - * - All breadcrumbs (console, click, network, etc.) - * - Redux state / localStorage / sessionStorage - * - User PII (IP address, cookies) - * - Request bodies / headers - * - Session replay - * - * Error flow: beforeSend intercepts all events, sanitizes them, queues them - * in the errorReportQueue for user opt-in, and returns null to prevent - * auto-sending. Users can then review and explicitly report each error. + * Privacy guarantees enforced in `beforeSend`: + * - No breadcrumbs, requests, extras, or arbitrary contexts (only OS / + * browser / device metadata kept) + * - No frame-level locals or source-context snippets + * - No PII — `user` is reduced to a stable anonymous id (or omitted) + * - `sendDefaultPii: false` (no IP, no cookies) + * - All breadcrumb-producing integrations disabled */ import * as Sentry from '@sentry/react'; import { getCoreStateSnapshot } from '../lib/coreState/store'; -import { APP_ENVIRONMENT, IS_DEV, SENTRY_DSN, SENTRY_RELEASE } from '../utils/config'; -import { enqueueError, registerSentrySender, type SanitizedSentryEvent } from './errorReportQueue'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Strip sensitive fields from the exception object before including it - * in the sanitized event shown to the user and sent to Sentry. - * - * Removes: local variables (vars), source code lines (context_line, - * pre_context, post_context), mechanism.data, and module_metadata. - */ -function sanitizeException( - exception: Sentry.Event['exception'] -): SanitizedSentryEvent['exception'] { - if (!exception?.values) return undefined; - - return { - values: exception.values.map(entry => ({ - type: entry.type ?? 'Error', - value: entry.value ?? '', - stacktrace: entry.stacktrace?.frames - ? { - frames: entry.stacktrace.frames.map(frame => ({ - filename: frame.filename, - function: frame.function, - module: frame.module, - lineno: frame.lineno, - colno: frame.colno, - abs_path: frame.abs_path, - in_app: frame.in_app, - // Stripped: vars, context_line, pre_context, post_context, - // instruction_addr, addr_mode, debug_id, module_metadata - })), - } - : undefined, - mechanism: entry.mechanism - ? { type: entry.mechanism.type, handled: entry.mechanism.handled } - : undefined, - // Stripped: mechanism.data (arbitrary key-value pairs) - })), - }; -} +import { + APP_ENVIRONMENT, + IS_DEV, + SENTRY_DSN, + SENTRY_RELEASE, + SENTRY_SMOKE_TEST, +} from '../utils/config'; /** Check if the current user has opted into analytics. */ export function isAnalyticsEnabled(): boolean { return getCoreStateSnapshot().snapshot.analyticsEnabled; } -// --------------------------------------------------------------------------- -// Bypass flag — when true, beforeSend passes the event through to Sentry -// --------------------------------------------------------------------------- - -let _bypassBeforeSend = false; - -// --------------------------------------------------------------------------- -// Sentry initialisation -// --------------------------------------------------------------------------- - export function initSentry(): void { if (!SENTRY_DSN) return; Sentry.init({ dsn: SENTRY_DSN, environment: APP_ENVIRONMENT, - // Canonical release tag shared with the core sidecar and source-map - // upload (see @sentry/vite-plugin in app/vite.config.ts). Lets events - // from all surfaces group under a single Sentry release with - // symbolicated stack traces. + // Canonical release tag shared with the Tauri shell (see + // `app/src-tauri/src/lib.rs::build_sentry_release_tag`) and the Vite + // source-map upload (see `@sentry/vite-plugin` in app/vite.config.ts) + // so events from every surface group under the same release. release: SENTRY_RELEASE, - enabled: !IS_DEV, // disable in dev builds + enabled: !IS_DEV, - // ----------------------------------------------------------------------- - // Privacy: disable EVERYTHING that could leak sensitive state - // ----------------------------------------------------------------------- - - // No session replay + // Privacy: disable EVERYTHING that could leak sensitive state. replaysSessionSampleRate: 0, replaysOnErrorSampleRate: 0, - - // No performance / tracing tracesSampleRate: 0, - - // No breadcrumbs at all (console, clicks, network, etc.) defaultIntegrations: false, integrations: [ - // Only keep the bare-minimum integrations for stack traces Sentry.functionToStringIntegration(), Sentry.linkedErrorsIntegration(), Sentry.dedupeIntegration(), Sentry.browserApiErrorsIntegration(), Sentry.globalHandlersIntegration(), ], - - // Strip IP address sendDefaultPii: false, - // ----------------------------------------------------------------------- - // Intercept every event: sanitize, queue for user opt-in, block auto-send - // ----------------------------------------------------------------------- beforeSend(event) { - // Bypass mode: let the event through (used by sendEventToSentry) - if (_bypassBeforeSend) { - _bypassBeforeSend = false; - return event; - } + // Always allow the smoke-test event through so pipeline validation works + // even when the user hasn't opted into analytics yet on first boot. + const isSmokeTest = event.message === 'react-sentry-smoke-test'; + // Drop events when the user hasn't opted into analytics. + if (!isSmokeTest && !isAnalyticsEnabled()) return null; - // --- Sanitize the event --- - - // Strip any breadcrumbs that somehow snuck in + // Strip anything that could carry Redux / localStorage / request bodies. event.breadcrumbs = []; - - // Strip request data (cookies, headers, body) delete event.request; - - // Strip user PII — keep only a stable anonymous ID - const userId = getCoreStateSnapshot().snapshot.currentUser?._id; - event.user = userId ? { id: userId } : undefined; - - // Strip any extra/contexts that could contain Redux or localStorage data delete event.extra; event.contexts = { - // Keep only OS / browser / device metadata os: event.contexts?.os, browser: event.contexts?.browser, device: event.contexts?.device, }; - // --- Build a sanitized snapshot for the user to inspect --- - const sanitized: SanitizedSentryEvent = { - event_id: event.event_id ?? crypto.randomUUID().replace(/-/g, ''), - timestamp: typeof event.timestamp === 'number' ? event.timestamp : Date.now() / 1000, - platform: event.platform ?? 'javascript', - exception: sanitizeException(event.exception), - contexts: event.contexts as SanitizedSentryEvent['contexts'], - user: event.user as SanitizedSentryEvent['user'], - tags: event.tags as Record | undefined, - environment: IS_DEV ? 'development' : 'production', - }; + // Tag with surface so events filter cleanly inside `openhuman-react`. + event.tags = { ...(event.tags ?? {}), surface: 'react' }; - // Extract human-readable title + message from the exception - const firstException = event.exception?.values?.[0]; - const title = firstException?.type ?? 'Error'; - const message = firstException?.value ?? 'Unknown error'; + // Strip PII; keep a stable anonymous user id only. + const userId = getCoreStateSnapshot().snapshot.currentUser?._id; + event.user = userId ? { id: userId } : undefined; - // Queue the error for the notification UI - enqueueError({ - id: crypto.randomUUID(), - timestamp: Date.now(), - source: 'global', - title, - message, - sentryEvent: sanitized, - }); + // Strip frame-level local variables and source context — never send + // raw source snippets or live variable values to the dashboard. + if (event.exception?.values) { + for (const v of event.exception.values) { + if (v.stacktrace?.frames) { + for (const f of v.stacktrace.frames) { + delete f.vars; + delete f.context_line; + delete f.pre_context; + delete f.post_context; + } + } + if (v.mechanism) { + delete v.mechanism.data; + } + } + } - // Return null to prevent Sentry from auto-sending - return null; + return event; }, beforeSendTransaction() { - // Block all transactions (performance traces) + // Block all transactions (performance traces). return null; }, - // Ignore common non-actionable errors + // Ignore common non-actionable errors. ignoreErrors: ['ResizeObserver loop', 'Network request failed', 'Load failed', 'AbortError'], }); - // Register the bypass sender so the error queue can actually send events - registerSentrySender((sanitizedEvent: SanitizedSentryEvent) => { - _bypassBeforeSend = true; - Sentry.captureEvent(sanitizedEvent as unknown as Sentry.Event); - }); + // Optional smoke trigger for verifying the pipeline end-to-end. Set + // `VITE_SENTRY_SMOKE_TEST=true` for one build (or in `.env.local` for + // local verification) and the next initSentry call will fire a test + // message before returning. No-op when unset. The smoke event bypasses + // the analytics-consent gate in `beforeSend` so it reaches Sentry even + // on a fresh install where consent hasn't been granted yet. + if (SENTRY_SMOKE_TEST) { + Sentry.captureMessage('react-sentry-smoke-test', 'info'); + } } -// --------------------------------------------------------------------------- -// Consent sync — call when the user toggles analytics on/off -// --------------------------------------------------------------------------- - /** * Re-sync Sentry's enabled state after the user changes their consent. * Called from onboarding and settings. + * + * `beforeSend` reads `isAnalyticsEnabled()` on every event, so toggling + * consent takes effect immediately for new errors. Flush pending events + * on opt-out so anything already in flight respects the previous state. */ export function syncAnalyticsConsent(enabled: boolean): void { const client = Sentry.getClient(); if (!client) return; - - if (enabled) { - // Client is already initialised; events will pass through beforeSend - // because isAnalyticsEnabled() will now return true. - } else { - // Flush any pending events, then future events will be dropped by beforeSend. + if (!enabled) { void Sentry.flush(2000); } } diff --git a/app/src/services/errorReportQueue.test.ts b/app/src/services/errorReportQueue.test.ts deleted file mode 100644 index e1559b2c0..000000000 --- a/app/src/services/errorReportQueue.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -/** - * @vitest-environment jsdom - * - * Tests for errorReportQueue — module-level error queue with no React/Redux/Sentry deps. - * - * Because the module holds mutable singleton state, we re-import it fresh in each - * describe block via `vi.resetModules()` to isolate tests from each other. - * - * The source module calls `initGlobalListeners()` at load time, which reads `window` - * — the explicit environment directive ensures jsdom is active even when Vitest picks - * a different default for a given run. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// setup.ts already mocks @sentry/react and ../utils/config globally. -// We rely on those global mocks here. - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeReport( - id: string, - overrides: Partial<{ - title: string; - message: string; - source: 'react' | 'global' | 'manual'; - timestamp: number; - }> = {} -) { - return { - id, - timestamp: overrides.timestamp ?? Date.now(), - source: overrides.source ?? ('manual' as const), - title: overrides.title ?? `Error ${id}`, - message: overrides.message ?? `Message for ${id}`, - sentryEvent: null, - }; -} - -// --------------------------------------------------------------------------- -// enqueueError / getErrors / dequeueError -// --------------------------------------------------------------------------- - -describe('errorReportQueue — enqueue / dequeue / getErrors', () => { - let eq: typeof import('./errorReportQueue'); - - beforeEach(async () => { - vi.resetModules(); - eq = await import('./errorReportQueue'); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('starts with an empty queue', () => { - expect(eq.getErrors()).toEqual([]); - }); - - it('adds a report and returns it from getErrors', () => { - const report = makeReport('r1'); - eq.enqueueError(report); - - const queue = eq.getErrors(); - expect(queue).toHaveLength(1); - expect(queue[0].id).toBe('r1'); - }); - - it('removes a report via dequeueError', () => { - eq.enqueueError(makeReport('r1')); - eq.enqueueError(makeReport('r2')); - eq.dequeueError('r1'); - - const queue = eq.getErrors(); - expect(queue).toHaveLength(1); - expect(queue[0].id).toBe('r2'); - }); - - it('is a no-op when dequeuing a non-existent ID', () => { - eq.enqueueError(makeReport('r1')); - eq.dequeueError('does-not-exist'); - - expect(eq.getErrors()).toHaveLength(1); - }); - - it('preserves insertion order', () => { - eq.enqueueError(makeReport('r1')); - eq.enqueueError(makeReport('r2')); - eq.enqueueError(makeReport('r3')); - - const ids = eq.getErrors().map(r => r.id); - expect(ids).toEqual(['r1', 'r2', 'r3']); - }); - - it('caps queue at 10 items, keeping the most recent', () => { - for (let i = 0; i < 12; i++) { - // Each report has a unique title+message to avoid dedup - eq.enqueueError(makeReport(`r${i}`, { title: `T${i}`, message: `M${i}` })); - } - - const queue = eq.getErrors(); - expect(queue).toHaveLength(10); - // Oldest two should have been dropped - expect(queue[0].id).toBe('r2'); - expect(queue[9].id).toBe('r11'); - }); -}); - -// --------------------------------------------------------------------------- -// Deduplication -// --------------------------------------------------------------------------- - -describe('errorReportQueue — deduplication', () => { - let eq: typeof import('./errorReportQueue'); - - beforeEach(async () => { - vi.useFakeTimers(); - vi.resetModules(); - eq = await import('./errorReportQueue'); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('suppresses a duplicate error within the dedup window (2 s)', () => { - const base = makeReport('r1', { title: 'Same', message: 'Same' }); - eq.enqueueError(base); - // Same title + message but different ID — should be deduped - eq.enqueueError({ ...base, id: 'r1-dup' }); - - expect(eq.getErrors()).toHaveLength(1); - }); - - it('allows the same error again after the dedup window expires', () => { - const base = makeReport('r1', { title: 'Same', message: 'Same' }); - eq.enqueueError(base); - - // Advance past the 2000 ms DEDUP_WINDOW_MS - vi.advanceTimersByTime(2001); - - eq.enqueueError({ ...base, id: 'r1-after' }); - expect(eq.getErrors()).toHaveLength(2); - }); - - it('allows distinct errors (different title/message) within the dedup window', () => { - eq.enqueueError(makeReport('r1', { title: 'A', message: 'a' })); - eq.enqueueError(makeReport('r2', { title: 'B', message: 'b' })); - - expect(eq.getErrors()).toHaveLength(2); - }); -}); - -// --------------------------------------------------------------------------- -// subscribe / notify -// --------------------------------------------------------------------------- - -describe('errorReportQueue — subscribe', () => { - let eq: typeof import('./errorReportQueue'); - - beforeEach(async () => { - vi.resetModules(); - eq = await import('./errorReportQueue'); - }); - - it('calls subscriber when a report is enqueued', () => { - const cb = vi.fn(); - eq.subscribe(cb); - eq.enqueueError(makeReport('r1')); - - expect(cb).toHaveBeenCalledTimes(1); - }); - - it('calls subscriber when a report is dequeued', () => { - eq.enqueueError(makeReport('r1')); - const cb = vi.fn(); - eq.subscribe(cb); - eq.dequeueError('r1'); - - expect(cb).toHaveBeenCalledTimes(1); - }); - - it('does not call subscriber after unsubscribe', () => { - const cb = vi.fn(); - const unsub = eq.subscribe(cb); - unsub(); - eq.enqueueError(makeReport('r1')); - - expect(cb).not.toHaveBeenCalled(); - }); - - it('supports multiple simultaneous subscribers', () => { - const cb1 = vi.fn(); - const cb2 = vi.fn(); - eq.subscribe(cb1); - eq.subscribe(cb2); - eq.enqueueError(makeReport('r1')); - - expect(cb1).toHaveBeenCalledTimes(1); - expect(cb2).toHaveBeenCalledTimes(1); - }); - - it('continues notifying other subscribers if one throws', () => { - const bad = vi.fn(() => { - throw new Error('subscriber boom'); - }); - const good = vi.fn(); - eq.subscribe(bad); - eq.subscribe(good); - - expect(() => eq.enqueueError(makeReport('r1'))).not.toThrow(); - expect(good).toHaveBeenCalledTimes(1); - }); -}); - -// --------------------------------------------------------------------------- -// tagErrorSource -// --------------------------------------------------------------------------- - -describe('errorReportQueue — tagErrorSource', () => { - let eq: typeof import('./errorReportQueue'); - - beforeEach(async () => { - vi.resetModules(); - eq = await import('./errorReportQueue'); - }); - - it('updates source on a queued report matched by sentryEvent.event_id', () => { - const report = { - ...makeReport('r1'), - source: 'global' as const, - sentryEvent: { - event_id: 'sentry-abc', - timestamp: Date.now() / 1000, - platform: 'javascript', - environment: 'test', - }, - }; - eq.enqueueError(report); - eq.tagErrorSource('sentry-abc', 'react', ''); - - const updated = eq.getErrors().find(r => r.id === 'r1'); - expect(updated?.source).toBe('react'); - expect(updated?.componentStack).toBe(''); - }); - - it('is a no-op when event_id is undefined', () => { - eq.enqueueError(makeReport('r1')); - expect(() => eq.tagErrorSource(undefined, 'react')).not.toThrow(); - expect(eq.getErrors()[0].source).toBe('manual'); - }); - - it('is a no-op when event_id does not match any queued report', () => { - eq.enqueueError(makeReport('r1')); - expect(() => eq.tagErrorSource('unknown-id', 'react')).not.toThrow(); - expect(eq.getErrors()[0].source).toBe('manual'); - }); -}); - -// --------------------------------------------------------------------------- -// registerSentrySender / sendToSentry -// --------------------------------------------------------------------------- - -describe('errorReportQueue — sendToSentry', () => { - let eq: typeof import('./errorReportQueue'); - - beforeEach(async () => { - vi.resetModules(); - eq = await import('./errorReportQueue'); - }); - - it('returns false when no sender is registered', () => { - const report = { - ...makeReport('r1'), - sentryEvent: { - event_id: 'evt-1', - timestamp: Date.now() / 1000, - platform: 'javascript', - environment: 'test', - }, - }; - eq.enqueueError(report); - - expect(eq.sendToSentry(report)).toBe(false); - }); - - it('returns false when sentryEvent is null', () => { - const sender = vi.fn(); - const report = makeReport('r1'); - eq.enqueueError(report); - eq.registerSentrySender(sender); - - expect(eq.sendToSentry(report)).toBe(false); - expect(sender).not.toHaveBeenCalled(); - }); - - it('calls the registered sender and removes the report on success', () => { - const sender = vi.fn(); - const sentryEvent = { - event_id: 'evt-2', - timestamp: Date.now() / 1000, - platform: 'javascript', - environment: 'test', - }; - const report = { ...makeReport('r2'), sentryEvent }; - eq.enqueueError(report); - eq.registerSentrySender(sender); - - const result = eq.sendToSentry(report); - - expect(result).toBe(true); - expect(sender).toHaveBeenCalledWith(sentryEvent); - expect(eq.getErrors().find(r => r.id === 'r2')).toBeUndefined(); - }); -}); - -// --------------------------------------------------------------------------- -// buildManualSentryEvent -// --------------------------------------------------------------------------- - -describe('errorReportQueue — buildManualSentryEvent', () => { - let eq: typeof import('./errorReportQueue'); - - beforeEach(async () => { - vi.resetModules(); - eq = await import('./errorReportQueue'); - }); - - it('creates an event with the correct error type and value', () => { - const evt = eq.buildManualSentryEvent({ type: 'TypeError', value: 'bad input' }); - - expect(evt.exception?.values[0].type).toBe('TypeError'); - expect(evt.exception?.values[0].value).toBe('bad input'); - }); - - it('sets platform to javascript', () => { - const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' }); - expect(evt.platform).toBe('javascript'); - }); - - it('includes optional tags when provided', () => { - const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' }, { context: 'chat' }); - expect(evt.tags).toEqual({ context: 'chat' }); - }); - - it('generates a non-empty event_id', () => { - const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' }); - expect(evt.event_id).toBeTruthy(); - expect(typeof evt.event_id).toBe('string'); - }); - - it('sets timestamp as a unix seconds value (numeric)', () => { - const before = Date.now() / 1000; - const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' }); - const after = Date.now() / 1000; - - expect(evt.timestamp).toBeGreaterThanOrEqual(before); - expect(evt.timestamp).toBeLessThanOrEqual(after); - }); -}); diff --git a/app/src/services/errorReportQueue.ts b/app/src/services/errorReportQueue.ts deleted file mode 100644 index 47a12fbaf..000000000 --- a/app/src/services/errorReportQueue.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Error Report Queue - * - * Module-level error queue with zero React/Redux/Sentry dependencies. - * Captures errors from all sources (React, global JS, core/runtime services) and - * lets the notification UI subscribe to display them for user opt-in reporting. - */ -import * as Sentry from '@sentry/react'; - -import { IS_DEV } from '../utils/config'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** A stack frame with sensitive fields (vars, source context) stripped. */ -interface SafeStackFrame { - filename?: string; - function?: string; - module?: string; - lineno?: number; - colno?: number; - abs_path?: string; - in_app?: boolean; -} - -export interface SanitizedSentryEvent { - event_id: string; - timestamp: number; - platform: string; - exception?: { - values: Array<{ - type: string; - value: string; - stacktrace?: { frames?: SafeStackFrame[] }; - mechanism?: { type: string; handled?: boolean }; - }>; - }; - contexts?: { os?: object; browser?: object; device?: object }; - user?: { id: string }; - tags?: Record; - environment: string; -} - -export interface PendingErrorReport { - id: string; - timestamp: number; - source: 'react' | 'global' | 'manual'; - title: string; - message: string; - componentStack?: string; - sentryEvent: SanitizedSentryEvent | null; - originalError?: Error; -} - -// --------------------------------------------------------------------------- -// Internal state -// --------------------------------------------------------------------------- - -const MAX_QUEUE_SIZE = 10; - -let _queue: PendingErrorReport[] = []; -const _subscribers = new Set<() => void>(); - -// Dedup: track recent error messages to avoid duplicate notifications -const _recentErrors = new Map(); -const DEDUP_WINDOW_MS = 2000; - -function _notify(): void { - for (const cb of _subscribers) { - try { - cb(); - } catch { - // Subscriber error — silently ignore to prevent cascading failures - } - } -} - -function _dedupeKey(report: Pick): string { - return `${report.title}::${report.message}`; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** Add an error report to the queue. Notifies all subscribers. */ -export function enqueueError(report: PendingErrorReport): void { - const key = _dedupeKey(report); - const now = Date.now(); - const lastSeen = _recentErrors.get(key); - if (lastSeen && now - lastSeen < DEDUP_WINDOW_MS) return; - _recentErrors.set(key, now); - - // Prune old dedup entries - if (_recentErrors.size > 50) { - for (const [k, t] of _recentErrors) { - if (now - t > DEDUP_WINDOW_MS) _recentErrors.delete(k); - } - } - - _queue = [..._queue, report]; - if (_queue.length > MAX_QUEUE_SIZE) { - _queue = _queue.slice(_queue.length - MAX_QUEUE_SIZE); - } - _notify(); -} - -/** Remove an error report by ID (after user acts on it). */ -export function dequeueError(id: string): void { - _queue = _queue.filter(r => r.id !== id); - _notify(); -} - -/** Return current queue snapshot. Compatible with useSyncExternalStore. */ -export function getErrors(): PendingErrorReport[] { - return _queue; -} - -/** Subscribe to queue changes. Returns unsubscribe function. */ -export function subscribe(cb: () => void): () => void { - _subscribers.add(cb); - return () => { - _subscribers.delete(cb); - }; -} - -/** - * Find a queued error by Sentry event ID and enrich it with React source info. - * Called from the ErrorBoundary's onError callback. - */ -export function tagErrorSource( - eventId: string | undefined, - source: PendingErrorReport['source'], - componentStack?: string -): void { - if (!eventId) return; - const idx = _queue.findIndex(r => r.sentryEvent?.event_id === eventId); - if (idx === -1) return; - - const updated = { - ..._queue[idx], - source, - componentStack: componentStack ?? _queue[idx].componentStack, - }; - _queue = [..._queue.slice(0, idx), updated, ..._queue.slice(idx + 1)]; - _notify(); -} - -// --------------------------------------------------------------------------- -// Sentry bypass — used by the notification to actually send a queued event -// --------------------------------------------------------------------------- - -/** Reference to the bypass sender set by analytics.ts during init. */ -let _sendViaSentry: ((event: SanitizedSentryEvent) => void) | null = null; - -export function registerSentrySender(fn: (event: SanitizedSentryEvent) => void): void { - _sendViaSentry = fn; -} - -/** Send a queued error's payload to Sentry and remove from queue. */ -export function sendToSentry(report: PendingErrorReport): boolean { - if (!report.sentryEvent || !_sendViaSentry) return false; - _sendViaSentry(report.sentryEvent); - dequeueError(report.id); - return true; -} - -// --------------------------------------------------------------------------- -// Sentry active check -// --------------------------------------------------------------------------- - -function isSentryActive(): boolean { - try { - const client = Sentry.getClient(); - return Boolean(client); - } catch { - return false; - } -} - -// --------------------------------------------------------------------------- -// Build a SanitizedSentryEvent manually (for errors not from Sentry pipeline) -// --------------------------------------------------------------------------- - -export function buildManualSentryEvent( - error: { type: string; value: string }, - tags?: Record -): SanitizedSentryEvent { - return { - event_id: crypto.randomUUID().replace(/-/g, ''), - timestamp: Date.now() / 1000, - platform: 'javascript', - exception: { values: [{ type: error.type, value: error.value }] }, - tags, - environment: IS_DEV ? 'development' : 'production', - }; -} - -// --------------------------------------------------------------------------- -// Dev-mode global listeners -// --------------------------------------------------------------------------- - -function initGlobalListeners(): void { - window.addEventListener('error', (event: ErrorEvent) => { - // Skip if Sentry is active — it captures these via globalHandlersIntegration - if (isSentryActive()) return; - - const error = event.error instanceof Error ? event.error : new Error(event.message); - enqueueError({ - id: crypto.randomUUID(), - timestamp: Date.now(), - source: 'global', - title: error.name || 'Error', - message: error.message || event.message || 'Unknown error', - sentryEvent: null, - originalError: error, - }); - }); - - window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { - if (isSentryActive()) return; - - const reason = event.reason; - const error = reason instanceof Error ? reason : new Error(String(reason)); - enqueueError({ - id: crypto.randomUUID(), - timestamp: Date.now(), - source: 'global', - title: error.name || 'UnhandledRejection', - message: error.message || String(reason) || 'Unhandled promise rejection', - sentryEvent: null, - originalError: error, - }); - }); -} - -// Register listeners immediately on module load -initGlobalListeners(); diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts index aa31e6d5f..18e2d03c3 100644 --- a/app/src/utils/config.ts +++ b/app/src/utils/config.ts @@ -132,3 +132,10 @@ export const MINIMUM_SUPPORTED_APP_VERSION = export const LATEST_APP_DOWNLOAD_URL = (import.meta.env.VITE_LATEST_APP_DOWNLOAD_URL as string | undefined)?.trim() || 'https://github.com/tinyhumansai/openhuman/releases/latest'; + +/** + * Set `VITE_SENTRY_SMOKE_TEST=true` in one build (or in `.env.local`) to + * fire a one-shot diagnostic event at `initSentry()` time and verify the + * Sentry pipeline end-to-end. Has no effect in normal builds. + */ +export const SENTRY_SMOKE_TEST = import.meta.env.VITE_SENTRY_SMOKE_TEST === 'true'; diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts index 6f0a5873a..33d2e6945 100644 --- a/app/src/utils/desktopDeepLinkListener.ts +++ b/app/src/utils/desktopDeepLinkListener.ts @@ -1,10 +1,10 @@ +import * as Sentry from '@sentry/react'; import { isTauri as coreIsTauri } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; import { getCoreStateSnapshot, patchCoreStateSnapshot } from '../lib/coreState/store'; import { consumeLoginToken } from '../services/api/authApi'; -import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; import { beginDeepLinkAuthProcessing, completeDeepLinkAuthProcessing, @@ -152,26 +152,23 @@ const handleOAuthDeepLink = async (parsed: URL) => { versionGate.current === 'unknown' ? `OpenHuman could not verify this build against the minimum required for OAuth (${versionGate.minimum}). Install the latest release, then try connecting again.` : `This OpenHuman build (${versionGate.current}) is older than the minimum required for OAuth (${versionGate.minimum}). Install the latest release, then try connecting again.`; + console.warn(`[DeepLink][oauth:stale-app] ${msg}`); try { await openUrl(versionGate.downloadUrl); } catch (e) { console.warn('[DeepLink] Could not open latest release URL', e); } - enqueueError({ - id: crypto.randomUUID(), - timestamp: Date.now(), - source: 'manual', - title: 'Update OpenHuman to finish OAuth', - message: msg, - sentryEvent: buildManualSentryEvent( - { type: 'OAuthStaleAppVersion', value: `${versionGate.current}<${versionGate.minimum}` }, - { + Sentry.captureMessage( + `OAuth blocked: stale app version ${versionGate.current}<${versionGate.minimum}`, + { + level: 'warning', + tags: { component: 'desktopDeepLinkListener', current: versionGate.current, minimum: versionGate.minimum, - } - ), - }); + }, + } + ); window.dispatchEvent( new CustomEvent('oauth:stale-app', { detail: { diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts index a713957a5..654d19a03 100644 --- a/app/src/vite-env.d.ts +++ b/app/src/vite-env.d.ts @@ -6,6 +6,7 @@ interface ImportMetaEnv { readonly VITE_BACKEND_URL?: string; readonly VITE_SKILLS_GITHUB_REPO?: string; readonly VITE_SENTRY_DSN?: string; + readonly VITE_SENTRY_SMOKE_TEST?: string; readonly VITE_BUILD_SHA?: string; readonly VITE_DEV_JWT_TOKEN?: string; readonly VITE_DEV_FORCE_ONBOARDING?: string;