前端应用监控系统包含四部分
我们通过浏览器的 performance API 获取性能数据, 其中要用到以下数据
/** 性能数据字段 */
const fields = [
'navigationStart', // 0
'unloadEventStart',
'unloadEventEnd',
'redirectStart',
'redirectEnd',
'fetchStart', // 5
'domainLookupStart',
'domainLookupEnd',
'connectStart',
'secureConnectionStart',
'connectEnd', // 10
'requestStart',
'responseStart',
'responseEnd',
'domLoading',
'domInteractive', // 15
'domContentLoadedEventStart',
'domContentLoadedEventEnd',
'domComplete',
'loadEventStart',
'loadEventEnd', // 20
];
上面的数据是某事件触发的时刻, 下面我们定义要数据的性能指标, 这些指标都是由上述数据计算得出
/**
* 性能数据指标
* key: 指标
* value: [字段1, 字段2]
* result: key = fields[字段1] - fields[字段2]
*/
const targets: { [key: string]: [number, number] } = {
firstbyte: [5, 12], // 首字节
domready: [5, 17], // DOM Ready
load: [5, 19], // load 触发
dns: [6, 7], // DNS 查询
tcp: [8, 10], // TCP 连接
ssl: [9, 10], // HTTPS 连接
ttfb: [11, 12], // TTFB
contentdownload: [12, 13], // HTML 下载
domparsing: [13, 15], // DOM 解析
total: [5, 20], // 总耗时
unload: [1, 2], // 上个页面 unload
redirect: [3, 4], // 重定向时间
appcache: [5, 6], // 缓存查询
};
接下来开始计算每个指标的值, 其中白屏时间是单独写的计算方法(实际运行中发现很多时候收集到的数据是0), 还做了一些数据修正工作(抛弃负数、小数点保留三位)
/**
* 获取性能信息
*/
getPerformance() {
/** timing api v1 */
const timing1 = window.performance.timing;
/** timing api v2 */
let timing2: PerformanceEntry = {} as PerformanceEntry;
// 优先使用 navigation v2 https://www.w3.org/TR/navigation-timing-2/
if (SupportNavigationV2) {
try {
var nt2Timing = performance.getEntriesByType('navigation')[0];
if (nt2Timing) {
timing2 = nt2Timing;
}
} catch (err) {}
}
/** 合并 v1, v2 的数据 */
const timing: { [key: string]: number | string; } = {};
fields.forEach((field: string) => {
// @ts-ignore
timing[field] = timing2[field] || timing1[field];
});
/** 计算每个性能指标 */
const times: { [key: string]: number | string; } = {};
Object.keys(targets).forEach((key) => {
const [fieldIndex1, fieldIndex2] = targets[key];
times[key] = <number>timing[fields[fieldIndex2]] - <number>timing[fields[fieldIndex1]];
// 修正负数指标
if (times[key] < 0) {
times[key] = 0;
}
});
// 计算白屏时间
if (SupportNavigationV2) {
const paintTimimg = performance.getEntriesByType('paint');
if (paintTimimg && paintTimimg.length > 0) {
times.blank = paintTimimg[1] ? paintTimimg[1].startTime : paintTimimg[0].startTime;
}
} else if (window.chrome && window.chrome.loadTimes) {
times.blank =
window.chrome.loadTimes().firstPaintTime * 1000 -
window.performance.timing.fetchStart;
}
// 修正负数时间
if (!times.blank || times.blank < 0) {
times.blank = 0;
}
// 保留三位小数
Object.keys(times).forEach((key) => {
if (typeof times[key] === 'number') {
times[key] = ((times[key] as number).toFixed(3) as unknown) as number;
}
});
return times;
}
网络类型经常会收集不到. id / userId / assertVer 由使用 SDK 的人在初始化 SDK 时传入, 还有用户可能刚进页面时没有登录态, 后面才登录的, 所以 userId 需要支持传入 function 来在上报信息时获取 userId (代码中未体现)
/**
* 获取公共信息
*/
getCommon() {
const { innerWidth: width, innerHeight: height, location, document, navigator } = window;
const { href: url } = location;
const { title, referrer } = document;
let net = '';
// @ts-ignore
if (navigator.connection && navigator.connection.effectiveType) {
// @ts-ignore
net = navigator.connection.effectiveType;
}
// url, ua, ip 解析在服务端做
return {
id: this.options.id, // 应用 id
timestamp: Date.now(), // 上报时间
url, // 页面 url
title, // 页面标题
referrer, // 页面 referrer
ua: navigator.userAgent, // 用户 UA
net, // 网络类型
width, // 屏幕宽度
height, // 屏幕高度
sdkVer: version, // SDK 版本
userId: '', // 用户 id
assetsVer: '', // 页面版本(js 资源版本)
};
}
上报数据
上报数据有两种方式, sendBeacon
和 img.src
sendBeacon 是 POST 请求, 数据通过 body 传输, 了解sendBeacon
img.src 是 GET 请求, 数据通过 url params 传输, string 数据需要编码
/**
* 上报数据
* @param measurement influxDB Measurement
* @param data 上报数据
*/
log(measurement: Measurement, data: any) {
// rollup 注入的值
// @ts-ignore
const host = TRACKER_HOST;
try {
// throw Error('use img');
// 使用 sendBeacon 上报, 参考: https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator/sendBeacon
data.sendMode = 'sendBeacon';
window.navigator.sendBeacon(`${host}/${measurement}`, JSON.stringify(data));
} catch {
// 降级为 img.src 上报
data.sendMode = 'img';
const params = Object.keys(data)
.map((field) => `${field}=${encodeURIComponent(data[field])}`)
.join('&');
const img = document.createElement('img');
img.src = `${host}/${measurement}?${params}`;
}
}
代码中考虑了功能拓展, 第一个参数是上报数据类型(对应数据库表)
网关要做的事情是, 解析数据并存储到数据库中
async function parseCommonInfo(params: Object) {
const result: { [key: string]: any } = Object.assign({}, params);
const { protocol, host, pathname, query, hash } = url.parse(result.url);
Object.assign(result, {
protocol,
page: host + pathname,
query,
hash,
});
const parsedIpv4 = result.ip.match(/\d+.\d+.\d+.\d+/);
result.ipv4 = parsedIpv4 ? parsedIpv4[0] : '';
// @ts-ignore
const uaParseResult = uaParser(params.ua);
const { browser, os, device } = uaParseResult;
const mfwAppRegex = /mfwappver\/(\d+)\.\d+\.\d+/;
// @ts-ignore
const appParseResult = mfwAppRegex.exec(params.ua);
if (appParseResult) {
browser.name = 'Mfw APP';
browser.major = appParseResult[1];
}
const osMajorRegex = /^\d+/;
const osParseResult = osMajorRegex.exec(os.version);
if (osParseResult) {
os.version = osParseResult[0];
}
Object.assign(result, {
browser: browser.name + ' ' + browser.major,
os: os.name + ' ' + os.version,
device: device.vendor && device.model ? device.vendor + ' ' + device.model : '',
});
let ipInfo = {
country: '',
region: '',
county: '',
city: '',
isp: '',
};
if (result.ipv4) {
// 淘宝的服务是有限流的
const url = `https://ip.taobao.com/service/getIpInfo.php?ip=${result.ipv4}`;
try {
const { status, data } = await axios.get(url);
if (status === 200 && data.code === 0 && data.data.isp !== '内网IP') {
function getIpInfo(field: string) {
return data.data[field] === 'XX' ? '' : data.data[field];
}
ipInfo = {
country: getIpInfo('country'),
region: getIpInfo('region'),
county: getIpInfo('county'),
city: getIpInfo('city'),
isp: getIpInfo('isp'),
};
}
} catch (err){
console.error('获取 ip 信息失败', url, err.message);
}
}
Object.assign(result, ipInfo);
return result;
}
router.get('/performance', async (ctx) => {
const { query } = url.parse(ctx.request.url);
const params = querystring.parse(query);
params.ip = ctx.request.ip;
const performance = await parseCommonInfo(params);
await logPerformance(performance);
ctx.status = 204;
});
router.post('/performance', async (ctx) => {
const params = ctx.request.body;
params.ip = ctx.request.ip;
const performance = await parseCommonInfo(params);
await logPerformance(performance);
ctx.status = 204;
});
因为存的是日志数据, 适合使用时序数据库, 经过简单对比(star数量, 相关文章, 口碑)后选择了 influxDB
中文文档 node API
使用起来没什么难度, 和其它 SQL 数据库差不多
有了数据之后, 需要将其展示出来, 我没有自己去实现页面, 直接选择了使用 Grafana
Grafana 是一款开源的可视化仪表盘, 支持通过配置或者写 SQL 的方式直接生成展示图表, 并且配置报警项
文档 中文文档 配置文档 内置图表使用文档
启动 Grafana 后, 打开 web 端登录管理员账号, 先配置 influxDB 数据源, 然后创建图表配置数据查询就 ok 了
文章写得比较简单, 没有具体说一些细节, 如果大家很感兴趣的话, 我可以提供一套可以直接跑的源码和 docker 镜像