From a9affcfdc2ea10fc7d2ee96471e1b4f112ff06d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:34:35 +0000 Subject: [PATCH 1/6] Initial plan From 74bd99080737a9faeee75cf74121bb25ca7ecae7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:36:33 +0000 Subject: [PATCH 2/6] =?UTF-8?q?chore:=20=E5=88=B6=E5=AE=9A=20monitor-test?= =?UTF-8?q?=20=E8=A1=A5=E5=BC=BA=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/EzStars/EzMonitor/sessions/34f3cb4c-9d92-472f-b3c6-4f5e9d11522f --- pnpm-workspace.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 85b5bc0..c87b188 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -135,3 +135,17 @@ catalogs: typescript: ^5.7.3 conflicts_typescript-eslint_h8_20_0: typescript-eslint: ^8.20.0 + conflicts_react-router-dom_h6_28_0: + react-router-dom: ^6.28.0 + conflicts_@eslint/js_h9_18_0: + '@eslint/js': ^9.18.0 + conflicts_conflicts_@types/node_h24_12_0_h22_10_7: + '@types/node': ^22.10.7 + conflicts_conflicts_eslint_h9_39_4_h9_18_0: + eslint: ^9.18.0 + conflicts_conflicts_globals_h17_4_0_h16_0_0: + globals: ^16.0.0 + conflicts_conflicts_typescript_t5_9_3_h5_7_3: + typescript: ^5.7.3 + conflicts_conflicts_typescript-eslint_h8_57_0_h8_20_0: + typescript-eslint: ^8.20.0 From 1042500b282d84d5a85d342fb7f2c40203b0cf95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:42:20 +0000 Subject: [PATCH 3/6] =?UTF-8?q?feat(monitor-test):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E6=81=A2=E5=A4=8D=E4=B8=8A=E4=BC=A0=E4=B8=8E?= =?UTF-8?q?=E7=99=BD=E5=B1=8F=E6=A3=80=E6=B5=8B=E9=AA=8C=E8=AF=81=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/EzStars/EzMonitor/sessions/34f3cb4c-9d92-472f-b3c6-4f5e9d11522f --- app/monitor-test/README.md | 16 +- .../build/autoSourcemapUploadPlugin.ts | 90 ++++++++ app/monitor-test/src/hooks/useMonitorSDK.ts | 6 + app/monitor-test/src/pages/ErrorPage.tsx | 201 +++++++++++++++++- app/monitor-test/src/pages/HomePage.tsx | 9 +- .../src/pages/PerformancePage.tsx | 22 +- .../src/services/reliability.spec.ts | 47 ++++ app/monitor-test/src/services/reliability.ts | 92 ++++++++ app/monitor-test/src/services/sdkRuntime.ts | 62 ++++++ 9 files changed, 531 insertions(+), 14 deletions(-) create mode 100644 app/monitor-test/build/autoSourcemapUploadPlugin.ts create mode 100644 app/monitor-test/src/services/reliability.spec.ts create mode 100644 app/monitor-test/src/services/reliability.ts diff --git a/app/monitor-test/README.md b/app/monitor-test/README.md index f62e812..bbd641d 100644 --- a/app/monitor-test/README.md +++ b/app/monitor-test/README.md @@ -12,8 +12,8 @@ monitor-test 是 EzMonitor SDK 的测试门户应用,不是单一 demo 页面 - `/`:测试主页 - `/tracking`:TrackingPlugin(track / trackPage / trackUser) -- `/performance`:性能测试(long task、Navigation/Resource/User Timing,含 Core Web Vitals 自动采集) -- `/error`:错误测试(同步错误、Promise rejection、资源错误) +- `/performance`:性能测试(long task、Navigation/Resource/User Timing、交互延迟,含 Core Web Vitals 自动采集) +- `/error`:错误测试(同步错误、Promise rejection、资源错误、离线恢复上传、白屏检测) - `/data-generator`:批量数据生成器 ## 运行 @@ -37,9 +37,11 @@ pnpm --filter monitor-test run dev 1. 打开主页,确认可跳转到所有专题页。 2. 在 tracking 页分别触发 track、trackPage、trackUser,确认页面日志有回显。 -3. 在 performance 页触发 long task,确认耗时显示并触发事件上报。 +3. 在 performance 页触发 long task/交互延迟采样,确认耗时显示并触发事件上报。 4. 在 error 页触发三类错误,确认错误回显区域可见记录。 -5. 运行构建与 lint: +5. 在 error 页断网后触发“发送离线探针事件”,再恢复网络,确认本地队列可被刷新重传。 +6. 在 error 页启动白屏检测并触发“显示 7 秒白屏遮罩”,确认出现 `error_white_screen` 上报日志。 +7. 运行构建与 lint: ```bash pnpm --filter monitor-test run build @@ -94,6 +96,12 @@ pnpm --filter monitor-test run preview - **跨域失败**:确认前端端口在后端 CORS 白名单中。 - **端口冲突**:如果 `monitor-app` 同时运行,Vite 可能自动切换到 `5174`。 +## 对标补齐说明(Sentry / New Relic / OpenTelemetry Browser) + +- **离线恢复上传**:支持本地队列可视化、online 事件自动 flush、手动 flush。 +- **白屏检测**:支持 18 点采样 + 持续时间阈值触发 `error_white_screen`。 +- **交互延迟验证**:补充 `performance_interaction_delay` 用于辅助 INP 场景回归。 + ## 目录结构 ```text diff --git a/app/monitor-test/build/autoSourcemapUploadPlugin.ts b/app/monitor-test/build/autoSourcemapUploadPlugin.ts new file mode 100644 index 0000000..e203a1f --- /dev/null +++ b/app/monitor-test/build/autoSourcemapUploadPlugin.ts @@ -0,0 +1,90 @@ +import type { Plugin } from 'vite' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +interface AutoSourcemapUploadPluginOptions { + appId: string + release: string + uploadKey?: string + baseUrl: string + strict?: boolean +} + +interface SourceMapPayload { + appId: string + release: string + file: string + map: string +} + +async function collectSourcemapPayloads( + assetsDir: string, + options: AutoSourcemapUploadPluginOptions, +): Promise { + const files = await fs.readdir(assetsDir) + const payloads: SourceMapPayload[] = [] + + for (const file of files) { + if (!file.endsWith('.js.map')) { + continue + } + const mapPath = path.join(assetsDir, file) + const map = await fs.readFile(mapPath, 'utf8') + payloads.push({ + appId: options.appId, + release: options.release, + file: `assets/${file.slice(0, -4)}`, + map, + }) + } + + return payloads +} + +export function createAutoSourcemapUploadPlugin( + options: AutoSourcemapUploadPluginOptions, +): Plugin { + return { + name: 'monitor-test-auto-sourcemap-upload', + apply: 'build', + async closeBundle() { + if (!options.uploadKey) { + return + } + + const assetsDir = path.resolve(process.cwd(), 'dist/assets') + let payloads: SourceMapPayload[] = [] + try { + payloads = await collectSourcemapPayloads(assetsDir, options) + } + catch (error) { + if (options.strict) { + throw error + } + console.warn('[monitor-test] skip sourcemap upload:', error) + return + } + + for (const payload of payloads) { + const response = await fetch(`${options.baseUrl}/api/monitor/sourcemap`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-monitor-upload-key': options.uploadKey, + }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + const body = await response.text() + const error = new Error(`[monitor-test] sourcemap upload failed (${response.status}): ${body}`) + if (options.strict) { + throw error + } + console.warn(error.message) + } + } + }, + } +} diff --git a/app/monitor-test/src/hooks/useMonitorSDK.ts b/app/monitor-test/src/hooks/useMonitorSDK.ts index db7b9b5..50bcc8f 100644 --- a/app/monitor-test/src/hooks/useMonitorSDK.ts +++ b/app/monitor-test/src/hooks/useMonitorSDK.ts @@ -2,7 +2,10 @@ import { useEffect, useMemo, useState } from 'react' import { ensureSDKStarted, flushReplay, + flushReportQueue, + getReportQueueStorageKey, getSDKStatus, + readPersistedReportQueue, reportError, trackEvent, trackPage, @@ -26,7 +29,10 @@ export function useMonitorSDK() { return useMemo(() => ({ status, + flushReportQueue, flushReplay, + getReportQueueStorageKey, + readPersistedReportQueue, reportError, trackEvent, trackPage, diff --git a/app/monitor-test/src/pages/ErrorPage.tsx b/app/monitor-test/src/pages/ErrorPage.tsx index 07c0dc0..eb07966 100644 --- a/app/monitor-test/src/pages/ErrorPage.tsx +++ b/app/monitor-test/src/pages/ErrorPage.tsx @@ -1,19 +1,38 @@ -import { useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useMonitorSDK } from '../hooks/useMonitorSDK' +import { collectWhiteScreenSnapshot } from '../services/reliability' interface ErrorLog { id: number - kind: 'sync' | 'promise' | 'resource' | 'network' | 'listener' + kind: 'sync' | 'promise' | 'resource' | 'network' | 'listener' | 'offline' | 'white-screen' title: string detail: string payload: unknown } +const WHITE_SCREEN_WINDOW_MS = 6000 +const WHITE_SCREEN_INTERVAL_MS = 1000 +const WHITE_SCREEN_THRESHOLD = 0.95 +const ROOT_SELECTORS = ['#root', '.portal-shell'] +const SKELETON_SELECTORS = ['.skeleton', '.loading', '[data-skeleton]'] + export default function ErrorPage() { - const { status } = useMonitorSDK() + const { + status, + flushReportQueue, + getReportQueueStorageKey, + readPersistedReportQueue, + reportError, + trackEvent, + } = useMonitorSDK() const [logs, setLogs] = useState([]) + const [queueInfo, setQueueInfo] = useState(() => readPersistedReportQueue()) + const [isWhiteMaskVisible, setIsWhiteMaskVisible] = useState(false) + const [whiteScreenMonitoring, setWhiteScreenMonitoring] = useState(false) + const whiteScreenTimerRef = useRef(null) + const whiteScreenStartedAtRef = useRef(null) - const pushLog = (kind: ErrorLog['kind'], title: string, detail: string, payload: unknown) => { + const pushLog = useCallback((kind: ErrorLog['kind'], title: string, detail: string, payload: unknown) => { setLogs(prev => [ { id: Date.now() + Math.floor(Math.random() * 1000), @@ -24,7 +43,11 @@ export default function ErrorPage() { }, ...prev, ]) - } + }, []) + + const refreshQueueInfo = useCallback(() => { + setQueueInfo(readPersistedReportQueue()) + }, [readPersistedReportQueue]) const triggerCaughtSyncError = () => { try { @@ -88,6 +111,119 @@ export default function ErrorPage() { } } + const enqueueOfflineProbe = async () => { + const payload = await trackEvent('reliability_offline_probe', { + page: '/error', + hint: 'disconnect-network-and-click', + online: typeof navigator !== 'undefined' ? navigator.onLine : undefined, + happenedAt: new Date().toISOString(), + }) + refreshQueueInfo() + pushLog('offline', '离线恢复-写入队列', '已发送离线探针事件,断网状态下将进入本地队列', payload) + } + + const flushOfflineQueue = async () => { + await flushReportQueue() + refreshQueueInfo() + pushLog('offline', '离线恢复-手动刷新', '已执行 Reporter.flush(),联网后应看到队列减少', { + online: typeof navigator !== 'undefined' ? navigator.onLine : undefined, + queue: readPersistedReportQueue(), + }) + } + + const runWhiteScreenCheck = async () => { + const snapshot = collectWhiteScreenSnapshot(ROOT_SELECTORS, SKELETON_SELECTORS) + const isPotentialWhiteScreen = snapshot.ratio >= WHITE_SCREEN_THRESHOLD + if (!isPotentialWhiteScreen) { + whiteScreenStartedAtRef.current = null + pushLog('white-screen', '白屏检测采样', '当前页面存在内容,未命中白屏阈值', snapshot) + return + } + + if (whiteScreenStartedAtRef.current === null) { + whiteScreenStartedAtRef.current = Date.now() + pushLog('white-screen', '白屏检测采样', '首次命中潜在白屏阈值,进入持续观察', snapshot) + return + } + + const duration = Date.now() - whiteScreenStartedAtRef.current + if (duration < WHITE_SCREEN_WINDOW_MS) { + pushLog('white-screen', '白屏检测采样', `持续命中 ${duration}ms,尚未达到 ${WHITE_SCREEN_WINDOW_MS}ms 上报阈值`, snapshot) + return + } + + await reportError('white_screen', { + message: 'Potential white screen detected in monitor-test', + detail: { + duration, + thresholdRatio: WHITE_SCREEN_THRESHOLD, + sample: snapshot, + }, + url: typeof window !== 'undefined' ? window.location.href : undefined, + }) + whiteScreenStartedAtRef.current = null + pushLog('white-screen', '白屏检测上报', `已达到 ${WHITE_SCREEN_WINDOW_MS}ms 阈值并完成 error_white_screen 上报`, snapshot) + } + + const startWhiteScreenMonitoring = () => { + if (whiteScreenTimerRef.current !== null) { + return + } + + setWhiteScreenMonitoring(true) + whiteScreenTimerRef.current = window.setInterval(() => { + void runWhiteScreenCheck() + }, WHITE_SCREEN_INTERVAL_MS) + pushLog('white-screen', '白屏检测启动', '每 1s 采样一次,连续 6s 命中才会上报', { + intervalMs: WHITE_SCREEN_INTERVAL_MS, + thresholdMs: WHITE_SCREEN_WINDOW_MS, + thresholdRatio: WHITE_SCREEN_THRESHOLD, + }) + } + + const stopWhiteScreenMonitoring = () => { + if (whiteScreenTimerRef.current !== null) { + window.clearInterval(whiteScreenTimerRef.current) + whiteScreenTimerRef.current = null + } + whiteScreenStartedAtRef.current = null + setWhiteScreenMonitoring(false) + pushLog('white-screen', '白屏检测停止', '已停止周期采样', {}) + } + + const showWhiteMask = async () => { + setIsWhiteMaskVisible(true) + pushLog('white-screen', '白屏模拟开始', '已显示 7 秒白屏遮罩,可用于触发自动检测', { durationMs: 7000 }) + await new Promise(resolve => window.setTimeout(resolve, 7000)) + setIsWhiteMaskVisible(false) + pushLog('white-screen', '白屏模拟结束', '白屏遮罩已移除', {}) + } + + useEffect(() => { + const handleOnline = () => { + void flushReportQueue().then(() => { + refreshQueueInfo() + pushLog('offline', '网络恢复自动刷新', '检测到 online 事件,已自动尝试重发本地队列', { + queue: readPersistedReportQueue(), + }) + }) + } + const handleOffline = () => { + refreshQueueInfo() + pushLog('offline', '网络断开', '检测到 offline 事件,可先触发离线探针再恢复联网验证重传', {}) + } + + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + return () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + if (whiteScreenTimerRef.current !== null) { + window.clearInterval(whiteScreenTimerRef.current) + } + } + }, [flushReportQueue, pushLog, readPersistedReportQueue, refreshQueueInfo]) + return (

错误测试页

@@ -95,7 +231,7 @@ export default function ErrorPage() { SDK 状态: {status}

-

用于触发同步错误、Promise rejection、网络失败、资源错误;错误监听与上报由 SDK ErrorPlugin 自动处理。

+

用于触发同步错误、Promise rejection、网络失败、资源错误;同时补充离线恢复上传与白屏检测验证。

@@ -106,6 +242,59 @@ export default function ErrorPage() {
+

离线恢复上传验证

+

+ 本地队列 Key: + {getReportQueueStorageKey()} +

+

+ 当前网络: + {typeof navigator !== 'undefined' && navigator.onLine ? 'online' : 'offline'} + {' '} + |本地队列条数: + {queueInfo.itemCount} +

+

+ 最近持久化时间: + {queueInfo.savedAt ? new Date(queueInfo.savedAt).toLocaleTimeString() : '无'} + {' '} + |样本类型: + {queueInfo.sampleTypes.length > 0 ? queueInfo.sampleTypes.join(', ') : '无'} +

+
+ + + +
+ +

白屏检测验证

+

+ 规则:每 1 秒采样 18 个点,容器命中率 ≥ + {' '} + {WHITE_SCREEN_THRESHOLD} + {' '} + 且持续 6 秒,上报 error_white_screen。 +

+
+ + + + +
+ + {isWhiteMaskVisible + ? ( +
+ ) + : null} +
{logs.length === 0 ?

还没有触发错误

: null} {logs.map(item => ( diff --git a/app/monitor-test/src/pages/HomePage.tsx b/app/monitor-test/src/pages/HomePage.tsx index 2d4d21f..b5ec2a9 100644 --- a/app/monitor-test/src/pages/HomePage.tsx +++ b/app/monitor-test/src/pages/HomePage.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom' -import { getReportUrl } from '../services/sdkRuntime' import { useMonitorSDK } from '../hooks/useMonitorSDK' +import { getReportUrl } from '../services/sdkRuntime' export default function HomePage() { const { status } = useMonitorSDK() @@ -16,7 +16,10 @@ export default function HomePage() { {status}

-

上报地址:{getReportUrl()}

+

+ 上报地址: + {getReportUrl()} +

Tracking

@@ -30,7 +33,7 @@ export default function HomePage() {

错误监控

-

验证同步错误、Promise rejection、资源加载错误的捕获与回显。

+

验证同步错误、Promise rejection、资源加载错误,并覆盖离线恢复上传与白屏检测。

进入测试页
diff --git a/app/monitor-test/src/pages/PerformancePage.tsx b/app/monitor-test/src/pages/PerformancePage.tsx index fc80147..9bc2443 100644 --- a/app/monitor-test/src/pages/PerformancePage.tsx +++ b/app/monitor-test/src/pages/PerformancePage.tsx @@ -3,7 +3,7 @@ import { useMonitorSDK } from '../hooks/useMonitorSDK' interface PerfLog { id: number - kind: 'long-task' | 'metrics' | 'observer' | 'cls' + kind: 'long-task' | 'metrics' | 'observer' | 'cls' | 'interaction' title: string detail: string payload: unknown @@ -226,11 +226,31 @@ export default function PerformancePage() { } } + const collectInteractionLatency = async () => { + const startedAt = performance.now() + await new Promise((resolve) => { + window.requestAnimationFrame(() => { + resolve() + }) + }) + const afterRaf = performance.now() + const delay = Number((afterRaf - startedAt).toFixed(2)) + const metrics = { + page: '/performance', + interactionDelay: delay, + hint: '对标 Sentry/NewRelic 的交互延迟观测,补充 INP 场景验证', + timestamp: Date.now(), + } + const payload = await trackEvent('performance_interaction_delay', metrics) + pushLog('interaction', '交互延迟采样', '已采样一次 requestAnimationFrame 延迟,可辅助验证 INP 场景', payload ?? metrics) + } + const metricCases = [ { title: '轻量 Long Task (80ms)', run: () => emitLongTask('轻量 Long Task', 80, 'light-blocking-work') }, { title: '标准 Long Task (180ms)', run: () => emitLongTask('标准 Long Task', 180, 'standard-blocking-work') }, { title: '观测 Long Task', run: observeLongTask }, { title: '触发 CLS 变化', run: triggerClsShift }, + { title: '交互延迟采样', run: collectInteractionLatency }, { title: '导航指标采集', run: collectNavigationMetrics }, { title: '资源概览采集', run: collectResourceSummary }, { title: '用户计时采集', run: collectUserTiming }, diff --git a/app/monitor-test/src/services/reliability.spec.ts b/app/monitor-test/src/services/reliability.spec.ts new file mode 100644 index 0000000..c502159 --- /dev/null +++ b/app/monitor-test/src/services/reliability.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + createWhiteScreenSamplePoints, + isContainerElement, +} from './reliability' + +function createElementStub( + input: { + tagName: string + id?: string + className?: string + matchResult?: boolean + }, +) { + return { + tagName: input.tagName, + id: input.id ?? '', + className: input.className ?? '', + matches: () => input.matchResult ?? false, + } as unknown as Element +} + +describe('reliability helpers', () => { + it('creates 18 sample points for white-screen detection', () => { + const points = createWhiteScreenSamplePoints(1200, 900) + expect(points).toHaveLength(18) + expect(points[0]).toEqual({ x: 120, y: 90 }) + expect(points.at(-1)).toEqual({ x: 1080, y: 810 }) + }) + + it('treats html/body/root as container elements', () => { + const roots = ['#root', '.portal-shell'] + const skeletons = ['.skeleton'] + + expect(isContainerElement(createElementStub({ tagName: 'HTML' }), roots, skeletons)).toBe(true) + expect(isContainerElement(createElementStub({ tagName: 'BODY' }), roots, skeletons)).toBe(true) + expect(isContainerElement(createElementStub({ tagName: 'DIV', id: 'root' }), roots, skeletons)).toBe(true) + expect(isContainerElement(createElementStub({ tagName: 'DIV', className: 'portal-shell' }), roots, skeletons)).toBe(true) + }) + + it('treats non-container element as content', () => { + const roots = ['#root', '.portal-shell'] + const skeletons = ['.skeleton'] + const content = createElementStub({ tagName: 'SECTION', className: 'content-area' }) + expect(isContainerElement(content, roots, skeletons)).toBe(false) + }) +}) diff --git a/app/monitor-test/src/services/reliability.ts b/app/monitor-test/src/services/reliability.ts new file mode 100644 index 0000000..0da5373 --- /dev/null +++ b/app/monitor-test/src/services/reliability.ts @@ -0,0 +1,92 @@ +export interface Point { + x: number + y: number +} + +function normalizeClassName(value: string): string[] { + return value + .trim() + .split(/\s+/) + .filter(Boolean) +} + +export function createWhiteScreenSamplePoints(width: number, height: number): Point[] { + const xRatios = [0.1, 0.5, 0.9] + const yRatios = [0.1, 0.2, 0.4, 0.6, 0.8, 0.9] + const points: Point[] = [] + + for (const yRatio of yRatios) { + for (const xRatio of xRatios) { + points.push({ + x: Math.max(0, Math.floor(width * xRatio)), + y: Math.max(0, Math.floor(height * yRatio)), + }) + } + } + + return points +} + +export function isContainerElement( + element: Element, + rootSelectors: string[], + skeletonSelectors: string[], +): boolean { + const tagName = element.tagName.toLowerCase() + if (tagName === 'html' || tagName === 'body') { + return true + } + + const id = element.id ? `#${element.id}` : '' + if (id && rootSelectors.includes(id)) { + return true + } + + const className = typeof element.className === 'string' ? normalizeClassName(element.className) : [] + for (const classToken of className) { + if (rootSelectors.includes(`.${classToken}`)) { + return true + } + } + + return skeletonSelectors.some((selector) => { + try { + return typeof element.matches === 'function' && element.matches(selector) + } + catch { + return false + } + }) +} + +export interface WhiteScreenSnapshot { + total: number + containerHits: number + ratio: number + points: Point[] +} + +export function collectWhiteScreenSnapshot( + rootSelectors: string[], + skeletonSelectors: string[], +): WhiteScreenSnapshot { + const width = window.innerWidth + const height = window.innerHeight + const points = createWhiteScreenSamplePoints(width, height) + let containerHits = 0 + + for (const point of points) { + const element = document.elementFromPoint(point.x, point.y) + if (element && isContainerElement(element, rootSelectors, skeletonSelectors)) { + containerHits++ + } + } + + const total = points.length + return { + total, + containerHits, + ratio: total === 0 ? 0 : Number((containerHits / total).toFixed(3)), + points, + } +} diff --git a/app/monitor-test/src/services/sdkRuntime.ts b/app/monitor-test/src/services/sdkRuntime.ts index d6bfaf4..1d0991e 100644 --- a/app/monitor-test/src/services/sdkRuntime.ts +++ b/app/monitor-test/src/services/sdkRuntime.ts @@ -60,6 +60,11 @@ type TrackPageResult = Awaited> type TrackUserResult = Awaited> type TrackUvResult = Awaited> +interface PersistedReportQueue { + savedAt?: number + items?: Array<{ type?: string, timestamp?: number }> +} + let startPromise: Promise | null = null export async function ensureSDKStarted() { @@ -175,3 +180,60 @@ export async function flushReplay(reason = 'manual_debug') { replayPlugin.flushForError(reason) await sdk.getReporter().flush() } + +export function getReportQueueStorageKey() { + return String(sdk.getConfig().localStorageKey ?? 'ez_monitor_report_queue') +} + +export function readPersistedReportQueue() { + const key = getReportQueueStorageKey() + if (typeof localStorage === 'undefined') { + return { + key, + supported: false, + itemCount: 0, + savedAt: undefined, + sampleTypes: [] as string[], + } + } + + const raw = localStorage.getItem(key) + if (!raw) { + return { + key, + supported: true, + itemCount: 0, + savedAt: undefined, + sampleTypes: [] as string[], + } + } + + try { + const parsed = JSON.parse(raw) as PersistedReportQueue + const items = Array.isArray(parsed.items) ? parsed.items : [] + return { + key, + supported: true, + itemCount: items.length, + savedAt: parsed.savedAt, + sampleTypes: items + .slice(0, 8) + .map(item => item.type) + .filter((item): item is string => typeof item === 'string' && item.length > 0), + } + } + catch { + return { + key, + supported: true, + itemCount: 0, + savedAt: undefined, + sampleTypes: [''], + } + } +} + +export async function flushReportQueue() { + await ensureSDKStarted() + await sdk.getReporter().flush() +} From d1e71e6fb91e46dff6bae65369a62be8599f8573 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:44:04 +0000 Subject: [PATCH 4/6] =?UTF-8?q?fix(monitor-test):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=99=BD=E5=B1=8F=E6=A3=80=E6=B5=8B=E5=B9=B6=E5=8F=91=E9=87=87?= =?UTF-8?q?=E6=A0=B7=E4=B8=8E=E9=87=87=E6=A0=B7=E8=AE=A1=E7=AE=97=E7=BB=86?= =?UTF-8?q?=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/EzStars/EzMonitor/sessions/34f3cb4c-9d92-472f-b3c6-4f5e9d11522f --- app/monitor-test/src/pages/ErrorPage.tsx | 63 ++++++++++++-------- app/monitor-test/src/services/reliability.ts | 2 +- 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/app/monitor-test/src/pages/ErrorPage.tsx b/app/monitor-test/src/pages/ErrorPage.tsx index eb07966..692ef4d 100644 --- a/app/monitor-test/src/pages/ErrorPage.tsx +++ b/app/monitor-test/src/pages/ErrorPage.tsx @@ -31,6 +31,7 @@ export default function ErrorPage() { const [whiteScreenMonitoring, setWhiteScreenMonitoring] = useState(false) const whiteScreenTimerRef = useRef(null) const whiteScreenStartedAtRef = useRef(null) + const whiteScreenCheckingRef = useRef(false) const pushLog = useCallback((kind: ErrorLog['kind'], title: string, detail: string, payload: unknown) => { setLogs(prev => [ @@ -132,37 +133,47 @@ export default function ErrorPage() { } const runWhiteScreenCheck = async () => { - const snapshot = collectWhiteScreenSnapshot(ROOT_SELECTORS, SKELETON_SELECTORS) - const isPotentialWhiteScreen = snapshot.ratio >= WHITE_SCREEN_THRESHOLD - if (!isPotentialWhiteScreen) { - whiteScreenStartedAtRef.current = null - pushLog('white-screen', '白屏检测采样', '当前页面存在内容,未命中白屏阈值', snapshot) + if (whiteScreenCheckingRef.current) { return } + whiteScreenCheckingRef.current = true - if (whiteScreenStartedAtRef.current === null) { - whiteScreenStartedAtRef.current = Date.now() - pushLog('white-screen', '白屏检测采样', '首次命中潜在白屏阈值,进入持续观察', snapshot) - return - } + try { + const snapshot = collectWhiteScreenSnapshot(ROOT_SELECTORS, SKELETON_SELECTORS) + const isPotentialWhiteScreen = snapshot.ratio >= WHITE_SCREEN_THRESHOLD + if (!isPotentialWhiteScreen) { + whiteScreenStartedAtRef.current = null + pushLog('white-screen', '白屏检测采样', '当前页面存在内容,未命中白屏阈值', snapshot) + return + } - const duration = Date.now() - whiteScreenStartedAtRef.current - if (duration < WHITE_SCREEN_WINDOW_MS) { - pushLog('white-screen', '白屏检测采样', `持续命中 ${duration}ms,尚未达到 ${WHITE_SCREEN_WINDOW_MS}ms 上报阈值`, snapshot) - return - } + if (whiteScreenStartedAtRef.current === null) { + whiteScreenStartedAtRef.current = Date.now() + pushLog('white-screen', '白屏检测采样', '首次命中潜在白屏阈值,进入持续观察', snapshot) + return + } - await reportError('white_screen', { - message: 'Potential white screen detected in monitor-test', - detail: { - duration, - thresholdRatio: WHITE_SCREEN_THRESHOLD, - sample: snapshot, - }, - url: typeof window !== 'undefined' ? window.location.href : undefined, - }) - whiteScreenStartedAtRef.current = null - pushLog('white-screen', '白屏检测上报', `已达到 ${WHITE_SCREEN_WINDOW_MS}ms 阈值并完成 error_white_screen 上报`, snapshot) + const duration = Date.now() - whiteScreenStartedAtRef.current + if (duration < WHITE_SCREEN_WINDOW_MS) { + pushLog('white-screen', '白屏检测采样', `持续命中 ${duration}ms,尚未达到 ${WHITE_SCREEN_WINDOW_MS}ms 上报阈值`, snapshot) + return + } + + await reportError('white_screen', { + message: 'Potential white screen detected in monitor-test', + detail: { + duration, + thresholdRatio: WHITE_SCREEN_THRESHOLD, + sample: snapshot, + }, + url: typeof window !== 'undefined' ? window.location.href : undefined, + }) + whiteScreenStartedAtRef.current = null + pushLog('white-screen', '白屏检测上报', `已达到 ${WHITE_SCREEN_WINDOW_MS}ms 阈值并完成 error_white_screen 上报`, snapshot) + } + finally { + whiteScreenCheckingRef.current = false + } } const startWhiteScreenMonitoring = () => { diff --git a/app/monitor-test/src/services/reliability.ts b/app/monitor-test/src/services/reliability.ts index 0da5373..37babd0 100644 --- a/app/monitor-test/src/services/reliability.ts +++ b/app/monitor-test/src/services/reliability.ts @@ -86,7 +86,7 @@ export function collectWhiteScreenSnapshot( return { total, containerHits, - ratio: total === 0 ? 0 : Number((containerHits / total).toFixed(3)), + ratio: total === 0 ? 0 : Math.round((containerHits * 1000) / total) / 1000, points, } } From 2dc25255ae1ee29a830f5ccfed96d90ad29b1709 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:45:38 +0000 Subject: [PATCH 5/6] =?UTF-8?q?refactor(monitor-test):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=99=BD=E5=B1=8F=E9=87=87=E6=A0=B7=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/EzStars/EzMonitor/sessions/34f3cb4c-9d92-472f-b3c6-4f5e9d11522f --- app/monitor-test/src/pages/ErrorPage.tsx | 6 ++++-- app/monitor-test/src/services/reliability.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/monitor-test/src/pages/ErrorPage.tsx b/app/monitor-test/src/pages/ErrorPage.tsx index 692ef4d..9412f6d 100644 --- a/app/monitor-test/src/pages/ErrorPage.tsx +++ b/app/monitor-test/src/pages/ErrorPage.tsx @@ -212,12 +212,14 @@ export default function ErrorPage() { useEffect(() => { const handleOnline = () => { - void flushReportQueue().then(() => { + const flushWhenOnline = async () => { + await flushReportQueue() refreshQueueInfo() pushLog('offline', '网络恢复自动刷新', '检测到 online 事件,已自动尝试重发本地队列', { queue: readPersistedReportQueue(), }) - }) + } + void flushWhenOnline() } const handleOffline = () => { refreshQueueInfo() diff --git a/app/monitor-test/src/services/reliability.ts b/app/monitor-test/src/services/reliability.ts index 37babd0..e5cc777 100644 --- a/app/monitor-test/src/services/reliability.ts +++ b/app/monitor-test/src/services/reliability.ts @@ -3,6 +3,8 @@ export interface Point { y: number } +const RATIO_PRECISION_MULTIPLIER = 1000 + function normalizeClassName(value: string): string[] { return value .trim() @@ -86,7 +88,7 @@ export function collectWhiteScreenSnapshot( return { total, containerHits, - ratio: total === 0 ? 0 : Math.round((containerHits * 1000) / total) / 1000, + ratio: total === 0 ? 0 : Math.round((containerHits * RATIO_PRECISION_MULTIPLIER) / total) / RATIO_PRECISION_MULTIPLIER, points, } } From 44721b09d9f85a72ca26f257b6ff3d58cb15e902 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:46:56 +0000 Subject: [PATCH 6/6] =?UTF-8?q?chore(monitor-test):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=99=BD=E5=B1=8F=E6=A8=A1=E6=8B=9F=E6=97=B6=E9=95=BF=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E6=97=A5=E5=BF=97=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/EzStars/EzMonitor/sessions/34f3cb4c-9d92-472f-b3c6-4f5e9d11522f --- app/monitor-test/src/pages/ErrorPage.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/monitor-test/src/pages/ErrorPage.tsx b/app/monitor-test/src/pages/ErrorPage.tsx index 9412f6d..1708241 100644 --- a/app/monitor-test/src/pages/ErrorPage.tsx +++ b/app/monitor-test/src/pages/ErrorPage.tsx @@ -13,6 +13,7 @@ interface ErrorLog { const WHITE_SCREEN_WINDOW_MS = 6000 const WHITE_SCREEN_INTERVAL_MS = 1000 const WHITE_SCREEN_THRESHOLD = 0.95 +const WHITE_SCREEN_MASK_DURATION_MS = 7000 const ROOT_SELECTORS = ['#root', '.portal-shell'] const SKELETON_SELECTORS = ['.skeleton', '.loading', '[data-skeleton]'] @@ -169,7 +170,7 @@ export default function ErrorPage() { url: typeof window !== 'undefined' ? window.location.href : undefined, }) whiteScreenStartedAtRef.current = null - pushLog('white-screen', '白屏检测上报', `已达到 ${WHITE_SCREEN_WINDOW_MS}ms 阈值并完成 error_white_screen 上报`, snapshot) + pushLog('white-screen', '白屏检测上报', `已达到 ${WHITE_SCREEN_WINDOW_MS}ms 阈值并完成 white_screen(error_white_screen)上报`, snapshot) } finally { whiteScreenCheckingRef.current = false @@ -204,8 +205,8 @@ export default function ErrorPage() { const showWhiteMask = async () => { setIsWhiteMaskVisible(true) - pushLog('white-screen', '白屏模拟开始', '已显示 7 秒白屏遮罩,可用于触发自动检测', { durationMs: 7000 }) - await new Promise(resolve => window.setTimeout(resolve, 7000)) + pushLog('white-screen', '白屏模拟开始', `已显示 ${WHITE_SCREEN_MASK_DURATION_MS / 1000} 秒白屏遮罩,可用于触发自动检测`, { durationMs: WHITE_SCREEN_MASK_DURATION_MS }) + await new Promise(resolve => window.setTimeout(resolve, WHITE_SCREEN_MASK_DURATION_MS)) setIsWhiteMaskVisible(false) pushLog('white-screen', '白屏模拟结束', '白屏遮罩已移除', {}) }