Web 应用崩溃异常上报方案
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]);
}
}); 时序图
技术方案
事件监听注入方案
只会向满足特定域的Tab注入 使用 content.js 有如下优点
- 业务侵入性为零 不需要在项目代码中写入
- 运行时按需注入 非目标页面不会注入
- 逻辑独立全部内敛在插件内
数据存储方案
最后选择 chrome.storage作为存储方案 因为有如下优势
- 天然隔离 localStorage 会有其他可能侵入 如:不会被误删除
- 丰富的 API 如:get、set、clear、onChange 等
- 丰富的存储类型 如:可以存储 Array 对象
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 可直接存储对象。 |
| 持久性 | localStorage 和 chrome.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 可用的时候再搞

方案优劣局限
- 优点
- 业务解耦可独立更新
- 应用全版本覆盖支持
- 插件可扩展集成更多研发效能工具
- 缺点
- 需要手动下载/更新 插件 需要人为介入
- 目前只提供了 Chrome 插件其他浏览器 暂不支持 Edg 其他浏览器
参考文献
- Chrome Extension 插件开发文档
- Sentry 技术文档
- Grafana 接入文档