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:
Mohamad Tarbin
2026-08-07 00:10:51 -04:00
committed by GitHub
parent 903d022877
commit 475806ac29
7 changed files with 995 additions and 39 deletions

View 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 }),
}
}