Sentry 告警 → 飞书通知 完整实现方案
模拟告警项目地址:https://github.com/keepongo/sentry-test-app.git 中继服务项目地址:https://github.com/keepongo/sentry-relay.git
一、学什么:整条链路概念
1.1 Sentry 是什么
Sentry 是一个错误/异常监控平台。你的应用在运行中产生异常(NullPointerException、业务错误等),Sentry 会:
- 收集:通过 SDK/logback 把异常事件上报
- 聚合:相同异常合并为一个 Issue,记录触发次数、影响范围
- 告警:根据你配置的规则(如"首次出现"“1 小时内出现 10 次”),触发告警动作
1.2 DSN 是什么
DSN = Data Source Name,是 Sentry 项目的"上报地址",格式如:
https://<公钥>@<Sentry域名>/<项目ID>
- SaaS 版示例:https://abc123@o123456.ingest.sentry.io/456789
- 自建版示例:https://abc123@sentry.yourcompany.com/2
你在环境变量里配的 LOG_SENTRY_DSN 就是这个。
1.3 整条链路
#mermaid-svg-tq1WFEEWmR1a8WyP{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-tq1WFEEWmR1a8WyP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tq1WFEEWmR1a8WyP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tq1WFEEWmR1a8WyP .error-icon{fill:#552222;}#mermaid-svg-tq1WFEEWmR1a8WyP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tq1WFEEWmR1a8WyP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tq1WFEEWmR1a8WyP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tq1WFEEWmR1a8WyP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tq1WFEEWmR1a8WyP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tq1WFEEWmR1a8WyP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tq1WFEEWmR1a8WyP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tq1WFEEWmR1a8WyP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tq1WFEEWmR1a8WyP .marker.cross{stroke:#333333;}#mermaid-svg-tq1WFEEWmR1a8WyP svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tq1WFEEWmR1a8WyP p{margin:0;}#mermaid-svg-tq1WFEEWmR1a8WyP .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-tq1WFEEWmR1a8WyP .cluster-label text{fill:#333;}#mermaid-svg-tq1WFEEWmR1a8WyP .cluster-label span{color:#333;}#mermaid-svg-tq1WFEEWmR1a8WyP .cluster-label span p{background-color:transparent;}#mermaid-svg-tq1WFEEWmR1a8WyP .label text,#mermaid-svg-tq1WFEEWmR1a8WyP span{fill:#333;color:#333;}#mermaid-svg-tq1WFEEWmR1a8WyP .node rect,#mermaid-svg-tq1WFEEWmR1a8WyP .node circle,#mermaid-svg-tq1WFEEWmR1a8WyP .node ellipse,#mermaid-svg-tq1WFEEWmR1a8WyP .node polygon,#mermaid-svg-tq1WFEEWmR1a8WyP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-tq1WFEEWmR1a8WyP .rough-node .label text,#mermaid-svg-tq1WFEEWmR1a8WyP .node .label text,#mermaid-svg-tq1WFEEWmR1a8WyP .image-shape .label,#mermaid-svg-tq1WFEEWmR1a8WyP .icon-shape .label{text-anchor:middle;}#mermaid-svg-tq1WFEEWmR1a8WyP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-tq1WFEEWmR1a8WyP .rough-node .label,#mermaid-svg-tq1WFEEWmR1a8WyP .node .label,#mermaid-svg-tq1WFEEWmR1a8WyP .image-shape .label,#mermaid-svg-tq1WFEEWmR1a8WyP .icon-shape .label{text-align:center;}#mermaid-svg-tq1WFEEWmR1a8WyP .node.clickable{cursor:pointer;}#mermaid-svg-tq1WFEEWmR1a8WyP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-tq1WFEEWmR1a8WyP .arrowheadPath{fill:#333333;}#mermaid-svg-tq1WFEEWmR1a8WyP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-tq1WFEEWmR1a8WyP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-tq1WFEEWmR1a8WyP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tq1WFEEWmR1a8WyP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-tq1WFEEWmR1a8WyP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tq1WFEEWmR1a8WyP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-tq1WFEEWmR1a8WyP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-tq1WFEEWmR1a8WyP .cluster text{fill:#333;}#mermaid-svg-tq1WFEEWmR1a8WyP .cluster span{color:#333;}#mermaid-svg-tq1WFEEWmR1a8WyP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-tq1WFEEWmR1a8WyP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-tq1WFEEWmR1a8WyP rect.text{fill:none;stroke-width:0;}#mermaid-svg-tq1WFEEWmR1a8WyP .icon-shape,#mermaid-svg-tq1WFEEWmR1a8WyP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tq1WFEEWmR1a8WyP .icon-shape p,#mermaid-svg-tq1WFEEWmR1a8WyP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-tq1WFEEWmR1a8WyP .icon-shape rect,#mermaid-svg-tq1WFEEWmR1a8WyP .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tq1WFEEWmR1a8WyP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-tq1WFEEWmR1a8WyP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-tq1WFEEWmR1a8WyP :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
上报异常
触发告警\\n发 Webhook POST
转成飞书格式\\nPOST
显示告警消息
你的 Java 应用\\n(logback + DSN)
Sentry\\n(收集/聚合/告警)
中继服务\\n(Spring Boot)
飞书机器人\\n(群聊 Webhook)
飞书群
1.4 为什么需要「中继」
- Sentry Webhook 发出的 JSON 格式(含 event、issue、triggered_rule 等)和飞书机器人要求的 JSON 格式(msg_type、content 等)完全不同
- 不能直接把飞书 Webhook 填到 Sentry 告警里,Sentry 发过去飞书不认
- 中继服务做的事:收 Sentry JSON → 解析 → 拼成飞书 JSON → 转发
二、准备工作:注册 Sentry
2.1 注册 Sentry SaaS(推荐,免费,自学用)
-
点 “Create Project”
-
Platform 选 Java(或搜索 “Logback”,选 Java – Logback)
-
点 “Create Project”


或者
- 左侧 Settings → Projects → 点 java-logback
- 左侧找到 Client Keys (DSN)
- 复制那个完整的 DSN(注意不要多复制空格或少复制字符)
- 粘贴到 E:\\sentry-test-app\\src\\main\\resources\\logback.xml 里替换掉现在的 DSN


2.2 Sentry 界面关键页面(先认识一下)
登录后左侧菜单:
- Issues:所有收集到的异常列表,相同异常会自动聚合为一个 Issue
- Alerts:告警规则配置(后面第四节详细讲)
- Settings → Projects → 你的项目:DSN 在这里可以再次查看
- Settings → Integrations:配置 Webhook 等第三方集成
三、应用侧配置:测试项目验证 Sentry 上报
测试项目地址
3.1 Sentry 上报原理
Sentry 服务端
Sentry SDK
logback-spring.xml
你的应用
Sentry 服务端
Sentry SDK
logback-spring.xml
你的应用
#mermaid-svg-T9dsZVRdXrG9mCVZ{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-T9dsZVRdXrG9mCVZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-T9dsZVRdXrG9mCVZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-T9dsZVRdXrG9mCVZ .error-icon{fill:#552222;}#mermaid-svg-T9dsZVRdXrG9mCVZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-T9dsZVRdXrG9mCVZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-T9dsZVRdXrG9mCVZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-T9dsZVRdXrG9mCVZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-T9dsZVRdXrG9mCVZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-T9dsZVRdXrG9mCVZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-T9dsZVRdXrG9mCVZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-T9dsZVRdXrG9mCVZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-T9dsZVRdXrG9mCVZ .marker.cross{stroke:#333333;}#mermaid-svg-T9dsZVRdXrG9mCVZ svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-T9dsZVRdXrG9mCVZ p{margin:0;}#mermaid-svg-T9dsZVRdXrG9mCVZ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-T9dsZVRdXrG9mCVZ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-T9dsZVRdXrG9mCVZ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-T9dsZVRdXrG9mCVZ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-T9dsZVRdXrG9mCVZ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-T9dsZVRdXrG9mCVZ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-T9dsZVRdXrG9mCVZ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-T9dsZVRdXrG9mCVZ .sequenceNumber{fill:white;}#mermaid-svg-T9dsZVRdXrG9mCVZ #sequencenumber{fill:#333;}#mermaid-svg-T9dsZVRdXrG9mCVZ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-T9dsZVRdXrG9mCVZ .messageText{fill:#333;stroke:none;}#mermaid-svg-T9dsZVRdXrG9mCVZ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-T9dsZVRdXrG9mCVZ .labelText,#mermaid-svg-T9dsZVRdXrG9mCVZ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-T9dsZVRdXrG9mCVZ .loopText,#mermaid-svg-T9dsZVRdXrG9mCVZ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-T9dsZVRdXrG9mCVZ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-T9dsZVRdXrG9mCVZ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-T9dsZVRdXrG9mCVZ .noteText,#mermaid-svg-T9dsZVRdXrG9mCVZ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-T9dsZVRdXrG9mCVZ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-T9dsZVRdXrG9mCVZ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-T9dsZVRdXrG9mCVZ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-T9dsZVRdXrG9mCVZ .actorPopupMenu{position:absolute;}#mermaid-svg-T9dsZVRdXrG9mCVZ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-T9dsZVRdXrG9mCVZ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-T9dsZVRdXrG9mCVZ .actor-man circle,#mermaid-svg-T9dsZVRdXrG9mCVZ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-T9dsZVRdXrG9mCVZ :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
logger.error("xxx")
匹配 root appender
转给 SentryAppender(ERROR级别命中)
封装为 Sentry Event(含堆栈、MDC上下文等)
HTTP POST 到 DSN 地址
存储、聚合为 Issue
检查 Alert Rules 是否满足
核心就三步:
<dependency>
<groupId>io.sentry</groupId>
<artifactId>sentry-logback</artifactId>
<version>7.6.0</version>
</dependency>
这是简单的配置
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!– 从系统环境变量读取,供下面的 appender 引用 –>
<springProperty scope="context" name="LOG_SENTRY_DSN" source="LOG_SENTRY_DSN" defaultValue=""/>
<springProperty scope="context" name="SENTRY_ENVIRONMENT" source="SENTRY_ENVIRONMENT" defaultValue="dev"/>
<!– ==================== 控制台输出 ==================== –>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} – %msg%n</pattern>
</encoder>
</appender>
<!– ==================== Sentry Appender ==================== –>
<appender name="SENTRY" class="io.sentry.logback.SentryAppender">
<options>
<dsn>${LOG_SENTRY_DSN}</dsn>
<environment>${SENTRY_ENVIRONMENT}</environment>
<debug>true</debug>
</options>
<minimumEventLevel>ERROR</minimumEventLevel>
<minimumBreadcrumbLevel>WARN</minimumBreadcrumbLevel>
</appender>
<!– 启动时打印 DSN 值,方便确认是否生效(调试用,确认后可删) –>
<statusListener class="ch.qos.logback.core.status.OnConsoleStatusListener" />
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="SENTRY" />
</root>
</configuration>
高级的配置可以这样
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!– ==================== 控制台输出 ==================== –>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} – %msg%n</pattern>
</encoder>
</appender>
<!– ==================== Sentry Appender ==================== –>
<!–
通过环境变量 LOG_SENTRY_DSN 传入 Sentry 的 DSN 地址。
如果不设置或为空,Sentry 不会初始化,不影响应用运行。
–>
<!– 从环境变量读取 Sentry 配置 –>
<springProperty scope="context" name="LOG_SENTRY_DSN" source="LOG_SENTRY_DSN" defaultValue=""/>
<springProperty scope="context" name="SENTRY_ENVIRONMENT" source="SENTRY_ENVIRONMENT" defaultValue="dev"/>
<springProperty scope="context" name="LOG_SENTRY_BREADCRUMB_LEVEL" source="LOG_SENTRY_BREADCRUMB_LEVEL" defaultValue="INFO"/>
<springProperty scope="context" name="LOG_SENTRY_EVENT_LEVEL" source="LOG_SENTRY_EVENT_LEVEL" defaultValue="ERROR"/>
<!– 新增:Sentry 高级配置 –>
<springProperty scope="context" name="SENTRY_TRACES_SAMPLE_RATE" source="SENTRY_TRACES_SAMPLE_RATE" defaultValue="1.0"/>
<springProperty scope="context" name="SENTRY_MAX_BREADCRUMBS" source="SENTRY_MAX_BREADCRUMBS" defaultValue="100"/>
<springProperty scope="context" name="SENTRY_SEND_DEFAULT_PII" source="SENTRY_SEND_DEFAULT_PII" defaultValue="false"/>
<appender name="SENTRY" class="io.sentry.logback.SentryAppender">
<options>
<dsn>${LOG_SENTRY_DSN}</dsn>
<environment>${SENTRY_ENVIRONMENT}</environment>
<tracesSampleRate>${SENTRY_TRACES_SAMPLE_RATE}</tracesSampleRate>
<maxBreadcrumbs>${SENTRY_MAX_BREADCRUMBS}</maxBreadcrumbs>
<sendDefaultPii>${SENTRY_SEND_DEFAULT_PII}</sendDefaultPii>
</options>
<minimumBreadcrumbLevel>${LOG_SENTRY_BREADCRUMB_LEVEL}</minimumBreadcrumbLevel>
<minimumEventLevel>${LOG_SENTRY_EVENT_LEVEL}</minimumEventLevel>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="SENTRY" />
</root>
</configuration>
SENTRY_ENVIRONMENT=dev
LOG_SENTRY_EVENT_LEVEL=ERROR
LOG_SENTRY_BREADCRUMB_LEVEL=DEBUG
SENTRY_TRACES_SAMPLE_RATE=1.0
SENTRY_MAX_BREADCRUMBS=100 //面包屑级别
SENTRY_SEND_DEFAULT_PII=false
3.2 测试项目:sentry-test-app
独立的 Spring Boot 测试项目 E:\\sentry-test-app,用来验证 Sentry 上报。
项目结构:
E:\\sentry-test-app\\
├── pom.xml — Spring Boot 3.2 + sentry-logback 7.6.0
├── src/main/java/com/example/sentrytest/
│ ├── SentryTestApplication.java — 启动类
│ └── controller/
│ └── TestController.java — 5 个测试接口
└── src/main/resources/
├── application.yml — 端口 8080
└── logback-spring.xml — CONSOLE + SENTRY Appender
关键文件说明:
pom.xml – 只需要两个依赖:
<!– Web –>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!– Sentry + Logback 集成 –>
<dependency>
<groupId>io.sentry</groupId>
<artifactId>sentry-logback</artifactId>
<version>7.6.0</version>
</dependency>
logback-spring.xml – 核心配置:
<springProperty scope="context" name="LOG_SENTRY_DSN" source="LOG_SENTRY_DSN" defaultValue=""/>
<springProperty scope="context" name="SENTRY_ENVIRONMENT" source="SENTRY_ENVIRONMENT" defaultValue="dev"/>
<!– ==================== Sentry Appender ==================== –>
<appender name="SENTRY" class="io.sentry.logback.SentryAppender">
<options>
<dsn>${LOG_SENTRY_DSN}</dsn>
<environment>${SENTRY_ENVIRONMENT}</environment>
<debug>true</debug>
</options>
<minimumEventLevel>ERROR</minimumEventLevel>
<minimumBreadcrumbLevel>WARN</minimumBreadcrumbLevel>
</appender>
<!– 启动时打印 DSN 值,方便确认是否生效(调试用,确认后可删) –>
<statusListener class="ch.qos.logback.core.status.OnConsoleStatusListener" />
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="SENTRY" />
</root>
如果 LOG_SENTRY_DSN 不设置或为空,Sentry Appender 不会初始化,不影响应用正常运行。
测试接口:
| GET /test/ok | 正常 INFO 日志 | Sentry 不捕获 |
| GET /test/warn | WARN 日志 | 只记为 Breadcrumb |
| GET /test/error | ERROR 日志 | 上报为 Sentry Event,出现在 Issues |
| GET /test/exception | 抛未捕获异常 | 上报完整堆栈到 Sentry |
| GET /test/business-error?code=CNY | 模拟业务异常 | 上报带堆栈的 ERROR |
3.3 运行和验证
cd E:\\sentry-test-app
# 设置 DSN(替换为你在 sentry.io 创建项目后拿到的 DSN)
set LOG_SENTRY_DSN=https://你的DSN@xxx.ingest.us.sentry.io/xxx
# 编译运行
mvn spring-boot:run
在项目的环境变量设置也行,然后启动项目


验证步骤:


四、Sentry 配置告警规则 + Webhook
这部分全在 Sentry 网页端 操作,不改项目代码。
4.1 启用 WebHooks 集成

填写:
- Name:Feishu Relay
- Webhook URL:http://你的服务器IP:8090/api/sentry/webhook(中继服务地址,暂时可以先填 https://webhook.site 生成的临时 URL)
- Permissions 部分:Issue & Event → 选 Read
- 勾选 Alert Rule Action(这样在告警规则里就能选这个 Integration 了)

保存
4.2 创建告警规则

- When:选 “A new issue is created”(首次出现新异常就告警)
- 或者选 “The issue is seen more than {value} times in {interval}”(N 分钟内出现超过 X 次)
- 示例:出现超过 2 次 in 1 hour
- 如果你设了 APP_ENV,可以加条件 “The event’s environment is Prod”
- 可以按 Issue 级别过滤:“The event’s level is equal to error”
- 选 “Send a notification via an integration”
- Integration 选 WebHooks
- 这会把告警发到你在 4.1 步骤填的 Callback URL
- 建议选 “30 minutes” 或 “1 hour”,避免同一 Issue 反复发告警刷屏


4.3 Sentry Webhook 发出的 payload 长什么样
当告警触发时,Sentry 会向你的 Callback URL 发一个 HTTP POST,body 是 JSON:
{
"action": "triggered",
"data": {
"issue": {
"id": "90175418",
"title": "Unsupported currency codeCNY",
"culprit": "com.redteamobile.einstein.services.DataPlanServiceImpl in getCurrencyRate",
"permalink": "https://redteamobile-kz.sentry.io/issues/90175418/",
"project": {
"name": "einstein",
"slug": "einstein"
},
"status": "unresolved",
"count": "2",
"metadata": {
"type": "UnsupportedOperationException",
"value": "Unsupported currency codeCNY"
}
},
"triggered_rule": "通知飞书-所有新异常"
},
"actor": { "type": "application", "name": "Sentry" }
}
当 Issue 被 resolve(手动或自动标记为已解决)时,会发:
{
"action": "resolved",
"data": {
"issue": { … 同上 … }
}
}
中继服务只需要从这个 JSON 里取出关键字段,转成飞书格式即可。
五、创建飞书自定义机器人
- 可以开启「签名校验」,会给你一个 secret,中继服务发消息时需要带签名
- 自学阶段可以先不开,直接用即可
curl -X POST -H "Content-Type: application/json" -d "{\\"msg_type\\":\\"text\\",\\"content\\":{\\"text\\":\\"Hello from curl!\\"}}" https://open.feishu.cn/open-apis/bot/v2/hook/你的token
群里应该能看到机器人发出 “Hello from curl!”
六、中继服务实现(新建项目)
中继服务地址
5.1 项目结构
新建一个独立的 Spring Boot 项目 sentry-feishu-relay:
sentry-feishu-relay/
src/main/java/com/example/relay/
RelayApplication.java — Spring Boot 启动类
controller/
SentryWebhookController.java — 接收 Sentry Webhook
service/
FeishuNotifyService.java — 发送飞书消息
model/
SentryPayload.java — Sentry Webhook 请求体模型
FeishuMessage.java — 飞书消息模型
config/
RelayConfig.java — 配置类(飞书 Webhook URL 等)
src/main/resources/
application.yml — 配置文件
Dockerfile — Docker 构建
pom.xml — Maven 依赖
5.2 中继服务详细工作流程
中继服务做的事情就是三步:接收 Sentry Webhook → 解析关键字段 → 拼成飞书消息发出去。下面详细说明每一步。
飞书群
飞书机器人 Webhook
中继服务 (Spring Boot)
Sentry 服务端
飞书群
飞书机器人 Webhook
中继服务 (Spring Boot)
Sentry 服务端
#mermaid-svg-LaoFP8ZQlWvq8G6N{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LaoFP8ZQlWvq8G6N .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LaoFP8ZQlWvq8G6N .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LaoFP8ZQlWvq8G6N .error-icon{fill:#552222;}#mermaid-svg-LaoFP8ZQlWvq8G6N .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LaoFP8ZQlWvq8G6N .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LaoFP8ZQlWvq8G6N .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LaoFP8ZQlWvq8G6N .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LaoFP8ZQlWvq8G6N .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LaoFP8ZQlWvq8G6N .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LaoFP8ZQlWvq8G6N .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LaoFP8ZQlWvq8G6N .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LaoFP8ZQlWvq8G6N .marker.cross{stroke:#333333;}#mermaid-svg-LaoFP8ZQlWvq8G6N svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LaoFP8ZQlWvq8G6N p{margin:0;}#mermaid-svg-LaoFP8ZQlWvq8G6N .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LaoFP8ZQlWvq8G6N text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-LaoFP8ZQlWvq8G6N .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-LaoFP8ZQlWvq8G6N .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-LaoFP8ZQlWvq8G6N .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-LaoFP8ZQlWvq8G6N .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-LaoFP8ZQlWvq8G6N #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-LaoFP8ZQlWvq8G6N .sequenceNumber{fill:white;}#mermaid-svg-LaoFP8ZQlWvq8G6N #sequencenumber{fill:#333;}#mermaid-svg-LaoFP8ZQlWvq8G6N #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-LaoFP8ZQlWvq8G6N .messageText{fill:#333;stroke:none;}#mermaid-svg-LaoFP8ZQlWvq8G6N .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LaoFP8ZQlWvq8G6N .labelText,#mermaid-svg-LaoFP8ZQlWvq8G6N .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-LaoFP8ZQlWvq8G6N .loopText,#mermaid-svg-LaoFP8ZQlWvq8G6N .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-LaoFP8ZQlWvq8G6N .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-LaoFP8ZQlWvq8G6N .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-LaoFP8ZQlWvq8G6N .noteText,#mermaid-svg-LaoFP8ZQlWvq8G6N .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-LaoFP8ZQlWvq8G6N .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LaoFP8ZQlWvq8G6N .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LaoFP8ZQlWvq8G6N .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LaoFP8ZQlWvq8G6N .actorPopupMenu{position:absolute;}#mermaid-svg-LaoFP8ZQlWvq8G6N .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-LaoFP8ZQlWvq8G6N .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LaoFP8ZQlWvq8G6N .actor-man circle,#mermaid-svg-LaoFP8ZQlWvq8G6N line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-LaoFP8ZQlWvq8G6N :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
1. POST /api/sentry/webhook(JSON payload)
2. Jackson 反序列化为 SentryPayload 对象
3. 从 payload 提取 action、title、issueId、permalink 等
4. 拼装飞书卡片消息 JSON
5. POST 飞书 Webhook URL(卡片 JSON)
6. 群里显示告警卡片
7. 返回 200 ok
第一步:接收 Sentry Webhook
Sentry 告警触发时,会向你配置的 Webhook URL 发一个 HTTP POST 请求,Content-Type 是 application/json。
中继服务用 Spring Boot 的 @PostMapping 接收,Spring 自动用 Jackson 把 JSON body 反序列化为 Java 对象。
实际收到的 Sentry Internal Integration payload 结构(简化版):
{
"action": "triggered",
"installation": { "uuid": "xxx" },
"data": {
"event": {
"event_id": "45c244dbdf504451…",
"title": "ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer",
"culprit": "com.example.sentrytest.controller.TestController in classCast",
"web_url": "https://sentry.io/organizations/xxx/issues/7302285707/events/45c244db…/",
"issue_id": "7302285707",
"project": 4510972645933056,
"metadata": {
"type": "ClassCastException",
"value": "class java.lang.String cannot be cast to class java.lang.Integer"
}
},
"triggered_rule": "菜头-通知飞书-所有新异常"
},
"actor": { "type": "application", "name": "Sentry" }
}
注意: Sentry 有两种 Webhook 方式,payload 结构不同:
- WebHooks Plugin(旧版插件):issue 信息在 data.issue 里
- Internal Integration(新版,我们用的这种):issue 信息在 data.event 里,而且多了 installation 字段
中继服务必须兼容两种格式。
第二步:解析关键字段
不需要解析整个 payload(里面有巨大的堆栈信息),只需要提取这几个字段:
| action | 根节点 | "triggered"(告警触发)或 "resolved"(告警解决) |
| title | data.event.title | 异常的完整描述,如 "ClassCastException: …" |
| issue_id | data.event.issue_id | Sentry Issue 的唯一 ID |
| web_url | data.event.web_url | 可以直接点击跳转到 Sentry Issue 详情页 |
| culprit | data.event.culprit | 出错的类和方法,如 "TestController in classCast" |
| triggered_rule | data.triggered_rule | 触发的告警规则名称 |
解析代码的核心逻辑:
// SentryPayload 模型用 @JsonIgnoreProperties(ignoreUnknown = true)
// 这样 Sentry payload 里多余的字段不会报错
// 先尝试从 data.issue 取(旧版 WebHooks Plugin 格式)
Map<String, Object> issue = payload.getIssue();
if (issue != null) {
title = issue.get("title");
issueId = issue.get("id");
permalink = issue.get("permalink");
} else {
// 再从 data.event 取(Internal Integration 格式)
Map<String, Object> event = payload.getEvent();
title = event.get("title");
issueId = event.get("issue_id");
permalink = event.get("web_url");
}
第三步:拼装飞书消息并发送
飞书机器人 Webhook 要求的 JSON 格式和 Sentry 完全不同。飞书支持两种消息类型:
简单文本消息(msg_type: text):
{
"msg_type": "text",
"content": {
"text": "einstein->告警触发\\n●环境: Prod\\n●ID: 7302285707\\n●问题: ClassCastException…"
}
}
交互式卡片消息(msg_type: interactive,我们用的这种,更美观):
{
"msg_type": "interactive",
"card": {
"header": {
"title": { "tag": "plain_text", "content": "einstein->告警触发" },
"template": "red"
},
"elements": [
{ "tag": "markdown", "content": "**环境:** Prod" },
{ "tag": "markdown", "content": "**ID:** 7302285707" },
{ "tag": "markdown", "content": "**问题:** ClassCastException: …" },
{ "tag": "markdown", "content": "**告警规则:** 菜头-通知飞书-所有新异常" },
{ "tag": "hr" },
{
"tag": "action",
"actions": [{
"tag": "button",
"text": { "tag": "plain_text", "content": "查看详情" },
"url": "https://sentry.io/organizations/xxx/issues/7302285707/…",
"type": "primary"
}]
}
]
}
}
卡片消息的效果:红色头部表示告警触发(绿色表示告警解决),下面是结构化的字段,底部有“查看详情”按钮可以直接跳转到 Sentry。
最后用 RestTemplate 把这个 JSON POST 到飞书的 Webhook URL:
restTemplate.postForEntity(feishuWebhookUrl, message, String.class);
// 飞书返回 {"StatusCode":0,"StatusMessage":"success"} 就表示发送成功
实践中踩过的坑
| Jackson 反序列化报错 Unrecognized field "installation" | Sentry Internal Integration 的 payload 比旧版多了字段 | 模型类加 @JsonIgnoreProperties(ignoreUnknown = true) |
| 解析出来 issue 为 null | Internal Integration 的 issue 信息在 data.event 而不是 data.issue | Controller 兼容两种格式,先找 issue 再找 event |
| Docker 容器里中文乱码 | Alpine 镜像默认不是 UTF-8 | Dockerfile 加 ENV LANG=C.UTF-8,启动参数加 -Dfile.encoding=UTF-8 |
| RestTemplate 发飞书中文乱码 | StringHttpMessageConverter 默认 ISO-8859-1 | 配置 RestTemplate 的 StringHttpMessageConverter 编码为 UTF-8 |
| 告警规则不重复触发 | Sentry 的 Action Interval 防刷间隔,同一 Issue 在间隔内不会重复发 | 等待间隔过,或触发一个全新类型的异常 |
5.3 核心代码
SentryWebhookController.java – 接收和解析:
@RestController
@RequestMapping("/api/sentry")
@JsonIgnoreProperties(ignoreUnknown = true) // payload 模型类上加这个
public class SentryWebhookController {
private final FeishuNotifyService feishuNotifyService;
@PostMapping("/webhook")
public ResponseEntity<String> handleWebhook(@RequestBody SentryPayload payload) {
String action = payload.getAction(); // "triggered" / "resolved"
// 兼容两种格式:先尝试 data.issue,再尝试 data.event
Map<String, Object> issue = payload.getIssue();
if (issue != null) {
// 旧版 WebHooks Plugin 格式
issueId = issue.get("id");
title = issue.get("title");
permalink = issue.get("permalink");
} else {
// Internal Integration 格式
Map<String, Object> event = payload.getEvent();
title = event.get("title");
issueId = event.get("issue_id");
permalink = event.get("web_url");
}
feishuNotifyService.sendAlert(action, issueId, title, permalink, ...);
return ResponseEntity.ok("ok");
}
}
FeishuNotifyService.java – 拼成飞书卡片并发送:
@Service
public class FeishuNotifyService {
private final RelayConfig relayConfig;
private final RestTemplate restTemplate;
public void sendAlert(String action, String issueId, String title,
String permalink, String count, String projectName,
String triggeredRule) {
String statusLabel = "resolved".equals(action) ? "告警解决" : "告警触发";
// 构建飞书卡片 JSON(红色头部=告警触发,绿色=告警解决)
Map<String, Object> card = buildCard(statusLabel, issueId, title,
permalink, count, projectName, triggeredRule);
FeishuMessage message = FeishuMessage.card(card);
// POST 到飞书 Webhook
var response = restTemplate.postForEntity(webhookUrl, message, String.class);
// 飞书返回 {"StatusCode":0,"msg":"success"} 表示成功
}
}
application.yml:
server:
port: 8090
feishu:
webhook:
url: ${FEISHU_WEBHOOK_URL:https://open.feishu.cn/open–apis/bot/v2/hook/your–token}
5.3 pom.xml 关键依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
5.4 Dockerfile
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/sentry-feishu-relay-*.jar app.jar
EXPOSE 8090
ENTRYPOINT ["java", "-jar", "app.jar"]
5.5 打包、Docker 构建和运行
cd E:\\sentry-feishu-relay
mvn clean package -DskipTests
docker build -t sentry-feishu-relay .
docker run -d –name sentry-relay -p 8090:8090 -e FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/你的token sentry-feishu-relay
第一步:打包 jar
如果本机没有安装 Maven 的情况下,用 IDEA 自带的 Maven 打包:
方式 A:IDEA 图形界面打包(推荐)

方式 B:命令行(用 IDEA 自带的 Maven)
如果命令行 mvn 提示不是命令,可以用 IDEA 内置 Maven 的完整路径:
cd E:\\sentry-feishu-relay
"D:\\IntelliJ IDEA Community Edition 2025.2.5\\plugins\\maven\\lib\\maven3\\bin\\mvn.cmd" clean package -DskipTests
路径根据你的 IDEA 实际安装目录调整。
方式 C:安装 Maven 到系统 PATH
下载 Maven:https://maven.apache.org/download.cgi ,解压后把 bin 目录加到系统环境变量 PATH 里,之后就可以直接用 mvn 命令了。
第二步:构建 Docker 镜像
cd E:\\sentry-feishu-relay
docker build -t sentry-feishu-relay .

第三步:运行容器
docker run -d \\
–name sentry-relay \\
-p 8090:8090 \\
-e FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/你的飞书token \\
sentry-feishu-relay
Windows CMD 如果不支持 \\ 换行,写成一行:
docker run -d –name sentry-relay -p 8090:8090 -e FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/你的飞书token sentry-feishu-relay

第四步:验证中继是否正常
# 健康检查
curl http://localhost:8090/api/sentry/webhook
# 应该返回 ok
# 模拟 Sentry Webhook,验证飞书群能否收到消息
curl -X POST http://localhost:8090/api/sentry/webhook -H "Content-Type: application/json" -d "{\\"action\\":\\"triggered\\",\\"data\\":{\\"issue\\":{\\"id\\":\\"90175418\\",\\"title\\":\\"Unsupported currency codeCNY\\",\\"permalink\\":\\"https://sentry.io/issues/90175418/\\",\\"project\\":{\\"name\\":\\"einstein\\"},\\"count\\":\\"2\\"},\\"triggered_rule\\":\\"test-rule\\"}}"



第五步:让 Sentry 能访问到中继(ngrok 内网穿透)
Sentry SaaS 在公网,访问不到你的 localhost。用 ngrok 临时暴露本地端口:


2.运行:
ngrok http 8090

在 Sentry 的 Webhook URL 里填:https://abc123.ngrok-free.app/api/sentry/webhook

注意:ngrok 免费版每次重启地址会变,需要重新去 Sentry 改 Webhook URL。正式环境应该用有公网 IP 的服务器。
常用 Docker 管理命令
# 查看容器日志
docker logs sentry-relay
# 停止容器
docker stop sentry-relay
# 重新启动
docker start sentry-relay
# 删除容器(需要先 stop)
docker rm sentry-relay
# 更新代码后重新构建部署
docker stop sentry-relay && docker rm sentry-relay
docker build -t sentry-feishu-relay .
docker run -d –name sentry-relay -p 8090:8090 -e FEISHU_WEBHOOK_URL=你的飞书URL sentry-feishu-relay
七、端到端测试
curl -X POST -H "Content-Type: application/json" -d "{\\"msg_type\\":\\"text\\",\\"content\\":{\\"text\\":\\"Hello from curl!\\"}}" https://open.feishu.cn/open-apis/bot/v2/hook/00867813-cf24-4942-8b7c-61b9ee8c08aa

curl -X POST http://localhost:8090/api/sentry/webhook -H "Content-Type: application/json" -d "{\\"action\\":\\"triggered\\",\\"data\\":{\\"issue\\":{\\"id\\":\\"90175418\\",\\"title\\":\\"Unsupported currency codeCNY\\",\\"permalink\\":\\"https://sentry.io/issues/90175418/\\",\\"project\\":{\\"name\\":\\"einstein\\"},\\"count\\":\\"2\\"},\\"triggered_rule\\":\\"test-rule\\"}}"






八、进阶:@ 指定人 & 告警解决通知
前面的基本流程只区分了「告警触发(红色卡片)」一种情况。实际生产中还需要:
下面分别说明原理和实现。
8.1 飞书卡片 @ 人的语法
飞书的 interactive 卡片消息 中,markdown 类型元素支持 <at> 标签:
| <at id=ou_xxxxxx>张三</at> | @ 指定用户(需要用 open_id) |
| <at id=all>所有人</at> | @ 所有人 |
open_id 怎么拿?
飞书开放平台和管理后台(需要权限)是两个东西,开放平台任何人都能访问:
1. 用你的飞书账号登录 https://open.feishu.cn
2. 点 创建企业自建应用(名字随便取,比如"查ID工具",不需要发布)
3. 进入应用 → 凭证与基础信息 → 记下 App ID 和 App Secret
4. 进入 权限管理 → 搜索 contact:user.base:readonly → 开通
5. 打开 API 调试台 → 选你的应用 → 用 用户身份 (user_access_token) 调用「获取登录用户信息」接口
6. 返回结果里就有你自己的 open_id
> 这个应用不需要管理员审批就能在调试台里使用,这里就不放截图了,步骤很详细,有不懂的可以问ai或博客哈
拼装到卡片 JSON 中的位置:
{
"tag": "markdown",
"content": "**通知:** <at id=ou_abc123>张三</at> <at id=ou_def456>李四</at>"
}
这个元素放在卡片的 elements 数组中即可。
8.2 告警解决通知
Sentry 在 Issue 被标记为 Resolved(手动或自动)时,同样会发 Webhook,区别在于 action 字段:
| "triggered" | 新告警 / 再次触发 |
| "resolved" | 问题已被解决 |
中继服务只需要根据 action 字段做不同处理:
- triggered → 红色卡片头 + 显示触发次数 + @ 负责人
- resolved → 绿色卡片头 + 显示「已解决」+ 不 @ 人(免打扰)
Sentry Issue 触发 → action="triggered" → 🔴 红色卡片 + @ 人
Sentry Issue 手动 Resolve → action="resolved" → 🟢 绿色卡片(不 @)
8.3 配置方式
在 application.yml 中配置 @ 人员,支持全局和按项目两种粒度:
feishu:
webhook:
url: ${FEISHU_WEBHOOK_URL:https://open.feishu.cn/…} # 改为你的token
at:
enabled: true # 是否启用 @ 功能
all: false # true = @ 所有人(优先级最高)
open-ids: # 全局兜底的 open_id 列表
– ou_global_001
– ou_global_002
project-map: # 按项目名 @ 不同的人
einstein: # Sentry 项目名(小写)
– ou_abc123 # 负责人 A
– ou_def456 # 负责人 B
newton:
– ou_ghi789
sentry-test-app:
– ou_test001
匹配优先级(从高到低):
1. at.all=true → 直接 @所有人,忽略其他配置
2. at.project-map[项目名] → @ 该项目对应的负责人
3. at.open-ids → 项目没有专属配置时,走全局兜底列表
4. 都没配 → 不 @ 任何人
project-map 中的 key 就是 Sentry 上报时的项目名(如 einstein、newton), 中继会自动用小写匹配,大小写不敏感。
也可以通过环境变量覆盖(Docker 部署时):
docker run -d –name sentry-relay \\
-p 8090:8090 \\
-e FEISHU_WEBHOOK_URL=你的飞书URL \\
-e FEISHU_AT_ENABLED=true \\
-e FEISHU_AT_ALL=false \\
-e FEISHU_AT_OPEN_IDS=ou_abc123,ou_def456 \\
sentry-feishu-relay
⚠️ project-map 是嵌套结构,通过环境变量配置较复杂,建议直接写在 application.yml 中。 如果一定要用环境变量,格式为: FEISHU_AT_PROJECTMAP_EINSTEIN=ou_abc123,ou_def456(Spring Boot 宽松绑定)
8.4 核心代码改动
RelayConfig.java — At 类新增 projectMap 字段:
public static class At {
private boolean enabled = false;
private boolean all = false;
private List<String> openIds = Collections.emptyList();
private Map<String, List<String>> projectMap = new HashMap<>();
// getter/setter …
}
Spring Boot 会自动把 YAML 里的 project-map 绑定到 Map<String, List<String>>。
FeishuNotifyService.java — buildAtText 增加项目名参数,按优先级匹配:
private String buildAtText(boolean resolved, String projectName) {
RelayConfig.At atConfig = relayConfig.getAt();
if (!atConfig.isEnabled() || resolved) {
return null;
}
// 优先级 1:@ 所有人
if (atConfig.isAll()) {
return "**通知:** <at id=all>所有人</at>";
}
// 优先级 2:按项目名查 project-map
Map<String, List<String>> projectMap = atConfig.getProjectMap();
List<String> ids = null;
if (projectMap != null && projectName != null) {
ids = projectMap.get(projectName.toLowerCase()); // 小写匹配
if (ids == null) {
ids = projectMap.get(projectName); // 原始大小写兜底
}
}
// 优先级 3:全局 open-ids 兜底
if (ids == null || ids.isEmpty()) {
ids = atConfig.getOpenIds();
}
if (ids == null || ids.isEmpty()) {
return null;
}
String mentions = ids.stream()
.filter(id -> id != null && !id.isBlank())
.map(id -> "<at id=" + id.trim() + "></at>")
.collect(Collectors.joining(" "));
return mentions.isEmpty() ? null : "**通知:** " + mentions;
}
调用处改动(buildCard 方法中):
// 之前:buildAtText(resolved)
// 现在:buildAtText(resolved, projectName) ← 多传了项目名
String atText = buildAtText(resolved, projectName);
8.5 两种卡片效果对比
告警触发(红色 + 按项目 @人):
假设 project-map 配置了 ein: [ou_abc, ou_def],当 ein 项目报错:
┌──────────────────────────────────────┐
│ 🔴 ein → 告警触发 🔥 │
├──────────────────────────────────────┤
│ 环境: Prod │
│ ID: 90175418 │
│ 问题: NullPointerException at … │
│ 问题触发次数: 3 │
│ 告警规则: Send to relay │
│ ─────────────────────────────────── │
│ 通知: @张三 @李四 ← 只 @ einstein │
│ ─────────────────────────────────── │ 的负责人
│ [查看详情] │
└──────────────────────────────────────┘
当 news 项目报错(假设 news: [ou_ghi]):
┌──────────────────────────────────────┐
│ 🔴 news → 告警触发 🔥 │
├──────────────────────────────────────┤
│ … │
│ ─────────────────────────────────── │
│ 通知: @王五 ← 只 @ news │
│ ─────────────────────────────────── │ 的负责人
│ [查看详情] │
└──────────────────────────────────────┘
如果某个项目没有在 project-map 中配置,则走全局 open-ids 兜底。
告警解决(绿色,不 @):
┌──────────────────────────────────────┐
│ 🟢 sentry-test-app → 告警解决 ✅ │
├──────────────────────────────────────┤
│ 环境: Prod │
│ ID: 90175418 │
│ 问题: NullPointerException at … │
│ 状态: 该问题已被标记为解决 │
│ ─────────────────────────────────── │
│ [查看Issue] │
└──────────────────────────────────────┘
8.6 测试 @ 人和解决通知
1. curl 模拟告警触发(带 @):
先在 application.yml 开启 at:
feishu:
at:
enabled: true
all: true
重启中继后:
curl -X POST http://localhost:8090/api/sentry/webhook -H "Content-Type: application/json" -d "{\\"action\\":\\"triggered\\",\\"data\\":{\\"issue\\":{\\"id\\":\\"90175418\\",\\"title\\":\\"Unsupported currency codeCNY\\",\\"permalink\\":\\"https://sentry.io/issues/90175418/\\",\\"project\\":{\\"name\\":\\"einstein\\"},\\"count\\":\\"2\\"},\\"triggered_rule\\":\\"test-rule\\"}}"
飞书群应收到红色卡片 + “通知: @所有人”。

2. curl 模拟告警解决:
curl -X POST http://localhost:8090/api/sentry/webhook -H "Content-Type: application/json" -d "{\\"action\\":\\"resolved\\",\\"data\\":{\\"issue\\":{\\"id\\":\\"123\\",\\"title\\":\\"Testresolve\\",\\"permalink\\":\\"https://sentry.io\\",\\"project\\":{\\"name\\":\\"demo\\"},\\"count\\":\\"0\\"}}}"
飞书群应收到绿色卡片 + “该问题已被标记为解决”,且不 @ 任何人。

3. Sentry 真实触发 resolved:
在 Sentry 控制台找到一个 Issue → 点击 Resolve 按钮 → Sentry 会向你配置的 Webhook URL 发送 action="resolved" 的请求 → 中继转发绿色卡片到飞书。


⚠️ Sentry 的告警规则需要包含 resolved 条件才会发 webhook。检查方法: Sentry → Alerts → 编辑规则 → 确认 “When” 条件里包含 A new issue is created 和 An existing issue changes state from resolved to unresolved 等。
8.7 进阶(可选)
- 签名校验:Sentry Webhook 支持配 secret,中继服务可以校验请求签名防伪造;飞书机器人也支持签名校验
- 消息模板化:将卡片结构抽成 JSON 模板文件,运行时填充变量,方便非开发人员修改样式
- 按环境区分:在 application.yml 中配置环境名,卡片中的"环境"字段从配置读取而不是写死 Prod


