249 lines
8.0 KiB
TypeScript
249 lines
8.0 KiB
TypeScript
export type CrashReportStatus = 'pending' | 'sent' | 'dismissed'
|
|
export type CrashReportSource = 'renderer' | 'child'
|
|
|
|
export type CrashReportDetailValue = string | number | boolean | null
|
|
export type CrashReportBreadcrumbData = Record<string, CrashReportDetailValue>
|
|
|
|
export type CrashReportBreadcrumb = {
|
|
createdAt: string
|
|
name: string
|
|
data?: CrashReportBreadcrumbData
|
|
}
|
|
|
|
export type CrashReportBreadcrumbInput = {
|
|
createdAt: string
|
|
name: string
|
|
data?: Record<string, unknown>
|
|
}
|
|
|
|
export type CrashReportRecord = {
|
|
id: string
|
|
createdAt: string
|
|
status: CrashReportStatus
|
|
source: CrashReportSource
|
|
processType: string
|
|
reason: string
|
|
exitCode: number | null
|
|
appVersion: string
|
|
platform: NodeJS.Platform
|
|
osRelease: string
|
|
arch: string
|
|
electronVersion: string
|
|
chromeVersion: string
|
|
details: Record<string, CrashReportDetailValue>
|
|
breadcrumbs?: CrashReportBreadcrumb[]
|
|
}
|
|
|
|
export type CrashReportCreateInput = Omit<
|
|
CrashReportRecord,
|
|
'id' | 'createdAt' | 'status' | 'details' | 'breadcrumbs'
|
|
> & {
|
|
details: Record<string, unknown>
|
|
breadcrumbs?: CrashReportBreadcrumbInput[]
|
|
}
|
|
|
|
export type ReactErrorBoundarySurface =
|
|
| 'app-root'
|
|
| 'web-root'
|
|
| 'workspace-shell'
|
|
| 'sidebar'
|
|
| 'terminal-workbench'
|
|
| 'right-sidebar'
|
|
| 'page'
|
|
| 'modal'
|
|
| 'overlay'
|
|
| 'rich-markdown-editor'
|
|
|
|
export type ReactErrorBoundaryReportArgs = {
|
|
boundaryId: string
|
|
surface: ReactErrorBoundarySurface
|
|
errorName: string
|
|
errorMessage: string
|
|
errorStack?: string
|
|
componentStack?: string
|
|
activeView?: string
|
|
activeModal?: string | null
|
|
activeTabType?: string | null
|
|
activeRightSidebarTab?: string | null
|
|
hasActiveWorktree?: boolean
|
|
}
|
|
|
|
export type ReactErrorBoundaryReportResult =
|
|
| { ok: true; report: CrashReportRecord | null; deduped: boolean }
|
|
| { ok: false; error: string }
|
|
|
|
export type CrashReportSubmitArgs = {
|
|
reportId?: string
|
|
notes?: string
|
|
submitAnonymously?: boolean
|
|
githubLogin: string | null
|
|
githubEmail: string | null
|
|
}
|
|
|
|
export type CrashReportSubmitResult =
|
|
| { ok: true; report: CrashReportRecord }
|
|
| { ok: false; status: number | null; error: string; report?: CrashReportRecord }
|
|
|
|
const MAX_STRING_DETAIL_LENGTH = 240
|
|
const MAX_STACK_DETAIL_LENGTH = 4_000
|
|
const MAX_BREADCRUMB_NAME_LENGTH = 80
|
|
const MAX_BREADCRUMBS = 30
|
|
const MAX_FORMATTED_REPORT_LENGTH = 64_000
|
|
const FORMATTED_REPORT_TRUNCATION_SUFFIX =
|
|
'\n\n[Crash report truncated to fit feedback endpoint limits.]'
|
|
const SECRET_PATTERNS = [
|
|
/\b(gh[pousr]_[A-Za-z0-9_]{20,})\b/g,
|
|
/\b(sk-[A-Za-z0-9_-]{20,})\b/g,
|
|
/\b([A-Za-z0-9._%+-]+:[A-Za-z0-9._%+-]+@)(?=[^/\s]+)/g,
|
|
/\b(token|api[_-]?key|secret|password)=([^&\s]+)/gi
|
|
]
|
|
|
|
const PATH_PATTERNS = [
|
|
/\/(?:Users|home)\/(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi,
|
|
/\/(?:Applications|Library|System|Volumes|etc|media|mnt|opt|private|root|srv|tmp|usr|var)\/(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi,
|
|
/\/[A-Za-z0-9._ -]+\/(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi,
|
|
/[A-Za-z]:\\(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi,
|
|
/\\\\[^\\\s"'`<>\n\r)]+\\(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi
|
|
]
|
|
|
|
export function isCrashReportReason(reason: string): boolean {
|
|
return [
|
|
'abnormal-exit',
|
|
'crashed',
|
|
'integrity-failure',
|
|
'killed',
|
|
'launch-failed',
|
|
'memory-eviction',
|
|
'oom'
|
|
].includes(reason)
|
|
}
|
|
|
|
export function isReactErrorBoundaryReport(report: CrashReportRecord): boolean {
|
|
return (
|
|
report.source === 'renderer' &&
|
|
report.processType === 'react-render' &&
|
|
report.reason === 'react-error-boundary'
|
|
)
|
|
}
|
|
|
|
export function sanitizeCrashReportString(
|
|
value: string,
|
|
maxLength = MAX_STRING_DETAIL_LENGTH
|
|
): string {
|
|
let sanitized = value
|
|
for (const pattern of PATH_PATTERNS) {
|
|
sanitized = sanitized.replace(pattern, '[redacted-path]')
|
|
}
|
|
for (const pattern of SECRET_PATTERNS) {
|
|
sanitized = sanitized.replace(pattern, (match, key?: string) => {
|
|
if (key && /^(token|api[_-]?key|secret|password)$/i.test(key)) {
|
|
return `${key}=[redacted]`
|
|
}
|
|
return match.includes('@') ? '[redacted-credential]@' : '[redacted-secret]'
|
|
})
|
|
}
|
|
return sanitized.length > maxLength ? `${sanitized.slice(0, maxLength)}...` : sanitized
|
|
}
|
|
|
|
function maxDetailStringLengthForKey(key: string): number {
|
|
return /(?:^|_)(?:stack|component_stack|error_stack)$/i.test(key)
|
|
? MAX_STACK_DETAIL_LENGTH
|
|
: MAX_STRING_DETAIL_LENGTH
|
|
}
|
|
|
|
export function sanitizeCrashReportDetails(
|
|
details: Record<string, unknown>
|
|
): Record<string, CrashReportDetailValue> {
|
|
const sanitized: Record<string, CrashReportDetailValue> = {}
|
|
for (const [key, value] of Object.entries(details)) {
|
|
if (typeof value === 'string') {
|
|
sanitized[key] = sanitizeCrashReportString(value, maxDetailStringLengthForKey(key))
|
|
} else if (typeof value === 'number' && Number.isFinite(value)) {
|
|
sanitized[key] = value
|
|
} else if (typeof value === 'boolean' || value === null) {
|
|
sanitized[key] = value
|
|
}
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
export function sanitizeCrashReportBreadcrumbs(
|
|
breadcrumbs: CrashReportBreadcrumbInput[] | undefined
|
|
): CrashReportBreadcrumb[] | undefined {
|
|
if (!breadcrumbs || breadcrumbs.length === 0) {
|
|
return undefined
|
|
}
|
|
|
|
const sanitized = breadcrumbs
|
|
.slice(-MAX_BREADCRUMBS)
|
|
.map((breadcrumb): CrashReportBreadcrumb | null => {
|
|
if (!breadcrumb.name.trim() || !breadcrumb.createdAt.trim()) {
|
|
return null
|
|
}
|
|
const data = breadcrumb.data ? sanitizeCrashReportDetails(breadcrumb.data) : {}
|
|
return {
|
|
createdAt: sanitizeCrashReportString(breadcrumb.createdAt),
|
|
name: sanitizeCrashReportString(breadcrumb.name).slice(0, MAX_BREADCRUMB_NAME_LENGTH),
|
|
...(Object.keys(data).length > 0 ? { data } : {})
|
|
}
|
|
})
|
|
.filter((breadcrumb): breadcrumb is CrashReportBreadcrumb => breadcrumb !== null)
|
|
|
|
return sanitized.length > 0 ? sanitized : undefined
|
|
}
|
|
|
|
export function formatCrashReportText(report: CrashReportRecord, notes?: string): string {
|
|
const lines = [
|
|
'[Crash Report]',
|
|
'',
|
|
`Report ID: ${report.id}`,
|
|
`Created: ${report.createdAt}`,
|
|
`Status: ${report.status}`,
|
|
`Source: ${report.source}`,
|
|
`Process: ${report.processType}`,
|
|
`Reason: ${report.reason}`,
|
|
`Exit code: ${report.exitCode ?? 'unknown'}`,
|
|
`App version: ${report.appVersion}`,
|
|
`Platform: ${report.platform} ${report.osRelease} ${report.arch}`,
|
|
`Electron: ${report.electronVersion}`,
|
|
`Chrome: ${report.chromeVersion}`
|
|
]
|
|
|
|
const details = Object.entries(report.details)
|
|
if (details.length > 0) {
|
|
lines.push('', 'Details:')
|
|
for (const [key, value] of details) {
|
|
lines.push(`- ${key}: ${String(value)}`)
|
|
}
|
|
}
|
|
|
|
if (report.breadcrumbs && report.breadcrumbs.length > 0) {
|
|
lines.push('', 'Recent activity:')
|
|
for (const breadcrumb of report.breadcrumbs) {
|
|
const data = breadcrumb.data ? Object.entries(breadcrumb.data) : []
|
|
const suffix =
|
|
data.length > 0
|
|
? ` (${data.map(([key, value]) => `${key}=${String(value)}`).join(', ')})`
|
|
: ''
|
|
lines.push(`- ${breadcrumb.createdAt}: ${breadcrumb.name}${suffix}`)
|
|
}
|
|
}
|
|
|
|
const trimmedNotes = notes?.trim()
|
|
if (trimmedNotes) {
|
|
lines.push('', 'User notes:', sanitizeCrashReportString(trimmedNotes))
|
|
}
|
|
|
|
return truncateFormattedCrashReport(lines.join('\n'))
|
|
}
|
|
|
|
function truncateFormattedCrashReport(text: string): string {
|
|
if (text.length <= MAX_FORMATTED_REPORT_LENGTH) {
|
|
return text
|
|
}
|
|
// Why: the feedback endpoint accepts larger crash bodies and handles
|
|
// Slack-specific attachments server-side. Keep local reports below that API cap.
|
|
const budget = MAX_FORMATTED_REPORT_LENGTH - FORMATTED_REPORT_TRUNCATION_SUFFIX.length
|
|
return `${text.slice(0, Math.max(0, budget)).trimEnd()}${FORMATTED_REPORT_TRUNCATION_SUFFIX}`
|
|
}
|