feat(error-page): copyable Sentry event id, reveal-logs + support link (#3980) (#4004)

This commit is contained in:
oxoxDev
2026-06-23 11:52:29 -07:00
committed by GitHub
parent a3791d45b7
commit 87837130d9
20 changed files with 312 additions and 6 deletions
+7 -2
View File
@@ -128,8 +128,13 @@ function App() {
*/
return (
<Sentry.ErrorBoundary
fallback={({ error, componentStack, resetError }) => (
<ErrorFallbackScreen error={error} componentStack={componentStack} onReset={resetError} />
fallback={({ error, componentStack, resetError, eventId }) => (
<ErrorFallbackScreen
error={error}
componentStack={componentStack}
eventId={eventId}
onReset={resetError}
/>
)}>
<Provider store={store}>
<PersistGate loading={<PersistRehydrationScreen />} persistor={persistor}>
@@ -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(<ErrorFallbackScreen {...baseProps} eventId="abc123def456" />);
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(<ErrorFallbackScreen {...baseProps} eventId={null} />);
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(<ErrorFallbackScreen {...baseProps} eventId="abc123def456" />);
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(<ErrorFallbackScreen {...baseProps} eventId="id/with space" />);
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(<ErrorFallbackScreen {...baseProps} eventId="x" />);
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(<ErrorFallbackScreen {...baseProps} eventId={null} />);
expect(screen.getByText('app.errorFallback.revealLogs')).toBeInTheDocument();
});
test('Try recover calls onReset', () => {
const onReset = vi.fn();
render(<ErrorFallbackScreen {...baseProps} onReset={onReset} eventId="x" />);
fireEvent.click(screen.getByText('app.errorFallback.tryRecover'));
expect(onReset).toHaveBeenCalledTimes(1);
});
test('Download latest opens the release page', () => {
render(<ErrorFallbackScreen {...baseProps} eventId={null} />);
fireEvent.click(screen.getByText('app.errorFallback.downloadLatest'));
expect(hoisted.openUrl).toHaveBeenCalledWith('https://downloads.example/latest');
});
});
+93 -4
View File
@@ -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 (
<div className="fixed inset-0 flex items-center justify-center bg-gradient-to-b from-stone-950 to-stone-900">
@@ -63,6 +111,27 @@ export default function ErrorFallbackScreen({
</p>
<p className="text-xs text-stone-500 text-center mb-6">{t('app.errorFallback.hint')}</p>
{/* Sentry Event ID — copyable; hidden when analytics produced no id */}
{hasEventId && (
<div className="flex items-center justify-between gap-3 bg-stone-800/50 border border-stone-700/50 rounded-xl px-3 py-2.5 mb-4">
<div className="flex flex-col min-w-0">
<span className="text-[10px] uppercase tracking-wide text-stone-500">
{t('app.errorFallback.eventIdLabel')}
</span>
<span className="font-mono text-xs text-stone-300 truncate">{eventId}</span>
</div>
<button
onClick={copyEventId}
className={`flex-none text-xs font-medium rounded-lg px-3 py-1.5 transition-colors ${
copied
? 'bg-primary-500/20 text-primary-300'
: 'bg-stone-700 hover:bg-stone-600 text-white'
}`}>
{copied ? t('app.errorFallback.eventIdCopied') : t('app.errorFallback.copyEventId')}
</button>
</div>
)}
{/* Error details */}
<div className="bg-stone-800/50 border border-stone-700/50 rounded-xl p-4 mb-6">
<p className="text-sm font-medium text-coral-400 mb-1">{errorName}</p>
@@ -79,7 +148,7 @@ export default function ErrorFallbackScreen({
)}
</div>
{/* Actions */}
{/* Primary actions — escalate recover → reload → reveal logs */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
onClick={onReset}
@@ -94,9 +163,29 @@ export default function ErrorFallbackScreen({
className="bg-coral-500 hover:bg-coral-600 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors">
{t('app.errorFallback.reloadApp')}
</button>
{/* 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). */}
<button
onClick={revealLogs}
className="bg-stone-800 hover:bg-stone-700 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors border border-stone-600">
{t('app.errorFallback.revealLogs')}
</button>
</div>
{/* Secondary links */}
<div className="flex items-center justify-center gap-4 mt-5 text-xs">
{hasEventId && (
<button
onClick={openSupport}
className="text-primary-400 hover:text-primary-300 hover:underline transition-colors">
{t('app.errorFallback.contactSupport')}
</button>
)}
<button
onClick={() => openUrl(LATEST_APP_DOWNLOAD_URL)}
className="bg-stone-800 hover:bg-stone-700 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors border border-stone-600">
className="text-stone-500 hover:text-stone-300 hover:underline transition-colors">
{t('app.errorFallback.downloadLatest')}
</button>
</div>
+5
View File
@@ -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': 'تلميح',
+5
View File
@@ -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': 'হিন্ট',
+5
View File
@@ -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',
+5
View File
@@ -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...',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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': 'संकेत',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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': '힌트',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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': 'Подсказка',
+5
View File
@@ -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': '提示',
@@ -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<string, string> } | 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<string, string>;
} | 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
+12
View File
@@ -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;
+5
View File
@@ -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=<sentryEventId>` 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