Web 应用崩溃异常上报方案

You,12 min read

Web 应用崩溃异常上报方案

核心思路

当触发 Tab 关闭或者刷新时 如果队列中还存在此 Tab 则表明其未触发 beforeunload 事件可以认定为是应用崩溃导致的一种情况 然后将崩溃数据上报到自己的监控服务器上即可

核心代码实现

// 监听标签页关闭事件  
chrome.tabs.onRemoved.addListener(async (tabId: number) => {  
 const closeTab = await  chrome.storage.local.get(String(tabId));  
 if (!isEmpty(closeTab)) {  
 crashReport(tabId, closeTab[tabId]);  
 }  
});  

时序图

9e029be5ba14c3507ab51276055f11ab

技术方案

事件监听注入方案

只会向满足特定域的Tab注入 使用 content.js 有如下优点

数据存储方案

最后选择 chrome.storage作为存储方案 因为有如下优势

chrome.storage 和 localStorage 都是用于 web 存储的方式,但是在 Chrome 扩展程序中,他们有一些重要的区别

项目说明
数据同步chrome.storage.sync 允许通过 Chrome 同步存储的数据在用户所有登录的 Chrome 浏览器中共享。这对于希望保持跨设备设置一致性的扩展非常有用。localStorage 中的数据仅存在于本地,不会跨设备同步。
存储容量localStorage 的存储容量大约为5MB。chrome.storage.sync 允许每个项最大8KB,总计最大100KB。但是,如果不需要同步数据,可以使用 chrome.storage.local,其存储容量可达 5MB。
异步 vs 同步chrome.storage 的 API 是异步的,这意味着当请求数据时,不会立即返回数据,而是通过回调函数在数据可用时提供。这可以防止磁盘读写阻塞浏览器主线程。而 localStorage 的 API 是同步的,会在数据返回前阻塞其他操作。
数据类型localStorage 只能存储字符串,需先转换其他数据类型为字符串。chrome.storage 可直接存储对象。
持久性localStoragechrome.storage 的数据都是持久的,即使浏览器关闭或计算机重启,数据仍存在,除非用户清除或插件删除。
总结chrome.storage 在 Chrome 扩展开发中提供了更多灵活性,尤其是需要数据同步和异步操作时。但如果只需在单设备存储少量数据且不需要异步操作,localStorage 是可行选择。
触发上报时机关闭标签或刷新
beforeunload 事件和 chrome.tabs.onRemoved 事件都可以在某种程度上监听到标签页的关闭,但它们的触发时序和适用场景有所不同

beforeunload 事件是在标签页的内容即将卸载时触发的。它是在网页级别进行的,并且只能在网页内部监听到。它通常用于在用户尝试关闭标签页或者离开当前页面时,提示用户是否确定离开(例如,如果用户正在填写一个表单但还没有提交,可以用 beforeunload 事件提示用户数据可能会丢失)。

chrome.tabs.onRemoved 事件是在标签页从浏览器中移除后触发的。它是在浏览器扩展级别进行的,只有在扩展的背景脚本中才能监听到。它提供了一个更全局的视角,可以监听到所有标签页的关闭事件,包括用户主动关闭标签页、关闭窗口、或者其他扩展关闭标签页等情况。

beforeunload 事件会先于 chrome.tabs.onRemoved 事件触发。当用户点击关闭标签页时,首先会触发当前页面的 beforeunload 事件。如果此事件的处理函数没有取消关闭操作(例如,通过 event.preventDefault()),那么标签页会被关闭,然后触发扩展的 chrome.tabs.onRemoved 事件。

Panel 与 Content 的通讯实现

因为 Panel 无法直接与 Content 通讯需要借由 Background 做中转

src/pages/Background/index.ts

// 监听插件与 panel 的连接  
chrome.runtime.onConnect.addListener((port) => {  
console.assert(port.name === "panel");  
port.onMessage.addListener((msg: IMessage) => {  
// 将消息转发给内容脚本  
chrome.tabs.query({active: true, currentWindow: true}, (tabs) => {  
chrome.tabs.sendMessage(tabs[0].id as number, msg);  
});  
});  
});  
src/utils/panelConnect.ts 建连 port 单实例  
import { IMessage } from '@/types';
 
let port: chrome.runtime.Port | null = null;
 
export const connectPanel = () => {  
if (!port) {  
port = chrome.runtime.connect({ name: "panel" });  
port.onDisconnect.addListener(() => {  
port = null;  
});  
}  
};
 
export const sendMessage = (message: IMessage) => {  
if (port) {  
port.postMessage(message);  
} else {  
console.warn('Port is not connected.');  
}  
};
 
export const disconnectPanel = () => {  
if (port) {  
port.disconnect();  
port = null;  
}  
};

src/pages/Content/index.ts content 拥有当前 window 的具有读写能力
承载来自 Panel 的消息触发具体功能实现

import { PERFORMANCE_MODE_KEY, SENTRY_CONFIG_KEY } from "@/constants";  
import { IMessage, IPayload, MessageTypeEnum } from "@/types";
 
// 记录页面加载的函数  
function recordPage() {  
console.log('🚀 LCAP Content.js: recordPage');  
chrome.runtime.sendMessage({ type: MessageTypeEnum.ADD });  
}
 
function updatePage(payload: IPayload) {  
console.log('🚀 LCAP Content.js: updatePage', payload);  
chrome.runtime.sendMessage({  
type: MessageTypeEnum.UPDATE,  
payload  
});  
}
 
// 移除页面的函数  
function removePage() {  
console.log('🚀 LCAP Content.js: removePage');  
chrome.runtime.sendMessage({ type: MessageTypeEnum.REMOVE });  
}
 
// 注销消息监听器的函数  
function unregisterMessageListeners() {  
chrome.runtime.onMessage.removeListener(recordPage);  
chrome.runtime.onMessage.removeListener(removePage);  
}
 
window.addEventListener('load', () => {  
recordPage();  
injectScript('injectScript.bundle.js');  
});
 
window.addEventListener('beforeunload', () => {  
removePage();  
unregisterMessageListeners();  
});
 
function injectScript(file: string): void {  
// 获取页面的 body 元素  
const body = document.getElementsByTagName('body')[0];
 
// 创建一个新的 script 元素  
const script = document.createElement('script');
 
// 从插件资源中获取完整的 URL  
const src = chrome.runtime.getURL(file);
 
// 设置 script 元素的属性  
script.setAttribute('type', 'text/javascript');  
script.setAttribute('src', src);
 
// 将 script 元素附加到 body 中  
body.appendChild(script);  
}
 
chrome.runtime.onMessage.addListener((msg: IMessage) => {  
const { type, payload = {} } = msg;  
console.log('🔥 LCAP Content.js: chrome.runtime.onMessage.addListener', msg);
 
switch (type) {  
case MessageTypeEnum.ENABLE_PERFORMANCE_MODE:  
window.localStorage.setItem(PERFORMANCE_MODE_KEY, 'beta');  
window.location.reload();  
break;  
case MessageTypeEnum.DISABLE_PERFORMANCE_MODE:  
window.localStorage.removeItem(PERFORMANCE_MODE_KEY);  
window.location.reload();  
break;  
case MessageTypeEnum.SET_SENTRY_CONFIG:  
window.sessionStorage.setItem(SENTRY_CONFIG_KEY, JSON.stringify(payload));  
break;  
case MessageTypeEnum.REMOVE_SENTRY_CONFIG:  
window.sessionStorage.removeItem(SENTRY_CONFIG_KEY);  
break;  
default:  
console.warn('Unknown message type:', type);  
break;  
}  
});

应用崩溃数据上报

Sentry 上报数据(在 Background 中)

src/pages/Background/index.ts

import { SELF_SENTRY_DSN } from '@/constants';  
import { IExtendedMessageSender, IMessage, MessageTypeEnum } from '@/types';  
import { isEmpty } from '@/utils';  
import * as Sentry from "@sentry/browser";
 
Sentry.init({  
dsn: SELF_SENTRY_DSN,  
tracesSampleRate: 1.0,  
});
 
/**
 
- @description: 崩溃上报
- [@param](/param) {number} tabId
- [@param](/param) {IExtendedMessageSender} data
- [@return](/return) {*}  
    */  
    const reportCrash = (tabId: number, data: IExtendedMessageSender) => {  
    const {  
    url = '',  
    payload: {  
    ideVersion = '',  
    jflowVersion = ''  
    }  
    } = data || {};
 
Sentry.captureMessage(`IDE Crash: ${url} ${Date.now()}`, (scope) => {  
scope.setLevel('error');  
scope.setTag('data.type', 'IDE Crash');  
scope.setTag('ide.version', ideVersion);  
scope.setTag('jflow.version', jflowVersion);  
// 使用 fingerprint 控制分组逻辑  
scope.setFingerprint(['{{ default }}', url]);  
return scope;  
});
 
// 异常上报后清除  
chrome.storage.local.remove(String(tabId));  
};
 
// 监听 content.js 发来的事件  
chrome.runtime.onMessage.addListener((message: IMessage, sender) => {  
const { type, payload } = message;  
const { tab: { id: tabId = 0 } = {} } = sender || {};
 
if (!tabId) {  
return;  
}
 
const value = Object.assign(sender, { payload });  
console.log('🚀 LCAP Background.js: chrome.runtime.onMessage.addListener', message, value);
 
switch (type) {  
case MessageTypeEnum.ADD:  
chrome.storage.local.set({ [`${tabId}`]: value });  
break;  
case MessageTypeEnum.UPDATE:  
chrome.storage.local.set({ [`${tabId}`]: value });  
break;  
case MessageTypeEnum.REMOVE:  
chrome.storage.local.remove(String(tabId));  
break;  
default:  
break;  
}
 
});
 
// 监听标签页关闭事件  
chrome.tabs.onRemoved.addListener(async (tabId: number) => {  
const closeTab = await chrome.storage.local.get(String(tabId));  
if (!isEmpty(closeTab)) reportCrash(tabId, closeTab[tabId]);  
});  

上报哪些数据

function crashReport(tabId: number, data: chrome.runtime.MessageSender) {  
const { url = ' ' } = data || {};
 
// 上报 IDE Crash  
Sentry.captureException(  
new Error(`Crash: ${url}`),  
(scope) => {  
// 方便后续定位问题的错误数据  
return scope;  
}  
);
 
// 异常上报后清除  
chrome.storage.local.remove(String(tabId));  
};

插件安装数据上报

/**
 
- @description: 上报插件安装数据
- Chrome插件的卸载事件不会直接触发在插件内部的任何代码,因此,与其他浏览器事件不同,监听卸载事件并直接报告给Sentry会比较复杂。
- chrome.runtime.setUninstallURL('[https://yourserver.com/uninstall](https://yourserver.com/uninstall)'); 可以在卸载页面上报 不过暂时没数据 可临时通过Extension Version 来大概看一下分布
- [@return](/return) {*}  
    */  
    const reportInstallation = () => {  
    const manifest = chrome.runtime.getManifest();  
    const extensionVersion = manifest.version;
 
Sentry.captureMessage('Extension Installed', (scope) => {  
scope.setLevel('info');  
scope.setTag('data.type', 'Extension Installed');  
scope.setTag('extension.version', extensionVersion); // 设置版本作为tag  
// 或者使用 extra 来存储版本信息  
// scope.setExtra('extension.version', extensionVersion);  
return scope;  
});  
}
 
// 当插件安装或更新时触发  
chrome.runtime.onInstalled.addListener((details) => {  
if (details.reason === 'install') {  
reportInstallation();  
}  
});  

监听应用的运行时数据方案

content 监听 inject 的发来的消息 因为需要 window 通讯
content 中无法直接读写 window.appInfo 、$data 等业务注入的全局变量因为沙箱安全问题

// 监听来自 inject.ts 的自定义事件  
window.addEventListener('globalDataFirstSet', function (e: Event) {  
const customEvent = e as CustomEvent;  
const { ideVersion = '', jflowVersion = '' } = customEvent.detail || {};
 
const payload = {  
ideVersion,  
jflowVersion,  
};
 
updatePage(payload);  
});

inject.ts 作为插件网络资源可访问当前 window 的完全访问权限
src/pages/Content/inject.ts

type PollingOptions = {  
maxAttempts?: number;  
interval?: number;  
};
 
/**
 
- 触发自定义的全局数据设置事件
- [@param](/param) globalData 全局数据对象  
    */  
    function triggerGlobalDataSetEvent(detail: any): void {  
    const event = new CustomEvent('globalDataFirstSet', {  
    detail  
    });
 
window.dispatchEvent(event);  
}
 
/**
 
- 轮询 window.globalData
- [@param](/param) onFound 当全局数据被发现时的回调函数
- [@param](/param) options 配置对象,可以设置最大尝试次数和轮询间隔  
    */  
    function pollForGlobalData(onFound: (data: any) => void, options?: PollingOptions): void {  
    const maxAttempts = options?.maxAttempts || 10;  
    const interval = options?.interval || 5000; // 3 seconds
 
let attempts = 0;
 
const polling = setInterval(() => {  
attempts++;
 
const {
  globalData: {
    ideVersionDetail: {
      version: ideVersion = ''
    } = {}
  } = {},
  $jflow_version: jflowVersion = ''
} = window || {};
 
if (ideVersion && jflowVersion) {
  clearInterval(polling);
  onFound({
    ideVersion,
    jflowVersion
  });
}
 
if (attempts >= maxAttempts) {
  clearInterval(polling);
  console.warn('Max polling attempts reached. Stopping.');
}
 
}, interval);  
}
 
// 使用  
pollForGlobalData(triggerGlobalDataSetEvent);

数据可视化方案

因为 Sentry 是可以集成 Grafana 可以借助  Grafana 将 Sentry 源数据进行可视化图像处理 Sentry 还是用来处理错误与异常 各司其职
目前已经打通了 两个平台的数据流向 后续等 线上 Sentry 可用的时候再搞

t1vijF

方案优劣局限

参考文献

参考仓库地址

仓库传送门 (opens in a new tab)

2026 © Lizhenyui.