Error reporting (#196)
* implement error reporting modal and service integration * Add session diagnostics for error reporting and enhance API failure tracking
This commit is contained in:
295
src/service/ErrorReportService.js
Normal file
295
src/service/ErrorReportService.js
Normal file
@@ -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 }),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user