From 87837130d9041ccd0315a878c849931fb4e472a5 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:22:29 +0530 Subject: [PATCH] feat(error-page): copyable Sentry event id, reveal-logs + support link (#3980) (#4004) --- app/src/App.tsx | 9 +- .../components/ErrorFallbackScreen.test.tsx | 100 ++++++++++++++++++ app/src/components/ErrorFallbackScreen.tsx | 97 ++++++++++++++++- app/src/lib/i18n/ar.ts | 5 + app/src/lib/i18n/bn.ts | 5 + app/src/lib/i18n/de.ts | 5 + app/src/lib/i18n/en.ts | 5 + app/src/lib/i18n/es.ts | 5 + app/src/lib/i18n/fr.ts | 5 + app/src/lib/i18n/hi.ts | 5 + app/src/lib/i18n/id.ts | 5 + app/src/lib/i18n/it.ts | 5 + app/src/lib/i18n/ko.ts | 5 + app/src/lib/i18n/pl.ts | 5 + app/src/lib/i18n/pt.ts | 5 + app/src/lib/i18n/ru.ts | 5 + app/src/lib/i18n/zh-CN.ts | 5 + app/src/services/__tests__/analytics.test.ts | 25 +++++ app/src/services/analytics.ts | 12 +++ app/src/utils/config.ts | 5 + 20 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 app/src/components/ErrorFallbackScreen.test.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index bce8c4d95..b63663a4f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -128,8 +128,13 @@ function App() { */ return ( ( - + fallback={({ error, componentStack, resetError, eventId }) => ( + )}> } persistor={persistor}> diff --git a/app/src/components/ErrorFallbackScreen.test.tsx b/app/src/components/ErrorFallbackScreen.test.tsx new file mode 100644 index 000000000..ee53f93e9 --- /dev/null +++ b/app/src/components/ErrorFallbackScreen.test.tsx @@ -0,0 +1,100 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import ErrorFallbackScreen from './ErrorFallbackScreen'; + +// `t` echoes the key so assertions can target stable key strings. +vi.mock('../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +const hoisted = vi.hoisted(() => ({ + openUrl: vi.fn(), + safeInvoke: vi.fn().mockResolvedValue(undefined), + isAnalyticsEnabled: vi.fn(() => true), +})); + +vi.mock('../utils/openUrl', () => ({ openUrl: hoisted.openUrl })); +vi.mock('../utils/tauriCommands/common', () => ({ safeInvoke: hoisted.safeInvoke })); +vi.mock('../services/analytics', () => ({ isAnalyticsEnabled: hoisted.isAnalyticsEnabled })); +vi.mock('../utils/config', () => ({ + SUPPORT_URL: 'https://support.example/help', + LATEST_APP_DOWNLOAD_URL: 'https://downloads.example/latest', +})); + +const baseProps = { + error: new Error('boom went the render'), + componentStack: ' at Foo\n at Bar', + onReset: vi.fn(), +}; + +afterEach(() => { + vi.clearAllMocks(); + hoisted.isAnalyticsEnabled.mockReturnValue(true); +}); + +describe('ErrorFallbackScreen', () => { + test('shows a copyable Error ID and copies it on click', async () => { + const originalClipboard = navigator.clipboard; + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + render(); + + expect(screen.getByText('app.errorFallback.eventIdLabel')).toBeInTheDocument(); + expect(screen.getByText('abc123def456')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('app.errorFallback.copyEventId')); + expect(writeText).toHaveBeenCalledWith('abc123def456'); + await waitFor(() => + expect(screen.getByText('app.errorFallback.eventIdCopied')).toBeInTheDocument() + ); + + Object.assign(navigator, { clipboard: originalClipboard }); + }); + + test('hides the Error ID and support link when no event id', () => { + render(); + expect(screen.queryByText('app.errorFallback.eventIdLabel')).not.toBeInTheDocument(); + expect(screen.queryByText('app.errorFallback.contactSupport')).not.toBeInTheDocument(); + }); + + test('hides the Error ID when analytics is disabled (event was dropped)', () => { + // Consent off → beforeSend drops the event, so the generated id maps to + // nothing support can look up; the chip / support ref must stay hidden. + hoisted.isAnalyticsEnabled.mockReturnValue(false); + render(); + expect(screen.queryByText('app.errorFallback.eventIdLabel')).not.toBeInTheDocument(); + expect(screen.queryByText('app.errorFallback.contactSupport')).not.toBeInTheDocument(); + }); + + test('opens the support deep link seeded with the (encoded) event id', () => { + render(); + fireEvent.click(screen.getByText('app.errorFallback.contactSupport')); + expect(hoisted.openUrl).toHaveBeenCalledWith( + 'https://support.example/help?ref=id%2Fwith%20space' + ); + }); + + test('reveals the logs folder via the tauri command', () => { + render(); + fireEvent.click(screen.getByText('app.errorFallback.revealLogs')); + expect(hoisted.safeInvoke).toHaveBeenCalledWith('reveal_logs_folder'); + }); + + test('always renders Reveal logs (escape hatch survives the bootstrap gap)', () => { + render(); + expect(screen.getByText('app.errorFallback.revealLogs')).toBeInTheDocument(); + }); + + test('Try recover calls onReset', () => { + const onReset = vi.fn(); + render(); + fireEvent.click(screen.getByText('app.errorFallback.tryRecover')); + expect(onReset).toHaveBeenCalledTimes(1); + }); + + test('Download latest opens the release page', () => { + render(); + fireEvent.click(screen.getByText('app.errorFallback.downloadLatest')); + expect(hoisted.openUrl).toHaveBeenCalledWith('https://downloads.example/latest'); + }); +}); diff --git a/app/src/components/ErrorFallbackScreen.tsx b/app/src/components/ErrorFallbackScreen.tsx index c2f3a8d0f..3e3f0d71e 100644 --- a/app/src/components/ErrorFallbackScreen.tsx +++ b/app/src/components/ErrorFallbackScreen.tsx @@ -1,6 +1,10 @@ +import { useState } from 'react'; + import { useT } from '../lib/i18n/I18nContext'; -import { LATEST_APP_DOWNLOAD_URL } from '../utils/config'; +import { isAnalyticsEnabled } from '../services/analytics'; +import { LATEST_APP_DOWNLOAD_URL, SUPPORT_URL } from '../utils/config'; import { openUrl } from '../utils/openUrl'; +import { safeInvoke as invoke } from '../utils/tauriCommands/common'; /** * ErrorFallbackScreen @@ -11,23 +15,67 @@ import { openUrl } from '../utils/openUrl'; * * 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`). + * consent enforced in `analytics.ts::beforeSend`). When the capture + * produces an event id, it is surfaced here as a copyable Error ID so the + * user can share it with support, and a support deep link is pre-seeded + * with it. When the user hasn't opted into analytics (no event id), the + * Error ID / support affordances are hidden rather than shown empty. + * + * Recovery escalates: `Try recover` (in-place `resetError`) → `Reload app` + * (hard reload to /home) → `Reveal logs` (open the logs folder so a user + * trapped by a deterministic crash can still pull diagnostics for support). */ interface ErrorFallbackScreenProps { error: unknown; componentStack?: string; + /** Sentry event id for the captured crash, when analytics is enabled. */ + eventId?: string | null; onReset: () => void; } export default function ErrorFallbackScreen({ error, componentStack, + eventId, onReset, }: ErrorFallbackScreenProps) { const { t } = useT(); + const [copied, setCopied] = useState(false); const errorName = error instanceof Error ? error.name : 'Error'; const errorMessage = error instanceof Error ? error.message : String(error); + // Only surface the Error ID / support ref when analytics is on: with consent + // off, `analytics.ts::beforeSend` drops the event, yet the SDK still hands the + // boundary a generated id — showing it would let an opted-out user copy a ref + // support can never look up (Codex P2 on #3980). + const hasEventId = typeof eventId === 'string' && eventId.length > 0 && isAnalyticsEnabled(); + + const copyEventId = async () => { + if (!hasEventId) return; + try { + await navigator.clipboard.writeText(eventId as string); + setCopied(true); + setTimeout(() => setCopied(false), 1800); + } catch { + // Clipboard unavailable (permissions / non-secure context) — no-op; + // the id stays visible for manual copy. + } + }; + + const revealLogs = () => { + // Diagnostics escape hatch: works even when the UI is otherwise dead. + // `.catch` swallows the rejection on non-desktop / pre-bootstrap so a + // fire-and-forget invoke can't surface as an unhandled rejection. + void invoke('reveal_logs_folder').catch(() => {}); + }; + + const openSupport = () => { + if (!hasEventId) return; + // `&` when SUPPORT_URL already carries a query (env override) so the ref + // never produces a malformed double-`?` link. + const sep = SUPPORT_URL.includes('?') ? '&' : '?'; + openUrl(`${SUPPORT_URL}${sep}ref=${encodeURIComponent(eventId as string)}`); + }; return (
@@ -63,6 +111,27 @@ export default function ErrorFallbackScreen({

{t('app.errorFallback.hint')}

+ {/* Sentry Event ID — copyable; hidden when analytics produced no id */} + {hasEventId && ( +
+
+ + {t('app.errorFallback.eventIdLabel')} + + {eventId} +
+ +
+ )} + {/* Error details */}

{errorName}

@@ -79,7 +148,7 @@ export default function ErrorFallbackScreen({ )}
- {/* Actions */} + {/* Primary actions — escalate recover → reload → reveal logs */}
+ {/* Always rendered — `isTauri()` is false during the CEF IPC + bootstrap gap, which is exactly when an early deterministic + crash needs this escape hatch; the invoke fails safe off-desktop + (Codex P2 on #3980). */} + +
+ + {/* Secondary links */} +
+ {hasEventId && ( + + )}
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 74b52f218..9776fefee 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -2670,6 +2670,11 @@ const messages: TranslationMap = { 'app.connectionIndicator.offline': 'غير متصل بالإنترنت', 'app.connectionIndicator.reconnecting': 'إعادة الاتصال…', 'app.errorFallback.componentStack': 'مكدس المكوّنات', + 'app.errorFallback.contactSupport': 'تواصل مع الدعم', + 'app.errorFallback.copyEventId': 'نسخ', + 'app.errorFallback.eventIdCopied': 'تم النسخ', + 'app.errorFallback.eventIdLabel': 'معرّف الخطأ', + 'app.errorFallback.revealLogs': 'إظهار السجلات', 'app.errorFallback.downloadLatest': 'تنزيل الأحدث', 'app.errorFallback.heading': 'العنوان', 'app.errorFallback.hint': 'تلميح', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index ea73eaf95..9794b7c6a 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -2727,6 +2727,11 @@ const messages: TranslationMap = { 'app.connectionIndicator.offline': 'অফলাইন', 'app.connectionIndicator.reconnecting': 'পুনঃসংযোগ হচ্ছে…', 'app.errorFallback.componentStack': 'কম্পোনেন্ট স্ট্যাক', + 'app.errorFallback.contactSupport': 'সহায়তায় যোগাযোগ করুন', + 'app.errorFallback.copyEventId': 'অনুলিপি', + 'app.errorFallback.eventIdCopied': 'অনুলিপি হয়েছে', + 'app.errorFallback.eventIdLabel': 'ত্রুটি আইডি', + 'app.errorFallback.revealLogs': 'লগ দেখান', 'app.errorFallback.downloadLatest': 'সর্বশেষ ডাউনলোড করুন', 'app.errorFallback.heading': 'শিরোনাম', 'app.errorFallback.hint': 'হিন্ট', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index b9f34547f..c24fef911 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -2788,6 +2788,11 @@ const messages: TranslationMap = { 'app.connectionIndicator.offline': 'Offline', 'app.connectionIndicator.reconnecting': 'Wieder verbinden…', 'app.errorFallback.componentStack': 'Komponentenstapel', + 'app.errorFallback.contactSupport': 'Support kontaktieren', + 'app.errorFallback.copyEventId': 'Kopieren', + 'app.errorFallback.eventIdCopied': 'Kopiert', + 'app.errorFallback.eventIdLabel': 'Fehler-ID', + 'app.errorFallback.revealLogs': 'Protokolle anzeigen', 'app.errorFallback.downloadLatest': 'Neueste herunterladen', 'app.errorFallback.heading': 'Überschrift', 'app.errorFallback.hint': 'Hinweis', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index b0ff5f1b5..7a1b370a5 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3244,11 +3244,16 @@ const en: TranslationMap = { 'app.connectionIndicator.offline': 'Offline', 'app.connectionIndicator.reconnecting': 'Reconnecting…', 'app.errorFallback.componentStack': 'Component stack', + 'app.errorFallback.contactSupport': 'Contact support', + 'app.errorFallback.copyEventId': 'Copy', 'app.errorFallback.downloadLatest': 'Download latest', + 'app.errorFallback.eventIdCopied': 'Copied', + 'app.errorFallback.eventIdLabel': 'Error ID', 'app.errorFallback.heading': 'Something went wrong', 'app.errorFallback.hint': 'Try reloading the app. If the problem persists, download the latest version.', 'app.errorFallback.reloadApp': 'Reload app', + 'app.errorFallback.revealLogs': 'Reveal logs', 'app.errorFallback.subheading': 'An unexpected error occurred', 'app.errorFallback.tryRecover': 'Try recover', 'app.localAiDownload.installing': 'Installing...', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index fbc4bcfbc..982f40f6f 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -2774,6 +2774,11 @@ const messages: TranslationMap = { 'app.connectionIndicator.offline': 'Sin conexión', 'app.connectionIndicator.reconnecting': 'Reconectando…', 'app.errorFallback.componentStack': 'Pila de componentes', + 'app.errorFallback.contactSupport': 'Contactar con soporte', + 'app.errorFallback.copyEventId': 'Copiar', + 'app.errorFallback.eventIdCopied': 'Copiado', + 'app.errorFallback.eventIdLabel': 'ID de error', + 'app.errorFallback.revealLogs': 'Mostrar registros', 'app.errorFallback.downloadLatest': 'Descargar la última versión', 'app.errorFallback.heading': 'Encabezado', 'app.errorFallback.hint': 'Sugerencia', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index ee5ea1ee6..845fd9035 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -2786,6 +2786,11 @@ const messages: TranslationMap = { 'app.connectionIndicator.offline': 'Hors ligne', 'app.connectionIndicator.reconnecting': 'Reconnexion…', 'app.errorFallback.componentStack': 'Pile de composants', + 'app.errorFallback.contactSupport': 'Contacter le support', + 'app.errorFallback.copyEventId': 'Copier', + 'app.errorFallback.eventIdCopied': 'Copié', + 'app.errorFallback.eventIdLabel': "ID d'erreur", + 'app.errorFallback.revealLogs': 'Afficher les journaux', 'app.errorFallback.downloadLatest': 'Télécharger la dernière version', 'app.errorFallback.heading': 'Titre', 'app.errorFallback.hint': 'Indice', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 9dd2cfe1d..eb2f76949 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -2724,6 +2724,11 @@ const messages: TranslationMap = { 'app.connectionIndicator.offline': 'ऑफलाइन', 'app.connectionIndicator.reconnecting': 'पुनः कनेक्ट हो रहा है…', 'app.errorFallback.componentStack': 'कम्पोनेंट स्टैक', + 'app.errorFallback.contactSupport': 'सहायता से संपर्क करें', + 'app.errorFallback.copyEventId': 'कॉपी करें', + 'app.errorFallback.eventIdCopied': 'कॉपी हो गया', + 'app.errorFallback.eventIdLabel': 'त्रुटि आईडी', + 'app.errorFallback.revealLogs': 'लॉग दिखाएं', 'app.errorFallback.downloadLatest': 'लेटेस्ट डाउनलोड करें', 'app.errorFallback.heading': 'शीर्षक', 'app.errorFallback.hint': 'संकेत', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 4f8138d49..3ec5b61e0 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -2728,6 +2728,11 @@ const messages: TranslationMap = { 'app.connectionIndicator.offline': 'Tidak online', 'app.connectionIndicator.reconnecting': 'Menyambung ulang…', 'app.errorFallback.componentStack': 'Stack komponen', + 'app.errorFallback.contactSupport': 'Hubungi dukungan', + 'app.errorFallback.copyEventId': 'Salin', + 'app.errorFallback.eventIdCopied': 'Tersalin', + 'app.errorFallback.eventIdLabel': 'ID Kesalahan', + 'app.errorFallback.revealLogs': 'Tampilkan log', 'app.errorFallback.downloadLatest': 'Unduh terbaru', 'app.errorFallback.heading': 'Judul', 'app.errorFallback.hint': 'Petunjuk', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index bf2206a23..adcfcfcbd 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -2767,6 +2767,11 @@ const messages: TranslationMap = { 'app.connectionIndicator.offline': 'Offline', 'app.connectionIndicator.reconnecting': 'Riconnessione…', 'app.errorFallback.componentStack': 'Stack del componente', + 'app.errorFallback.contactSupport': "Contatta l'assistenza", + 'app.errorFallback.copyEventId': 'Copia', + 'app.errorFallback.eventIdCopied': 'Copiato', + 'app.errorFallback.eventIdLabel': 'ID errore', + 'app.errorFallback.revealLogs': 'Mostra i log', 'app.errorFallback.downloadLatest': "Scarica l'ultima versione", 'app.errorFallback.heading': 'Intestazione', 'app.errorFallback.hint': 'Suggerimento', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 38b7ca9ed..10668f88b 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -2700,6 +2700,11 @@ const messages: TranslationMap = { 'app.connectionIndicator.offline': '오프라인', 'app.connectionIndicator.reconnecting': '다시 연결 중…', 'app.errorFallback.componentStack': '컴포넌트 스택', + 'app.errorFallback.contactSupport': '지원팀에 문의', + 'app.errorFallback.copyEventId': '복사', + 'app.errorFallback.eventIdCopied': '복사됨', + 'app.errorFallback.eventIdLabel': '오류 ID', + 'app.errorFallback.revealLogs': '로그 보기', 'app.errorFallback.downloadLatest': '최신 버전 다운로드', 'app.errorFallback.heading': '제목', 'app.errorFallback.hint': '힌트', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 9c9cdfe7a..651c3ef2e 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -2750,6 +2750,11 @@ const messages: TranslationMap = { 'app.connectionIndicator.offline': 'Offline', 'app.connectionIndicator.reconnecting': 'Ponowne łączenie…', 'app.errorFallback.componentStack': 'Stos komponentów', + 'app.errorFallback.contactSupport': 'Skontaktuj się z pomocą', + 'app.errorFallback.copyEventId': 'Kopiuj', + 'app.errorFallback.eventIdCopied': 'Skopiowano', + 'app.errorFallback.eventIdLabel': 'Identyfikator błędu', + 'app.errorFallback.revealLogs': 'Pokaż dzienniki', 'app.errorFallback.downloadLatest': 'Pobierz najnowszą wersję', 'app.errorFallback.heading': 'Coś poszło nie tak', 'app.errorFallback.hint': 'Wskazówka', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 90eec2793..d224cb948 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -2772,6 +2772,11 @@ const messages: TranslationMap = { 'app.connectionIndicator.offline': 'Offline', 'app.connectionIndicator.reconnecting': 'Reconectando…', 'app.errorFallback.componentStack': 'Pilha de componentes', + 'app.errorFallback.contactSupport': 'Falar com o suporte', + 'app.errorFallback.copyEventId': 'Copiar', + 'app.errorFallback.eventIdCopied': 'Copiado', + 'app.errorFallback.eventIdLabel': 'ID do erro', + 'app.errorFallback.revealLogs': 'Mostrar registros', 'app.errorFallback.downloadLatest': 'Baixar mais recente', 'app.errorFallback.heading': 'Título', 'app.errorFallback.hint': 'Dica', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 2991f33eb..9429307a2 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -2746,6 +2746,11 @@ const messages: TranslationMap = { 'app.connectionIndicator.offline': 'Офлайн', 'app.connectionIndicator.reconnecting': 'Переподключение…', 'app.errorFallback.componentStack': 'Стек компонентов', + 'app.errorFallback.contactSupport': 'Связаться с поддержкой', + 'app.errorFallback.copyEventId': 'Копировать', + 'app.errorFallback.eventIdCopied': 'Скопировано', + 'app.errorFallback.eventIdLabel': 'Идентификатор ошибки', + 'app.errorFallback.revealLogs': 'Показать журналы', 'app.errorFallback.downloadLatest': 'Скачать последнюю версию', 'app.errorFallback.heading': 'Заголовок', 'app.errorFallback.hint': 'Подсказка', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 9f1d27298..016b51a66 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2595,6 +2595,11 @@ const messages: TranslationMap = { 'app.connectionIndicator.offline': '离线', 'app.connectionIndicator.reconnecting': '重新连接中…', 'app.errorFallback.componentStack': '组件堆栈', + 'app.errorFallback.contactSupport': '联系支持', + 'app.errorFallback.copyEventId': '复制', + 'app.errorFallback.eventIdCopied': '已复制', + 'app.errorFallback.eventIdLabel': '错误 ID', + 'app.errorFallback.revealLogs': '显示日志', 'app.errorFallback.downloadLatest': '下载最新版本', 'app.errorFallback.heading': '出现错误', 'app.errorFallback.hint': '提示', diff --git a/app/src/services/__tests__/analytics.test.ts b/app/src/services/__tests__/analytics.test.ts index ddc3b9357..8a14598dc 100644 --- a/app/src/services/__tests__/analytics.test.ts +++ b/app/src/services/__tests__/analytics.test.ts @@ -73,6 +73,7 @@ vi.mock('../../utils/config', () => ({ SENTRY_DSN: 'https://abc@example.ingest.sentry.io/1', SENTRY_RELEASE: 'openhuman@test+abc', SENTRY_SMOKE_TEST: false, + SUPPORT_URL: 'https://support.example/help', TAURI_CARGO_VERSION: '0.57.4', // analytics.ts now imports CoreRpcError from coreRpcClient, whose // dependency chain reads CORE_RPC_URL and CORE_RPC_TIMEOUT_MS. Provide @@ -231,6 +232,30 @@ describe('initSentry beforeSend manual-staging bypass', () => { expect(result).not.toBeNull(); }); + test('seeds a support_url tag keyed on the event id (#3980)', async () => { + const beforeSend = await captureBeforeSend(); + const result = beforeSend({ + message: 'react-sentry-smoke-test', // bypasses consent gate + event_id: 'deadbeefcafe', + tags: {}, + contexts: {}, + }) as { tags: Record } | null; + expect(result).not.toBeNull(); + // Set as a TAG (extras are scrubbed); carries only the event's own id. + expect(result?.tags.support_url).toBe('https://support.example/help?ref=deadbeefcafe'); + // Extras still dropped — the support link must not reintroduce a leak path. + expect(result).not.toHaveProperty('extra'); + }); + + test('omits support_url when the event has no id', async () => { + const beforeSend = await captureBeforeSend(); + const result = beforeSend({ message: 'react-sentry-smoke-test', tags: {}, contexts: {} }) as { + tags: Record; + } | null; + expect(result).not.toBeNull(); + expect(result?.tags).not.toHaveProperty('support_url'); + }); + test('drops CoreRpcError with kind=timeout via the originalException hint', async () => { // Regression for OPENHUMAN-REACT-15/11/10/12/Z/Y: a missed `.catch()` at // any `await callCoreRpc(...)` chain in the team panels surfaced as an diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index 67c3e7fec..6dd0fc29d 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -32,6 +32,7 @@ import { SENTRY_DSN, SENTRY_RELEASE, SENTRY_SMOKE_TEST, + SUPPORT_URL, TAURI_CARGO_VERSION, } from '../utils/config'; import { CoreRpcError } from './coreRpcClient'; @@ -217,6 +218,17 @@ export function initSentry(): void { // Tag with surface so events filter cleanly inside `openhuman-react`. event.tags = { ...(event.tags ?? {}), surface: 'react' }; + // Seed a support deep link keyed on this event's own id so the crash + // report links back to the support channel (#3980). Set as a TAG, not + // an `extra` — the privacy scrub above deletes `event.extra`, but the + // event id is known here and tags survive. The URL carries only the + // event's own id + a static base (no PII). Mirrors the id the user + // sees + copies on `ErrorFallbackScreen`. + if (event.event_id) { + const sep = SUPPORT_URL.includes('?') ? '&' : '?'; + event.tags.support_url = `${SUPPORT_URL}${sep}ref=${event.event_id}`; + } + // Strip PII; keep a stable account id only. const userId = getCoreStateSnapshot().snapshot.currentUser?._id; event.user = userId ? { id: userId } : undefined; diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts index 256885467..f1ea7e9b4 100644 --- a/app/src/utils/config.ts +++ b/app/src/utils/config.ts @@ -208,6 +208,11 @@ 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'; +/** Support page base URL. The crash screen appends `?ref=` so support can correlate a user's pasted Error ID to the exact Sentry event. Override via VITE_SUPPORT_URL for deployment-specific support endpoints. */ +export const SUPPORT_URL = + (import.meta.env.VITE_SUPPORT_URL as string | undefined)?.trim() || + 'https://tinyhumans.ai/support'; + /** * 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