文章首发于: https://feinterview.poetries.top/blog/nextjs-sentry-feishu-alert
服务端报错只落在容器的 stdout 里,而容器日志是轮转的(--log-opt max-size),等有人在群里说「你那个页面白屏了」,我再去 docker logs,那段往往已经被冲掉了。浏览器端更彻底,压根看不见。更尴尬的是代码里那个组件级 ErrorBoundary,componentDidCatch 里只有一句 console.error,而生产构建开着 compiler.removeConsole,所以线上那个分支什么都没做,组件静默消失,没有任何人知道。
这周把这件事补上了:Sentry 收错误,飞书群收告警,卡片里直接带调用栈。整条链路从装包到上线跑通,中间踩了七八个坑,有几个是真的把我卡住了一阵,比如「本地怎么试都收不到上报」和「验签永远失败」。这篇就按我实际走的顺序记下来。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
@sentry/nextjs10.x 在Next.js 16里三端(client / server / edge)怎么接线,各自的入口文件放哪- 为什么本地
yarn dev收不到任何上报,以及这是我刻意做的 beforeSend脱敏层要防什么,以及怎么不发一条数据给 Sentry 就验证它生效了- Sentry 的
exception.values[].stacktrace.frames为什么顺序是反的,渲染进卡片前要怎么处理 - webhook 路由的完整实现:验签、payload 解析、堆栈渲染、推卡片,四段代码都给全
sentry-hook-signature验签失败,为什么多半是因为你用了JSON.stringify(req.body)- 飞书自建应用从建应用到发版怎么配:机器人能力、权限批量导入、事件订阅,以及配了不发版不生效
- 飞书推交互卡片的两个代码坑:
content必须是字符串、tenant_access_token必须缓存 - 集成建好了却在告警规则里选不到,是漏了哪个开关
next start在output: 'standalone'下直接不工作,本地怎么验生产行为- 上线后那两次「以为出事了其实没有」的误判,怎么用对照实验证伪
# 一、整体架构
先把链路画清楚,后面每个坑落在哪一段就有地方挂了:
浏览器 我们的 Next.js 容器 外部服务
┌──────────┐ ┌──────────────────────────────┐ ┌─────────────┐
│ 页面 JS │ │ instrumentation.ts │ │ Sentry │
│ │──── 错误 ────▶│ register() 按 runtime 加载 │───▶│ (poetry-org)│
│ instrume-│ (直连或经 │ onRequestError 钩子 │ │ │
│ ntation- │ /monitoring │ │ └──────┬──────┘
│ client │ 隧道) │ src/libs/sentry/ │ │
└──────────┘ │ beforeSend 统一脱敏 │ │ 告警规则触发
│ │ │ event_alert
│ /api/alerts/sentry-webhook │◀──────────┘ (POST + 签名)
│ HMAC 验签 → 解析堆栈 │
│ │ │ ┌─────────────┐
│ ▼ │ │ 飞书自建应用 │
│ server/notify/feishu.ts │───▶│ 告警群 │
│ token 缓存 + 节流 + 卡片 │ └─────────────┘
│ server/notify/alert.ts │
│ 邮件 + 飞书 并行扇出 │ ┌─────────────┐
│ │───▶│ 阿里企业邮 │
└──────────────────────────────┘ └─────────────┘
三件事值得先说清楚:
告警中转放在了自己的站点里,不是独立服务。少一套部署、少一份凭据,跟着现有 Docker 一起发版。代价是站点整个挂掉时这条通道也哑,但那个场景 Sentry 自带的邮件会兜底,而且站全挂通常比单个 issue 更早被别的方式发现。真正需要覆盖的是「站点活着但某个功能在报错」,那正是这条链路的主场。
邮件和飞书是并列的两条通道,不是替代关系。 飞书是「现在就有人看见」,邮件是「事后能翻到、能转发」。更关键的是它们的故障面不重叠,飞书挂了(换 token 失败、频控、应用被停用)邮件照发,SMTP 挂了飞书照发。告警系统最怕的就是出事的时候它自己也哑了,多一条独立通道比把单条做得更可靠划算。
卡片里必须有堆栈。 这是我第一版做漏的,当时卡片只有一行 位置: app:///src/app/(web)/(routes)/blog/page.tsx,看到告警还得去开 Sentry,那这条推送的价值就打了对折。
# 二、20 分钟跑通最小可用版本
下面这套是我实际跑通的顺序,环境是 Next.js 16.3.1 + @sentry/nextjs 10.74.0 + Node 22.23.1,output: 'standalone',部署在腾讯云 Docker 容器里。
# Step 1:装包,注意 yarn 缓存这个坑
yarn add @sentry/nextjs
我第一次跑这条命令,终端最后打了 exit code 0,看起来是成功的。结果 node_modules/@sentry/nextjs 根本不存在,package.json 里也没有这一行。回头翻完整日志才看到藏在一堆 peer dependency warning 后面的这句:
error Error: ENOENT: no such file or directory, open ‘/Users/mac/Library/Caches/Yarn/v6/npm-@sentry-cli-darwin-2.58.6-…/node_modules/@sentry/cli-darwin/.yarn-metadata.json’
@sentry/cli-darwin 在 yarn 全局缓存里的那份元数据坏了。yarn 1.x 在这种情况下会报错但仍然退出 0,这个组合非常坑,你以为装好了其实什么都没发生。清掉那一份缓存重来就好:
rm -rf "$HOME/Library/Caches/Yarn/v6/npm-@sentry-cli-darwin-2.58.6-"*
yarn add @sentry/nextjs
装完记得 node -p "require('@sentry/nextjs/package.json').version" 确认一下,别只看 exit code。
# Step 2:三端接线,文件位置不能放错
@sentry/nextjs 在 App Router 下有三个入口,位置是 Next 规定的,放错地方不会报错,只会静默不生效,这一点很要命。
浏览器端是 src/instrumentation-client.ts(和 src/app 同级):
import * as Sentry from '@sentry/nextjs'
import { initSentryClient } from '@/libs/sentry'
initSentryClient()
// 不接这个的话,App Router 的软导航在 Sentry 里完全看不到,
// 所有页面的性能数据都会算到首次进站的那个 pageload 上
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart
服务端和 edge 各自是仓库根目录的 sentry.server.config.ts / sentry.edge.config.ts,由 src/instrumentation.ts 按 runtime 动态加载:
export async function register() {
// 必须动态 import:这两个配置文件分别只在对应 runtime 下可用,
// server 那份会拉进 node: 内置模块,顶层引会让 edge 编译直接失败
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('../sentry.server.config')
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('../sentry.edge.config')
}
// …原有逻辑
}
// Next 15+ 提供的服务端请求出错钩子,RSC 和 route handler 都走它。
// 名字是 Next 约定的,不能改、也不能包一层
export const onRequestError = Sentry.captureRequestError
onRequestError 是我觉得最值回票价的一个。接上之前,服务端渲染阶段抛的错只有容器 stdout 知道;接上之后它和浏览器端错误进同一个项目,按 runtime tag 区分。
# Step 3:包一层再给业务用
我没让业务代码直接 from '@sentry/nextjs',而是全部收到 @/libs/sentry 后面。这层隔离换来两件事:未 init 时所有调用自动 no-op,业务侧一行防御都不用写;所有出站数据都过同一份脱敏规则,不会有哪条路径漏掉。
function isInitialized(): boolean {
try {
return !!Sentry.getClient()
} catch {
return false
}
}
export function captureException(error: unknown, options?: CaptureOptions): void {
if (!isInitialized()) return
Sentry.captureException(error, { tags: options?.tags, level: options?.level ?? 'error' })
}
然后把三层错误边界都接上:路由级 error.tsx、根级 global-error.tsx、组件级 ErrorBoundary。三者打不同的 boundary tag,因为严重程度差很远,global 意味着整站白屏,component 只是黑掉一块。
error.tsx 里有个细节值得单独说:生产构建下服务端错误的 message 会被 Next 抹掉,只留一个 digest。不把它带上的话,Sentry 上就是一条光秃秃的 An error occurred in the Server Components render,等于没有信息。
useEffect(() => {
captureException(error, {
tags: { boundary: 'route', ...(error?.digest ? { digest: error.digest } : {}) }
})
}, [error])