欢迎光临
我们一直在努力

「企业项目集成Sentry 告警 进 飞书群聊:中继方案与实现」!!!附全部源码!!!

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(推荐,免费,自学用)

  • 打开 https://sentry.io → Sign Up(GitHub / Google 登录即可)
  • 创建 Organization,我登录的时候已经填写组织,这里自动创建了 image-20260228151551156
  • 创建 Project:
    • 点 “Create Project”

    • Platform 选 Java(或搜索 “Logback”,选 Java – Logback)

    • 点 “Create Project” 在这里插入图片描述

  • 创建完成后,页面会显示 DSN,形如: https://abc123def456@o123456.ingest.sentry.io/789012 这就是 LOG_SENTRY_DSN 的值。复制保存下来
  • 在这里插入图片描述

    或者

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

    在这里插入图片描述

    在这里插入图片描述

  • 免费版:每月 5,000 事件额度,自学足够
  • 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 是否满足

    核心就三步:

  • 依赖:项目引入 sentry-logback
  • <dependency>
    <groupId>io.sentry</groupId>
    <artifactId>sentry-logback</artifactId>
    <version>7.6.0</version>
    </dependency>

  • logback 配置:加一个 SentryAppender,通过环境变量 LOG_SENTRY_DSN 读取 DSN
  • 这是简单的配置

    <?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

  • 环境变量:启动时设 LOG_SENTRY_DSN=你的DSN,不设就不上报、不影响运行
  • 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 不会初始化,不影响应用正常运行。

    测试接口:

    接口作用Sentry 效果
    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

    在项目的环境变量设置也行,然后启动项目

    在这里插入图片描述

    在这里插入图片描述

    验证步骤:

  • 访问 http://localhost:8080/test/error → 页面返回 “ERROR logged”
  • 访问 http://localhost:8080/test/business-error?code=CNY → 页面返回 “Business error logged”
  • 打开 sentry.io → 你的项目 → Issues → 应该能看到两条新 Issue
  • 点进 Issue 可以看到完整的堆栈、环境信息(environment=dev)
  • 在这里插入图片描述

    在这里插入图片描述


    四、Sentry 配置告警规则 + Webhook

    这部分全在 Sentry 网页端 操作,不改项目代码。

    4.1 启用 WebHooks 集成

  • 登录 sentry.io → 你的项目
  • 左侧 Settings → Integrations
  • 搜索 “WebHooks” → 点 Enable Plugin 或 Configure
  • 在这里插入图片描述

    填写:

    • 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 创建告警规则

  • 左侧 Alerts → Create Alert Rule → 选 Issues(Issue Alert)
  • 在这里插入图片描述

  • Set Conditions(触发条件):
    • When:选 “A new issue is created”(首次出现新异常就告警)
    • 或者选 “The issue is seen more than {value} times in {interval}”(N 分钟内出现超过 X 次)
    • 示例:出现超过 2 次 in 1 hour
  • Set Filters(可选过滤):
    • 如果你设了 APP_ENV,可以加条件 “The event’s environment is Prod”
    • 可以按 Issue 级别过滤:“The event’s level is equal to error”
  • Set Actions(触发动作):
    • 选 “Send a notification via an integration”
    • Integration 选 WebHooks
    • 这会把告警发到你在 4.1 步骤填的 Callback URL
  • Action Interval(防刷间隔):
    • 建议选 “30 minutes” 或 “1 hour”,避免同一 Issue 反复发告警刷屏
  • Rule Name:起个名字,如 “通知飞书-所有新异常”
  • 点 Save Rule
  • 在这里插入图片描述

    在这里插入图片描述

    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 里取出关键字段,转成飞书格式即可。


    五、创建飞书自定义机器人

  • 打开飞书 → 创建一个测试群(或用现有群)
  • 群设置 → 群机器人 → 添加机器人 → 自定义机器人
  • 名称填 “Sentry告警” → 完成
  • 记录生成的 Webhook 地址,形如: https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
  • 安全设置(可选但推荐):
    • 可以开启「签名校验」,会给你一个 secret,中继服务发消息时需要带签名
    • 自学阶段可以先不开,直接用即可
  • 用 curl 验证机器人是否正常:
  • 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/openapis/bot/v2/hook/yourtoken}

    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 图形界面打包(推荐)

  • IDEA 打开 E:\\sentry-feishu-relay 项目
  • 右侧边栏点 Maven 面板(如果没有,菜单 View → Tool Windows → Maven)
  • 展开 Lifecycle
  • 先双击 clean,等执行完
  • 再双击 package(会自动编译 + 打包)
  • 完成后在 E:\\sentry-feishu-relay\\target\\ 下会生成 sentry-feishu-relay-1.0.0.jar
  • 在这里插入图片描述

    方式 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 临时暴露本地端口:

  • 注册 https://ngrok.com (免费),下载 ngrok
  • 在这里插入图片描述

    在这里插入图片描述

    2.运行:

    ngrok http 8090

  • 会输出类似:https://abc123.ngrok-free.app -> http://localhost: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


    七、端到端测试

  • 测试飞书:用 Postman 或 curl 直接 POST 飞书 Webhook,确认群里能收到消息
  • 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 模拟 Sentry Webhook,POST 到 http://localhost:8090/api/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 是否收到 → 看告警是否触发 → 看飞书群是否收到通知
  • 在这里插入图片描述

    在这里插入图片描述

    在这里插入图片描述

    在这里插入图片描述


    八、进阶:@ 指定人 & 告警解决通知

    前面的基本流程只区分了「告警触发(红色卡片)」一种情况。实际生产中还需要:

  • 告警触发时自动 @ 相关负责人
  • 问题解决后收到 绿色卡片,清楚地告知「问题已解决」
  • 下面分别说明原理和实现。


    8.1 飞书卡片 @ 人的语法

    飞书的 interactive 卡片消息 中,markdown 类型元素支持 <at> 标签:

    写法效果
    <at id=ou_xxxxxx>张三</at> @ 指定用户(需要用 open_id)
    <at id=all>所有人</at> @ 所有人

    open_id 怎么拿?

  • 打开飞书管理后台 → 通讯录 → 找到成员 → 复制 open_id(ou_ 开头的字符串)
  • 或者通过飞书 API GET /open-apis/contact/v3/users/:user_id 获取
  • 群机器人 webhook 不需要 token 就可以 @ 人,只要 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 字段:

    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
    赞(0)
    未经允许不得转载:171主机测评 » 「企业项目集成Sentry 告警 进 飞书群聊:中继方案与实现」!!!附全部源码!!!
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址