diff --git a/src/App.jsx b/src/App.jsx index ba2bc65..ec3684a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,22 +1,23 @@ -import NavBar from '@/views/components/NavBar' +import './styles/safe-area.css' + import { Button, Typography, useColorScheme } from '@mui/joy' import { useCallback, useEffect } from 'react' -import { Outlet, useNavigate } from 'react-router-dom' +import { Outlet, useLocation, useNavigate } from 'react-router-dom' import { useRegisterSW } from 'virtual:pwa-register/react' + +import NavBar from '@/views/components/NavBar' + import { registerCapacitorListeners } from './CapacitorListener' import PageTransition from './components/animations/PageTransition' import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext' +import SSEProvider from './contexts/SSEContext' import { AuthProvider } from './hooks/useAuth.jsx' - import useOnboardingGate from './hooks/useOnboardingGate' import useStatusBar from './hooks/useStatusBar' -import { useResource } from './queries/ResourceQueries' -import './styles/safe-area.css' - -import SSEProvider from './contexts/SSEContext' -import { useNotification } from './service/NotificationProvider' - import { useSyncOnReconnect } from './hooks/useSyncOnReconnect' +import { useResource } from './queries/ResourceQueries' +import { recordRoute } from './service/DiagnosticsSession' +import { useNotification } from './service/NotificationProvider' import NetworkBanner from './views/components/NetworkBanner' const add = className => { @@ -32,8 +33,15 @@ const intervalMS = 5 * 60 * 1000 // 5 minutes const AppContent = () => { const { showNotification } = useNotification() + const location = useLocation() useSyncOnReconnect() + // Every route renders through this Outlet, so one listener here gives crash + // reports the trail that led to the failure. + useEffect(() => { + recordRoute(location.pathname) + }, [location.pathname]) + // // First-launch native users see the onboarding flow before anything else. useOnboardingGate() @@ -41,8 +49,8 @@ const AppContent = () => { useStatusBar() const { - offlineReady: [offlineReady, setOfflineReady], // eslint-disable-line no-unused-vars needRefresh: [needRefresh, setNeedRefresh], + offlineReady: [offlineReady, setOfflineReady], updateServiceWorker, } = useRegisterSW({ onRegistered(r) { @@ -100,7 +108,7 @@ const AppContent = () => { } function App() { - const resource = useResource() // eslint-disable-line no-unused-vars + const resource = useResource() const { mode, systemMode } = useColorScheme() const navigate = useNavigate() diff --git a/src/service/DiagnosticsSession.js b/src/service/DiagnosticsSession.js new file mode 100644 index 0000000..1592f5b --- /dev/null +++ b/src/service/DiagnosticsSession.js @@ -0,0 +1,189 @@ +/** + * Ambient session state worth having the moment something breaks: how long the + * user has been in the app, how they got to the screen that failed, which + * server they were talking to and what it had already refused. + * + * Deliberately dependency-free — ApiClient imports it on the request path, so + * anything imported here would risk a module cycle. Everything is in memory + * and dies with the tab; nothing is persisted. + */ + +const SESSION_STARTED_AT = Date.now() +const SESSION_ID = `${SESSION_STARTED_AT.toString(36)}-${Math.random() + .toString(36) + .slice(2, 8)}` + +// A cold start (vs. a reload of an already-running app) changes what a crash +// means: a reload loop looks very different from a first-launch failure. +const NAVIGATION_TYPE = + performance.getEntriesByType?.('navigation')?.[0]?.type ?? 'unknown' + +const MAX_ROUTES = 10 +const MAX_API_FAILURES = 8 + +const routeTrail = [] +const apiFailures = [] +let backgroundedCount = 0 +let serverVersion = null + +// --------------------------------------------------------------------------- +// Route trail +// --------------------------------------------------------------------------- + +/** + * Records a navigation and closes out the dwell time on the previous screen. + * "The crash happened 400ms after landing on /chores/12 from /chores" is a + * far better bug report than "the crash happened on /chores/12". + */ +export const recordRoute = path => { + if (!path) return + const now = Date.now() + const previous = routeTrail[routeTrail.length - 1] + if (previous) { + if (previous.path === path) return + previous.dwellMs = now - previous.at + } + routeTrail.push({ path, at: now }) + if (routeTrail.length > MAX_ROUTES) routeTrail.shift() +} + +export const getRouteTrail = () => { + const now = Date.now() + return routeTrail.map((entry, index) => ({ + path: entry.path, + // The current screen has no closing dwell yet; measure it up to now. + dwellMs: + entry.dwellMs ?? + (index === routeTrail.length - 1 ? now - entry.at : null), + msAgo: now - entry.at, + })) +} + +/** The screen the user came from, which is usually where the bug was planted. */ +export const getPreviousRoute = () => + routeTrail.length > 1 ? routeTrail[routeTrail.length - 2].path : null + +// --------------------------------------------------------------------------- +// Server identity +// --------------------------------------------------------------------------- + +/** + * Picks the server build out of response headers. Costs nothing when the + * server doesn't send them — the field simply stays null. + */ +export const recordServerVersionFromResponse = response => { + if (serverVersion) return + try { + serverVersion = + response?.headers?.get?.('x-donetick-version') || + response?.headers?.get?.('x-api-version') || + null + } catch { + // headers may be inaccessible on opaque responses; not worth reporting + } +} + +export const setServerVersion = version => { + if (version) serverVersion = version +} + +export const getServerVersion = () => serverVersion + +// --------------------------------------------------------------------------- +// API failures +// --------------------------------------------------------------------------- + +/** Strips ids and query strings so failures group by endpoint, not by row. */ +const normalizeEndpoint = endpoint => + String(endpoint || '') + .split('?')[0] + .replace(/\/\d+/g, '/:id') + +export const recordApiFailure = ({ endpoint, method, status }) => { + apiFailures.push({ + at: Date.now(), + method: method || 'GET', + endpoint: normalizeEndpoint(endpoint), + status: status ?? 'network', + }) + if (apiFailures.length > MAX_API_FAILURES) apiFailures.shift() +} + +export const getApiFailures = () => { + const now = Date.now() + return apiFailures.map(failure => ({ + method: failure.method, + endpoint: failure.endpoint, + status: failure.status, + msAgo: now - failure.at, + })) +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') backgroundedCount += 1 + }) +} + +// --------------------------------------------------------------------------- +// Snapshot +// --------------------------------------------------------------------------- + +const getServiceWorkerState = async () => { + if (!('serviceWorker' in navigator)) return { supported: false } + try { + const registration = await navigator.serviceWorker.getRegistration() + return { + supported: true, + controlled: Boolean(navigator.serviceWorker.controller), + // A waiting worker means the user is running a stale bundle against a + // newer deploy — the usual cause of chunk-load failures after a release. + updateWaiting: Boolean(registration?.waiting), + } + } catch { + return { supported: true, controlled: null, updateWaiting: null } + } +} + +const getStorageState = async () => { + try { + const { quota, usage } = await navigator.storage.estimate() + return { + usageMb: Math.round((usage / 1048576) * 10) / 10, + quotaMb: Math.round(quota / 1048576), + // Storage pressure produces failures that look like anything but. + pressure: quota ? Math.round((usage / quota) * 100) : null, + } + } catch { + return null + } +} + +export const getSessionDiagnostics = async () => { + const [serviceWorker, storage] = await Promise.all([ + getServiceWorkerState(), + getStorageState(), + ]) + + return { + sessionId: SESSION_ID, + sessionStartedAt: new Date(SESSION_STARTED_AT).toISOString(), + sessionDurationMs: Date.now() - SESSION_STARTED_AT, + navigationType: NAVIGATION_TYPE, + backgroundedCount, + serverVersion, + previousRoute: getPreviousRoute(), + routeTrail: getRouteTrail(), + apiFailures: getApiFailures(), + // Chromium only; absent elsewhere rather than faked. + heapUsedMb: performance.memory + ? Math.round(performance.memory.usedJSHeapSize / 1048576) + : null, + storage, + serviceWorker, + } +} diff --git a/src/service/ErrorReportService.js b/src/service/ErrorReportService.js new file mode 100644 index 0000000..c290c52 --- /dev/null +++ b/src/service/ErrorReportService.js @@ -0,0 +1,295 @@ +import { getSessionDiagnostics } from './DiagnosticsSession' +import { collectFeedbackContext } from './FeedbackService' + +const GITHUB_URL = 'https://github.com/donetick/donetick' + +// Reports go to the same relay as feedback unless a dedicated one is set, so +// self-hosters who point at their own Worker get both for the price of one. +const REPORT_URL = + import.meta.env.VITE_ERROR_REPORT_WEBHOOK_URL || + import.meta.env.VITE_FEEDBACK_WEBHOOK_URL + +// Same trap as feedback: a chat webhook pasted straight in would reject our +// schema and ship inside the public bundle. Relay through the Worker instead. +const isRawChatWebhook = url => + /^https:\/\/(discord(app)?\.com\/api\/webhooks|hooks\.slack\.com)/i.test( + url || '', + ) + +export const SUBMIT_RESULT = { + SENT: 'sent', + FAILED: 'failed', + UNCONFIGURED: 'unconfigured', + MISCONFIGURED: 'misconfigured', + SELF_HOSTED: 'self-hosted', +} + +const MAX_STACK = 4000 + +const clamp = (value, max) => + typeof value === 'string' && value.length > max + ? `${value.slice(0, max)}\n… truncated` + : value + +/** Short, human-readable handle the user can quote back to support. */ +export const newReportId = () => + `DT-${Date.now().toString(36).toUpperCase().slice(-5)}-${Math.random() + .toString(36) + .toUpperCase() + .slice(2, 6)}` + +const safeMessage = error => { + const message = error?.message ?? error?.statusText + if (!message || message === '[object Object]') return null + return message +} + +/** Everything about the failure itself, normalised across throw shapes. */ +const describeError = (error, errorInfo) => { + if (!error) return { name: 'Unknown', message: null } + return { + name: error.name ?? error.constructor?.name ?? typeof error, + message: safeMessage(error) ?? String(error).slice(0, 500), + // react-router route errors carry an HTTP shape instead of a stack. + status: error.status ?? error.response?.status ?? null, + statusText: error.statusText ?? null, + // Vite/react-router attach a digest to server-side thrown responses. + digest: error.digest ?? null, + stack: clamp(error.stack ?? null, MAX_STACK), + componentStack: clamp(errorInfo?.componentStack ?? null, MAX_STACK), + } +} + +/** Everything about the environment the failure happened in. */ +const describeRuntime = () => { + const connection = + navigator.connection || + navigator.mozConnection || + navigator.webkitConnection + + return { + url: window.location.href, + route: window.location.pathname + window.location.search, + referrer: document.referrer || null, + viewport: `${window.innerWidth}×${window.innerHeight}`, + screen: `${window.screen?.width}×${window.screen?.height}`, + devicePixelRatio: window.devicePixelRatio, + online: navigator.onLine, + connectionType: connection?.effectiveType ?? null, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + colorScheme: window.matchMedia?.('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light', + standalone: window.matchMedia?.('(display-mode: standalone)').matches, + userAgent: navigator.userAgent, + } +} + +const cachedUser = () => { + try { + return JSON.parse(localStorage.getItem('user') || 'null') + } catch { + return null + } +} + +/** + * The full diagnostic bundle. Deliberately assembled in one place so the copy + * button, the GitHub fallback and the webhook all describe the same crash. + * + * Reads the signed-in user from cache rather than the network: by the time + * this runs the app has already failed, and a fetch may be exactly what broke. + */ +export const collectErrorReport = async ({ error, errorInfo, reportId }) => { + const user = cachedUser() + const [context, session] = await Promise.all([ + collectFeedbackContext({ + feature: window.location.pathname, + userProfile: user, + }).catch(() => ({})), + getSessionDiagnostics().catch(() => ({})), + ]) + + return { + reportId: reportId ?? newReportId(), + occurredAt: new Date().toISOString(), + error: describeError(error, errorInfo), + runtime: describeRuntime(), + app: context, + session, + } +} + +/** The plain-text rendering used by the copy button and the details panel. */ +const formatDuration = ms => { + if (ms == null) return 'unknown' + if (ms < 1000) return `${ms}ms` + if (ms < 60_000) return `${Math.round(ms / 1000)}s` + const minutes = Math.floor(ms / 60_000) + if (minutes < 60) return `${minutes}m ${Math.round((ms % 60_000) / 1000)}s` + return `${Math.floor(minutes / 60)}h ${minutes % 60}m` +} + +export const formatErrorReport = report => { + if (!report) return '' + const { app, error, runtime, session = {} } = report + const lines = [ + `Report ID: ${report.reportId}`, + `Time: ${report.occurredAt}`, + '', + `Error: ${error.name}${error.message ? `: ${error.message}` : ''}`, + error.status + ? `HTTP: ${error.status} ${error.statusText ?? ''}`.trim() + : null, + '', + `URL: ${runtime.url}`, + session.previousRoute ? `Came from: ${session.previousRoute}` : null, + '', + `App: ${app.appVersion} · ${app.platform}${app.isNative ? ' (native)' : ''}`, + `Server: ${session.serverVersion ?? 'not reported'}`, + `Session: ${formatDuration(session.sessionDurationMs)} active · ${ + session.navigationType ?? 'unknown' + } start · backgrounded ${session.backgroundedCount ?? 0}×`, + `Device: ${app.deviceModel} · ${app.osVersion}`, + `Viewport: ${runtime.viewport} @${runtime.devicePixelRatio}x · ${runtime.colorScheme}`, + `Locale: ${app.locale} · ${runtime.timezone}`, + `Network: ${runtime.online ? 'online' : 'offline'}${ + runtime.connectionType ? ` (${runtime.connectionType})` : '' + }`, + `Hosting: ${app.hosting}`, + app.userId ? `User: ${app.userId}` : null, + session.storage + ? `Storage: ${session.storage.usageMb}MB / ${session.storage.quotaMb}MB (${session.storage.pressure}%)` + : null, + session.heapUsedMb ? `Heap: ${session.heapUsedMb}MB` : null, + session.serviceWorker?.supported + ? `Service worker: ${ + session.serviceWorker.controlled ? 'controlling' : 'not controlling' + }${session.serviceWorker.updateWaiting ? ' · UPDATE WAITING' : ''}` + : null, + ].filter(Boolean) + + if (session.routeTrail?.length) { + lines.push( + '', + 'Route trail (oldest first):', + ...session.routeTrail.map( + entry => + `- ${entry.path} · ${formatDuration(entry.dwellMs)} · ${formatDuration( + entry.msAgo, + )} ago`, + ), + ) + } + if (session.apiFailures?.length) { + lines.push( + '', + 'Recent API failures:', + ...session.apiFailures.map( + failure => + `- ${failure.method} ${failure.endpoint} → ${ + failure.status + } (${formatDuration(failure.msAgo)} ago)`, + ), + ) + } + if (app.recentErrors?.length) { + lines.push('', 'Recent errors:', ...app.recentErrors.map(e => `- ${e}`)) + } + if (error.componentStack) { + lines.push('', 'Component stack:', error.componentStack.trim()) + } + if (error.stack) { + lines.push('', 'Stack:', error.stack) + } + return lines.join('\n') +} + +/** + * Pre-filled GitHub issue for self-hosted instances — their crash data never + * leaves infrastructure they control, and they see it before it is published. + */ +export const buildErrorIssueUrl = ({ description, report }) => { + const title = `[crash] ${ + report.error.message?.slice(0, 80) || + report.error.name || + 'Unexpected error' + }` + const body = [ + '### What happened', + description?.trim() || '_no description provided_', + '', + '### Diagnostics', + '```', + formatErrorReport(report), + '```', + ].join('\n') + + return `${GITHUB_URL}/issues/new?labels=bug&title=${encodeURIComponent( + title, + )}&body=${encodeURIComponent(body)}` +} + +/** + * Posts the report to the relay. Never throws — a failed crash report must not + * produce a second crash, so every path resolves to a result the UI can show. + */ +export const submitErrorReport = async ({ + contactEmail, + description, + report, +}) => { + const payload = { + source: 'donetick-app', + kind: 'error-report', + reportId: report.reportId, + description: description?.trim() || null, + contactEmail: contactEmail?.trim() || null, + report, + } + + // Enforced here, not only in the UI, so no future caller can relay a + // self-hosted instance's stack traces to the hosted endpoint. + if (report.app?.hosting !== 'cloud') { + return { + result: SUBMIT_RESULT.SELF_HOSTED, + githubUrl: buildErrorIssueUrl({ description, report }), + } + } + + if (!REPORT_URL) { + console.info('ErrorReportService: no endpoint configured, report:', payload) + return { + result: SUBMIT_RESULT.UNCONFIGURED, + githubUrl: buildErrorIssueUrl({ description, report }), + } + } + + if (isRawChatWebhook(REPORT_URL)) { + console.error( + 'ErrorReportService: the report URL points directly at a Discord/Slack ' + + 'webhook. Deploy the relay Worker and point the variable at it.', + payload, + ) + return { + result: SUBMIT_RESULT.MISCONFIGURED, + githubUrl: buildErrorIssueUrl({ description, report }), + } + } + + try { + const response = await fetch(REPORT_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (response.ok) return { result: SUBMIT_RESULT.SENT } + } catch (submitError) { + console.warn('ErrorReportService: submission failed', submitError) + } + + return { + result: SUBMIT_RESULT.FAILED, + githubUrl: buildErrorIssueUrl({ description, report }), + } +} diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js index 25542d2..3510ae1 100644 --- a/src/utils/ApiClient.js +++ b/src/utils/ApiClient.js @@ -1,6 +1,11 @@ import { Preferences } from '@capacitor/preferences' + import { API_URL } from '../Config' import { networkManager } from '../hooks/NetworkManager' +import { + recordApiFailure, + recordServerVersionFromResponse, +} from '../service/DiagnosticsSession' import { logout, RefreshToken } from './Fetcher' import { isOAuthExchangeInProgress } from './OAuthExchangeState' import { offlineDB } from './OfflineDB' @@ -129,7 +134,7 @@ class ApiClient { // Process queued requests after refresh attempt processQueue(error, token = null) { - this.failedQueue.forEach(({ resolve, reject }) => { + this.failedQueue.forEach(({ reject, resolve }) => { if (error) { reject(error) } else { @@ -199,6 +204,17 @@ class ApiClient { let response = await fetch(url, config) clearTimeout(timeoutId) + // Passive diagnostics: learn the server build from whatever it already + // answers, and keep the last few refusals for crash reports. + recordServerVersionFromResponse(response) + if (!response.ok) { + recordApiFailure({ + endpoint, + method: config.method, + status: response.status, + }) + } + // 2. Check for 401 (Unauthorized) if (response.status === 401) { // Always queue this request first @@ -280,6 +296,7 @@ class ApiClient { error?.name === 'AbortError' && options.signal?.aborted if (!externalAbort) { networkManager.setServerUnreachable() + recordApiFailure({ endpoint, method: config.method, status: 'network' }) } console.error('Request failed', error) throw error diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index d453449..bf6ec29 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -372,7 +372,6 @@ const MyChores = () => { } processEffectAsync() - // throw new Error('Fake Error to test posthog') } }, [ membersLoading, diff --git a/src/views/Error.jsx b/src/views/Error.jsx index 10a06e1..f06a2b1 100644 --- a/src/views/Error.jsx +++ b/src/views/Error.jsx @@ -10,9 +10,15 @@ import { SearchOffRounded, } from '@mui/icons-material' import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Link, useRouteError } from 'react-router-dom' +import { + collectErrorReport, + formatErrorReport, +} from '../service/ErrorReportService' +import ErrorReportModal from './Modals/ErrorReportModal' + const getErrorKind = error => { if (!error) return { label: 'Unknown Error', color: 'danger', Icon: ErrorRounded } @@ -48,27 +54,30 @@ const safeMessage = error => { return msg } -const buildErrorText = (error, url) => { - const lines = [ - `URL: ${url}`, - `Time: ${new Date().toISOString()}`, - `Error: ${safeMessage(error) ?? String(error)}`, - ] - if (error?.stack) lines.push(`\nStack:\n${error.stack}`) - return lines.join('\n') -} - const Error = () => { const error = useRouteError() const [showDetails, setShowDetails] = useState(false) const [copied, setCopied] = useState(false) + const [reportOpen, setReportOpen] = useState(false) + const [reportText, setReportText] = useState('') - const { color, Icon } = getErrorKind(error) + const { Icon, color } = getErrorKind(error) const message = safeMessage(error) - const url = window.location.href + + // The same bundle the report modal sends, so what the user copies and what + // we receive can never disagree. + useEffect(() => { + let active = true + collectErrorReport({ error }).then(report => { + if (active) setReportText(formatErrorReport(report)) + }) + return () => { + active = false + } + }, [error]) const handleCopy = () => { - navigator.clipboard.writeText(buildErrorText(error, url)).then(() => { + navigator.clipboard.writeText(reportText).then(() => { setCopied(true) }) } @@ -209,11 +218,24 @@ const Error = () => { size='lg' startDecorator={} onClick={() => window.location.reload()} - sx={{ width: '100%', mb: 2 }} + sx={{ width: '100%', mb: 1.5 }} > Try again + {/* Reporting is one tap from the failure, where the context is still + fresh — asking people to find it in Settings afterwards never works. */} + + {/* Secondary actions */}