mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 17:37:46 +00:00
feat: structured preload error logging with Sentry enrichment (#8928)
## Summary Add structured preload error logging with Sentry context enrichment and a user-facing toast notification when chunk loading fails (e.g. after a deploy with new hashed filenames). ## Changes - **`parsePreloadError` utility** (`src/utils/preloadErrorUtil.ts`): Extracts structured info from `vite:preloadError` events — URL, file type (JS/CSS/unknown), chunk name, and whether it looks like a hash mismatch. - **Sentry enrichment** (`src/App.vue`): Sets Sentry context and tags on preload errors so they are searchable/filterable in the Sentry dashboard. - **User-facing toast**: Shows an actionable "please refresh" message when a preload error occurs, across all distributions (cloud, desktop, localhost). - **Capture-phase resource error listener** (`src/App.vue`): Catches CSS/script load failures that bypass `vite:preloadError` and reports them to Sentry with the same structured context. - **Unit tests** (`src/utils/preloadErrorUtil.test.ts`): 9 tests covering URL parsing, chunk name extraction, hash mismatch detection, and edge cases. ## Files Changed | File | What | |------|------| | `src/App.vue` | Preload error handler + resource error listener | | `src/locales/en/main.json` | Toast message string | | `src/utils/preloadErrorUtil.ts` | `parsePreloadError()` utility | | `src/utils/preloadErrorUtil.test.ts` | Unit tests | ## Review Focus - Toast fires for all distributions (cloud/desktop/localhost) — intentional so all users see stale chunk errors - `parsePreloadError` is defensive — returns `unknown` for any field it cannot parse - Capture-phase listener filters to only `<script>` and `<link rel="stylesheet">` elements ## References - [Vite preload error handling](https://vite.dev/guide/build#load-error-handling) --------- Co-authored-by: bymyself <cbyrne@comfy.org>
This commit is contained in:
committed by
GitHub
parent
f1626acb61
commit
fcdc08fb27
72
src/App.vue
72
src/App.vue
@@ -9,15 +9,19 @@ import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { parsePreloadError } from '@/utils/preloadErrorUtil'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
|
||||
@@ -45,6 +49,19 @@ const showContextMenu = (event: MouseEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourceError(url: string, tagName: string) {
|
||||
console.error('[resource:loadError]', { url, tagName })
|
||||
|
||||
if (__DISTRIBUTION__ === 'cloud') {
|
||||
captureException(new Error(`Resource load failed: ${url}`), {
|
||||
tags: {
|
||||
error_type: 'resource_load_error',
|
||||
tag_name: tagName
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
|
||||
|
||||
@@ -56,15 +73,56 @@ onMounted(() => {
|
||||
// See: https://vite.dev/guide/build#load-error-handling
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
event.preventDefault()
|
||||
const info = parsePreloadError(event.payload)
|
||||
console.error('[vite:preloadError]', {
|
||||
url: info.url,
|
||||
fileType: info.fileType,
|
||||
chunkName: info.chunkName,
|
||||
message: info.message
|
||||
})
|
||||
if (__DISTRIBUTION__ === 'cloud') {
|
||||
captureException(event.payload, {
|
||||
tags: { error_type: 'vite_preload_error' }
|
||||
tags: {
|
||||
error_type: 'vite_preload_error',
|
||||
file_type: info.fileType,
|
||||
chunk_name: info.chunkName ?? undefined
|
||||
},
|
||||
contexts: {
|
||||
preload: {
|
||||
url: info.url,
|
||||
fileType: info.fileType,
|
||||
chunkName: info.chunkName
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.error('[vite:preloadError]', event.payload)
|
||||
}
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.preloadErrorTitle'),
|
||||
detail: t('g.preloadError'),
|
||||
life: 10000
|
||||
})
|
||||
})
|
||||
|
||||
// Capture resource load failures (CSS, scripts) in non-localhost distributions
|
||||
if (__DISTRIBUTION__ !== 'localhost') {
|
||||
window.addEventListener(
|
||||
'error',
|
||||
(event) => {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLScriptElement) {
|
||||
handleResourceError(target.src, 'script')
|
||||
} else if (
|
||||
target instanceof HTMLLinkElement &&
|
||||
target.rel === 'stylesheet'
|
||||
) {
|
||||
handleResourceError(target.href, 'link')
|
||||
}
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize conflict detection in background
|
||||
// This runs async and doesn't block UI setup
|
||||
void conflictDetection.initializeConflictDetection()
|
||||
|
||||
@@ -326,6 +326,8 @@
|
||||
"nightly": "NIGHTLY",
|
||||
"profile": "Profile",
|
||||
"noItems": "No items",
|
||||
"preloadError": "A required resource failed to load. Please reload the page.",
|
||||
"preloadErrorTitle": "Loading Error",
|
||||
"recents": "Recents",
|
||||
"partner": "Partner",
|
||||
"collapseAll": "Collapse all",
|
||||
|
||||
92
src/utils/preloadErrorUtil.test.ts
Normal file
92
src/utils/preloadErrorUtil.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parsePreloadError } from './preloadErrorUtil'
|
||||
|
||||
describe('parsePreloadError', () => {
|
||||
it('parses CSS preload error', () => {
|
||||
const error = new Error(
|
||||
'Unable to preload CSS for /assets/vendor-vue-core-abc123.css'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.url).toBe('/assets/vendor-vue-core-abc123.css')
|
||||
expect(result.fileType).toBe('css')
|
||||
expect(result.chunkName).toBe('vendor-vue-core')
|
||||
expect(result.message).toBe(error.message)
|
||||
})
|
||||
|
||||
it('parses dynamically imported module error', () => {
|
||||
const error = new Error(
|
||||
'Failed to fetch dynamically imported module: https://example.com/assets/vendor-three-def456.js'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.url).toBe('https://example.com/assets/vendor-three-def456.js')
|
||||
expect(result.fileType).toBe('js')
|
||||
expect(result.chunkName).toBe('vendor-three')
|
||||
})
|
||||
|
||||
it('extracts URL from generic error message', () => {
|
||||
const error = new Error(
|
||||
'Something went wrong loading https://cdn.example.com/assets/app-9f8e7d.js'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.url).toBe('https://cdn.example.com/assets/app-9f8e7d.js')
|
||||
expect(result.fileType).toBe('js')
|
||||
expect(result.chunkName).toBe('app')
|
||||
})
|
||||
|
||||
it('returns null url when no URL found', () => {
|
||||
const error = new Error('Something failed')
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.url).toBeNull()
|
||||
expect(result.fileType).toBe('unknown')
|
||||
expect(result.chunkName).toBeNull()
|
||||
})
|
||||
|
||||
it('detects font file types', () => {
|
||||
const error = new Error(
|
||||
'Unable to preload CSS for /assets/inter-abc123.woff2'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.fileType).toBe('font')
|
||||
})
|
||||
|
||||
it('detects image file types', () => {
|
||||
const error = new Error('Unable to preload CSS for /assets/logo-abc123.png')
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.fileType).toBe('image')
|
||||
})
|
||||
|
||||
it('handles mjs extension', () => {
|
||||
const error = new Error(
|
||||
'Failed to fetch dynamically imported module: /assets/chunk-abc123.mjs'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.fileType).toBe('js')
|
||||
})
|
||||
|
||||
it('handles URLs with query parameters', () => {
|
||||
const error = new Error(
|
||||
'Unable to preload CSS for /assets/style-abc123.css?v=2'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.url).toBe('/assets/style-abc123.css?v=2')
|
||||
expect(result.fileType).toBe('css')
|
||||
})
|
||||
|
||||
it('extracts chunk name from filename without hash', () => {
|
||||
const error = new Error(
|
||||
'Failed to fetch dynamically imported module: /assets/index.js'
|
||||
)
|
||||
const result = parsePreloadError(error)
|
||||
|
||||
expect(result.chunkName).toBe('index')
|
||||
})
|
||||
})
|
||||
77
src/utils/preloadErrorUtil.ts
Normal file
77
src/utils/preloadErrorUtil.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
type PreloadFileType = 'js' | 'css' | 'font' | 'image' | 'unknown'
|
||||
|
||||
interface PreloadErrorInfo {
|
||||
url: string | null
|
||||
fileType: PreloadFileType
|
||||
chunkName: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
const CSS_PRELOAD_RE = /Unable to preload CSS for (.+)/
|
||||
const JS_DYNAMIC_IMPORT_RE =
|
||||
/Failed to fetch dynamically imported module:\s*(.+)/
|
||||
const URL_FALLBACK_RE = /https?:\/\/[^\s"')]+/
|
||||
|
||||
const FONT_EXTENSIONS = new Set(['woff', 'woff2', 'ttf', 'otf', 'eot'])
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'svg',
|
||||
'webp',
|
||||
'avif',
|
||||
'ico'
|
||||
])
|
||||
|
||||
function extractUrl(message: string): string | null {
|
||||
const cssMatch = message.match(CSS_PRELOAD_RE)
|
||||
if (cssMatch) return cssMatch[1].trim()
|
||||
|
||||
const jsMatch = message.match(JS_DYNAMIC_IMPORT_RE)
|
||||
if (jsMatch) return jsMatch[1].trim()
|
||||
|
||||
const fallbackMatch = message.match(URL_FALLBACK_RE)
|
||||
if (fallbackMatch) return fallbackMatch[0]
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function detectFileType(url: string): PreloadFileType {
|
||||
const pathname = new URL(url, 'https://cloud.comfy.org').pathname
|
||||
const ext = pathname.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return 'unknown'
|
||||
|
||||
// Strip query params from extension
|
||||
const cleanExt = ext.split('?')[0]
|
||||
|
||||
if (cleanExt === 'js' || cleanExt === 'mjs') return 'js'
|
||||
if (cleanExt === 'css') return 'css'
|
||||
if (FONT_EXTENSIONS.has(cleanExt)) return 'font'
|
||||
if (IMAGE_EXTENSIONS.has(cleanExt)) return 'image'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function extractChunkName(url: string): string | null {
|
||||
const pathname = new URL(url, 'https://cloud.comfy.org').pathname
|
||||
const filename = pathname.split('/').pop()
|
||||
if (!filename) return null
|
||||
|
||||
// Strip extension
|
||||
const nameWithoutExt = filename.replace(/\.[^.]+$/, '')
|
||||
// Strip hash suffix (e.g. "vendor-vue-core-abc123" -> "vendor-vue-core")
|
||||
const withoutHash = nameWithoutExt.replace(/-[a-f0-9]{6,}$/, '')
|
||||
return withoutHash || null
|
||||
}
|
||||
|
||||
export function parsePreloadError(error: Error): PreloadErrorInfo {
|
||||
const message = error.message || String(error)
|
||||
const url = extractUrl(message)
|
||||
|
||||
return {
|
||||
url,
|
||||
fileType: url ? detectFileType(url) : 'unknown',
|
||||
chunkName: url ? extractChunkName(url) : null,
|
||||
message
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user