nextjs+koa集成sentry

最新公司的新项目用到了nextjs + koa 做服务端渲染,同时集成了Sentry做异常监控。由于页面的代码可能会在服务端和客户端两个环境下运行,所以我们需要对他们分开监控。

sentry 依赖

sentry 提供了不同的模块来处理浏览器和服务端的监控

  • 浏览器 @sentry/browser
  • 服务端 @sentry/node

soucemap

nextjs 默认没有生成 soucemap 文件,但是我们可以通过修改 webpack 配置来生成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// next.config.js
// Copy from
// https://github.com/zeit/next-plugins/blob/6786c6c431d896757b870f112f89e1fcf7ac7ae6/packages/next-source-maps/index.js#L13

webpack(options){
const { dev, isServer } = options;
if (!dev) {
config.devtool = "source-map";

for (const plugin of config.plugins) {
if (plugin.constructor.name === "UglifyJsPlugin") {
plugin.options.sourceMap = true;
break;
}
}

if (config.optimization && config.optimization.minimizer) {
for (const plugin of config.optimization.minimizer) {
if (plugin.constructor.name === "TerserPlugin") {
plugin.options.sourceMap = true;
break;
}
}
}
}

return config;
}

服务端

捕获

使用 nextjs 做服务端渲染时,我们是通过在页面组件上的静态方法getInitialProps来初始化页面数据,并通过props传递给页面组件,从而在服务端完成页面渲染。在这个过程中会有以下几个可能几个异常点

  • getInitialProps 调用链抛出了异常,如空指针等
  • getInitialProps 存在未捕获的rejection
  • 页面组件在初始化首次render过程中抛出的异常

而坑爹的是,nextjs 在内部处理了异常,却没有把错误在服务端层面抛出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// next-server.js源码
async renderErrorToHTML(err, req, res, _pathname, query = {}) {
const result = await this.findPageComponents('/_error', query);
let html;
try {
html = await this.renderToHTMLWithComponents(req, res, '/_error', query, result, Object.assign({}, this.renderOpts, { err }));
}
// 这里捕获了异常,所以服务端中间件中不会捕获到错误
catch (err) {
console.error(err);
res.statusCode = 500;
html = 'Internal Server Error';
}
return html;
}
// ...

所幸的是,如果我们在_error.js自定义错误页的话,可以在getInitialProps的参数ctx中得到error对象。

1
2
3
4
static getInitialProps({ res, err }: NextPageContext) {
// 上报error
// ...
}

但是getInitialProps钩子函数在浏览器端也是会执行的,如在 render 的过程中报错,被 App 的componentDidCatch捕获后也会渲染 error.js 页面 。为了让客户端和服务端的依赖干净和分离,我更倾向于与在服务端代码中来捕获异常。上面提到了 next 并没有把 react 渲染层的异常抛出来,那我们只能用一些 hack 的方式来处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// app是nextApp实例
// 先保存原始方法
const sourceRenderErrorToHTML = app.renderErrorToHTML.bind(app);
// 重写renderErrorToHTML,这个时候我们是可以得到error对象的。
app.renderErrorToHTML = async (err: any, req, res, pathname, query) => {
let userId = "";
if (req.headers.cookie) {
// 如果有需要的话,可以将用户数据解析出来,让监控信息更完善
const matched = req.headers.cookie.match(/sess=(\d+)_/);
if (matched) {
[, userId] = matched;
}
}
if (err) {
sentry.captureException(err, { id: userId }, err.extraObj || {});
}
// 执行原方法
return await sourceRenderErrorToHTML(err, req, res, pathname, query);
};

上传到 sentry

sentry 提供了 release 管理功能,即将源码上传到 sentry,这样 sentry 就能将异常的堆栈信息和源码比对,从而定位到保持错误对应的源代码。那么 nextjs 打包的文件在哪呢?

每次打包后,nextjs 会在配置的本地根目录下创建一个.next文件夹,在.next/server/staic目录下会有一个由字符串命名的文件夹,这个文件夹的名字则是 nextjs 打包生成的BUILD_ID,这个文件夹下的pages文件夹就存放在我们的页面文件,每一个文件都代表一个页面。而这些页面文件都是用于服务端渲染时用的,所以每个文件都会包含所需的全部代码。

所以在打包后,我们需要知道BUILD_ID才能知道文件所在的目录,而在每次打包后,.next/目录下会生成一个BUILD_ID文件,文件的内容就是本次生成的BUILD_ID。通过读取这个文件便能知道打包文件所在的位置。

由于上传后的文件名只包含了static之后的路径,所以需要使用命令行的 --url-prefix 参数添加路径前缀,让路径从根目录开始计算。上传的代码大致如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 先执行打包,生成 BUILD_ID
const buildFile = path.join(__dirname, "../../client/.next/BUILD_ID");
const nextBuildId = fs.readFileSync(buildFile).toString();

const releaseServerFiles = subdir => {
run(
`./node_modules/.bin/sentry-cli --auth=xxx --url=yyy releases -o "medlinker" -p "web-ih-server" files ${VERSION} upload-sourcemaps ${path.join(
__dirname,
`../../client/.next/server/static/`,
subdir
)} --url-prefix 'app:///client/.next/server/static/${subdir}/'`
);
};

releaseServerFiles(nextBuildId);

到此为止就可以了吗?并没有。因为当代码部署到服务器后,nodejs 抛出的异常的是文件包含完整的路径的,比如 /user/xxx/xxx/xx/.client/server/static/yyyy/pages/xx.js 这样的路径。而我们上传的文件名是从项目根路径开始计算的,也就是说 sentry 无法判断异常来自哪一个文件。

好在 sentry 提供了RewriteFrames这个插件,在上传异常前重写错误堆栈中的路径信息。我们将根目录指定成当前项目路径,这个插件就会去掉项目文件夹之前那部分路径,这样一来堆栈中的文件路径就和上传的文件保持一致了。

1
2
3
4
5
6
7
8
9
10
11
import { RewriteFrames } from "@sentry/integrations";

Sentry.init({
// 其他配置
// ...
integrations: [
new RewriteFrames({
root: path.resolve(".")
})
]
});

绿色框框中就是我们源码

客户端

客户端的流程和服务端基本一致,但有如下几点区别

  • 客户端 js 代码在.next/static/chunks.next/static/runtime.next/static/pages目录下
  • sentry 默认会下根据代码文件底部的 //# sourceMappingURL=_ 自动加载 sourcemap,这是最简单的方法。但是这样容易在客户端暴露源码,一般来说我们会不上传 map 文件到服务器或者对 map 文件禁止访问。这种情况下我们需要和服务端代码一样,把打包后的代码和 map 文件上传到 release 的工件库,sentry 也就能正确识别 soucemap 了。
  • 如果手动上传 sentry,需要上传上述的三个文件夹

总结

  1. next 打包
  2. 通过 BUILD_ID 找到打包的目录
  3. 上传文件和 sourcemap 到 sentry 并重写路径
  4. 服务端上传前重写异常的堆栈信息