前言
原谅我起了一个这么大的标题,当了一回标题党😛
之前一直做B端系统,针对线上错误引入了 sentry
来收集错误日志,一开始还能盯着告警邮件,后面告警越来越多就选择性忽视了🤪,大伙可千万别学我,我忽视的可都是不影响业务和流程的报错(嗨,还在找借口🤣),让我想起来每次说我爸骑车闯红灯,他都会说是在不影响安全的情况下闯的,有那么点异曲同工之妙了。
入职新公司后顺理成章也接入了 sentry
,满心期待着能发挥他的作用,果然不多久产品就反馈说线上出问题了,说是屏幕上不显示立定跳远成绩,对了新公司是做AI智慧体育的,出现的问题是在立定跳远项目上。出现问题后第一时间肯定是自测一遍,咦没问题啊,再测还是没问题,那没辙只能从代码上看看。因为学生跳完后不知道什么时候出成绩,所以和后端约定了通过 WebSocket
发送成绩消息给前端,那既然这样前端不显示成绩肯定是后端没发送对应的 WebSocket
消息了,可是证据呢,对啊从哪找证据呢!
接下来的几个月又出现了各种奇葩的问题,无一例外这些在 sentry
里都看不到错误日志,因为本身代码没有报错,只是流程上出了问题。忍无可忍,无需再忍,看来开发一套前端自己的日志系统迫在眉睫了。
为什么要大费周章自己开发呢,网上前端日志系统不是一抓一大把吗,很遗憾都是处理异常的,而我们需要记录所有 WebSocket
消息,所有前端 log
,所有 http
请求,还有一些特殊的要求,综上只能自己来了。
一个合格的日志系统必须有的几个功能。
- console 劫持
- 前端离线存储
- http 请求劫持
- 错误日志劫持
- 上传日志
- 服务器查看日志
console 劫持
因为产品的特殊性,需要记录程序运行过程中所有日志打印,方便线上出问题排查,所以第一步就要劫持所有 console 打印,并在最前面加上时间戳方便和后端配合定位问题,最简单的方案就是重写 console.log
rewritetConsole() {
const consoleLog = console.log
console.log = (...args: any[]) => {
const now = dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')
consoleLog.apply(console, [`${now} [info]`, ...args])
}
}
那 console.error
和 console.warn
怎么办,是不是每个都要劫持原生方法,况且 console
下面有25个方法,挨个写不得累死,那换个姿势重写,遍历 console
下所有方法。
rewritetConsole() {
Object.keys(console).forEach(key => {
const rewrite = console[key]
console[key] = (...args: any[]) => {
const now = dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')
rewrite.apply(console, [`${now} [${key}]`, ...args])
}
})
}
但是我不建议这么操作,因为 console.time
和 console.count
等方法接收的传参不能多个,会导致功能不生效,最好的办法是提前定义好需要劫持的方法,这样自由度最高。
rewritetConsole() {
const rewriteFunctions = ['log', 'warn', 'error']
rewriteFunctions.forEach((key) => {
const rewrite = console[key]
console[key] = (...args: any[]) => {
const now = dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')
rewrite.apply(console, [`${now} [${key}]`, ...args])
}
})
}
前端离线存储
虽然已经劫持了 console
日志,但是日志并没有上传到服务器,考虑到性能和网络掉线等情况,不可能每打印一条日志就上传一条,所以需要先把日志临时存储起来。
前端常见的存储方案有 localStorage
、cookie
、indexDB
,不过 localStorage
和 cookie
存储上限都太小了,有时候打印一个base64图片可能就超过4Mb了,所以最终敲定方案为 indexDB
,内容是存储在硬盘的,每个浏览器策略还不一样,火狐 FireFox 全局限制为可用磁盘空间的 50%,chrome 没有找到文档说明但也不用顾虑超过存储上限的。至于 兼容性 现代浏览器就更不用担心了。
indexDB 原生操作起来还是有点繁琐的,所以找了一个库 dexie 来协助操作,而且 dexie
的用法和之前写 Sequelize
有点像,上手起来也快。
首先创建一个数据库 logger
,新建一个 logs
表记录所有日志内容,策略是每隔几秒上传一次最近的日志,不过有个问题如果网络异常或者服务器异常导致日志上传失败,那么下一次上传时还要继续上传未上传的日志,所以得有一个字段 hasUploadMessageId
记录已经上传过的日志 id,上传成功后修改该字段为最后一条日志 id。另外还需要一个字段 latestMessageId
记录最新日志 id,不然每次从 logs
表计算性能损耗太大了,后续上传日志到服务器时取 hasUploadMessageId
和 latestMessageId
之间的日志。
import Dexie from 'dexie'
import type { Table } from 'dexie'
class IndexDB extends Dexie {
/** 日志内容表 */
logs!: Table<{
/** 自增id */
id?: number
/** 日志内容 */
message: string
}>
/** 标志字段表 */
flag!: Table<{
/** 自增id */
id?: number
/** 最新日志id */
latestMessageId: number
/** 已经上传成功的日志id */
hasUploadMessageId: number
}>
/** 初始化 */
constructor() {
super('logger')
this.version(1).stores({
logs: '++id, message',
flag: '++id, latestMessageId, hasUploadMessageId'
})
}
}
接下来写一个通用方法把日志写入到 indexDB
中,这样就完成了日志离线存储。
/**
* 日志写入到 indexDB 中
*/
async insertLog(...args: any[]) {
const messages: string[] = []
args.forEach((arg) => {
if (typeof arg === 'string') {
messages.push(arg)
} else {
// 非字符串需要通过 JSON.stringify 转义成字符串
try {
messages.push(JSON.stringify(arg))
} catch {}
}
})
// 拼接后的日志内容
const message = messages.join(' ').trim()
// 日志为空或者超过最大长度不记录
if (message.length === 0 || message.length > this.config.maxLength) {
return
}
try {
// 插入日志
const id = await db.logs.add({
message
})
// 更新
db.flag
.update(1, {
latestMessageId: id
})
.then((updated) => {
if (updated) {
this.latestMessageId = id as number
}
})
} catch (error) {}
}
测试下效果,可以看到 indexDB
中已经有了日志
因为还没有上传到服务器,所以 hasUploadMessageId
为 0
http 请求劫持
在排查线上问题的时候由于不知道出错那一刻 http 请求返回内容而没头绪,所以需要劫持 http 请求,目前 http 请求有两种 xhr
和 fetch
,分两种情况劫持。
xhr 劫持 的原理就是重写 XMLHttpRequest.prototype
原型链上的 open
方法,获取请求方法和请求地址,然后监听 readystatechange
事件获取响应内容,最后把这些一起写入到 indexDB
中。
/**
* 劫持 xhr 请求
*/
interceptXHR() {
const insertLog = this.insertLog.bind(this)
// 重写 xhr.open 方法,open 方法接收两个参数,第一个为请求方法,第二个为请求地址
const xhrOpen = XMLHttpRequest.prototype.open
XMLHttpRequest.prototype.open = function (...args: any) {
const method = args[0]
const url = args[1]
const now = dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')
// 调用原生方法
xhrOpen.apply(this, args)
// 监听 readystatechange 事件
this.addEventListener('readystatechange', () => {
if (this.readyState === XMLHttpRequest.prototype.DONE) {
// 20 开头的状态码都是成功的,比如201、204
if (String(this.status).startsWith('20')) {
// 正常 http 请求不需要打印到控制台,在 network 里可以看到完整信息,
// 只需要记录到 indexDB 并上传服务器即可,所以不能使用 console.log
insertLog(
`${now} [http ok] method: ${method}, url: ${url}, status: ${this.status}, response: ${this.response}`
)
} else {
// http 请求异常
insertLog(
`${now} [http error] method: ${method}, url: ${url}, status: ${this.status}, response: ${this.response}`
)
}
}
})
}
}
fetch 劫持 原理和 xhr
一样,也是重写原生方法,不过 fetch
能获取到请求参数和 headers
等。xhr
劫持因为请求参数是在 xhr.send
时携带的,在 xhr.open
中获取不到,所以在监听回调事件的时候无法获取请求参数,除非不用原生 xhr
请求自己封装一个。
/**
* 劫持 fetch 请求
*/
interceptFetch() {
const insertLog = this.insertLog.bind(this)
const originalFetch = window.fetch
window.fetch = async (...args) => {
let [url, config] = args
// 请求开始时间
const start = dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')
const response = await originalFetch(url, config)
// 响应内容
let responseText = ''
try {
responseText = await response.clone().json()
} catch (error) {
responseText = ''
}
// 响应时间
const now = dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')
// 20 开头的状态码都是成功的,比如201、204
if (String(response.status).startsWith('20')) {
insertLog(
`${now} [http ok] method: ${
config?.method || 'get'
}, url: ${url}, status: ${response.status}, requestBody: ${
config?.body
}, requestTime: ${start}, response: `,
responseText
)
} else {
insertLog(
`${now} [http error] method: ${
config?.method || 'get'
}, url: ${url}, status: ${response.status}, requestBody: ${
config?.body
}, requestTime: ${start}, response: `,
responseText
)
}
return response
}
}
至此 http 劫持已经实现了,在日志中可以看到请求方法,请求地址,请求时间,请求体和返回内容
系统接入了友盟统计,每次点击页面的时候都会自动发送一个请求到友盟服务器,这个请求其实我们是不关心他有没有发送成功的,也不需要记录日志,所以需要把他过滤掉,定义一个参数 filterHttpUrls
,如果请求地址包含这个就不记录日志。
可选配置项 config
type ConfigType = {
/** 单条日志最大长度,超过不写入 indexDB 也不上报后台 */
maxLength?: number
/** 过滤掉的 http 请求地址,这些地址不记录到日志里 */
filterHttpUrls: string[]
}
// 默认配置
const defaultConfig: Required<ConfigType> = {
maxLength: 10000,
filterHttpUrls: []
}
export default class Logger {
/** 配置 */
config!: Required<ConfigType>
constructor(config?: ConfigType) {
this.config = Object.assign(config || {}, defaultConfig)
}
}
过滤不需要记录日志的 url
// 请求地址过滤
if (config.filterHttpUrls.filter((item) => url.indexOf(item) !== -1).length === 0) {
return
}
错误日志劫持
window.onerror
MDN的解释为:当资源加载失败或无法使用时,会在Window
对象触发error
事件,例如:script 执行时报错。
不过 window.onerror
无法捕获 promise
异常,需要监听 window.onunhandledrejection
事件,MDN解释为:当 Promise
被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection
事件;这可能发生在 window
下,但也可能发生在 Worker
中。这对于调试和为意外情况提供后备错误处理非常有用。
/**
* 监听错误
*/
interceptError() {
// 监听全局错误
window.onerror = (message, source, _lineno, _colno, error) => {
console.error(`${message}, ${error}, ${source}`)
}
// 监听未处理的 promise 错误,不能打印 event.reason,JSON.stringify 后为空对象 {}
window.onunhandledrejection = (event) => {
// 这里如果打印 event.reason 在控制台是可以看到报错的,但是 JSON.stringify 却为空
// 必须打印 event.reason.stack
console.error(event.reason.stack)
}
}
上传日志
日志都已经记录下来了,接下来就是要上传日志到服务器了,我的策略是每隔一段时间上传几十条,上传间隔和数量用户都可以自定义。
为了方便在服务器上查看日志,我们规定每台设备生成一个日志文件,文件名字为该设备的唯一设备ID,初始用 uuid
生成,后续存储到 localStorage
中,保证后续设备ID不会变化。当然用户也可以自定义设备ID。
type ConfigType = {
/** 日志服务器地址 */
uploadUrl?: string
/** 日志上传间隔 */
uploadInterval?: number
/** 单次日志上传最大数量 */
uploadMaxCount?: number
/** 设备ID */
deviceId?: string
}
接口轮询上传日志
/**
* 上传日志到服务器
*/
uploadLog(currentMessageId: number) {
setInterval(async () => {
// 不能重复上传
if (this.isUploading) return
this.isUploading = true
// 如果需要上传的日志大于最大数量则只上传固定数量
const offset =
currentMessageId - this.hasUploadMessageId > this.config.uploadMaxCount
? this.hasUploadMessageId + this.config.uploadMaxCount
: currentMessageId
const res = await db.logs
.where('id')
.between(this.hasUploadMessageId, offset)
.toArray()
// 上传日志,这里可以根据自己服务器配置自定义
fetch(this.config.uploadUrl, {
method: 'POST',
mode: 'cors',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
deviceId: this.config.deviceId,
logs: res.map((item) => item.message)
})
})
.then((res) => res.json())
.then((res) => {
// 日志上传成功需要更新 hasUploadMessageId 为最新已上传的 id
if (res.code === 200) {
db.flag
.update(1, {
hasUploadMessageId: offset
})
.then((update) => {
if (update) {
this.hasUploadMessageId = offset
}
})
}
})
.finally(() => {
this.isUploading = false
})
}, this.config.uploadInterval * 1000)
}
虽然说 indexDB
使用过程中不需要考虑容量限制,但也架不住日志越来越多,所以可以每隔一段时间清空已经上传的日志,已经上传的日志删除不影响现有功能的。
/**
* 周期性删除已经上传成功的日志
*/
deleteHasUploadLogs() {
setInterval(() => {
// 删除日志的时候不能上传日志
this.isUploading = true
db.logs
.where('id')
.belowOrEqual(this.hasUploadMessageId)
.delete()
.finally(() => {
this.isUploading = true
})
}, 60 * 60 * 1000)
}
合并配置
定义了很多配置项,用户可以使用默认配置也可以自定义配置,所以需要把用户配置和默认配置进行合并。因为要保证合并后的配置项 ts 类型不会丢失,所以要用到范型约束自动推导出配置类型。
/**
* 合并配置项
*/
mergeConfig<T extends object | undefined, U extends object>(
config: T,
defaultConfig: U
) {
const mergedConfig = {}
Object.keys(defaultConfig).forEach((key) => {
if (this.isValidKey(key, defaultConfig)) {
if (config) {
mergedConfig[key] =
config[key] === undefined ? defaultConfig[key] : config[key]
} else {
mergedConfig[key] = defaultConfig[key]
}
}
})
return mergedConfig as U
}
初始化
/**
* 初始化
*/
constructor(config?: ConfigType) {
this.config = this.mergeConfig(config, defaultConfig)
this.initialDeviceId()
this.initialIndexDB()
this.rewritetConsole()
this.interceptXHR()
this.interceptFetch()
this.interceptError()
this.uploadLog()
this.deleteHasUploadLogs()
}
日志文件在服务器是以设备ID命名的,所以需要知道终端设备的设备ID才能在服务器上查看对应的日志,我的想法是在设备上连续点击10次弹出 toast
显示设备ID。
/**
* 连续点击设备10次弹窗显示设备ID
*/
showDeviceId() {
/** 连续点击次数 */
let clickTimes = 0
/** 两次点击之间最小间隔 */
let waitTime = 300
let lastTime = new Date().getTime()
document.body.addEventListener('click', () => {
clickTimes =
new Date().getTime() - lastTime < waitTime ? clickTimes + 1 : 1
lastTime = new Date().getTime()
if (clickTimes >= 10) {
const div = document.createElement('div')
div.setAttribute(
'style',
'position: fixed; top: 0; right: 0; bottom: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 99999'
)
const deviceId = document.createElement('div')
deviceId.setAttribute(
'style',
'max-width: 500px; height: 60px; padding: 0 24px; border-radius: 6px; background: rgba(0,0,0,0.7); color: #fff; font-size: 18px; display: flex; align-items: center; justify-content: center;'
)
deviceId.innerHTML = `设备ID: ${this.config.deviceId}`
div.appendChild(deviceId)
document.body.append(div)
// 5秒后消失
setTimeout(() => {
document.body.removeChild(div)
}, 5000)
}
})
}
服务器
服务端我用 node.js
简单写了个接口接收日志上传并写入本地文件中,难度不大。
const fs = require("fs");
const dayjs = require("dayjs");
const axios = require("axios");
const Koa = require("koa");
const app = new Koa();
const Router = require("koa-router");
const router = new Router();
const onerror = require("koa-onerror");
const bodyParser = require("koa-bodyparser");
const logger = require("koa-logger");
const cors = require("koa-cors");
onerror(app);
app.use(bodyParser());
app.use(logger());
app.use(cors());
const logPath = `${__dirname}/logs`;
if (!fs.existsSync(logPath)) {
fs.mkdirSync(logPath);
}
const writeStreams = {};
router.post("/logger", (ctx, next) => {
const { deviceId, logs } = ctx.request.body;
const date = dayjs().format("YYYY-MM-DD");
// 新建日期文件夹
if (!fs.existsSync(`${logPath}/${date}`)) {
fs.mkdirSync(`${logPath}/${date}`);
}
if (!deviceId) {
ctx.body = {
code: 500,
msg: "参数 deviceId 不能为空",
};
return;
}
// 不能每次都新建写文件流,需要缓存起来
let writeStream = null;
if (writeStreams[deviceId]) {
writeStream = writeStreams[deviceId];
} else {
writeStream = fs.createWriteStream(`${logPath}/${date}/${deviceId}.log`, {
flags: "a",
});
writeStreams[deviceId] = writeStream;
}
// 如果上报的日志日期和服务器当前日期不一样则关闭之前的文件流,重新新建一个当前日期的文件流写入
const paths = writeStream.path.split("/");
if (paths.length > 0 && paths[paths.length - 1] !== date) {
Object.keys(writeStreams).forEach((key) => {
writeStreams[key].close();
delete writeStreams[key];
});
writeStream = fs.createWriteStream(`${logPath}/${date}/${deviceId}.log`, {
flags: "a",
});
writeStreams[deviceId] = writeStream;
}
// 遍历日志写入
if (Array.isArray(logs)) {
logs.forEach((log) => {
writeStream.write(log + "\n");
});
} else {
writeStream.write(logs + "\n");
}
ctx.body = {
code: 200,
};
});
app.use(router.routes());
app.on("error", (err, ctx) => {
console.error("server error", err, ctx);
});
app.listen(3000);
console.log("server listing on http://localhost:3000");
效果展示
偷偷的说一句,自从前端有了日志后,BUG没解决几个,但可以挺直腰板义正言辞的把锅甩给后端了,确实是你们没有发送哦,自此再也不用去看后端日志和nginx日志了,离到点下班又近了一步😎。
总结
至此一个简单的满足特定场景的日志框架搭建好了,当然我根据公司业务场景上报了很多东西,大家也可以根据自己要求自行上报。比如监听 WebSocket
事件并上报链接成功,链接失败,接收和发送的消息等,方便后续排查问题。另外我还监听了页面路由跳转并打印进一步协助排查问题。
router.afterEach((to, from) => {
const fromPath = decodeURIComponent(from.fullPath)
const toPath = decodeURIComponent(to.fullPath)
console.log(`路由跳转 from: ${fromPath} to: ${toPath}`)
})
😭我的 github
密码弄丢了,等找回了把源码传到 github
上供大家参考