├── .github └── workflows │ └── tag-deployment.yml ├── .gitignore ├── README.md ├── README_EN.md ├── pom.xml └── src ├── main ├── java │ └── com │ │ └── nolimit35 │ │ └── springkit │ │ ├── annotation │ │ └── ExceptionNotify.java │ │ ├── aspect │ │ └── ExceptionNotificationAspect.java │ │ ├── config │ │ ├── EnvironmentPostProcessor.java │ │ ├── ExceptionNotifyAutoConfiguration.java │ │ └── ExceptionNotifyProperties.java │ │ ├── filter │ │ ├── DefaultExceptionFilter.java │ │ └── ExceptionFilter.java │ │ ├── formatter │ │ ├── DefaultNotificationFormatter.java │ │ └── NotificationFormatter.java │ │ ├── model │ │ ├── CodeAuthorInfo.java │ │ └── ExceptionInfo.java │ │ ├── monitor │ │ └── Monitor.java │ │ ├── notification │ │ ├── AbstractNotificationProvider.java │ │ ├── NotificationProvider.java │ │ ├── NotificationProviderManager.java │ │ └── provider │ │ │ ├── DingTalkNotificationProvider.java │ │ │ ├── FeishuNotificationProvider.java │ │ │ └── WeChatWorkNotificationProvider.java │ │ ├── service │ │ ├── AbstractGitSourceControlService.java │ │ ├── EnvironmentProvider.java │ │ ├── ExceptionAnalyzerService.java │ │ ├── ExceptionNotificationService.java │ │ ├── GitHubService.java │ │ ├── GitLabService.java │ │ ├── GitSourceControlService.java │ │ └── GiteeService.java │ │ └── trace │ │ ├── DefaultTraceInfoProvider.java │ │ └── TraceInfoProvider.java └── resources │ ├── META-INF │ └── spring.factories │ └── application-example.yaml └── test ├── java └── com │ └── nolimit35 │ └── springkit │ ├── notification │ └── provider │ │ ├── AllProvidersYamlTest.java │ │ ├── DingTalkNotificationProviderYamlTest.java │ │ ├── FeishuNotificationProviderYamlTest.java │ │ ├── TestApplication.java │ │ ├── WeChatWorkNotificationProviderYamlTest.java │ │ └── YamlConfigNotificationProviderTest.java │ ├── service │ ├── GitHubServiceTest.java │ └── GitLabServiceTest.java │ └── trace │ └── DefaultTraceInfoProviderTest.java └── resources └── application-test.yml /.github/workflows/tag-deployment.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to the Maven Central Repository 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | 11 | - name: Setup Java 12 | uses: actions/setup-java@v4 # Does also set up Maven and GPG 13 | with: 14 | distribution: 'temurin' # As good as any other, see: https://github.com/actions/setup-java#supported-distributions 15 | java-package: 'jdk' 16 | java-version: '1.8' 17 | check-latest: true 18 | server-id: 'central' # must match the serverId configured for the nexus-staging-maven-plugin 19 | server-username: OSSRH_USERNAME # Env var that holds your OSSRH user name 20 | server-password: OSSRH_PASSWORD # Env var that holds your OSSRH user pw 21 | gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} # Substituted with the value stored in the referenced secret 22 | gpg-passphrase: SIGN_KEY_PASS # Env var that holds the key's passphrase 23 | cache: 'maven' 24 | 25 | - name: Build & Deploy 26 | run: | 27 | # -U force updates just to make sure we are using latest dependencies 28 | # -B Batch mode (do not ask for user input), just in case 29 | # -P activate profile 30 | mvn -U -B clean deploy -Dmaven.test.skip=true 31 | env: 32 | SIGN_KEY_PASS: ${{ secrets.GPG_PASSPHRASE }} 33 | OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} 34 | OSSRH_PASSWORD: ${{ secrets.OSSRH_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | !**/src/main/**/target/ 4 | !**/src/test/**/target/ 5 | 6 | ### IntelliJ IDEA ### 7 | .idea/ 8 | 9 | ### Eclipse ### 10 | .apt_generated 11 | .classpath 12 | .factorypath 13 | .project 14 | .settings 15 | .springBeans 16 | .sts4-cache 17 | 18 | ### NetBeans ### 19 | /nbproject/private/ 20 | /nbbuild/ 21 | /dist/ 22 | /nbdist/ 23 | /.nb-gradle/ 24 | build/ 25 | !**/src/main/**/build/ 26 | !**/src/test/**/build/ 27 | 28 | ### VS Code ### 29 | .vscode/ 30 | 31 | ### Mac OS ### 32 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Exception-Notify 2 | 3 | [![Maven Central](https://img.shields.io/maven-central/v/com.nolimit35.springkit/exception-notify.svg)](https://search.maven.org/search?q=g:com.nolimit35.springkit%20AND%20a:exception-notify) 4 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 5 | 6 | [English](README_EN.md) | [简体中文](README.md) 7 | 8 | ## 简介 9 | 10 | Exception-Notify 是一个 Spring Boot Starter 组件,用于捕获 Spring Boot 应用中未处理的异常,并通过钉钉、飞书或企业微信实时告警通知。它能够自动分析异常堆栈信息,定位到异常发生的源代码文件和行号,并通过 GitHub、GitLab 或 Gitee API 获取代码提交者信息,最终将异常详情、TraceID 以及责任人信息发送到钉钉群、飞书群或企业微信群,实现异常的实时上报与全链路追踪。 11 | 12 | ## 功能特点 13 | 14 | - 基于 @AfterThrowing 自动捕获 Spring Boot 应用中未处理的异常 15 | - 分析异常堆栈,精确定位异常源码位置(文件名和行号) 16 | - 通过 GitHub API、GitLab API 或 Gitee API 的 Git Blame 功能获取代码提交者信息 17 | - 支持与分布式链路追踪系统集成,关联 TraceID 18 | - 支持通过钉钉机器人、飞书机器人和企业微信机器人实时推送异常告警 19 | - 支持腾讯云日志服务(CLS)的链路追踪 20 | - 零侵入式设计,仅需添加依赖和简单配置即可使用 21 | - 支持自定义告警模板和告警规则 22 | 23 | ## 快速开始 24 | 25 | ### 1. 添加依赖 26 | 27 | 在你的 Spring Boot 项目的 `pom.xml` 文件中添加以下依赖: 28 | 29 | ```xml 30 | 31 | com.nolimit35.springkit 32 | exception-notify 33 | 1.3.1-RELEASE 34 | 35 | ``` 36 | 37 | ### 2. 配置参数 38 | 39 | 在 `application.yml` 或 `application.properties` 中添加以下配置: 40 | 41 | ```yaml 42 | exception: 43 | notify: 44 | enabled: true # 是否启用异常通知功能 45 | dingtalk: 46 | webhook: https://oapi.dingtalk.com/robot/send?access_token=xxx # 钉钉机器人 Webhook 地址 47 | at: 48 | enabled: true # 是否启用@功能 49 | userIdMappingGitEmail: # 钉钉 用户id 与 git 提交邮箱的映射关系 50 | xxx: ['xxx@xx.com','xxxx@xx.com'] 51 | feishu: 52 | webhook: https://open.feishu.cn/open-apis/bot/v2/hook/xxx # 飞书机器人 Webhook 地址 53 | at: 54 | enabled: true # 是否启用@功能 55 | openIdMappingGitEmail: # 飞书 openid 与 git 提交邮箱的映射关系 56 | ou_xxxxxxxx: ['xxx@xx.com','xxxx@xx.com'] 57 | wechatwork: 58 | webhook: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx # 企业微信机器人 Webhook 地址 59 | at: 60 | enabled: true # 是否启用@功能 61 | userIdMappingGitEmail: # 企微 用户id 与 git 提交邮箱的映射关系 62 | # 企微用户 id 带有 @ 符号时,需要手动特殊处理成 [@] 63 | 'xxx[@]xx.com': ['xxx@xx.com','xxxx@xx.com'] 64 | # GitHub 配置 (与 GitLab、Gitee 配置互斥,只能选择其中一种) 65 | github: 66 | token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # GitHub 访问令牌 67 | repo-owner: your-github-username # GitHub 仓库所有者 68 | repo-name: your-repo-name # GitHub 仓库名称 69 | branch: master # GitHub 仓库分支 70 | # GitLab 配置 (与 GitHub、Gitee 配置互斥,只能选择其中一种) 71 | gitlab: 72 | token: glpat-xxxxxxxxxxxxxxxxxxxx # GitLab 访问令牌 73 | project-id: your-project-id-or-path # GitLab 项目 ID 或路径 74 | base-url: https://gitlab.com/api/v4 # GitLab API 基础 URL 75 | branch: master # GitLab 仓库分支 76 | # Gitee 配置 (与 GitHub、GitLab 配置互斥,只能选择其中一种) 77 | gitee: 78 | token: xxxxxxxxxxxxxxxxxxxxxxx # Gitee 访问令牌 79 | repo-owner: your-gitee-username # Gitee 仓库所有者 80 | repo-name: your-repo-name # Gitee 仓库名称 81 | branch: master # Gitee 仓库分支 82 | tencentcls: 83 | region: ap-guangzhou # 腾讯云日志服务(CLS)的地域 84 | topic-id: xxx-xxx-xxx # 腾讯云日志服务(CLS)的主题ID 85 | trace: 86 | enabled: true # 是否启用链路追踪 87 | header-name: X-Trace-Id # 链路追踪 ID 的请求头名称 88 | package-filter: 89 | enabled: false # 是否启用包名过滤 90 | include-packages: # 需要解析的包名列表 91 | - com.example.app 92 | - com.example.service 93 | notification: 94 | title-template: "【${appName}】异常告警" # 告警标题模板 95 | include-stacktrace: true # 是否包含完整堆栈信息 96 | max-stacktrace-lines: 10 # 堆栈信息最大行数 97 | environment: 98 | report-from: test,prod # 需要上报异常的环境列表,多个环境用逗号分隔 99 | 100 | # Spring 配置 101 | spring: 102 | # 应用名称,用于告警标题 103 | application: 104 | name: YourApplicationName 105 | # 当前环境配置,会自动用于确定异常通知的当前环境 106 | profiles: 107 | active: dev # 当前激活的环境配置 108 | ``` 109 | 110 | > **注意**:当前环境会自动从 Spring 的 `spring.profiles.active` 属性中读取,无需手动设置。只有在 `exception.notify.environment.report-from` 列表中的环境才会上报异常,默认只上报 test 和 prod 环境的异常。 111 | 112 | ### 3. 启动应用 113 | 114 | 启动你的 Spring Boot 应用,Exception-Notify 将自动注册全局异常处理器,捕获所有被 @Controller 或 @RestController 或 @ExceptionNotify 标记的类中未处理的异常并发送告警。 115 | 116 | ## 告警示例 117 | 118 | 当应用发生未处理的异常时,钉钉群或企业微信群将收到类似以下格式的告警消息: 119 | 120 | ``` 121 | 【服务名称】异常告警 122 | ------------------------------- 123 | 异常时间:2023-03-17 14:30:45 124 | 异常类型:java.lang.NullPointerException 125 | 异常描述:Cannot invoke "String.length()" because "str" is null 126 | 异常位置:com.example.service.UserService.processData(UserService.java:42) 127 | 当前环境:prod 128 | 代码提交者:张三 (zhangsan@example.com) 129 | 最后提交时间:2023-03-15 10:23:18 130 | TraceID:7b2d1e8f9c3a5b4d6e8f9c3a5b4d6e8f 131 | 云日志链路:https://console.cloud.tencent.com/cls/search?region=ap-guangzhou&topic_id=xxx-xxx-xxx&interactiveQueryBase64=xxxx 132 | ------------------------------- 133 | 堆栈信息: 134 | java.lang.NullPointerException: Cannot invoke "String.length()" because "str" is null 135 | at com.example.service.UserService.processData(UserService.java:42) 136 | at com.example.controller.UserController.getData(UserController.java:28) 137 | at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) 138 | ... 139 | 处理人: @张三 140 | ``` 141 | 142 | ## 高级配置 143 | 144 | ### 环境配置 145 | 146 | 你可以通过配置 `exception.notify.environment.report-from` 属性来指定哪些环境需要上报异常: 147 | 148 | ```yaml 149 | exception: 150 | notify: 151 | environment: 152 | report-from: dev,test,prod # 在开发、测试和生产环境都上报异常 153 | ``` 154 | 155 | 默认情况下,组件只会在 test 和 prod 环境上报异常,而在 dev 环境不上报。当前环境会自动从 Spring 的 `spring.profiles.active` 属性中读取。 156 | 157 | ### 包名过滤配置 158 | 159 | 你可以通过配置 `exception.notify.package-filter` 来控制异常堆栈分析时只关注特定包名下的代码: 160 | 161 | ```yaml 162 | exception: 163 | notify: 164 | package-filter: 165 | enabled: true # 启用包名过滤功能 166 | include-packages: # 需要解析的包名列表 167 | - com.example.app 168 | - com.example.service 169 | ``` 170 | 171 | 当启用包名过滤功能后,异常分析器会优先从指定的包名列表中寻找异常堆栈信息,这对于定位业务代码中的问题特别有用。如果没有找到匹配的堆栈信息,会使用原始的过滤逻辑。 172 | 173 | ### 腾讯云日志服务(CLS)集成 174 | 175 | 如果你使用了腾讯云日志服务(CLS),可以配置相关参数来在异常告警中添加云日志链路: 176 | 177 | ```yaml 178 | exception: 179 | notify: 180 | tencentcls: 181 | region: ap-guangzhou # 腾讯云日志服务(CLS)的地域 182 | topic-id: xxx-xxx-xxx # 腾讯云日志服务(CLS)的主题ID 183 | trace: 184 | enabled: true # 启用链路追踪 185 | ``` 186 | 187 | 当同时配置了 CLS 参数和链路追踪,异常告警消息中会包含指向云日志的链接,方便快速查看完整的日志上下文。 188 | 189 | ### 代码提交者信息集成 190 | 191 | Exception-Notify 支持通过 GitHub API、GitLab API 或 Gitee API 获取代码提交者信息。你需要选择其中一种方式进行配置,不能同时配置多者: 192 | 193 | ```yaml 194 | exception: 195 | notify: 196 | # 使用 GitHub API 获取代码提交者信息 197 | github: 198 | token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # GitHub 访问令牌 199 | repo-owner: your-github-username # GitHub 仓库所有者 200 | repo-name: your-repo-name # GitHub 仓库名称 201 | branch: master # GitHub 仓库分支 202 | ``` 203 | 204 | 或者: 205 | 206 | ```yaml 207 | exception: 208 | notify: 209 | # 使用 GitLab API 获取代码提交者信息 210 | gitlab: 211 | token: glpat-xxxxxxxxxxxxxxxxxxxx # GitLab 访问令牌 212 | project-id: your-project-id-or-path # GitLab 项目 ID 或路径 213 | base-url: https://gitlab.com/api/v4 # GitLab API 基础 URL 214 | branch: master # GitLab 仓库分支 215 | ``` 216 | 217 | 或者: 218 | 219 | ```yaml 220 | exception: 221 | notify: 222 | # 使用 Gitee API 获取代码提交者信息 223 | gitee: 224 | token: xxxxxxxxxxxxxxxxxxxxxxx # Gitee 访问令牌 225 | repo-owner: your-gitee-username # Gitee 仓库所有者 226 | repo-name: your-repo-name # Gitee 仓库名称 227 | branch: master # Gitee 仓库分支 228 | ``` 229 | 230 | > **注意**:GitHub、GitLab 和 Gitee 配置是互斥的,系统只能从一个代码托管平台读取提交信息。如果同时配置了多个,将按照 Gitee、GitLab、GitHub 的优先顺序选择。 231 | 232 | 233 | ### 通知@功能配置 234 | 235 | 异常通知支持在钉钉、飞书或企业微信群中@相关责任人,以便更快地引起注意。你可以通过以下配置来启用和自定义@功能: 236 | 237 | ```yaml 238 | exception: 239 | notify: 240 | dingtalk: 241 | at: 242 | enabled: true # 是否启用@功能 243 | userIdMappingGitEmail: # 钉钉 用户id 与 git 提交邮箱的映射关系 244 | xxx: ['xxx@xx.com','xxxx@xx.com'] 245 | feishu: 246 | at: 247 | enabled: true # 是否启用@功能 248 | openIdMappingGitEmail: # 飞书 openid 与 git 提交邮箱的映射关系 249 | ou_xxxxxxxx: ['xxx@xx.com','xxxx@xx.com'] 250 | wechatwork: 251 | at: 252 | enabled: true # 是否启用@功能 253 | userIdMappingGitEmail: # 企微 用户id 与 git 提交邮箱的映射关系 254 | # 企微用户 id 带有 @ 符号时,需要手动特殊处理成 [@] 255 | 'xxx[@]xx.com': ['xxx@xx.com','xxxx@xx.com'] 256 | ``` 257 | 258 | 配置说明: 259 | 260 | 1. **钉钉@功能**: 261 | - `enabled`: 是否启用钉钉@功能 262 | - `userIdMappingGitEmail`: 钉钉用户ID与Git提交邮箱的映射关系,支持一个用户ID对应多个Git邮箱 263 | 264 | 2. **飞书@功能**: 265 | - `enabled`: 是否启用飞书@功能 266 | - `openIdMappingGitEmail`: 飞书用户openID与Git提交邮箱的映射关系,支持一个openID对应多个Git邮箱 267 | 268 | 3. **企业微信@功能**: 269 | - `enabled`: 是否启用企业微信@功能 270 | - `userIdMappingGitEmail`: 企业微信用户ID与Git提交邮箱的映射关系,支持一个用户ID对应多个Git邮箱 271 | - 注意:企业微信用户ID如果包含`@`符号,需要特殊处理成`[@]` 272 | 273 | 启用@功能后,当异常发生时,系统会根据Git提交信息找到对应的责任人,并在告警消息中@相关人员,提高异常处理的及时性和准确性。 274 | 275 | 276 | ### 自定义异常过滤 277 | 278 | 你可以通过实现 `ExceptionFilter` 接口并注册为 Spring Bean 来自定义哪些异常需要告警: 279 | 280 | ```java 281 | @Component 282 | public class CustomExceptionFilter implements ExceptionFilter { 283 | @Override 284 | public boolean shouldNotify(Throwable throwable) { 285 | // 忽略特定类型的异常 286 | if (throwable instanceof ResourceNotFoundException) { 287 | return false; 288 | } 289 | return true; 290 | } 291 | } 292 | ``` 293 | 294 | ### 自定义告警内容 295 | 296 | 通过实现 `NotificationFormatter` 接口并注册为 Spring Bean 来自定义告警内容格式: 297 | 298 | ```java 299 | @Component 300 | public class CustomNotificationFormatter implements NotificationFormatter { 301 | @Override 302 | public String format(ExceptionInfo exceptionInfo) { 303 | // 自定义告警内容格式 304 | return "自定义告警内容"; 305 | } 306 | } 307 | ``` 308 | 309 | ### 自定义链路追踪 310 | 311 | 你可以通过实现 `TraceInfoProvider` 接口并注册为 Spring Bean 来自定义如何获取 TraceID 和生成链路追踪 URL: 312 | 313 | ```java 314 | @Component 315 | public class CustomTraceInfoProvider implements TraceInfoProvider { 316 | @Override 317 | public String getTraceId() { 318 | // 自定义获取 TraceID 的逻辑 319 | return "custom-trace-id"; 320 | } 321 | 322 | @Override 323 | public String generateTraceUrl(String traceId) { 324 | // 自定义生成链路追踪 URL 的逻辑 325 | return "https://your-log-system.com/trace?id=" + traceId; 326 | } 327 | } 328 | ``` 329 | 330 | 默认实现 `DefaultTraceInfoProvider` 会从 MDC 或请求头中获取 TraceID,并生成腾讯云日志服务(CLS)的链路追踪 URL。 331 | 332 | ### 自定义通知渠道 333 | 334 | 您可以通过实现 `NotificationProvider` 接口来添加自定义通知渠道: 335 | 336 | ```java 337 | @Component 338 | public class CustomNotificationProvider implements NotificationProvider { 339 | @Override 340 | public boolean sendNotification(ExceptionInfo exceptionInfo) { 341 | // 实现自定义通知渠道的发送逻辑 342 | // exceptionInfo 包含了异常的所有相关信息,如:类型、消息、堆栈跟踪、环境、代码提交者等 343 | System.out.println("发送通知: " + exceptionInfo.getType()); 344 | return true; 345 | } 346 | 347 | @Override 348 | public boolean isEnabled() { 349 | // 决定此通知渠道是否启用 350 | return true; 351 | } 352 | } 353 | ``` 354 | 355 | 或者使用更推荐的方式继承 `AbstractNotificationProvider` 抽象类: 356 | 357 | ```java 358 | @Component 359 | public class CustomNotificationProvider extends AbstractNotificationProvider { 360 | 361 | public CustomNotificationProvider(ExceptionNotifyProperties properties) { 362 | super(properties); 363 | } 364 | 365 | @Override 366 | protected boolean doSendNotification(ExceptionInfo exceptionInfo) throws Exception { 367 | // Implement actual notification sending logic 368 | // exceptionInfo contains all related information about the exception 369 | // such as: type, message, stacktrace, environment, code committer, etc. 370 | System.out.println("Sending notification for: " + exceptionInfo.getType()); 371 | return true; 372 | } 373 | 374 | @Override 375 | public boolean isEnabled() { 376 | // Determine if this notification channel is enabled 377 | return true; 378 | } 379 | } 380 | ``` 381 | 382 | ## Monitor 工具类 383 | 384 | Monitor 是一个简单易用的工具类,可以在记录日志的同时,将消息通过 Exception-Notify 配置的通知渠道(如钉钉、飞书或企业微信)发送出去。 385 | 386 | ### 特点 387 | 388 | - 类似 SLF4J 的简单 API,与现有日志系统集成 389 | - 自动通过配置的通知渠道发送消息 390 | - 支持多种日志级别(info、warn、error) 391 | - 支持带异常和不带异常的消息推送 392 | - 支持自定义 Logger 实例 393 | - 自动从 MDC 或请求头中捕获 TraceID(当链路追踪功能启用时) 394 | - 生成腾讯云日志服务(CLS)的可点击链接,便于日志追踪 395 | - 包含调用者位置信息,便于更好的调试 396 | 397 | ### 使用方法 398 | 399 | #### 基本用法 400 | 401 | 直接调用静态方法记录日志并发送通知: 402 | 403 | ```java 404 | // 信息级别通知 405 | Monitor.info("用户注册成功完成"); 406 | 407 | // 警告级别通知 408 | Monitor.warn("支付处理延迟"); 409 | 410 | // 错误级别通知 411 | Monitor.error("数据库连接失败"); 412 | 413 | // 带异常的通知 414 | try { 415 | // 业务逻辑 416 | } catch (Exception e) { 417 | Monitor.info("订单接收存在问题", e); 418 | Monitor.warn("订单处理部分失败", e); 419 | Monitor.error("订单处理失败", e); 420 | } 421 | ``` 422 | 423 | #### 使用自定义 Logger 424 | 425 | 您可以通过 `Monitor.getLogger()` 方法获取一个 SLF4J Logger 实例,然后使用该实例进行日志记录: 426 | 427 | ```java 428 | // 获取 Logger 429 | Logger logger = Monitor.getLogger(YourService.class); 430 | 431 | // 使用自定义 Logger 发送通知 432 | Monitor.info(logger, "支付处理开始"); 433 | Monitor.warn(logger, "支付处理延迟"); 434 | Monitor.error(logger, "支付处理失败"); 435 | 436 | // 带异常的通知 437 | Monitor.info(logger, "第三方服务返回警告信息", exception); 438 | Monitor.warn(logger, "第三方服务返回错误信息", exception); 439 | Monitor.error(logger, "第三方服务调用失败", exception); 440 | ``` 441 | 442 | ### 配置 443 | 444 | Monitor 工具类使用与 Exception-Notify 相同的配置,无需额外配置。只要已经在 `application.yml` 或 `application.properties` 中配置了 Exception-Notify 组件,Monitor 就会自动使用这些配置。 445 | 446 | ### 常见使用场景 447 | 448 | #### 数据库操作失败 449 | 450 | ```java 451 | try { 452 | repository.save(entity); 453 | } catch (DataAccessException e) { 454 | Monitor.error("保存实体失败,ID: " + entity.getId(), e); 455 | } 456 | ``` 457 | 458 | #### 第三方服务调用失败 459 | 460 | ```java 461 | try { 462 | String response = thirdPartyApiClient.call(); 463 | if (response == null || response.isEmpty()) { 464 | Monitor.error("第三方 API 返回空响应"); 465 | } 466 | } catch (Exception e) { 467 | Monitor.error("第三方 API 调用失败", e); 468 | } 469 | ``` 470 | 471 | #### 业务规则违反 472 | 473 | ```java 474 | if (withdrawAmount > dailyLimit) { 475 | Monitor.error("业务规则违反: 尝试提取 " + withdrawAmount + 476 | " 超过每日限额 " + dailyLimit); 477 | throw new BusinessRuleException("提款金额超过每日限额"); 478 | } 479 | ``` 480 | 481 | #### 重要业务流程状态变更 482 | 483 | ```java 484 | Monitor.error("订单 #12345 状态从 PENDING 变更为 FAILED"); 485 | ``` 486 | 487 | ### 注意事项 488 | 489 | - Monitor 主要用于需要即时通知的重要错误和业务事件 490 | - 避免过度使用,以免通知渠道被大量消息淹没 491 | - 通知仅在配置的环境中发送(通常是 test 和 prod 环境) 492 | - 当链路追踪功能启用时,TraceID 会自动从 MDC 或请求头中获取 493 | - 如果配置了腾讯云日志服务(CLS),通知中将包含可点击的日志链接 494 | 495 | ## 工作原理 496 | 497 | 1. 通过 Spring AOP 的 `@AfterThrowing` 注解机制捕获未处理的异常 498 | 2. 分析异常堆栈信息,提取出异常发生的源代码文件和行号 499 | 3. 调用 GitHub API、GitLab API 或 Gitee API 的 Git Blame 接口,获取对应代码行的提交者信息 500 | 4. 从当前请求上下文中提取 TraceID(如果启用了链路追踪) 501 | 5. 将异常信息、代码提交者信息和 TraceID 组装成告警消息 502 | 6. 通过钉钉机器人或企业微信机器人 Webhook 接口发送告警消息到指定群组 503 | 504 | ## 注意事项 505 | 506 | - 需要确保应用有访问 GitHub API、GitLab API 或 Gitee API 的网络权限 507 | - GitHub Token、GitLab Token 或 Gitee Token 需要有仓库的读取权限 508 | - 钉钉机器人和企业微信机器人需要正确配置安全设置 509 | - 为了获取准确的代码提交者信息,确保代码仓库与实际部署的代码版本一致 510 | 511 | ## 贡献指南 512 | 513 | 欢迎提交 Issue 和 Pull Request 来帮助改进这个项目。 514 | 515 | ## 许可证 516 | 517 | 本项目采用 [Apache License 2.0](LICENSE) 许可证。 -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 | # Exception-Notify 2 | 3 | [![Maven Central](https://img.shields.io/maven-central/v/com.nolimit35.springkit/exception-notify.svg)](https://search.maven.org/search?q=g:com.nolimit35.springkit%20AND%20a:exception-notify) 4 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 5 | 6 | [English](README_EN.md) | [简体中文](README.md) 7 | 8 | ## Introduction 9 | 10 | Exception-Notify is a Spring Boot Starter component designed to capture unhandled exceptions in Spring Boot applications and send real-time alerts through DingTalk, Feishu or WeChat Work. It automatically analyzes exception stack traces, pinpoints the source code file and line number where the exception occurred, and retrieves code committer information through GitHub, GitLab or Gitee APIs. Finally, it sends exception details, TraceID, and responsible person information to a DingTalk, Feishu or WeChat Work group, enabling real-time exception reporting and full-chain tracking. 11 | 12 | ## Features 13 | 14 | - Basing on `@AfterThrowing` to automatically capture unhandled exceptions in Spring Boot applications 15 | - Stack trace analysis to precisely locate exception source (file name and line number) 16 | - Retrieval of code committer information via GitHub API, GitLab API or Gitee API's Git Blame feature 17 | - Integration with distributed tracing systems to correlate TraceID 18 | - Support for real-time exception alerts via DingTalk robot, Feishu robot and WeChat Work robot 19 | - Support for Tencent Cloud Log Service (CLS) trace linking 20 | - Zero-intrusion design, requiring only dependency addition and simple configuration 21 | - Support for custom alert templates and rules 22 | 23 | ## Quick Start 24 | 25 | ### 1. Add Dependency 26 | 27 | Add the following dependency to your Spring Boot project's `pom.xml` file: 28 | 29 | ```xml 30 | 31 | com.nolimit35.springkit 32 | exception-notify 33 | 1.3.1-RELEASE 34 | 35 | ``` 36 | 37 | ### 2. Configure Parameters 38 | 39 | Add the following configuration to your `application.yml` or `application.properties`: 40 | 41 | ```yaml 42 | exception: 43 | notify: 44 | enabled: true # Enable exception notification 45 | dingtalk: 46 | webhook: https://oapi.dingtalk.com/robot/send?access_token=xxx # DingTalk robot webhook URL 47 | at: 48 | enabled: true # Enable @ mention feature 49 | userIdMappingGitEmail: # Mapping between DingTalk user ID and Git email 50 | xxx: ['xxx@xx.com','xxxx@xx.com'] 51 | feishu: 52 | webhook: https://open.feishu.cn/open-apis/bot/v2/hook/xxx # Feishu robot webhook URL 53 | at: 54 | enabled: true # Enable @ mention feature 55 | openIdMappingGitEmail: # Mapping between Feishu openID and Git email 56 | ou_xxxxxxxx: ['xxx@xx.com','xxxx@xx.com'] 57 | wechatwork: 58 | webhook: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx # WeChat Work robot webhook URL 59 | at: 60 | enabled: true # Enable @ mention feature 61 | userIdMappingGitEmail: # Mapping between WeChat Work user ID and Git email 62 | # For WeChat Work user IDs containing @ symbol, replace with [@] 63 | 'xxx[@]xx.com': ['xxx@xx.com','xxxx@xx.com'] 64 | # GitHub configuration (choose one of GitHub, GitLab, or Gitee) 65 | github: 66 | token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # GitHub access token 67 | repo-owner: your-github-username # GitHub repository owner 68 | repo-name: your-repo-name # GitHub repository name 69 | branch: master # GitHub repository branch 70 | # GitLab configuration (choose one of GitHub, GitLab, or Gitee) 71 | gitlab: 72 | token: glpat-xxxxxxxxxxxxxxxxxxxx # GitLab access token 73 | project-id: your-project-id-or-path # GitLab project ID or path 74 | base-url: https://gitlab.com/api/v4 # GitLab API base URL 75 | branch: master # GitLab repository branch 76 | # Gitee configuration (choose one of GitHub, GitLab, or Gitee) 77 | gitee: 78 | token: xxxxxxxxxxxxxxxxxxxxxxx # Gitee access token 79 | repo-owner: your-gitee-username # Gitee repository owner 80 | repo-name: your-repo-name # Gitee repository name 81 | branch: master # Gitee repository branch 82 | tencentcls: 83 | region: ap-guangzhou # Tencent Cloud Log Service (CLS) region 84 | topic-id: xxx-xxx-xxx # Tencent Cloud Log Service (CLS) topic ID 85 | trace: 86 | enabled: true # Enable trace linking 87 | header-name: X-Trace-Id # Trace ID request header name 88 | package-filter: 89 | enabled: false # Enable package name filtering 90 | include-packages: # List of packages to include in analysis 91 | - com.example.app 92 | - com.example.service 93 | notification: 94 | title-template: "【${appName}】Exception Alert" # Alert title template 95 | include-stacktrace: true # Include full stack trace 96 | max-stacktrace-lines: 10 # Maximum number of stack trace lines 97 | environment: 98 | report-from: test,prod # List of environments to report exceptions from 99 | 100 | # Spring configuration 101 | spring: 102 | # Application name, used in alert title 103 | application: 104 | name: YourApplicationName 105 | # Current environment configuration, used to determine exception notification environment 106 | profiles: 107 | active: dev # Current active environment 108 | ``` 109 | 110 | > **Note**: The current environment is automatically read from Spring's `spring.profiles.active` property, so manual setting is not required. Exceptions are only reported from environments listed in `exception.notify.environment.report-from`. By default, exceptions are only reported from test and prod environments. 111 | 112 | ### 3. Start the Application 113 | 114 | Start your Spring Boot application, and Exception-Notify will automatically register a global exception handler to capture unhandled exceptions in classes marked with @Controller, @RestController, or @ExceptionNotify and send alerts. 115 | 116 | ## Alert Example 117 | 118 | When an unhandled exception occurs in the application, a message like the following will be sent to the DingTalk or WeChat Work group: 119 | 120 | ``` 121 | 【Service Name】Exception Alert 122 | ------------------------------- 123 | Exception Time: 2023-03-17 14:30:45 124 | Exception Type: java.lang.NullPointerException 125 | Exception Message: Cannot invoke "String.length()" because "str" is null 126 | Exception Location: com.example.service.UserService.processData(UserService.java:42) 127 | Current Environment: prod 128 | Code Committer: John Doe (johndoe@example.com) 129 | Last Commit Time: 2023-03-15 10:23:18 130 | TraceID: 7b2d1e8f9c3a5b4d6e8f9c3a5b4d6e8f 131 | Cloud Log Link: https://console.cloud.tencent.com/cls/search?region=ap-guangzhou&topic_id=xxx-xxx-xxx&interactiveQueryBase64=xxxx 132 | ------------------------------- 133 | Stack Trace: 134 | java.lang.NullPointerException: Cannot invoke "String.length()" because "str" is null 135 | at com.example.service.UserService.processData(UserService.java:42) 136 | at com.example.controller.UserController.getData(UserController.java:28) 137 | at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) 138 | ... 139 | mention: @John Doe 140 | ``` 141 | 142 | ## Advanced Configuration 143 | 144 | ### Environment Configuration 145 | 146 | You can specify which environments should report exceptions by configuring the `exception.notify.environment.report-from` property: 147 | 148 | ```yaml 149 | exception: 150 | notify: 151 | environment: 152 | report-from: dev,test,prod # Report exceptions from development, test, and production environments 153 | ``` 154 | 155 | By default, the component only reports exceptions from test and prod environments, but not from the dev environment. The current environment is automatically read from Spring's `spring.profiles.active` property. 156 | 157 | ### Package Filter Configuration 158 | 159 | You can control which package names to focus on during exception stack trace analysis by configuring `exception.notify.package-filter`: 160 | 161 | ```yaml 162 | exception: 163 | notify: 164 | package-filter: 165 | enabled: true # Enable package filter 166 | include-packages: # List of packages to analyze 167 | - com.example.app 168 | - com.example.service 169 | ``` 170 | 171 | When package filtering is enabled, the exception analyzer will prioritize finding stack trace information from the specified package list, which is particularly useful for locating problems in business code. If no matching stack information is found, the original filtering logic will be used. 172 | 173 | ### Tencent Cloud Log Service (CLS) Integration 174 | 175 | If you use Tencent Cloud Log Service (CLS), you can configure the relevant parameters to add cloud log links to exception alerts: 176 | 177 | ```yaml 178 | exception: 179 | notify: 180 | tencentcls: 181 | region: ap-guangzhou # Tencent Cloud Log Service (CLS) region 182 | topic-id: xxx-xxx-xxx # Tencent Cloud Log Service (CLS) topic ID 183 | trace: 184 | enabled: true # Enable trace linking 185 | ``` 186 | 187 | When both CLS parameters and trace linking are configured, the exception alert message will include a link to the cloud logs, making it easy to quickly view the complete log context. 188 | 189 | ### Code Committer Information Integration 190 | 191 | Exception-Notify supports retrieving code committer information via GitHub API, GitLab API, or Gitee API. You need to choose one of these methods for configuration, as they cannot be used simultaneously: 192 | 193 | ```yaml 194 | exception: 195 | notify: 196 | # Use GitHub API to get code committer information 197 | github: 198 | token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # GitHub access token 199 | repo-owner: your-github-username # GitHub repository owner 200 | repo-name: your-repo-name # GitHub repository name 201 | branch: master # GitHub repository branch 202 | ``` 203 | 204 | Or: 205 | 206 | ```yaml 207 | exception: 208 | notify: 209 | # Use GitLab API to get code committer information 210 | gitlab: 211 | token: glpat-xxxxxxxxxxxxxxxxxxxx # GitLab access token 212 | project-id: your-project-id-or-path # GitLab project ID or path 213 | base-url: https://gitlab.com/api/v4 # GitLab API base URL 214 | branch: master # GitLab repository branch 215 | ``` 216 | 217 | Or: 218 | 219 | ```yaml 220 | exception: 221 | notify: 222 | # Use Gitee API to get code committer information 223 | gitee: 224 | token: xxxxxxxxxxxxxxxxxxxxxxx # Gitee access token 225 | repo-owner: your-gitee-username # Gitee repository owner 226 | repo-name: your-repo-name # Gitee repository name 227 | branch: master # Gitee repository branch 228 | ``` 229 | 230 | > **Note**: GitHub, GitLab, and Gitee configurations are mutually exclusive; the system can only read commit information from one code hosting platform. If multiple are configured, preference order is Gitee, then GitLab, then GitHub. 231 | 232 | 233 | ### Notification @ Mention Configuration 234 | 235 | Exception notifications support @mentioning responsible persons in DingTalk, Feishu, or WeChat Work groups to draw attention more quickly. You can enable and customize the @ mention feature with the following configuration: 236 | 237 | ```yaml 238 | exception: 239 | notify: 240 | dingtalk: 241 | at: 242 | enabled: true # Enable @ mention feature 243 | userIdMappingGitEmail: # Mapping between DingTalk user ID and Git email 244 | xxx: ['xxx@xx.com','xxxx@xx.com'] 245 | feishu: 246 | at: 247 | enabled: true # Enable @ mention feature 248 | openIdMappingGitEmail: # Mapping between Feishu openID and Git email 249 | ou_xxxxxxxx: ['xxx@xx.com','xxxx@xx.com'] 250 | wechatwork: 251 | at: 252 | enabled: true # Enable @ mention feature 253 | userIdMappingGitEmail: # Mapping between WeChat Work user ID and Git email 254 | # For WeChat Work user IDs containing @ symbol, replace with [@] 255 | 'xxx[@]xx.com': ['xxx@xx.com','xxxx@xx.com'] 256 | ``` 257 | 258 | Configuration details: 259 | 260 | 1. **DingTalk @ Mention**: 261 | - `enabled`: Whether to enable DingTalk @ mentions 262 | - `userIdMappingGitEmail`: Mapping between DingTalk user IDs and Git commit emails, supporting multiple Git emails for one user ID 263 | 264 | 2. **Feishu @ Mention**: 265 | - `enabled`: Whether to enable Feishu @ mentions 266 | - `openIdMappingGitEmail`: Mapping between Feishu openIDs and Git commit emails, supporting multiple Git emails for one openID 267 | 268 | 3. **WeChat Work @ Mention**: 269 | - `enabled`: Whether to enable WeChat Work @ mentions 270 | - `userIdMappingGitEmail`: Mapping between WeChat Work user IDs and Git commit emails, supporting multiple Git emails for one user ID 271 | - Note: For WeChat Work user IDs containing `@` symbol, replace with `[@]` 272 | 273 | When the @ mention feature is enabled, the system will identify the responsible person based on Git commit information and mention them in the alert message, improving the timeliness and accuracy of exception handling. 274 | 275 | ### Custom Exception Filtering 276 | 277 | You can customize which exceptions should trigger alerts by implementing the `ExceptionFilter` interface and registering it as a Spring Bean: 278 | 279 | ```java 280 | @Component 281 | public class CustomExceptionFilter implements ExceptionFilter { 282 | @Override 283 | public boolean shouldNotify(Throwable throwable) { 284 | // Ignore specific types of exceptions 285 | if (throwable instanceof ResourceNotFoundException) { 286 | return false; 287 | } 288 | return true; 289 | } 290 | } 291 | ``` 292 | 293 | ### Custom Alert Content 294 | 295 | You can customize the alert content format by implementing the `NotificationFormatter` interface and registering it as a Spring Bean: 296 | 297 | ```java 298 | @Component 299 | public class CustomNotificationFormatter implements NotificationFormatter { 300 | @Override 301 | public String format(ExceptionInfo exceptionInfo) { 302 | // Custom alert content format 303 | return "Custom alert content"; 304 | } 305 | } 306 | ``` 307 | 308 | ### Custom Trace Information 309 | 310 | You can customize how TraceID is retrieved and trace URLs are generated by implementing the `TraceInfoProvider` interface and registering it as a Spring Bean: 311 | 312 | ```java 313 | @Component 314 | public class CustomTraceInfoProvider implements TraceInfoProvider { 315 | @Override 316 | public String getTraceId() { 317 | // Custom logic to retrieve TraceID 318 | return "custom-trace-id"; 319 | } 320 | 321 | @Override 322 | public String generateTraceUrl(String traceId) { 323 | // Custom logic to generate trace URL 324 | return "https://your-log-system.com/trace?id=" + traceId; 325 | } 326 | } 327 | ``` 328 | 329 | The default implementation `DefaultTraceInfoProvider` retrieves TraceID from MDC or request headers and generates Tencent Cloud Log Service (CLS) trace URLs. 330 | 331 | ## Customization 332 | 333 | ### Custom Notification Format 334 | 335 | You can customize the format of exception notifications by implementing the `NotificationFormatter` interface: 336 | 337 | ```java 338 | @Component 339 | public class CustomNotificationFormatter implements NotificationFormatter { 340 | @Override 341 | public String format(ExceptionInfo exceptionInfo) { 342 | // Custom notification format 343 | return "Custom alert content"; 344 | } 345 | } 346 | ``` 347 | 348 | ### Custom Exception Filter 349 | 350 | You can customize which exceptions should trigger notifications by implementing the `ExceptionFilter` interface: 351 | 352 | ```java 353 | @Component 354 | public class CustomExceptionFilter implements ExceptionFilter { 355 | @Override 356 | public boolean shouldNotify(Throwable throwable) { 357 | // Custom filtering logic to determine if a notification should be sent 358 | return throwable instanceof RuntimeException; 359 | } 360 | } 361 | ``` 362 | 363 | ### Custom Notification Channel 364 | 365 | You can add a custom notification channel by implementing the `NotificationProvider` interface: 366 | 367 | ```java 368 | @Component 369 | public class CustomNotificationProvider implements NotificationProvider { 370 | @Override 371 | public boolean sendNotification(ExceptionInfo exceptionInfo) { 372 | // Implement custom notification channel logic 373 | // exceptionInfo contains all related information about the exception 374 | // such as: type, message, stacktrace, environment, code committer, etc. 375 | System.out.println("Sending notification for: " + exceptionInfo.getType()); 376 | return true; 377 | } 378 | 379 | @Override 380 | public boolean isEnabled() { 381 | // Determine if this notification channel is enabled 382 | return true; 383 | } 384 | } 385 | ``` 386 | 387 | Or more preferably by extending the `AbstractNotificationProvider` abstract class: 388 | 389 | ```java 390 | @Component 391 | public class CustomNotificationProvider extends AbstractNotificationProvider { 392 | 393 | public CustomNotificationProvider(ExceptionNotifyProperties properties) { 394 | super(properties); 395 | } 396 | 397 | @Override 398 | protected boolean doSendNotification(ExceptionInfo exceptionInfo) throws Exception { 399 | // Implement actual notification sending logic 400 | // exceptionInfo contains all related information about the exception 401 | // such as: type, message, stacktrace, environment, code committer, etc. 402 | System.out.println("Sending notification for: " + exceptionInfo.getType()); 403 | return true; 404 | } 405 | 406 | @Override 407 | public boolean isEnabled() { 408 | // Determine if this notification channel is enabled 409 | return true; 410 | } 411 | } 412 | ``` 413 | 414 | ## Monitor Utility 415 | 416 | Monitor is a simple utility class that allows you to record logs and send messages through notification channels configured in Exception-Notify (such as DingTalk, Feishu, or WeChat Work). 417 | 418 | ### Features 419 | 420 | - Simple API similar to SLF4J, integrates with existing logging systems 421 | - Automatically sends messages through configured notification channels 422 | - Supports multiple logging levels (info, warn, error) 423 | - Supports messages with or without exceptions 424 | - Supports custom Logger instances 425 | - Automatically captures TraceID from MDC or request headers when trace is enabled 426 | - Generates links to Tencent Cloud Log Service (CLS) for easy log tracking 427 | - Includes caller location information for better debugging 428 | 429 | ### Usage 430 | 431 | #### Basic Usage 432 | 433 | Call static methods directly to record logs and send notifications: 434 | 435 | ```java 436 | // Info level notification 437 | Monitor.info("User registration completed successfully"); 438 | 439 | // Warning level notification 440 | Monitor.warn("Payment processing delayed"); 441 | 442 | // Error level notification 443 | Monitor.error("Database connection failed"); 444 | 445 | // Notifications with exceptions 446 | try { 447 | // Business logic 448 | } catch (Exception e) { 449 | Monitor.info("Order received with issues", e); 450 | Monitor.warn("Order processing partially failed", e); 451 | Monitor.error("Order processing failed", e); 452 | } 453 | ``` 454 | 455 | #### Using Custom Logger 456 | 457 | You can get an SLF4J Logger instance through the `Monitor.getLogger()` method and use it for logging: 458 | 459 | ```java 460 | // Get Logger 461 | Logger logger = Monitor.getLogger(YourService.class); 462 | 463 | // Use custom Logger to send notifications 464 | Monitor.info(logger, "Payment processing started"); 465 | Monitor.warn(logger, "Payment processing delayed"); 466 | Monitor.error(logger, "Payment processing failed"); 467 | 468 | // With exceptions 469 | Monitor.info(logger, "Third-party service responded with warnings", exception); 470 | Monitor.warn(logger, "Third-party service responded with errors", exception); 471 | Monitor.error(logger, "Third-party service call failed", exception); 472 | ``` 473 | 474 | ### Configuration 475 | 476 | Monitor utility uses the same configuration as Exception-Notify, no additional configuration is needed. As long as Exception-Notify is configured in `application.yml` or `application.properties`, Monitor will automatically use these configurations. 477 | 478 | ### Common Use Cases 479 | 480 | #### Database Operation Failure 481 | 482 | ```java 483 | try { 484 | repository.save(entity); 485 | } catch (DataAccessException e) { 486 | Monitor.error("Failed to save entity with id: " + entity.getId(), e); 487 | } 488 | ``` 489 | 490 | #### Third-Party Service Call Failure 491 | 492 | ```java 493 | try { 494 | String response = thirdPartyApiClient.call(); 495 | if (response == null || response.isEmpty()) { 496 | Monitor.error("Third-party API returned empty response"); 497 | } 498 | } catch (Exception e) { 499 | Monitor.error("Third-party API call failed", e); 500 | } 501 | ``` 502 | 503 | #### Business Rule Violation 504 | 505 | ```java 506 | if (withdrawAmount > dailyLimit) { 507 | Monitor.error("Business rule violation: attempted to withdraw " + 508 | withdrawAmount + " exceeding daily limit of " + dailyLimit); 509 | throw new BusinessRuleException("Withdrawal amount exceeds daily limit"); 510 | } 511 | ``` 512 | 513 | #### Important Business Process State Change 514 | 515 | ```java 516 | Monitor.error("Order #12345 status changed from PENDING to FAILED"); 517 | ``` 518 | 519 | ### Notes 520 | 521 | - Monitor is primarily used for important errors and business events that require immediate notification 522 | - Avoid overuse to prevent notification channel overload 523 | - Notifications are only sent in configured environments (typically test and prod environments) 524 | - TraceID is automatically captured from MDC or request headers when trace is enabled 525 | - If Tencent CLS is configured, clickable log links will be included in notifications 526 | 527 | ## How It Works 528 | 529 | 1. Captures unhandled exceptions through Spring AOP's `@AfterThrowing` annotation mechanism 530 | 2. Analyzes exception stack trace information to extract the source code file and line number where the exception occurred 531 | 3. Calls the GitHub API, GitLab API, or Gitee API's Git Blame interface to get committer information for the corresponding line of code 532 | 4. Extracts TraceID from the current request context (if trace linking is enabled) 533 | 5. Assembles exception information, code committer information, and TraceID into an alert message 534 | 6. Sends the alert message to the specified group through DingTalk or WeChat Work robot Webhook interface 535 | 536 | ## Precautions 537 | 538 | - Ensure the application has network permissions to access GitHub API, GitLab API, or Gitee API 539 | - GitHub Token, GitLab Token, or Gitee Token needs repository read permission 540 | - DingTalk and WeChat Work robots need to be correctly configured with security settings 541 | - To get accurate code committer information, ensure the code repository is consistent with the deployed code version 542 | 543 | ## Contribution Guidelines 544 | 545 | Issues and Pull Requests are welcome to help improve this project. 546 | 547 | ## License 548 | 549 | This project is licensed under the [Apache License 2.0](LICENSE). 550 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | com.nolimit35.springkit 8 | exception-notify 9 | 1.3.1-RELEASE 10 | 11 | 12 | Exception Notify 13 | A Spring Boot library for exception notification and handling 14 | https://github.com/GuangYiDing/exception-notify 15 | 16 | 17 | 18 | The Apache License, Version 2.0 19 | http://www.apache.org/licenses/LICENSE-2.0.txt 20 | 21 | 22 | 23 | 24 | 25 | ChowXiaoDi 26 | xiaodingsiren@gmail.com 27 | com.nolimit35 28 | https://www.nolimit35.com 29 | 30 | 31 | 32 | 33 | scm:git:git://github.com/GuangYiDing/exception-notify.git 34 | scm:git:ssh://github.com:GuangYiDing/exception-notify.git 35 | https://github.com/GuangYiDing/exception-notify/tree/main 36 | 37 | 38 | 39 | 8 40 | 8 41 | UTF-8 42 | 2.7.9 43 | 1.18.26 44 | 45 | 46 | 47 | 48 | 49 | org.springframework.boot 50 | spring-boot-starter 51 | ${spring-boot.version} 52 | true 53 | 54 | 55 | 56 | 57 | org.springframework.boot 58 | spring-boot-starter-web 59 | ${spring-boot.version} 60 | true 61 | 62 | 63 | 64 | 65 | 66 | org.springframework.boot 67 | spring-boot-starter-aop 68 | ${spring-boot.version} 69 | 70 | 71 | 72 | 73 | org.springframework.boot 74 | spring-boot-configuration-processor 75 | ${spring-boot.version} 76 | true 77 | 78 | 79 | 80 | 81 | org.springframework.boot 82 | spring-boot-autoconfigure 83 | ${spring-boot.version} 84 | true 85 | 86 | 87 | 88 | 89 | org.projectlombok 90 | lombok 91 | ${lombok.version} 92 | true 93 | 94 | 95 | 96 | 97 | com.squareup.okhttp3 98 | okhttp 99 | 4.10.0 100 | 101 | 102 | 103 | 104 | com.fasterxml.jackson.core 105 | jackson-databind 106 | 2.13.5 107 | 108 | 109 | 110 | 111 | org.springframework.boot 112 | spring-boot-starter-test 113 | ${spring-boot.version} 114 | test 115 | 116 | 117 | org.junit.jupiter 118 | junit-jupiter-api 119 | 5.8.2 120 | test 121 | 122 | 123 | org.junit.jupiter 124 | junit-jupiter-engine 125 | 5.8.2 126 | test 127 | 128 | 129 | org.mockito 130 | mockito-core 131 | 4.5.1 132 | test 133 | 134 | 135 | org.mockito 136 | mockito-junit-jupiter 137 | 4.5.1 138 | test 139 | 140 | 141 | 142 | 143 | 144 | 145 | org.springframework.boot 146 | spring-boot-maven-plugin 147 | ${spring-boot.version} 148 | 149 | 150 | 151 | org.projectlombok 152 | lombok 153 | 154 | 155 | 156 | 157 | 158 | org.apache.maven.plugins 159 | maven-compiler-plugin 160 | 3.10.1 161 | 162 | ${maven.compiler.source} 163 | ${maven.compiler.target} 164 | 165 | 166 | 167 | 168 | 169 | org.apache.maven.plugins 170 | maven-source-plugin 171 | 3.2.1 172 | 173 | 174 | attach-sources 175 | 176 | jar-no-fork 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | org.apache.maven.plugins 185 | maven-javadoc-plugin 186 | 3.4.1 187 | 188 | 189 | attach-javadocs 190 | 191 | jar 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | org.apache.maven.plugins 200 | maven-gpg-plugin 201 | 3.0.1 202 | 203 | 204 | sign-artifacts 205 | verify 206 | 207 | sign 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | org.apache.maven.plugins 216 | maven-deploy-plugin 217 | 2.8.2 218 | 219 | 220 | 221 | org.sonatype.central 222 | central-publishing-maven-plugin 223 | 0.5.0 224 | true 225 | 226 | ossrh 227 | 228 | 229 | 230 | 231 | -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/annotation/ExceptionNotify.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.annotation; 2 | 3 | import java.lang.annotation.*; 4 | 5 | /** 6 | * 标记需要异常通知的类或方法 7 | * 可以标记在类上或方法上 8 | */ 9 | @Target({ElementType.METHOD, ElementType.TYPE}) 10 | @Retention(RetentionPolicy.RUNTIME) 11 | @Documented 12 | public @interface ExceptionNotify { 13 | 14 | /** 15 | * 异常通知的标题 16 | */ 17 | String title() default ""; 18 | 19 | /** 20 | * 是否启用异常通知 21 | */ 22 | boolean enabled() default true; 23 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/aspect/ExceptionNotificationAspect.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.aspect; 2 | 3 | import com.nolimit35.springkit.service.ExceptionNotificationService; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.aspectj.lang.annotation.AfterThrowing; 6 | import org.aspectj.lang.annotation.Aspect; 7 | import org.aspectj.lang.annotation.Pointcut; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Slf4j 11 | @Aspect 12 | @Component 13 | public class ExceptionNotificationAspect { 14 | 15 | private final ExceptionNotificationService notificationService; 16 | 17 | public ExceptionNotificationAspect(ExceptionNotificationService notificationService) { 18 | this.notificationService = notificationService; 19 | } 20 | 21 | // 定义切点:所有被 @Controller 或 @RestController 标记的类的所有方法,或者 @ExceptionNotify 标记的类 22 | @Pointcut("@within(org.springframework.stereotype.Controller) || @within(org.springframework.web.bind.annotation.RestController) || @annotation(com.nolimit35.springkit.annotation.ExceptionNotify)") 23 | public void allPointcut() { 24 | } 25 | 26 | /** 27 | * 当拦截的切点返回抛出异常时,会执行此方法 且在 @ControllerAdvice 和 @RestControllerAdvice 前执行 28 | * * @param ex 29 | */ 30 | @AfterThrowing( 31 | pointcut = "allPointcut()", 32 | throwing = "ex" 33 | ) 34 | public void handleException(Exception ex) { 35 | try { 36 | notificationService.processException(ex); 37 | } catch (Exception e) { 38 | log.error("Error in exception notification aspect", e); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/config/EnvironmentPostProcessor.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.config; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent; 5 | import org.springframework.context.ApplicationListener; 6 | import org.springframework.core.env.ConfigurableEnvironment; 7 | import org.springframework.core.env.Environment; 8 | import org.springframework.stereotype.Component; 9 | 10 | /** 11 | * Listener to set the current environment from spring.profiles.active 12 | */ 13 | @Slf4j 14 | @Component 15 | public class EnvironmentPostProcessor implements ApplicationListener { 16 | 17 | @Override 18 | public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) { 19 | ConfigurableEnvironment environment = event.getEnvironment(); 20 | String activeProfile = getActiveProfile(environment); 21 | 22 | if (activeProfile != null) { 23 | // Set the current environment property based on the active profile 24 | System.setProperty("exception.notify.environment.current", activeProfile); 25 | log.debug("Set exception.notify.environment.current to active profile: {}", activeProfile); 26 | } 27 | } 28 | 29 | /** 30 | * Get the active profile from Spring environment 31 | * 32 | * @param environment the Spring environment 33 | * @return the active profile or null if not found 34 | */ 35 | private String getActiveProfile(Environment environment) { 36 | String[] activeProfiles = environment.getActiveProfiles(); 37 | 38 | if (activeProfiles.length > 0) { 39 | // Use the first active profile 40 | return activeProfiles[0]; 41 | } 42 | 43 | // Check default profiles if no active profiles found 44 | String[] defaultProfiles = environment.getDefaultProfiles(); 45 | if (defaultProfiles.length > 0) { 46 | return defaultProfiles[0]; 47 | } 48 | 49 | return null; 50 | } 51 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/config/ExceptionNotifyAutoConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.config; 2 | 3 | import com.nolimit35.springkit.aspect.ExceptionNotificationAspect; 4 | import com.nolimit35.springkit.filter.DefaultExceptionFilter; 5 | import com.nolimit35.springkit.filter.ExceptionFilter; 6 | import com.nolimit35.springkit.formatter.DefaultNotificationFormatter; 7 | import com.nolimit35.springkit.formatter.NotificationFormatter; 8 | import com.nolimit35.springkit.monitor.Monitor; 9 | import com.nolimit35.springkit.notification.NotificationProviderManager; 10 | import com.nolimit35.springkit.notification.provider.DingTalkNotificationProvider; 11 | import com.nolimit35.springkit.notification.provider.FeishuNotificationProvider; 12 | import com.nolimit35.springkit.notification.provider.WeChatWorkNotificationProvider; 13 | import com.nolimit35.springkit.service.*; 14 | import com.nolimit35.springkit.trace.DefaultTraceInfoProvider; 15 | import com.nolimit35.springkit.trace.TraceInfoProvider; 16 | import lombok.extern.slf4j.Slf4j; 17 | import org.springframework.beans.factory.annotation.Value; 18 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 19 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 20 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 21 | import org.springframework.context.annotation.Bean; 22 | import org.springframework.context.annotation.Configuration; 23 | import org.springframework.context.annotation.Import; 24 | import org.springframework.core.env.Environment; 25 | 26 | import java.util.List; 27 | 28 | /** 29 | * Auto-configuration for Exception-Notify 30 | */ 31 | @Configuration 32 | @EnableConfigurationProperties(ExceptionNotifyProperties.class) 33 | @ConditionalOnProperty(prefix = "exception.notify", name = "enabled", havingValue = "true", matchIfMissing = true) 34 | @Import({ 35 | DefaultExceptionFilter.class, 36 | DefaultNotificationFormatter.class, 37 | DefaultTraceInfoProvider.class 38 | }) 39 | @Slf4j 40 | public class ExceptionNotifyAutoConfiguration { 41 | 42 | @Bean 43 | @ConditionalOnMissingBean 44 | public GitHubService gitHubService(ExceptionNotifyProperties properties) { 45 | return new GitHubService(properties); 46 | } 47 | 48 | @Bean 49 | @ConditionalOnMissingBean 50 | public GiteeService giteeService(ExceptionNotifyProperties properties) { 51 | return new GiteeService(properties); 52 | } 53 | 54 | @Bean 55 | @ConditionalOnMissingBean 56 | public GitLabService gitLabService(ExceptionNotifyProperties properties) { 57 | return new GitLabService(properties); 58 | } 59 | 60 | @Bean 61 | @ConditionalOnMissingBean 62 | public EnvironmentProvider environmentProvider(Environment environment) { 63 | return new EnvironmentProvider(environment); 64 | } 65 | 66 | @Bean 67 | @ConditionalOnMissingBean 68 | public ExceptionAnalyzerService exceptionAnalyzerService( 69 | List gitSourceControlServices, 70 | ExceptionNotifyProperties properties, 71 | TraceInfoProvider traceInfoProvider) { 72 | return new ExceptionAnalyzerService(gitSourceControlServices, properties, traceInfoProvider); 73 | } 74 | 75 | @Bean 76 | @ConditionalOnMissingBean 77 | public NotificationProviderManager notificationProviderManager(List providers) { 78 | return new NotificationProviderManager(providers); 79 | } 80 | 81 | @Bean 82 | @ConditionalOnMissingBean 83 | public DingTalkNotificationProvider dingTalkNotificationProvider(ExceptionNotifyProperties properties, NotificationFormatter formatter) { 84 | return new DingTalkNotificationProvider(properties, formatter); 85 | } 86 | 87 | @Bean 88 | @ConditionalOnMissingBean 89 | public FeishuNotificationProvider feishuNotificationProvider(ExceptionNotifyProperties properties, NotificationFormatter formatter) { 90 | return new FeishuNotificationProvider(properties, formatter); 91 | } 92 | 93 | @Bean 94 | @ConditionalOnMissingBean 95 | public WeChatWorkNotificationProvider weChatWorkNotificationProvider(ExceptionNotifyProperties properties, NotificationFormatter formatter) { 96 | return new WeChatWorkNotificationProvider(properties, formatter); 97 | } 98 | 99 | 100 | @Bean 101 | @ConditionalOnMissingBean 102 | public ExceptionNotificationAspect exceptionNotificationAspect(ExceptionNotificationService notificationService) { 103 | return new ExceptionNotificationAspect(notificationService); 104 | } 105 | 106 | @Bean 107 | public Monitor monitor(NotificationProviderManager manager, 108 | @Value("${spring.application.name:unknown}") String appName, 109 | ExceptionNotifyProperties properties, 110 | TraceInfoProvider traceInfoProvider) { 111 | return new Monitor(manager, appName, properties, traceInfoProvider); 112 | } 113 | 114 | 115 | @Bean 116 | @ConditionalOnMissingBean 117 | public ExceptionNotificationService exceptionNotificationService( 118 | ExceptionNotifyProperties properties, 119 | ExceptionAnalyzerService analyzerService, 120 | NotificationProviderManager notificationManager, 121 | NotificationFormatter formatter, 122 | ExceptionFilter filter, 123 | EnvironmentProvider environmentProvider, 124 | TraceInfoProvider traceInfoProvider) { 125 | ExceptionNotificationService notificationService = new ExceptionNotificationService( 126 | properties, analyzerService, notificationManager, formatter, filter, environmentProvider, traceInfoProvider); 127 | log.info("异常通知组件已注入 :) "); 128 | return notificationService; 129 | } 130 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/config/ExceptionNotifyProperties.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.config; 2 | 3 | import lombok.Data; 4 | import org.springframework.boot.context.properties.ConfigurationProperties; 5 | 6 | import java.util.Arrays; 7 | import java.util.HashSet; 8 | import java.util.List; 9 | import java.util.Map; 10 | import java.util.Set; 11 | 12 | /** 13 | * Configuration properties for Exception-Notify 14 | */ 15 | @Data 16 | @ConfigurationProperties(prefix = "exception.notify") 17 | public class ExceptionNotifyProperties { 18 | /** 19 | * Whether to enable exception notification 20 | */ 21 | private boolean enabled = true; 22 | 23 | /** 24 | * DingTalk configuration 25 | */ 26 | private DingTalk dingtalk = new DingTalk(); 27 | 28 | /** 29 | * Feishu configuration 30 | */ 31 | private Feishu feishu = new Feishu(); 32 | 33 | /** 34 | * WeChat Work configuration 35 | */ 36 | private WeChatWork wechatwork = new WeChatWork(); 37 | 38 | /** 39 | * GitHub configuration 40 | */ 41 | private GitHub github = new GitHub(); 42 | 43 | /** 44 | * Gitee configuration 45 | */ 46 | private Gitee gitee = new Gitee(); 47 | 48 | /** 49 | * GitLab configuration 50 | */ 51 | private GitLab gitlab = new GitLab(); 52 | 53 | /** 54 | * Trace configuration 55 | */ 56 | private Trace trace = new Trace(); 57 | 58 | /** 59 | * Notification configuration 60 | */ 61 | private Notification notification = new Notification(); 62 | 63 | /** 64 | * Environment configuration 65 | */ 66 | private Environment environment = new Environment(); 67 | 68 | /** 69 | * Tencent CLS configuration 70 | */ 71 | private TencentCls tencentcls = new TencentCls(); 72 | 73 | /** 74 | * Package filter configuration 75 | */ 76 | private PackageFilter packageFilter = new PackageFilter(); 77 | 78 | /** 79 | * DingTalk configuration properties 80 | */ 81 | @Data 82 | public static class DingTalk { 83 | /** 84 | * DingTalk webhook URL 85 | */ 86 | private String webhook; 87 | 88 | /** 89 | * DingTalk @ configuration 90 | */ 91 | private At at = new At(); 92 | 93 | /** 94 | * DingTalk @ configuration properties 95 | */ 96 | @Data 97 | public static class At { 98 | /** 99 | * Whether to enable @ functionality 100 | */ 101 | private boolean enabled = true; 102 | 103 | 104 | /** 105 | * Mapping of DingTalk user IDs to Git email addresses 106 | */ 107 | private Map> userIdMappingGitEmail; 108 | } 109 | } 110 | 111 | /** 112 | * Feishu configuration properties 113 | */ 114 | @Data 115 | public static class Feishu { 116 | /** 117 | * Feishu webhook URL 118 | */ 119 | private String webhook; 120 | 121 | /** 122 | * Feishu @ configuration 123 | */ 124 | private At at = new At(); 125 | 126 | /** 127 | * Feishu @ configuration properties 128 | */ 129 | @Data 130 | public static class At { 131 | /** 132 | * Whether to enable @ functionality 133 | */ 134 | private boolean enabled = true; 135 | 136 | /** 137 | * Mapping of Feishu open IDs to Git email addresses 138 | */ 139 | private Map> openIdMappingGitEmail; 140 | } 141 | } 142 | 143 | /** 144 | * WeChat Work configuration properties 145 | */ 146 | @Data 147 | public static class WeChatWork { 148 | /** 149 | * WeChat Work webhook URL 150 | */ 151 | private String webhook; 152 | 153 | /** 154 | * WeChat Work @ configuration 155 | */ 156 | private At at = new At(); 157 | 158 | /** 159 | * WeChat Work @ configuration properties 160 | */ 161 | @Data 162 | public static class At { 163 | /** 164 | * Whether to enable @ functionality 165 | */ 166 | private boolean enabled = true; 167 | 168 | /** 169 | * Mapping of WeChat Work user IDs to Git email addresses 170 | */ 171 | private Map> userIdMappingGitEmail; 172 | } 173 | } 174 | 175 | /** 176 | * Tencent CLS configuration properties 177 | */ 178 | @Data 179 | public static class TencentCls { 180 | /** 181 | * Tencent CLS region 182 | */ 183 | private String region; 184 | 185 | /** 186 | * Tencent CLS topic ID 187 | */ 188 | private String topicId; 189 | } 190 | 191 | /** 192 | * GitHub configuration properties 193 | */ 194 | @Data 195 | public static class GitHub { 196 | /** 197 | * GitHub access token 198 | */ 199 | private String token; 200 | 201 | /** 202 | * GitHub repository owner 203 | */ 204 | private String repoOwner; 205 | 206 | /** 207 | * GitHub repository name 208 | */ 209 | private String repoName; 210 | 211 | /** 212 | * GitHub repository branch 213 | */ 214 | private String branch = "master"; 215 | } 216 | 217 | /** 218 | * Gitee configuration properties 219 | */ 220 | @Data 221 | public static class Gitee { 222 | /** 223 | * Gitee access token 224 | */ 225 | private String token; 226 | 227 | /** 228 | * Gitee repository owner 229 | */ 230 | private String repoOwner; 231 | 232 | /** 233 | * Gitee repository name 234 | */ 235 | private String repoName; 236 | 237 | /** 238 | * Gitee repository branch 239 | */ 240 | private String branch = "master"; 241 | } 242 | 243 | /** 244 | * GitLab configuration properties 245 | */ 246 | @Data 247 | public static class GitLab { 248 | /** 249 | * GitLab access token 250 | */ 251 | private String token; 252 | 253 | /** 254 | * GitLab project id or path 255 | */ 256 | private String projectId; 257 | 258 | /** 259 | * GitLab API base URL 260 | */ 261 | private String baseUrl = "https://gitlab.com/api/v4"; 262 | 263 | /** 264 | * GitLab repository branch 265 | */ 266 | private String branch = "master"; 267 | } 268 | 269 | /** 270 | * Trace configuration properties 271 | */ 272 | @Data 273 | public static class Trace { 274 | /** 275 | * Whether to enable trace 276 | */ 277 | private boolean enabled = true; 278 | 279 | /** 280 | * Trace ID header name 281 | */ 282 | private String headerName = "X-Trace-Id"; 283 | } 284 | 285 | /** 286 | * Notification configuration properties 287 | */ 288 | @Data 289 | public static class Notification { 290 | /** 291 | * Title template for notification 292 | */ 293 | private String titleTemplate = "【${appName}】异常告警"; 294 | 295 | /** 296 | * Whether to include stacktrace in notification 297 | */ 298 | private boolean includeStacktrace = true; 299 | 300 | /** 301 | * Maximum number of stacktrace lines to include 302 | */ 303 | private int maxStacktraceLines = 10; 304 | } 305 | 306 | /** 307 | * Environment configuration properties 308 | */ 309 | @Data 310 | public static class Environment { 311 | /** 312 | * Current environment (automatically determined from spring.profiles.active) 313 | * This property is not meant to be set directly in most cases. 314 | * It will be automatically set based on spring.profiles.active. 315 | */ 316 | private String current = "dev"; 317 | 318 | /** 319 | * Environments to report exceptions from (comma-separated) 320 | */ 321 | private Set reportFrom = new HashSet<>(Arrays.asList("test", "prod")); 322 | 323 | /** 324 | * Check if exceptions should be reported from the current environment 325 | * 326 | * @return true if exceptions should be reported, false otherwise 327 | */ 328 | public boolean shouldReportFromCurrentEnvironment() { 329 | return reportFrom.contains(current); 330 | } 331 | } 332 | 333 | /** 334 | * Package filter configuration properties 335 | */ 336 | @Data 337 | public static class PackageFilter { 338 | /** 339 | * Whether to enable package filtering 340 | */ 341 | private boolean enabled = false; 342 | 343 | /** 344 | * List of package names to include in exception analysis 345 | */ 346 | private Set includePackages = new HashSet<>(); 347 | } 348 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/filter/DefaultExceptionFilter.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.filter; 2 | 3 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 4 | import org.springframework.stereotype.Component; 5 | 6 | /** 7 | * Default implementation of ExceptionFilter 8 | */ 9 | @Component 10 | @ConditionalOnMissingBean(ExceptionFilter.class) 11 | public class DefaultExceptionFilter implements ExceptionFilter { 12 | @Override 13 | public boolean shouldNotify(Throwable throwable) { 14 | // By default, notify for all exceptions 15 | return true; 16 | } 17 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/filter/ExceptionFilter.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.filter; 2 | 3 | /** 4 | * Interface for filtering exceptions 5 | */ 6 | public interface ExceptionFilter { 7 | /** 8 | * Determine whether to notify for the given exception 9 | * 10 | * @param throwable the exception to check 11 | * @return true if notification should be sent, false otherwise 12 | */ 13 | boolean shouldNotify(Throwable throwable); 14 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/formatter/DefaultNotificationFormatter.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.formatter; 2 | 3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 4 | import com.nolimit35.springkit.model.CodeAuthorInfo; 5 | import com.nolimit35.springkit.model.ExceptionInfo; 6 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 7 | import org.springframework.stereotype.Component; 8 | 9 | import java.time.format.DateTimeFormatter; 10 | import java.util.Arrays; 11 | import java.util.stream.Collectors; 12 | 13 | /** 14 | * Default implementation of NotificationFormatter 15 | */ 16 | @Component 17 | @ConditionalOnMissingBean(NotificationFormatter.class) 18 | public class DefaultNotificationFormatter implements NotificationFormatter { 19 | private final ExceptionNotifyProperties properties; 20 | public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); 21 | 22 | public DefaultNotificationFormatter(ExceptionNotifyProperties properties) { 23 | this.properties = properties; 24 | } 25 | 26 | @Override 27 | public String format(ExceptionInfo exceptionInfo) { 28 | StringBuilder sb = new StringBuilder(); 29 | 30 | // Format title as Markdown heading 31 | String title = properties.getNotification().getTitleTemplate() 32 | .replace("${appName}", exceptionInfo.getAppName()); 33 | sb.append("# ").append(title).append("\n\n"); 34 | 35 | // Horizontal rule in Markdown 36 | sb.append("---\n\n"); 37 | 38 | // Format exception details in Markdown 39 | sb.append("**异常时间:** ").append(exceptionInfo.getTime().format(DATE_FORMATTER)).append("\n\n"); 40 | sb.append("**异常类型:** ").append(exceptionInfo.getType()).append("\n\n"); 41 | sb.append("**异常描述:** ").append(exceptionInfo.getMessage()).append("\n\n"); 42 | sb.append("**异常位置:** ").append(exceptionInfo.getLocation()).append("\n\n"); 43 | 44 | // Format environment if available 45 | if (exceptionInfo.getEnvironment() != null && !exceptionInfo.getEnvironment().isEmpty()) { 46 | sb.append("**当前环境:** ").append(exceptionInfo.getEnvironment()).append("\n\n"); 47 | } 48 | 49 | // Add branch information from GitHub or Gitee configuration 50 | String branch = null; 51 | if (properties.getGithub() != null && properties.getGithub().getToken() != null && properties.getGithub().getBranch() != null) { 52 | branch = properties.getGithub().getBranch(); 53 | } else if (properties.getGitee() != null && properties.getGitee().getToken() != null && properties.getGitee().getBranch() != null) { 54 | branch = properties.getGitee().getBranch(); 55 | } 56 | 57 | if (branch != null && !branch.isEmpty()) { 58 | sb.append("**当前分支:** ").append(branch).append("\n\n"); 59 | } 60 | 61 | // Format author info if available 62 | CodeAuthorInfo authorInfo = exceptionInfo.getAuthorInfo(); 63 | if (authorInfo != null) { 64 | sb.append("**代码提交者:** ").append(authorInfo.getName()) 65 | .append(" (").append(authorInfo.getEmail()).append(")\n\n"); 66 | 67 | if (authorInfo.getLastCommitTime() != null) { 68 | sb.append("**最后提交时间:** ").append(authorInfo.getLastCommitTime().format(DATE_FORMATTER)).append("\n\n"); 69 | } 70 | 71 | if(authorInfo.getCommitMessage() != null){ 72 | sb.append("**提交信息:** ").append(authorInfo.getCommitMessage()); 73 | } 74 | } 75 | 76 | // Format trace ID if available 77 | if (exceptionInfo.getTraceId() != null && !exceptionInfo.getTraceId().isEmpty()) { 78 | sb.append("**TraceID:** ").append(exceptionInfo.getTraceId()).append("\n\n"); 79 | 80 | // Include CLS trace URL as a clickable link if available 81 | if (exceptionInfo.getTraceUrl() != null && !exceptionInfo.getTraceUrl().isEmpty()) { 82 | sb.append("**云日志链路:** [点击查看日志](").append(exceptionInfo.getTraceUrl()).append(")\n\n"); 83 | } 84 | } 85 | 86 | sb.append("---\n\n"); 87 | 88 | // Format stacktrace if enabled 89 | if (properties.getNotification().isIncludeStacktrace() && exceptionInfo.getStacktrace() != null) { 90 | sb.append("### 堆栈信息:\n\n"); 91 | sb.append("```java\n"); 92 | 93 | // Limit stacktrace lines if configured 94 | int maxLines = properties.getNotification().getMaxStacktraceLines(); 95 | if (maxLines > 0) { 96 | String[] lines = exceptionInfo.getStacktrace().split("\n"); 97 | String limitedStacktrace = Arrays.stream(lines) 98 | .limit(maxLines) 99 | .collect(Collectors.joining("\n")); 100 | sb.append(limitedStacktrace); 101 | 102 | if (lines.length > maxLines) { 103 | sb.append("\n... (").append(lines.length - maxLines).append(" more lines)"); 104 | } 105 | } else { 106 | sb.append(exceptionInfo.getStacktrace()); 107 | } 108 | 109 | sb.append("\n```"); 110 | } 111 | 112 | return sb.toString(); 113 | } 114 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/formatter/NotificationFormatter.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.formatter; 2 | 3 | import com.nolimit35.springkit.model.ExceptionInfo; 4 | 5 | /** 6 | * Interface for formatting exception notifications 7 | */ 8 | public interface NotificationFormatter { 9 | /** 10 | * Format exception information into notification content 11 | * 12 | * @param exceptionInfo the exception information 13 | * @return formatted notification content 14 | */ 15 | String format(ExceptionInfo exceptionInfo); 16 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/model/CodeAuthorInfo.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.model; 2 | 3 | import lombok.Builder; 4 | import lombok.Data; 5 | 6 | import java.time.LocalDateTime; 7 | 8 | /** 9 | * Code author information model 10 | */ 11 | @Data 12 | @Builder 13 | public class CodeAuthorInfo { 14 | /** 15 | * Author name 16 | */ 17 | private String name; 18 | 19 | /** 20 | * Author email 21 | */ 22 | private String email; 23 | 24 | /** 25 | * Last commit time 26 | */ 27 | private LocalDateTime lastCommitTime; 28 | 29 | /** 30 | * Source file name 31 | */ 32 | private String fileName; 33 | 34 | /** 35 | * Source line number 36 | */ 37 | private int lineNumber; 38 | 39 | /** 40 | * Commit message 41 | */ 42 | private String commitMessage; 43 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/model/ExceptionInfo.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.model; 2 | 3 | import lombok.Builder; 4 | import lombok.Data; 5 | 6 | import java.time.LocalDateTime; 7 | 8 | /** 9 | * Exception information model 10 | */ 11 | @Data 12 | @Builder 13 | public class ExceptionInfo { 14 | /** 15 | * Exception time 16 | */ 17 | private LocalDateTime time; 18 | 19 | /** 20 | * Exception type 21 | */ 22 | private String type; 23 | 24 | /** 25 | * Exception message 26 | */ 27 | private String message; 28 | 29 | /** 30 | * Exception location (file and line number) 31 | */ 32 | private String location; 33 | 34 | /** 35 | * Exception stacktrace 36 | */ 37 | private String stacktrace; 38 | 39 | /** 40 | * Trace ID 41 | */ 42 | private String traceId; 43 | 44 | /** 45 | * Application name 46 | */ 47 | private String appName; 48 | 49 | /** 50 | * Current environment (e.g., dev, test, prod) 51 | */ 52 | private String environment; 53 | 54 | /** 55 | * Code author information 56 | */ 57 | private CodeAuthorInfo authorInfo; 58 | 59 | /** 60 | * Trace URL 61 | */ 62 | private String traceUrl; 63 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/monitor/Monitor.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.monitor; 2 | 3 | import java.time.LocalDateTime; 4 | import java.util.Arrays; 5 | import java.util.stream.Collectors; 6 | 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.beans.factory.annotation.Value; 11 | import org.springframework.stereotype.Component; 12 | 13 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 14 | import com.nolimit35.springkit.model.ExceptionInfo; 15 | import com.nolimit35.springkit.notification.NotificationProviderManager; 16 | import com.nolimit35.springkit.trace.TraceInfoProvider; 17 | 18 | import lombok.extern.slf4j.Slf4j; 19 | 20 | /** 21 | * 监控工具,用于日志记录和发送通知 22 | *

23 | * 该类提供类似于SLF4J日志的方法,同时通过exception-notify中配置的通知渠道发送通知 24 | */ 25 | @Slf4j 26 | @Component 27 | public class Monitor { 28 | 29 | private static NotificationProviderManager notificationManager; 30 | private static ExceptionNotifyProperties properties; 31 | private static TraceInfoProvider traceInfoProvider; 32 | 33 | @Value("${spring.application.name:unknown}") 34 | private String applicationName; 35 | 36 | private static String appName = "unknown"; 37 | 38 | @Autowired 39 | public Monitor(NotificationProviderManager notificationManager, 40 | @Value("${spring.application.name:unknown}") String applicationName, 41 | ExceptionNotifyProperties properties, 42 | TraceInfoProvider traceInfoProvider) { 43 | Monitor.notificationManager = notificationManager; 44 | Monitor.appName = applicationName; 45 | Monitor.properties = properties; 46 | Monitor.traceInfoProvider = traceInfoProvider; 47 | } 48 | 49 | /** 50 | * 记录信息消息并发送通知 51 | * 52 | * @param message 信息消息 53 | */ 54 | public static void info(String message) { 55 | log.info(message); 56 | // 获取调用堆栈 57 | StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); 58 | // 索引2通常是调用者(0是getStackTrace,1是当前方法) 59 | StackTraceElement caller = stackTrace.length > 2 ? stackTrace[2] : null; 60 | sendNotification("INFO: " + message, null, caller); 61 | } 62 | 63 | /** 64 | * 使用自定义日志记录器记录信息消息并发送通知 65 | * 66 | * @param logger 要使用的日志记录器 67 | * @param message 信息消息 68 | */ 69 | public static void info(Logger logger, String message) { 70 | logger.info(message); 71 | // 获取调用堆栈 72 | StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); 73 | // 索引2通常是调用者(0是getStackTrace,1是当前方法) 74 | StackTraceElement caller = stackTrace.length > 2 ? stackTrace[2] : null; 75 | sendNotification("INFO: " + message, null, caller); 76 | } 77 | 78 | /** 79 | * 记录带有异常的信息消息并发送通知 80 | * 81 | * @param message 信息消息 82 | * @param throwable 异常 83 | */ 84 | public static void info(String message, Throwable throwable) { 85 | log.info(message, throwable); 86 | // 异常情况下,优先使用异常的堆栈信息,不需要额外获取调用者位置 87 | sendNotification("INFO: " + message, throwable); 88 | } 89 | 90 | /** 91 | * 使用自定义日志记录器记录带有异常的信息消息并发送通知 92 | * 93 | * @param logger 要使用的日志记录器 94 | * @param message 信息消息 95 | * @param throwable 异常 96 | */ 97 | public static void info(Logger logger, String message, Throwable throwable) { 98 | logger.info(message, throwable); 99 | // 异常情况下,优先使用异常的堆栈信息,不需要额外获取调用者位置 100 | sendNotification("INFO: " + message, throwable); 101 | } 102 | 103 | /** 104 | * 记录警告消息并发送通知 105 | * 106 | * @param message 警告消息 107 | */ 108 | public static void warn(String message) { 109 | log.warn(message); 110 | // 获取调用堆栈 111 | StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); 112 | // 索引2通常是调用者(0是getStackTrace,1是当前方法) 113 | StackTraceElement caller = stackTrace.length > 2 ? stackTrace[2] : null; 114 | sendNotification("WARN: " + message, null, caller); 115 | } 116 | 117 | /** 118 | * 记录带有异常的警告消息并发送通知 119 | * 120 | * @param message 警告消息 121 | * @param throwable 异常 122 | */ 123 | public static void warn(String message, Throwable throwable) { 124 | log.warn(message, throwable); 125 | // 异常情况下,优先使用异常的堆栈信息,不需要额外获取调用者位置 126 | sendNotification("WARN: " + message, throwable); 127 | } 128 | 129 | /** 130 | * 使用自定义日志记录器记录警告消息并发送通知 131 | * 132 | * @param logger 要使用的日志记录器 133 | * @param message 警告消息 134 | */ 135 | public static void warn(Logger logger, String message) { 136 | logger.warn(message); 137 | // 获取调用堆栈 138 | StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); 139 | // 索引2通常是调用者(0是getStackTrace,1是当前方法) 140 | StackTraceElement caller = stackTrace.length > 2 ? stackTrace[2] : null; 141 | sendNotification("WARN: " + message, null, caller); 142 | } 143 | 144 | /** 145 | * 使用自定义日志记录器记录带有异常的警告消息并发送通知 146 | * 147 | * @param logger 要使用的日志记录器 148 | * @param message 警告消息 149 | * @param throwable 异常 150 | */ 151 | public static void warn(Logger logger, String message, Throwable throwable) { 152 | logger.warn(message, throwable); 153 | // 异常情况下,优先使用异常的堆栈信息,不需要额外获取调用者位置 154 | sendNotification("WARN: " + message, throwable); 155 | } 156 | 157 | /** 158 | * 记录错误消息并发送通知 159 | * 160 | * @param message 错误消息 161 | */ 162 | public static void error(String message) { 163 | log.error(message); 164 | // 获取调用堆栈 165 | StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); 166 | // 索引2通常是调用者(0是getStackTrace,1是当前方法) 167 | StackTraceElement caller = stackTrace.length > 2 ? stackTrace[2] : null; 168 | sendNotification(message, null, caller); 169 | } 170 | 171 | /** 172 | * 记录带有异常的错误消息并发送通知 173 | * 174 | * @param message 错误消息 175 | * @param throwable 异常 176 | */ 177 | public static void error(String message, Throwable throwable) { 178 | log.error(message, throwable); 179 | // 异常情况下,优先使用异常的堆栈信息,不需要额外获取调用者位置 180 | sendNotification(message, throwable); 181 | } 182 | 183 | /** 184 | * 使用自定义日志记录器记录错误消息并发送通知 185 | * 186 | * @param logger 要使用的日志记录器 187 | * @param message 错误消息 188 | */ 189 | public static void error(Logger logger, String message) { 190 | logger.error(message); 191 | // 获取调用堆栈 192 | StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); 193 | // 索引2通常是调用者(0是getStackTrace,1是当前方法) 194 | StackTraceElement caller = stackTrace.length > 2 ? stackTrace[2] : null; 195 | sendNotification(message, null, caller); 196 | } 197 | 198 | /** 199 | * 使用自定义日志记录器记录带有异常的错误消息并发送通知 200 | * 201 | * @param logger 要使用的日志记录器 202 | * @param message 错误消息 203 | * @param throwable 异常 204 | */ 205 | public static void error(Logger logger, String message, Throwable throwable) { 206 | logger.error(message, throwable); 207 | // 异常情况下,优先使用异常的堆栈信息,不需要额外获取调用者位置 208 | sendNotification(message, throwable); 209 | } 210 | 211 | /** 212 | * 获取指定类的日志记录器 213 | * 214 | * @param clazz 要获取日志记录器的类 215 | * @return 日志记录器 216 | */ 217 | public static Logger getLogger(Class clazz) { 218 | return LoggerFactory.getLogger(clazz); 219 | } 220 | 221 | /** 222 | * 获取指定名称的日志记录器 223 | * 224 | * @param name 要获取日志记录器的名称 225 | * @return 日志记录器 226 | */ 227 | public static Logger getLogger(String name) { 228 | return LoggerFactory.getLogger(name); 229 | } 230 | 231 | private static void sendNotification(String message, Throwable throwable) { 232 | sendNotification(message, throwable, null); 233 | } 234 | 235 | private static void sendNotification(String message, Throwable throwable, StackTraceElement caller) { 236 | if (notificationManager == null) { 237 | log.warn("Monitor尚未完全初始化,通知将不会被发送"); 238 | return; 239 | } 240 | 241 | try { 242 | ExceptionInfo exceptionInfo = buildExceptionInfo(message, throwable, caller); 243 | 244 | // 通过通知管理器发送通知 245 | notificationManager.sendNotification(exceptionInfo); 246 | } catch (Exception e) { 247 | log.error("发送通知失败", e); 248 | } 249 | } 250 | 251 | private static ExceptionInfo buildExceptionInfo(String message, Throwable throwable) { 252 | return buildExceptionInfo(message, throwable, null); 253 | } 254 | 255 | private static ExceptionInfo buildExceptionInfo(String message, Throwable throwable, StackTraceElement caller) { 256 | ExceptionInfo.ExceptionInfoBuilder builder = ExceptionInfo.builder() 257 | .time(LocalDateTime.now()) 258 | .appName(appName) 259 | .message(message); 260 | 261 | // 如果配置中启用了trace,获取traceId 262 | String traceId = null; 263 | if (properties != null && properties.getTrace().isEnabled() && traceInfoProvider != null) { 264 | // 使用TraceInfoProvider获取traceId 265 | traceId = traceInfoProvider.getTraceId(); 266 | 267 | if (traceId != null && !traceId.isEmpty()) { 268 | builder.traceId(traceId); 269 | 270 | // 使用TraceInfoProvider生成trace URL 271 | String traceUrl = traceInfoProvider.generateTraceUrl(traceId); 272 | if (traceUrl != null) { 273 | builder.traceUrl(traceUrl); 274 | } 275 | } 276 | } 277 | 278 | if (throwable != null) { 279 | builder.type(throwable.getClass().getName()) 280 | .stacktrace(getStackTraceAsString(throwable)); 281 | 282 | // 如果有异常堆栈信息,查找可用的第一个应用程序元素 283 | StackTraceElement[] stackTraceElements = throwable.getStackTrace(); 284 | if (stackTraceElements != null && stackTraceElements.length > 0) { 285 | // 仅使用堆栈跟踪中的第一个元素 286 | StackTraceElement element = stackTraceElements[0]; 287 | builder.location(element.getClassName() + "." + element.getMethodName() + 288 | "(" + element.getFileName() + ":" + element.getLineNumber() + ")"); 289 | } 290 | } else { 291 | builder.type("MonitoredMessage"); 292 | 293 | // 如果没有异常但有调用者信息,使用调用者信息 294 | if (caller != null) { 295 | builder.location(caller.getClassName() + "." + caller.getMethodName() + 296 | "(" + caller.getFileName() + ":" + caller.getLineNumber() + ")"); 297 | } 298 | } 299 | 300 | return builder.build(); 301 | } 302 | 303 | 304 | 305 | private static String getStackTraceAsString(Throwable throwable) { 306 | if (throwable == null) { 307 | return ""; 308 | } 309 | 310 | return Arrays.stream(throwable.getStackTrace()) 311 | .limit(10) // 限制堆栈深度 312 | .map(StackTraceElement::toString) 313 | .collect(Collectors.joining("\n")); 314 | } 315 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/notification/AbstractNotificationProvider.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.notification; 2 | 3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 4 | import com.nolimit35.springkit.model.ExceptionInfo; 5 | import lombok.extern.slf4j.Slf4j; 6 | 7 | /** 8 | * Abstract base class for notification providers 9 | * Makes it easier to implement custom notification providers 10 | */ 11 | @Slf4j 12 | public abstract class AbstractNotificationProvider implements NotificationProvider { 13 | protected final ExceptionNotifyProperties properties; 14 | 15 | public AbstractNotificationProvider(ExceptionNotifyProperties properties) { 16 | this.properties = properties; 17 | } 18 | 19 | @Override 20 | public boolean sendNotification(ExceptionInfo exceptionInfo) { 21 | if (!isEnabled()) { 22 | log.debug("{} notification provider is not enabled", getProviderName()); 23 | return false; 24 | } 25 | 26 | try { 27 | return doSendNotification(exceptionInfo); 28 | } catch (Exception e) { 29 | log.error("Error sending notification through {}: {}", 30 | getProviderName(), e.getMessage(), e); 31 | return false; 32 | } 33 | } 34 | 35 | /** 36 | * Implement actual notification sending logic in subclasses 37 | * 38 | * @param exceptionInfo the complete exception information 39 | * @return true if notification was sent successfully 40 | * @throws Exception if an error occurs during sending 41 | */ 42 | protected abstract boolean doSendNotification(ExceptionInfo exceptionInfo) throws Exception; 43 | 44 | /** 45 | * Get provider name for logging purposes 46 | * 47 | * @return name of the provider 48 | */ 49 | protected String getProviderName() { 50 | return this.getClass().getSimpleName(); 51 | } 52 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/notification/NotificationProvider.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.notification; 2 | 3 | import com.nolimit35.springkit.model.ExceptionInfo; 4 | 5 | /** 6 | * Interface for notification providers 7 | */ 8 | public interface NotificationProvider { 9 | /** 10 | * Send a notification 11 | * 12 | * @param exceptionInfo the complete exception information object 13 | * @return true if notification was sent successfully, false otherwise 14 | */ 15 | boolean sendNotification(ExceptionInfo exceptionInfo); 16 | 17 | /** 18 | * Check if this provider is enabled 19 | * 20 | * @return true if the provider is enabled, false otherwise 21 | */ 22 | boolean isEnabled(); 23 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/notification/NotificationProviderManager.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.notification; 2 | 3 | import com.nolimit35.springkit.model.ExceptionInfo; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.stereotype.Component; 6 | 7 | import java.util.List; 8 | import java.util.concurrent.atomic.AtomicBoolean; 9 | 10 | /** 11 | * Manager for notification providers 12 | */ 13 | @Slf4j 14 | @Component 15 | public class NotificationProviderManager { 16 | private final List providers; 17 | 18 | public NotificationProviderManager(List providers) { 19 | this.providers = providers; 20 | 21 | if (log.isInfoEnabled()) { 22 | log.info("Initialized NotificationProviderManager with {} provider(s)", providers.size()); 23 | for (NotificationProvider provider : providers) { 24 | log.info("Found notification provider: {} (enabled: {})", 25 | provider.getClass().getSimpleName(), 26 | provider.isEnabled()); 27 | } 28 | } 29 | } 30 | 31 | /** 32 | * Send notification through all enabled providers 33 | * 34 | * @param exceptionInfo the complete exception information 35 | * @return true if at least one provider sent the notification successfully 36 | */ 37 | public boolean sendNotification(ExceptionInfo exceptionInfo) { 38 | if (providers.isEmpty()) { 39 | log.warn("No notification providers available"); 40 | return false; 41 | } 42 | 43 | AtomicBoolean atLeastOneSent = new AtomicBoolean(false); 44 | 45 | // Try sending through all enabled providers 46 | providers.stream() 47 | .filter(NotificationProvider::isEnabled) 48 | .forEach(provider -> { 49 | try { 50 | boolean sent = provider.sendNotification(exceptionInfo); 51 | if (sent) { 52 | atLeastOneSent.set(true); 53 | log.info("Notification sent successfully through {}", 54 | provider.getClass().getSimpleName()); 55 | } else { 56 | log.warn("Failed to send notification through {}", 57 | provider.getClass().getSimpleName()); 58 | } 59 | } catch (Exception e) { 60 | log.error("Error sending notification through {}: {}", 61 | provider.getClass().getSimpleName(), e.getMessage(), e); 62 | } 63 | }); 64 | 65 | return atLeastOneSent.get(); 66 | } 67 | 68 | /** 69 | * Get all available providers 70 | * 71 | * @return list of notification providers 72 | */ 73 | public List getProviders() { 74 | return providers; 75 | } 76 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/notification/provider/DingTalkNotificationProvider.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.notification.provider; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 5 | import com.nolimit35.springkit.formatter.NotificationFormatter; 6 | import com.nolimit35.springkit.model.ExceptionInfo; 7 | import com.nolimit35.springkit.notification.AbstractNotificationProvider; 8 | import lombok.extern.slf4j.Slf4j; 9 | import okhttp3.*; 10 | import org.springframework.stereotype.Component; 11 | import org.springframework.util.StringUtils; 12 | 13 | import java.util.Collections; 14 | import java.util.HashMap; 15 | import java.util.List; 16 | import java.util.Map; 17 | 18 | /** 19 | * DingTalk implementation of NotificationProvider 20 | */ 21 | @Slf4j 22 | @Component 23 | public class DingTalkNotificationProvider extends AbstractNotificationProvider { 24 | private final OkHttpClient httpClient; 25 | private final ObjectMapper objectMapper; 26 | private final NotificationFormatter formatter; 27 | private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); 28 | 29 | public DingTalkNotificationProvider(ExceptionNotifyProperties properties, NotificationFormatter formatter) { 30 | super(properties); 31 | this.httpClient = new OkHttpClient(); 32 | this.objectMapper = new ObjectMapper(); 33 | this.formatter = formatter; 34 | } 35 | 36 | @Override 37 | protected boolean doSendNotification(ExceptionInfo exceptionInfo) throws Exception { 38 | String webhook = properties.getDingtalk().getWebhook(); 39 | 40 | // Format the exception info into a notification 41 | String content = formatter.format(exceptionInfo); 42 | 43 | // Extract title from the first line of content 44 | String title = content.split("\n")[0]; 45 | 46 | // Build request body 47 | Map requestBody = new HashMap<>(); 48 | requestBody.put("msgtype", "markdown"); 49 | 50 | // Format content as markdown 51 | String formattedContent = "#### " + title + "\n" + content; 52 | 53 | Map text = new HashMap<>(); 54 | text.put("text", formattedContent); 55 | text.put("title", "异常告警"); 56 | 57 | 58 | // 添加处理人信息 59 | if (exceptionInfo.getAuthorInfo() != null && StringUtils.hasText(exceptionInfo.getAuthorInfo().getEmail()) && 60 | properties.getDingtalk().getAt() != null && properties.getDingtalk().getAt().isEnabled()) { 61 | 62 | if (properties.getDingtalk().getAt().getUserIdMappingGitEmail() != null 63 | && !properties.getDingtalk().getAt().getUserIdMappingGitEmail().isEmpty()) { 64 | // at 具体用户 65 | String dingUserId = properties.getDingtalk().getAt().getUserIdMappingGitEmail().entrySet().stream() 66 | // 根据邮箱匹配对应的企微用户id 67 | .filter(entry -> entry.getValue().contains(exceptionInfo.getAuthorInfo().getEmail())) 68 | .map(Map.Entry::getKey) 69 | .findFirst() 70 | .orElse(null); 71 | 72 | if (StringUtils.hasText(dingUserId)) { 73 | Map> atUserId = new HashMap<>(); 74 | atUserId.put("atUserIds", Collections.singletonList(dingUserId)); 75 | requestBody.put("at", atUserId); 76 | } 77 | } 78 | } 79 | 80 | requestBody.put("markdown", text); 81 | 82 | 83 | String jsonBody = objectMapper.writeValueAsString(requestBody); 84 | 85 | Request request = new Request.Builder() 86 | .url(webhook) 87 | .header("Content-Type", "application/json") 88 | .post(RequestBody.create(jsonBody, JSON)) 89 | .build(); 90 | 91 | try (Response response = httpClient.newCall(request).execute()) { 92 | if (!response.isSuccessful()) { 93 | log.error("Failed to send DingTalk notification: {}", response.code()); 94 | return false; 95 | } 96 | 97 | String responseBody = response.body().string(); 98 | log.debug("DingTalk response: {}", responseBody); 99 | return true; 100 | } 101 | } 102 | 103 | @Override 104 | public boolean isEnabled() { 105 | return properties.isEnabled() && 106 | properties.getDingtalk().getWebhook() != null && 107 | !properties.getDingtalk().getWebhook().isEmpty(); 108 | } 109 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/notification/provider/FeishuNotificationProvider.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.notification.provider; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 5 | import com.nolimit35.springkit.formatter.NotificationFormatter; 6 | import com.nolimit35.springkit.model.CodeAuthorInfo; 7 | import com.nolimit35.springkit.model.ExceptionInfo; 8 | import com.nolimit35.springkit.notification.AbstractNotificationProvider; 9 | import lombok.extern.slf4j.Slf4j; 10 | import okhttp3.*; 11 | import org.springframework.stereotype.Component; 12 | import org.springframework.util.StringUtils; 13 | 14 | import java.util.*; 15 | import java.util.stream.Collectors; 16 | 17 | import static com.nolimit35.springkit.formatter.DefaultNotificationFormatter.DATE_FORMATTER; 18 | 19 | /** 20 | * Feishu implementation of NotificationProvider 21 | */ 22 | @Slf4j 23 | @Component 24 | public class FeishuNotificationProvider extends AbstractNotificationProvider { 25 | private final OkHttpClient httpClient; 26 | private final ObjectMapper objectMapper; 27 | private final NotificationFormatter formatter; 28 | private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); 29 | 30 | public FeishuNotificationProvider(ExceptionNotifyProperties properties, NotificationFormatter formatter) { 31 | super(properties); 32 | this.httpClient = new OkHttpClient(); 33 | this.objectMapper = new ObjectMapper(); 34 | this.formatter = formatter; 35 | } 36 | 37 | @Override 38 | protected boolean doSendNotification(ExceptionInfo exceptionInfo) throws Exception { 39 | String webhook = properties.getFeishu().getWebhook(); 40 | 41 | // 飞书机器人不能直接支持 markdown 格式,不使用 formatter 格式化,直接使用原始的异常信息 42 | // String content = formatter.format(exceptionInfo); 43 | StringBuilder sb = new StringBuilder(); 44 | 45 | // Format title as Markdown heading 46 | String title = properties.getNotification().getTitleTemplate() 47 | .replace("${appName}", exceptionInfo.getAppName()); 48 | sb.append(title).append("\n"); 49 | 50 | // Format exception details in Markdown 51 | sb.append("异常时间:").append(exceptionInfo.getTime().format(DATE_FORMATTER)).append("\n"); 52 | sb.append("异常类型:").append(exceptionInfo.getType()).append("\n"); 53 | sb.append("异常描述:").append(exceptionInfo.getMessage()).append("\n"); 54 | sb.append("异常位置:").append(exceptionInfo.getLocation()).append("\n"); 55 | 56 | // Format environment if available 57 | if (exceptionInfo.getEnvironment() != null && !exceptionInfo.getEnvironment().isEmpty()) { 58 | sb.append("当前环境:").append(exceptionInfo.getEnvironment()).append("\n"); 59 | } 60 | 61 | // Add branch information from GitHub or Gitee configuration 62 | String branch = null; 63 | if (properties.getGithub() != null && properties.getGithub().getToken() != null && properties.getGithub().getBranch() != null) { 64 | branch = properties.getGithub().getBranch(); 65 | } else if (properties.getGitee() != null && properties.getGitee().getToken() != null && properties.getGitee().getBranch() != null) { 66 | branch = properties.getGitee().getBranch(); 67 | } 68 | 69 | if (branch != null && !branch.isEmpty()) { 70 | sb.append("当前分支:").append(branch).append("\n"); 71 | } 72 | 73 | // Format author info if available 74 | CodeAuthorInfo authorInfo = exceptionInfo.getAuthorInfo(); 75 | if (authorInfo != null) { 76 | sb.append("代码提交者:").append(authorInfo.getName()) 77 | .append(" (").append(authorInfo.getEmail()).append(")\n"); 78 | 79 | if (authorInfo.getLastCommitTime() != null) { 80 | sb.append("最后提交时间:").append(authorInfo.getLastCommitTime().format(DATE_FORMATTER)).append("\n"); 81 | } 82 | 83 | if(authorInfo.getCommitMessage() != null){ 84 | sb.append("提交信息:").append(authorInfo.getCommitMessage()); 85 | } 86 | } 87 | 88 | // Format trace ID if available 89 | if (exceptionInfo.getTraceId() != null && !exceptionInfo.getTraceId().isEmpty()) { 90 | sb.append("TraceID:").append(exceptionInfo.getTraceId()).append("\n"); 91 | 92 | // Include CLS trace URL as a clickable link if available 93 | if (exceptionInfo.getTraceUrl() != null && !exceptionInfo.getTraceUrl().isEmpty()) { 94 | sb.append("云日志链路:").append(exceptionInfo.getTraceUrl()).append("\n"); 95 | } 96 | } 97 | 98 | // Format stacktrace if enabled 99 | if (properties.getNotification().isIncludeStacktrace() && exceptionInfo.getStacktrace() != null) { 100 | sb.append("堆栈信息:\n"); 101 | 102 | // Limit stacktrace lines if configured 103 | int maxLines = properties.getNotification().getMaxStacktraceLines(); 104 | if (maxLines > 0) { 105 | String[] lines = exceptionInfo.getStacktrace().split("\n"); 106 | String limitedStacktrace = Arrays.stream(lines) 107 | .limit(maxLines) 108 | .collect(Collectors.joining("\n")); 109 | sb.append(limitedStacktrace); 110 | 111 | if (lines.length > maxLines) { 112 | sb.append("\n... (").append(lines.length - maxLines).append(" more lines)"); 113 | } 114 | } else { 115 | sb.append(exceptionInfo.getStacktrace()); 116 | } 117 | } 118 | 119 | // 添加处理人信息 120 | if (exceptionInfo.getAuthorInfo() != null && StringUtils.hasText(exceptionInfo.getAuthorInfo().getEmail()) && 121 | properties.getFeishu().getAt() != null && properties.getFeishu().getAt().isEnabled()) { 122 | if (properties.getFeishu().getAt().getOpenIdMappingGitEmail() != null 123 | && !properties.getFeishu().getAt().getOpenIdMappingGitEmail().isEmpty()) { 124 | // at 具体用户 125 | String feishuOpenId = properties.getFeishu().getAt().getOpenIdMappingGitEmail().entrySet().stream() 126 | // 根据邮箱匹配对应的企微用户id 127 | .filter(entry -> entry.getValue().contains(exceptionInfo.getAuthorInfo().getEmail())) 128 | .map(Map.Entry::getKey) 129 | .findFirst() 130 | .orElse(null); 131 | 132 | if (StringUtils.hasText(feishuOpenId)) { 133 | sb.append(String.format("\n处理人: 名字", feishuOpenId)); 134 | } 135 | } 136 | } 137 | 138 | // Build request body according to Feishu bot API 139 | Map requestBody = new HashMap<>(); 140 | requestBody.put("msg_type", "text"); 141 | 142 | Map contentMap = new HashMap<>(); 143 | contentMap.put("text", sb.toString()); 144 | requestBody.put("content", contentMap); 145 | 146 | String jsonBody = objectMapper.writeValueAsString(requestBody); 147 | 148 | Request request = new Request.Builder() 149 | .url(webhook) 150 | .header("Content-Type", "application/json") 151 | .post(RequestBody.create(jsonBody, JSON)) 152 | .build(); 153 | 154 | try (Response response = httpClient.newCall(request).execute()) { 155 | if (!response.isSuccessful()) { 156 | log.error("Failed to send Feishu notification: {}", response.code()); 157 | return false; 158 | } 159 | 160 | String responseBody = response.body().string(); 161 | log.debug("Feishu response: {}", responseBody); 162 | return true; 163 | } 164 | } 165 | 166 | @Override 167 | public boolean isEnabled() { 168 | return properties.isEnabled() && 169 | properties.getFeishu().getWebhook() != null && 170 | !properties.getFeishu().getWebhook().isEmpty(); 171 | } 172 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/notification/provider/WeChatWorkNotificationProvider.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.notification.provider; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 5 | import com.nolimit35.springkit.formatter.NotificationFormatter; 6 | import com.nolimit35.springkit.model.ExceptionInfo; 7 | import com.nolimit35.springkit.notification.AbstractNotificationProvider; 8 | import lombok.extern.slf4j.Slf4j; 9 | import okhttp3.*; 10 | import org.springframework.stereotype.Component; 11 | import org.springframework.util.StringUtils; 12 | 13 | import java.util.HashMap; 14 | import java.util.Map; 15 | 16 | /** 17 | * WeChat Work implementation of NotificationProvider 18 | */ 19 | @Slf4j 20 | @Component 21 | public class WeChatWorkNotificationProvider extends AbstractNotificationProvider { 22 | private final OkHttpClient httpClient; 23 | private final ObjectMapper objectMapper; 24 | private final NotificationFormatter formatter; 25 | private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); 26 | 27 | public WeChatWorkNotificationProvider(ExceptionNotifyProperties properties, NotificationFormatter formatter) { 28 | super(properties); 29 | this.httpClient = new OkHttpClient(); 30 | this.objectMapper = new ObjectMapper(); 31 | this.formatter = formatter; 32 | } 33 | 34 | @Override 35 | protected boolean doSendNotification(ExceptionInfo exceptionInfo) throws Exception { 36 | String webhook = properties.getWechatwork().getWebhook(); 37 | 38 | // Format the exception info into a notification 39 | String content = formatter.format(exceptionInfo); 40 | 41 | // 添加处理人信息 42 | if (exceptionInfo.getAuthorInfo() != null && StringUtils.hasText(exceptionInfo.getAuthorInfo().getEmail()) && 43 | properties.getWechatwork().getAt() != null && properties.getWechatwork().getAt().isEnabled()) { 44 | 45 | if (properties.getWechatwork().getAt().getUserIdMappingGitEmail() != null 46 | && !properties.getWechatwork().getAt().getUserIdMappingGitEmail().isEmpty()) { 47 | // at 具体用户 48 | String qwUserId = properties.getWechatwork().getAt().getUserIdMappingGitEmail().entrySet().stream() 49 | // 根据邮箱匹配对应的企微用户id 50 | .filter(entry -> entry.getValue().contains(exceptionInfo.getAuthorInfo().getEmail())) 51 | .map(entryKey -> { 52 | // 处理 yaml 配置中 key 为 [@] 会序列化为 .@. 的情况 53 | if (entryKey.getKey().contains(".@.")) { 54 | return entryKey.getKey().replace(".@.", "@"); 55 | } 56 | return entryKey.getKey(); 57 | }) 58 | .findFirst() 59 | .orElse(null); 60 | 61 | if (StringUtils.hasText(qwUserId)) { 62 | content += new StringBuilder("\n**处理人:** <@").append(qwUserId).append(">\n"); 63 | } 64 | } 65 | } 66 | 67 | // Prepare request body 68 | Map requestBody = new HashMap<>(); 69 | requestBody.put("msgtype", "markdown"); 70 | 71 | Map markdown = new HashMap<>(); 72 | markdown.put("content", content); 73 | requestBody.put("markdown", markdown); 74 | 75 | String jsonBody = objectMapper.writeValueAsString(requestBody); 76 | 77 | Request request = new Request.Builder() 78 | .url(webhook) 79 | .post(RequestBody.create(jsonBody, JSON)) 80 | .build(); 81 | 82 | try (Response response = httpClient.newCall(request).execute()) { 83 | if (!response.isSuccessful()) { 84 | log.error("Failed to send WeChat Work notification: {}", response.code()); 85 | return false; 86 | } 87 | 88 | String responseBody = response.body().string(); 89 | log.debug("WeChat Work response: {}", responseBody); 90 | return true; 91 | } 92 | } 93 | 94 | @Override 95 | public boolean isEnabled() { 96 | return properties.isEnabled() && 97 | properties.getWechatwork().getWebhook() != null && 98 | !properties.getWechatwork().getWebhook().isEmpty(); 99 | } 100 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/service/AbstractGitSourceControlService.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.service; 2 | 3 | import java.time.format.DateTimeFormatter; 4 | 5 | import com.fasterxml.jackson.databind.ObjectMapper; 6 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 7 | import com.nolimit35.springkit.model.CodeAuthorInfo; 8 | import lombok.extern.slf4j.Slf4j; 9 | import okhttp3.OkHttpClient; 10 | 11 | /** 12 | * Abstract implementation of GitSourceControlService providing common functionality 13 | * for different git source control providers (GitHub, Gitee, etc.) 14 | */ 15 | @Slf4j 16 | public abstract class AbstractGitSourceControlService implements GitSourceControlService { 17 | 18 | protected final ExceptionNotifyProperties properties; 19 | protected final OkHttpClient httpClient; 20 | protected final ObjectMapper objectMapper; 21 | protected final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); 22 | 23 | protected AbstractGitSourceControlService(ExceptionNotifyProperties properties) { 24 | this.properties = properties; 25 | this.httpClient = new OkHttpClient(); 26 | this.objectMapper = new ObjectMapper(); 27 | } 28 | 29 | /** 30 | * Validates that the required configuration is present 31 | * 32 | * @param token The API token 33 | * @param repoOwner The repository owner 34 | * @param repoName The repository name 35 | * @param serviceName The name of the service (for logging) 36 | * @return true if configuration is valid, false otherwise 37 | */ 38 | protected boolean validateConfiguration(String token, String repoOwner, String repoName, String serviceName) { 39 | if (token == null || repoOwner == null || repoName == null) { 40 | log.warn("{} configuration is incomplete. Cannot fetch author information.", serviceName); 41 | return false; 42 | } 43 | return true; 44 | } 45 | 46 | /** 47 | * Abstract method to be implemented by concrete services to get author information 48 | * for a specific file and line 49 | * 50 | * @param fileName the file name 51 | * @param lineNumber the line number 52 | * @return author information or null if not found 53 | */ 54 | @Override 55 | public abstract CodeAuthorInfo getAuthorInfo(String fileName, int lineNumber); 56 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/service/EnvironmentProvider.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.service; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.core.env.Environment; 6 | import org.springframework.stereotype.Component; 7 | 8 | /** 9 | * Service to provide the current environment from Spring's active profiles 10 | */ 11 | @Slf4j 12 | @Component 13 | public class EnvironmentProvider { 14 | 15 | private final Environment environment; 16 | 17 | @Autowired 18 | public EnvironmentProvider(Environment environment) { 19 | this.environment = environment; 20 | } 21 | 22 | /** 23 | * Get the current environment from Spring's active profiles 24 | * 25 | * @return the current environment or "dev" if not found 26 | */ 27 | public String getCurrentEnvironment() { 28 | String[] activeProfiles = environment.getActiveProfiles(); 29 | 30 | if (activeProfiles.length > 0) { 31 | // Use the first active profile 32 | return activeProfiles[0]; 33 | } 34 | 35 | // Check default profiles if no active profiles found 36 | String[] defaultProfiles = environment.getDefaultProfiles(); 37 | if (defaultProfiles.length > 0) { 38 | return defaultProfiles[0]; 39 | } 40 | 41 | // Default to "dev" if no profiles found 42 | return "dev"; 43 | } 44 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/service/ExceptionAnalyzerService.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.service; 2 | 3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 4 | import com.nolimit35.springkit.model.CodeAuthorInfo; 5 | import com.nolimit35.springkit.model.ExceptionInfo; 6 | import com.nolimit35.springkit.trace.TraceInfoProvider; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.beans.factory.annotation.Value; 9 | import org.springframework.stereotype.Service; 10 | 11 | import java.time.LocalDateTime; 12 | import java.util.Arrays; 13 | import java.util.List; 14 | import java.util.stream.Collectors; 15 | 16 | /** 17 | * Service for analyzing exceptions 18 | */ 19 | @Slf4j 20 | @Service 21 | public class ExceptionAnalyzerService { 22 | private final List gitSourceControlServices; 23 | private final ExceptionNotifyProperties properties; 24 | private final TraceInfoProvider traceInfoProvider; 25 | 26 | @Value("${spring.application.name:unknown}") 27 | private String applicationName; 28 | 29 | public ExceptionAnalyzerService(List gitSourceControlServices, 30 | ExceptionNotifyProperties properties, 31 | TraceInfoProvider traceInfoProvider) { 32 | this.gitSourceControlServices = gitSourceControlServices; 33 | this.properties = properties; 34 | this.traceInfoProvider = traceInfoProvider; 35 | } 36 | 37 | /** 38 | * Analyze exception and create ExceptionInfo 39 | * 40 | * @param throwable the exception to analyze 41 | * @param traceId the trace ID (optional) 42 | * @return exception information 43 | */ 44 | public ExceptionInfo analyzeException(Throwable throwable, String traceId) { 45 | // Get exception details 46 | String exceptionType = throwable.getClass().getName(); 47 | String message = throwable.getMessage(); 48 | String stacktrace = getStackTraceAsString(throwable); 49 | 50 | // Find the first application-specific stack trace element 51 | StackTraceElement[] stackTraceElements = throwable.getStackTrace(); 52 | StackTraceElement firstAppElement = findFirstApplicationElement(stackTraceElements); 53 | 54 | String location = null; 55 | CodeAuthorInfo authorInfo = null; 56 | 57 | if (firstAppElement != null) { 58 | location = firstAppElement.getClassName() + "." + firstAppElement.getMethodName() + 59 | "(" + firstAppElement.getFileName() + ":" + firstAppElement.getLineNumber() + ")"; 60 | 61 | // Get author information from available git source control services 62 | try { 63 | String fileName = convertClassNameToFilePath(firstAppElement.getClassName()) + ".java"; 64 | authorInfo = findAuthorInfo(fileName, firstAppElement.getLineNumber()); 65 | } catch (Exception e) { 66 | log.error("Error getting author information", e); 67 | } 68 | } 69 | 70 | // Generate trace URL if trace is enabled and traceId is available 71 | String traceUrl = null; 72 | if (properties.getTrace().isEnabled() && traceId != null && !traceId.isEmpty()) { 73 | traceUrl = traceInfoProvider.generateTraceUrl(traceId); 74 | } 75 | 76 | // Build exception info 77 | return ExceptionInfo.builder() 78 | .time(LocalDateTime.now()) 79 | .type(exceptionType) 80 | .message(message != null ? message : "No message") 81 | .location(location) 82 | .stacktrace(stacktrace) 83 | .traceId(traceId) 84 | .appName(applicationName) 85 | .environment(properties.getEnvironment().getCurrent()) 86 | .authorInfo(authorInfo) 87 | .traceUrl(traceUrl) 88 | .build(); 89 | } 90 | 91 | /** 92 | * Find author information by trying all available git source control services 93 | * 94 | * @param fileName the file name 95 | * @param lineNumber the line number 96 | * @return author information or null if not found 97 | */ 98 | private CodeAuthorInfo findAuthorInfo(String fileName, int lineNumber) { 99 | for (GitSourceControlService service : gitSourceControlServices) { 100 | CodeAuthorInfo authorInfo = service.getAuthorInfo(fileName, lineNumber); 101 | if (authorInfo != null) { 102 | return authorInfo; 103 | } 104 | } 105 | return null; 106 | } 107 | 108 | 109 | 110 | /** 111 | * Find the first application-specific stack trace element 112 | * 113 | * @param stackTraceElements the stack trace elements 114 | * @return the first application-specific element or null if not found 115 | */ 116 | private StackTraceElement findFirstApplicationElement(StackTraceElement[] stackTraceElements) { 117 | // Check if package filtering is enabled 118 | if (properties.getPackageFilter().isEnabled() && !properties.getPackageFilter().getIncludePackages().isEmpty()) { 119 | // Filter based on configured packages 120 | for (StackTraceElement element : stackTraceElements) { 121 | String className = element.getClassName(); 122 | 123 | // Check if the class belongs to any of the configured packages 124 | for (String packageName : properties.getPackageFilter().getIncludePackages()) { 125 | if (className.startsWith(packageName)) { 126 | return element; 127 | } 128 | } 129 | } 130 | 131 | // If no stack trace element matches the configured packages, return the first element 132 | return stackTraceElements.length > 0 ? stackTraceElements[0] : null; 133 | } else { 134 | // Original behavior if package filtering is not enabled 135 | for (StackTraceElement element : stackTraceElements) { 136 | String className = element.getClassName(); 137 | 138 | // Skip common framework packages 139 | if (!className.startsWith("java.") && 140 | !className.startsWith("javax.") && 141 | !className.startsWith("sun.") && 142 | !className.startsWith("com.sun.") && 143 | !className.startsWith("org.springframework.") && 144 | !className.startsWith("org.apache.") && 145 | !className.startsWith("com.nolimit35.springkit")) { 146 | return element; 147 | } 148 | } 149 | 150 | // If no application-specific element found, return the first element 151 | return stackTraceElements.length > 0 ? stackTraceElements[0] : null; 152 | } 153 | } 154 | 155 | /** 156 | * Convert class name to file path 157 | * 158 | * @param className the class name 159 | * @return file path 160 | */ 161 | private String convertClassNameToFilePath(String className) { 162 | return className.replace('.', '/'); 163 | } 164 | 165 | /** 166 | * Get stack trace as string 167 | * 168 | * @param throwable the exception 169 | * @return stack trace as string 170 | */ 171 | private String getStackTraceAsString(Throwable throwable) { 172 | return Arrays.stream(throwable.getStackTrace()) 173 | .map(StackTraceElement::toString) 174 | .collect(Collectors.joining("\n")); 175 | } 176 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/service/ExceptionNotificationService.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.service; 2 | 3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 4 | import com.nolimit35.springkit.filter.ExceptionFilter; 5 | import com.nolimit35.springkit.formatter.NotificationFormatter; 6 | import com.nolimit35.springkit.model.ExceptionInfo; 7 | import com.nolimit35.springkit.notification.NotificationProviderManager; 8 | import com.nolimit35.springkit.trace.TraceInfoProvider; 9 | import lombok.extern.slf4j.Slf4j; 10 | import org.springframework.stereotype.Service; 11 | 12 | /** 13 | * Service for handling exception notifications 14 | */ 15 | @Slf4j 16 | @Service 17 | public class ExceptionNotificationService { 18 | private final ExceptionNotifyProperties properties; 19 | private final ExceptionAnalyzerService analyzerService; 20 | private final NotificationProviderManager notificationManager; 21 | private final NotificationFormatter formatter; 22 | private final ExceptionFilter filter; 23 | private final EnvironmentProvider environmentProvider; 24 | private final TraceInfoProvider traceInfoProvider; 25 | 26 | public ExceptionNotificationService( 27 | ExceptionNotifyProperties properties, 28 | ExceptionAnalyzerService analyzerService, 29 | NotificationProviderManager notificationManager, 30 | NotificationFormatter formatter, 31 | ExceptionFilter filter, 32 | EnvironmentProvider environmentProvider, 33 | TraceInfoProvider traceInfoProvider) { 34 | this.properties = properties; 35 | this.analyzerService = analyzerService; 36 | this.notificationManager = notificationManager; 37 | this.formatter = formatter; 38 | this.filter = filter; 39 | this.environmentProvider = environmentProvider; 40 | this.traceInfoProvider = traceInfoProvider; 41 | } 42 | 43 | /** 44 | * Process exception and send notification if needed 45 | * 46 | * @param throwable the exception to process 47 | */ 48 | public void processException(Throwable throwable) { 49 | if (!properties.isEnabled()) { 50 | log.debug("Exception notification is disabled"); 51 | return; 52 | } 53 | 54 | // Get current environment from Spring profiles 55 | String currentEnvironment = environmentProvider.getCurrentEnvironment(); 56 | 57 | // Update the current environment in properties 58 | properties.getEnvironment().setCurrent(currentEnvironment); 59 | 60 | // Check if we should report from the current environment 61 | if (!properties.getEnvironment().shouldReportFromCurrentEnvironment()) { 62 | log.debug("Exception notification is disabled for the current environment: {}", currentEnvironment); 63 | return; 64 | } 65 | 66 | if (!filter.shouldNotify(throwable)) { 67 | log.debug("Exception filtered out: {}", throwable.getClass().getName()); 68 | return; 69 | } 70 | 71 | try { 72 | // Get trace ID from provider 73 | String traceId = traceInfoProvider.getTraceId(); 74 | 75 | // Analyze exception 76 | ExceptionInfo exceptionInfo = analyzerService.analyzeException(throwable, traceId); 77 | 78 | // Add current environment to exception info 79 | exceptionInfo.setEnvironment(currentEnvironment); 80 | 81 | // Send notification via notification manager 82 | boolean notificationSent = notificationManager.sendNotification(exceptionInfo); 83 | 84 | if (notificationSent) { 85 | log.info("Exception notification sent for: {}", exceptionInfo.getType()); 86 | } else { 87 | log.warn("No notification channels were successful for exception: {}", exceptionInfo.getType()); 88 | } 89 | } catch (Exception e) { 90 | log.error("Error processing exception notification", e); 91 | } 92 | } 93 | 94 | 95 | 96 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/service/GitHubService.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.service; 2 | 3 | import java.io.IOException; 4 | import java.time.LocalDateTime; 5 | import java.time.format.DateTimeFormatter; 6 | 7 | import org.springframework.stereotype.Service; 8 | 9 | import com.fasterxml.jackson.databind.JsonNode; 10 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 11 | import com.nolimit35.springkit.model.CodeAuthorInfo; 12 | 13 | import lombok.extern.slf4j.Slf4j; 14 | import okhttp3.MediaType; 15 | import okhttp3.Request; 16 | import okhttp3.RequestBody; 17 | import okhttp3.Response; 18 | 19 | /** 20 | * Service for interacting with GitHub API 21 | */ 22 | @Slf4j 23 | @Service 24 | public class GitHubService extends AbstractGitSourceControlService { 25 | private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); 26 | private static final String GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql"; 27 | 28 | public GitHubService(ExceptionNotifyProperties properties) { 29 | super(properties); 30 | } 31 | 32 | /** 33 | * Get author information for a specific file and line using GitHub GraphQL API 34 | * 35 | * @param fileName the file name 36 | * @param lineNumber the line number 37 | * @return author information or null if not found 38 | */ 39 | @Override 40 | public CodeAuthorInfo getAuthorInfo(String fileName, int lineNumber) { 41 | if (!validateConfiguration( 42 | properties.getGithub().getToken(), 43 | properties.getGithub().getRepoOwner(), 44 | properties.getGithub().getRepoName(), 45 | "GitHub")) { 46 | return null; 47 | } 48 | 49 | try { 50 | // Construct GraphQL query for blame information 51 | String graphQLQuery = String.format( 52 | "{\"query\":\"query {\\n" + 53 | " repository(name: \\\"%s\\\", owner: \\\"%s\\\") {\\n" + 54 | " ref(qualifiedName:\\\"%s\\\") {\\n" + 55 | " target {\\n" + 56 | " ... on Commit {\\n" + 57 | " blame(path:\\\"%s\\\") {\\n" + 58 | " ranges {\\n" + 59 | " commit {\\n" + 60 | " author {\\n" + 61 | " name\\n" + 62 | " email\\n" + 63 | " date\\n" + 64 | " }\\n" + 65 | " committer {\\n" + 66 | " date\\n" + 67 | " }\\n" + 68 | " message\\n" + 69 | " }\\n" + 70 | " startingLine\\n" + 71 | " endingLine\\n" + 72 | " }\\n" + 73 | " }\\n" + 74 | " }\\n" + 75 | " }\\n" + 76 | " }\\n" + 77 | " }\\n" + 78 | "}\"}", 79 | properties.getGithub().getRepoName(), 80 | properties.getGithub().getRepoOwner(), 81 | properties.getGithub().getBranch(), 82 | fileName 83 | ); 84 | 85 | RequestBody body = RequestBody.create(graphQLQuery, JSON); 86 | Request request = new Request.Builder() 87 | .url(GITHUB_GRAPHQL_ENDPOINT) 88 | .header("Authorization", "Bearer " + properties.getGithub().getToken()) 89 | .post(body) 90 | .build(); 91 | 92 | try (Response response = httpClient.newCall(request).execute()) { 93 | if (!response.isSuccessful()) { 94 | log.error("Failed to get blame information: {}", response.code()); 95 | return null; 96 | } 97 | 98 | String responseBody = response.body().string(); 99 | JsonNode data = objectMapper.readTree(responseBody).get("data"); 100 | 101 | if (data == null || data.has("errors")) { 102 | log.error("GraphQL query returned errors: {}", responseBody); 103 | return null; 104 | } 105 | 106 | JsonNode blame = data.get("repository") 107 | .get("ref") 108 | .get("target") 109 | .get("blame"); 110 | 111 | JsonNode ranges = blame.get("ranges"); 112 | 113 | // Find the blame range that contains the line 114 | for (JsonNode range : ranges) { 115 | int startLine = range.get("startingLine").asInt(); 116 | int endLine = range.get("endingLine").asInt(); 117 | 118 | if (lineNumber >= startLine && lineNumber <= endLine) { 119 | JsonNode commit = range.get("commit"); 120 | JsonNode author = commit.get("author"); 121 | JsonNode committer = commit.get("committer"); 122 | 123 | return CodeAuthorInfo.builder() 124 | .name(author.get("name").asText()) 125 | .email(author.get("email").asText()) 126 | .lastCommitTime(LocalDateTime.parse(committer.get("date").asText(), DateTimeFormatter.ISO_DATE_TIME)) 127 | .fileName(fileName) 128 | .lineNumber(lineNumber) 129 | .commitMessage(commit.get("message").asText()) 130 | .build(); 131 | } 132 | } 133 | 134 | log.warn("Could not find blame information for {}:{}", fileName, lineNumber); 135 | } 136 | } catch (IOException e) { 137 | log.error("Error fetching author information from GitHub", e); 138 | } 139 | 140 | return null; 141 | } 142 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/service/GitLabService.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.service; 2 | 3 | import java.io.IOException; 4 | import java.time.LocalDateTime; 5 | import java.time.format.DateTimeFormatter; 6 | import java.net.URLEncoder; 7 | import java.nio.charset.StandardCharsets; 8 | 9 | import org.springframework.stereotype.Service; 10 | 11 | import com.fasterxml.jackson.databind.JsonNode; 12 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 13 | import com.nolimit35.springkit.model.CodeAuthorInfo; 14 | 15 | import lombok.extern.slf4j.Slf4j; 16 | import okhttp3.Request; 17 | import okhttp3.Response; 18 | 19 | /** 20 | * Service for interacting with GitLab API 21 | */ 22 | @Slf4j 23 | @Service 24 | public class GitLabService extends AbstractGitSourceControlService { 25 | private static final String API_FILE_BLAME = "%s/projects/%s/repository/files/%s/blame"; 26 | 27 | public GitLabService(ExceptionNotifyProperties properties) { 28 | super(properties); 29 | } 30 | 31 | /** 32 | * Get author information for a specific file and line using GitLab API 33 | * 34 | * @param fileName the file name 35 | * @param lineNumber the line number 36 | * @return author information or null if not found 37 | */ 38 | @Override 39 | public CodeAuthorInfo getAuthorInfo(String fileName, int lineNumber) { 40 | if (!validateConfiguration( 41 | properties.getGitlab().getToken(), 42 | properties.getGitlab().getProjectId(), 43 | properties.getGitlab().getBranch(), 44 | "GitLab")) { 45 | return null; 46 | } 47 | 48 | try { 49 | // URL encode the file path for GitLab API 50 | String encodedFilePath = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()); 51 | 52 | // Construct GitLab API URL for blame information 53 | String apiUrl = String.format( 54 | API_FILE_BLAME, 55 | properties.getGitlab().getBaseUrl(), 56 | properties.getGitlab().getProjectId(), 57 | encodedFilePath 58 | ); 59 | 60 | // Add query parameter for branch 61 | apiUrl += "?ref=" + properties.getGitlab().getBranch(); 62 | 63 | Request request = new Request.Builder() 64 | .url(apiUrl) 65 | .header("PRIVATE-TOKEN", properties.getGitlab().getToken()) 66 | .get() 67 | .build(); 68 | 69 | try (Response response = httpClient.newCall(request).execute()) { 70 | if (!response.isSuccessful()) { 71 | log.error("Failed to get blame information from GitLab: {}", response.code()); 72 | return null; 73 | } 74 | 75 | String responseBody = response.body().string(); 76 | JsonNode blameData = objectMapper.readTree(responseBody); 77 | 78 | return processBlameData(blameData, fileName, lineNumber); 79 | } 80 | } catch (IOException e) { 81 | log.error("Error fetching author information from GitLab", e); 82 | } 83 | 84 | return null; 85 | } 86 | 87 | /** 88 | * Process the GitLab blame data to extract author information for a specific line 89 | * 90 | * @param blameData the GitLab blame response data as JsonNode 91 | * @param fileName the file name 92 | * @param lineNumber the line number to get author for 93 | * @return the author information or null if not found 94 | */ 95 | private CodeAuthorInfo processBlameData(JsonNode blameData, String fileName, int lineNumber) { 96 | // Find the blame range that contains the line 97 | for (JsonNode blameRange : blameData) { 98 | JsonNode lines = blameRange.get("lines"); 99 | if (lines == null || lines.isEmpty()) { 100 | continue; 101 | } 102 | 103 | // Get the line numbers in this range 104 | int startLine = -1; 105 | int endLine = -1; 106 | 107 | for (int i = 0; i < lines.size(); i++) { 108 | if (startLine == -1) { 109 | startLine = i + 1; // Line numbers are 1-based 110 | } 111 | endLine = i + 1; 112 | } 113 | 114 | if (lineNumber >= startLine && lineNumber <= endLine) { 115 | JsonNode commit = blameRange.get("commit"); 116 | 117 | return CodeAuthorInfo.builder() 118 | .name(commit.get("author_name").asText()) 119 | .email(commit.get("author_email").asText()) 120 | .lastCommitTime(LocalDateTime.parse( 121 | commit.get("authored_date").asText(), 122 | DateTimeFormatter.ISO_DATE_TIME)) 123 | .fileName(fileName) 124 | .lineNumber(lineNumber) 125 | .commitMessage(commit.get("message").asText()) 126 | .build(); 127 | } 128 | } 129 | 130 | return null; 131 | } 132 | 133 | /** 134 | * Validates that the required configuration is present 135 | * 136 | * @param token The API token 137 | * @param projectId The project ID 138 | * @param branch The branch name 139 | * @param serviceName The name of the service (for logging) 140 | * @return true if configuration is valid, false otherwise 141 | */ 142 | @Override 143 | protected boolean validateConfiguration(String token, String projectId, String branch, String serviceName) { 144 | if (token == null || projectId == null || branch == null) { 145 | log.warn("{} configuration is incomplete. Cannot fetch author information.", serviceName); 146 | return false; 147 | } 148 | return true; 149 | } 150 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/service/GitSourceControlService.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.service; 2 | 3 | import com.nolimit35.springkit.model.CodeAuthorInfo; 4 | 5 | /** 6 | * Interface for Git source control services (GitHub, Gitee, etc.) 7 | * Provides common operations for interacting with git repositories 8 | */ 9 | public interface GitSourceControlService { 10 | 11 | /** 12 | * Get author information for a specific file and line 13 | * 14 | * @param fileName the file name 15 | * @param lineNumber the line number 16 | * @return author information or null if not found 17 | */ 18 | CodeAuthorInfo getAuthorInfo(String fileName, int lineNumber); 19 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/service/GiteeService.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.service; 2 | 3 | import java.io.IOException; 4 | import java.time.LocalDateTime; 5 | 6 | import org.springframework.stereotype.Service; 7 | 8 | import com.fasterxml.jackson.databind.JsonNode; 9 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 10 | import com.nolimit35.springkit.model.CodeAuthorInfo; 11 | 12 | import lombok.extern.slf4j.Slf4j; 13 | import okhttp3.Request; 14 | import okhttp3.Response; 15 | 16 | /** 17 | * Service for interacting with Gitee API 18 | */ 19 | @Slf4j 20 | @Service 21 | public class GiteeService extends AbstractGitSourceControlService { 22 | 23 | public GiteeService(ExceptionNotifyProperties properties) { 24 | super(properties); 25 | } 26 | 27 | /** 28 | * Get the full file path in the repository based on a filename 29 | * 30 | * @param fileName the file name or partial path 31 | * @return the full path of the file in the repository, or null if not found 32 | */ 33 | private String getFilePathFromName(String fileName) { 34 | if (!validateConfiguration( 35 | properties.getGitee().getToken(), 36 | properties.getGitee().getRepoOwner(), 37 | properties.getGitee().getRepoName(), 38 | "Gitee")) { 39 | return null; 40 | } 41 | 42 | // Check if fileName is null or empty 43 | if (fileName == null || fileName.trim().isEmpty()) { 44 | log.error("File name is empty or null"); 45 | return null; 46 | } 47 | 48 | // Log if fileName already appears to be a path 49 | boolean isLikelyPath = fileName.contains("/") && !fileName.startsWith("/"); 50 | if (isLikelyPath) { 51 | log.debug("File name already appears to be a path: {}", fileName); 52 | } 53 | 54 | try { 55 | String url = String.format( 56 | "https://gitee.com/api/v5/repos/%s/%s/git/trees/%s?access_token=%s&recursive=1", 57 | properties.getGitee().getRepoOwner(), 58 | properties.getGitee().getRepoName(), 59 | properties.getGitee().getBranch(), 60 | properties.getGitee().getToken() 61 | ); 62 | 63 | log.debug("Fetching repository tree from: {}", url.replaceAll("access_token=[^&]+", "access_token=***")); 64 | 65 | Request request = new Request.Builder() 66 | .url(url) 67 | .header("Content-Type", "application/json;charset=UTF-8") 68 | .build(); 69 | 70 | try (Response response = httpClient.newCall(request).execute()) { 71 | if (!response.isSuccessful()) { 72 | log.error("Failed to get repository tree from Gitee: {}", response.code()); 73 | return null; 74 | } 75 | 76 | String responseBody = response.body().string(); 77 | JsonNode treeData = objectMapper.readTree(responseBody); 78 | JsonNode tree = treeData.get("tree"); 79 | 80 | if (tree == null || tree.isEmpty()) { 81 | log.warn("Repository tree is empty or not available"); 82 | return null; 83 | } 84 | 85 | log.debug("Repository tree contains {} items", tree.size()); 86 | 87 | // First check for exact match 88 | for (JsonNode item : tree) { 89 | String path = item.get("path").asText(); 90 | String type = item.get("type").asText(); 91 | 92 | // Check if path exactly matches the fileName (for full paths) 93 | if ("blob".equals(type) && path.equals(fileName)) { 94 | log.info("Found exact file path match: {}", path); 95 | return path; 96 | } 97 | } 98 | 99 | // If exact match not found, try other matching strategies 100 | String simpleFileName = getSimpleFileName(fileName); 101 | log.debug("Simple file name extracted: {}", simpleFileName); 102 | 103 | // Try to find the best match using different strategies 104 | String bestMatch = null; 105 | int bestMatchScore = 0; 106 | 107 | for (JsonNode item : tree) { 108 | String path = item.get("path").asText(); 109 | String type = item.get("type").asText(); 110 | 111 | if (!"blob".equals(type)) { 112 | continue; // Skip directories 113 | } 114 | 115 | int score = 0; 116 | 117 | // Strategy 1: Path ends with the simple file name 118 | if (path.endsWith("/" + simpleFileName) || path.equals(simpleFileName)) { 119 | score += 10; 120 | } 121 | 122 | // Strategy 2: For paths, check if path contains the full fileName 123 | if (isLikelyPath && path.contains(fileName)) { 124 | score += 20; 125 | } 126 | 127 | // Strategy 3: Check if the file name components match in order 128 | if (isLikelyPath) { 129 | String[] fileNameParts = fileName.split("/"); 130 | String[] pathParts = path.split("/"); 131 | int matchingParts = 0; 132 | 133 | // Check matching parts from the end 134 | for (int i = 0; i < Math.min(fileNameParts.length, pathParts.length); i++) { 135 | if (fileNameParts[fileNameParts.length - 1 - i].equals( 136 | pathParts[pathParts.length - 1 - i])) { 137 | matchingParts++; 138 | } else { 139 | break; 140 | } 141 | } 142 | 143 | score += matchingParts * 5; 144 | } 145 | 146 | // Update best match if this path has a higher score 147 | if (score > bestMatchScore) { 148 | bestMatch = path; 149 | bestMatchScore = score; 150 | } 151 | } 152 | 153 | if (bestMatch != null) { 154 | log.info("Found best matching file path: {} (score: {})", bestMatch, bestMatchScore); 155 | return bestMatch; 156 | } 157 | 158 | log.warn("File '{}' not found in repository tree", fileName); 159 | } 160 | } catch (IOException e) { 161 | log.error("Error fetching repository tree from Gitee", e); 162 | } 163 | 164 | return null; 165 | } 166 | 167 | /** 168 | * Extract simple file name from a path 169 | * 170 | * @param path Path or filename 171 | * @return Simple file name without directories 172 | */ 173 | private String getSimpleFileName(String path) { 174 | if (path == null) { 175 | return ""; 176 | } 177 | int lastSeparator = path.lastIndexOf('/'); 178 | return lastSeparator >= 0 ? path.substring(lastSeparator + 1) : path; 179 | } 180 | 181 | /** 182 | * Get author information for a specific file and line 183 | * 184 | * @param fileName the file name 185 | * @param lineNumber the line number 186 | * @return author information or null if not found 187 | */ 188 | @Override 189 | public CodeAuthorInfo getAuthorInfo(String fileName, int lineNumber) { 190 | if (!validateConfiguration( 191 | properties.getGitee().getToken(), 192 | properties.getGitee().getRepoOwner(), 193 | properties.getGitee().getRepoName(), 194 | "Gitee")) { 195 | return null; 196 | } 197 | 198 | // Get the full file path 199 | String filePath = getFilePathFromName(fileName); 200 | if (filePath == null) { 201 | log.error("Could not find file path for: {}", fileName); 202 | return null; 203 | } 204 | 205 | try { 206 | String url = String.format( 207 | "https://gitee.com/api/v5/repos/%s/%s/blame/%s?access_token=%s&ref=%s", 208 | properties.getGitee().getRepoOwner(), 209 | properties.getGitee().getRepoName(), 210 | filePath, 211 | properties.getGitee().getToken(), 212 | properties.getGitee().getBranch() 213 | 214 | ); 215 | 216 | Request request = new Request.Builder() 217 | .url(url) 218 | .build(); 219 | 220 | try (Response response = httpClient.newCall(request).execute()) { 221 | if (!response.isSuccessful()) { 222 | log.error("Failed to get blame information from Gitee: {}", response.code()); 223 | return null; 224 | } 225 | 226 | String responseBody = response.body().string(); 227 | JsonNode blameData = objectMapper.readTree(responseBody); 228 | 229 | // Track the current line number through all blame ranges 230 | int currentLineIndex = 1; 231 | 232 | // Iterate through blame ranges 233 | for (JsonNode range : blameData) { 234 | JsonNode lines = range.get("lines"); 235 | int linesCount = lines.size(); 236 | 237 | // Check if our target line is within this range 238 | if (lineNumber >= currentLineIndex && lineNumber < currentLineIndex + linesCount) { 239 | // Found the range containing our line 240 | JsonNode commit = range.get("commit"); 241 | JsonNode commitAuthor = commit.get("committer"); 242 | String dateStr = commitAuthor.get("date").asText() 243 | .replaceAll("\\+\\d{2}:\\d{2}$", "") 244 | .replaceAll("T"," "); 245 | return CodeAuthorInfo.builder() 246 | .name(commitAuthor.get("name").asText()) 247 | .email(commitAuthor.get("email").asText()) 248 | .lastCommitTime(LocalDateTime.parse(dateStr, this.DATE_FORMAT)) 249 | .fileName(filePath) 250 | .lineNumber(lineNumber) 251 | .commitMessage(commit.get("message").asText()) 252 | .build(); 253 | } 254 | 255 | // Move to the next range 256 | currentLineIndex += linesCount; 257 | } 258 | 259 | log.warn("Could not find author information for line {} in file {}", lineNumber, filePath); 260 | } 261 | } catch (IOException e) { 262 | log.error("Error fetching author information from Gitee", e); 263 | } 264 | 265 | return null; 266 | } 267 | } -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/trace/DefaultTraceInfoProvider.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.trace; 2 | 3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.slf4j.MDC; 6 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 7 | import org.springframework.stereotype.Component; 8 | import org.springframework.web.context.request.RequestAttributes; 9 | import org.springframework.web.context.request.RequestContextHolder; 10 | import org.springframework.web.context.request.ServletRequestAttributes; 11 | 12 | import javax.servlet.http.HttpServletRequest; 13 | import java.nio.charset.StandardCharsets; 14 | import java.util.Base64; 15 | 16 | /** 17 | * Default implementation of TraceInfoProvider 18 | * Retrieves trace ID from MDC or request headers and generates Tencent CLS trace URLs 19 | */ 20 | @Slf4j 21 | @Component 22 | @ConditionalOnMissingBean(TraceInfoProvider.class) 23 | public class DefaultTraceInfoProvider implements TraceInfoProvider { 24 | private final ExceptionNotifyProperties properties; 25 | 26 | public DefaultTraceInfoProvider(ExceptionNotifyProperties properties) { 27 | this.properties = properties; 28 | } 29 | 30 | @Override 31 | public String getTraceId() { 32 | if (!properties.getTrace().isEnabled()) { 33 | return null; 34 | } 35 | 36 | try { 37 | // First try to get traceId from MDC 38 | String traceId = MDC.get("traceId"); 39 | if (traceId != null && !traceId.isEmpty()) { 40 | return traceId; 41 | } 42 | 43 | // If not found in MDC, try to get from request header 44 | RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); 45 | if (requestAttributes instanceof ServletRequestAttributes) { 46 | HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest(); 47 | String headerName = properties.getTrace().getHeaderName(); 48 | traceId = request.getHeader(headerName); 49 | 50 | if (traceId != null && !traceId.isEmpty()) { 51 | return traceId; 52 | } 53 | } 54 | } catch (Exception e) { 55 | log.debug("Error getting trace ID", e); 56 | } 57 | 58 | return null; 59 | } 60 | 61 | @Override 62 | public String generateTraceUrl(String traceId) { 63 | if (traceId == null || traceId.isEmpty()) { 64 | return null; 65 | } 66 | 67 | String region = properties.getTencentcls().getRegion(); 68 | String topicId = properties.getTencentcls().getTopicId(); 69 | 70 | if (region != null && !region.isEmpty() && topicId != null && !topicId.isEmpty()) { 71 | // Build query JSON 72 | String interactiveQuery = String.format( 73 | "{\"filters\":[{\"key\":\"traceId\",\"grammarName\":\"INCLUDE\",\"values\":[{\"values\":[{\"value\":\"%s\",\"isPartialEscape\":true}],\"isOpen\":false}],\"alias_name\":\"traceId\",\"cnName\":\"\"}],\"sql\":{\"quotas\":[],\"dimensions\":[],\"sequences\":[],\"limit\":1000,\"samplingRate\":1},\"sqlStr\":\"\"}", 74 | traceId 75 | ); 76 | 77 | // Base64 encode query parameters 78 | String interactiveQueryBase64 = Base64.getEncoder().encodeToString( 79 | interactiveQuery.getBytes(StandardCharsets.UTF_8) 80 | ); 81 | 82 | // Build complete URL 83 | return String.format( 84 | "https://console.cloud.tencent.com/cls/search?region=%s&topic_id=%s&interactiveQueryBase64=%s", 85 | region, topicId, interactiveQueryBase64 86 | ) + "&time=now%2Fd,now%2Fd"; 87 | } 88 | 89 | return null; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/main/java/com/nolimit35/springkit/trace/TraceInfoProvider.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.trace; 2 | 3 | /** 4 | * Interface for providing trace information 5 | * This interface allows customization of how trace IDs are retrieved and trace URLs are generated 6 | */ 7 | public interface TraceInfoProvider { 8 | /** 9 | * Get trace ID from the current context 10 | * 11 | * @return trace ID or null if not available 12 | */ 13 | String getTraceId(); 14 | 15 | /** 16 | * Generate trace URL for the given trace ID 17 | * 18 | * @param traceId the trace ID 19 | * @return trace URL or null if not available 20 | */ 21 | String generateTraceUrl(String traceId); 22 | } 23 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ 2 | com.nolimit35.springkit.config.ExceptionNotifyAutoConfiguration 3 | 4 | org.springframework.context.ApplicationListener=\ 5 | com.nolimit35.springkit.config.EnvironmentPostProcessor -------------------------------------------------------------------------------- /src/main/resources/application-example.yaml: -------------------------------------------------------------------------------- 1 | exception: 2 | notify: 3 | enabled: true # 是否启用异常通知功能 4 | package-filter: 5 | enabled: true # 是否启用包名过滤功能 6 | include-packages: # 需要解析的包名列表,启用后只会分析这些包名下的异常堆栈 7 | - com.nolimit35 8 | dingtalk: 9 | webhook: https://oapi.dingtalk.com/robot/send?access_token=xxx # 钉钉机器人 Webhook 地址 10 | at: 11 | enabled: true # 是否启用@功能 12 | userIdMappingGitEmail: # 钉钉 用户id 与 git 提交邮箱的映射关系 13 | xxx: ['xxx@xx.com','xxxx@xx.com'] 14 | feishu: 15 | webhook: https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx # 飞书机器人 Webhook 地址 16 | at: 17 | enabled: true # 是否启用@功能 18 | openIdMappingGitEmail: # 飞书 openid 与 git 提交邮箱的映射关系 19 | ou_xxxxxxxx: ['xxx@xx.com','xxxx@xx.com'] 20 | wechatwork: 21 | webhook: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxx # 企业微信机器人 Webhook 地址 22 | at: 23 | enabled: true # 是否启用@功能 24 | userIdMappingGitEmail: # 企微 用户id 与 git 提交邮箱的映射关系 25 | # 企微用户 id 带有 @ 符号时,需要手动特殊处理成 [@] 26 | 'xxx[@]xx.com': ['xxx@xx.com','xxxx@xx.com'] 27 | # GitHub 配置 (与 Gitee 配置互斥,只能选择其中一种) 28 | github: 29 | token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # GitHub 访问令牌 30 | repo-owner: your-github-username # GitHub 仓库所有者 31 | repo-name: your-repo-name # GitHub 仓库名称 32 | branch: master # GitHub 仓库分支 33 | # Gitee 配置 (与 GitHub 配置互斥,只能选择其中一种) 34 | gitee: 35 | token: xxxxxxxxxxxxxxxxxxxxxxx # Gitee 访问令牌 36 | repo-owner: your-gitee-username # Gitee 仓库所有者 37 | repo-name: your-repo-name # Gitee 仓库名称 38 | branch: master # Gitee 仓库分支 39 | tencentcls: 40 | region: ap-guangzhou # 腾讯云日志服务(CLS)的地域 41 | topic-id: xxx-xxx-xxx # 腾讯云日志服务(CLS)的主题ID 42 | trace: 43 | enabled: true # 是否启用链路追踪 44 | header-name: X-Trace-Id # 链路追踪 ID 的请求头名称 45 | notification: 46 | title-template: "【${appName}】异常告警" # 告警标题模板 47 | include-stacktrace: true # 是否包含完整堆栈信息 48 | max-stacktrace-lines: 10 # 堆栈信息最大行数 49 | environment: 50 | report-from: test,prod # 需要上报异常的环境列表,多个环境用逗号分隔 51 | 52 | # Spring 配置 53 | spring: 54 | # 应用名称,用于告警标题 55 | application: 56 | name: YourApplicationName 57 | # 当前环境配置,会自动用于确定异常通知的当前环境 58 | profiles: 59 | active: dev # 当前激活的环境配置 -------------------------------------------------------------------------------- /src/test/java/com/nolimit35/springkit/notification/provider/AllProvidersYamlTest.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.notification.provider; 2 | 3 | import com.nolimit35.springkit.notification.NotificationProvider; 4 | import com.nolimit35.springkit.notification.NotificationProviderManager; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.test.context.TestPropertySource; 8 | 9 | import java.util.Arrays; 10 | import java.util.List; 11 | 12 | import static org.junit.jupiter.api.Assertions.assertEquals; 13 | import static org.junit.jupiter.api.Assertions.assertTrue; 14 | 15 | /** 16 | * 基于 YAML 配置的所有通知提供者组合测试 17 | */ 18 | @TestPropertySource(properties = { 19 | "spring.config.location=classpath:application-test.yml" 20 | }) 21 | class AllProvidersYamlTest extends YamlConfigNotificationProviderTest { 22 | 23 | private DingTalkNotificationProvider dingTalkProvider; 24 | private FeishuNotificationProvider feishuProvider; 25 | private WeChatWorkNotificationProvider weChatWorkProvider; 26 | private NotificationProviderManager providerManager; 27 | 28 | @BeforeEach 29 | void setUpProviders() { 30 | // 创建提供者实例 31 | dingTalkProvider = new DingTalkNotificationProvider(properties, formatter); 32 | feishuProvider = new FeishuNotificationProvider(properties, formatter); 33 | weChatWorkProvider = new WeChatWorkNotificationProvider(properties, formatter); 34 | 35 | // 创建提供者管理器,包含所有提供者 36 | List providers = Arrays.asList( 37 | dingTalkProvider, 38 | feishuProvider, 39 | weChatWorkProvider 40 | ); 41 | providerManager = new NotificationProviderManager(providers); 42 | } 43 | 44 | @Test 45 | void testProviderEnabledStatus() { 46 | // 检查提供者是否根据 webhook 配置正确启用 47 | assertTrue(dingTalkProvider.isEnabled()); 48 | 49 | assertTrue(feishuProvider.isEnabled()); 50 | 51 | assertTrue(weChatWorkProvider.isEnabled()); 52 | } 53 | 54 | @Test 55 | void testSendNotificationToAllEnabledProviders() { 56 | // 通过提供者管理器发送通知到所有启用的提供者 57 | boolean result = providerManager.sendNotification(exceptionInfo); 58 | 59 | // 如果任何提供者已启用,结果应该为 true 60 | assertEquals(isAnyProviderEnabled(), result); 61 | } 62 | 63 | private boolean hasEnvironmentVariable(String name) { 64 | String value = System.getenv(name); 65 | return value != null && !value.isEmpty(); 66 | } 67 | 68 | private boolean isAnyProviderEnabled() { 69 | return dingTalkProvider.isEnabled() || 70 | feishuProvider.isEnabled() || 71 | weChatWorkProvider.isEnabled(); 72 | } 73 | } -------------------------------------------------------------------------------- /src/test/java/com/nolimit35/springkit/notification/provider/DingTalkNotificationProviderYamlTest.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.notification.provider; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; 6 | import org.springframework.test.context.TestPropertySource; 7 | 8 | import static org.junit.jupiter.api.Assertions.*; 9 | 10 | /** 11 | * 基于 YAML 配置的钉钉通知提供者测试 12 | * 需要设置 DINGTALK_WEBHOOK 环境变量才能运行 13 | */ 14 | @TestPropertySource(properties = { 15 | "spring.config.location=classpath:application-test.yml" 16 | }) 17 | class DingTalkNotificationProviderYamlTest extends YamlConfigNotificationProviderTest { 18 | 19 | private DingTalkNotificationProvider provider; 20 | 21 | @BeforeEach 22 | void setUpProvider() { 23 | // 创建提供者实例 24 | provider = new DingTalkNotificationProvider(properties, formatter); 25 | } 26 | 27 | @Test 28 | void testIsEnabled() { 29 | // 如果环境变量存在,应该是启用的 30 | assertTrue(provider.isEnabled()); 31 | 32 | // 验证配置是否正确加载 33 | assertEquals(System.getenv("DINGTALK_WEBHOOK"), properties.getDingtalk().getWebhook()); 34 | } 35 | 36 | @Test 37 | void testSendNotification() throws Exception { 38 | // 发送真实的通知请求 39 | boolean result = provider.doSendNotification(exceptionInfo); 40 | 41 | // 断言 42 | assertTrue(result, "通知应该成功发送"); 43 | } 44 | } -------------------------------------------------------------------------------- /src/test/java/com/nolimit35/springkit/notification/provider/FeishuNotificationProviderYamlTest.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.notification.provider; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; 6 | import org.springframework.test.context.TestPropertySource; 7 | 8 | import static org.junit.jupiter.api.Assertions.*; 9 | 10 | /** 11 | * 基于 YAML 配置的飞书通知提供者测试 12 | * 需要设置 FEISHU_WEBHOOK 环境变量才能运行 13 | */ 14 | @TestPropertySource(properties = { 15 | "spring.config.location=classpath:application-test.yml" 16 | }) 17 | class FeishuNotificationProviderYamlTest extends YamlConfigNotificationProviderTest { 18 | 19 | private FeishuNotificationProvider provider; 20 | 21 | @BeforeEach 22 | void setUpProvider() { 23 | // 创建提供者实例 24 | provider = new FeishuNotificationProvider(properties, formatter); 25 | } 26 | 27 | @Test 28 | void testIsEnabled() { 29 | // 如果环境变量存在,应该是启用的 30 | assertTrue(provider.isEnabled()); 31 | 32 | // 验证配置是否正确加载 33 | assertEquals(System.getenv("FEISHU_WEBHOOK"), properties.getFeishu().getWebhook()); 34 | } 35 | 36 | @Test 37 | void testSendNotification() throws Exception { 38 | // 发送真实的通知请求 39 | boolean result = provider.doSendNotification(exceptionInfo); 40 | 41 | // 断言 42 | assertTrue(result, "通知应该成功发送"); 43 | } 44 | } -------------------------------------------------------------------------------- /src/test/java/com/nolimit35/springkit/notification/provider/TestApplication.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.notification.provider; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | /** 7 | * 用于测试的 Spring Boot 应用类 8 | */ 9 | @SpringBootApplication 10 | public class TestApplication { 11 | 12 | public static void main(String[] args) { 13 | SpringApplication.run(TestApplication.class, args); 14 | } 15 | } -------------------------------------------------------------------------------- /src/test/java/com/nolimit35/springkit/notification/provider/WeChatWorkNotificationProviderYamlTest.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.notification.provider; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; 6 | import org.springframework.test.context.TestPropertySource; 7 | 8 | import static org.junit.jupiter.api.Assertions.*; 9 | 10 | /** 11 | * 基于 YAML 配置的企业微信通知提供者测试 12 | * 需要设置 WECHATWORK_WEBHOOK 环境变量才能运行 13 | */ 14 | @TestPropertySource(properties = { 15 | "spring.config.location=classpath:application-test.yml" 16 | }) 17 | class WeChatWorkNotificationProviderYamlTest extends YamlConfigNotificationProviderTest { 18 | 19 | private WeChatWorkNotificationProvider provider; 20 | 21 | @BeforeEach 22 | void setUpProvider() { 23 | // 创建提供者实例 24 | provider = new WeChatWorkNotificationProvider(properties, formatter); 25 | } 26 | 27 | @Test 28 | void testIsEnabled() { 29 | // 如果环境变量存在,应该是启用的 30 | assertTrue(provider.isEnabled()); 31 | 32 | // 验证配置是否正确加载 33 | assertEquals(System.getenv("WECHATWORK_WEBHOOK"), properties.getWechatwork().getWebhook()); 34 | } 35 | 36 | @Test 37 | void testSendNotification() throws Exception { 38 | // 发送真实的通知请求 39 | boolean result = provider.doSendNotification(exceptionInfo); 40 | 41 | // 断言 42 | assertTrue(result, "通知应该成功发送"); 43 | } 44 | } -------------------------------------------------------------------------------- /src/test/java/com/nolimit35/springkit/notification/provider/YamlConfigNotificationProviderTest.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.notification.provider; 2 | 3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 4 | import com.nolimit35.springkit.formatter.DefaultNotificationFormatter; 5 | import com.nolimit35.springkit.formatter.NotificationFormatter; 6 | import com.nolimit35.springkit.model.CodeAuthorInfo; 7 | import com.nolimit35.springkit.model.ExceptionInfo; 8 | import org.junit.jupiter.api.BeforeEach; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 12 | import org.springframework.boot.test.context.SpringBootTest; 13 | import org.springframework.test.context.ActiveProfiles; 14 | 15 | import java.time.LocalDateTime; 16 | 17 | import static org.junit.jupiter.api.Assertions.assertTrue; 18 | 19 | /** 20 | * 从 YAML 配置文件加载 ExceptionNotifyProperties 的集成测试基类 21 | */ 22 | @SpringBootTest(classes = { TestApplication.class }) 23 | @EnableConfigurationProperties(ExceptionNotifyProperties.class) 24 | @ActiveProfiles("test") 25 | public abstract class YamlConfigNotificationProviderTest { 26 | 27 | @Autowired 28 | protected ExceptionNotifyProperties properties; 29 | 30 | protected NotificationFormatter formatter; 31 | protected ExceptionInfo exceptionInfo; 32 | 33 | @BeforeEach 34 | void setUp() { 35 | // 创建实际的 formatter 36 | formatter = new DefaultNotificationFormatter(properties); 37 | 38 | // 设置测试异常信息 39 | RuntimeException testException = new RuntimeException("测试异常,请忽略"); 40 | 41 | exceptionInfo = ExceptionInfo.builder() 42 | .time(LocalDateTime.now()) 43 | .type(testException.getClass().getName()) 44 | .message(testException.getMessage()) 45 | .location("com.nolimit35.springkit.test.IntegrationTest.testMethod(IntegrationTest.java:42)") 46 | .stacktrace(getStackTraceAsString(testException)) 47 | .appName("springkit-test") 48 | .authorInfo(CodeAuthorInfo.builder().name("xxx").email("xxx@xx.com").build()) 49 | .build(); 50 | } 51 | 52 | /** 53 | * 辅助方法,将栈追踪转换为字符串 54 | */ 55 | public String getStackTraceAsString(Throwable throwable) { 56 | StringBuilder sb = new StringBuilder(); 57 | sb.append(throwable.toString()).append("\n"); 58 | 59 | for (StackTraceElement element : throwable.getStackTrace()) { 60 | sb.append("\tat ").append(element).append("\n"); 61 | } 62 | 63 | return sb.toString(); 64 | } 65 | } -------------------------------------------------------------------------------- /src/test/java/com/nolimit35/springkit/service/GitHubServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.service; 2 | 3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 4 | import com.nolimit35.springkit.model.CodeAuthorInfo; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Tag; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.mockito.InjectMocks; 10 | import org.mockito.Mock; 11 | import org.mockito.junit.jupiter.MockitoExtension; 12 | 13 | import static org.junit.jupiter.api.Assertions.*; 14 | import static org.mockito.Mockito.when; 15 | 16 | /** 17 | * Integration test for GitHubService 18 | * Note: This test makes actual API calls to GitHub and requires a valid GitHub token 19 | */ 20 | @ExtendWith(MockitoExtension.class) 21 | @Tag("integration") 22 | public class GitHubServiceTest { 23 | 24 | @Mock 25 | private ExceptionNotifyProperties properties; 26 | 27 | @Mock 28 | private ExceptionNotifyProperties.GitHub githubProperties; 29 | 30 | @InjectMocks 31 | private GitHubService gitHubService; 32 | 33 | private static final String TEST_FILE = "src/main/java/com/nolimit35/springfast/service/GitHubService.java"; 34 | private static final int TEST_LINE = 15; 35 | 36 | @BeforeEach 37 | public void setUp() { 38 | // Set up properties with real GitHub credentials 39 | when(properties.getGithub()).thenReturn(githubProperties); 40 | when(githubProperties.getToken()).thenReturn("your_token"); 41 | when(githubProperties.getRepoOwner()).thenReturn("GuangYiDing"); 42 | when(githubProperties.getRepoName()).thenReturn("exception-notify"); 43 | when(githubProperties.getBranch()).thenReturn("main"); 44 | } 45 | 46 | @Test 47 | public void testGetAuthorInfo_Success() { 48 | // Call the actual service method 49 | CodeAuthorInfo authorInfo = gitHubService.getAuthorInfo(TEST_FILE, TEST_LINE); 50 | 51 | // Verify we got a result 52 | assertNotNull(authorInfo); 53 | 54 | // Verify the basic structure is correct (we can't assert exact values since they depend on the actual repository) 55 | assertNotNull(authorInfo.getName()); 56 | assertNotNull(authorInfo.getEmail()); 57 | assertNotNull(authorInfo.getLastCommitTime()); 58 | assertEquals(TEST_FILE, authorInfo.getFileName()); 59 | assertEquals(TEST_LINE, authorInfo.getLineNumber()); 60 | } 61 | 62 | @Test 63 | public void testGetAuthorInfo_InvalidFile() { 64 | // Test with a file that doesn't exist 65 | CodeAuthorInfo authorInfo = gitHubService.getAuthorInfo("non-existent-file.txt", 1); 66 | 67 | // Should return null for non-existent file 68 | assertNull(authorInfo); 69 | } 70 | 71 | @Test 72 | public void testGetAuthorInfo_MissingConfiguration() { 73 | // Test with missing token 74 | when(githubProperties.getToken()).thenReturn(null); 75 | 76 | CodeAuthorInfo authorInfo = gitHubService.getAuthorInfo(TEST_FILE, TEST_LINE); 77 | assertNull(authorInfo); 78 | 79 | // Reset token and test with missing repo owner 80 | when(githubProperties.getToken()).thenReturn("your_token"); 81 | when(githubProperties.getRepoOwner()).thenReturn(null); 82 | 83 | authorInfo = gitHubService.getAuthorInfo(TEST_FILE, TEST_LINE); 84 | assertNull(authorInfo); 85 | 86 | // Reset repo owner and test with missing repo name 87 | when(githubProperties.getRepoOwner()).thenReturn("GuangYiDing"); 88 | when(githubProperties.getRepoName()).thenReturn(null); 89 | 90 | authorInfo = gitHubService.getAuthorInfo(TEST_FILE, TEST_LINE); 91 | assertNull(authorInfo); 92 | } 93 | } -------------------------------------------------------------------------------- /src/test/java/com/nolimit35/springkit/service/GitLabServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.service; 2 | 3 | import com.fasterxml.jackson.databind.JsonNode; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 6 | import com.nolimit35.springkit.model.CodeAuthorInfo; 7 | import okhttp3.Call; 8 | import okhttp3.OkHttpClient; 9 | import okhttp3.Request; 10 | import okhttp3.Response; 11 | import okhttp3.ResponseBody; 12 | import okhttp3.Protocol; 13 | import org.junit.jupiter.api.BeforeEach; 14 | import org.junit.jupiter.api.Tag; 15 | import org.junit.jupiter.api.Test; 16 | import org.junit.jupiter.api.extension.ExtendWith; 17 | import org.junit.jupiter.api.Assumptions; 18 | import org.mockito.InjectMocks; 19 | import org.mockito.Mock; 20 | import org.mockito.Spy; 21 | import org.mockito.junit.jupiter.MockitoExtension; 22 | 23 | import java.io.ByteArrayOutputStream; 24 | import java.io.IOException; 25 | import java.io.InputStream; 26 | import java.lang.reflect.Field; 27 | import java.lang.reflect.Method; 28 | import java.nio.charset.StandardCharsets; 29 | import java.time.LocalDateTime; 30 | import java.time.format.DateTimeFormatter; 31 | 32 | import static org.junit.jupiter.api.Assertions.*; 33 | import static org.junit.jupiter.api.Assumptions.assumeTrue; 34 | import static org.mockito.Mockito.*; 35 | 36 | /** 37 | * Integration test for GitLabService 38 | * Note: This test makes actual API calls to GitLab and requires a valid GitLab token 39 | */ 40 | @ExtendWith(MockitoExtension.class) 41 | @Tag("integration") 42 | public class GitLabServiceTest { 43 | 44 | @Mock 45 | private ExceptionNotifyProperties properties; 46 | 47 | @Mock 48 | private ExceptionNotifyProperties.GitLab gitlabProperties; 49 | 50 | @InjectMocks 51 | private GitLabService gitLabService; 52 | 53 | private static final String TEST_FILE = "src/main/java/com/nolimit35/springkit/service/GitLabService.java"; 54 | private static final int TEST_LINE = 15; 55 | 56 | @BeforeEach 57 | public void setUp() { 58 | // Set up properties with test GitLab credentials 59 | // Replace these values with valid test values for your GitLab instance 60 | when(properties.getGitlab()).thenReturn(gitlabProperties); 61 | when(gitlabProperties.getToken()).thenReturn("your_gitlab_token"); 62 | when(gitlabProperties.getProjectId()).thenReturn("your_project_id"); 63 | when(gitlabProperties.getBranch()).thenReturn("main"); 64 | when(gitlabProperties.getBaseUrl()).thenReturn("https://gitlab.com/api/v4"); 65 | } 66 | 67 | @Test 68 | public void testGetAuthorInfo_Success() { 69 | // Skip this test if you don't have valid GitLab credentials for testing 70 | // Uncomment the following line to skip 71 | // assumeTrue(false, "Skipped because no valid GitLab credentials available"); 72 | 73 | // Call the actual service method 74 | CodeAuthorInfo authorInfo = gitLabService.getAuthorInfo(TEST_FILE, TEST_LINE); 75 | 76 | // Verify we got a result 77 | assertNotNull(authorInfo); 78 | 79 | // Verify the basic structure is correct (we can't assert exact values since they depend on the actual repository) 80 | assertNotNull(authorInfo.getName()); 81 | assertNotNull(authorInfo.getEmail()); 82 | assertNotNull(authorInfo.getLastCommitTime()); 83 | assertEquals(TEST_FILE, authorInfo.getFileName()); 84 | assertEquals(TEST_LINE, authorInfo.getLineNumber()); 85 | } 86 | 87 | @Test 88 | public void testGetAuthorInfo_InvalidFile() { 89 | // Test with a file that doesn't exist 90 | CodeAuthorInfo authorInfo = gitLabService.getAuthorInfo("non-existent-file.txt", 1); 91 | 92 | // Should return null for non-existent file 93 | assertNull(authorInfo); 94 | } 95 | 96 | } -------------------------------------------------------------------------------- /src/test/java/com/nolimit35/springkit/trace/DefaultTraceInfoProviderTest.java: -------------------------------------------------------------------------------- 1 | package com.nolimit35.springkit.trace; 2 | 3 | import com.nolimit35.springkit.config.ExceptionNotifyProperties; 4 | import org.junit.jupiter.api.BeforeEach; 5 | import org.junit.jupiter.api.Test; 6 | import org.mockito.Mock; 7 | import org.mockito.MockitoAnnotations; 8 | import org.slf4j.MDC; 9 | import org.springframework.mock.web.MockHttpServletRequest; 10 | import org.springframework.web.context.request.RequestContextHolder; 11 | import org.springframework.web.context.request.ServletRequestAttributes; 12 | 13 | import static org.junit.jupiter.api.Assertions.*; 14 | import static org.mockito.Mockito.when; 15 | 16 | class DefaultTraceInfoProviderTest { 17 | 18 | @Mock 19 | private ExceptionNotifyProperties properties; 20 | 21 | @Mock 22 | private ExceptionNotifyProperties.Trace trace; 23 | 24 | @Mock 25 | private ExceptionNotifyProperties.TencentCls tencentCls; 26 | 27 | private DefaultTraceInfoProvider traceInfoProvider; 28 | 29 | @BeforeEach 30 | void setUp() { 31 | MockitoAnnotations.openMocks(this); 32 | when(properties.getTrace()).thenReturn(trace); 33 | when(properties.getTencentcls()).thenReturn(tencentCls); 34 | when(trace.isEnabled()).thenReturn(true); 35 | when(trace.getHeaderName()).thenReturn("X-Trace-Id"); 36 | 37 | traceInfoProvider = new DefaultTraceInfoProvider(properties); 38 | 39 | // Clear MDC before each test 40 | MDC.clear(); 41 | } 42 | 43 | @Test 44 | void getTraceId_fromMDC() { 45 | // Given 46 | String expectedTraceId = "test-trace-id-from-mdc"; 47 | MDC.put("traceId", expectedTraceId); 48 | 49 | // When 50 | String traceId = traceInfoProvider.getTraceId(); 51 | 52 | // Then 53 | assertEquals(expectedTraceId, traceId); 54 | } 55 | 56 | @Test 57 | void getTraceId_fromRequestHeader() { 58 | // Given 59 | String expectedTraceId = "test-trace-id-from-header"; 60 | MockHttpServletRequest request = new MockHttpServletRequest(); 61 | request.addHeader("X-Trace-Id", expectedTraceId); 62 | RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); 63 | 64 | // When 65 | String traceId = traceInfoProvider.getTraceId(); 66 | 67 | // Then 68 | assertEquals(expectedTraceId, traceId); 69 | 70 | // Clean up 71 | RequestContextHolder.resetRequestAttributes(); 72 | } 73 | 74 | @Test 75 | void getTraceId_traceDisabled() { 76 | // Given 77 | when(trace.isEnabled()).thenReturn(false); 78 | 79 | // When 80 | String traceId = traceInfoProvider.getTraceId(); 81 | 82 | // Then 83 | assertNull(traceId); 84 | } 85 | 86 | @Test 87 | void generateTraceUrl_withValidConfig() { 88 | // Given 89 | String traceId = "test-trace-id"; 90 | when(tencentCls.getRegion()).thenReturn("ap-guangzhou"); 91 | when(tencentCls.getTopicId()).thenReturn("test-topic-id"); 92 | 93 | // When 94 | String traceUrl = traceInfoProvider.generateTraceUrl(traceId); 95 | 96 | // Then 97 | assertNotNull(traceUrl); 98 | assertTrue(traceUrl.contains("region=ap-guangzhou")); 99 | assertTrue(traceUrl.contains("topic_id=test-topic-id")); 100 | assertTrue(traceUrl.contains("traceId")); 101 | assertTrue(traceUrl.contains("test-trace-id")); 102 | } 103 | 104 | @Test 105 | void generateTraceUrl_withNullTraceId() { 106 | // When 107 | String traceUrl = traceInfoProvider.generateTraceUrl(null); 108 | 109 | // Then 110 | assertNull(traceUrl); 111 | } 112 | 113 | @Test 114 | void generateTraceUrl_withEmptyTraceId() { 115 | // When 116 | String traceUrl = traceInfoProvider.generateTraceUrl(""); 117 | 118 | // Then 119 | assertNull(traceUrl); 120 | } 121 | 122 | @Test 123 | void generateTraceUrl_withMissingConfig() { 124 | // Given 125 | String traceId = "test-trace-id"; 126 | when(tencentCls.getRegion()).thenReturn(null); 127 | when(tencentCls.getTopicId()).thenReturn(null); 128 | 129 | // When 130 | String traceUrl = traceInfoProvider.generateTraceUrl(traceId); 131 | 132 | // Then 133 | assertNull(traceUrl); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/test/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | exception: 2 | notify: 3 | enabled: true # 是否启用异常通知功能 4 | package-filter: 5 | enabled: true # 是否启用包名过滤功能 6 | include-packages: # 需要解析的包名列表,启用后只会分析这些包名下的异常堆栈 7 | - com.nolimit35 8 | dingtalk: 9 | webhook: https://oapi.dingtalk.com/robot/send?access_token=xxx # 钉钉机器人 Webhook 地址 10 | at: 11 | enabled: true # 是否启用@功能 12 | userIdMappingGitEmail: # 钉钉 用户id 与 git 提交邮箱的映射关系 13 | xxx: ['xxx@xx.com','xxxx@xx.com'] 14 | feishu: 15 | webhook: https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx # 飞书机器人 Webhook 地址 16 | at: 17 | enabled: true # 是否启用@功能 18 | openIdMappingGitEmail: # 飞书 openid 与 git 提交邮箱的映射关系 19 | ou_xxxxxxxx: ['xxx@xx.com','xxxx@xx.com'] 20 | wechatwork: 21 | webhook: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxx # 企业微信机器人 Webhook 地址 22 | at: 23 | enabled: true # 是否启用@功能 24 | userIdMappingGitEmail: # 企微 用户id 与 git 提交邮箱的映射关系 25 | # 企微用户 id 带有 @ 符号时,需要手动特殊处理成 [@] 26 | 'xxx[@]xx.com': ['xxx@xx.com','xxxx@xx.com'] 27 | 28 | # GitHub 配置 (与 Gitee 配置互斥,只能选择其中一种) 29 | github: 30 | token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # GitHub 访问令牌 31 | repo-owner: your-github-username # GitHub 仓库所有者 32 | repo-name: your-repo-name # GitHub 仓库名称 33 | branch: master # GitHub 仓库分支 34 | # Gitee 配置 (与 GitHub 配置互斥,只能选择其中一种) 35 | gitee: 36 | token: xxxxxxxxxxxxxxxxxxxxxxx # Gitee 访问令牌 37 | repo-owner: your-gitee-username # Gitee 仓库所有者 38 | repo-name: your-repo-name # Gitee 仓库名称 39 | branch: master # Gitee 仓库分支 40 | tencentcls: 41 | region: ap-guangzhou # 腾讯云日志服务(CLS)的地域 42 | topic-id: xxx-xxx-xxx # 腾讯云日志服务(CLS)的主题ID 43 | trace: 44 | enabled: true # 是否启用链路追踪 45 | header-name: X-Trace-Id # 链路追踪 ID 的请求头名称 46 | notification: 47 | title-template: "【${appName}】异常告警" # 告警标题模板 48 | include-stacktrace: true # 是否包含完整堆栈信息 49 | max-stacktrace-lines: 10 # 堆栈信息最大行数 50 | environment: 51 | report-from: test,prod # 需要上报异常的环境列表,多个环境用逗号分隔 52 | 53 | # Spring 配置 54 | spring: 55 | # 应用名称,用于告警标题 56 | application: 57 | name: YourApplicationName 58 | # 当前环境配置,会自动用于确定异常通知的当前环境 59 | profiles: 60 | active: test # 当前激活的环境配置 61 | 62 | logging: 63 | level: 64 | root: INFO 65 | com.nolimit35: DEBUG 66 | --------------------------------------------------------------------------------