Compare commits

...
Author SHA1 Message Date
Tomsun28 74cbfe0c74 Merge branch 'master' into update-doc9083 2025-10-25 13:17:14 +08:00
tomsun28 ae5d30790c update
Signed-off-by: tomsun28 <tomsun28@outlook.com>
2025-10-25 13:16:02 +08:00
tomsun28 a6be462eba [doc] update 2025-10-25 13:12:27 +08:00
tomsun28 afa41621f7 [doc] update 2025-10-25 13:09:22 +08:00
tomsun28 92fce7edac update
Signed-off-by: tomsun28 <tomsun28@outlook.com>
2025-10-25 12:57:33 +08:00
Tomsun28andCalvin e073bfee91 refactor: refactor ai feature (#3823)
Signed-off-by: tomsun28 <tomsun28@outlook.com>
Co-authored-by: Calvin <zhengqiwei@apache.org>
2025-10-25 12:39:53 +08:00
tomsun28 9e791ff6b8 Update @pentium100 as a contributor 2025-10-25 12:23:03 +08:00
tomsun28 6ce68ab94c Add @dedyks as a contributor 2025-10-25 12:22:47 +08:00
tomsun28 38843e8e86 Update @pentium100 as a contributor 2025-10-25 12:22:31 +08:00
tomsun28 591ad4a101 Add @pentium100 as a contributor 2025-10-25 12:22:16 +08:00
tomsun28 f57972e389 Add @Jetiaime as a contributor 2025-10-25 12:21:59 +08:00
tomsun28 4327e888da Add @warrobe as a contributor 2025-10-25 12:21:14 +08:00
tomsun28 e2290bec92 Add @mengnankkkk as a contributor 2025-10-25 12:20:09 +08:00
tomsun28 0c1a810701 Add @AlbertYang0801 as a contributor 2025-10-25 12:19:54 +08:00
tomsun28 98275f3a61 Add @jl15988 as a contributor 2025-10-25 12:19:39 +08:00
tomsun28 f17844e3e8 Update @tuzuy as a contributor 2025-10-25 12:19:17 +08:00
tomsun28 0317b67975 Add @cxhello as a contributor 2025-10-25 12:18:54 +08:00
tomsun28 262c6b31c7 Update @lynx009 as a contributor 2025-10-25 12:18:01 +08:00
tomsun28 d8dba67066 Update @sarthakeash as a contributor 2025-10-25 12:17:36 +08:00
tomsun28 dd13b74a6f Update @sarthakeash as a contributor 2025-10-25 12:17:23 +08:00
Duansg 897d5d4220 [fix]Fixed issue where refresh did not take effect (#3825) 2025-10-24 21:58:22 +08:00
DuansgandTomsun28 4228605a3d [improve] Jexl validation rule enhancements (#3821)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2025-10-19 23:08:19 +08:00
DeleiGuo e7131b1881 [docs] fix the issue of avatar reference on the team page (#3820) 2025-10-18 17:59:16 +08:00
DuansgandTomsun28 8a22797bca [fix] Only POST and PUT methods carry payload (#3814)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2025-10-14 21:51:50 +08:00
b9e569a1e0 [fix]: fix bugs in frontend for monitoring (#3806)
Co-authored-by: aias00 <liuhongyu@apache.org>
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2025-10-13 23:54:11 +08:00
1284b4b46b [feat] Monitoring Center - Added cron expression support for scheduling type (#3777)
Co-authored-by: Calvin <zhengqiwei@apache.org>
Co-authored-by: Sherlock Yin <sherlock.yin1994@gmail.com>
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2025-10-12 00:12:26 +08:00
175c289e66 modify apt sources from http to https. (#3808)
Co-authored-by: liwei <liwei@itg.com.cn>
Co-authored-by: Logic <zqr10159@dromara.org>
2025-10-11 14:37:06 +08:00
Duansg 899e92882d [refactor]Refactoring prometheus label value utf8 support (#3810) 2025-10-11 11:31:09 +08:00
Yang ChenandTomsun28 d516b3a15d improvement: improve log severity text input (#3805)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2025-10-08 00:31:31 +08:00
pentium100andTomsun28 7678c3f139 fix some bug in service discovery: (#3804)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2025-10-08 00:29:30 +08:00
Tomsun28 3b49c0cb75 chore: use pnpm instead of yarn (#3802) 2025-10-06 21:33:11 +08:00
zhou yong kangandTomsun28 32d784e57e fix: use UTF-8 to solve Chineses bug (#3792)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2025-10-05 09:17:33 +08:00
pentium100andTomsun28 d8f8444275 [feature] dns add record type (#3799)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2025-10-05 08:58:34 +08:00
Dedy Kurniawan Santoso 41676c1f13 [improve] nitpicking "Dispatch ALl" on en-US.json (#3800)
Signed-off-by: Dedy Kurniawan Santoso <dedy@xendit.co>
2025-10-04 11:08:13 +08:00
Yang ChenandTomsun28 a3def497b4 [fix] remove v174 and add v180 flway script (#3787)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2025-10-01 00:20:55 +08:00
381db85149 [OSPP] Modify Dockerfile to support RISC-V. (#3713)
Co-authored-by: Calvin <zhengqiwei@apache.org>
Co-authored-by: Tom <tomsun28@outlook.com>
2025-09-30 23:22:31 +08:00
会功夫的李白andCalvin d0daa2eec5 [improve] Optimize Gretimedb time-series statistics. (#3776)
Co-authored-by: Calvin <zhengqiwei@apache.org>
2025-09-25 22:56:13 +08:00
9b5f0f7fce [OSPP] Apache HertzBeat Log Monitoring Capability (#3673)
Signed-off-by: Yang Chen <1597081640@qq.com>
Co-authored-by: Calvin <zhengqiwei@apache.org>
Co-authored-by: tomsun28 <tomsun28@outlook.com>
Co-authored-by: Jast <shenghang@apache.org>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-25 00:29:00 +08:00
Tom b3ef3d8a6f [docs] update doc and add new contributors (#3783)
Signed-off-by: tomsun28 <tomsun28@outlook.com>
2025-09-24 14:15:49 +08:00
267 changed files with 51531 additions and 25000 deletions
+74 -8
View File
@@ -7,7 +7,6 @@
"README.md",
"README_CN.md",
"README_JP.md"
"home/src/pages/team/index.jsx"
],
"imageSize": 100,
"commit": true,
@@ -1275,11 +1274,7 @@
"avatar_url": "https://avatars.githubusercontent.com/u/105542329?v=4",
"profile": "https://github.com/lynx009",
"contributions": [
"code",
"doc",
"blog",
"bug",
"design"
"doc"
]
},
{
@@ -2589,8 +2584,7 @@
"avatar_url": "https://avatars.githubusercontent.com/u/95274591?v=4",
"profile": "https://github.com/tuzuy",
"contributions": [
"code",
"design"
"code"
]
},
{
@@ -2601,6 +2595,78 @@
"contributions": [
"code"
]
},
{
"login": "cxhello",
"name": "cxhello",
"avatar_url": "https://avatars.githubusercontent.com/u/49056040?v=4",
"profile": "https://cxhello.top/",
"contributions": [
"doc"
]
},
{
"login": "jl15988",
"name": "会功夫的李白",
"avatar_url": "https://avatars.githubusercontent.com/u/70638770?v=4",
"profile": "https://github.com/jl15988",
"contributions": [
"code"
]
},
{
"login": "AlbertYang0801",
"name": "Albert.Yang",
"avatar_url": "https://avatars.githubusercontent.com/u/37127008?v=4",
"profile": "https://blog.aytop.cloud/",
"contributions": [
"code"
]
},
{
"login": "mengnankkkk",
"name": "zhou yong kang",
"avatar_url": "https://avatars.githubusercontent.com/u/150590575?v=4",
"profile": "https://blog.tokenlen.top/",
"contributions": [
"code"
]
},
{
"login": "warrobe",
"name": "warrobe",
"avatar_url": "https://avatars.githubusercontent.com/u/89446159?v=4",
"profile": "https://github.com/warrobe",
"contributions": [
"code"
]
},
{
"login": "Jetiaime",
"name": "TeAmo",
"avatar_url": "https://avatars.githubusercontent.com/u/93769000?v=4",
"profile": "https://github.com/Jetiaime",
"contributions": [
"code"
]
},
{
"login": "pentium100",
"name": "pentium100",
"avatar_url": "https://avatars.githubusercontent.com/u/27917?v=4",
"profile": "https://github.com/pentium100",
"contributions": [
"code"
]
},
{
"login": "dedyks",
"name": "Dedy Kurniawan Santoso",
"avatar_url": "https://avatars.githubusercontent.com/u/23741665?v=4",
"profile": "https://github.com/dedyks",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
-1
View File
@@ -15,7 +15,6 @@
"customizations": {
"vscode": {
"settings": {},
// same extensions as Gitpod, should match /.gitpod.yml
"extensions": [
"vscjava.vscode-java-pack",
"editorconfig.editorconfig",
+6 -3
View File
@@ -35,9 +35,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Yarn Install
- uses: pnpm/action-setup@v4
with:
version: 10
- name: Install
working-directory: web-app
run: yarn
run: pnpm install
- name: EsLint Test
working-directory: web-app
run: yarn lint:ts
run: pnpm lint:ts
+5 -2
View File
@@ -31,12 +31,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ./script/ci/github-actions/setup-deps
- uses: pnpm/action-setup@v4
with:
version: 10
- name: Build the Frontend
run: |
cd web-app
yarn install
yarn package
pnpm install
pnpm package
- name: Build the Backend
run: |
+1
View File
@@ -66,6 +66,7 @@ header:
- '.gitpod.yml'
- 'karma.conf.js'
- 'proxy.conf.js'
- 'pnpm-lock.yaml'
- '.helmignore'
- 'script/helm/**'
- 'web-app/src/app/core/**'
+10 -14
View File
@@ -44,19 +44,17 @@ Even small corrections to typos are very welcome :)
#### Frontend start
1. Need `Node Yarn` Environment, Make sure `Node.js >= 18`
1. Need `Node pnpm` Environment, Make sure `Node.js >= 18`
2. Cd to the `web-app` directory: `cd web-app`
3. Install yarn if not existed `npm install -g yarn`
3. Install pnpm if not existed `npm install -g pnpm`
4. Install Dependencies: `yarn install` or `yarn install --registry=https://registry.npmmirror.com` in `web-app`
4. Install Dependencies: `pnpm install` or `pnpm install --registry=https://registry.npmmirror.com` in `web-app`
5. Install angular-cli globally: `yarn global add @angular/cli@15` or `yarn global add @angular/cli@15 --registry=https://registry.npmmirror.com`
5. After the local backend is started, start the local frontend in the web-app directory: `pnpm start`
6. After the local backend is started, start the local frontend in the web-app directory: `ng serve --open`
7. Browser access to localhost:4200 to start, default account/password is *admin/hertzbeat*
6. Browser access to localhost:4200 to start, default account/password is *admin/hertzbeat*
### Find tasks
@@ -198,19 +196,17 @@ Add WeChat account `ahertzbeat` to pull you into the WeChat group.
#### 前端启动
1. 需要 `nodejs yarn` 环境, Make sure `Node.js >= 18`
1. 需要 `nodejs pnpm` 环境, Make sure `Node.js >= 18`
2. 进入 `web-app` 目录: `cd web-app`
3. 安装yarn: `npm install -g yarn`
3. 安装pnpm: `npm install -g pnpm`
4. 在前端工程目录 `web-app` 下执行: `yarn install` or `yarn install --registry=https://registry.npmmirror.com` in `web-app`
4. 在前端工程目录 `web-app` 下执行: `pnpm install` or `pnpm install --registry=https://registry.npmmirror.com` in `web-app`
5. 全局安装 `angular-cli`: `yarn global add @angular/cli@15` or `yarn global add @angular/cli@15 --registry=https://registry.npmmirror.com`
5. 待本地后端启动后,在web-app目录下启动本地前端 `pnpm start`
6. 待本地后端启动后,在web-app目录下启动本地前端 `ng serve --open`
7. 浏览器访问 localhost:4200 即可开始,默认账号密码 *admin/hertzbeat*
6. 浏览器访问 localhost:4200 即可开始,默认账号密码 *admin/hertzbeat*
### 寻找任务
+22 -2
View File
@@ -361,7 +361,7 @@ Thanks to these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lisongning"><img src="https://avatars.githubusercontent.com/u/93140178?v=4?s=100" width="100px;" alt="lisongning"/><br /><sub><b>lisongning</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=lisongning" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/YutingNie"><img src="https://avatars.githubusercontent.com/u/104416402?v=4?s=100" width="100px;" alt="YutingNie"/><br /><sub><b>YutingNie</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=YutingNie" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=YutingNie" title="Documentation">📖</a> <a href="#design-YutingNie" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mikezzb"><img src="https://avatars.githubusercontent.com/u/23418428?v=4?s=100" width="100px;" alt="Mike Zhou"/><br /><sub><b>Mike Zhou</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=mikezzb" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=mikezzb" title="Documentation">📖</a> <a href="#design-mikezzb" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/a-little-fool"><img src="https://avatars.githubusercontent.com/u/105542329?v=4?s=100" width="100px;" alt="小笨蛋"/><br /><sub><b>小笨蛋</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=a-little-fool" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=a-little-fool" title="Documentation">📖</a> <a href="#blog-a-little-fool" title="Blogposts">📝</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3Aa-little-fool" title="Bug reports">🐛</a> <a href="#design-a-little-fool" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lynx009"><img src="https://avatars.githubusercontent.com/u/105542329?v=4?s=100" width="100px;" alt="lynx009"/><br /><sub><b>lynx009</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=lynx009" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/littlezhongzer"><img src="https://avatars.githubusercontent.com/u/33685289?v=4?s=100" width="100px;" alt="littlezhongzer"/><br /><sub><b>littlezhongzer</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=littlezhongzer" title="Code">💻</a></td>
@@ -385,7 +385,7 @@ Thanks to these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://github.com/1036664317"><img src="https://avatars.githubusercontent.com/u/7696697?v=4?s=100" width="100px;" alt="moghn"/><br /><sub><b>moghn</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=1036664317" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xiaoguolong"><img src="https://avatars.githubusercontent.com/u/33684988?v=4?s=100" width="100px;" alt="xiaoguolong"/><br /><sub><b>xiaoguolong</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=xiaoguolong" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Clownsw"><img src="https://avatars.githubusercontent.com/u/28394742?v=4?s=100" width="100px;" alt="Smliexx"/><br /><sub><b>Smliexx</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Clownsw" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3AClownsw" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Calvin979"><img src="https://avatars.githubusercontent.com/u/131688897?v=4?s=100" width="100px;" alt="Naruse"/><br /><sub><b>Naruse</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Documentation">📖</a> <a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Code">💻</a> <a href="#design-Calvin979" title="Design">🎨</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3ACalvin979" title="Bug reports">🐛</a> <a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Calvin979"><img src="https://avatars.githubusercontent.com/u/131688897?v=4?s=100" width="100px;" alt="Calvin"/><br /><sub><b>Calvin</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Documentation">📖</a> <a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Code">💻</a> <a href="#design-Calvin979" title="Design">🎨</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3ACalvin979" title="Bug reports">🐛</a> <a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/bbelide2"><img src="https://avatars.githubusercontent.com/u/26840796?v=4?s=100" width="100px;" alt="Bala Sukesh"/><br /><sub><b>Bala Sukesh</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=bbelide2" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jinyaoMa"><img src="https://avatars.githubusercontent.com/u/25066570?v=4?s=100" width="100px;" alt="Jinyao Ma"/><br /><sub><b>Jinyao Ma</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=jinyaoMa" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://linuxsuren.github.io/open-source-best-practice/"><img src="https://avatars.githubusercontent.com/u/1450685?v=4?s=100" width="100px;" alt="Rick"/><br /><sub><b>Rick</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=LinuxSuRen" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=LinuxSuRen" title="Tests">⚠️</a></td>
@@ -529,6 +529,26 @@ Thanks to these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xiaomizhou2"><img src="https://avatars.githubusercontent.com/u/47807926?v=4?s=100" width="100px;" alt="zhangyaxi"/><br /><sub><b>zhangyaxi</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=xiaomizhou2" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=xiaomizhou2" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RainBondsongyg"><img src="https://avatars.githubusercontent.com/u/94501396?v=4?s=100" width="100px;" alt="songyg"/><br /><sub><b>songyg</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=RainBondsongyg" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lx1229"><img src="https://avatars.githubusercontent.com/u/44620005?v=4?s=100" width="100px;" alt="Liuxin"/><br /><sub><b>Liuxin</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=lx1229" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yy549159265"><img src="https://avatars.githubusercontent.com/u/40821310?v=4?s=100" width="100px;" alt="yy549159265"/><br /><sub><b>yy549159265</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=yy549159265" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=yy549159265" title="Tests">⚠️</a> <a href="#design-yy549159265" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/cto-huhang"><img src="https://avatars.githubusercontent.com/u/53338629?v=4?s=100" width="100px;" alt="cto-huhang"/><br /><sub><b>cto-huhang</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=cto-huhang" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Saramanda9988"><img src="https://avatars.githubusercontent.com/u/176664901?v=4?s=100" width="100px;" alt="LunaRain_079"/><br /><sub><b>LunaRain_079</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Saramanda9988" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/delei"><img src="https://avatars.githubusercontent.com/u/17263766?v=4?s=100" width="100px;" alt="DeleiGuo"/><br /><sub><b>DeleiGuo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=delei" title="Documentation">📖</a> <a href="https://github.com/apache/hertzbeat/commits?author=delei" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=delei" title="Tests">⚠️</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3Adelei" title="Bug reports">🐛</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/chingjustwe"><img src="https://avatars.githubusercontent.com/u/13643747?v=4?s=100" width="100px;" alt="Rocky, Chi"/><br /><sub><b>Rocky, Chi</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=chingjustwe" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rowankid"><img src="https://avatars.githubusercontent.com/u/18652781?v=4?s=100" width="100px;" alt="Wenqi Luo"/><br /><sub><b>Wenqi Luo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/issues?q=author%3Arowankid" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tuzuy"><img src="https://avatars.githubusercontent.com/u/95274591?v=4?s=100" width="100px;" alt="tuzuy"/><br /><sub><b>tuzuy</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=tuzuy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/carlpinto25"><img src="https://avatars.githubusercontent.com/u/117299909?v=4?s=100" width="100px;" alt="carl pinto"/><br /><sub><b>carl pinto</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=carlpinto25" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://cxhello.top/"><img src="https://avatars.githubusercontent.com/u/49056040?v=4?s=100" width="100px;" alt="cxhello"/><br /><sub><b>cxhello</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=cxhello" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jl15988"><img src="https://avatars.githubusercontent.com/u/70638770?v=4?s=100" width="100px;" alt="会功夫的李白"/><br /><sub><b>会功夫的李白</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=jl15988" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://blog.aytop.cloud/"><img src="https://avatars.githubusercontent.com/u/37127008?v=4?s=100" width="100px;" alt="Albert.Yang"/><br /><sub><b>Albert.Yang</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=AlbertYang0801" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://blog.tokenlen.top/"><img src="https://avatars.githubusercontent.com/u/150590575?v=4?s=100" width="100px;" alt="zhou yong kang"/><br /><sub><b>zhou yong kang</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=mengnankkkk" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/warrobe"><img src="https://avatars.githubusercontent.com/u/89446159?v=4?s=100" width="100px;" alt="warrobe"/><br /><sub><b>warrobe</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=warrobe" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jetiaime"><img src="https://avatars.githubusercontent.com/u/93769000?v=4?s=100" width="100px;" alt="TeAmo"/><br /><sub><b>TeAmo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Jetiaime" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/pentium100"><img src="https://avatars.githubusercontent.com/u/27917?v=4?s=100" width="100px;" alt="pentium100"/><br /><sub><b>pentium100</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=pentium100" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dedyks"><img src="https://avatars.githubusercontent.com/u/23741665?v=4?s=100" width="100px;" alt="Dedy Kurniawan Santoso"/><br /><sub><b>Dedy Kurniawan Santoso</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=dedyks" title="Code">💻</a></td>
</tr>
</tbody>
</table>
+22 -2
View File
@@ -359,7 +359,7 @@ Thanks these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lisongning"><img src="https://avatars.githubusercontent.com/u/93140178?v=4?s=100" width="100px;" alt="lisongning"/><br /><sub><b>lisongning</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=lisongning" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/YutingNie"><img src="https://avatars.githubusercontent.com/u/104416402?v=4?s=100" width="100px;" alt="YutingNie"/><br /><sub><b>YutingNie</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=YutingNie" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=YutingNie" title="Documentation">📖</a> <a href="#design-YutingNie" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mikezzb"><img src="https://avatars.githubusercontent.com/u/23418428?v=4?s=100" width="100px;" alt="Mike Zhou"/><br /><sub><b>Mike Zhou</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=mikezzb" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=mikezzb" title="Documentation">📖</a> <a href="#design-mikezzb" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/a-little-fool"><img src="https://avatars.githubusercontent.com/u/105542329?v=4?s=100" width="100px;" alt="小笨蛋"/><br /><sub><b>小笨蛋</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=a-little-fool" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=a-little-fool" title="Documentation">📖</a> <a href="#blog-a-little-fool" title="Blogposts">📝</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3Aa-little-fool" title="Bug reports">🐛</a> <a href="#design-a-little-fool" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lynx009"><img src="https://avatars.githubusercontent.com/u/105542329?v=4?s=100" width="100px;" alt="lynx009"/><br /><sub><b>lynx009</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=lynx009" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/littlezhongzer"><img src="https://avatars.githubusercontent.com/u/33685289?v=4?s=100" width="100px;" alt="littlezhongzer"/><br /><sub><b>littlezhongzer</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=littlezhongzer" title="Code">💻</a></td>
@@ -383,7 +383,7 @@ Thanks these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://github.com/1036664317"><img src="https://avatars.githubusercontent.com/u/7696697?v=4?s=100" width="100px;" alt="moghn"/><br /><sub><b>moghn</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=1036664317" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xiaoguolong"><img src="https://avatars.githubusercontent.com/u/33684988?v=4?s=100" width="100px;" alt="xiaoguolong"/><br /><sub><b>xiaoguolong</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=xiaoguolong" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Clownsw"><img src="https://avatars.githubusercontent.com/u/28394742?v=4?s=100" width="100px;" alt="Smliexx"/><br /><sub><b>Smliexx</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Clownsw" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3AClownsw" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Calvin979"><img src="https://avatars.githubusercontent.com/u/131688897?v=4?s=100" width="100px;" alt="Naruse"/><br /><sub><b>Naruse</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Documentation">📖</a> <a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Code">💻</a> <a href="#design-Calvin979" title="Design">🎨</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3ACalvin979" title="Bug reports">🐛</a> <a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Calvin979"><img src="https://avatars.githubusercontent.com/u/131688897?v=4?s=100" width="100px;" alt="Calvin"/><br /><sub><b>Calvin</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Documentation">📖</a> <a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Code">💻</a> <a href="#design-Calvin979" title="Design">🎨</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3ACalvin979" title="Bug reports">🐛</a> <a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/bbelide2"><img src="https://avatars.githubusercontent.com/u/26840796?v=4?s=100" width="100px;" alt="Bala Sukesh"/><br /><sub><b>Bala Sukesh</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=bbelide2" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jinyaoMa"><img src="https://avatars.githubusercontent.com/u/25066570?v=4?s=100" width="100px;" alt="Jinyao Ma"/><br /><sub><b>Jinyao Ma</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=jinyaoMa" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://linuxsuren.github.io/open-source-best-practice/"><img src="https://avatars.githubusercontent.com/u/1450685?v=4?s=100" width="100px;" alt="Rick"/><br /><sub><b>Rick</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=LinuxSuRen" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=LinuxSuRen" title="Tests">⚠️</a></td>
@@ -527,6 +527,26 @@ Thanks these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xiaomizhou2"><img src="https://avatars.githubusercontent.com/u/47807926?v=4?s=100" width="100px;" alt="zhangyaxi"/><br /><sub><b>zhangyaxi</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=xiaomizhou2" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=xiaomizhou2" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RainBondsongyg"><img src="https://avatars.githubusercontent.com/u/94501396?v=4?s=100" width="100px;" alt="songyg"/><br /><sub><b>songyg</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=RainBondsongyg" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lx1229"><img src="https://avatars.githubusercontent.com/u/44620005?v=4?s=100" width="100px;" alt="Liuxin"/><br /><sub><b>Liuxin</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=lx1229" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yy549159265"><img src="https://avatars.githubusercontent.com/u/40821310?v=4?s=100" width="100px;" alt="yy549159265"/><br /><sub><b>yy549159265</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=yy549159265" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=yy549159265" title="Tests">⚠️</a> <a href="#design-yy549159265" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/cto-huhang"><img src="https://avatars.githubusercontent.com/u/53338629?v=4?s=100" width="100px;" alt="cto-huhang"/><br /><sub><b>cto-huhang</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=cto-huhang" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Saramanda9988"><img src="https://avatars.githubusercontent.com/u/176664901?v=4?s=100" width="100px;" alt="LunaRain_079"/><br /><sub><b>LunaRain_079</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Saramanda9988" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/delei"><img src="https://avatars.githubusercontent.com/u/17263766?v=4?s=100" width="100px;" alt="DeleiGuo"/><br /><sub><b>DeleiGuo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=delei" title="Documentation">📖</a> <a href="https://github.com/apache/hertzbeat/commits?author=delei" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=delei" title="Tests">⚠️</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3Adelei" title="Bug reports">🐛</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/chingjustwe"><img src="https://avatars.githubusercontent.com/u/13643747?v=4?s=100" width="100px;" alt="Rocky, Chi"/><br /><sub><b>Rocky, Chi</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=chingjustwe" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rowankid"><img src="https://avatars.githubusercontent.com/u/18652781?v=4?s=100" width="100px;" alt="Wenqi Luo"/><br /><sub><b>Wenqi Luo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/issues?q=author%3Arowankid" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tuzuy"><img src="https://avatars.githubusercontent.com/u/95274591?v=4?s=100" width="100px;" alt="tuzuy"/><br /><sub><b>tuzuy</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=tuzuy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/carlpinto25"><img src="https://avatars.githubusercontent.com/u/117299909?v=4?s=100" width="100px;" alt="carl pinto"/><br /><sub><b>carl pinto</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=carlpinto25" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://cxhello.top/"><img src="https://avatars.githubusercontent.com/u/49056040?v=4?s=100" width="100px;" alt="cxhello"/><br /><sub><b>cxhello</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=cxhello" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jl15988"><img src="https://avatars.githubusercontent.com/u/70638770?v=4?s=100" width="100px;" alt="会功夫的李白"/><br /><sub><b>会功夫的李白</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=jl15988" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://blog.aytop.cloud/"><img src="https://avatars.githubusercontent.com/u/37127008?v=4?s=100" width="100px;" alt="Albert.Yang"/><br /><sub><b>Albert.Yang</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=AlbertYang0801" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://blog.tokenlen.top/"><img src="https://avatars.githubusercontent.com/u/150590575?v=4?s=100" width="100px;" alt="zhou yong kang"/><br /><sub><b>zhou yong kang</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=mengnankkkk" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/warrobe"><img src="https://avatars.githubusercontent.com/u/89446159?v=4?s=100" width="100px;" alt="warrobe"/><br /><sub><b>warrobe</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=warrobe" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jetiaime"><img src="https://avatars.githubusercontent.com/u/93769000?v=4?s=100" width="100px;" alt="TeAmo"/><br /><sub><b>TeAmo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Jetiaime" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/pentium100"><img src="https://avatars.githubusercontent.com/u/27917?v=4?s=100" width="100px;" alt="pentium100"/><br /><sub><b>pentium100</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=pentium100" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dedyks"><img src="https://avatars.githubusercontent.com/u/23741665?v=4?s=100" width="100px;" alt="Dedy Kurniawan Santoso"/><br /><sub><b>Dedy Kurniawan Santoso</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=dedyks" title="Code">💻</a></td>
</tr>
</tbody>
</table>
+22 -2
View File
@@ -363,7 +363,7 @@ Thanks these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lisongning"><img src="https://avatars.githubusercontent.com/u/93140178?v=4?s=100" width="100px;" alt="lisongning"/><br /><sub><b>lisongning</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=lisongning" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/YutingNie"><img src="https://avatars.githubusercontent.com/u/104416402?v=4?s=100" width="100px;" alt="YutingNie"/><br /><sub><b>YutingNie</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=YutingNie" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=YutingNie" title="Documentation">📖</a> <a href="#design-YutingNie" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mikezzb"><img src="https://avatars.githubusercontent.com/u/23418428?v=4?s=100" width="100px;" alt="Mike Zhou"/><br /><sub><b>Mike Zhou</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=mikezzb" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=mikezzb" title="Documentation">📖</a> <a href="#design-mikezzb" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/a-little-fool"><img src="https://avatars.githubusercontent.com/u/105542329?v=4?s=100" width="100px;" alt="小笨蛋"/><br /><sub><b>小笨蛋</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=a-little-fool" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=a-little-fool" title="Documentation">📖</a> <a href="#blog-a-little-fool" title="Blogposts">📝</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3Aa-little-fool" title="Bug reports">🐛</a> <a href="#design-a-little-fool" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lynx009"><img src="https://avatars.githubusercontent.com/u/105542329?v=4?s=100" width="100px;" alt="lynx009"/><br /><sub><b>lynx009</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=lynx009" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/littlezhongzer"><img src="https://avatars.githubusercontent.com/u/33685289?v=4?s=100" width="100px;" alt="littlezhongzer"/><br /><sub><b>littlezhongzer</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=littlezhongzer" title="Code">💻</a></td>
@@ -387,7 +387,7 @@ Thanks these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://github.com/1036664317"><img src="https://avatars.githubusercontent.com/u/7696697?v=4?s=100" width="100px;" alt="moghn"/><br /><sub><b>moghn</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=1036664317" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xiaoguolong"><img src="https://avatars.githubusercontent.com/u/33684988?v=4?s=100" width="100px;" alt="xiaoguolong"/><br /><sub><b>xiaoguolong</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=xiaoguolong" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Clownsw"><img src="https://avatars.githubusercontent.com/u/28394742?v=4?s=100" width="100px;" alt="Smliexx"/><br /><sub><b>Smliexx</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Clownsw" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3AClownsw" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Calvin979"><img src="https://avatars.githubusercontent.com/u/131688897?v=4?s=100" width="100px;" alt="Naruse"/><br /><sub><b>Naruse</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Documentation">📖</a> <a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Code">💻</a> <a href="#design-Calvin979" title="Design">🎨</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3ACalvin979" title="Bug reports">🐛</a> <a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Calvin979"><img src="https://avatars.githubusercontent.com/u/131688897?v=4?s=100" width="100px;" alt="Calvin"/><br /><sub><b>Calvin</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Documentation">📖</a> <a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Code">💻</a> <a href="#design-Calvin979" title="Design">🎨</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3ACalvin979" title="Bug reports">🐛</a> <a href="https://github.com/apache/hertzbeat/commits?author=Calvin979" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/bbelide2"><img src="https://avatars.githubusercontent.com/u/26840796?v=4?s=100" width="100px;" alt="Bala Sukesh"/><br /><sub><b>Bala Sukesh</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=bbelide2" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jinyaoMa"><img src="https://avatars.githubusercontent.com/u/25066570?v=4?s=100" width="100px;" alt="Jinyao Ma"/><br /><sub><b>Jinyao Ma</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=jinyaoMa" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://linuxsuren.github.io/open-source-best-practice/"><img src="https://avatars.githubusercontent.com/u/1450685?v=4?s=100" width="100px;" alt="Rick"/><br /><sub><b>Rick</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=LinuxSuRen" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=LinuxSuRen" title="Tests">⚠️</a></td>
@@ -531,6 +531,26 @@ Thanks these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xiaomizhou2"><img src="https://avatars.githubusercontent.com/u/47807926?v=4?s=100" width="100px;" alt="zhangyaxi"/><br /><sub><b>zhangyaxi</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=xiaomizhou2" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=xiaomizhou2" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RainBondsongyg"><img src="https://avatars.githubusercontent.com/u/94501396?v=4?s=100" width="100px;" alt="songyg"/><br /><sub><b>songyg</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=RainBondsongyg" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lx1229"><img src="https://avatars.githubusercontent.com/u/44620005?v=4?s=100" width="100px;" alt="Liuxin"/><br /><sub><b>Liuxin</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=lx1229" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yy549159265"><img src="https://avatars.githubusercontent.com/u/40821310?v=4?s=100" width="100px;" alt="yy549159265"/><br /><sub><b>yy549159265</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=yy549159265" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=yy549159265" title="Tests">⚠️</a> <a href="#design-yy549159265" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/cto-huhang"><img src="https://avatars.githubusercontent.com/u/53338629?v=4?s=100" width="100px;" alt="cto-huhang"/><br /><sub><b>cto-huhang</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=cto-huhang" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Saramanda9988"><img src="https://avatars.githubusercontent.com/u/176664901?v=4?s=100" width="100px;" alt="LunaRain_079"/><br /><sub><b>LunaRain_079</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Saramanda9988" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/delei"><img src="https://avatars.githubusercontent.com/u/17263766?v=4?s=100" width="100px;" alt="DeleiGuo"/><br /><sub><b>DeleiGuo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=delei" title="Documentation">📖</a> <a href="https://github.com/apache/hertzbeat/commits?author=delei" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=delei" title="Tests">⚠️</a> <a href="https://github.com/apache/hertzbeat/issues?q=author%3Adelei" title="Bug reports">🐛</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/chingjustwe"><img src="https://avatars.githubusercontent.com/u/13643747?v=4?s=100" width="100px;" alt="Rocky, Chi"/><br /><sub><b>Rocky, Chi</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=chingjustwe" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rowankid"><img src="https://avatars.githubusercontent.com/u/18652781?v=4?s=100" width="100px;" alt="Wenqi Luo"/><br /><sub><b>Wenqi Luo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/issues?q=author%3Arowankid" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tuzuy"><img src="https://avatars.githubusercontent.com/u/95274591?v=4?s=100" width="100px;" alt="tuzuy"/><br /><sub><b>tuzuy</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=tuzuy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/carlpinto25"><img src="https://avatars.githubusercontent.com/u/117299909?v=4?s=100" width="100px;" alt="carl pinto"/><br /><sub><b>carl pinto</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=carlpinto25" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://cxhello.top/"><img src="https://avatars.githubusercontent.com/u/49056040?v=4?s=100" width="100px;" alt="cxhello"/><br /><sub><b>cxhello</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=cxhello" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jl15988"><img src="https://avatars.githubusercontent.com/u/70638770?v=4?s=100" width="100px;" alt="会功夫的李白"/><br /><sub><b>会功夫的李白</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=jl15988" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://blog.aytop.cloud/"><img src="https://avatars.githubusercontent.com/u/37127008?v=4?s=100" width="100px;" alt="Albert.Yang"/><br /><sub><b>Albert.Yang</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=AlbertYang0801" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://blog.tokenlen.top/"><img src="https://avatars.githubusercontent.com/u/150590575?v=4?s=100" width="100px;" alt="zhou yong kang"/><br /><sub><b>zhou yong kang</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=mengnankkkk" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/warrobe"><img src="https://avatars.githubusercontent.com/u/89446159?v=4?s=100" width="100px;" alt="warrobe"/><br /><sub><b>warrobe</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=warrobe" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jetiaime"><img src="https://avatars.githubusercontent.com/u/93769000?v=4?s=100" width="100px;" alt="TeAmo"/><br /><sub><b>TeAmo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Jetiaime" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/pentium100"><img src="https://avatars.githubusercontent.com/u/27917?v=4?s=100" width="100px;" alt="pentium100"/><br /><sub><b>pentium100</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=pentium100" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dedyks"><img src="https://avatars.githubusercontent.com/u/23741665?v=4?s=100" width="100px;" alt="Dedy Kurniawan Santoso"/><br /><sub><b>Dedy Kurniawan Santoso</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=dedyks" title="Code">💻</a></td>
</tr>
</tbody>
</table>
+13 -3
View File
@@ -26,7 +26,7 @@
<artifactId>hertzbeat-ai-agent</artifactId>
<version>${hertzbeat.version}</version>
<properties>
<spring-ai.version>1.0.1</spring-ai.version>
<spring-ai.version>1.0.3</spring-ai.version>
<java.version>17</java.version>
</properties>
@@ -51,12 +51,22 @@
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
<artifactId>spring-ai-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
</dependency>
<dependency>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat-common</artifactId>
</dependency>
<!-- common -->
<dependency>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat-base</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat-alerter</artifactId>
@@ -90,4 +100,4 @@
</plugins>
</build>
</project>
</project>
@@ -18,40 +18,39 @@
package org.apache.hertzbeat.ai.agent.config;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.agent.pojo.dto.OpenAiConfigDto;
import org.apache.hertzbeat.ai.agent.service.OpenAiConfigService;
import org.apache.hertzbeat.ai.agent.pojo.dto.ModelProviderConfig;
import org.apache.hertzbeat.base.dao.GeneralConfigDao;
import org.apache.hertzbeat.common.entity.manager.GeneralConfig;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.jetbrains.annotations.NotNull;
import org.springframework.ai.model.ApiKey;
import org.springframework.stereotype.Component;
/**
* Dynamic OpenAI API Key implementation that retrieves the API key
* from our configuration service (database first, then YAML fallback)
* Dynamic LLM Provider API Key implementation that retrieves the API key
*/
@Slf4j
@Component
public class DynamicOpenAiApiKey implements ApiKey {
private final OpenAiConfigService openAiConfigService;
public DynamicOpenAiApiKey(OpenAiConfigService openAiConfigService) {
this.openAiConfigService = openAiConfigService;
private final GeneralConfigDao generalConfigDao;
public DynamicOpenAiApiKey(GeneralConfigDao generalConfigDao) {
this.generalConfigDao = generalConfigDao;
}
@NotNull
@Override
public String getValue() {
try {
OpenAiConfigDto effectiveConfig = openAiConfigService.getEffectiveConfig();
if (effectiveConfig != null && effectiveConfig.isEnable() && effectiveConfig.getApiKey() != null) {
log.debug("Retrieved OpenAI API key from configuration service");
return effectiveConfig.getApiKey();
} else {
log.warn("No valid OpenAI API key found in configuration");
return null;
}
} catch (Exception e) {
log.error("Error retrieving OpenAI API key from configuration", e);
return null;
GeneralConfig providerConfig = generalConfigDao.findByType("provider");
ModelProviderConfig modelProviderConfig = JsonUtil.fromJson(providerConfig.getContent(), ModelProviderConfig.class);
if (modelProviderConfig != null && modelProviderConfig.isEnable() && modelProviderConfig.isStatus()) {
log.debug("Retrieved {} API key from configuration service", modelProviderConfig.getCode());
return modelProviderConfig.getApiKey();
} else {
log.warn("No valid LLM Provider API key found in configuration");
return "";
}
}
}
}
@@ -18,59 +18,144 @@
package org.apache.hertzbeat.ai.agent.config;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.support.event.AiProviderConfigChangeEvent;
import org.apache.hertzbeat.ai.agent.pojo.dto.ModelProviderConfig;
import org.apache.hertzbeat.base.dao.GeneralConfigDao;
import org.apache.hertzbeat.common.entity.manager.GeneralConfig;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
/**
* Configuration class for Large Language Model (LLM) settings.
*/
@Configuration
@Slf4j
public class LlmConfig {
@Value("${spring.ai.openai.chat.options.model}")
private String model;
private final GeneralConfigDao generalConfigDao;
private ApplicationContext applicationContext;
/**
* Create OpenAI API instance with dynamic API key
*/
@Bean
public OpenAiApi openAiApi(DynamicOpenAiApiKey dynamicApiKey) {
return OpenAiApi.builder()
.apiKey(dynamicApiKey)
.build();
public LlmConfig(GeneralConfigDao generalConfigDao, ApplicationContext applicationContext) {
this.generalConfigDao = generalConfigDao;
this.applicationContext = applicationContext;
}
/**
* Create OpenAI Chat Options with custom settings
* Create ChatClient bean with all dependencies created internally
*/
@Bean
public OpenAiChatOptions openAiChatOptions() {
return OpenAiChatOptions.builder()
.model(model)
public ChatClient openAiChatClient() {
return createChatClient();
}
/**
* Create ChatClient with all necessary components
*/
private ChatClient createChatClient() {
GeneralConfig providerConfig = generalConfigDao.findByType("provider");
if (providerConfig == null || providerConfig.getContent() == null) {
log.warn("LLM Provider is not set, ChatClient bean will not be created");
return null;
}
ModelProviderConfig modelProviderConfig = JsonUtil.fromJson(providerConfig.getContent(), ModelProviderConfig.class);
if (!modelProviderConfig.isEnable() || !modelProviderConfig.isStatus()) {
log.warn("LLM Provider is not enabled or status is not valid, ChatClient bean will not be created");
return null;
}
if (modelProviderConfig.getApiKey() == null) {
log.warn("LLM Provider configuration is incomplete, ChatClient bean will not be created");
return null;
}
if (modelProviderConfig.getBaseUrl() == null) {
if ("openai".equals(modelProviderConfig.getCode())) {
modelProviderConfig.setBaseUrl("https://api.openai.com/v1");
} else if ("zhipu".equals(modelProviderConfig.getCode())) {
modelProviderConfig.setBaseUrl("https://open.bigmodel.cn/api/paas/v4");
} else if ("zai".equals(modelProviderConfig.getCode())) {
modelProviderConfig.setBaseUrl("https://api.z.ai/api/paas/v4");
} else {
modelProviderConfig.setBaseUrl("https://api.openai.com/v1");
}
}
if (modelProviderConfig.getModel() == null) {
if ("openai".equals(modelProviderConfig.getCode())) {
modelProviderConfig.setModel("gpt-5");
} else if ("zhipu".equals(modelProviderConfig.getCode())) {
modelProviderConfig.setModel("glm-4.6");
} else if ("zai".equals(modelProviderConfig.getCode())) {
modelProviderConfig.setModel("glm-4.6");
} else {
modelProviderConfig.setModel("gpt-5");
}
}
OpenAiApi.Builder builder = new OpenAiApi.Builder();
builder.baseUrl(modelProviderConfig.getBaseUrl());
builder.apiKey(modelProviderConfig.getApiKey());
builder.completionsPath("/chat/completions");
// Create Chat Options
OpenAiChatOptions openAiChatOptions = OpenAiChatOptions.builder()
.model(modelProviderConfig.getModel())
.temperature(0.3)
.build();
}
/**
* Create OpenAI Chat Model with custom API configuration
*/
@Bean
public OpenAiChatModel openAiChatModel(OpenAiApi openAiApi, OpenAiChatOptions openAiChatOptions) {
return OpenAiChatModel.builder()
.openAiApi(openAiApi)
// Create Chat Model
OpenAiChatModel openAiChatModel = OpenAiChatModel.builder()
.openAiApi(builder.build())
.defaultOptions(openAiChatOptions)
.build();
}
@Bean
public ChatClient openAiChatClient(OpenAiChatModel openAiChatModel) {
// Create and return ChatClient
return ChatClient.create(openAiChatModel);
}
}
/**
* AI configuration change event listener
* Uses ApplicationContext to unregister and re-register the ChatClient bean
*/
@EventListener(AiProviderConfigChangeEvent.class)
public void onAiProviderConfigChange(AiProviderConfigChangeEvent event) {
log.info("Provider configuration change event received, refreshing ChatClient bean");
try {
ConfigurableApplicationContext configurableContext = (ConfigurableApplicationContext) applicationContext;
DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) configurableContext.getBeanFactory();
// Remove the existing ChatClient bean
if (beanFactory.containsSingleton("openAiChatClient")) {
beanFactory.destroySingleton("openAiChatClient");
log.info("Existing ChatClient bean destroyed");
}
// Create new ChatClient with updated configuration
ChatClient newChatClient = createChatClient();
// Register the new ChatClient bean
beanFactory.registerSingleton("openAiChatClient", newChatClient);
log.info("ChatClient bean refreshed successfully with new AI provider configuration");
} catch (Exception e) {
log.error("Failed to refresh ChatClient bean after configuration change", e);
}
}
}
@@ -1,160 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.agent.pojo.dto.OpenAiConfigDto;
import org.apache.hertzbeat.ai.agent.service.OpenAiConfigService;
import org.springframework.http.ResponseEntity;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
/**
* OpenAI Configuration API
*/
@RestController
@RequestMapping(value = "/api/ai-agent/config", produces = {APPLICATION_JSON_VALUE})
@Tag(name = "OpenAI Configuration API")
@Slf4j
public class OpenAiConfigController {
private final OpenAiConfigService openAiConfigService;
public OpenAiConfigController(OpenAiConfigService openAiConfigService) {
this.openAiConfigService = openAiConfigService;
}
@PostMapping("/openai")
@Operation(summary = "Save OpenAI configuration", description = "Save or update OpenAI configuration")
public ResponseEntity<Map<String, Object>> saveOpenAiConfig(@Valid @RequestBody OpenAiConfigDto config) {
try {
Map<String, Object> response = new HashMap<>();
// Validate API key if enabled
if (config.isEnable() && config.getApiKey() != null && !config.getApiKey().trim().isEmpty()) {
OpenAiConfigService.ValidationResult validationResult = openAiConfigService.validateApiKey(config.getApiKey());
if (!validationResult.isValid()) {
log.warn("API key validation failed during save: {}", validationResult.getMessage());
response.put("code", 1);
response.put("msg", "API key validation failed: " + validationResult.getMessage());
return ResponseEntity.ok(response);
}
log.info("API key validation successful during save");
}
// Save the configuration
openAiConfigService.saveConfig(config);
response.put("code", 0);
response.put("msg", "OpenAI configuration saved successfully");
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to save OpenAI configuration", e);
Map<String, Object> response = new HashMap<>();
response.put("code", 1);
response.put("msg", "Failed to save configuration: " + e.getMessage());
return ResponseEntity.ok(response);
}
}
@GetMapping("/openai")
@Operation(summary = "Get OpenAI configuration", description = "Get current OpenAI configuration")
public ResponseEntity<Map<String, Object>> getOpenAiConfig() {
try {
OpenAiConfigDto config = openAiConfigService.getConfig();
Map<String, Object> response = new HashMap<>();
response.put("code", 0);
response.put("data", config);
response.put("msg", "Success");
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to get OpenAI configuration", e);
Map<String, Object> response = new HashMap<>();
response.put("code", 1);
response.put("msg", "Failed to get configuration: " + e.getMessage());
return ResponseEntity.ok(response);
}
}
@GetMapping("/openai/status")
@Operation(summary = "Check OpenAI configuration status", description = "Check if OpenAI is properly configured")
public ResponseEntity<Map<String, Object>> getOpenAiConfigStatus() {
try {
boolean configured = openAiConfigService.isConfigured();
OpenAiConfigDto effectiveConfig = openAiConfigService.getEffectiveConfig();
boolean hasDbConfig = openAiConfigService.getConfig() != null;
boolean hasYamlConfig = effectiveConfig != null && !hasDbConfig;
// Validate the effective configuration
boolean validationPassed = false;
String validationMessage = "No configuration found";
if (effectiveConfig != null && effectiveConfig.isEnable() && effectiveConfig.getApiKey() != null && !effectiveConfig.getApiKey().trim().isEmpty()) {
OpenAiConfigService.ValidationResult validationResult = openAiConfigService.validateApiKey(effectiveConfig.getApiKey());
validationPassed = validationResult.isValid();
validationMessage = validationResult.getMessage();
if (!validationPassed) {
log.warn("OpenAI API key validation failed during status check: {}", validationMessage);
}
}
Map<String, Object> response = new HashMap<>();
response.put("code", 0);
response.put("data", Map.of(
"configured", configured && validationPassed,
"hasDbConfig", hasDbConfig,
"hasYamlConfig", hasYamlConfig,
"validationPassed", validationPassed,
"validationMessage", validationMessage
));
response.put("msg", "Success");
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to get OpenAI configuration status", e);
Map<String, Object> response = new HashMap<>();
response.put("code", 1);
response.put("msg", "Failed to get status: " + e.getMessage());
return ResponseEntity.ok(response);
}
}
}
@@ -24,24 +24,36 @@ import lombok.Data;
import lombok.NoArgsConstructor;
/**
* OpenAI Configuration DTO - simplified to handle only API key
* Model Provider Configuration
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Schema(description = "OpenAI configuration")
public class OpenAiConfigDto {
/**
* Whether to enable OpenAI, default is false
*/
@Schema(title = "Enable OpenAI", description = "Whether OpenAI is enabled", example = "true")
@Schema(description = "LLM Model Provider configuration")
public class ModelProviderConfig {
@Schema(title = "Enable Provider", description = "Whether Provider is enabled", example = "true")
private boolean enable = false;
/**
* OpenAI API key
*/
@Schema(title = "API Key", description = "OpenAI API key", example = "sk-...")
@Schema(title = "Check the provider available status")
private boolean status = false;
@Schema(title = "The error message when provider status check failed")
private String error;
@Schema(title = "Model type, text-generate, vision")
private String type;
@Schema(title = "Model Provider code, like openai, zai, bigmodel")
private String code;
@Schema(title = "custom the provider server base url")
private String baseUrl;
@Schema(title = "use the model id name, eg: gpt-5, glm-4.6")
private String model;
@Schema(title = "API Key", description = "API key", example = "sk-...")
@NotBlank(message = "API Key cannot be empty when enabled")
private String apiKey;
}
}
@@ -17,38 +17,10 @@
package org.apache.hertzbeat.ai.agent.service;
import org.apache.hertzbeat.ai.agent.event.OpenAiConfigChangeEvent;
import org.apache.hertzbeat.ai.agent.pojo.dto.OpenAiConfigDto;
/**
* OpenAI Configuration Service
* Consolidated service for OpenAI configuration, validation, and client factory management
* Ai Configuration Service
*/
public interface OpenAiConfigService {
/**
* Save OpenAI configuration
* @param config OpenAI configuration
*/
void saveConfig(OpenAiConfigDto config);
/**
* Get OpenAI configuration
* @return OpenAI configuration
*/
OpenAiConfigDto getConfig();
/**
* Check if OpenAI is properly configured
* @return true if configured and enabled
*/
boolean isConfigured();
/**
* Get effective OpenAI configuration (DB first, then YAML fallback)
* @return effective configuration or null if not configured
*/
OpenAiConfigDto getEffectiveConfig();
public interface AiConfigService {
/**
* Validate OpenAI API key by calling the OpenAI API
@@ -57,18 +29,6 @@ public interface OpenAiConfigService {
*/
ValidationResult validateApiKey(String apiKey);
/**
* Force reload of OpenAI configuration cache
* This method is typically called when configuration changes
*/
void reloadConfig();
/**
* Handle OpenAI configuration change events
* @param event OpenAI configuration change event
*/
void onOpenAiConfigChange(OpenAiConfigChangeEvent event);
/**
* Validation result class
*/
@@ -97,4 +57,4 @@ public interface OpenAiConfigService {
return message;
}
}
}
}
@@ -33,4 +33,10 @@ public interface ChatClientProviderService {
* @return Flux of string chunks from the LLM response
*/
Flux<String> streamChat(ChatRequestContext context);
}
/**
* Check if provider is properly configured
* @return true if configured and enabled
*/
boolean isConfigured();
}
@@ -0,0 +1,100 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.service.impl;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.agent.service.AiConfigService;
import org.apache.hertzbeat.base.dao.GeneralConfigDao;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
/**
* Ai Configuration Service Implementation
*/
@Slf4j
@Service
public class AiConfigServiceImpl implements AiConfigService {
private static final String OPENAI_MODELS_ENDPOINT = "https://api.openai.com/v1/models";
private final RestTemplate restTemplate;
private final GeneralConfigDao generalConfigDao;
public AiConfigServiceImpl(GeneralConfigDao generalConfigDao, RestTemplate restTemplate) {
this.restTemplate = restTemplate;
this.generalConfigDao = generalConfigDao;
}
@Override
public ValidationResult validateApiKey(String apiKey) {
if (!StringUtils.hasText(apiKey)) {
return ValidationResult.failure("API key cannot be empty");
}
if (!apiKey.startsWith("sk-")) {
return ValidationResult.failure("Invalid API key format. OpenAI API keys should start with 'sk-'");
}
try {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiKey);
headers.set("Content-Type", "application/json");
HttpEntity<String> entity = new HttpEntity<>(headers);
log.debug("Validating OpenAI API key by calling models endpoint");
ResponseEntity<String> response = restTemplate.exchange(
OPENAI_MODELS_ENDPOINT,
HttpMethod.GET,
entity,
String.class
);
if (response.getStatusCode() == HttpStatus.OK) {
log.info("OpenAI API key validation successful");
return ValidationResult.success("API key is valid");
} else {
log.warn("OpenAI API key validation failed with status: {}", response.getStatusCode());
return ValidationResult.failure("API key validation failed: " + response.getStatusCode());
}
} catch (Exception e) {
log.error("Error validating OpenAI API key", e);
String errorMessage = e.getMessage();
// Parse common error messages
if (errorMessage.contains("401")) {
return ValidationResult.failure("Invalid API key - authentication failed");
} else if (errorMessage.contains("403")) {
return ValidationResult.failure("API key does not have permission to access models");
} else if (errorMessage.contains("429")) {
return ValidationResult.failure("Rate limit exceeded - please try again later");
} else if (errorMessage.contains("timeout") || errorMessage.contains("connect")) {
return ValidationResult.failure("Network error - unable to connect to OpenAI API");
} else {
return ValidationResult.failure("API key validation failed: " + errorMessage);
}
}
}
}
@@ -21,7 +21,11 @@ package org.apache.hertzbeat.ai.agent.service.impl;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.agent.config.PromptProvider;
import org.apache.hertzbeat.ai.agent.pojo.dto.MessageDto;
import org.apache.hertzbeat.ai.agent.pojo.dto.ModelProviderConfig;
import org.apache.hertzbeat.ai.agent.service.ChatClientProviderService;
import org.apache.hertzbeat.base.dao.GeneralConfigDao;
import org.apache.hertzbeat.common.entity.manager.GeneralConfig;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.springframework.stereotype.Service;
import org.apache.hertzbeat.ai.agent.pojo.dto.ChatRequestContext;
import org.springframework.ai.chat.client.ChatClient;
@@ -31,6 +35,7 @@ import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.ApplicationContext;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
@@ -45,19 +50,25 @@ import java.util.List;
@Service
public class ChatClientProviderServiceImpl implements ChatClientProviderService {
private final ChatClient chatClient;
private final ApplicationContext applicationContext;
private final GeneralConfigDao generalConfigDao;
@Qualifier("hertzbeatTools")
@Autowired
private ToolCallbackProvider toolCallbackProvider;
private boolean isConfigured = false;
@Autowired
public ChatClientProviderServiceImpl(ChatClient openAiChatClient) {
this.chatClient = openAiChatClient;
public ChatClientProviderServiceImpl(ApplicationContext applicationContext, GeneralConfigDao generalConfigDao) {
this.applicationContext = applicationContext;
this.generalConfigDao = generalConfigDao;
}
public String complete(String message) {
return this.chatClient.prompt()
ChatClient chatClient = applicationContext.getBean("openAiChatClient", ChatClient.class);
return chatClient.prompt()
.user(message)
.call()
.content();
@@ -66,6 +77,9 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
@Override
public Flux<String> streamChat(ChatRequestContext context) {
try {
// Get the current (potentially refreshed) ChatClient instance
ChatClient chatClient = applicationContext.getBean("openAiChatClient", ChatClient.class);
List<Message> messages = new ArrayList<>();
// Add conversation history if available
@@ -83,7 +97,7 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
log.info("Starting streaming chat for conversation: {}", context.getConversationId());
return this.chatClient.prompt()
return chatClient.prompt()
.messages(messages)
.system(PromptProvider.HERTZBEAT_SYSTEM_PROMPT)
.toolCallbacks(toolCallbackProvider)
@@ -97,4 +111,14 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
return Flux.error(e);
}
}
}
@Override
public boolean isConfigured() {
if (!isConfigured) {
GeneralConfig providerConfig = generalConfigDao.findByType("provider");
ModelProviderConfig modelProviderConfig = JsonUtil.fromJson(providerConfig.getContent(), ModelProviderConfig.class);
isConfigured = modelProviderConfig != null && modelProviderConfig.isStatus();
}
return isConfigured;
}
}
@@ -24,7 +24,6 @@ import org.apache.hertzbeat.ai.agent.pojo.dto.ConversationDto;
import org.apache.hertzbeat.ai.agent.pojo.dto.MessageDto;
import org.apache.hertzbeat.ai.agent.service.ChatClientProviderService;
import org.apache.hertzbeat.ai.agent.service.ConversationService;
import org.apache.hertzbeat.ai.agent.service.OpenAiConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
@@ -51,10 +50,7 @@ public class ConversationServiceImpl implements ConversationService {
@Autowired
private ChatClientProviderService chatClientProviderService;
@Autowired
private OpenAiConfigService openAiConfigService;
@Override
public ConversationDto createConversation() {
String conversationId = createNewConversation();
@@ -75,10 +71,10 @@ public class ConversationServiceImpl implements ConversationService {
}
// Check if OpenAI is properly configured
if (!openAiConfigService.isConfigured()) {
if (!chatClientProviderService.isConfigured()) {
ChatResponseDto errorResponse = ChatResponseDto.builder()
.conversationId(conversationId)
.response("OpenAI is not configured. Please configure your OpenAI API key in the settings or application.yml file.")
.response("Provider is not configured. Please configure your AI Provider.")
.build();
return Flux.just(ServerSentEvent.builder(errorResponse)
.event("error")
@@ -273,4 +269,4 @@ public class ConversationServiceImpl implements ConversationService {
}
}
@@ -1,200 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.service.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.agent.config.OpenAiYamlConfig;
import org.apache.hertzbeat.ai.agent.dao.OpenAiConfigDao;
import org.apache.hertzbeat.ai.agent.entity.OpenAiConfig;
import org.apache.hertzbeat.ai.agent.event.OpenAiConfigChangeEvent;
import org.apache.hertzbeat.ai.agent.pojo.dto.OpenAiConfigDto;
import org.apache.hertzbeat.ai.agent.service.OpenAiConfigService;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
/**
* OpenAI Configuration Service Implementation
* Consolidated service for OpenAI configuration, validation, and client factory management
*/
@Slf4j
@Service
public class OpenAiConfigServiceImpl implements OpenAiConfigService {
private static final String CONFIG_TYPE = "openai";
private static final String OPENAI_MODELS_ENDPOINT = "https://api.openai.com/v1/models";
private final OpenAiConfigDao openAiConfigDao;
private final ObjectMapper objectMapper;
private final ApplicationContext applicationContext;
private final OpenAiYamlConfig yamlConfig;
private final RestTemplate restTemplate;
// Client factory cache
private volatile OpenAiConfigDto currentConfig;
public OpenAiConfigServiceImpl(OpenAiConfigDao openAiConfigDao,
ObjectMapper objectMapper,
ApplicationContext applicationContext,
OpenAiYamlConfig yamlConfig) {
this.openAiConfigDao = openAiConfigDao;
this.objectMapper = objectMapper;
this.applicationContext = applicationContext;
this.yamlConfig = yamlConfig;
this.restTemplate = new RestTemplate();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void saveConfig(OpenAiConfigDto config) {
try {
String contentJson = objectMapper.writeValueAsString(config);
OpenAiConfig openAiConfig = OpenAiConfig.builder()
.type(CONFIG_TYPE)
.content(contentJson)
.build();
openAiConfigDao.save(openAiConfig);
log.info("OpenAI configuration saved successfully");
applicationContext.publishEvent(new OpenAiConfigChangeEvent(applicationContext));
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to save OpenAI configuration: " + e.getMessage());
}
}
@Override
public OpenAiConfigDto getConfig() {
OpenAiConfig config = openAiConfigDao.findByType(CONFIG_TYPE);
if (config == null || !StringUtils.hasText(config.getContent())) {
return null;
}
try {
return objectMapper.readValue(config.getContent(), OpenAiConfigDto.class);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to parse OpenAI configuration: " + e.getMessage());
}
}
@Override
public boolean isConfigured() {
OpenAiConfigDto effective = getEffectiveConfig();
return effective != null && effective.isEnable() && StringUtils.hasText(effective.getApiKey());
}
@Override
public OpenAiConfigDto getEffectiveConfig() {
OpenAiConfigDto dbConfig = getConfig();
if (dbConfig != null && dbConfig.isEnable() && StringUtils.hasText(dbConfig.getApiKey())) {
log.debug("Using database OpenAI configuration");
return dbConfig;
}
if (yamlConfig != null && yamlConfig.isEnable() && StringUtils.hasText(yamlConfig.getApiKey())) {
log.debug("Using YAML OpenAI configuration from spring.ai.openai.api-key");
OpenAiConfigDto yamlDto = new OpenAiConfigDto();
yamlDto.setEnable(true);
yamlDto.setApiKey(yamlConfig.getApiKey());
return yamlDto;
}
log.debug("No valid OpenAI configuration found");
return null;
}
@Override
public ValidationResult validateApiKey(String apiKey) {
if (!StringUtils.hasText(apiKey)) {
return ValidationResult.failure("API key cannot be empty");
}
if (!apiKey.startsWith("sk-")) {
return ValidationResult.failure("Invalid API key format. OpenAI API keys should start with 'sk-'");
}
try {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiKey);
headers.set("Content-Type", "application/json");
HttpEntity<String> entity = new HttpEntity<>(headers);
log.debug("Validating OpenAI API key by calling models endpoint");
ResponseEntity<String> response = restTemplate.exchange(
OPENAI_MODELS_ENDPOINT,
HttpMethod.GET,
entity,
String.class
);
if (response.getStatusCode() == HttpStatus.OK) {
log.info("OpenAI API key validation successful");
return ValidationResult.success("API key is valid");
} else {
log.warn("OpenAI API key validation failed with status: {}", response.getStatusCode());
return ValidationResult.failure("API key validation failed: " + response.getStatusCode());
}
} catch (Exception e) {
log.error("Error validating OpenAI API key", e);
String errorMessage = e.getMessage();
// Parse common error messages
if (errorMessage.contains("401")) {
return ValidationResult.failure("Invalid API key - authentication failed");
} else if (errorMessage.contains("403")) {
return ValidationResult.failure("API key does not have permission to access models");
} else if (errorMessage.contains("429")) {
return ValidationResult.failure("Rate limit exceeded - please try again later");
} else if (errorMessage.contains("timeout") || errorMessage.contains("connect")) {
return ValidationResult.failure("Network error - unable to connect to OpenAI API");
} else {
return ValidationResult.failure("API key validation failed: " + errorMessage);
}
}
}
@Override
public void reloadConfig() {
synchronized (this) {
currentConfig = null; // Force reload
}
}
/**
* OpenAI configuration change event listener
*/
@EventListener(OpenAiConfigChangeEvent.class)
public void onOpenAiConfigChange(OpenAiConfigChangeEvent event) {
log.info("[OpenAiConfigService] OpenAI configuration change event received");
reloadConfig();
}
}
@@ -35,10 +35,12 @@ public class AlerterWorkerPool {
private ThreadPoolExecutor workerExecutor;
private ThreadPoolExecutor notifyExecutor;
private ThreadPoolExecutor logWorkerExecutor;
public AlerterWorkerPool() {
initWorkExecutor();
initNotifyExecutor();
initLogWorkerExecutor();
}
private void initWorkExecutor() {
@@ -77,6 +79,21 @@ public class AlerterWorkerPool {
new ThreadPoolExecutor.AbortPolicy());
}
private void initLogWorkerExecutor() {
ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setUncaughtExceptionHandler((thread, throwable) -> {
log.error("Alerter logWorkerExecutor has uncaughtException.");
log.error(throwable.getMessage(), throwable);
})
.setDaemon(true)
.setNameFormat("log-worker-%d")
.build();
logWorkerExecutor = new ThreadPoolExecutor(10, 10, 10, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
threadFactory,
new ThreadPoolExecutor.AbortPolicy());
}
/**
* Run the alerter task
* @param runnable task
@@ -96,4 +113,13 @@ public class AlerterWorkerPool {
notifyExecutor.execute(runnable);
}
/**
* Executes the given runnable task using the logWorkerExecutor.
*
* @param runnable the task to be executed
* @throws RejectedExecutionException if the task cannot be accepted for execution
*/
public void executeLogJob(Runnable runnable) throws RejectedExecutionException {
logWorkerExecutor.execute(runnable);
}
}
@@ -0,0 +1,72 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.hertzbeat.alert.calculate;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.jexl3.JexlException;
import org.apache.commons.jexl3.JexlExpression;
import org.apache.hertzbeat.common.util.JexlExpressionRunner;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* JexlExprCalculator is a utility class for evaluating JEXL expressions
*/
@Slf4j
@Component
public class JexlExprCalculator {
/**
* Execute an alert expression
* @param fieldValueMap The field value map for expression evaluation
* @param expr The expression to evaluate
* @param ignoreJexlException Whether to ignore JEXL exceptions
* @return true if the expression matches, false otherwise
*/
public boolean execAlertExpression(Map<String, Object> fieldValueMap, String expr, boolean ignoreJexlException) {
Boolean match;
JexlExpression expression;
try {
expression = JexlExpressionRunner.compile(expr);
} catch (JexlException jexlException) {
log.warn("Alarm Rule: {} Compile Error: {}.", expr, jexlException.getMessage());
throw jexlException;
} catch (Exception e) {
log.error("Alarm Rule: {} Unknown Error: {}.", expr, e.getMessage());
throw e;
}
try {
match = (Boolean) JexlExpressionRunner.evaluate(expression, fieldValueMap);
} catch (JexlException jexlException) {
if (ignoreJexlException) {
log.debug("Alarm Rule: {} Run Error: {}.", expr, jexlException.getMessage());
} else {
log.error("Alarm Rule: {} Run Error: {}.", expr, jexlException.getMessage());
}
throw jexlException;
} catch (Exception e) {
log.error("Alarm Rule: {} Unknown Error: {}.", expr, e.getMessage());
throw e;
}
return match != null && match;
}
}
@@ -0,0 +1,232 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate.periodic;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.DataSourceService;
import org.apache.hertzbeat.alert.util.AlertTemplateUtil;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Log Periodic Alert Calculator
*/
@Slf4j
@Component
public class LogPeriodicAlertCalculator {
private static final String ROWS = "__rows__";
private final DataSourceService dataSourceService;
private final AlarmCommonReduce alarmCommonReduce;
public LogPeriodicAlertCalculator(DataSourceService dataSourceService, AlarmCommonReduce alarmCommonReduce) {
this.alarmCommonReduce = alarmCommonReduce;
this.dataSourceService = dataSourceService;
}
public void calculate(AlertDefine define) {
if (!define.isEnable() || StringUtils.isEmpty(define.getExpr())) {
log.error("Log define {} is disabled or expression is empty", define.getName());
return;
}
try {
doCalculate(define);
} catch (Exception e) {
log.error("Calculate periodic define {} failed: {}", define.getName(), e.getMessage());
}
}
private void doCalculate(AlertDefine define) {
try {
// Log-based queries are SQL queries with log-specific expressions
List<Map<String, Object>> results = dataSourceService.query(define.getDatasource(), define.getExpr());
if (CollectionUtils.isEmpty(results)) {
return;
}
afterThresholdRuleMatch(results, define);
} catch (Exception ignored) {
// Ignore the query exception eg: no result, timeout, etc
}
}
String getAlertMode(AlertDefine alertDefine) {
String mode = null;
if (alertDefine.getLabels() != null) {
mode = alertDefine.getLabels().get(CommonConstants.ALERT_MODE_LABEL);
}
if (mode == null || mode.isEmpty()) {
return CommonConstants.ALERT_MODE_GROUP; // Default to group mode if not specified
} else {
return mode;
}
}
/**
* Handle alert after threshold rule match
*/
private void afterThresholdRuleMatch(List<Map<String, Object>> alertContext, AlertDefine define) {
// Determine alert mode from configuration
String alertMode = getAlertMode(define);
long currentTime = System.currentTimeMillis();
switch (alertMode) {
case CommonConstants.ALERT_MODE_INDIVIDUAL:
// Generate individual alerts for each matching log
for (Map<String, Object> context : alertContext) {
generateIndividualAlert(define, context, currentTime);
}
break;
case CommonConstants.ALERT_MODE_GROUP:
// Generate a single alert group for all matching logs
generateGroupAlert(define, alertContext, currentTime);
break;
default:
log.warn("Unknown alert mode for define {}: {}", define.getName(), alertMode);
}
}
private void generateIndividualAlert(AlertDefine define, Map<String, Object> context, long currentTime) {
Map<String, String> alertLabels = new HashMap<>(8);
Map<String, String> commonFingerPrints = createCommonFingerprints(define);
alertLabels.putAll(commonFingerPrints);
addContextToMap(context, alertLabels);
Map<String, Object> fieldValueMap = createFieldValueMap(context, define);
Map<String, String> alertAnnotations = createAlertAnnotations(define, fieldValueMap);
// Create and send group alert
SingleAlert alert = SingleAlert.builder()
.labels(alertLabels)
.annotations(alertAnnotations)
.content(AlertTemplateUtil.render(define.getTemplate(), fieldValueMap))
.status(CommonConstants.ALERT_STATUS_FIRING)
.triggerTimes(1)
.startAt(currentTime)
.activeAt(currentTime)
.build();
alarmCommonReduce.reduceAndSendAlarm(alert.clone());
log.debug("Generated individual alert for define: {}", define.getName());
}
private void addContextToMap(Map<String, Object> context, Map<String, String> alertLabels) {
for (Map.Entry<String, Object> entry : context.entrySet()) {
if (entry.getValue() != null) {
alertLabels.put(entry.getKey(), entry.getValue().toString());
}
}
}
private void generateGroupAlert(AlertDefine define, List<Map<String, Object>> alertContext, long currentTime) {
List<SingleAlert> alerts = new ArrayList<>(alertContext.size());
// Create fingerprints for group alert
Map<String, String> commonFingerPrints = createCommonFingerprints(define);
// Add context information to fingerprints
commonFingerPrints.put(ROWS, String.valueOf(alertContext.size()));
commonFingerPrints.put(CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_GROUP);
for (Map<String, Object> context : alertContext) {
Map<String, String> alertLabels = new HashMap<>(8);
alertLabels.putAll(commonFingerPrints);
// add the context to commonFingerPrints
addContextToMap(context, alertLabels);
Map<String, Object> fieldValueMap = createFieldValueMap(context, define);
Map<String, String> alertAnnotations = createAlertAnnotations(define, fieldValueMap);
// Create and send group alert
SingleAlert alert = SingleAlert.builder()
.labels(alertLabels)
.annotations(alertAnnotations)
.content(AlertTemplateUtil.render(define.getTemplate(), fieldValueMap))
.status(CommonConstants.ALERT_STATUS_FIRING)
.triggerTimes(alertContext.size())
.startAt(currentTime)
.activeAt(currentTime)
.build();
alerts.add(alert.clone());
}
alarmCommonReduce.reduceAndSendAlarmGroup(commonFingerPrints, alerts);
log.debug("Generated group alert for define: {} with {} matching data",
define.getName(), alertContext.size());
}
private Map<String, String> createCommonFingerprints(AlertDefine define) {
Map<String, String> fingerprints = new HashMap<>(8);
fingerprints.put(CommonConstants.LABEL_ALERT_NAME, define.getName());
fingerprints.put(CommonConstants.LABEL_DEFINE_ID, String.valueOf(define.getId()));
if (define.getLabels() != null) {
fingerprints.putAll(define.getLabels());
}
return fingerprints;
}
private Map<String, Object> createFieldValueMap(Map<String, Object> context, AlertDefine define) {
Map<String, Object> fieldValueMap = new HashMap<>(8);
for (Map.Entry<String, Object> entry : context.entrySet()) {
if (entry.getValue() != null) {
fieldValueMap.put(entry.getKey(), entry.getValue().toString());
}
}
if (define.getLabels() != null) {
fieldValueMap.putAll(define.getLabels());
}
return fieldValueMap;
}
private Map<String, String> createAlertAnnotations(AlertDefine define, Map<String, Object> fieldValueMap) {
Map<String, String> annotations = new HashMap<>(8);
if (define.getAnnotations() != null) {
for (Map.Entry<String, String> entry : define.getAnnotations().entrySet()) {
annotations.put(entry.getKey(),
AlertTemplateUtil.render(entry.getValue(), fieldValueMap));
}
}
return annotations;
}
}
@@ -15,11 +15,12 @@
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate;
package org.apache.hertzbeat.alert.calculate.periodic;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.hertzbeat.alert.calculate.AlarmCacheManager;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.DataSourceService;
import org.apache.hertzbeat.alert.util.AlertTemplateUtil;
@@ -34,12 +35,11 @@ import java.util.List;
import java.util.Map;
/**
* Periodic Alert Calculator
* Metrics Periodic Alert Calculator
*/
@Slf4j
@Component
public class PeriodicAlertCalculator {
public class MetricsPeriodicAlertCalculator {
private static final String VALUE = "__value__";
private static final String TIMESTAMP = "__timestamp__";
@@ -48,71 +48,82 @@ public class PeriodicAlertCalculator {
private final AlarmCommonReduce alarmCommonReduce;
private final AlarmCacheManager alarmCacheManager;
public PeriodicAlertCalculator(DataSourceService dataSourceService, AlarmCommonReduce alarmCommonReduce,
AlarmCacheManager alarmCacheManager) {
this.dataSourceService = dataSourceService;
public MetricsPeriodicAlertCalculator(DataSourceService dataSourceService, AlarmCommonReduce alarmCommonReduce,
AlarmCacheManager alarmCacheManager) {
this.alarmCommonReduce = alarmCommonReduce;
this.alarmCacheManager = alarmCacheManager;
this.dataSourceService = dataSourceService;
}
/**
* Calculate alerts for the given alert definition
* @param define The alert definition to calculate
*/
public void calculate(AlertDefine define) {
if (!define.isEnable() || StringUtils.isEmpty(define.getExpr())) {
log.error("Periodic define {} is disabled or expression is empty", define.getName());
return;
}
long currentTimeMilli = System.currentTimeMillis();
try {
// for prometheus is instant promql query, for db is sql query
// result: [{'value': 100, 'timestamp': 1343554, 'instance': 'node1'},{'value': 200, 'timestamp': 1343555, 'instance': 'node2'}]
// the return result should be matched with threshold
try {
List<Map<String, Object>> results = dataSourceService.calculate(
define.getDatasource(),
define.getExpr()
);
// if no match the expr threshold, the results item map {'value': null} should be null and others field keep
// if results has multi list, should trigger multi alert
if (CollectionUtils.isEmpty(results)) {
return;
}
for (Map<String, Object> result : results) {
Map<String, String> fingerPrints = new HashMap<>(8);
// here use the alert name as finger, not care the alert name may be changed
fingerPrints.put(CommonConstants.LABEL_DEFINE_ID, String.valueOf(define.getId()));
fingerPrints.put(CommonConstants.LABEL_ALERT_NAME, define.getName());
fingerPrints.putAll(define.getLabels());
for (Map.Entry<String, Object> entry : result.entrySet()) {
if (entry.getValue() != null && !VALUE.equals(entry.getKey())
&& !TIMESTAMP.equals(entry.getKey())) {
fingerPrints.put(entry.getKey(), entry.getValue().toString());
}
}
if (result.get(VALUE) == null) {
// recovery the alert
handleRecoveredAlert(define.getId(), fingerPrints);
continue;
}
Map<String, Object> fieldValueMap = new HashMap<>(8);
fieldValueMap.putAll(define.getLabels());
fieldValueMap.put(CommonConstants.LABEL_ALERT_NAME, define.getName());
for (Map.Entry<String, Object> entry : result.entrySet()) {
if (entry.getValue() != null) {
fieldValueMap.put(entry.getKey(), entry.getValue());
}
}
afterThresholdRuleMatch(currentTimeMilli, fingerPrints, fieldValueMap, define);
}
} catch (Exception ignored) {
// ignore the query exception eg: no result, timeout, etc
return;
}
doCalculate(define, currentTimeMilli);
} catch (Exception e) {
log.error("Calculate periodic define {} failed: {}", define.getName(), e.getMessage());
}
}
private void doCalculate(AlertDefine define, long currentTimeMilli) {
try {
List<Map<String, Object>> results = dataSourceService.calculate(
define.getDatasource(),
define.getExpr()
);
// If no match the expr threshold, the results item map {'value': null} should be null and others field keep
// If results has multi list, should trigger multi alert
if (CollectionUtils.isEmpty(results)) {
return;
}
for (Map<String, Object> result : results) {
Map<String, String> fingerPrints = new HashMap<>(8);
// Here use the alert name as finger, not care the alert name may be changed
fingerPrints.put(CommonConstants.LABEL_DEFINE_ID, String.valueOf(define.getId()));
fingerPrints.put(CommonConstants.LABEL_ALERT_NAME, define.getName());
fingerPrints.putAll(define.getLabels());
for (Map.Entry<String, Object> entry : result.entrySet()) {
if (entry.getValue() != null && !VALUE.equals(entry.getKey())
&& !TIMESTAMP.equals(entry.getKey())) {
fingerPrints.put(entry.getKey(), entry.getValue().toString());
}
}
if (result.get(VALUE) == null) {
// Recovery the alert
handleRecoveredAlert(define.getId(), fingerPrints);
continue;
}
Map<String, Object> fieldValueMap = new HashMap<>(8);
fieldValueMap.putAll(define.getLabels());
fieldValueMap.put(CommonConstants.LABEL_ALERT_NAME, define.getName());
for (Map.Entry<String, Object> entry : result.entrySet()) {
if (entry.getValue() != null) {
fieldValueMap.put(entry.getKey(), entry.getValue());
}
}
afterThresholdRuleMatch(currentTimeMilli, fingerPrints, fieldValueMap, define);
}
} catch (Exception ignored) {
// Ignore the query exception eg: no result, timeout, etc
}
}
/**
* Handle alert after threshold rule match
*/
private void afterThresholdRuleMatch(long currentTimeMilli, Map<String, String> fingerPrints,
Map<String, Object> fieldValueMap, AlertDefine define) {
Map<String, Object> fieldValueMap, AlertDefine define) {
Long defineId = define.getId();
String fingerprint = AlertUtil.calculateFingerprint(fingerPrints);
SingleAlert existingAlert = alarmCacheManager.getPending(defineId, fingerprint);
@@ -124,7 +135,6 @@ public class PeriodicAlertCalculator {
// First time triggering alert, create new alert and set to pending status
SingleAlert newAlert = SingleAlert.builder()
.labels(labels)
// todo render var content in annotations
.annotations(define.getAnnotations())
.content(AlertTemplateUtil.render(define.getTemplate(), fieldValueMap))
.status(CommonConstants.ALERT_STATUS_PENDING)
@@ -158,11 +168,13 @@ public class PeriodicAlertCalculator {
}
}
/**
* Handle recovered alert
*/
private void handleRecoveredAlert(Long defineId, Map<String, String> fingerprints) {
String fingerprint = AlertUtil.calculateFingerprint(fingerprints);
SingleAlert firingAlert = alarmCacheManager.removeFiring(defineId, fingerprint);
if (firingAlert != null) {
// todo consider multi times to tig for resolved alert
firingAlert.setTriggerTimes(1);
firingAlert.setEndAt(System.currentTimeMillis());
firingAlert.setStatus(CommonConstants.ALERT_STATUS_RESOLVED);
@@ -171,4 +183,4 @@ public class PeriodicAlertCalculator {
alarmCacheManager.removePending(defineId, fingerprint);
}
}
}
@@ -15,10 +15,13 @@
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate;
package org.apache.hertzbeat.alert.calculate.periodic;
import static org.apache.hertzbeat.common.constants.CommonConstants.ALERT_THRESHOLD_TYPE_PERIODIC;
import static org.apache.hertzbeat.common.constants.CommonConstants.LOG_ALERT_THRESHOLD_TYPE_PERIODIC;
import static org.apache.hertzbeat.common.constants.CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_PERIODIC;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -39,13 +42,15 @@ import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
@Component
public class PeriodicAlertRuleScheduler implements CommandLineRunner {
private final PeriodicAlertCalculator calculator;
private final MetricsPeriodicAlertCalculator metricsCalculator;
private final LogPeriodicAlertCalculator logCalculator;
private final AlertDefineDao alertDefineDao;
private final ScheduledExecutorService scheduledExecutor;
private final Map<Long, ScheduledFuture<?>> scheduledFutures;
public PeriodicAlertRuleScheduler(PeriodicAlertCalculator calculator, AlertDefineDao alertDefineDao) {
this.calculator = calculator;
public PeriodicAlertRuleScheduler(MetricsPeriodicAlertCalculator metricsCalculator, LogPeriodicAlertCalculator logCalculator, AlertDefineDao alertDefineDao) {
this.metricsCalculator = metricsCalculator;
this.logCalculator = logCalculator;
this.alertDefineDao = alertDefineDao;
ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setUncaughtExceptionHandler((thread, throwable) -> {
@@ -76,9 +81,14 @@ public class PeriodicAlertRuleScheduler implements CommandLineRunner {
return;
}
cancelSchedule(rule.getId());
if (rule.getType().equals(ALERT_THRESHOLD_TYPE_PERIODIC)) {
if (rule.getType().equals(METRIC_ALERT_THRESHOLD_TYPE_PERIODIC)
|| rule.getType().equals(LOG_ALERT_THRESHOLD_TYPE_PERIODIC)) {
ScheduledFuture<?> future = scheduledExecutor.scheduleAtFixedRate(() -> {
calculator.calculate(rule);
if (rule.getType().equals(METRIC_ALERT_THRESHOLD_TYPE_PERIODIC)) {
metricsCalculator.calculate(rule);
} else if (rule.getType().equals(LOG_ALERT_THRESHOLD_TYPE_PERIODIC)) {
logCalculator.calculate(rule);
}
}, 0, rule.getPeriod(), java.util.concurrent.TimeUnit.SECONDS);
scheduledFutures.put(rule.getId(), future);
}
@@ -87,7 +97,11 @@ public class PeriodicAlertRuleScheduler implements CommandLineRunner {
@Override
public void run(String... args) throws Exception {
log.info("Starting periodic alert rule scheduler...");
List<AlertDefine> periodicRules = alertDefineDao.findAlertDefinesByTypeAndEnableTrue(ALERT_THRESHOLD_TYPE_PERIODIC);
List<AlertDefine> metricsPeriodicRules = alertDefineDao.findAlertDefinesByTypeAndEnableTrue(METRIC_ALERT_THRESHOLD_TYPE_PERIODIC);
List<AlertDefine> logPeriodicRules = alertDefineDao.findAlertDefinesByTypeAndEnableTrue(LOG_ALERT_THRESHOLD_TYPE_PERIODIC);
List<AlertDefine> periodicRules = new ArrayList<>(metricsPeriodicRules.size() + logPeriodicRules.size());
periodicRules.addAll(metricsPeriodicRules);
periodicRules.addAll(logPeriodicRules);
for (AlertDefine rule : periodicRules) {
updateSchedule(rule);
}
@@ -15,8 +15,7 @@
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate;
package org.apache.hertzbeat.alert.calculate.realtime;
import java.util.Collections;
import java.util.HashMap;
@@ -28,10 +27,10 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.jexl3.JexlException;
import org.apache.commons.jexl3.JexlExpression;
import org.apache.commons.lang3.StringUtils;
import org.apache.hertzbeat.alert.AlerterWorkerPool;
import org.apache.hertzbeat.alert.calculate.AlarmCacheManager;
import org.apache.hertzbeat.alert.calculate.JexlExprCalculator;
import org.apache.hertzbeat.alert.dao.SingleAlertDao;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.AlertDefineService;
@@ -43,7 +42,6 @@ import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.apache.hertzbeat.common.util.CommonUtil;
import org.apache.hertzbeat.common.util.JexlExpressionRunner;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
@@ -53,10 +51,10 @@ import org.springframework.util.CollectionUtils;
*/
@Component
@Slf4j
public class RealTimeAlertCalculator {
public class MetricsRealTimeAlertCalculator {
private static final int CALCULATE_THREADS = 3;
private static final String KEY_INSTANCE = "__instance__";
private static final String KEY_INSTANCE_NAME = "__instancename__";
private static final String KEY_INSTANCE_HOST = "__instancehost__";
@@ -81,38 +79,47 @@ public class RealTimeAlertCalculator {
private final AlertDefineService alertDefineService;
private final AlarmCommonReduce alarmCommonReduce;
private final AlarmCacheManager alarmCacheManager;
private final JexlExprCalculator jexlExprCalculator;
@Autowired
public RealTimeAlertCalculator(AlerterWorkerPool workerPool, CommonDataQueue dataQueue,
AlertDefineService alertDefineService, SingleAlertDao singleAlertDao,
AlarmCommonReduce alarmCommonReduce, AlarmCacheManager alarmCacheManager) {
this(workerPool, dataQueue, alertDefineService, singleAlertDao, alarmCommonReduce, alarmCacheManager, true);
public MetricsRealTimeAlertCalculator(AlerterWorkerPool workerPool, CommonDataQueue dataQueue,
AlertDefineService alertDefineService, SingleAlertDao singleAlertDao,
AlarmCommonReduce alarmCommonReduce, AlarmCacheManager alarmCacheManager,
JexlExprCalculator jexlExprCalculator) {
this(workerPool, dataQueue, alertDefineService, singleAlertDao, alarmCommonReduce, alarmCacheManager, jexlExprCalculator, true);
}
/**
* Constructor for RealTimeAlertCalculator with a toggle to control whether to start alert calculation threads.
* Constructor for MetricsRealTimeAlertCalculator with a toggle to control whether to start alert calculation threads.
*
* @param workerPool The worker pool used for concurrent alert calculation.
* @param dataQueue The queue from which metric data is pulled and pushed.
* @param alertDefineService The service providing alert definition rules.
* @param singleAlertDao The DAO for fetching persisted alert states from storage.
* @param alarmCommonReduce The component responsible for reducing and sending alerts.
* @param alarmCacheManager The cache manager for managing alert states.
* @param start If true, the alert calculation threads will start automatically;
* set to false to disable thread start (useful for unit testing).
*/
public RealTimeAlertCalculator(AlerterWorkerPool workerPool, CommonDataQueue dataQueue,
AlertDefineService alertDefineService, SingleAlertDao singleAlertDao,
AlarmCommonReduce alarmCommonReduce, AlarmCacheManager alarmCacheManager, boolean start) {
public MetricsRealTimeAlertCalculator(AlerterWorkerPool workerPool, CommonDataQueue dataQueue,
AlertDefineService alertDefineService, SingleAlertDao singleAlertDao,
AlarmCommonReduce alarmCommonReduce, AlarmCacheManager alarmCacheManager,
JexlExprCalculator jexlExprCalculator, boolean start) {
this.workerPool = workerPool;
this.dataQueue = dataQueue;
this.alarmCommonReduce = alarmCommonReduce;
this.alertDefineService = alertDefineService;
this.alarmCacheManager = alarmCacheManager;
this.jexlExprCalculator = jexlExprCalculator;
if (start) {
startCalculate();
}
}
/**
* Start the alert calculation threads
*/
public void startCalculate() {
Runnable runnable = () -> {
while (!Thread.currentThread().isInterrupted()) {
@@ -132,7 +139,7 @@ public class RealTimeAlertCalculator {
}
}
private void calculate(CollectRep.MetricsData metricsData) {
protected void calculate(CollectRep.MetricsData metricsData) {
long currentTimeMilli = System.currentTimeMillis();
String instance = String.valueOf(metricsData.getId());
String instanceName = metricsData.getInstanceName();
@@ -146,7 +153,7 @@ public class RealTimeAlertCalculator {
int code = metricsData.getCode().getNumber();
Map<String, String> labels = metricsData.getLabels();
Map<String, String> annotations = metricsData.getAnnotations();
List<AlertDefine> thresholds = this.alertDefineService.getRealTimeAlertDefines();
List<AlertDefine> thresholds = this.alertDefineService.getMetricsRealTimeAlertDefines();
// Filter thresholds by app, metrics, labels and instance
thresholds = filterThresholdsByAppAndMetrics(thresholds, app, metrics, labels, instance, priority);
if (thresholds.isEmpty()) {
@@ -198,7 +205,7 @@ public class RealTimeAlertCalculator {
{
// trigger the expr before the metrics data, due the available up down or others
try {
boolean match = execAlertExpression(fieldValueMap, expr, true);
boolean match = jexlExprCalculator.execAlertExpression(fieldValueMap, expr, true);
try {
if (match) {
// If the threshold rule matches, the number of times the threshold has been triggered is determined and an alarm is triggered
@@ -253,7 +260,7 @@ public class RealTimeAlertCalculator {
}
}
try {
boolean match = execAlertExpression(fieldValueMap, expr, false);
boolean match = jexlExprCalculator.execAlertExpression(fieldValueMap, expr, false);
try {
if (match) {
afterThresholdRuleMatch(defineId, currentTimeMilli, fingerPrints, fieldValueMap, define, annotations);
@@ -274,8 +281,9 @@ public class RealTimeAlertCalculator {
* @param thresholds Alert definitions to filter
* @param app Current app name
* @param metrics Current metrics name
* @param labels Current labels
* @param instance Current instance id
* @param priority Current priority
* @param priority Current priority
* @return Filtered alert definitions
*/
public List<AlertDefine> filterThresholdsByAppAndMetrics(List<AlertDefine> thresholds, String app, String metrics, Map<String, String> labels, String instance, int priority) {
@@ -291,7 +299,7 @@ public class RealTimeAlertCalculator {
if (!appMatcher.find() || !app.equals(appMatcher.group(1))) {
return false;
}
// Extract and check available - required
if (priority != 0) {
Matcher availableMatcher = AVAILABLE_PATTERN.matcher(expr);
@@ -313,7 +321,7 @@ public class RealTimeAlertCalculator {
if (!instanceMatcher.find() && !labelMatcher.find()) {
return true;
}
// Reset matcher to check all instances
instanceMatcher.reset();
labelMatcher.reset();
@@ -336,6 +344,9 @@ public class RealTimeAlertCalculator {
.collect(Collectors.toList());
}
/**
* Handle recovered alert
*/
private void handleRecoveredAlert(Long defineId, Map<String, String> fingerprints) {
String fingerprint = AlertUtil.calculateFingerprint(fingerprints);
SingleAlert firingAlert = alarmCacheManager.removeFiring(defineId, fingerprint);
@@ -349,9 +360,14 @@ public class RealTimeAlertCalculator {
alarmCacheManager.removePending(defineId, fingerprint);
}
/**
* Handle alert after threshold rule match
*/
private void afterThresholdRuleMatch(long defineId, long currentTimeMilli, Map<String, String> fingerPrints,
Map<String, Object> fieldValueMap, AlertDefine define,
Map<String, String> annotations) {
// fingerprint for the padding cache
String fingerprint = AlertUtil.calculateFingerprint(fingerPrints);
SingleAlert existingAlert = alarmCacheManager.getPending(defineId, fingerprint);
fieldValueMap.putAll(define.getLabels());
@@ -377,11 +393,11 @@ public class RealTimeAlertCalculator {
// render var content in content
.content(AlertTemplateUtil.render(define.getTemplate(), fieldValueMap))
.status(CommonConstants.ALERT_STATUS_PENDING)
.triggerTimes(1)
.triggerTimes(1)
.startAt(currentTimeMilli)
.activeAt(currentTimeMilli)
.build();
// If required trigger times is 1, set to firing status directly
if (requiredTimes <= 1) {
newAlert.setStatus(CommonConstants.ALERT_STATUS_FIRING);
@@ -395,7 +411,7 @@ public class RealTimeAlertCalculator {
// Update existing alert
existingAlert.setTriggerTimes(existingAlert.getTriggerTimes() + 1);
existingAlert.setActiveAt(currentTimeMilli);
// Check if required trigger times reached
if (existingAlert.getStatus().equals(CommonConstants.ALERT_STATUS_PENDING) && existingAlert.getTriggerTimes() >= requiredTimes) {
// Reached trigger times threshold, change to firing status
@@ -407,35 +423,6 @@ public class RealTimeAlertCalculator {
}
}
private boolean execAlertExpression(Map<String, Object> fieldValueMap, String expr, boolean ignoreJexlException) {
Boolean match;
JexlExpression expression;
try {
expression = JexlExpressionRunner.compile(expr);
} catch (JexlException jexlException) {
log.warn("Alarm Rule: {} Compile Error: {}.", expr, jexlException.getMessage());
throw jexlException;
} catch (Exception e) {
log.error("Alarm Rule: {} Unknown Error: {}.", expr, e.getMessage());
throw e;
}
try {
match = (Boolean) JexlExpressionRunner.evaluate(expression, fieldValueMap);
} catch (JexlException jexlException) {
if (ignoreJexlException) {
log.debug("Alarm Rule: {} Run Error: {}.", expr, jexlException.getMessage());
} else {
log.error("Alarm Rule: {} Run Error: {}.", expr, jexlException.getMessage());
}
throw jexlException;
} catch (Exception e) {
log.error("Alarm Rule: {} Unknown Error: {}.", expr, e.getMessage());
throw e;
}
return match != null && match;
}
private Set<String> kvLabelsToKvStringSet(Map<String, String> labels) {
if (labels == null || labels.isEmpty()) {
return Collections.singleton("");
@@ -0,0 +1,140 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate.realtime;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.calculate.realtime.window.LogWorker;
import org.apache.hertzbeat.alert.calculate.realtime.window.TimeService;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.springframework.stereotype.Component;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* WindowedLogRealTimeAlertCalculator - Single entry point for log stream processing
* Responsible for:
* 1. Reading from original log stream
* 2. Extracting event timestamps
* 3. Maintaining maxTimestamp for watermark generation
* 4. Distributing logs to workers
*/
@Component
@Slf4j
public class WindowedLogRealTimeAlertCalculator implements Runnable {
private static final int CALCULATE_THREADS = 3;
private final CommonDataQueue dataQueue;
private final TimeService timeService;
private ThreadPoolExecutor dispatcherExecutor;
private final LogWorker logWorker;
public WindowedLogRealTimeAlertCalculator(CommonDataQueue dataQueue, TimeService timeService, LogWorker logWorker) {
this.dataQueue = dataQueue;
this.timeService = timeService;
this.logWorker = logWorker;
}
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
LogEntry logEntry = dataQueue.pollLogEntry();
if (logEntry != null) {
processLogEntry(logEntry);
dataQueue.sendLogEntryToStorage(logEntry);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("Error in log dispatch loop: {}", e.getMessage(), e);
}
}
}
private void processLogEntry(LogEntry logEntry) {
// Extract event timestamp
long eventTimestamp = extractEventTimestamp(logEntry);
// Check if timestamp is within reasonable range
if (!timeService.isValidTimestamp(eventTimestamp)) {
log.warn("Dropping log with invalid timestamp: {}", eventTimestamp);
return;
}
// Check if this is late data based on current watermark
if (timeService.isLateData(eventTimestamp)) {
log.warn("Dropping late data, timestamp: {}, watermark: {}",
eventTimestamp, timeService.getCurrentWatermark());
return;
}
// Update max timestamp (only validated timestamps can update watermark)
timeService.updateMaxTimestamp(eventTimestamp);
logWorker.reduceAndSendLogTask(logEntry);
}
private long extractEventTimestamp(LogEntry logEntry) {
if (logEntry.getTimeUnixNano() != null && logEntry.getTimeUnixNano() != 0) {
return logEntry.getTimeUnixNano() / 1_000_000; // Convert to milliseconds
}
if (logEntry.getObservedTimeUnixNano() != null && logEntry.getObservedTimeUnixNano() != 0) {
return logEntry.getObservedTimeUnixNano() / 1_000_000; // Convert to milliseconds
}
return System.currentTimeMillis();
}
@PostConstruct
public void start() {
ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setUncaughtExceptionHandler((thread, throwable) -> {
log.error("Alerter workerExecutor has uncaughtException.");
log.error(throwable.getMessage(), throwable);
})
.setDaemon(true)
.setNameFormat("log-dispatcher-%d")
.build();
// Create dispatcher thread executor
this.dispatcherExecutor = new ThreadPoolExecutor(
CALCULATE_THREADS,
CALCULATE_THREADS,
10,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(),
threadFactory,
new ThreadPoolExecutor.AbortPolicy()
);
for (int i = 0; i < CALCULATE_THREADS; i++) {
dispatcherExecutor.execute(this);
}
}
@PreDestroy
public void stop() {
if (dispatcherExecutor != null) {
dispatcherExecutor.shutdownNow();
}
}
}
@@ -0,0 +1,317 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate.realtime.window;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.springframework.stereotype.Component;
import org.apache.hertzbeat.alert.util.AlertTemplateUtil;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* Alarm Evaluator - Final alarm logic trigger
* Responsible for:
* 1. Receiving closed window data from WindowAggregator
* 2. Applying alert logic (count threshold, alert mode)
* 3. Generating individual alerts or alert groups
* 4. Sending alerts to AlarmCommonReduce
*/
@Component
@Slf4j
public class AlarmEvaluator {
private static final String WINDOW_START_TIME = "window_start_time";
private static final String WINDOW_END_TIME = "window_end_time";
private static final String MATCHING_LOGS_COUNT = "matching_logs_count";
private final AlarmCommonReduce alarmCommonReduce;
private ThreadPoolExecutor workerExecutor;
public AlarmEvaluator(AlarmCommonReduce alarmCommonReduce) {
this.alarmCommonReduce = alarmCommonReduce;
initAlarmEvaluator();
}
public void initAlarmEvaluator() {
ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setUncaughtExceptionHandler((thread, throwable) -> {
log.error("alerter-reduce-worker has uncaughtException.");
log.error(throwable.getMessage(), throwable);
})
.setDaemon(true)
.setNameFormat("alerter-reduce-worker-%d")
.build();
workerExecutor = new ThreadPoolExecutor(2,
10,
10,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(),
threadFactory,
new ThreadPoolExecutor.AbortPolicy());
}
public void sendAndProcessWindowData(WindowAggregator.WindowData windowData) {
workerExecutor.execute(processWindowData(windowData));
}
private Runnable processWindowData(WindowAggregator.WindowData windowData) {
return () -> {
AlertDefine alertDefine = windowData.getAlertDefine();
List<MatchingLogEvent> matchingLogs = windowData.getMatchingLogs();
if (matchingLogs.isEmpty()) {
return;
}
// Check if count threshold is met
int requiredTimes = alertDefine.getTimes() != null ? alertDefine.getTimes() : 1;
if (matchingLogs.size() < requiredTimes) {
log.debug("Window {} has {} matching logs, but requires {} times",
windowData.getWindowKey(), matchingLogs.size(), requiredTimes);
return;
}
// Determine alert mode from configuration
String alertMode = getAlertMode(alertDefine);
long currentTime = System.currentTimeMillis();
switch (alertMode) {
case CommonConstants.ALERT_MODE_INDIVIDUAL:
// Generate individual alerts for each matching log
for (MatchingLogEvent matchingLog : matchingLogs) {
generateIndividualAlert(matchingLog, currentTime);
}
break;
case CommonConstants.ALERT_MODE_GROUP:
// Generate a single alert group for all matching logs
generateGroupAlert(windowData, matchingLogs, currentTime);
break;
default:
log.warn("Unknown alert mode for define {}: {}", alertDefine.getName(), alertMode);
}
};
}
private void generateIndividualAlert(MatchingLogEvent matchingLog, long currentTime) {
AlertDefine define = matchingLog.getAlertDefine();
LogEntry logEntry = matchingLog.getLogEntry();
Map<String, String> alertLabels = new HashMap<>(8);
// Create fingerprints for group alert
Map<String, String> commonFingerPrints = createCommonFingerprints(define);
alertLabels.putAll(commonFingerPrints);
// add the log data to commonFingerPrints
addLogEntryToMap(logEntry, alertLabels);
Map<String, Object> fieldValueMap = createFieldValueMap(logEntry, define);
Map<String, String> alertAnnotations = createAlertAnnotations(define, fieldValueMap);
// Create and send group alert
SingleAlert alert = SingleAlert.builder()
.labels(alertLabels)
.annotations(alertAnnotations)
.content(AlertTemplateUtil.render(define.getTemplate(), fieldValueMap))
.status(CommonConstants.ALERT_STATUS_FIRING)
.triggerTimes(1)
.startAt(currentTime)
.activeAt(currentTime)
.build();
alarmCommonReduce.reduceAndSendAlarm(alert.clone());
log.debug("Generated individual alert for define: {}", define.getName());
}
private void generateGroupAlert(WindowAggregator.WindowData windowData,
List<MatchingLogEvent> matchingLogs, long currentTime) {
List<SingleAlert> alerts = new ArrayList<>(matchingLogs.size());
AlertDefine define = windowData.getAlertDefine();
// Create fingerprints for group alert
Map<String, String> commonFingerPrints = createCommonFingerprints(define);
// Add window information to fingerprints
commonFingerPrints.put(WINDOW_START_TIME, String.valueOf(windowData.getStartTime()));
commonFingerPrints.put(WINDOW_END_TIME, String.valueOf(windowData.getEndTime()));
commonFingerPrints.put(CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_GROUP);
commonFingerPrints.put(MATCHING_LOGS_COUNT, String.valueOf(matchingLogs.size()));
for (MatchingLogEvent event: matchingLogs) {
LogEntry logEntry = event.getLogEntry();
Map<String, String> alertLabels = new HashMap<>(8);
alertLabels.putAll(commonFingerPrints);
// add the log data to commonFingerPrints
addLogEntryToMap(logEntry, alertLabels);
Map<String, Object> fieldValueMap = createFieldValueMap(logEntry, define);
Map<String, String> alertAnnotations = createAlertAnnotations(define, fieldValueMap);
// Create and send group alert
SingleAlert alert = SingleAlert.builder()
.labels(alertLabels)
.annotations(alertAnnotations)
.content(AlertTemplateUtil.render(define.getTemplate(), fieldValueMap))
.status(CommonConstants.ALERT_STATUS_FIRING)
.triggerTimes(matchingLogs.size())
.startAt(currentTime)
.activeAt(currentTime)
.build();
alerts.add(alert.clone());
}
alarmCommonReduce.reduceAndSendAlarmGroup(commonFingerPrints, alerts);
log.debug("Generated group alert for define: {} with {} matching logs",
define.getName(), matchingLogs.size());
}
private String getAlertMode(AlertDefine alertDefine) {
String mode = null;
if (alertDefine.getLabels() != null) {
mode = alertDefine.getLabels().get(CommonConstants.ALERT_MODE_LABEL);
}
if (mode == null || mode.isEmpty()) {
return CommonConstants.ALERT_MODE_GROUP; // Default to group mode if not specified
} else {
return mode;
}
}
private Map<String, String> createCommonFingerprints(AlertDefine define) {
Map<String, String> fingerprints = new HashMap<>(8);
fingerprints.put(CommonConstants.LABEL_ALERT_NAME, define.getName());
fingerprints.put(CommonConstants.LABEL_DEFINE_ID, String.valueOf(define.getId()));
if (define.getLabels() != null) {
fingerprints.putAll(define.getLabels());
}
return fingerprints;
}
private Map<String, Object> createFieldValueMap(LogEntry logEntry, AlertDefine define) {
Map<String, Object> fieldValueMap = new HashMap<>(8);
fieldValueMap.put("log", logEntry);
if (define.getLabels() != null) {
fieldValueMap.putAll(define.getLabels());
}
return fieldValueMap;
}
private Map<String, String> createAlertAnnotations(AlertDefine define, Map<String, Object> fieldValueMap) {
Map<String, String> annotations = new HashMap<>(8);
if (define.getAnnotations() != null) {
for (Map.Entry<String, String> entry : define.getAnnotations().entrySet()) {
annotations.put(entry.getKey(),
AlertTemplateUtil.render(entry.getValue(), fieldValueMap));
}
}
return annotations;
}
/**
* Add the content from LogEntry object (except timestamp) to commonFingerPrints
*
* @param logEntry log entry object
* @param context context
*/
private void addLogEntryToMap(LogEntry logEntry, Map<String, String> context) {
// Add basic fields
if (logEntry.getSeverityNumber() != null) {
context.put("severityNumber", String.valueOf(logEntry.getSeverityNumber()));
}
if (logEntry.getSeverityText() != null) {
context.put("severityText", logEntry.getSeverityText());
}
if (logEntry.getBody() != null) {
context.put("body", String.valueOf(logEntry.getBody()));
}
if (logEntry.getDroppedAttributesCount() != null) {
context.put("droppedAttributesCount", String.valueOf(logEntry.getDroppedAttributesCount()));
}
if (logEntry.getTraceId() != null) {
context.put("traceId", logEntry.getTraceId());
}
if (logEntry.getSpanId() != null) {
context.put("spanId", logEntry.getSpanId());
}
if (logEntry.getTraceFlags() != null) {
context.put("traceFlags", String.valueOf(logEntry.getTraceFlags()));
}
// Add attributes
if (logEntry.getAttributes() != null && !logEntry.getAttributes().isEmpty()) {
for (Map.Entry<String, Object> entry : logEntry.getAttributes().entrySet()) {
if (entry.getValue() != null) {
context.put("attr_" + entry.getKey(), String.valueOf(entry.getValue()));
}
}
}
// Add resource
if (logEntry.getResource() != null && !logEntry.getResource().isEmpty()) {
for (Map.Entry<String, Object> entry : logEntry.getResource().entrySet()) {
if (entry.getValue() != null) {
context.put("resource_" + entry.getKey(), String.valueOf(entry.getValue()));
}
}
}
// Add instrumentationScope
if (logEntry.getInstrumentationScope() != null) {
LogEntry.InstrumentationScope scope = logEntry.getInstrumentationScope();
if (scope.getName() != null) {
context.put("scope_name", scope.getName());
}
if (scope.getVersion() != null) {
context.put("scope_version", scope.getVersion());
}
if (scope.getDroppedAttributesCount() != null) {
context.put("scope_droppedAttributesCount", String.valueOf(scope.getDroppedAttributesCount()));
}
if (scope.getAttributes() != null && !scope.getAttributes().isEmpty()) {
for (Map.Entry<String, Object> entry : scope.getAttributes().entrySet()) {
if (entry.getValue() != null) {
context.put("scope_attr_" + entry.getKey(), String.valueOf(entry.getValue()));
}
}
}
}
}
}
@@ -0,0 +1,123 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate.realtime.window;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.AlerterWorkerPool;
import org.apache.hertzbeat.alert.calculate.JexlExprCalculator;
import org.apache.hertzbeat.alert.service.AlertDefineService;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* LogWorker Responsible for:
* 1. Receiving logs from WindowedLogRealTimeAlertCalculator
* 2. Evaluating alert expressions against logs
* 3. Sending matching logs to WindowAggregator
*/
@Slf4j
@Component
public class LogWorker {
private static final String LOG_PREFIX = "log";
private final AlertDefineService alertDefineService;
private final JexlExprCalculator jexlExprCalculator;
private final WindowAggregator windowAggregator;
private final AlerterWorkerPool workerPool;
@Autowired
public LogWorker(AlertDefineService alertDefineService,
JexlExprCalculator jexlExprCalculator, WindowAggregator windowAggregator,
AlerterWorkerPool workerPool) {
this.alertDefineService = alertDefineService;
this.jexlExprCalculator = jexlExprCalculator;
this.windowAggregator = windowAggregator;
this.workerPool = workerPool;
}
public void reduceAndSendLogTask(LogEntry logEntry) {
workerPool.executeLogJob(reduceLogTask(logEntry));
}
public Runnable reduceLogTask(LogEntry logEntry) {
return () -> {
try {
processLogEntry(logEntry);
} catch (Exception e) {
log.error("Error processing log entry in worker: {}", e.getMessage(), e);
}
};
}
private void processLogEntry(LogEntry logEntry) {
// Get all log alert definitions
List<AlertDefine> alertDefines = alertDefineService.getLogRealTimeAlertDefines();
// Create context for expression evaluation
Map<String, Object> context = new HashMap<>(8);
context.put(LOG_PREFIX, logEntry);
// Process each alert definition
for (AlertDefine define : alertDefines) {
if (define.getExpr() == null || define.getExpr().trim().isEmpty()) {
continue;
}
try {
// Evaluate alert expression
boolean match = jexlExprCalculator.execAlertExpression(context, define.getExpr(), false);
if (match) {
// Create matching log event and send to WindowAggregator
MatchingLogEvent event = createMatchingLogEvent(logEntry, define);
windowAggregator.addMatchingLog(event);
}
} catch (Exception e) {
log.warn("Error evaluating expression for alert define {}: {}", define.getName(), e.getMessage());
}
}
}
private MatchingLogEvent createMatchingLogEvent(LogEntry logEntry, AlertDefine define) {
long eventTimestamp = extractEventTimestamp(logEntry);
return MatchingLogEvent.builder()
.logEntry(logEntry)
.alertDefine(define)
.eventTimestamp(eventTimestamp)
.workerTimestamp(System.currentTimeMillis())
.build();
}
private long extractEventTimestamp(LogEntry logEntry) {
if (logEntry.getTimeUnixNano() != null && logEntry.getTimeUnixNano() != 0) {
return logEntry.getTimeUnixNano() / 1_000_000; // Convert to milliseconds
}
if (logEntry.getObservedTimeUnixNano() != null && logEntry.getObservedTimeUnixNano() != 0) {
return logEntry.getObservedTimeUnixNano() / 1_000_000; // Convert to milliseconds
}
return System.currentTimeMillis();
}
}
@@ -15,29 +15,26 @@
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.config;
package org.apache.hertzbeat.alert.calculate.realtime.window;
import lombok.Builder;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.apache.hertzbeat.common.entity.log.LogEntry;
/**
* OpenAI YAML Configuration - reads from spring.ai.openai.api-key
* Represents a log entry that matched an alert expression
* Used for communication between LogWorker and WindowAggregator
*/
@Data
@Component
@ConfigurationProperties(prefix = "spring.ai.openai")
public class OpenAiYamlConfig {
/**
* OpenAI API key from spring.ai.openai.api-key
*/
private String apiKey;
/**
* Check if OpenAI is enabled (has API key)
*/
public boolean isEnable() {
return apiKey != null && !apiKey.trim().isEmpty();
}
@Builder
public class MatchingLogEvent {
private LogEntry logEntry;
private AlertDefine alertDefine;
private long eventTimestamp;
private long workerTimestamp;
}
@@ -0,0 +1,213 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate.realtime.window;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/**
* Watermark Generator responsible for:
* 1. Receiving maxTimestamp updates from WindowedLogRealTimeAlertCalculator
* 2. Calculating watermarks based on configurable delay
* 3. Broadcasting watermarks to all subscribers (WindowAggregator)
*/
@Component
@Slf4j
public class TimeService {
static final long DEFAULT_WATERMARK_DELAY_MS = 30_000; // 30 seconds
private static final long WATERMARK_BROADCAST_INTERVAL_MS = 5_000; // 5 seconds
// Define acceptable timestamp range to filter abnormal timestamps
private static final long MAX_FUTURE_TIME_MS = 60_000; // Allow 1 minute future time
private static final long MAX_PAST_TIME_MS = 24 * 60 * 60 * 1000; // Allow 24 hours past time
private final AtomicLong maxTimestamp = new AtomicLong(0);
private final AtomicLong currentWatermark = new AtomicLong(0);
private final CopyOnWriteArrayList<WatermarkListener> listeners = new CopyOnWriteArrayList<>();
private ScheduledExecutorService scheduler;
public TimeService(List<WatermarkListener> initialListeners) {
listeners.addAll(initialListeners);
}
@PostConstruct
public void start() {
// Create internal scheduled executor
ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setUncaughtExceptionHandler((thread, throwable) -> {
log.error("TimeService scheduler has uncaughtException.");
log.error(throwable.getMessage(), throwable);
})
.setDaemon(true)
.setNameFormat("timeservice-scheduler-%d")
.build();
this.scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory);
// Start watermark broadcast scheduler
scheduler.scheduleAtFixedRate(
this::broadcastWatermark,
0,
WATERMARK_BROADCAST_INTERVAL_MS,
TimeUnit.MILLISECONDS
);
log.info("TimeService started with watermark delay: {}ms", DEFAULT_WATERMARK_DELAY_MS);
}
@PreDestroy
public void stop() {
if (scheduler != null && !scheduler.isShutdown()) {
log.info("Shutting down TimeService scheduler...");
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) {
log.warn("TimeService scheduler did not terminate within 10 seconds, forcing shutdown");
scheduler.shutdownNow();
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
log.error("TimeService scheduler did not terminate");
}
}
} catch (InterruptedException e) {
log.warn("Interrupted while waiting for TimeService scheduler to terminate");
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
log.info("TimeService stopped");
}
/**
* Check if timestamp is within acceptable range
*/
public boolean isValidTimestamp(long timestamp) {
long currentTime = System.currentTimeMillis();
return timestamp >= (currentTime - MAX_PAST_TIME_MS) && timestamp <= (currentTime + MAX_FUTURE_TIME_MS);
}
/**
* Check if data is late based on current watermark
*/
public boolean isLateData(long timestamp) {
return timestamp < getCurrentWatermark();
}
/**
* Update max timestamp from WindowedLogRealTimeAlertCalculator
*/
public void updateMaxTimestamp(long timestamp) {
maxTimestamp.getAndUpdate(current -> Math.max(current, timestamp));
}
/**
* Add watermark listener
*/
public void addWatermarkListener(WatermarkListener listener) {
listeners.add(listener);
}
/**
* Remove watermark listener
*/
public void removeWatermarkListener(WatermarkListener listener) {
listeners.remove(listener);
}
/**
* Get current watermark
*/
public long getCurrentWatermark() {
return currentWatermark.get();
}
/**
* Get max timestamp
*/
public long getMaxTimestamp() {
return maxTimestamp.get();
}
/**
* Calculate and broadcast watermark
*/
private void broadcastWatermark() {
try {
long maxTs = maxTimestamp.get();
if (maxTs <= 0) {
return;
}
// Calculate watermark: maxTimestamp - delay
long newWatermark = maxTs - DEFAULT_WATERMARK_DELAY_MS;
long currentWm = currentWatermark.get();
// Only advance watermark (monotonic property)
if (newWatermark <= currentWm) {
return;
}
if (currentWatermark.compareAndSet(currentWm, newWatermark)) {
// Broadcast to all listeners
Watermark watermark = new Watermark(newWatermark);
for (WatermarkListener listener : listeners) {
try {
listener.onWatermark(watermark);
} catch (Exception e) {
log.error("Error notifying watermark listener: {}", e.getMessage(), e);
}
}
log.debug("Broadcast watermark: {} (maxTimestamp: {})", newWatermark, maxTs);
}
} catch (Exception e) {
log.error("Error in watermark broadcast: {}", e.getMessage(), e);
}
}
/**
* Watermark data class
*/
@Data
@AllArgsConstructor
@Getter
public static class Watermark {
private final long timestamp;
}
/**
* Interface for watermark listeners
*/
public interface WatermarkListener {
void onWatermark(Watermark watermark);
}
}
@@ -0,0 +1,251 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate.realtime.window;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
/**
* Window Aggregator Responsible for:
* 1. Managing window data structure for all active windows
* 2. Receiving matching logs from Workers
* 3. Receiving watermarks from TimeService
* 4. Sending closed windows to AlarmEvaluator
*/
@Component
@Slf4j
public class WindowAggregator implements TimeService.WatermarkListener, Runnable {
private static final long DEFAULT_WINDOW_SIZE_MS = 1 * 60 * 1000; // 1 minutes
private final AlarmEvaluator alarmEvaluator;
private final BlockingQueue<MatchingLogEvent> eventQueue = new LinkedBlockingQueue<>();
private final Map<WindowKey, WindowData> activeWindows = new HashMap<>();
private final Object windowLock = new Object();
private ExecutorService aggregatorExecutor;
public WindowAggregator(AlarmEvaluator alarmEvaluator) {
this.alarmEvaluator = alarmEvaluator;
}
public void addMatchingLog(MatchingLogEvent event) {
try {
eventQueue.put(event);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("Interrupted while adding matching log to aggregator");
}
}
@Override
public void onWatermark(TimeService.Watermark watermark) {
List<WindowData> closedWindows;
synchronized (windowLock) {
closedWindows = new ArrayList<>();
// Find windows that should be closed based on watermark
Iterator<Map.Entry<WindowKey, WindowData>> iterator = activeWindows.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<WindowKey, WindowData> entry = iterator.next();
WindowData windowData = entry.getValue();
// Close window if its end time <= watermark timestamp
if (windowData.getEndTime() <= watermark.getTimestamp()) {
closedWindows.add(windowData);
iterator.remove();
log.debug("Closing window: {} with {} matching logs",
entry.getKey(), windowData.getMatchingLogs().size());
}
}
}
for (WindowData windowData : closedWindows) {
alarmEvaluator.sendAndProcessWindowData(windowData);
}
}
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
MatchingLogEvent event = eventQueue.take();
processMatchingLogEvent(event);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("Error processing matching log event: {}", e.getMessage(), e);
}
}
}
private void processMatchingLogEvent(MatchingLogEvent event) {
// Determine window size from alert define (if specified) or use default
long windowSizeMs = getWindowSize(event.getAlertDefine());
// Calculate window boundaries
long eventTime = event.getEventTimestamp();
long windowStart = (eventTime / windowSizeMs) * windowSizeMs;
long windowEnd = windowStart + windowSizeMs;
// Create window key
WindowKey windowKey = new WindowKey(
event.getAlertDefine().getId(),
windowStart,
windowEnd
);
synchronized (windowLock) {
// Get or create window data
WindowData windowData = activeWindows.computeIfAbsent(windowKey,
key -> new WindowData(key, event.getAlertDefine()));
// Add matching log to window
windowData.addMatchingLog(event);
log.debug("Added matching log to window: {} (total logs: {})",
windowKey, windowData.getMatchingLogs().size());
}
}
private long getWindowSize(AlertDefine alertDefine) {
// Check if alert define has custom window size configuration
if (alertDefine.getPeriod() != null) {
return alertDefine.getPeriod() * 1000; // Convert seconds to milliseconds
}
log.info("Using default window size of {} ms for alert define: {}",
DEFAULT_WINDOW_SIZE_MS, alertDefine.getName());
return DEFAULT_WINDOW_SIZE_MS;
}
@PostConstruct
public void start() {
// Create internal executor
ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setUncaughtExceptionHandler((thread, throwable) -> {
log.error("WindowAggregator executor has uncaughtException.");
log.error(throwable.getMessage(), throwable);
})
.setDaemon(true)
.setNameFormat("window-aggregator-%d")
.build();
aggregatorExecutor = Executors.newSingleThreadExecutor(threadFactory);
// Submit aggregation task
aggregatorExecutor.submit(this);
log.info("WindowAggregator started");
}
@PreDestroy
public void stop() {
if (aggregatorExecutor != null && !aggregatorExecutor.isShutdown()) {
log.info("Shutting down WindowAggregator executor...");
aggregatorExecutor.shutdown();
try {
if (!aggregatorExecutor.awaitTermination(10, TimeUnit.SECONDS)) {
log.warn("WindowAggregator executor did not terminate within 10 seconds, forcing shutdown");
aggregatorExecutor.shutdownNow();
if (!aggregatorExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
log.error("WindowAggregator executor did not terminate");
}
}
} catch (InterruptedException e) {
log.warn("Interrupted while waiting for WindowAggregator executor to terminate");
aggregatorExecutor.shutdownNow();
Thread.currentThread().interrupt();
}
}
log.info("WindowAggregator stopped");
}
/**
* Window key for identifying unique windows
*/
@Data
@Getter
@AllArgsConstructor
public static class WindowKey {
private final long alertDefineId;
private final long startTime;
private final long endTime;
}
/**
* Window data container
*/
public static class WindowData {
@Getter
private final WindowKey windowKey;
@Getter
private final AlertDefine alertDefine;
private final List<MatchingLogEvent> matchingLogs = new ArrayList<>();
@Getter
private final long createdTime;
public WindowData(WindowKey windowKey, AlertDefine alertDefine) {
this.windowKey = windowKey;
this.alertDefine = alertDefine;
this.createdTime = System.currentTimeMillis();
}
public void addMatchingLog(MatchingLogEvent event) {
matchingLogs.add(event);
}
public List<MatchingLogEvent> getMatchingLogs() {
return new ArrayList<>(matchingLogs);
}
/**
* Get start time of the window
* @return start time
*/
public long getStartTime() { return windowKey.getStartTime(); }
/**
* Get end time of the window
* @return end time
*/
public long getEndTime() { return windowKey.getEndTime(); }
}
}
@@ -18,6 +18,8 @@
package org.apache.hertzbeat.alert.reduce;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import java.util.List;
import java.util.Map;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
@@ -66,6 +68,22 @@ public class AlarmCommonReduce {
public void reduceAndSendAlarm(SingleAlert alert) {
workerExecutor.execute(reduceAlarmTask(alert));
}
public void reduceAndSendAlarmGroup(Map<String, String> groupLabels, List<SingleAlert> alerts) {
workerExecutor.execute(() -> {
try {
// Generate alert fingerprint
for (SingleAlert alert : alerts) {
String fingerprint = generateAlertFingerprint(alert.getLabels());
alert.setFingerprint(fingerprint);
}
// Process the group alert
alarmGroupReduce.processGroupAlert(groupLabels, alerts);
} catch (Exception e) {
log.error("Reduce alarm group failed: {}", e.getMessage());
}
});
}
Runnable reduceAlarmTask(SingleAlert alert) {
return () -> {
@@ -159,7 +159,20 @@ public class AlarmGroupReduce {
sendSingleAlert(alert);
}
}
public void processGroupAlert(Map<String, String> groupLabels, List<SingleAlert> alertList) {
GroupAlert groupAlert = GroupAlert.builder()
.groupKey(generateGroupKey(groupLabels))
.groupLabels(groupLabels)
.commonLabels(extractCommonLabels(alertList))
.commonAnnotations(extractCommonAnnotations(alertList))
.alerts(alertList)
.status(CommonConstants.ALERT_STATUS_FIRING)
.build();
alarmInhibitReduce.inhibitAlarm(groupAlert);
}
private boolean hasRequiredLabels(Map<String, String> labels, List<String> requiredLabels) {
return requiredLabels.stream().allMatch(labels::containsKey);
}
@@ -22,6 +22,7 @@ import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.apache.hertzbeat.common.support.exception.AlertExpressionException;
import org.springframework.data.domain.Page;
import org.springframework.web.multipart.MultipartFile;
import org.apache.hertzbeat.common.constants.CommonConstants;
import java.util.List;
import java.util.Map;
@@ -106,10 +107,23 @@ public interface AlertDefineService {
void importConfig(MultipartFile file) throws Exception;
/**
* Get the real-time alarm definition list
* @return Real-time alarm definition list
* Get the real-time metrics alarm definition list
* @return Real-time metrics alarm definition list
*/
List<AlertDefine> getRealTimeAlertDefines();
List<AlertDefine> getMetricsRealTimeAlertDefines();
/**
* Get the real-time log alarm definition list
* @return Real-time log alarm definition list
*/
List<AlertDefine> getLogRealTimeAlertDefines();
/**
* Get the alarm definition list by type
* @param type Alarm definition type, must be one of the constants defined in {@link CommonConstants}
* @return List of enabled alarm definitions matching the specified type, empty list if none found
*/
List<AlertDefine> getAlertDefinesByType(String type);
/**
* Get define preview
@@ -32,4 +32,12 @@ public interface DataSourceService {
* @return result
*/
List<Map<String, Object>> calculate(String datasource, String expr);
/**
* query result set from db
* @param datasource sql or promql
* @param expr query expr
* @return result
*/
List<Map<String, Object>> query(String datasource, String expr);
}
@@ -24,7 +24,7 @@ import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.Predicate;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.calculate.PeriodicAlertRuleScheduler;
import org.apache.hertzbeat.alert.calculate.periodic.PeriodicAlertRuleScheduler;
import org.apache.hertzbeat.alert.dao.AlertDefineDao;
import org.apache.hertzbeat.alert.service.AlertDefineImExportService;
import org.apache.hertzbeat.alert.service.AlertDefineService;
@@ -87,7 +87,8 @@ public class AlertDefineServiceImpl implements AlertDefineService {
@Override
public void validate(AlertDefine alertDefine, boolean isModify) throws IllegalArgumentException {
if (StringUtils.hasText(alertDefine.getExpr())) {
if (CommonConstants.ALERT_THRESHOLD_TYPE_REALTIME.equals(alertDefine.getType())) {
if (CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_REALTIME.equals(alertDefine.getType())
|| CommonConstants.LOG_ALERT_THRESHOLD_TYPE_REALTIME.equals(alertDefine.getType())) {
try {
JexlExpressionRunner.compile(alertDefine.getExpr());
} catch (Exception e) {
@@ -214,11 +215,21 @@ public class AlertDefineServiceImpl implements AlertDefineService {
}
@Override
public List<AlertDefine> getRealTimeAlertDefines() {
List<AlertDefine> alertDefines = CacheFactory.getAlertDefineCache();
public List<AlertDefine> getMetricsRealTimeAlertDefines() {
List<AlertDefine> alertDefines = CacheFactory.getMetricsAlertDefineCache();
if (alertDefines == null) {
alertDefines = alertDefineDao.findAlertDefinesByTypeAndEnableTrue(CommonConstants.ALERT_THRESHOLD_TYPE_REALTIME);
CacheFactory.setAlertDefineCache(alertDefines);
alertDefines = alertDefineDao.findAlertDefinesByTypeAndEnableTrue(CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_REALTIME);
CacheFactory.setMetricsAlertDefineCache(alertDefines);
}
return alertDefines;
}
@Override
public List<AlertDefine> getLogRealTimeAlertDefines() {
List<AlertDefine> alertDefines = CacheFactory.getLogAlertDefineCache();
if (alertDefines == null) {
alertDefines = alertDefineDao.findAlertDefinesByTypeAndEnableTrue(CommonConstants.LOG_ALERT_THRESHOLD_TYPE_REALTIME);
CacheFactory.setLogAlertDefineCache(alertDefines);
}
return alertDefines;
}
@@ -229,11 +240,35 @@ public class AlertDefineServiceImpl implements AlertDefineService {
return Collections.emptyList();
}
switch (type) {
case CommonConstants.ALERT_THRESHOLD_TYPE_PERIODIC:
case CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_PERIODIC:
return dataSourceService.calculate(datasource, expr);
case CommonConstants.LOG_ALERT_THRESHOLD_TYPE_PERIODIC:
// todo support alert expr preview
return dataSourceService.query(datasource, expr);
default:
log.error("Get define preview unsupported type: {}", type);
return Collections.emptyList();
}
}
@Override
public List<AlertDefine> getAlertDefinesByType(String type) {
if (!StringUtils.hasText(type)) {
throw new IllegalArgumentException("Alert definition type cannot be null or empty");
}
switch (type) {
case CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_REALTIME:
case CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_PERIODIC:
case CommonConstants.LOG_ALERT_THRESHOLD_TYPE_REALTIME:
case CommonConstants.LOG_ALERT_THRESHOLD_TYPE_PERIODIC:
// Valid type, proceed with query
break;
default:
throw new IllegalArgumentException("Unsupported alert definition type: " + type);
}
// Query enabled alert definitions by type
return alertDefineDao.findAlertDefinesByTypeAndEnableTrue(type);
}
}
@@ -95,6 +95,29 @@ public class DataSourceServiceImpl implements DataSourceService {
}
}
@Override
public List<Map<String, Object>> query(String datasource, String expr) {
if (!StringUtils.hasText(expr)) {
throw new IllegalArgumentException("Empty expression");
}
if (executors == null || executors.isEmpty()) {
throw new IllegalArgumentException(bundle.getString("alerter.datasource.executor.not.found"));
}
QueryExecutor executor = executors.stream().filter(e -> e.support(datasource)).findFirst().orElse(null);
if (executor == null) {
throw new IllegalArgumentException("Unsupported datasource: " + datasource);
}
// replace all white space
expr = expr.replaceAll("\\s+", " ");
try {
return executor.execute(expr);
} catch (Exception e) {
log.error("Error executing query on datasource {}: {}", datasource, e.getMessage());
throw new RuntimeException("Query execution failed", e);
}
}
private List<Map<String, Object>> evaluate(String expr, QueryExecutor executor) {
CommonTokenStream tokens = tokenStreamCache.get(expr, this::createTokenStream);
AlertExpressionParser parser = new AlertExpressionParser(tokens);
@@ -17,6 +17,8 @@
package org.apache.hertzbeat.alert.util;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -33,10 +35,11 @@ public final class AlertTemplateUtil {
}
/**
* Match the variable ${key}
* Match the variable ${key} or ${key.property.subproperty}
* eg: Alert, the instance: ${instance} metrics: ${metrics} is over flow.
* eg: Log alert: ${log.attributes.level} - ${log.message}
*/
private static final Pattern PATTERN = Pattern.compile("\\$\\{(\\w+)\\}");
private static final Pattern PATTERN = Pattern.compile("\\$\\{([\\w.]+)}");
public static String render(String template, Map<String, Object> replaceData) {
if (!StringUtils.hasText(template)) {
@@ -50,7 +53,8 @@ public final class AlertTemplateUtil {
Matcher matcher = PATTERN.matcher(template);
StringBuilder builder = new StringBuilder();
while (matcher.find()) {
Object objectValue = replaceData.getOrDefault(matcher.group(1), "NullValue");
String propertyPath = matcher.group(1);
Object objectValue = getNestedProperty(replaceData, propertyPath);
String value = objectValue != null ? objectValue.toString() : "NullValue";
matcher.appendReplacement(builder, Matcher.quoteReplacement(value));
}
@@ -61,4 +65,116 @@ public final class AlertTemplateUtil {
return template;
}
}
/**
* Get nested property value from object using property path like "log.attributes.demo"
* @param replaceData the root data map
* @param propertyPath the property path, e.g., "log.attributes.demo"
* @return the property value or null if not found
*/
private static Object getNestedProperty(Map<String, Object> replaceData, String propertyPath) {
if (propertyPath == null || propertyPath.isEmpty()) {
return null;
}
String[] parts = propertyPath.split("\\.");
Object current = replaceData.get(parts[0]);
if (current == null) {
return null;
}
// If only one part, return the direct value
if (parts.length == 1) {
return current;
}
// Navigate through the property path
for (int i = 1; i < parts.length; i++) {
current = getPropertyValue(current, parts[i]);
if (current == null) {
return null;
}
}
return current;
}
/**
* Get property value from an object using reflection
* @param obj the object
* @param propertyName the property name
* @return the property value or null if not found
*/
private static Object getPropertyValue(Object obj, String propertyName) {
if (obj == null || propertyName == null) {
return null;
}
try {
// Handle Map objects
if (obj instanceof Map) {
return ((Map<?, ?>) obj).get(propertyName);
}
Class<?> clazz = obj.getClass();
// Try getter method first (getPropertyName or isPropertyName for boolean)
String getterName = "get" + capitalize(propertyName);
String booleanGetterName = "is" + capitalize(propertyName);
try {
Method getter = clazz.getMethod(getterName);
return getter.invoke(obj);
} catch (NoSuchMethodException e) {
// Try boolean getter
try {
Method booleanGetter = clazz.getMethod(booleanGetterName);
return booleanGetter.invoke(obj);
} catch (NoSuchMethodException ignored) {
// Continue to field access
}
}
// Try direct field access
try {
Field field = clazz.getDeclaredField(propertyName);
field.setAccessible(true);
return field.get(obj);
} catch (NoSuchFieldException ignored) {
// Field not found
}
// Try parent classes
Class<?> superClass = clazz.getSuperclass();
while (superClass != null && !superClass.equals(Object.class)) {
try {
Field field = superClass.getDeclaredField(propertyName);
field.setAccessible(true);
return field.get(obj);
} catch (NoSuchFieldException ignored) {
// Continue with parent class
}
superClass = superClass.getSuperclass();
}
} catch (Exception e) {
log.debug("Failed to access property '{}' on object of type {}: {}",
propertyName, obj.getClass().getSimpleName(), e.getMessage());
}
return null;
}
/**
* Capitalize the first letter of a string
* @param str the input string
* @return the capitalized string
*/
private static String capitalize(String str) {
if (str == null || str.isEmpty()) {
return str;
}
return str.substring(0, 1).toUpperCase() + str.substring(1);
}
}
@@ -52,4 +52,36 @@ class AlerterWorkerPoolTest {
assertEquals(NUMBER_OF_THREADS, counter.get());
}
@Test
void executeNotify() throws InterruptedException {
counter = new AtomicInteger();
latch = new CountDownLatch(NUMBER_OF_THREADS);
for (int i = 0; i < NUMBER_OF_THREADS; i++) {
pool.executeNotify(() -> {
counter.incrementAndGet();
latch.countDown();
});
}
latch.await();
assertEquals(NUMBER_OF_THREADS, counter.get());
}
@Test
void executeLogJob() throws InterruptedException {
counter = new AtomicInteger();
latch = new CountDownLatch(NUMBER_OF_THREADS);
for (int i = 0; i < NUMBER_OF_THREADS; i++) {
pool.executeLogJob(() -> {
counter.incrementAndGet();
latch.countDown();
});
}
latch.await();
assertEquals(NUMBER_OF_THREADS, counter.get());
}
}
@@ -0,0 +1,252 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate.periodic;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.DataSourceService;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* Test case for {@link LogPeriodicAlertCalculator}
*/
@ExtendWith(MockitoExtension.class)
class LogPeriodicAlertCalculatorTest {
@Mock
private DataSourceService dataSourceService;
@Mock
private AlarmCommonReduce alarmCommonReduce;
@InjectMocks
private LogPeriodicAlertCalculator calculator;
private AlertDefine groupAlertDefine;
private AlertDefine individualAlertDefine;
@BeforeEach
void setUp() {
groupAlertDefine = AlertDefine.builder()
.id(1L)
.name("log_error_alert")
.type("periodic_log")
.expr("SELECT * FROM hertzbeat_logs WHERE severity_text = 'ERROR' AND time_unix_nano >= ? AND time_unix_nano <= ?")
.labels(Map.of(CommonConstants.LABEL_ALERT_SEVERITY, CommonConstants.ALERT_SEVERITY_WARNING, "team", "backend", CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_GROUP))
.annotations(Map.of("summary", "Error logs detected", "description", "Multiple error logs found"))
.template("Found ${severity_text} log: ${body} from ${service_name}")
.datasource("sql")
.enable(true)
.period(300)
.build();
individualAlertDefine = AlertDefine.builder()
.id(1L)
.name("log_error_alert")
.type("periodic_log")
.expr("SELECT * FROM hertzbeat_logs WHERE severity_text = 'ERROR' AND time_unix_nano >= ? AND time_unix_nano <= ?")
.labels(Map.of(CommonConstants.LABEL_ALERT_SEVERITY, CommonConstants.ALERT_SEVERITY_WARNING, "team", "backend",
CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_INDIVIDUAL))
.annotations(Map.of("summary", "Error logs detected", "description", "Multiple error logs found"))
.template("Found ${severity_text} log: ${body} from ${service_name}")
.datasource("sql")
.enable(true)
.period(300)
.build();
}
@Test
void testCalculateWithEnabledRuleAndValidExpression() {
// Given
List<Map<String, Object>> queryResults = createSampleLogResults(3);
when(dataSourceService.query(anyString(), anyString())).thenReturn(queryResults);
// When
calculator.calculate(groupAlertDefine);
// Then
verify(dataSourceService).query("sql", "SELECT * FROM hertzbeat_logs WHERE severity_text = 'ERROR' AND time_unix_nano >= ? AND time_unix_nano <= ?");
verify(alarmCommonReduce).reduceAndSendAlarmGroup(any(), any());
}
@Test
void testCalculateWithEmptyQueryResults() {
// Given
when(dataSourceService.query(anyString(), anyString())).thenReturn(new ArrayList<>());
// When
calculator.calculate(groupAlertDefine);
// Then
verify(dataSourceService).query(anyString(), anyString());
verifyNoInteractions(alarmCommonReduce);
}
@Test
void testCalculateWithNullQueryResults() {
// Given
when(dataSourceService.query(anyString(), anyString())).thenReturn(null);
// When
calculator.calculate(groupAlertDefine);
// Then
verify(dataSourceService).query(anyString(), anyString());
verifyNoInteractions(alarmCommonReduce);
}
@Test
void testCalculateWithQueryException() {
// Given
when(dataSourceService.query(anyString(), anyString())).thenThrow(new RuntimeException("Database connection failed"));
// When
calculator.calculate(groupAlertDefine);
// Then
verify(dataSourceService).query(anyString(), anyString());
verifyNoInteractions(alarmCommonReduce);
}
@Test
void testIndividualAlertGeneration() {
// Given
Map<String, Object> logContext = createSingleLogResult("Error in payment service", "ERROR");
List<Map<String, Object>> queryResults = List.of(logContext);
when(dataSourceService.query(anyString(), anyString())).thenReturn(queryResults);
try (MockedStatic<org.apache.hertzbeat.alert.util.AlertTemplateUtil> mockedTemplateUtil =
Mockito.mockStatic(org.apache.hertzbeat.alert.util.AlertTemplateUtil.class)) {
mockedTemplateUtil.when(() -> org.apache.hertzbeat.alert.util.AlertTemplateUtil.render(anyString(), any()))
.thenReturn("Rendered alert content");
// When
calculator.calculate(individualAlertDefine);
// Then
ArgumentCaptor<SingleAlert> alertCaptor = ArgumentCaptor.forClass(SingleAlert.class);
verify(alarmCommonReduce).reduceAndSendAlarm(alertCaptor.capture());
SingleAlert capturedAlert = alertCaptor.getValue();
assertAll(
() -> assertEquals(CommonConstants.ALERT_STATUS_FIRING, capturedAlert.getStatus()),
() -> assertEquals(1, capturedAlert.getTriggerTimes()),
() -> assertEquals("Rendered alert content", capturedAlert.getContent()),
() -> assertNotNull(capturedAlert.getStartAt()),
() -> assertNotNull(capturedAlert.getActiveAt()),
() -> assertTrue(capturedAlert.getLabels().containsKey("alertname")),
() -> assertTrue(capturedAlert.getLabels().containsKey("defineid")),
() -> assertEquals("log_error_alert", capturedAlert.getLabels().get("alertname")),
() -> assertEquals("1", capturedAlert.getLabels().get("defineid"))
);
}
}
@Test
void testGroupAlertGeneration() {
// Given
List<Map<String, Object>> queryResults = createSampleLogResults(2);
when(dataSourceService.query(anyString(), anyString())).thenReturn(queryResults);
try (MockedStatic<org.apache.hertzbeat.alert.util.AlertTemplateUtil> mockedTemplateUtil =
Mockito.mockStatic(org.apache.hertzbeat.alert.util.AlertTemplateUtil.class)) {
mockedTemplateUtil.when(() -> org.apache.hertzbeat.alert.util.AlertTemplateUtil.render(anyString(), any()))
.thenReturn("Rendered alert content");
// When
calculator.calculate(groupAlertDefine);
// Then
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> groupLabelsCaptor = ArgumentCaptor.forClass(Map.class);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<SingleAlert>> alertsCaptor = ArgumentCaptor.forClass(List.class);
verify(alarmCommonReduce).reduceAndSendAlarmGroup(groupLabelsCaptor.capture(), alertsCaptor.capture());
Map<String, String> groupLabels = groupLabelsCaptor.getValue();
List<SingleAlert> alerts = alertsCaptor.getValue();
assertAll(
() -> assertEquals("2", groupLabels.get("__rows__")),
() -> assertEquals(CommonConstants.ALERT_MODE_GROUP, groupLabels.get(CommonConstants.ALERT_MODE_LABEL)),
() -> assertEquals("log_error_alert", groupLabels.get("alertname")),
() -> assertEquals("1", groupLabels.get("defineid")),
() -> assertEquals(2, alerts.size()),
() -> alerts.forEach(alert -> {
assertEquals(CommonConstants.ALERT_STATUS_FIRING, alert.getStatus());
assertEquals(2, alert.getTriggerTimes());
assertNotNull(alert.getStartAt());
assertNotNull(alert.getActiveAt());
})
);
}
}
/**
* Create sample log results with simplified structure - only key fields
*/
private List<Map<String, Object>> createSampleLogResults(int count) {
List<Map<String, Object>> results = new ArrayList<>();
for (int i = 0; i < count; i++) {
results.add(createSingleLogResult("Error message " + i, "ERROR"));
}
return results;
}
/**
* Create a single log result with simplified structure
*/
private Map<String, Object> createSingleLogResult(String message, String severityText) {
Map<String, Object> logEntry = new HashMap<>();
// Only essential fields for testing
logEntry.put("time_unix_nano", System.nanoTime());
logEntry.put("severity_text", severityText);
logEntry.put("body", message);
logEntry.put("service_name", "payment-service");
return logEntry;
}
}
@@ -15,8 +15,9 @@
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate;
package org.apache.hertzbeat.alert.calculate.periodic;
import org.apache.hertzbeat.alert.calculate.AlarmCacheManager;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.DataSourceService;
import org.apache.hertzbeat.common.constants.CommonConstants;
@@ -50,10 +51,10 @@ import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* Test case for {@link PeriodicAlertCalculator}
* Test case for {@link MetricsPeriodicAlertCalculator}
*/
@ExtendWith(MockitoExtension.class)
class PeriodicAlertCalculatorTest {
class MetricsPeriodicAlertCalculatorTest {
@Mock
private DataSourceService dataSourceService;
@@ -65,7 +66,7 @@ class PeriodicAlertCalculatorTest {
private AlarmCacheManager alarmCacheManager;
@InjectMocks
private PeriodicAlertCalculator periodicAlertCalculator;
private MetricsPeriodicAlertCalculator periodicAlertCalculator;
private AlertDefine rule;
@@ -15,10 +15,12 @@
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate;
package org.apache.hertzbeat.alert.calculate.realtime;
import com.google.common.collect.Lists;
import org.apache.hertzbeat.alert.AlerterWorkerPool;
import org.apache.hertzbeat.alert.calculate.AlarmCacheManager;
import org.apache.hertzbeat.alert.calculate.JexlExprCalculator;
import org.apache.hertzbeat.alert.dao.SingleAlertDao;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.AlertDefineService;
@@ -48,7 +50,7 @@ import static org.mockito.Mockito.when;
/**
*
*/
public class RealTimeAlertCalculatorMatchTest {
public class MetricsRealTimeAlertCalculatorMatchTest {
private final AlerterWorkerPool workerPool = new AlerterWorkerPool();
@@ -67,19 +69,20 @@ public class RealTimeAlertCalculatorMatchTest {
@Mock
private AlarmCacheManager alarmCacheManager;
private RealTimeAlertCalculator realTimeAlertCalculator;
private MetricsRealTimeAlertCalculator metricsRealTimeAlertCalculator;
@BeforeEach
public void setUp() {
MockitoAnnotations.openMocks(this);
when(singleAlertDao.querySingleAlertsByStatus(any())).thenReturn(new ArrayList<>());
realTimeAlertCalculator = new RealTimeAlertCalculator(
metricsRealTimeAlertCalculator = new MetricsRealTimeAlertCalculator(
workerPool,
dataQueue,
alertDefineService,
singleAlertDao,
alarmCommonReduce,
alarmCacheManager,
new JexlExprCalculator(),
false
);
}
@@ -99,7 +102,7 @@ public class RealTimeAlertCalculatorMatchTest {
List<AlertDefine> allDefines = Collections.singletonList(matchDefine);
List<AlertDefine> filtered = realTimeAlertCalculator.filterThresholdsByAppAndMetrics(allDefines, app, "", Map.of(), instanceId, priority);
List<AlertDefine> filtered = metricsRealTimeAlertCalculator.filterThresholdsByAppAndMetrics(allDefines, app, "", Map.of(), instanceId, priority);
// It should filter out 999999999.
assertEquals(1, filtered.size());
@@ -145,10 +148,10 @@ public class RealTimeAlertCalculatorMatchTest {
List<AlertDefine> allDefines = Collections.singletonList(matchDefine);
when(alertDefineService.getRealTimeAlertDefines()).thenReturn(allDefines);
when(alertDefineService.getMetricsRealTimeAlertDefines()).thenReturn(allDefines);
when(dataQueue.pollMetricsDataToAlerter()).thenReturn(metricsData).thenThrow(new InterruptedException());
realTimeAlertCalculator.startCalculate();
metricsRealTimeAlertCalculator.startCalculate();
Thread.sleep(3000);
@@ -189,10 +192,10 @@ public class RealTimeAlertCalculatorMatchTest {
List<AlertDefine> allDefines = Collections.singletonList(matchDefine);
when(alertDefineService.getRealTimeAlertDefines()).thenReturn(allDefines);
when(alertDefineService.getMetricsRealTimeAlertDefines()).thenReturn(allDefines);
when(dataQueue.pollMetricsDataToAlerter()).thenReturn(metricsData).thenThrow(new InterruptedException());
realTimeAlertCalculator.startCalculate();
metricsRealTimeAlertCalculator.startCalculate();
Thread.sleep(3000);
@@ -239,10 +242,10 @@ public class RealTimeAlertCalculatorMatchTest {
List<AlertDefine> allDefines = Collections.singletonList(matchDefine);
when(alertDefineService.getRealTimeAlertDefines()).thenReturn(allDefines);
when(alertDefineService.getMetricsRealTimeAlertDefines()).thenReturn(allDefines);
when(dataQueue.pollMetricsDataToAlerter()).thenReturn(metricsData).thenThrow(new InterruptedException());
realTimeAlertCalculator.startCalculate();
metricsRealTimeAlertCalculator.startCalculate();
Thread.sleep(3000);
@@ -15,11 +15,13 @@
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate;
package org.apache.hertzbeat.alert.calculate.realtime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.apache.hertzbeat.alert.AlerterWorkerPool;
import org.apache.hertzbeat.alert.calculate.AlarmCacheManager;
import org.apache.hertzbeat.alert.calculate.JexlExprCalculator;
import org.apache.hertzbeat.alert.dao.SingleAlertDao;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.AlertDefineService;
@@ -35,9 +37,9 @@ import java.util.List;
import java.util.Map;
class RealTimeAlertCalculatorTest {
class MetricsRealTimeAlertCalculatorTest {
private RealTimeAlertCalculator calculator;
private MetricsRealTimeAlertCalculator calculator;
@BeforeEach
void setUp() {
@@ -51,7 +53,8 @@ class RealTimeAlertCalculatorTest {
Mockito.when(mockDao.querySingleAlertsByStatus(Mockito.anyString()))
.thenReturn(Collections.emptyList());
calculator = new RealTimeAlertCalculator(mockPool, mockQueue, mockAlertDefineService, mockDao, mockReduce, alarmCacheManager, false);
calculator = new MetricsRealTimeAlertCalculator(mockPool, mockQueue, mockAlertDefineService
, mockDao, mockReduce, alarmCacheManager, new JexlExprCalculator(), false);
}
@Test
@@ -0,0 +1,189 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate.realtime;
import org.apache.hertzbeat.alert.calculate.realtime.window.LogWorker;
import org.apache.hertzbeat.alert.calculate.realtime.window.TimeService;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Test case for {@link WindowedLogRealTimeAlertCalculator}
*/
@ExtendWith(MockitoExtension.class)
class WindowedLogRealTimeAlertCalculatorTest {
@Mock
private CommonDataQueue dataQueue;
@Mock
private TimeService timeService;
@Mock
private LogWorker logWorker;
@InjectMocks
private WindowedLogRealTimeAlertCalculator calculator;
private LogEntry validLogEntry;
private LogEntry invalidTimestampLogEntry;
private LogEntry lateDataLogEntry;
@BeforeEach
void setUp() {
// Create valid log entry with current timestamp
long currentTimeNano = System.currentTimeMillis() * 1_000_000L;
validLogEntry = LogEntry.builder()
.timeUnixNano(currentTimeNano)
.severityText("ERROR")
.body("Test error message")
.build();
// Create log entry with invalid timestamp (far future)
long invalidTimeNano = (System.currentTimeMillis() + 2 * 60 * 60 * 1000L) * 1_000_000L; // 2 hours in future
invalidTimestampLogEntry = LogEntry.builder()
.timeUnixNano(invalidTimeNano)
.severityText("ERROR")
.body("Test error message with invalid timestamp")
.build();
// Create late data log entry
long lateTimeNano = (System.currentTimeMillis() - 60 * 60 * 1000L) * 1_000_000L; // 1 hour in past
lateDataLogEntry = LogEntry.builder()
.timeUnixNano(lateTimeNano)
.severityText("ERROR")
.body("Test late data message")
.build();
}
@Test
void testRunWithValidLogEntry() throws InterruptedException {
// Given
when(dataQueue.pollLogEntry()).thenReturn(validLogEntry, (LogEntry) null);
when(timeService.isValidTimestamp(anyLong())).thenReturn(true);
when(timeService.isLateData(anyLong())).thenReturn(false);
// Create a thread to run the calculator
Thread calculatorThread = new Thread(calculator);
// When
calculatorThread.start();
// Give some time for processing
Thread.sleep(100);
calculatorThread.interrupt();
calculatorThread.join(1000);
// Then
verify(dataQueue, atLeastOnce()).pollLogEntry();
verify(timeService).isValidTimestamp(anyLong());
verify(timeService).isLateData(anyLong());
verify(timeService).updateMaxTimestamp(anyLong());
verify(logWorker).reduceAndSendLogTask(validLogEntry);
verify(dataQueue).sendLogEntryToStorage(validLogEntry);
}
@Test
void testRunWithInvalidTimestamp() throws InterruptedException {
// Given
when(dataQueue.pollLogEntry()).thenReturn(invalidTimestampLogEntry, (LogEntry) null);
when(timeService.isValidTimestamp(anyLong())).thenReturn(false);
// Create a thread to run the calculator
Thread calculatorThread = new Thread(calculator);
// When
calculatorThread.start();
// Give some time for processing
Thread.sleep(100);
calculatorThread.interrupt();
calculatorThread.join(1000);
// Then
verify(dataQueue, atLeastOnce()).pollLogEntry();
verify(timeService).isValidTimestamp(anyLong());
verify(timeService, never()).isLateData(anyLong());
verify(timeService, never()).updateMaxTimestamp(anyLong());
verify(logWorker, never()).reduceAndSendLogTask(any());
verify(dataQueue).sendLogEntryToStorage(invalidTimestampLogEntry);
}
@Test
void testRunWithLateData() throws InterruptedException {
// Given
when(dataQueue.pollLogEntry()).thenReturn(lateDataLogEntry, (LogEntry) null);
when(timeService.isValidTimestamp(anyLong())).thenReturn(true);
when(timeService.isLateData(anyLong())).thenReturn(true);
when(timeService.getCurrentWatermark()).thenReturn(System.currentTimeMillis() - 30 * 60 * 1000L); // 30 minutes ago
// Create a thread to run the calculator
Thread calculatorThread = new Thread(calculator);
// When
calculatorThread.start();
// Give some time for processing
Thread.sleep(100);
calculatorThread.interrupt();
calculatorThread.join(1000);
// Then
verify(dataQueue, atLeastOnce()).pollLogEntry();
verify(timeService).isValidTimestamp(anyLong());
verify(timeService).isLateData(anyLong());
verify(timeService, never()).updateMaxTimestamp(anyLong());
verify(logWorker, never()).reduceAndSendLogTask(any());
verify(dataQueue).sendLogEntryToStorage(lateDataLogEntry);
}
@Test
void testRunWithGeneralException() throws InterruptedException {
// Given
when(dataQueue.pollLogEntry()).thenThrow(new RuntimeException("Test runtime exception"))
.thenReturn(null); // Return null to exit the loop
// Create a thread to run the calculator
Thread calculatorThread = new Thread(calculator);
// When
calculatorThread.start();
// Give some time for processing
Thread.sleep(100);
calculatorThread.interrupt();
calculatorThread.join(1000);
// Then
verify(dataQueue, atLeastOnce()).pollLogEntry();
}
}
@@ -0,0 +1,346 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate.realtime.window;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* Test case for {@link AlarmEvaluator}
*/
@ExtendWith(MockitoExtension.class)
class AlarmEvaluatorTest {
@Mock
private AlarmCommonReduce alarmCommonReduce;
@InjectMocks
private AlarmEvaluator alarmEvaluator;
private AlertDefine alertDefine;
private WindowAggregator.WindowData windowData;
@BeforeEach
void setUp() {
// Create test log entry
LogEntry logEntry = LogEntry.builder()
.timeUnixNano(System.currentTimeMillis() * 1_000_000L)
.severityText("ERROR")
.body("Test error message")
.attributes(Map.of("service", "test-service", "pod", "test-pod"))
.resource(Map.of("cluster", "test-cluster"))
.build();
// Create test alert define for individual mode
alertDefine = AlertDefine.builder()
.id(1L)
.name("test_log_alert")
.type("realtime_log")
.expr("log.severityText == 'ERROR'")
.times(1)
.template("Alert: {{log.body}}")
.labels(Map.of(CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_INDIVIDUAL, CommonConstants.LABEL_ALERT_SEVERITY, CommonConstants.ALERT_SEVERITY_CRITICAL))
.annotations(Map.of("summary", "Test alert summary"))
.enable(true)
.build();
// Create matching log events
MatchingLogEvent matchingEvent = MatchingLogEvent.builder()
.logEntry(logEntry)
.alertDefine(alertDefine)
.eventTimestamp(System.currentTimeMillis())
.workerTimestamp(System.currentTimeMillis())
.build();
// Create window data
WindowAggregator.WindowKey windowKey = new WindowAggregator.WindowKey(1L,
System.currentTimeMillis() - 60000, System.currentTimeMillis());
windowData = new WindowAggregator.WindowData(windowKey, alertDefine);
windowData.addMatchingLog(matchingEvent);
}
@Test
void testProcessWindowDataWithIndividualMode() throws InterruptedException {
// Given - alert define with individual mode
alertDefine.setLabels(Map.of(CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_INDIVIDUAL));
// When
alarmEvaluator.sendAndProcessWindowData(windowData);
// Wait for async processing
Thread.sleep(2000);
// Then
verify(alarmCommonReduce, times(1)).reduceAndSendAlarm(any(SingleAlert.class));
verify(alarmCommonReduce, never()).reduceAndSendAlarmGroup(anyMap(), anyList());
}
@Test
void testProcessWindowDataWithGroupMode() throws InterruptedException {
// Given - alert define with group mode
alertDefine.setLabels(Map.of(CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_GROUP));
// When
alarmEvaluator.sendAndProcessWindowData(windowData);
// Wait for async processing
Thread.sleep(2000);
// Then
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
verify(alarmCommonReduce, times(1)).reduceAndSendAlarmGroup(anyMap(), anyList());
}
@Test
void testProcessWindowDataWithEmptyLogs() throws InterruptedException {
// Given - window data with no matching logs
WindowAggregator.WindowKey emptyWindowKey = new WindowAggregator.WindowKey(1L,
System.currentTimeMillis() - 60000, System.currentTimeMillis());
WindowAggregator.WindowData emptyWindowData = new WindowAggregator.WindowData(emptyWindowKey, alertDefine);
// When
alarmEvaluator.sendAndProcessWindowData(emptyWindowData);
// Wait for async processing
Thread.sleep(2000);
// Then - should not process any alerts
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
verify(alarmCommonReduce, never()).reduceAndSendAlarmGroup(anyMap(), anyList());
}
@Test
void testProcessWindowDataWithInsufficientTimes() throws InterruptedException {
// Given - alert define requiring 3 times but only 1 matching log
alertDefine.setTimes(3);
// When
alarmEvaluator.sendAndProcessWindowData(windowData);
// Wait for async processing
Thread.sleep(2000);
// Then - should not process any alerts due to insufficient count
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
verify(alarmCommonReduce, never()).reduceAndSendAlarmGroup(anyMap(), anyList());
}
@Test
void testIndividualAlertGeneration() throws InterruptedException {
// Given
alertDefine.setLabels(Map.of(CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_INDIVIDUAL, CommonConstants.LABEL_ALERT_SEVERITY, CommonConstants.ALERT_SEVERITY_CRITICAL));
alertDefine.setAnnotations(Map.of("summary", "Test summary", "description", "Test description"));
// When
alarmEvaluator.sendAndProcessWindowData(windowData);
// Wait for async processing
Thread.sleep(2000);
// Then
ArgumentCaptor<SingleAlert> alertCaptor = ArgumentCaptor.forClass(SingleAlert.class);
verify(alarmCommonReduce).reduceAndSendAlarm(alertCaptor.capture());
SingleAlert capturedAlert = alertCaptor.getValue();
assertAll(
() -> assertNotNull(capturedAlert),
() -> assertEquals(CommonConstants.ALERT_STATUS_FIRING, capturedAlert.getStatus()),
() -> assertEquals(1, capturedAlert.getTriggerTimes()),
() -> assertNotNull(capturedAlert.getContent()),
() -> assertNotNull(capturedAlert.getLabels()),
() -> assertNotNull(capturedAlert.getAnnotations()),
() -> assertTrue(capturedAlert.getStartAt() > 0),
() -> assertTrue(capturedAlert.getActiveAt() > 0)
);
// Verify labels contain alert define info and log entry data
Map<String, String> labels = capturedAlert.getLabels();
assertTrue(labels.containsKey(CommonConstants.LABEL_ALERT_SEVERITY));
assertTrue(labels.containsKey("severityText"));
assertTrue(labels.containsKey("body"));
}
@Test
void testGroupAlertGeneration() throws InterruptedException {
// Given
alertDefine.setLabels(Map.of(CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_GROUP, CommonConstants.LABEL_ALERT_SEVERITY, CommonConstants.ALERT_SEVERITY_WARNING));
// Add more matching logs to test group functionality
MatchingLogEvent secondEvent = MatchingLogEvent.builder()
.logEntry(LogEntry.builder()
.timeUnixNano((System.currentTimeMillis() + 1000) * 1_000_000L)
.severityText("ERROR")
.body("Second error message")
.build())
.alertDefine(alertDefine)
.eventTimestamp(System.currentTimeMillis() + 1000)
.workerTimestamp(System.currentTimeMillis() + 1000)
.build();
windowData.addMatchingLog(secondEvent);
// When
alarmEvaluator.sendAndProcessWindowData(windowData);
// Wait for async processing
Thread.sleep(2000);
// Then
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> labelsCaptor = ArgumentCaptor.forClass(Map.class);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<SingleAlert>> alertsCaptor = ArgumentCaptor.forClass(List.class);
verify(alarmCommonReduce).reduceAndSendAlarmGroup(labelsCaptor.capture(), alertsCaptor.capture());
Map<String, String> groupLabels = labelsCaptor.getValue();
List<SingleAlert> alerts = alertsCaptor.getValue();
assertAll(
() -> assertNotNull(groupLabels),
() -> assertNotNull(alerts),
() -> assertEquals(2, alerts.size()),
() -> assertTrue(groupLabels.containsKey("window_start_time")),
() -> assertTrue(groupLabels.containsKey("window_end_time")),
() -> assertTrue(groupLabels.containsKey("matching_logs_count")),
() -> assertEquals(CommonConstants.ALERT_MODE_GROUP, groupLabels.get(CommonConstants.ALERT_MODE_LABEL)),
() -> assertEquals("2", groupLabels.get("matching_logs_count"))
);
// Verify each alert in the group
for (SingleAlert alert : alerts) {
assertEquals(CommonConstants.ALERT_STATUS_FIRING, alert.getStatus());
assertEquals(2, alert.getTriggerTimes()); // Should equal the count of matching logs
}
}
@Test
void testLogEntryDataMapping() throws InterruptedException {
// Given - log entry with comprehensive data
LogEntry detailedLogEntry = LogEntry.builder()
.timeUnixNano(System.currentTimeMillis() * 1_000_000L)
.severityNumber(17) // ERROR level
.severityText("ERROR")
.body("Detailed error message")
.attributes(Map.of("service", "api-service", "version", "1.0.0"))
.resource(Map.of("cluster", "prod-cluster", "namespace", "default"))
.droppedAttributesCount(0)
.traceId("trace123")
.spanId("span456")
.traceFlags(1)
.build();
MatchingLogEvent detailedEvent = MatchingLogEvent.builder()
.logEntry(detailedLogEntry)
.alertDefine(alertDefine)
.eventTimestamp(System.currentTimeMillis())
.workerTimestamp(System.currentTimeMillis())
.build();
WindowAggregator.WindowData detailedWindowData = new WindowAggregator.WindowData(
new WindowAggregator.WindowKey(1L, System.currentTimeMillis() - 60000, System.currentTimeMillis()),
alertDefine);
detailedWindowData.addMatchingLog(detailedEvent);
alertDefine.setLabels(Map.of(CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_INDIVIDUAL));
// When
alarmEvaluator.sendAndProcessWindowData(detailedWindowData);
Thread.sleep(2000);
// Then
ArgumentCaptor<SingleAlert> alertCaptor = ArgumentCaptor.forClass(SingleAlert.class);
verify(alarmCommonReduce).reduceAndSendAlarm(alertCaptor.capture());
SingleAlert alert = alertCaptor.getValue();
Map<String, String> labels = alert.getLabels();
// Verify log entry fields are mapped to labels
assertAll(
() -> assertEquals("17", labels.get("severityNumber")),
() -> assertEquals("ERROR", labels.get("severityText")),
() -> assertEquals("Detailed error message", labels.get("body")),
() -> assertEquals("0", labels.get("droppedAttributesCount")),
() -> assertEquals("trace123", labels.get("traceId")),
() -> assertEquals("span456", labels.get("spanId")),
() -> assertEquals("1", labels.get("traceFlags")),
() -> assertEquals("api-service", labels.get("attr_service")),
() -> assertEquals("1.0.0", labels.get("attr_version")),
() -> assertEquals("prod-cluster", labels.get("resource_cluster")),
() -> assertEquals("default", labels.get("resource_namespace"))
);
}
@Test
void testMultipleMatchingLogsInWindow() throws InterruptedException {
// Given - multiple matching logs in the same window
alertDefine.setTimes(2); // Require at least 2 occurrences
alertDefine.setLabels(Map.of(CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_GROUP));
// Add second matching log
MatchingLogEvent secondEvent = MatchingLogEvent.builder()
.logEntry(LogEntry.builder()
.severityText("ERROR")
.body("Another error")
.build())
.alertDefine(alertDefine)
.eventTimestamp(System.currentTimeMillis() + 5000)
.workerTimestamp(System.currentTimeMillis() + 5000)
.build();
windowData.addMatchingLog(secondEvent);
// When
alarmEvaluator.sendAndProcessWindowData(windowData);
Thread.sleep(2000);
// Then - should trigger group alert since threshold is met
verify(alarmCommonReduce).reduceAndSendAlarmGroup(anyMap(), anyList());
@SuppressWarnings("unchecked")
ArgumentCaptor<List<SingleAlert>> alertsCaptor = ArgumentCaptor.forClass(List.class);
verify(alarmCommonReduce).reduceAndSendAlarmGroup(anyMap(), alertsCaptor.capture());
List<SingleAlert> alerts = alertsCaptor.getValue();
assertEquals(2, alerts.size());
assertEquals(2, alerts.get(0).getTriggerTimes()); // Each alert should have trigger times = total count
}
}
@@ -0,0 +1,239 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate.realtime.window;
import org.apache.hertzbeat.alert.AlerterWorkerPool;
import org.apache.hertzbeat.alert.calculate.JexlExprCalculator;
import org.apache.hertzbeat.alert.service.AlertDefineService;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Test case for {@link LogWorker}
*/
@ExtendWith(MockitoExtension.class)
class LogWorkerTest {
@Mock
private AlertDefineService alertDefineService;
@Mock
private JexlExprCalculator jexlExprCalculator;
@Mock
private WindowAggregator windowAggregator;
@Mock
private AlerterWorkerPool workerPool;
@InjectMocks
private LogWorker logWorker;
private LogEntry logEntry;
private AlertDefine alertDefine;
private List<AlertDefine> alertDefines;
@BeforeEach
void setUp() {
// Create test log entry
logEntry = LogEntry.builder()
.timeUnixNano(System.currentTimeMillis() * 1_000_000L)
.severityText("ERROR")
.body("Test error message")
.build();
// Create test alert define
alertDefine = AlertDefine.builder()
.id(1L)
.name("test_log_alert")
.type("realtime_log")
.expr("log.severityText == 'ERROR'")
.enable(true)
.build();
alertDefines = new ArrayList<>();
alertDefines.add(alertDefine);
}
@Test
void testReduceAndSendLogTask() {
// When
logWorker.reduceAndSendLogTask(logEntry);
// Then
verify(workerPool).executeLogJob(any(Runnable.class));
}
@Test
void testReduceLogTask() {
// Given
when(alertDefineService.getLogRealTimeAlertDefines()).thenReturn(alertDefines);
when(jexlExprCalculator.execAlertExpression(anyMap(), anyString(), eq(false))).thenReturn(true);
// When
Runnable task = logWorker.reduceLogTask(logEntry);
// Execute the task
task.run();
// Then
verify(alertDefineService).getLogRealTimeAlertDefines();
verify(jexlExprCalculator).execAlertExpression(anyMap(), eq("log.severityText == 'ERROR'"), eq(false));
verify(windowAggregator).addMatchingLog(any(MatchingLogEvent.class));
}
@Test
void testProcessLogEntryWithMatchingExpression() {
// Given
when(alertDefineService.getLogRealTimeAlertDefines()).thenReturn(alertDefines);
when(jexlExprCalculator.execAlertExpression(anyMap(), anyString(), eq(false))).thenReturn(true);
// When
Runnable task = logWorker.reduceLogTask(logEntry);
task.run();
// Then
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> contextCaptor = ArgumentCaptor.forClass(Map.class);
verify(jexlExprCalculator).execAlertExpression(contextCaptor.capture(), eq("log.severityText == 'ERROR'"), eq(false));
Map<String, Object> context = contextCaptor.getValue();
assertEquals(logEntry, context.get("log"));
ArgumentCaptor<MatchingLogEvent> eventCaptor = ArgumentCaptor.forClass(MatchingLogEvent.class);
verify(windowAggregator).addMatchingLog(eventCaptor.capture());
MatchingLogEvent capturedEvent = eventCaptor.getValue();
assertEquals(logEntry, capturedEvent.getLogEntry());
assertEquals(alertDefine, capturedEvent.getAlertDefine());
assertTrue(capturedEvent.getEventTimestamp() > 0);
assertTrue(capturedEvent.getWorkerTimestamp() > 0);
}
@Test
void testProcessLogEntryWithNonMatchingExpression() {
// Given
when(alertDefineService.getLogRealTimeAlertDefines()).thenReturn(alertDefines);
when(jexlExprCalculator.execAlertExpression(anyMap(), anyString(), eq(false))).thenReturn(false);
// When
Runnable task = logWorker.reduceLogTask(logEntry);
task.run();
// Then
verify(jexlExprCalculator).execAlertExpression(anyMap(), eq("log.severityText == 'ERROR'"), eq(false));
verify(windowAggregator, never()).addMatchingLog(any(MatchingLogEvent.class));
}
@Test
void testProcessLogEntryWithMultipleAlertDefines() {
// Given
AlertDefine secondDefine = AlertDefine.builder()
.id(2L)
.name("second_alert")
.type("realtime_log")
.expr("log.body != null")
.enable(true)
.build();
List<AlertDefine> multipleDefines = List.of(alertDefine, secondDefine);
when(alertDefineService.getLogRealTimeAlertDefines()).thenReturn(multipleDefines);
when(jexlExprCalculator.execAlertExpression(anyMap(), eq("log.severityText == 'ERROR'"), eq(false))).thenReturn(true);
when(jexlExprCalculator.execAlertExpression(anyMap(), eq("log.body != null"), eq(false))).thenReturn(true);
// When
Runnable task = logWorker.reduceLogTask(logEntry);
task.run();
// Then
verify(jexlExprCalculator).execAlertExpression(anyMap(), eq("log.severityText == 'ERROR'"), eq(false));
verify(jexlExprCalculator).execAlertExpression(anyMap(), eq("log.body != null"), eq(false));
verify(windowAggregator, times(2)).addMatchingLog(any(MatchingLogEvent.class));
}
@Test
void testCreateMatchingLogEventStructure() {
// Given
when(alertDefineService.getLogRealTimeAlertDefines()).thenReturn(alertDefines);
when(jexlExprCalculator.execAlertExpression(anyMap(), anyString(), eq(false))).thenReturn(true);
// When
Runnable task = logWorker.reduceLogTask(logEntry);
task.run();
// Then
ArgumentCaptor<MatchingLogEvent> eventCaptor = ArgumentCaptor.forClass(MatchingLogEvent.class);
verify(windowAggregator).addMatchingLog(eventCaptor.capture());
MatchingLogEvent event = eventCaptor.getValue();
assertAll(
() -> assertNotNull(event.getLogEntry()),
() -> assertNotNull(event.getAlertDefine()),
() -> assertTrue(event.getEventTimestamp() > 0),
() -> assertTrue(event.getWorkerTimestamp() > 0),
() -> assertEquals(logEntry, event.getLogEntry()),
() -> assertEquals(alertDefine, event.getAlertDefine()),
() -> assertTrue(event.getWorkerTimestamp() >= event.getEventTimestamp() - 1000)
);
}
@Test
void testLogPrefixInContext() {
// Given
when(alertDefineService.getLogRealTimeAlertDefines()).thenReturn(alertDefines);
when(jexlExprCalculator.execAlertExpression(anyMap(), anyString(), eq(false))).thenReturn(false);
// When
Runnable task = logWorker.reduceLogTask(logEntry);
task.run();
// Then
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> contextCaptor = ArgumentCaptor.forClass(Map.class);
verify(jexlExprCalculator).execAlertExpression(contextCaptor.capture(), anyString(), eq(false));
Map<String, Object> context = contextCaptor.getValue();
assertTrue(context.containsKey("log"));
assertEquals(logEntry, context.get("log"));
}
}
@@ -0,0 +1,291 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate.realtime.window;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Test case for {@link TimeService}
*/
@ExtendWith(MockitoExtension.class)
class TimeServiceTest {
@Mock
private TimeService.WatermarkListener mockListener1;
@Mock
private TimeService.WatermarkListener mockListener2;
private TimeService timeService;
@BeforeEach
void setUp() {
timeService = new TimeService(Collections.emptyList());
}
@AfterEach
void tearDown() {
if (timeService != null) {
timeService.stop();
}
}
@Test
void testIsValidTimestamp() {
// Given
long currentTime = System.currentTimeMillis();
long validFutureTime = currentTime + 30_000; // 30 seconds in future
long validPastTime = currentTime - 60_000; // 1 minute in past
long invalidFutureTime = currentTime + 120_000; // 2 minutes in future (exceeds 1 minute limit)
long invalidPastTime = currentTime - 25 * 60 * 60 * 1000; // 25 hours in past (exceeds 24 hours limit)
// When & Then
assertAll(
() -> assertTrue(timeService.isValidTimestamp(currentTime)),
() -> assertTrue(timeService.isValidTimestamp(validFutureTime)),
() -> assertTrue(timeService.isValidTimestamp(validPastTime)),
() -> assertFalse(timeService.isValidTimestamp(invalidFutureTime)),
() -> assertFalse(timeService.isValidTimestamp(invalidPastTime))
);
}
@Test
void testIsLateData() {
// Given
long currentTime = System.currentTimeMillis();
timeService.updateMaxTimestamp(currentTime);
timeService.start();
// Wait for watermark to be set
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// When & Then
long currentWatermark = timeService.getCurrentWatermark();
if (currentWatermark > 0) {
assertTrue(timeService.isLateData(currentWatermark - 1000));
assertFalse(timeService.isLateData(currentWatermark + 1000));
}
}
@Test
void testUpdateMaxTimestamp() {
// Given
long timestamp1 = 1000L;
long timestamp2 = 2000L;
long timestamp3 = 1500L;
// When
timeService.updateMaxTimestamp(timestamp1);
timeService.updateMaxTimestamp(timestamp2);
timeService.updateMaxTimestamp(timestamp3);
timeService.start();
// Wait for potential watermark broadcast
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Then - The max timestamp should be the highest value (timestamp2)
assertEquals(timestamp2, timeService.getMaxTimestamp());
}
@Test
void testWatermarkBroadcastWithRealTimestamps() throws InterruptedException {
// Given
CountDownLatch latch = new CountDownLatch(1);
AtomicLong receivedWatermark = new AtomicLong();
TimeService.WatermarkListener testListener = watermark -> {
receivedWatermark.set(watermark.getTimestamp());
latch.countDown();
};
timeService.addWatermarkListener(testListener);
timeService.start();
// When
long testTimestamp = System.currentTimeMillis();
timeService.updateMaxTimestamp(testTimestamp);
// Then
assertTrue(latch.await(10, TimeUnit.SECONDS), "Should receive watermark within 10 seconds");
long watermark = receivedWatermark.get();
long expectedWatermark = testTimestamp - TimeService.DEFAULT_WATERMARK_DELAY_MS; // DEFAULT_WATERMARK_DELAY_MS
assertEquals(expectedWatermark, watermark);
}
@Test
void testWatermarkMonotonicProperty() throws InterruptedException {
// Given
List<Long> receivedWatermarks = Collections.synchronizedList(new ArrayList<>());
CountDownLatch latch = new CountDownLatch(2);
TimeService.WatermarkListener testListener = watermark -> {
receivedWatermarks.add(watermark.getTimestamp());
latch.countDown();
};
timeService.addWatermarkListener(testListener);
timeService.start();
// When - Update with increasing timestamps
long baseTime = System.currentTimeMillis();
timeService.updateMaxTimestamp(baseTime);
Thread.sleep(6000); // Wait for first watermark
timeService.updateMaxTimestamp(baseTime + 40_000); // Increase by 40 seconds
// Then
assertTrue(latch.await(15, TimeUnit.SECONDS), "Should receive at least 2 watermarks");
// Verify watermarks are monotonic (non-decreasing)
for (int i = 1; i < receivedWatermarks.size(); i++) {
assertTrue(receivedWatermarks.get(i) >= receivedWatermarks.get(i - 1),
"Watermarks should be monotonic: " + receivedWatermarks);
}
}
@Test
void testListenerExceptionHandling() throws InterruptedException {
// Given
AtomicInteger successfulCalls = new AtomicInteger(0);
CountDownLatch latch = new CountDownLatch(1);
// Mock listener that throws exception
TimeService.WatermarkListener faultyListener = watermark -> {
throw new RuntimeException("Test exception");
};
// Mock listener that works normally
TimeService.WatermarkListener normalListener = watermark -> {
successfulCalls.incrementAndGet();
latch.countDown();
};
timeService.addWatermarkListener(faultyListener);
timeService.addWatermarkListener(normalListener);
timeService.start();
// When
timeService.updateMaxTimestamp(System.currentTimeMillis());
// Then - Normal listener should still work despite faulty listener
assertTrue(latch.await(10, TimeUnit.SECONDS), "Normal listener should receive watermark");
assertTrue(successfulCalls.get() > 0, "Normal listener should be called successfully");
}
@Test
void testGetCurrentWatermark() {
// Given
timeService.start();
// Initially watermark should be 0
assertEquals(0, timeService.getCurrentWatermark());
// When
long testTimestamp = System.currentTimeMillis();
timeService.updateMaxTimestamp(testTimestamp);
// Wait for watermark update
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Then
long watermark = timeService.getCurrentWatermark();
assertEquals(testTimestamp - TimeService.DEFAULT_WATERMARK_DELAY_MS, watermark);
}
@Test
void testZeroTimestampHandling() throws InterruptedException {
// Given
CountDownLatch latch = new CountDownLatch(1);
AtomicInteger callCount = new AtomicInteger(0);
TimeService.WatermarkListener testListener = watermark -> {
callCount.incrementAndGet();
latch.countDown();
};
timeService.addWatermarkListener(testListener);
timeService.start();
// When - Update with zero timestamp
timeService.updateMaxTimestamp(0);
// Wait for potential watermark broadcast
boolean received = latch.await(8, TimeUnit.SECONDS);
// Then - No watermark should be broadcast for zero timestamp
assertFalse(received, "No watermark should be broadcast for zero timestamp");
assertEquals(0, callCount.get());
}
@Test
void testNegativeTimestampHandling() throws InterruptedException {
// Given
CountDownLatch latch = new CountDownLatch(1);
AtomicInteger callCount = new AtomicInteger(0);
TimeService.WatermarkListener testListener = watermark -> {
callCount.incrementAndGet();
latch.countDown();
};
timeService.addWatermarkListener(testListener);
timeService.start();
// When - Update with negative timestamp
timeService.updateMaxTimestamp(-1000L);
// Wait for potential watermark broadcast
boolean received = latch.await(8, TimeUnit.SECONDS);
// Then - No watermark should be broadcast for negative timestamp
assertFalse(received, "No watermark should be broadcast for negative timestamp");
assertEquals(0, callCount.get());
}
}
@@ -0,0 +1,239 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.calculate.realtime.window;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
/**
* Test case for {@link WindowAggregator}
*/
@ExtendWith(MockitoExtension.class)
class WindowAggregatorTest {
@Mock
private AlarmEvaluator alarmEvaluator;
@InjectMocks
private WindowAggregator windowAggregator;
private AlertDefine alertDefine;
private LogEntry logEntry;
@BeforeEach
void setUp() {
// Create test log entry
logEntry = LogEntry.builder()
.timeUnixNano(System.currentTimeMillis() * 1_000_000L)
.severityText("ERROR")
.body("Test error message")
.attributes(Map.of("service", "test-service"))
.build();
// Create test alert define
alertDefine = AlertDefine.builder()
.id(1L)
.name("test_log_alert")
.type("realtime_log")
.expr("log.severityText == 'ERROR'")
.period(60) // 60 seconds window
.times(1)
.enable(true)
.build();
// Stop the default aggregator to avoid conflicts
windowAggregator.stop();
}
@AfterEach
void tearDown() {
if (windowAggregator != null) {
windowAggregator.stop();
}
}
@Test
void testWatermarkProcessing() throws InterruptedException {
// Given - add a matching log event first
long eventTime = System.currentTimeMillis();
long windowSize = 60 * 1000; // 60 seconds
long windowStart = (eventTime / windowSize) * windowSize;
long windowEnd = windowStart + windowSize;
MatchingLogEvent testEvent = MatchingLogEvent.builder()
.logEntry(logEntry)
.alertDefine(alertDefine)
.eventTimestamp(eventTime)
.workerTimestamp(eventTime)
.build();
windowAggregator.start();
windowAggregator.addMatchingLog(testEvent);
Thread.sleep(100); // Allow processing
// When - send watermark that should close the window
TimeService.Watermark watermark = new TimeService.Watermark(windowEnd + 1000);
windowAggregator.onWatermark(watermark);
// Then - the window should be closed and sent to AlarmEvaluator
verify(alarmEvaluator, timeout(1000)).sendAndProcessWindowData(any(WindowAggregator.WindowData.class));
}
@Test
void testWatermarkNotClosingActiveWindow() throws InterruptedException {
// Given - add a matching log event
long eventTime = System.currentTimeMillis();
MatchingLogEvent testEvent = MatchingLogEvent.builder()
.logEntry(logEntry)
.alertDefine(alertDefine)
.eventTimestamp(eventTime)
.workerTimestamp(eventTime)
.build();
windowAggregator.start();
windowAggregator.addMatchingLog(testEvent);
Thread.sleep(100);
// When - send watermark that should NOT close the window (watermark before window end)
TimeService.Watermark earlyWatermark = new TimeService.Watermark(eventTime - 1000);
windowAggregator.onWatermark(earlyWatermark);
// Give some time for potential processing
Thread.sleep(100);
// Then - no window should be closed
verify(alarmEvaluator, never()).sendAndProcessWindowData(any(WindowAggregator.WindowData.class));
}
@Test
void testMultipleWindowsWithDifferentAlertDefines() throws InterruptedException {
// Given - two different alert defines
AlertDefine alertDefine2 = AlertDefine.builder()
.id(2L)
.name("second_alert")
.type("realtime_log")
.expr("log.severityText == 'WARN'")
.period(60)
.times(1)
.enable(true)
.build();
long eventTime = System.currentTimeMillis();
MatchingLogEvent event1 = MatchingLogEvent.builder()
.logEntry(logEntry)
.alertDefine(alertDefine)
.eventTimestamp(eventTime)
.workerTimestamp(eventTime)
.build();
MatchingLogEvent event2 = MatchingLogEvent.builder()
.logEntry(logEntry)
.alertDefine(alertDefine2)
.eventTimestamp(eventTime)
.workerTimestamp(eventTime)
.build();
// When
windowAggregator.start();
windowAggregator.addMatchingLog(event1);
windowAggregator.addMatchingLog(event2);
Thread.sleep(100);
// Send watermark to close both windows
long windowSize = 60 * 1000;
long windowStart = (eventTime / windowSize) * windowSize;
long windowEnd = windowStart + windowSize;
TimeService.Watermark watermark = new TimeService.Watermark(windowEnd + 1000);
windowAggregator.onWatermark(watermark);
// Then - both windows should be closed
verify(alarmEvaluator, timeout(1000).times(2)).sendAndProcessWindowData(any(WindowAggregator.WindowData.class));
}
@Test
void testMultipleStartCalls() {
// When - call start multiple times
windowAggregator.start(); // First call (already called in setUp)
windowAggregator.start(); // Second call
// Then - should handle multiple start calls gracefully
assertNotNull(windowAggregator);
}
@Test
void testWatermarkWithMultipleWindows() throws InterruptedException {
// Given - multiple windows at different times
long baseTime = System.currentTimeMillis();
long windowSize = 60 * 1000; // 60 seconds
// Event in first window
MatchingLogEvent event1 = MatchingLogEvent.builder()
.logEntry(logEntry)
.alertDefine(alertDefine)
.eventTimestamp(baseTime)
.workerTimestamp(baseTime)
.build();
// Event in second window (1 minute later)
MatchingLogEvent event2 = MatchingLogEvent.builder()
.logEntry(logEntry)
.alertDefine(alertDefine)
.eventTimestamp(baseTime + windowSize + 5000)
.workerTimestamp(baseTime + windowSize + 5000)
.build();
windowAggregator.start();
windowAggregator.addMatchingLog(event1);
windowAggregator.addMatchingLog(event2);
Thread.sleep(100);
// When - send watermark that closes only the first window
long firstWindowStart = (baseTime / windowSize) * windowSize;
long firstWindowEnd = firstWindowStart + windowSize;
TimeService.Watermark watermark = new TimeService.Watermark(firstWindowEnd + 1000);
windowAggregator.onWatermark(watermark);
// Then - only first window should be closed
verify(alarmEvaluator, timeout(1000).times(1)).sendAndProcessWindowData(any(WindowAggregator.WindowData.class));
// When - send watermark that closes the second window
long secondWindowStart = ((baseTime + windowSize + 5000) / windowSize) * windowSize;
long secondWindowEnd = secondWindowStart + windowSize;
TimeService.Watermark secondWatermark = new TimeService.Watermark(secondWindowEnd + 1000);
windowAggregator.onWatermark(secondWatermark);
// Then - second window should also be closed (total 2 windows)
verify(alarmEvaluator, timeout(1000).times(2)).sendAndProcessWindowData(any(WindowAggregator.WindowData.class));
}
}
@@ -18,15 +18,18 @@
package org.apache.hertzbeat.alert.service;
import com.google.common.collect.Lists;
import org.apache.hertzbeat.alert.calculate.PeriodicAlertRuleScheduler;
import org.apache.hertzbeat.alert.calculate.periodic.PeriodicAlertRuleScheduler;
import org.apache.hertzbeat.alert.dao.AlertDefineDao;
import org.apache.hertzbeat.alert.service.impl.AlertDefineServiceImpl;
import org.apache.hertzbeat.common.cache.CacheFactory;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -39,8 +42,10 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.apache.hertzbeat.common.constants.CommonConstants.ALERT_THRESHOLD_TYPE_PERIODIC;
import static org.apache.hertzbeat.common.constants.CommonConstants.ALERT_THRESHOLD_TYPE_REALTIME;
import static org.apache.hertzbeat.common.constants.CommonConstants.LOG_ALERT_THRESHOLD_TYPE_PERIODIC;
import static org.apache.hertzbeat.common.constants.CommonConstants.LOG_ALERT_THRESHOLD_TYPE_REALTIME;
import static org.apache.hertzbeat.common.constants.CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_PERIODIC;
import static org.apache.hertzbeat.common.constants.CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_REALTIME;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -164,15 +169,171 @@ class AlertDefineServiceTest {
}
};
when(dataSourceService.calculate(eq("promql"), eq(expr))).thenReturn(Lists.newArrayList(countValue1));
List<Map<String, Object>> result = alertDefineService.getDefinePreview("promql", ALERT_THRESHOLD_TYPE_PERIODIC, expr);
List<Map<String, Object>> result = alertDefineService.getDefinePreview("promql", METRIC_ALERT_THRESHOLD_TYPE_PERIODIC, expr);
assertNotNull(result);
assertEquals(1307, result.get(0).get("__value__"));
result = alertDefineService.getDefinePreview("promql", ALERT_THRESHOLD_TYPE_PERIODIC, null);
result = alertDefineService.getDefinePreview("promql", METRIC_ALERT_THRESHOLD_TYPE_PERIODIC, null);
assertEquals(0, result.size());
result = alertDefineService.getDefinePreview("promql", ALERT_THRESHOLD_TYPE_REALTIME, null);
result = alertDefineService.getDefinePreview("promql", METRIC_ALERT_THRESHOLD_TYPE_REALTIME, null);
assertEquals(0, result.size());
}
@Test
void getMetricsRealTimeAlertDefines() {
// Test cache hit scenario
List<AlertDefine> cachedAlertDefines = Lists.newArrayList(
AlertDefine.builder().id(1L).type(METRIC_ALERT_THRESHOLD_TYPE_REALTIME).enable(true).build(),
AlertDefine.builder().id(2L).type(METRIC_ALERT_THRESHOLD_TYPE_REALTIME).enable(true).build()
);
try (MockedStatic<CacheFactory> cacheFactoryMock = Mockito.mockStatic(CacheFactory.class)) {
// Mock cache hit
cacheFactoryMock.when(CacheFactory::getMetricsAlertDefineCache).thenReturn(cachedAlertDefines);
List<AlertDefine> result = alertDefineService.getMetricsRealTimeAlertDefines();
assertNotNull(result);
assertEquals(2, result.size());
assertEquals(1L, result.get(0).getId());
assertEquals(2L, result.get(1).getId());
// Verify no database query was called
verify(alertDefineDao, times(0)).findAlertDefinesByTypeAndEnableTrue(any());
cacheFactoryMock.verify(() -> CacheFactory.setMetricsAlertDefineCache(any()), times(0));
}
// Test cache miss scenario
List<AlertDefine> dbAlertDefines = Lists.newArrayList(
AlertDefine.builder().id(3L).type(METRIC_ALERT_THRESHOLD_TYPE_REALTIME).enable(true).build()
);
try (MockedStatic<CacheFactory> cacheFactoryMock = Mockito.mockStatic(CacheFactory.class)) {
// Mock cache miss
cacheFactoryMock.when(CacheFactory::getMetricsAlertDefineCache).thenReturn(null);
when(alertDefineDao.findAlertDefinesByTypeAndEnableTrue(METRIC_ALERT_THRESHOLD_TYPE_REALTIME))
.thenReturn(dbAlertDefines);
List<AlertDefine> result = alertDefineService.getMetricsRealTimeAlertDefines();
assertNotNull(result);
assertEquals(1, result.size());
assertEquals(3L, result.get(0).getId());
// Verify database query and cache setting were called
verify(alertDefineDao, times(1)).findAlertDefinesByTypeAndEnableTrue(METRIC_ALERT_THRESHOLD_TYPE_REALTIME);
cacheFactoryMock.verify(() -> CacheFactory.setMetricsAlertDefineCache(dbAlertDefines), times(1));
}
}
@Test
void getLogRealTimeAlertDefines() {
// Test cache hit scenario
List<AlertDefine> cachedAlertDefines = Lists.newArrayList(
AlertDefine.builder().id(4L).type(LOG_ALERT_THRESHOLD_TYPE_REALTIME).enable(true).build(),
AlertDefine.builder().id(5L).type(LOG_ALERT_THRESHOLD_TYPE_REALTIME).enable(true).build()
);
try (MockedStatic<CacheFactory> cacheFactoryMock = Mockito.mockStatic(CacheFactory.class)) {
// Mock cache hit
cacheFactoryMock.when(CacheFactory::getLogAlertDefineCache).thenReturn(cachedAlertDefines);
List<AlertDefine> result = alertDefineService.getLogRealTimeAlertDefines();
assertNotNull(result);
assertEquals(2, result.size());
assertEquals(4L, result.get(0).getId());
assertEquals(5L, result.get(1).getId());
// Verify no database query was called
verify(alertDefineDao, times(0)).findAlertDefinesByTypeAndEnableTrue(any());
cacheFactoryMock.verify(() -> CacheFactory.setLogAlertDefineCache(any()), times(0));
}
// Test cache miss scenario
List<AlertDefine> dbAlertDefines = Lists.newArrayList(
AlertDefine.builder().id(6L).type(LOG_ALERT_THRESHOLD_TYPE_REALTIME).enable(true).build()
);
try (MockedStatic<CacheFactory> cacheFactoryMock = Mockito.mockStatic(CacheFactory.class)) {
// Mock cache miss
cacheFactoryMock.when(CacheFactory::getLogAlertDefineCache).thenReturn(null);
when(alertDefineDao.findAlertDefinesByTypeAndEnableTrue(LOG_ALERT_THRESHOLD_TYPE_REALTIME))
.thenReturn(dbAlertDefines);
List<AlertDefine> result = alertDefineService.getLogRealTimeAlertDefines();
assertNotNull(result);
assertEquals(1, result.size());
assertEquals(6L, result.get(0).getId());
// Verify database query and cache setting were called
verify(alertDefineDao, times(1)).findAlertDefinesByTypeAndEnableTrue(LOG_ALERT_THRESHOLD_TYPE_REALTIME);
cacheFactoryMock.verify(() -> CacheFactory.setLogAlertDefineCache(dbAlertDefines), times(1));
}
}
@Test
void getAlertDefinesByType() {
List<AlertDefine> mockAlertDefines = Lists.newArrayList(
AlertDefine.builder().id(7L).type(METRIC_ALERT_THRESHOLD_TYPE_REALTIME).enable(true).build(),
AlertDefine.builder().id(8L).type(METRIC_ALERT_THRESHOLD_TYPE_REALTIME).enable(true).build()
);
// Test valid metric realtime alert type
when(alertDefineDao.findAlertDefinesByTypeAndEnableTrue(METRIC_ALERT_THRESHOLD_TYPE_REALTIME))
.thenReturn(mockAlertDefines);
List<AlertDefine> result = alertDefineService.getAlertDefinesByType(METRIC_ALERT_THRESHOLD_TYPE_REALTIME);
assertNotNull(result);
assertEquals(2, result.size());
assertEquals(7L, result.get(0).getId());
assertEquals(8L, result.get(1).getId());
verify(alertDefineDao, times(1)).findAlertDefinesByTypeAndEnableTrue(METRIC_ALERT_THRESHOLD_TYPE_REALTIME);
// Test valid metric periodic alert type
reset(alertDefineDao);
when(alertDefineDao.findAlertDefinesByTypeAndEnableTrue(METRIC_ALERT_THRESHOLD_TYPE_PERIODIC))
.thenReturn(Lists.newArrayList());
result = alertDefineService.getAlertDefinesByType(METRIC_ALERT_THRESHOLD_TYPE_PERIODIC);
assertNotNull(result);
assertEquals(0, result.size());
verify(alertDefineDao, times(1)).findAlertDefinesByTypeAndEnableTrue(METRIC_ALERT_THRESHOLD_TYPE_PERIODIC);
// Test valid log realtime alert type
reset(alertDefineDao);
when(alertDefineDao.findAlertDefinesByTypeAndEnableTrue(LOG_ALERT_THRESHOLD_TYPE_REALTIME))
.thenReturn(mockAlertDefines);
result = alertDefineService.getAlertDefinesByType(LOG_ALERT_THRESHOLD_TYPE_REALTIME);
assertNotNull(result);
assertEquals(2, result.size());
verify(alertDefineDao, times(1)).findAlertDefinesByTypeAndEnableTrue(LOG_ALERT_THRESHOLD_TYPE_REALTIME);
// Test valid log periodic alert type
reset(alertDefineDao);
when(alertDefineDao.findAlertDefinesByTypeAndEnableTrue(LOG_ALERT_THRESHOLD_TYPE_PERIODIC))
.thenReturn(Lists.newArrayList());
result = alertDefineService.getAlertDefinesByType(LOG_ALERT_THRESHOLD_TYPE_PERIODIC);
assertNotNull(result);
assertEquals(0, result.size());
verify(alertDefineDao, times(1)).findAlertDefinesByTypeAndEnableTrue(LOG_ALERT_THRESHOLD_TYPE_PERIODIC);
// Test empty string type
assertThrows(IllegalArgumentException.class, () -> alertDefineService.getAlertDefinesByType(""));
// Test null type
assertThrows(IllegalArgumentException.class, () -> alertDefineService.getAlertDefinesByType(null));
// Test invalid type
assertThrows(IllegalArgumentException.class, () -> alertDefineService.getAlertDefinesByType("invalid_type"));
}
}
@@ -87,4 +87,199 @@ class AlertTemplateUtilTest {
String expectedResult = "The price is $100 and the path is C:\\Users";
assertEquals(expectedResult, AlertTemplateUtil.render(template, param));
}
@Test
void renderNestedMapProperties() {
Map<String, Object> param = new HashMap<>();
Map<String, Object> log = new HashMap<>();
Map<String, Object> attributes = new HashMap<>();
attributes.put("level", "ERROR");
attributes.put("thread", "main");
log.put("attributes", attributes);
log.put("message", "Connection failed");
log.put("timestamp", "2024-01-01T10:00:00Z");
param.put("log", log);
param.put("instance", "server-01");
String template = "Log alert: ${log.attributes.level} - ${log.message} on ${instance}";
String expectedResult = "Log alert: ERROR - Connection failed on server-01";
assertEquals(expectedResult, AlertTemplateUtil.render(template, param));
}
@Test
void renderMultiLevelNestedProperties() {
Map<String, Object> param = new HashMap<>();
Map<String, Object> config = new HashMap<>();
Map<String, Object> database = new HashMap<>();
Map<String, Object> connection = new HashMap<>();
connection.put("host", "localhost");
connection.put("port", 3306);
database.put("connection", connection);
database.put("name", "hertzbeat");
config.put("database", database);
param.put("config", config);
String template = "Database: ${config.database.name} at ${config.database.connection.host}:${config.database.connection.port}";
String expectedResult = "Database: hertzbeat at localhost:3306";
assertEquals(expectedResult, AlertTemplateUtil.render(template, param));
}
@Test
void renderNonExistentNestedProperties() {
Map<String, Object> param = new HashMap<>();
Map<String, Object> log = new HashMap<>();
log.put("message", "Test message");
param.put("log", log);
// Test non-existent nested property
String template = "Log alert: ${log.nonexistent.property} - ${log.message}";
String expectedResult = "Log alert: NullValue - Test message";
assertEquals(expectedResult, AlertTemplateUtil.render(template, param));
}
@Test
void renderNonExistentTopLevelProperty() {
Map<String, Object> param = new HashMap<>();
param.put("existing", "value");
String template = "Value: ${nonexistent.property}";
String expectedResult = "Value: NullValue";
assertEquals(expectedResult, AlertTemplateUtil.render(template, param));
}
@Test
void renderNestedPropertiesWithNullValues() {
Map<String, Object> param = new HashMap<>();
Map<String, Object> data = new HashMap<>();
data.put("value", null);
data.put("description", "Test data");
param.put("data", data);
String template = "Data: ${data.value} - ${data.description}";
String expectedResult = "Data: NullValue - Test data";
assertEquals(expectedResult, AlertTemplateUtil.render(template, param));
}
@Test
void renderComplexNestedStructure() {
Map<String, Object> param = new HashMap<>();
Map<String, Object> alert = new HashMap<>();
Map<String, Object> monitor = new HashMap<>();
Map<String, Object> target = new HashMap<>();
Map<String, Object> metadata = new HashMap<>();
metadata.put("version", "1.0");
metadata.put("author", "system");
target.put("host", "192.168.1.100");
target.put("port", 8080);
target.put("metadata", metadata);
monitor.put("name", "HTTP Monitor");
monitor.put("target", target);
alert.put("monitor", monitor);
alert.put("severity", "CRITICAL");
param.put("alert", alert);
String template = "Alert: ${alert.severity} from ${alert.monitor.name} targeting ${alert.monitor.target.host}:${alert.monitor.target.port} (v${alert.monitor.target.metadata.version})";
String expectedResult = "Alert: CRITICAL from HTTP Monitor targeting 192.168.1.100:8080 (v1.0)";
assertEquals(expectedResult, AlertTemplateUtil.render(template, param));
}
@Test
void renderWithDifferentDataTypes() {
Map<String, Object> param = new HashMap<>();
Map<String, Object> metrics = new HashMap<>();
metrics.put("cpu", 85.5);
metrics.put("memory", 1024);
metrics.put("active", true);
metrics.put("count", 42L);
param.put("metrics", metrics);
String template = "CPU: ${metrics.cpu}%, Memory: ${metrics.memory}MB, Active: ${metrics.active}, Count: ${metrics.count}";
String expectedResult = "CPU: 85.5%, Memory: 1024MB, Active: true, Count: 42";
assertEquals(expectedResult, AlertTemplateUtil.render(template, param));
}
// Helper classes for testing object property access
public static class TestObject {
private String name;
private int value;
private TestNestedObject nested;
public TestObject(String name, int value) {
this.name = name;
this.value = value;
}
public String getName() {
return name;
}
public int getValue() {
return value;
}
public TestNestedObject getNested() {
return nested;
}
public void setNested(TestNestedObject nested) {
this.nested = nested;
}
}
public static class TestNestedObject {
private String description;
private boolean enabled;
public TestNestedObject(String description, boolean enabled) {
this.description = description;
this.enabled = enabled;
}
public String getDescription() {
return description;
}
public boolean isEnabled() {
return enabled;
}
}
@Test
void renderWithObjectProperties() {
Map<String, Object> param = new HashMap<>();
TestNestedObject nested = new TestNestedObject("Test description", true);
TestObject obj = new TestObject("TestName", 123);
obj.setNested(nested);
param.put("object", obj);
String template = "Object: ${object.name} (value: ${object.value}) - ${object.nested.description}, enabled: ${object.nested.enabled}";
String expectedResult = "Object: TestName (value: 123) - Test description, enabled: true";
assertEquals(expectedResult, AlertTemplateUtil.render(template, param));
}
@Test
void renderWithMixedMapAndObjectProperties() {
Map<String, Object> param = new HashMap<>();
Map<String, Object> config = new HashMap<>();
TestObject obj = new TestObject("ConfigObject", 456);
config.put("object", obj);
config.put("type", "mixed");
param.put("config", config);
String template = "Config type: ${config.type}, object name: ${config.object.name}, value: ${config.object.value}";
String expectedResult = "Config type: mixed, object name: ConfigObject, value: 456";
assertEquals(expectedResult, AlertTemplateUtil.render(template, param));
}
}
@@ -158,7 +158,7 @@ public class DnsCollectImpl extends AbstractCollect {
responseTimeStopWatch.start();
Name name = Name.fromString(dns.getAddress(), Name.root);
Message query = Message.newQuery(Record.newRecord(name, Type.ANY, DClass.value(dns.getQueryClass())));
Message query = Message.newQuery(Record.newRecord(name, Type.value(dns.getRecordType()), DClass.value(dns.getQueryClass())));
Resolver res = new SimpleResolver(dns.getDnsServerIP());
res.setTimeout(Duration.of(Long.parseLong(dns.getTimeout()), ChronoUnit.MILLIS));
res.setTCP(Boolean.parseBoolean(dns.getTcp()));
@@ -65,6 +65,7 @@ import org.apache.http.impl.auth.DigestScheme;
import org.apache.http.impl.client.BasicAuthCache;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.protocol.HttpContext;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
@@ -276,7 +277,7 @@ public class PrometheusAutoCollectImpl implements PrometheusCollect {
}
// if it has payload, would override post params
if (StringUtils.hasLength(protocol.getPayload())) {
if (StringUtils.hasLength(protocol.getPayload()) && (HttpMethod.POST.matches(protocol.getMethod()) || HttpMethod.PUT.matches(protocol.getMethod()))) {
requestBuilder.setEntity(new StringEntity(protocol.getPayload(), StandardCharsets.UTF_8));
}
@@ -23,11 +23,13 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@@ -41,6 +43,7 @@ public class OnlineParser {
private static final char RIGHT_BRACKET = '}';
private static final char LEFT_BRACKET = '{';
private static final char UTF8_REPLACEMENT_CHARACTER = '\uFFFD';
static {
escapeMap.put((int) 'n', (int) '\n');
@@ -352,14 +355,135 @@ public class OnlineParser {
}
}
}
} else {
} else if (i <= 127) {
stringBuilder.append((char) i);
} else {
handleUtf8Character(i, inputStream, stringBuilder);
}
i = inputStream.read();
}
return new CharChecker(i);
}
/**
* Handles multi-byte UTF-8 character parsing from input stream.
* Reads additional bytes based on the first byte and validates the UTF-8 sequence.
* Appends the decoded character to the string builder or replacement character if invalid.
*
* @param firstByte the first byte of the UTF-8 character sequence
* @param inputStream the input stream to read additional bytes from
* @param stringBuilder the string builder to append the decoded character to
* @throws IOException if an I/O error occurs while reading from the input stream
*/
private static void handleUtf8Character(int firstByte, InputStream inputStream, StringBuilder stringBuilder) throws IOException {
byte[] byteArray = new byte[4];
byteArray[0] = (byte) firstByte;
int additionalBytes = calculateUtf8ContinuationBytes(firstByte);
if (additionalBytes == -1) {
appendInvalidCharacters(stringBuilder);
return;
}
int totalBytes = 1;
for (int i = 0; i < additionalBytes; i++) {
int nextByte = inputStream.read();
if (nextByte == -1) {
appendInvalidCharacters(stringBuilder);
return;
}
// Verify subsequent byte format:10xxxxxx
if ((nextByte & 0xC0) != 0x80) {
appendInvalidCharacters(stringBuilder);
return;
}
byteArray[i + 1] = (byte) nextByte;
totalBytes++;
}
try {
// todo: If stricter UTF-8 semantic validation is requiredboundary conditions are slightly strengthened.
String utf8Chars = new String(byteArray, 0, totalBytes, StandardCharsets.UTF_8);
stringBuilder.append(utf8Chars);
} catch (Exception e) {
log.debug("Invalid UTF-8 sequence detected at firstByte: {}", Integer.toHexString(firstByte));
appendInvalidCharacters(stringBuilder);
}
}
/**
* Appends the UTF-8 replacement character (\uFFFD) to the StringBuilder.
* This method is used to append the replacement character to the string builder
* when invalid UTF-8 byte sequences are encountered during parsing.
* The replacement character (U+FFFD) is a special Unicode character used to
* represent characters that cannot be decoded properly.
*
* @param stringBuilder the string builder to append to, no operation if null
*/
private static void appendInvalidCharacters(StringBuilder stringBuilder) {
Optional.ofNullable(stringBuilder).ifPresent(t -> t.append(UTF8_REPLACEMENT_CHARACTER));
}
/**
* Checks if a label value contains invalid characters.
* This method is used to validate the validity of Prometheus label values.
* If the label value contains the UTF-8 replacement character (\uFFFD),
* it is considered invalid because the replacement character indicates that
* byte sequences that could not be properly decoded were encountered during parsing.
*
* According to Prometheus specifications, label values should not contain
* replacement characters as this would cause data inconsistency and query issues.
*
* @param labelValue the label value to check
* @return true if the label value is not blank and contains UTF-8 replacement character, false otherwise
*/
private static boolean isInvalidLabelValue(String labelValue) {
return StringUtils.isNotBlank(labelValue) && labelValue.contains(String.valueOf(UTF8_REPLACEMENT_CHARACTER));
}
/**
* Calculates the number of continuation bytes needed for a UTF-8 character.
* This method analyzes the first byte of a UTF-8 encoding to determine how many
* continuation bytes (10xxxxxx pattern) are required to complete the character.
*
* UTF-8 encoding rules:
* - 1-byte character: 0xxxxxxx (ASCII characters, this method won't be called)
* - 2-byte character: 110xxxxx 10xxxxxx (returns 1 continuation byte)
* - 3-byte character: 1110xxxx 10xxxxxx 10xxxxxx (returns 2 continuation bytes)
* - 4-byte character: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx (returns 3 continuation bytes)
*
* Also validates byte sequences:
* - 0xC0 and 0xC1: overlong encoding, invalid
* - 0xF5-0xFF: out of Unicode range, invalid
*
* Note: A basic Overlong Encoding check has been added here.
* If stricter and more comprehensive validation is required later,
* this method should be separated out to serve solely as a check for the first character.
*
* @param firstByte the first byte of the UTF-8 character sequence
* @return the number of continuation bytes needed, or -1 if the first byte is invalid
* - returns 1: 2-byte character (needs 1 continuation byte)
* - returns 2: 3-byte character (needs 2 continuation bytes)
* - returns 3: 4-byte character (needs 3 continuation bytes)
* - returns -1: invalid first byte
*/
private static int calculateUtf8ContinuationBytes(int firstByte) {
if ((firstByte & 0xE0) == 0xC0) {
if (firstByte <= 0xC1) {
return -1;
}
return 1;
}
if ((firstByte & 0xF0) == 0xE0) {
return 2;
}
if ((firstByte & 0xF8) == 0xF0) {
if (firstByte >= 0xF5) {
return -1;
}
return 3;
}
return -1;
}
private static CharChecker skipSpaces(InputStream inputStream) throws IOException, FormatException {
int i = getChar(inputStream);
while (i == ' ') {
@@ -416,7 +540,8 @@ public class OnlineParser {
skipSpaces(inputStream).maybeQuotationMark().noElse();
parseLabelValue(inputStream, stringBuilder).maybeQuotationMark().noElse();
String labelValue = stringBuilder.toString();
if (!labelValue.equals(new String(labelValue.getBytes(StandardCharsets.UTF_8)))) {
if (isInvalidLabelValue(labelValue)) {
log.error("Invalid UTF-8 sequence detected at labelValue.");
throw new FormatException();
}
label.setValue(labelValue);
@@ -43,7 +43,7 @@ import java.util.Arrays;
@Slf4j
public class DnsSdCollectImpl extends AbstractCollect {
private static final int DEFAULT_TIME_OUT = 3;
private static final int DEFAULT_TIME_OUT = 3000;
@Override
public void preCheck(Metrics metrics) throws IllegalArgumentException {
@@ -46,6 +46,7 @@ public class DnsCollectImplTest {
.queryClass("IN")
.address("www.google.com")
.timeout("3000")
.recordType("A")
.port("53")
.tcp("tcp")
.build();
@@ -29,6 +29,7 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Parse a Single metric in prometheus test
@@ -444,7 +445,157 @@ public class OnlineParserSingleTest {
assertEquals("NT AUTHORITY\nLocalService", metricFamily.getMetricList().get(0).getLabels().get(3).getValue());
}
@Test
void testParseMetricsWithChineseLabels() throws Exception {
String str = "ST22{Dump_Name=\"Dump总数\",HostName=\"SAP_DEV\",instance_hostname=\"sapdev\"} 2\n";
InputStream inputStream = new ByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8));
Map<String, MetricFamily> metricFamilyMap = parseMetrics(inputStream, "ST22");
assertNotNull(metricFamilyMap);
MetricFamily metricFamily = metricFamilyMap.get("ST22");
assertNotNull(metricFamily);
assertEquals("ST22", metricFamily.getName());
assertEquals(1, metricFamily.getMetricList().size());
assertEquals(2.0, metricFamily.getMetricList().get(0).getValue());
// Verify Chinese label value is correctly parsed
MetricFamily.Label dumpNameLabel = metricFamily.getMetricList().get(0).getLabels().stream()
.filter(label -> "Dump_Name".equals(label.getName()))
.findFirst().orElse(null);
assertNotNull(dumpNameLabel);
assertEquals("Dump总数", dumpNameLabel.getValue());
}
private Map<String, MetricFamily> parseMetrics(InputStream inputStream, String metric) throws IOException {
return OnlineParser.parseMetrics(inputStream, metric);
}
@Test
void testHandleUtf8Character_ValidTwoByteSequence() throws Exception {
// Test valid 2-byte UTF-8 sequence: é (U+00E9) = 0xC3 0xA9
String str = "test_metric{label=\"caf\u00e9\"} 1\n";
InputStream inputStream = new ByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8));
Map<String, MetricFamily> metricFamilyMap = parseMetrics(inputStream, "test_metric");
assertNotNull(metricFamilyMap);
MetricFamily metricFamily = metricFamilyMap.get("test_metric");
assertNotNull(metricFamily);
assertEquals(1, metricFamily.getMetricList().size());
MetricFamily.Label label = metricFamily.getMetricList().get(0).getLabels().get(0);
assertEquals("label", label.getName());
assertEquals("café", label.getValue());
}
@Test
void testHandleUtf8Character_ValidThreeByteSequence() throws Exception {
// Test valid 3-byte UTF-8 sequence: Chinese character (U+4E2D) = 0xE4 0xB8 0xAD
String str = "test_metric{label=\"\u4e2d\u6587\"} 1\n";
InputStream inputStream = new ByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8));
Map<String, MetricFamily> metricFamilyMap = parseMetrics(inputStream, "test_metric");
assertNotNull(metricFamilyMap);
MetricFamily metricFamily = metricFamilyMap.get("test_metric");
assertNotNull(metricFamily);
assertEquals(1, metricFamily.getMetricList().size());
MetricFamily.Label label = metricFamily.getMetricList().get(0).getLabels().get(0);
assertEquals("label", label.getName());
assertEquals("\u4e2d\u6587", label.getValue());
}
@Test
void testHandleUtf8Character_ValidFourByteSequence() throws Exception {
// Test valid 4-byte UTF-8 sequence: 𝕳 (U+1D573) = 0xF0 0x9D 0x95 0xB3
String str = "test_metric{label=\"\uD835\uDD73ello\"} 1\n";
InputStream inputStream = new ByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8));
Map<String, MetricFamily> metricFamilyMap = parseMetrics(inputStream, "test_metric");
assertNotNull(metricFamilyMap);
MetricFamily metricFamily = metricFamilyMap.get("test_metric");
assertNotNull(metricFamily);
assertEquals(1, metricFamily.getMetricList().size());
MetricFamily.Label label = metricFamily.getMetricList().get(0).getLabels().get(0);
assertEquals("label", label.getName());
assertEquals("𝕳ello", label.getValue());
}
@Test
void testHandleUtf8Character_InvalidFirstByte_0xC0() throws Exception {
// Test invalid first byte 0xC0 (overlong encoding) - should cause parsing failure
byte[] invalidBytes = {
't', 'e', 's', 't', '_', 'm', 'e', 't', 'r', 'i', 'c', '{', 'l', 'a', 'b', 'e', 'l', '=', '"',
(byte) 0xC0, (byte) 0x80,
'"', '}', ' ', '1', '\n'
};
InputStream inputStream = new ByteArrayInputStream(invalidBytes);
Map<String, MetricFamily> metricFamilyMap = parseMetrics(inputStream, "test_metric");
assertNull(metricFamilyMap);
}
@Test
void testHandleUtf8Character_InvalidFirstByte_0xC1() throws Exception {
// Test invalid first byte 0xC1 (overlong encoding) - should cause parsing failure
byte[] invalidBytes = {
't', 'e', 's', 't', '_', 'm', 'e', 't', 'r', 'i', 'c', '{', 'l', 'a', 'b', 'e', 'l', '=', '"',
(byte) 0xC1, (byte) 0x80,
'"', '}', ' ', '1', '\n'
};
InputStream inputStream = new ByteArrayInputStream(invalidBytes);
Map<String, MetricFamily> metricFamilyMap = parseMetrics(inputStream, "test_metric");
assertNull(metricFamilyMap);
}
@Test
void testHandleUtf8Character_InvalidFirstByte_0xF5() throws Exception {
// Test invalid first byte 0xF5 (out of range) - should cause parsing failure
byte[] invalidBytes = {
't', 'e', 's', 't', '_', 'm', 'e', 't', 'r', 'i', 'c', '{', 'l', 'a', 'b', 'e', 'l', '=', '"',
(byte) 0xF5, (byte) 0x80, (byte) 0x80, (byte) 0x80,
'"', '}', ' ', '1', '\n'
};
InputStream inputStream = new ByteArrayInputStream(invalidBytes);
Map<String, MetricFamily> metricFamilyMap = parseMetrics(inputStream, "test_metric");
assertNull(metricFamilyMap);
}
@Test
void testHandleUtf8Character_InvalidContinuationByte() throws Exception {
// Test invalid continuation byte (should be 10xxxxxx but is 11xxxxxx) - should cause parsing failure
byte[] invalidBytes = {
't', 'e', 's', 't', '_', 'm', 'e', 't', 'r', 'i', 'c', '{', 'l', 'a', 'b', 'e', 'l', '=', '"',
(byte) 0xC3, (byte) 0xC0,
'"', '}', ' ', '1', '\n'
};
InputStream inputStream = new ByteArrayInputStream(invalidBytes);
Map<String, MetricFamily> metricFamilyMap = parseMetrics(inputStream, "test_metric");
assertNull(metricFamilyMap);
}
@Test
void testHandleUtf8Character_MixedValidInvalid() throws Exception {
// Test mixed valid UTF-8 sequences (removed invalid sequences that cause parsing to fail)
byte[] mixedBytes = {
't', 'e', 's', 't', '_', 'm', 'e', 't', 'r', 'i', 'c', '{', 'l', 'a', 'b', 'e', 'l', '=', '"',
'H', 'e', 'l', 'l', 'o',
(byte) 0xC3, (byte) 0xA9,
(byte) 0xE4, (byte) 0xB8, (byte) 0xAD,
(byte) 0xF0, (byte) 0x9F, (byte) 0x98, (byte) 0x80,
'"', '}', ' ', '1', '\n'
};
InputStream inputStream = new ByteArrayInputStream(mixedBytes);
Map<String, MetricFamily> metricFamilyMap = parseMetrics(inputStream, "test_metric");
assertNotNull(metricFamilyMap);
MetricFamily metricFamily = metricFamilyMap.get("test_metric");
assertNotNull(metricFamily);
assertEquals(1, metricFamily.getMetricList().size());
MetricFamily.Label label = metricFamily.getMetricList().get(0).getLabels().get(0);
assertEquals("label", label.getName());
String value = label.getValue();
assertNotNull(value);
assertEquals("Hello\u00e9\u4e2d\ud83d\ude00", value);
}
}
@@ -257,10 +257,7 @@ public class CommonDispatcher implements MetricsTaskDispatch, CollectDataDispatc
// The periodic task pushes the task to the time wheel again.
// First, determine the execution time of the task and the task collection interval.
if (!timeout.isCancelled()) {
long spendTime = System.currentTimeMillis() - job.getDispatchTime();
long interval = job.getInterval() - spendTime / 1000;
interval = interval <= 0 ? 0 : interval;
timerDispatch.cyclicJob(timerJob, interval, TimeUnit.SECONDS);
timerDispatch.cyclicJob(timerJob);
}
} else if (!metricsSet.isEmpty()) {
// The execution of the current level metrics is completed, and the execution of the next level metrics starts
@@ -360,10 +357,7 @@ public class CommonDispatcher implements MetricsTaskDispatch, CollectDataDispatc
// The periodic task pushes the task to the time wheel again.
// First, determine the execution time of the task and the task collection interval.
if (!timeout.isCancelled()) {
long spendTime = System.currentTimeMillis() - job.getDispatchTime();
long interval = job.getInterval() - spendTime / 1000;
interval = interval <= 0 ? 0 : interval;
timerDispatch.cyclicJob(timerJob, interval, TimeUnit.SECONDS);
timerDispatch.cyclicJob(timerJob);
}
// it is an asynchronous periodic cyclic task, directly response the collected data
metricsDataList.forEach(commonDataQueue::sendMetricsData);
@@ -0,0 +1,39 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.collector.constants;
import lombok.Getter;
/**
* Schedule Type
*/
@Getter
public enum ScheduleTypeEnum {
INTERVAL("interval"),
CRON("cron");
private final String type;
ScheduleTypeEnum(String type) {
this.type = type;
}
}
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.collector.dispatch.export;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.collector.dispatch.entrance.internal.CollectJobService;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -87,4 +88,24 @@ public class NettyDataQueue implements CommonDataQueue {
public void sendServiceDiscoveryData(CollectRep.MetricsData metricsData) {
collectJobService.sendAsyncServiceDiscoveryData(metricsData);
}
@Override
public void sendLogEntry(LogEntry logEntry) {
}
@Override
public LogEntry pollLogEntry() throws InterruptedException {
return null;
}
@Override
public void sendLogEntryToStorage(LogEntry logEntry) {
}
@Override
public LogEntry pollLogEntryToStorage() throws InterruptedException {
return null;
}
}
@@ -45,6 +45,12 @@ public interface TimerDispatch {
*/
void cyclicJob(WheelTimerTask timerTask, long interval, TimeUnit timeUnit);
/**
* Cyclic job
* @param timerTask timerTask
*/
void cyclicJob(WheelTimerTask timerTask);
/**
* Delete existing job
* @param jobId jobId
@@ -17,13 +17,17 @@
package org.apache.hertzbeat.collector.timer;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.collector.constants.ScheduleTypeEnum;
import org.apache.hertzbeat.collector.dispatch.entrance.internal.CollectResponseEventListener;
import org.apache.hertzbeat.common.entity.job.Job;
import org.apache.hertzbeat.common.entity.job.Metrics;
@@ -32,6 +36,7 @@ import org.apache.hertzbeat.common.timer.HashedWheelTimer;
import org.apache.hertzbeat.common.timer.Timeout;
import org.apache.hertzbeat.common.timer.Timer;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.stereotype.Component;
/**
@@ -58,12 +63,12 @@ public class TimerDispatcher implements TimerDispatch, DisposableBean {
* jobId - listener
*/
private final Map<Long, CollectResponseEventListener> eventListeners;
/**
* is dispatcher online running
*/
private final AtomicBoolean started;
public TimerDispatcher() {
this.wheelTimer = new HashedWheelTimer(r -> {
Thread ret = new Thread(r, "wheelTimer");
@@ -84,7 +89,8 @@ public class TimerDispatcher implements TimerDispatch, DisposableBean {
}
WheelTimerTask timerJob = new WheelTimerTask(addJob);
if (addJob.isCyclic()) {
Timeout timeout = wheelTimer.newTimeout(timerJob, addJob.getInterval(), TimeUnit.SECONDS);
Long nextExecutionTime = getNextExecutionInterval(addJob);
Timeout timeout = wheelTimer.newTimeout(timerJob, nextExecutionTime, TimeUnit.SECONDS);
currentCyclicTaskMap.put(addJob.getId(), timeout);
} else {
for (Metrics metric : addJob.getMetrics()) {
@@ -111,6 +117,13 @@ public class TimerDispatcher implements TimerDispatch, DisposableBean {
}
}
@Override
public void cyclicJob(WheelTimerTask timerTask) {
Job job = timerTask.getJob();
Long nextExecutionTime = getNextExecutionInterval(job);
cyclicJob(timerTask, nextExecutionTime, TimeUnit.SECONDS);
}
@Override
public void deleteJob(long jobId, boolean isCyclic) {
if (isCyclic) {
@@ -125,7 +138,7 @@ public class TimerDispatcher implements TimerDispatch, DisposableBean {
}
}
}
@Override
public void goOnline() {
currentCyclicTaskMap.forEach((key, value) -> value.cancel());
@@ -134,7 +147,7 @@ public class TimerDispatcher implements TimerDispatch, DisposableBean {
currentTempTaskMap.clear();
started.set(true);
}
@Override
public void goOffline() {
started.set(false);
@@ -143,8 +156,8 @@ public class TimerDispatcher implements TimerDispatch, DisposableBean {
currentTempTaskMap.forEach((key, value) -> value.cancel());
currentTempTaskMap.clear();
}
@Override
public void responseSyncJobData(long jobId, List<CollectRep.MetricsData> metricsDataTemps) {
currentTempTaskMap.remove(jobId);
@@ -153,9 +166,35 @@ public class TimerDispatcher implements TimerDispatch, DisposableBean {
eventListener.response(metricsDataTemps);
}
}
@Override
public void destroy() throws Exception {
this.wheelTimer.stop();
}
public Long getNextExecutionInterval(Job job) {
if (ScheduleTypeEnum.CRON.getType().equals(job.getScheduleType()) && job.getCronExpression() != null && !job.getCronExpression().isEmpty()) {
try {
CronExpression cronExpression = CronExpression.parse(job.getCronExpression());
ZonedDateTime nextExecutionTime = cronExpression.next(ZonedDateTime.now());
long delay = Duration.between(ZonedDateTime.now(), nextExecutionTime).toMillis();
// Convert to seconds and ensure non-negative
return Math.max(0, delay / 1000);
} catch (Exception e) {
log.error("Invalid cron expression: {}", job.getCronExpression(), e);
// Fall back to interval scheduling if cron is invalid
return job.getInterval();
}
} else {
if (job.getDispatchTime() > 0) {
long spendTime = System.currentTimeMillis() - job.getDispatchTime();
// Calculate remaining interval in seconds, preserving millisecond precision
long intervalMs = job.getInterval() * 1000 - spendTime;
// Ensure non-negative
return Math.max(0, intervalMs / 1000);
}
return job.getInterval();
}
}
}
@@ -0,0 +1,182 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.collector.timer;
import org.apache.hertzbeat.collector.constants.ScheduleTypeEnum;
import org.apache.hertzbeat.common.entity.job.Job;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
/**
* Test class for TimerDispatcher's getNextExecutionInterval method
*/
public class TimerDispatcherTest {
@InjectMocks
private TimerDispatcher timerDispatcher;
@Mock
private Job job;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
/**
* Test getNextExecutionInterval with valid CRON expression
* Should return the calculated interval based on cron expression
*/
@Test
void testGetNextExecutionIntervalWithValidCron() {
// Setup - Use a cron expression that runs every minute
when(job.getScheduleType()).thenReturn(ScheduleTypeEnum.CRON.getType());
when(job.getCronExpression()).thenReturn("0 * * * * ?");
when(job.getInterval()).thenReturn(60L); // Default interval as fallback
// Execute
Long result = timerDispatcher.getNextExecutionInterval(job);
// Verify - Result should be between 0 and 60 seconds
// since the cron expression runs every minute
assertEquals(true, result >= 0 && result <= 60);
}
/**
* Test getNextExecutionInterval with invalid CRON expression
* Should fall back to interval scheduling
*/
@Test
void testGetNextExecutionIntervalWithInvalidCron() {
// Setup - Use an invalid cron expression
when(job.getScheduleType()).thenReturn(ScheduleTypeEnum.CRON.getType());
when(job.getCronExpression()).thenReturn("invalid-cron-expression");
when(job.getInterval()).thenReturn(300L); // 5 minutes
// Execute
Long result = timerDispatcher.getNextExecutionInterval(job);
// Verify - Should fall back to interval value
assertEquals(300L, result);
}
/**
* Test getNextExecutionInterval with empty CRON expression
* Should fall back to interval scheduling
*/
@Test
void testGetNextExecutionIntervalWithEmptyCron() {
// Setup - Use empty cron expression
when(job.getScheduleType()).thenReturn(ScheduleTypeEnum.CRON.getType());
when(job.getCronExpression()).thenReturn("");
when(job.getInterval()).thenReturn(120L); // 2 minutes
// Execute
Long result = timerDispatcher.getNextExecutionInterval(job);
// Verify - Should fall back to interval value
assertEquals(120L, result);
}
/**
* Test getNextExecutionInterval with INTERVAL schedule type and no dispatchTime
* Should return the full interval value
*/
@Test
void testGetNextExecutionIntervalWithIntervalNoDispatchTime() {
// Setup - Use interval schedule type with no dispatchTime
when(job.getScheduleType()).thenReturn(ScheduleTypeEnum.INTERVAL.getType());
when(job.getDispatchTime()).thenReturn(0L); // No dispatchTime set
when(job.getInterval()).thenReturn(600L); // 10 minutes
// Execute
Long result = timerDispatcher.getNextExecutionInterval(job);
// Verify - Should return the full interval value
assertEquals(600L, result);
}
/**
* Test getNextExecutionInterval with INTERVAL schedule type and dispatchTime
* Should return the remaining interval time
*/
@Test
void testGetNextExecutionIntervalWithIntervalAndDispatchTime() {
// Setup - Use interval schedule type with dispatchTime
long interval = 600L; // 10 minutes in seconds
long spendTime = 300000L; // 5 minutes in milliseconds
long dispatchTime = System.currentTimeMillis() - spendTime;
when(job.getScheduleType()).thenReturn(ScheduleTypeEnum.INTERVAL.getType());
when(job.getDispatchTime()).thenReturn(dispatchTime);
when(job.getInterval()).thenReturn(interval);
// Execute
Long result = timerDispatcher.getNextExecutionInterval(job);
// Verify - Should return the remaining interval time (approximately 5 minutes)
// Considering possible time differences during execution, we check a range
assertEquals(true, result >= 295 && result <= 305);
}
/**
* Test getNextExecutionInterval with INTERVAL schedule type and negative remaining time
* Should return 0 to ensure non-negative value
*/
@Test
void testGetNextExecutionIntervalWithIntervalAndNegativeRemainingTime() {
// Setup - Use interval schedule type with dispatchTime resulting in negative remaining time
long interval = 600L; // 10 minutes in seconds
long spendTime = 1200000L; // 20 minutes in milliseconds (more than interval)
long dispatchTime = System.currentTimeMillis() - spendTime;
when(job.getScheduleType()).thenReturn(ScheduleTypeEnum.INTERVAL.getType());
when(job.getDispatchTime()).thenReturn(dispatchTime);
when(job.getInterval()).thenReturn(interval);
// Execute
Long result = timerDispatcher.getNextExecutionInterval(job);
// Verify - Should return 0 to ensure non-negative value
assertEquals(0L, result);
}
/**
* Test getNextExecutionInterval with CRON schedule type and null cronExpression
* Should fall back to interval scheduling
*/
@Test
void testGetNextExecutionIntervalWithCronAndNullExpression() {
// Setup - Use cron schedule type with null cronExpression
when(job.getScheduleType()).thenReturn(ScheduleTypeEnum.CRON.getType());
when(job.getCronExpression()).thenReturn(null);
when(job.getInterval()).thenReturn(180L); // 3 minutes
// Execute
Long result = timerDispatcher.getNextExecutionInterval(job);
// Verify - Should fall back to interval value
assertEquals(180L, result);
}
}
@@ -82,26 +82,58 @@ public final class CacheFactory {
}
/**
* get alert define cache
* get metrics alert define cache
* @return caffeine cache
*/
@SuppressWarnings("unchecked")
public static List<AlertDefine> getAlertDefineCache() {
return (List<AlertDefine>) COMMON_CACHE.get(CommonConstants.CACHE_ALERT_DEFINE);
public static List<AlertDefine> getMetricsAlertDefineCache() {
return (List<AlertDefine>) COMMON_CACHE.get(CommonConstants.METRIC_CACHE_ALERT_DEFINE);
}
/**
* set alert define cache
* set metrics alert define cache
* @param alertDefines alert defines
*/
public static void setAlertDefineCache(List<AlertDefine> alertDefines) {
COMMON_CACHE.put(CommonConstants.CACHE_ALERT_DEFINE, alertDefines);
public static void setMetricsAlertDefineCache(List<AlertDefine> alertDefines) {
COMMON_CACHE.put(CommonConstants.METRIC_CACHE_ALERT_DEFINE, alertDefines);
}
/**
* clear metrics alert define cache
*/
public static void clearMetricsAlertDefineCache() {
COMMON_CACHE.remove(CommonConstants.METRIC_CACHE_ALERT_DEFINE);
}
/**
* get log alert define cache
* @return caffeine cache
*/
@SuppressWarnings("unchecked")
public static List<AlertDefine> getLogAlertDefineCache() {
return (List<AlertDefine>) COMMON_CACHE.get(CommonConstants.LOG_CACHE_ALERT_DEFINE);
}
/**
* set log alert define cache
* @param alertDefines alert defines
*/
public static void setLogAlertDefineCache(List<AlertDefine> alertDefines) {
COMMON_CACHE.put(CommonConstants.LOG_CACHE_ALERT_DEFINE, alertDefines);
}
/**
* clear log alert define cache
*/
public static void clearLogAlertDefineCache() {
COMMON_CACHE.remove(CommonConstants.LOG_CACHE_ALERT_DEFINE);
}
/**
* clear alert define cache
*/
public static void clearAlertDefineCache() {
COMMON_CACHE.remove(CommonConstants.CACHE_ALERT_DEFINE);
clearLogAlertDefineCache();
clearMetricsAlertDefineCache();
}
}
@@ -115,6 +115,16 @@ public class CommonProperties {
*/
private String alertsDataQueueName;
/**
* Queue name for log entry data
*/
private String logEntryQueueName;
/**
* Queue name for log entry data to storage
*/
private String logEntryToStorageQueueName;
}
/**
@@ -140,5 +150,13 @@ public class CommonProperties {
* alerts data topic
*/
private String alertsDataTopic;
/**
* log entry data topic
*/
private String logEntryDataTopic;
/**
* log entry data to storage topic
*/
private String logEntryDataToStorageTopic;
}
}
@@ -153,14 +153,39 @@ public interface CommonConstants {
String ALERT_STATUS_PENDING = "pending";
/**
* alert threshold type: realtime
* metric alert threshold type: realtime
*/
String ALERT_THRESHOLD_TYPE_REALTIME = "realtime";
String METRIC_ALERT_THRESHOLD_TYPE_REALTIME = "realtime_metric";
/**
* alert threshold type: periodic
* metric alert threshold type: periodic
*/
String ALERT_THRESHOLD_TYPE_PERIODIC = "periodic";
String METRIC_ALERT_THRESHOLD_TYPE_PERIODIC = "periodic_metric";
/**
* log alert threshold type: realtime
*/
String LOG_ALERT_THRESHOLD_TYPE_REALTIME = "realtime_log";
/**
* log alert threshold type: periodic
*/
String LOG_ALERT_THRESHOLD_TYPE_PERIODIC = "periodic_log";
/**
* Alert mode label key
*/
String ALERT_MODE_LABEL = "alert_mode";
/**
* Alert mode: group
*/
String ALERT_MODE_GROUP = "group";
/**
* Alert mode: individual
*/
String ALERT_MODE_INDIVIDUAL = "individual";
/**
* Field parameter type: number
@@ -238,9 +263,14 @@ public interface CommonConstants {
String CACHE_ALERT_SILENCE = "alert_silence";
/**
* cache key alert define
* cache key metric alert define
*/
String CACHE_ALERT_DEFINE = "alert_define";
String METRIC_CACHE_ALERT_DEFINE = "metric_alert_define";
/**
* cache key log alert define
*/
String LOG_CACHE_ALERT_DEFINE = "log_alert_define";
/**
* cache key alert converge
@@ -55,5 +55,10 @@ public enum GeneralConfigTypeEnum {
/**
* system store config
*/
oss
oss,
/**
* model llm provider
*/
provider
}
@@ -24,23 +24,46 @@ import java.util.Arrays;
*/
public enum JexlKeywordsEnum {
SIZE("size"),
EMPTY("empty"),
NEW("new"),
VAR("var"),
RETURN("return"),
IF("if"),
ELSE("else"),
ELSEIF("elseif"),
WHILE("while"),
DO("do"),
FOR("for"),
CONTINUE("continue"),
BREAK("break"),
/**
* Official reserved keywords
* @reference https://commons.apache.org/proper/commons-jexl/reference/syntax.html
*/
OR("or"),
AND("and"),
EQ("eq"),
NE("ne"),
LT("lt"),
GT("gt"),
LE("le"),
GE("ge"),
DIV("div"),
MOD("mod"),
NOT("not"),
NULL("null"),
TRUE("true"),
FALSE("false"),
NULL("null"),
UNDEFINED("undefined");
NEW("new"),
VAR("var"),
DO("do"),
WHILE("while"),
BREAK("break"),
CONTINUE("continue"),
FUNCTION("function"),
RETURN("return"),
/**
* Syntax-reserved keywords
*/
IF("if"),
ELSE("else"),
FOR("for"),
/**
* Built-in functions reserve keywords
*/
SIZE("size"),
EMPTY("empty");
private final String keyword;
@@ -65,7 +65,7 @@ public class AlertDefine {
@NotNull
private String name;
@Schema(title = "Rule Type: realtime, periodic", example = "0")
@Schema(title = "Rule Type: realtime_metric, periodic_metric, realtime_log, periodic_log", example = "realtime_metric")
private String type;
@Schema(title = "Alarm Threshold Expr", example = "usage>90", accessMode = READ_WRITE)
@@ -73,7 +73,7 @@ public class AlertDefine {
@Column(length = 2048)
private String expr;
@Schema(title = "Execution Period (seconds) - For periodic rules", example = "300")
@Schema(title = "Execution Period/ Window Size (seconds) - For periodic rules/ For log realtime", example = "300")
private Integer period;
@Schema(title = "Alarm Trigger Times.The alarm is triggered only after the required number of times is reached",
@@ -143,6 +143,16 @@ public class Job {
*/
private boolean prometheusProxyMode = false;
/**
* Scheduling type: interval or cron
*/
private String scheduleType = "interval";
/**
* Cron expression for scheduling, used when scheduleType is "cron"
*/
private String cronExpression = null;
/**
* the collect data response metrics as env configmap for other collect use. ^o^xxx^o^
*/
@@ -37,9 +37,10 @@ public class DnsProtocol implements CommonRequestProtocol, Protocol {
private String timeout;
private String tcp;
private String queryClass;
private String recordType;
public boolean isInvalid() {
return StringUtils.isAnyBlank(dnsServerIP, port, address, timeout, tcp, queryClass);
return StringUtils.isAnyBlank(dnsServerIP, port, address, timeout, tcp, queryClass, recordType);
}
@Override
@@ -0,0 +1,143 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.entity.log;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* OpenTelemetry Log Entry entity based on OpenTelemetry log data model specification.
*
* @see <a href="https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md">OpenTelemetry Log Data Model</a>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LogEntry {
/**
* Time when the event occurred.
* Value is UNIX Epoch time in nanoseconds since 00:00:00 UTC on 1 January 1970.
*/
private Long timeUnixNano;
/**
* Time when the event was observed.
* Value is UNIX Epoch time in nanoseconds since 00:00:00 UTC on 1 January 1970.
*/
private Long observedTimeUnixNano;
/**
* Numerical value of the severity.
* Smaller numerical values correspond to less severe events (such as debug events),
* larger numerical values correspond to more severe events (such as errors and critical events).
*/
private Integer severityNumber;
/**
* The severity text (also known as log level).
* This is the original string representation of the severity as it is known at the source.
*/
private String severityText;
/**
* A value containing the body of the log record.
* Can be for example a human-readable string message (including multi-line)
* or it can be a structured data composed of arrays and maps of other values.
*/
private Object body;
/**
* Additional information about the specific event occurrence.
* Unlike the Resource field, these are NOT fixed for the lifetime of the process.
*/
private Map<String, Object> attributes;
/**
* Dropped attributes count.
* Number of attributes that were discarded due to limits being exceeded.
*/
private Integer droppedAttributesCount;
/**
* A unique identifier for a trace.
* All spans from the same trace share the same trace_id.
* The ID is a 16-byte array represented as a hex string.
*/
private String traceId;
/**
* A unique identifier for a span within a trace.
* The ID is an 8-byte array represented as a hex string.
*/
private String spanId;
/**
* Trace flag as defined in W3C Trace Context specification.
* At the time of writing the specification defines one flag - the SAMPLED flag.
*/
private Integer traceFlags;
/**
* Resource information.
* Information about the entity producing the telemetry.
*/
private Map<String, Object> resource;
/**
* Instrumentation Scope information.
* Information about the instrumentation scope that emitted the log.
*/
private InstrumentationScope instrumentationScope;
/**
* Instrumentation Scope information for logs.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class InstrumentationScope {
/**
* The name of the instrumentation scope.
* This should be the fully-qualified name of the instrumentation library.
*/
private String name;
/**
* The version of the instrumentation scope.
*/
private String version;
/**
* Additional attributes that describe the scope.
*/
private Map<String, Object> attributes;
/**
* Number of attributes that were discarded due to limits being exceeded.
*/
private Integer droppedAttributesCount;
}
}
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.common.entity.manager;
import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_ONLY;
import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_WRITE;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
@@ -90,6 +91,14 @@ public class Monitor {
@Min(10)
private Integer intervals;
@Schema(title = "Schedule type: interval | cron", example = "interval", accessMode = READ_WRITE)
@Size(max = 20)
private String scheduleType;
@Schema(title = "Cron expression when scheduleType is cron", example = "0/5 * * * * ?", accessMode = READ_WRITE)
@Size(max = 100)
private String cronExpression;
@Schema(title = "Task status 0: Paused, 1: Up, 2: Down", accessMode = READ_WRITE)
@Min(0)
@Max(4)
@@ -17,6 +17,7 @@
package org.apache.hertzbeat.common.queue;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.entity.message.CollectRep;
/**
@@ -62,4 +63,32 @@ public interface CommonDataQueue {
* @param metricsData service discovery data
*/
void sendServiceDiscoveryData(CollectRep.MetricsData metricsData);
/**
* send log entry to queue
* @param logEntry log entry data based on OpenTelemetry log data model
* @throws InterruptedException when sending is interrupted
*/
void sendLogEntry(LogEntry logEntry);
/**
* poll log entry from queue
* @return log entry data
* @throws InterruptedException when poll timeout
*/
LogEntry pollLogEntry() throws InterruptedException;
/**
* send log entry to storage queue
* @param logEntry log entry data based on OpenTelemetry log data model
* @throws InterruptedException when sending is interrupted
*/
void sendLogEntryToStorage(LogEntry logEntry);
/**
* poll log entry from storage queue
* @return log entry data
* @throws InterruptedException when poll timeout
*/
LogEntry pollLogEntryToStorage() throws InterruptedException;
}
@@ -22,6 +22,7 @@ import java.util.Map;
import java.util.concurrent.LinkedBlockingQueue;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.constants.DataQueueConstants;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.springframework.beans.factory.DisposableBean;
@@ -46,17 +47,23 @@ public class InMemoryCommonDataQueue implements CommonDataQueue, DisposableBean
private final LinkedBlockingQueue<CollectRep.MetricsData> metricsDataToAlertQueue;
private final LinkedBlockingQueue<CollectRep.MetricsData> metricsDataToStorageQueue;
private final LinkedBlockingQueue<CollectRep.MetricsData> serviceDiscoveryDataQueue;
private final LinkedBlockingQueue<LogEntry> logEntryQueue;
private final LinkedBlockingQueue<LogEntry> logEntryToStorageQueue;
public InMemoryCommonDataQueue() {
metricsDataToAlertQueue = new LinkedBlockingQueue<>();
metricsDataToStorageQueue = new LinkedBlockingQueue<>();
serviceDiscoveryDataQueue = new LinkedBlockingQueue<>();
logEntryQueue = new LinkedBlockingQueue<>();
logEntryToStorageQueue = new LinkedBlockingQueue<>();
}
public Map<String, Integer> getQueueSizeMetricsInfo() {
Map<String, Integer> metrics = new HashMap<>(8);
metrics.put("metricsDataToAlertQueue", metricsDataToAlertQueue.size());
metrics.put("metricsDataToStorageQueue", metricsDataToStorageQueue.size());
metrics.put("logEntryQueue", logEntryQueue.size());
metrics.put("logEntryToStorageQueue", logEntryToStorageQueue.size());
return metrics;
}
@@ -90,10 +97,32 @@ public class InMemoryCommonDataQueue implements CommonDataQueue, DisposableBean
serviceDiscoveryDataQueue.offer(metricsData);
}
@Override
public void sendLogEntry(LogEntry logEntry) {
logEntryQueue.offer(logEntry);
}
@Override
public LogEntry pollLogEntry() throws InterruptedException {
return logEntryQueue.take();
}
@Override
public void sendLogEntryToStorage(LogEntry logEntry) {
logEntryToStorageQueue.offer(logEntry);
}
@Override
public LogEntry pollLogEntryToStorage() throws InterruptedException {
return logEntryToStorageQueue.take();
}
@Override
public void destroy() {
metricsDataToAlertQueue.clear();
metricsDataToStorageQueue.clear();
serviceDiscoveryDataQueue.clear();
logEntryQueue.clear();
logEntryToStorageQueue.clear();
}
}
@@ -26,8 +26,11 @@ import java.util.concurrent.locks.ReentrantLock;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.config.CommonProperties;
import org.apache.hertzbeat.common.constants.DataQueueConstants;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.apache.hertzbeat.common.serialize.KafkaLogEntryDeserializer;
import org.apache.hertzbeat.common.serialize.KafkaLogEntrySerializer;
import org.apache.hertzbeat.common.serialize.KafkaMetricsDataDeserializer;
import org.apache.hertzbeat.common.serialize.KafkaMetricsDataSerializer;
import org.apache.kafka.clients.consumer.ConsumerConfig;
@@ -59,14 +62,21 @@ public class KafkaCommonDataQueue implements CommonDataQueue, DisposableBean {
private final ReentrantLock metricDataToAlertLock = new ReentrantLock();
private final ReentrantLock metricDataToStorageLock = new ReentrantLock();
private final ReentrantLock serviceDiscoveryDataLock = new ReentrantLock();
private final ReentrantLock logEntryLock = new ReentrantLock();
private final ReentrantLock logEntryToStorageLock = new ReentrantLock();
private final LinkedBlockingQueue<CollectRep.MetricsData> metricsDataToAlertQueue;
private final LinkedBlockingQueue<CollectRep.MetricsData> metricsDataToStorageQueue;
private final LinkedBlockingQueue<CollectRep.MetricsData> serviceDiscoveryDataQueue;
private final LinkedBlockingQueue<LogEntry> logEntryQueue;
private final LinkedBlockingQueue<LogEntry> logEntryToStorageQueue;
private final CommonProperties.KafkaProperties kafka;
private KafkaProducer<Long, CollectRep.MetricsData> metricsDataProducer;
private KafkaProducer<Long, LogEntry> logEntryProducer;
private KafkaConsumer<Long, CollectRep.MetricsData> metricsDataToAlertConsumer;
private KafkaConsumer<Long, CollectRep.MetricsData> metricsDataToStorageConsumer;
private KafkaConsumer<Long, CollectRep.MetricsData> serviceDiscoveryDataConsumer;
private KafkaConsumer<Long, LogEntry> logEntryConsumer;
private KafkaConsumer<Long, LogEntry> logEntryToStorageConsumer;
public KafkaCommonDataQueue(CommonProperties properties) {
if (properties == null || properties.getQueue() == null || properties.getQueue().getKafka() == null) {
@@ -77,6 +87,8 @@ public class KafkaCommonDataQueue implements CommonDataQueue, DisposableBean {
metricsDataToAlertQueue = new LinkedBlockingQueue<>();
metricsDataToStorageQueue = new LinkedBlockingQueue<>();
serviceDiscoveryDataQueue = new LinkedBlockingQueue<>();
logEntryQueue = new LinkedBlockingQueue<>();
logEntryToStorageQueue = new LinkedBlockingQueue<>();
initDataQueue();
}
@@ -87,6 +99,7 @@ public class KafkaCommonDataQueue implements CommonDataQueue, DisposableBean {
producerConfig.put(ProducerConfig.ACKS_CONFIG, "all");
producerConfig.put(ProducerConfig.RETRIES_CONFIG, 3);
metricsDataProducer = new KafkaProducer<>(producerConfig, new LongSerializer(), new KafkaMetricsDataSerializer());
logEntryProducer = new KafkaProducer<>(producerConfig, new LongSerializer(), new KafkaLogEntrySerializer());
Map<String, Object> consumerConfig = new HashMap<>(4);
consumerConfig.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getServers());
@@ -111,6 +124,16 @@ public class KafkaCommonDataQueue implements CommonDataQueue, DisposableBean {
serviceDiscoveryDataConsumer = new KafkaConsumer<>(serviceDiscoveryDataConsumerConfig, new LongDeserializer(),
new KafkaMetricsDataDeserializer());
serviceDiscoveryDataConsumer.subscribe(Collections.singletonList(kafka.getServiceDiscoveryDataTopic()));
Map<String, Object> logEntryConsumerConfig = new HashMap<>(consumerConfig);
logEntryConsumerConfig.put("group.id", "log-entry-consumer");
logEntryConsumer = new KafkaConsumer<>(logEntryConsumerConfig, new LongDeserializer(), new KafkaLogEntryDeserializer());
logEntryConsumer.subscribe(Collections.singletonList(kafka.getLogEntryDataTopic()));
Map<String, Object> logEntryToStorageConsumerConfig = new HashMap<>(consumerConfig);
logEntryToStorageConsumerConfig.put("group.id", "log-entry-storage-consumer");
logEntryToStorageConsumer = new KafkaConsumer<>(logEntryToStorageConsumerConfig, new LongDeserializer(), new KafkaLogEntryDeserializer());
logEntryToStorageConsumer.subscribe(Collections.singletonList(kafka.getLogEntryDataToStorageTopic()));
} catch (Exception e) {
log.error("please config common.queue.kafka props correctly", e);
throw e;
@@ -192,6 +215,50 @@ public class KafkaCommonDataQueue implements CommonDataQueue, DisposableBean {
}
}
@Override
public void sendLogEntry(LogEntry logEntry) {
if (logEntryProducer != null) {
try {
ProducerRecord<Long, LogEntry> record = new ProducerRecord<>(kafka.getLogEntryDataTopic(), logEntry);
logEntryProducer.send(record);
} catch (Exception e) {
log.error("Failed to send LogEntry to Kafka: {}", e.getMessage());
// Fallback to memory queue if Kafka fails
logEntryQueue.offer(logEntry);
}
} else {
log.warn("logEntryProducer is not enabled, using memory queue");
logEntryQueue.offer(logEntry);
}
}
@Override
public LogEntry pollLogEntry() throws InterruptedException {
return genericPollDataFunction(logEntryQueue, logEntryConsumer, logEntryLock);
}
@Override
public void sendLogEntryToStorage(LogEntry logEntry) {
if (logEntryProducer != null) {
try {
ProducerRecord<Long, LogEntry> record = new ProducerRecord<>(kafka.getLogEntryDataToStorageTopic(), logEntry);
logEntryProducer.send(record);
} catch (Exception e) {
log.error("Failed to send LogEntry to storage via Kafka: {}", e.getMessage());
// Fallback to memory queue if Kafka fails
logEntryToStorageQueue.offer(logEntry);
}
} else {
log.warn("logEntryProducer is not enabled, using memory queue for storage");
logEntryToStorageQueue.offer(logEntry);
}
}
@Override
public LogEntry pollLogEntryToStorage() throws InterruptedException {
return genericPollDataFunction(logEntryToStorageQueue, logEntryToStorageConsumer, logEntryToStorageLock);
}
@Override
public void destroy() throws Exception {
if (metricsDataProducer != null) {
@@ -206,5 +273,14 @@ public class KafkaCommonDataQueue implements CommonDataQueue, DisposableBean {
if (serviceDiscoveryDataConsumer != null) {
serviceDiscoveryDataConsumer.close();
}
if (logEntryProducer != null) {
logEntryProducer.close();
}
if (logEntryConsumer != null) {
logEntryConsumer.close();
}
if (logEntryToStorageConsumer != null) {
logEntryToStorageConsumer.close();
}
}
}
@@ -24,8 +24,10 @@ import io.lettuce.core.api.sync.RedisCommands;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.config.CommonProperties;
import org.apache.hertzbeat.common.constants.DataQueueConstants;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.apache.hertzbeat.common.serialize.RedisLogEntryCodec;
import org.apache.hertzbeat.common.serialize.RedisMetricsDataCodec;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -46,9 +48,13 @@ public class RedisCommonDataQueue implements CommonDataQueue, DisposableBean {
private final RedisClient redisClient;
private final StatefulRedisConnection<String, CollectRep.MetricsData> connection;
private final RedisCommands<String, CollectRep.MetricsData> syncCommands;
private final StatefulRedisConnection<String, LogEntry> logEntryConnection;
private final RedisCommands<String, LogEntry> logEntrySyncCommands;
private final String metricsDataQueueNameToStorage;
private final String metricsDataQueueNameForServiceDiscovery;
private final String metricsDataQueueNameToAlerter;
private final String logEntryQueueName;
private final String logEntryToStorageQueueName;
private final CommonProperties.RedisProperties redisProperties;
public RedisCommonDataQueue(CommonProperties properties) {
@@ -69,9 +75,14 @@ public class RedisCommonDataQueue implements CommonDataQueue, DisposableBean {
RedisMetricsDataCodec codec = new RedisMetricsDataCodec();
this.connection = redisClient.connect(codec);
this.syncCommands = connection.sync();
RedisLogEntryCodec logCodec = new RedisLogEntryCodec();
this.logEntryConnection = redisClient.connect(logCodec);
this.logEntrySyncCommands = logEntryConnection.sync();
this.metricsDataQueueNameToStorage = redisProperties.getMetricsDataQueueNameToPersistentStorage();
this.metricsDataQueueNameForServiceDiscovery = redisProperties.getMetricsDataQueueNameForServiceDiscovery();
this.metricsDataQueueNameToAlerter = redisProperties.getMetricsDataQueueNameToAlerter();
this.logEntryQueueName = redisProperties.getLogEntryQueueName();
this.logEntryToStorageQueueName = redisProperties.getLogEntryToStorageQueueName();
}
@Override
@@ -131,9 +142,48 @@ public class RedisCommonDataQueue implements CommonDataQueue, DisposableBean {
}
}
@Override
public void sendLogEntry(LogEntry logEntry) {
try {
logEntrySyncCommands.lpush(logEntryQueueName, logEntry);
} catch (Exception e) {
log.error("Failed to send LogEntry to Redis: {}", e.getMessage());
}
}
@Override
public LogEntry pollLogEntry() throws InterruptedException {
try {
return logEntrySyncCommands.rpop(logEntryQueueName);
} catch (Exception e) {
log.error("Failed to poll LogEntry from Redis: {}", e.getMessage());
throw new InterruptedException("Failed to poll LogEntry from Redis");
}
}
@Override
public void sendLogEntryToStorage(LogEntry logEntry) {
try {
logEntrySyncCommands.lpush(logEntryToStorageQueueName, logEntry);
} catch (Exception e) {
log.error("Failed to send LogEntry to storage via Redis: {}", e.getMessage());
}
}
@Override
public LogEntry pollLogEntryToStorage() throws InterruptedException {
try {
return logEntrySyncCommands.rpop(logEntryToStorageQueueName);
} catch (Exception e) {
log.error("Failed to poll LogEntry from storage via Redis: {}", e.getMessage());
throw new InterruptedException("Failed to poll LogEntry from storage via Redis");
}
}
@Override
public void destroy() {
connection.close();
logEntryConnection.close();
redisClient.shutdown();
}
@@ -0,0 +1,70 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.serialize;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.kafka.common.header.Headers;
import org.apache.kafka.common.serialization.Deserializer;
/**
* Kafka LogEntry deserializer using JSON
*/
@Slf4j
public class KafkaLogEntryDeserializer implements Deserializer<LogEntry> {
private final ObjectMapper objectMapper;
public KafkaLogEntryDeserializer() {
this.objectMapper = new ObjectMapper();
}
@Override
public void configure(Map<String, ?> configs, boolean isKey) {
Deserializer.super.configure(configs, isKey);
}
@Override
public LogEntry deserialize(String topic, byte[] data) {
if (data == null || data.length == 0) {
log.warn("Empty data received for topic: {}", topic);
return null;
}
try {
String jsonString = new String(data, StandardCharsets.UTF_8);
return objectMapper.readValue(jsonString, LogEntry.class);
} catch (JsonProcessingException e) {
log.error("Failed to deserialize LogEntry from JSON for topic: {}", topic, e);
return null;
}
}
@Override
public LogEntry deserialize(String topic, Headers headers, byte[] data) {
return deserialize(topic, data);
}
@Override
public void close() {
Deserializer.super.close();
}
}
@@ -0,0 +1,70 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.serialize;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.kafka.common.header.Headers;
import org.apache.kafka.common.serialization.Serializer;
/**
* Kafka LogEntry serializer using JSON
*/
@Slf4j
public class KafkaLogEntrySerializer implements Serializer<LogEntry> {
private final ObjectMapper objectMapper;
public KafkaLogEntrySerializer() {
this.objectMapper = new ObjectMapper();
}
@Override
public void configure(Map<String, ?> configs, boolean isKey) {
Serializer.super.configure(configs, isKey);
}
@Override
public byte[] serialize(String topic, LogEntry logEntry) {
if (logEntry == null) {
log.warn("LogEntry is null for topic: {}", topic);
return null;
}
try {
String jsonString = objectMapper.writeValueAsString(logEntry);
return jsonString.getBytes(StandardCharsets.UTF_8);
} catch (JsonProcessingException e) {
log.error("Failed to serialize LogEntry to JSON for topic: {}", topic, e);
return null;
}
}
@Override
public byte[] serialize(String topic, Headers headers, LogEntry logEntry) {
return serialize(topic, logEntry);
}
@Override
public void close() {
Serializer.super.close();
}
}
@@ -0,0 +1,75 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.serialize;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.lettuce.core.codec.RedisCodec;
import io.netty.buffer.Unpooled;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
/**
* Redis LogEntry codec using JSON serialization
*/
@Slf4j
public class RedisLogEntryCodec implements RedisCodec<String, LogEntry> {
private final ObjectMapper objectMapper;
public RedisLogEntryCodec() {
this.objectMapper = new ObjectMapper();
}
@Override
public String decodeKey(ByteBuffer byteBuffer) {
return Unpooled.wrappedBuffer(byteBuffer).toString(StandardCharsets.UTF_8);
}
@Override
public LogEntry decodeValue(ByteBuffer byteBuffer) {
if (byteBuffer == null || !byteBuffer.hasRemaining()) {
return null;
}
try {
String jsonString = Unpooled.wrappedBuffer(byteBuffer).toString(StandardCharsets.UTF_8);
return objectMapper.readValue(jsonString, LogEntry.class);
} catch (Exception e) {
log.error("Failed to decode LogEntry from JSON: {}", e.getMessage());
return null;
}
}
@Override
public ByteBuffer encodeKey(String s) {
return ByteBuffer.wrap(s.getBytes(StandardCharsets.UTF_8));
}
@Override
public ByteBuffer encodeValue(LogEntry logEntry) {
try {
String jsonString = objectMapper.writeValueAsString(logEntry);
return ByteBuffer.wrap(jsonString.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
log.error("Failed to encode LogEntry to JSON: {}", e.getMessage());
return null;
}
}
}
@@ -15,17 +15,17 @@
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.event;
package org.apache.hertzbeat.common.support.event;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationEvent;
/**
* OpenAI configuration change event
* Ai Provider configuration change event
*/
public class OpenAiConfigChangeEvent extends ApplicationEvent {
public class AiProviderConfigChangeEvent extends ApplicationEvent {
public OpenAiConfigChangeEvent(ApplicationContext source) {
public AiProviderConfigChangeEvent(ApplicationContext source) {
super(source);
}
}
}
@@ -0,0 +1,50 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.util;
import org.apache.hertzbeat.common.constants.JexlKeywordsEnum;
/**
* Validate JEXL rules
*/
public class JexlCheckerUtil {
public static final String SPACES_REGEX = "^\\S+(?:\\s+\\S+)+$";
/**
* Verify Keyword Information
*/
public static boolean verifyKeywords(String str) {
return JexlKeywordsEnum.match(str);
}
/**
* Verify the starting character
*/
public static boolean verifyStartCharacter(String str) {
char c = str.charAt(0);
return !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' || c == '$');
}
/**
* Verify contains spaces
*/
public static boolean verifySpaces(String str) {
return str.trim().matches(SPACES_REGEX);
}
}
@@ -19,7 +19,10 @@ package org.apache.hertzbeat.common.queue.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -49,6 +52,71 @@ class InMemoryCommonDataQueueTest {
assertEquals(metricsData, polledMetricsData);
}
@Test
void testLogEntry() throws InterruptedException {
// Create a test log entry with comprehensive data
Map<String, Object> attributes = new HashMap<>();
attributes.put("service.name", "hertzbeat");
attributes.put("service.version", "1.0.0");
Map<String, Object> resource = new HashMap<>();
resource.put("host.name", "localhost");
resource.put("os.type", "linux");
LogEntry.InstrumentationScope scope = LogEntry.InstrumentationScope.builder()
.name("org.apache.hertzbeat.test")
.version("1.0.0")
.build();
LogEntry logEntry = LogEntry.builder()
.timeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.observedTimeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.severityNumber(9) // INFO level
.severityText("INFO")
.body("Test log message for hertzbeat queue")
.attributes(attributes)
.resource(resource)
.instrumentationScope(scope)
.traceId("1234567890abcdef1234567890abcdef")
.spanId("1234567890abcdef")
.traceFlags(1)
.build();
// Test sending and polling log entry
queue.sendLogEntry(logEntry);
LogEntry polledLogEntry = queue.pollLogEntry();
assertNotNull(polledLogEntry);
assertEquals(logEntry.getSeverityText(), polledLogEntry.getSeverityText());
assertEquals(logEntry.getBody(), polledLogEntry.getBody());
assertEquals(logEntry.getTraceId(), polledLogEntry.getTraceId());
assertEquals(logEntry.getSpanId(), polledLogEntry.getSpanId());
}
@Test
void testLogEntryToStorage() throws InterruptedException {
// Create a simple log entry for storage test
LogEntry logEntry = LogEntry.builder()
.timeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.severityNumber(17) // ERROR level
.severityText("ERROR")
.body("Error log message for storage")
.build();
// Test sending and polling log entry to storage
queue.sendLogEntryToStorage(logEntry);
LogEntry polledLogEntry = queue.pollLogEntryToStorage();
assertNotNull(polledLogEntry);
assertEquals(logEntry.getSeverityText(), polledLogEntry.getSeverityText());
assertEquals(logEntry.getBody(), polledLogEntry.getBody());
assertEquals(logEntry.getSeverityNumber(), polledLogEntry.getSeverityNumber());
}
@Test
void testGetQueueSizeMetricsInfo() {
@@ -56,19 +124,29 @@ class InMemoryCommonDataQueueTest {
assertEquals(0, metricsInfo.get("metricsDataToAlertQueue"));
assertEquals(0, metricsInfo.get("metricsDataToStorageQueue"));
assertEquals(0, metricsInfo.get("logEntryQueue"));
assertEquals(0, metricsInfo.get("logEntryToStorageQueue"));
// Add metrics data and log entries
queue.sendMetricsData(CollectRep.MetricsData.newBuilder().build());
queue.sendLogEntry(LogEntry.builder().body("Test log").build());
queue.sendLogEntryToStorage(LogEntry.builder().body("Storage log").build());
metricsInfo = queue.getQueueSizeMetricsInfo();
assertEquals(1, metricsInfo.get("metricsDataToAlertQueue"));
assertEquals(0, metricsInfo.get("metricsDataToStorageQueue"));
assertEquals(1, metricsInfo.get("logEntryQueue"));
assertEquals(1, metricsInfo.get("logEntryToStorageQueue"));
}
@Test
void testDestroy() {
// Add both metrics data and log entries before destroy
queue.sendMetricsData(CollectRep.MetricsData.newBuilder().build());
queue.sendLogEntry(LogEntry.builder().body("Test log").build());
queue.sendLogEntryToStorage(LogEntry.builder().body("Storage log").build());
queue.destroy();
@@ -76,6 +154,8 @@ class InMemoryCommonDataQueueTest {
assertEquals(0, metricsInfo.get("metricsDataToAlertQueue"));
assertEquals(0, metricsInfo.get("metricsDataToStorageQueue"));
assertEquals(0, metricsInfo.get("logEntryQueue"));
assertEquals(0, metricsInfo.get("logEntryToStorageQueue"));
}
}
@@ -27,10 +27,13 @@ import static org.mockito.Mockito.when;
import static org.mockito.Mockito.withSettings;
import java.lang.reflect.Field;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.hertzbeat.common.config.CommonProperties;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
@@ -55,6 +58,15 @@ class KafkaCommonDataQueueTest {
@Mock(lenient = true)
private KafkaProducer<Long, CollectRep.MetricsData> metricsDataProducer;
@Mock(lenient = true)
private KafkaProducer<Long, LogEntry> logEntryProducer;
@Mock(lenient = true)
private KafkaConsumer<Long, LogEntry> logEntryConsumer;
@Mock(lenient = true)
private KafkaConsumer<Long, LogEntry> logEntryToStorageConsumer;
private KafkaCommonDataQueue kafkaCommonDataQueue;
private CommonProperties commonProperties;
@@ -70,6 +82,8 @@ class KafkaCommonDataQueueTest {
// Set all required topics
when(kafkaProperties.getMetricsDataTopic()).thenReturn("metricsDataTopic");
when(kafkaProperties.getLogEntryDataTopic()).thenReturn("logEntryDataTopic");
when(kafkaProperties.getLogEntryDataToStorageTopic()).thenReturn("logEntryDataToStorageTopic");
when(kafkaProperties.getAlertsDataTopic()).thenReturn("alertsDataTopic");
when(kafkaProperties.getMetricsDataToStorageTopic()).thenReturn("metricsDataToStorageTopic");
when(kafkaProperties.getServiceDiscoveryDataTopic()).thenReturn("serviceDiscoveryDataTopic");
@@ -77,12 +91,17 @@ class KafkaCommonDataQueueTest {
// Simulate the subscribe method for consumers
doNothing().when(metricsDataToAlertConsumer).subscribe(anyCollection());
doNothing().when(logEntryConsumer).subscribe(anyCollection());
doNothing().when(logEntryToStorageConsumer).subscribe(anyCollection());
kafkaCommonDataQueue = new KafkaCommonDataQueue(commonProperties);
// Use reflection to set private fields
setPrivateField(kafkaCommonDataQueue, "metricsDataProducer", metricsDataProducer);
setPrivateField(kafkaCommonDataQueue, "metricsDataToAlertConsumer", metricsDataToAlertConsumer);
setPrivateField(kafkaCommonDataQueue, "logEntryProducer", logEntryProducer);
setPrivateField(kafkaCommonDataQueue, "logEntryConsumer", logEntryConsumer);
setPrivateField(kafkaCommonDataQueue, "logEntryToStorageConsumer", logEntryToStorageConsumer);
}
@Test
@@ -122,11 +141,115 @@ class KafkaCommonDataQueueTest {
verify(metricsDataToAlertConsumer).commitAsync();
}
@Test
void testSendLogEntry() {
// Create a test log entry with comprehensive data
Map<String, Object> attributes = new HashMap<>();
attributes.put("service.name", "hertzbeat");
attributes.put("service.version", "1.0.0");
LogEntry logEntry = LogEntry.builder()
.timeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.severityNumber(9) // INFO level
.severityText("INFO")
.body("Test log message for Kafka queue")
.attributes(attributes)
.traceId("1234567890abcdef1234567890abcdef")
.spanId("1234567890abcdef")
.build();
// Test sending log entry
kafkaCommonDataQueue.sendLogEntry(logEntry);
// Verify that the producer was called
verify(logEntryProducer).send(any());
}
@Test
void testSendLogEntryToStorage() {
// Create a test log entry for storage
LogEntry logEntry = LogEntry.builder()
.timeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.severityNumber(17) // ERROR level
.severityText("ERROR")
.body("Error log message for storage via Kafka")
.build();
// Test sending log entry to storage
kafkaCommonDataQueue.sendLogEntryToStorage(logEntry);
// Verify that the producer was called
verify(logEntryProducer).send(any());
}
@Test
void testPollLogEntry() throws InterruptedException {
// Create test log entry
LogEntry expectedLogEntry = LogEntry.builder()
.timeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.severityNumber(13) // WARN level
.severityText("WARN")
.body("Test warning log message")
.build();
// Create a ConsumerRecord containing test log entry
ConsumerRecord<Long, LogEntry> record =
new ConsumerRecord<>("logEntryDataTopic", 0, 0L, 1L, expectedLogEntry);
// Create ConsumerRecords containing the log entry record
Map<TopicPartition, List<ConsumerRecord<Long, LogEntry>>> recordsMap =
Collections.singletonMap(
new TopicPartition("logEntryDataTopic", 0),
Collections.singletonList(record));
ConsumerRecords<Long, LogEntry> records = new ConsumerRecords<>(recordsMap);
when(logEntryConsumer.poll(any(Duration.class))).thenReturn(records);
LogEntry result = kafkaCommonDataQueue.pollLogEntry();
assertEquals(expectedLogEntry, result);
verify(logEntryConsumer).commitAsync();
}
@Test
void testPollLogEntryToStorage() throws InterruptedException {
// Create test log entry for storage
LogEntry expectedLogEntry = LogEntry.builder()
.timeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.severityNumber(21) // FATAL level
.severityText("FATAL")
.body("Critical error log for storage")
.build();
// Create a ConsumerRecord containing test log entry
ConsumerRecord<Long, LogEntry> record =
new ConsumerRecord<>("logEntryDataToStorageTopic", 0, 0L, 1L, expectedLogEntry);
// Create ConsumerRecords containing the log entry record
Map<TopicPartition, List<ConsumerRecord<Long, LogEntry>>> recordsMap =
Collections.singletonMap(
new TopicPartition("logEntryDataToStorageTopic", 0),
Collections.singletonList(record));
ConsumerRecords<Long, LogEntry> records = new ConsumerRecords<>(recordsMap);
when(logEntryToStorageConsumer.poll(any(Duration.class))).thenReturn(records);
LogEntry result = kafkaCommonDataQueue.pollLogEntryToStorage();
assertEquals(expectedLogEntry, result);
verify(logEntryToStorageConsumer).commitAsync();
}
@Test
void testDestroy() throws Exception {
kafkaCommonDataQueue.destroy();
// Verify that all producers and consumers are closed
verify(metricsDataToAlertConsumer).close();
verify(metricsDataProducer).close();
verify(logEntryProducer).close();
verify(logEntryConsumer).close();
verify(logEntryToStorageConsumer).close();
}
private void setPrivateField(Object object, String fieldName, Object value) throws Exception {
@@ -27,8 +27,13 @@ import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.apache.hertzbeat.common.config.CommonProperties;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.serialize.RedisLogEntryCodec;
import org.apache.hertzbeat.common.serialize.RedisMetricsDataCodec;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -46,9 +51,13 @@ class RedisCommonDataQueueTest {
@Mock
private StatefulRedisConnection<String, CollectRep.MetricsData> connection;
@Mock
private StatefulRedisConnection<String, LogEntry> logEntryConnection;
@Mock
private RedisCommands<String, CollectRep.MetricsData> syncCommands;
@Mock
private RedisCommands<String, LogEntry> logEntrySyncCommands;
private RedisClient redisClient;
private CommonProperties commonProperties;
@@ -65,12 +74,16 @@ class RedisCommonDataQueueTest {
when(commonProperties.getQueue()).thenReturn(dataQueueProperties);
when(dataQueueProperties.getRedis()).thenReturn(redisProperties);
when(redisProperties.getMetricsDataQueueNameToAlerter()).thenReturn("metricsDataQueueToAlerter");
when(redisProperties.getLogEntryQueueName()).thenReturn("logEntryQueue");
when(redisProperties.getLogEntryToStorageQueueName()).thenReturn("logEntryToStorageQueue");
when(redisProperties.getRedisHost()).thenReturn("localhost");
when(redisProperties.getRedisPort()).thenReturn(6379);
try (MockedStatic<RedisClient> mockedRedisClient = mockStatic(RedisClient.class)) {
mockedRedisClient.when(() -> RedisClient.create(any(RedisURI.class))).thenReturn(redisClient);
when(redisClient.connect(any(RedisMetricsDataCodec.class))).thenReturn(connection);
when(redisClient.connect(any(RedisLogEntryCodec.class))).thenReturn(logEntryConnection);
when(logEntryConnection.sync()).thenReturn(logEntrySyncCommands);
when(connection.sync()).thenReturn(syncCommands);
redisCommonDataQueue = new RedisCommonDataQueue(commonProperties);
@@ -100,10 +113,88 @@ class RedisCommonDataQueueTest {
verify(syncCommands).lpush("metricsDataQueueToAlerter", metricsData);
}
@Test
public void testSendLogEntry() {
// Create a test log entry with comprehensive data
Map<String, Object> attributes = new HashMap<>();
attributes.put("service.name", "hertzbeat");
attributes.put("service.version", "1.0.0");
LogEntry logEntry = LogEntry.builder()
.timeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.severityNumber(9) // INFO level
.severityText("INFO")
.body("Test log message for Redis queue")
.attributes(attributes)
.traceId("1234567890abcdef1234567890abcdef")
.spanId("1234567890abcdef")
.build();
// Test sending log entry
redisCommonDataQueue.sendLogEntry(logEntry);
// Verify that the Redis commands were called
verify(logEntrySyncCommands).lpush("logEntryQueue", logEntry);
}
@Test
public void testSendLogEntryToStorage() {
// Create a test log entry for storage
LogEntry logEntry = LogEntry.builder()
.timeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.severityNumber(17) // ERROR level
.severityText("ERROR")
.body("Error log message for storage via Redis")
.build();
// Test sending log entry to storage
redisCommonDataQueue.sendLogEntryToStorage(logEntry);
// Verify that the Redis commands were called
verify(logEntrySyncCommands).lpush("logEntryToStorageQueue", logEntry);
}
@Test
public void testPollLogEntry() throws Exception {
// Create test log entry
LogEntry expectedLogEntry = LogEntry.builder()
.timeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.severityNumber(13) // WARN level
.severityText("WARN")
.body("Test warning log message")
.build();
when(logEntrySyncCommands.rpop("logEntryQueue")).thenReturn(expectedLogEntry);
LogEntry result = redisCommonDataQueue.pollLogEntry();
assertEquals(expectedLogEntry, result);
verify(logEntrySyncCommands).rpop("logEntryQueue");
}
@Test
public void testPollLogEntryToStorage() throws Exception {
// Create test log entry for storage
LogEntry expectedLogEntry = LogEntry.builder()
.timeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.severityNumber(21) // FATAL level
.severityText("FATAL")
.body("Critical error log for storage")
.build();
when(logEntrySyncCommands.rpop("logEntryToStorageQueue")).thenReturn(expectedLogEntry);
LogEntry result = redisCommonDataQueue.pollLogEntryToStorage();
assertEquals(expectedLogEntry, result);
verify(logEntrySyncCommands).rpop("logEntryToStorageQueue");
}
@Test
public void testDestroy() {
redisCommonDataQueue.destroy();
verify(connection).close();
verify(logEntryConnection).close();
verify(redisClient).shutdown();
}
}
@@ -0,0 +1,267 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.serialize;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.kafka.common.header.Headers;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Test case for {@link KafkaLogEntryDeserializer}
*/
@ExtendWith(MockitoExtension.class)
class KafkaLogEntryDeserializerTest {
private KafkaLogEntryDeserializer deserializer;
private KafkaLogEntrySerializer serializer;
@Mock
private Map<String, ?> configs;
@Mock
private Headers headers;
@BeforeEach
void setUp() {
deserializer = new KafkaLogEntryDeserializer();
serializer = new KafkaLogEntrySerializer();
}
@Test
void testConfigure() {
// Test that configure method doesn't throw any exceptions
deserializer.configure(configs, false);
deserializer.configure(configs, true);
}
@Test
void testDeserializeWithValidLogEntry() {
// Create a comprehensive log entry
Map<String, Object> attributes = new HashMap<>();
attributes.put("service.name", "hertzbeat");
attributes.put("service.version", "1.0.0");
attributes.put("environment", "test");
Map<String, Object> resource = new HashMap<>();
resource.put("host.name", "localhost");
resource.put("os.type", "linux");
LogEntry.InstrumentationScope scope = LogEntry.InstrumentationScope.builder()
.name("org.apache.hertzbeat.test")
.version("1.0.0")
.attributes(new HashMap<>())
.droppedAttributesCount(0)
.build();
LogEntry originalLogEntry = LogEntry.builder()
.timeUnixNano(1234567890123456789L)
.observedTimeUnixNano(1234567890123456790L)
.severityNumber(9) // INFO level
.severityText("INFO")
.body("Test log message for deserialization")
.attributes(attributes)
.droppedAttributesCount(0)
.traceId("1234567890abcdef1234567890abcdef")
.spanId("1234567890abcdef")
.traceFlags(1)
.resource(resource)
.instrumentationScope(scope)
.build();
// Serialize the log entry to bytes
byte[] bytes = serializer.serialize("test-topic", originalLogEntry);
assertNotNull(bytes);
// Deserialize the bytes back to log entry
LogEntry deserializedLogEntry = deserializer.deserialize("test-topic", bytes);
assertNotNull(deserializedLogEntry);
assertEquals(originalLogEntry.getTimeUnixNano(), deserializedLogEntry.getTimeUnixNano());
assertEquals(originalLogEntry.getObservedTimeUnixNano(), deserializedLogEntry.getObservedTimeUnixNano());
assertEquals(originalLogEntry.getSeverityNumber(), deserializedLogEntry.getSeverityNumber());
assertEquals(originalLogEntry.getSeverityText(), deserializedLogEntry.getSeverityText());
assertEquals(originalLogEntry.getBody(), deserializedLogEntry.getBody());
assertEquals(originalLogEntry.getTraceId(), deserializedLogEntry.getTraceId());
assertEquals(originalLogEntry.getSpanId(), deserializedLogEntry.getSpanId());
assertEquals(originalLogEntry.getTraceFlags(), deserializedLogEntry.getTraceFlags());
assertEquals(originalLogEntry.getDroppedAttributesCount(), deserializedLogEntry.getDroppedAttributesCount());
// Verify attributes
assertNotNull(deserializedLogEntry.getAttributes());
assertEquals("hertzbeat", deserializedLogEntry.getAttributes().get("service.name"));
assertEquals("1.0.0", deserializedLogEntry.getAttributes().get("service.version"));
assertEquals("test", deserializedLogEntry.getAttributes().get("environment"));
// Verify resource
assertNotNull(deserializedLogEntry.getResource());
assertEquals("localhost", deserializedLogEntry.getResource().get("host.name"));
assertEquals("linux", deserializedLogEntry.getResource().get("os.type"));
// Verify instrumentation scope
assertNotNull(deserializedLogEntry.getInstrumentationScope());
assertEquals("org.apache.hertzbeat.test", deserializedLogEntry.getInstrumentationScope().getName());
assertEquals("1.0.0", deserializedLogEntry.getInstrumentationScope().getVersion());
}
@Test
void testDeserializeWithSimpleLogEntry() {
// Test with minimal log entry
LogEntry originalLogEntry = LogEntry.builder()
.timeUnixNano(System.currentTimeMillis() * 1_000_000L)
.severityNumber(17) // ERROR level
.severityText("ERROR")
.body("Simple error message")
.build();
// Serialize and deserialize
byte[] bytes = serializer.serialize("test-topic", originalLogEntry);
LogEntry deserializedLogEntry = deserializer.deserialize("test-topic", bytes);
assertNotNull(deserializedLogEntry);
assertEquals(originalLogEntry.getTimeUnixNano(), deserializedLogEntry.getTimeUnixNano());
assertEquals(originalLogEntry.getSeverityNumber(), deserializedLogEntry.getSeverityNumber());
assertEquals(originalLogEntry.getSeverityText(), deserializedLogEntry.getSeverityText());
assertEquals(originalLogEntry.getBody(), deserializedLogEntry.getBody());
}
@Test
void testDeserializeWithNullData() {
// Test deserialization with null data
LogEntry logEntry = deserializer.deserialize("test-topic", null);
assertNull(logEntry);
}
@Test
void testDeserializeWithEmptyData() {
// Test deserialization with empty data
byte[] emptyData = new byte[0];
LogEntry logEntry = deserializer.deserialize("test-topic", emptyData);
assertNull(logEntry);
}
@Test
void testDeserializeWithInvalidData() {
// Test deserialization with invalid JSON data
byte[] invalidData = "invalid json data".getBytes(StandardCharsets.UTF_8);
LogEntry logEntry = deserializer.deserialize("test-topic", invalidData);
assertNull(logEntry);
}
@Test
void testDeserializeWithMalformedJson() {
// Test deserialization with malformed JSON
String malformedJson = "{\"severityText\":\"INFO\",\"body\":\"test\","; // Missing closing brace
byte[] malformedData = malformedJson.getBytes(StandardCharsets.UTF_8);
LogEntry logEntry = deserializer.deserialize("test-topic", malformedData);
assertNull(logEntry);
}
@Test
void testDeserializeWithHeaders() {
// Test deserialization with headers
LogEntry originalLogEntry = LogEntry.builder()
.timeUnixNano(System.currentTimeMillis() * 1_000_000L)
.severityNumber(13) // WARN level
.severityText("WARN")
.body("Warning message with headers")
.build();
// Serialize and deserialize with headers
byte[] bytes = serializer.serialize("test-topic", headers, originalLogEntry);
LogEntry deserializedLogEntry = deserializer.deserialize("test-topic", headers, bytes);
assertNotNull(deserializedLogEntry);
assertEquals(originalLogEntry.getSeverityText(), deserializedLogEntry.getSeverityText());
assertEquals(originalLogEntry.getBody(), deserializedLogEntry.getBody());
assertEquals(originalLogEntry.getSeverityNumber(), deserializedLogEntry.getSeverityNumber());
}
@Test
void testDeserializeWithComplexAttributes() {
// Test with complex nested attributes
Map<String, Object> attributes = new HashMap<>();
attributes.put("string_value", "test_string");
attributes.put("numeric_value", 42);
attributes.put("boolean_value", true);
// Note: Nested objects will be deserialized as LinkedHashMap by Jackson
Map<String, Object> nestedMap = new HashMap<>();
nestedMap.put("nested_key", "nested_value");
attributes.put("nested_object", nestedMap);
LogEntry originalLogEntry = LogEntry.builder()
.timeUnixNano(System.currentTimeMillis() * 1_000_000L)
.severityNumber(5) // DEBUG level
.severityText("DEBUG")
.body("Debug message with complex attributes")
.attributes(attributes)
.build();
// Serialize and deserialize
byte[] bytes = serializer.serialize("test-topic", originalLogEntry);
LogEntry deserializedLogEntry = deserializer.deserialize("test-topic", bytes);
assertNotNull(deserializedLogEntry);
assertEquals(originalLogEntry.getSeverityText(), deserializedLogEntry.getSeverityText());
assertEquals(originalLogEntry.getBody(), deserializedLogEntry.getBody());
// Verify complex attributes
assertNotNull(deserializedLogEntry.getAttributes());
assertEquals("test_string", deserializedLogEntry.getAttributes().get("string_value"));
assertEquals(42, deserializedLogEntry.getAttributes().get("numeric_value"));
assertEquals(true, deserializedLogEntry.getAttributes().get("boolean_value"));
// Verify nested object (will be LinkedHashMap after JSON deserialization)
Object nestedObject = deserializedLogEntry.getAttributes().get("nested_object");
assertNotNull(nestedObject);
}
@Test
void testDeserializeWithPartialLogEntry() {
// Test with valid JSON but partial log entry structure
String partialJson = "{\"severityText\":\"INFO\",\"body\":\"partial log entry\"}";
byte[] partialData = partialJson.getBytes(StandardCharsets.UTF_8);
LogEntry logEntry = deserializer.deserialize("test-topic", partialData);
assertNotNull(logEntry);
assertEquals("INFO", logEntry.getSeverityText());
assertEquals("partial log entry", logEntry.getBody());
assertNull(logEntry.getSeverityNumber()); // Not provided in JSON
assertNull(logEntry.getTimeUnixNano()); // Not provided in JSON
}
@Test
void testClose() {
// Test that close method doesn't throw any exceptions
deserializer.close();
}
}
@@ -0,0 +1,202 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.serialize;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.kafka.common.header.Headers;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Test case for {@link KafkaLogEntrySerializer}
*/
@ExtendWith(MockitoExtension.class)
class KafkaLogEntrySerializerTest {
private KafkaLogEntrySerializer serializer;
@Mock
private Map<String, ?> configs;
@Mock
private Headers headers;
@BeforeEach
void setUp() {
serializer = new KafkaLogEntrySerializer();
}
@Test
void testConfigure() {
// Test that configure method doesn't throw any exceptions
serializer.configure(configs, false);
serializer.configure(configs, true);
}
@Test
void testSerializeWithLogEntry() {
// Create a comprehensive log entry for testing
Map<String, Object> attributes = new HashMap<>();
attributes.put("service.name", "hertzbeat");
attributes.put("service.version", "1.0.0");
attributes.put("environment", "test");
Map<String, Object> resource = new HashMap<>();
resource.put("host.name", "localhost");
resource.put("os.type", "linux");
LogEntry.InstrumentationScope scope = LogEntry.InstrumentationScope.builder()
.name("org.apache.hertzbeat.test")
.version("1.0.0")
.attributes(new HashMap<>())
.droppedAttributesCount(0)
.build();
LogEntry logEntry = LogEntry.builder()
.timeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.observedTimeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.severityNumber(9) // INFO level
.severityText("INFO")
.body("Test log message for serialization")
.attributes(attributes)
.droppedAttributesCount(0)
.traceId("1234567890abcdef1234567890abcdef")
.spanId("1234567890abcdef")
.traceFlags(1)
.resource(resource)
.instrumentationScope(scope)
.build();
byte[] bytes = serializer.serialize("test-topic", logEntry);
assertNotNull(bytes);
assertTrue(bytes.length > 0);
// Verify the JSON contains expected fields
String jsonString = new String(bytes, StandardCharsets.UTF_8);
assertTrue(jsonString.contains("\"severityText\":\"INFO\""));
assertTrue(jsonString.contains("\"body\":\"Test log message for serialization\""));
assertTrue(jsonString.contains("\"traceId\":\"1234567890abcdef1234567890abcdef\""));
assertTrue(jsonString.contains("\"spanId\":\"1234567890abcdef\""));
}
@Test
void testSerializeWithSimpleLogEntry() {
// Test with minimal log entry
LogEntry logEntry = LogEntry.builder()
.timeUnixNano(System.currentTimeMillis() * 1_000_000L)
.severityNumber(17) // ERROR level
.severityText("ERROR")
.body("Simple error message")
.build();
byte[] bytes = serializer.serialize("test-topic", logEntry);
assertNotNull(bytes);
assertTrue(bytes.length > 0);
String jsonString = new String(bytes, StandardCharsets.UTF_8);
assertTrue(jsonString.contains("\"severityText\":\"ERROR\""));
assertTrue(jsonString.contains("\"body\":\"Simple error message\""));
assertTrue(jsonString.contains("\"severityNumber\":17"));
}
@Test
void testSerializeWithNullLogEntry() {
// Test serialization with null log entry
byte[] bytes = serializer.serialize("test-topic", null);
assertNull(bytes);
}
@Test
void testSerializeWithHeaders() {
// Test serialization with headers
LogEntry logEntry = LogEntry.builder()
.timeUnixNano(System.currentTimeMillis() * 1_000_000L)
.severityNumber(13) // WARN level
.severityText("WARN")
.body("Warning message with headers")
.build();
byte[] bytes = serializer.serialize("test-topic", headers, logEntry);
assertNotNull(bytes);
assertTrue(bytes.length > 0);
String jsonString = new String(bytes, StandardCharsets.UTF_8);
assertTrue(jsonString.contains("\"severityText\":\"WARN\""));
assertTrue(jsonString.contains("\"body\":\"Warning message with headers\""));
}
@Test
void testSerializeWithNullLogEntryAndHeaders() {
// Test serialization with null log entry and headers
byte[] bytes = serializer.serialize("test-topic", headers, null);
assertNull(bytes);
}
@Test
void testSerializeWithComplexAttributes() {
// Test with complex nested attributes
Map<String, Object> attributes = new HashMap<>();
attributes.put("string_value", "test_string");
attributes.put("numeric_value", 42);
attributes.put("boolean_value", true);
Map<String, Object> nestedMap = new HashMap<>();
nestedMap.put("nested_key", "nested_value");
attributes.put("nested_object", nestedMap);
LogEntry logEntry = LogEntry.builder()
.timeUnixNano(System.currentTimeMillis() * 1_000_000L)
.severityNumber(5) // DEBUG level
.severityText("DEBUG")
.body("Debug message with complex attributes")
.attributes(attributes)
.build();
byte[] bytes = serializer.serialize("test-topic", logEntry);
assertNotNull(bytes);
assertTrue(bytes.length > 0);
String jsonString = new String(bytes, StandardCharsets.UTF_8);
assertTrue(jsonString.contains("\"severityText\":\"DEBUG\""));
assertTrue(jsonString.contains("\"string_value\":\"test_string\""));
assertTrue(jsonString.contains("\"numeric_value\":42"));
assertTrue(jsonString.contains("\"boolean_value\":true"));
}
@Test
void testClose() {
// Test that close method doesn't throw any exceptions
serializer.close();
}
}
@@ -0,0 +1,161 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.serialize;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Test case for {@link RedisLogEntryCodec}
*/
@ExtendWith(MockitoExtension.class)
class RedisLogEntryCodecTest {
private RedisLogEntryCodec codec;
@BeforeEach
void setUp() {
codec = new RedisLogEntryCodec();
}
@Test
void testEncodeDecodeKey() {
String key = "test-log-key";
// Test encoding
ByteBuffer encodedKey = codec.encodeKey(key);
assertNotNull(encodedKey);
// Test decoding
String decodedKey = codec.decodeKey(encodedKey);
assertEquals(key, decodedKey);
}
@Test
void testEncodeDecodeSimpleLogEntry() {
// Create a simple log entry
LogEntry logEntry = LogEntry.builder()
.timeUnixNano(System.currentTimeMillis() * 1_000_000L)
.severityNumber(17) // ERROR level
.severityText("ERROR")
.body("Simple error message")
.build();
// Test encoding
ByteBuffer encodedValue = codec.encodeValue(logEntry);
assertNotNull(encodedValue);
assertTrue(encodedValue.hasRemaining());
// Test decoding
LogEntry decodedLogEntry = codec.decodeValue(encodedValue);
assertNotNull(decodedLogEntry);
assertEquals(logEntry.getSeverityNumber(), decodedLogEntry.getSeverityNumber());
assertEquals(logEntry.getSeverityText(), decodedLogEntry.getSeverityText());
assertEquals(logEntry.getBody(), decodedLogEntry.getBody());
assertEquals(logEntry.getTimeUnixNano(), decodedLogEntry.getTimeUnixNano());
}
@Test
void testEncodeDecodeComplexLogEntry() {
// Create a comprehensive log entry for testing
Map<String, Object> attributes = new HashMap<>();
attributes.put("service.name", "hertzbeat");
attributes.put("service.version", "1.0.0");
attributes.put("environment", "test");
attributes.put("numeric_value", 42);
attributes.put("boolean_value", true);
Map<String, Object> resource = new HashMap<>();
resource.put("host.name", "localhost");
resource.put("os.type", "linux");
LogEntry.InstrumentationScope scope = LogEntry.InstrumentationScope.builder()
.name("org.apache.hertzbeat.test")
.version("1.0.0")
.attributes(new HashMap<>())
.droppedAttributesCount(0)
.build();
LogEntry logEntry = LogEntry.builder()
.timeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.observedTimeUnixNano(Instant.now().toEpochMilli() * 1_000_000L)
.severityNumber(9) // INFO level
.severityText("INFO")
.body("Test log message for Redis codec")
.attributes(attributes)
.droppedAttributesCount(0)
.traceId("1234567890abcdef1234567890abcdef")
.spanId("1234567890abcdef")
.traceFlags(1)
.resource(resource)
.instrumentationScope(scope)
.build();
// Test encoding
ByteBuffer encodedValue = codec.encodeValue(logEntry);
assertNotNull(encodedValue);
assertTrue(encodedValue.hasRemaining());
// Verify the JSON structure
String jsonString = new String(encodedValue.array(), StandardCharsets.UTF_8);
assertTrue(jsonString.contains("\"severityText\":\"INFO\""));
assertTrue(jsonString.contains("\"body\":\"Test log message for Redis codec\""));
assertTrue(jsonString.contains("\"traceId\":\"1234567890abcdef1234567890abcdef\""));
assertTrue(jsonString.contains("\"spanId\":\"1234567890abcdef\""));
// Test decoding
LogEntry decodedLogEntry = codec.decodeValue(encodedValue);
assertNotNull(decodedLogEntry);
assertEquals(logEntry.getSeverityNumber(), decodedLogEntry.getSeverityNumber());
assertEquals(logEntry.getSeverityText(), decodedLogEntry.getSeverityText());
assertEquals(logEntry.getBody(), decodedLogEntry.getBody());
assertEquals(logEntry.getTraceId(), decodedLogEntry.getTraceId());
assertEquals(logEntry.getSpanId(), decodedLogEntry.getSpanId());
assertEquals(logEntry.getTraceFlags(), decodedLogEntry.getTraceFlags());
}
@Test
void testDecodeEmptyByteBuffer() {
// Test decoding empty byte buffer
ByteBuffer emptyBuffer = ByteBuffer.allocate(0);
LogEntry decodedLogEntry = codec.decodeValue(emptyBuffer);
assertNull(decodedLogEntry);
}
@Test
void testDecodeInvalidJson() {
// Test decoding invalid JSON
String invalidJson = "{ invalid json }";
ByteBuffer invalidBuffer = ByteBuffer.wrap(invalidJson.getBytes(StandardCharsets.UTF_8));
LogEntry decodedLogEntry = codec.decodeValue(invalidBuffer);
assertNull(decodedLogEntry);
}
}
+160
View File
@@ -0,0 +1,160 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat-e2e</artifactId>
<version>2.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>hertzbeat-log-e2e</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<okhttp.version>4.12.0</okhttp.version>
<jackson.version>2.15.2</jackson.version>
<maven.resources.plugin.version>3.3.1</maven.resources.plugin.version>
<maven.dependency.plugin.version>3.6.1</maven.dependency.plugin.version>
</properties>
<dependencies>
<!-- HertzBeat modules -->
<dependency>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat-manager</artifactId>
<version>${hertzbeat.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat-log</artifactId>
<version>${hertzbeat.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat-alerter</artifactId>
<version>${hertzbeat.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat-warehouse</artifactId>
<version>${hertzbeat.version}</version>
<scope>test</scope>
</dependency>
<!-- TestContainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- AssertJ -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<!-- Awaitility -->
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Copy manager resources to test classpath -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>${maven.dependency.plugin.version}</version>
<executions>
<execution>
<id>unpack-manager-resources</id>
<phase>generate-test-resources</phase>
<goals>
<goal>unpack</goal>
</goals>
<configuration>
<artifactItems>
<artifactItem>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat-manager</artifactId>
<version>${hertzbeat.version}</version>
<type>jar</type>
<overWrite>true</overWrite>
<outputDirectory>${project.build.testOutputDirectory}</outputDirectory>
<includes>
define/**,
db/**,
templates/**,
grafana/**,
*.yml,
*.xml,
*.txt
</includes>
</artifactItem>
</artifactItems>
</configuration>
</execution>
</executions>
</plugin>
<!-- Ensure test resources are processed -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>${maven.resources.plugin.version}</version>
<executions>
<execution>
<id>copy-test-resources</id>
<phase>generate-test-resources</phase>
<goals>
<goal>testResources</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,230 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.alert;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.calculate.periodic.PeriodicAlertRuleScheduler;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.MountableFile;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.doAnswer;
/**
* E2E tests for periodic log alert processing.
*/
@SpringBootTest(classes = org.apache.hertzbeat.manager.Manager.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Slf4j
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class LogPeriodicAlertE2eTest {
private static final String VECTOR_IMAGE = "timberio/vector:latest-alpine";
private static final int VECTOR_PORT = 8686;
private static final String VECTOR_CONFIG_PATH = "/etc/vector/vector.yml";
private static final String ENV_HERTZBEAT_PORT = "HERTZBEAT_PORT";
private static final String GREPTIME_IMAGE = "greptime/greptimedb:latest";
private static final int GREPTIME_HTTP_PORT = 4000;
private static final int GREPTIME_GRPC_PORT = 4001;
private static final Duration CONTAINER_STARTUP_TIMEOUT = Duration.ofSeconds(120);
@LocalServerPort
private int port;
@Autowired
PeriodicAlertRuleScheduler periodicAlertRuleScheduler;
AlertDefine errorCountAlertByGroup;
AlertDefine errorCountAlertByIndividual;
@SpyBean
private AlarmCommonReduce alarmCommonReduce;
static GenericContainer<?> vector;
static GenericContainer<?> greptimedb;
static {
greptimedb = new GenericContainer<>(DockerImageName.parse(GREPTIME_IMAGE))
.withExposedPorts(GREPTIME_HTTP_PORT, GREPTIME_GRPC_PORT)
.withCommand("standalone", "start",
"--http-addr", "0.0.0.0:" + GREPTIME_HTTP_PORT,
"--rpc-bind-addr", "0.0.0.0:" + GREPTIME_GRPC_PORT)
.waitingFor(Wait.forListeningPorts(GREPTIME_HTTP_PORT, GREPTIME_GRPC_PORT))
.withStartupTimeout(CONTAINER_STARTUP_TIMEOUT);
greptimedb.start();
}
@DynamicPropertySource
static void greptimeProps(DynamicPropertyRegistry r) {
// Configure GreptimeDB storage
r.add("warehouse.store.jpa.enabled", () -> "false");
r.add("warehouse.store.greptime.enabled", () -> "true");
r.add("warehouse.store.greptime.http-endpoint", () -> "http://localhost:" + greptimedb.getMappedPort(GREPTIME_HTTP_PORT));
r.add("warehouse.store.greptime.grpc-endpoints", () -> "localhost:" + greptimedb.getMappedPort(GREPTIME_GRPC_PORT));
r.add("warehouse.store.greptime.username", () -> "");
r.add("warehouse.store.greptime.password", () -> "");
}
@BeforeAll
void setUpAll() {
// Setup test alert definitions
setupTestAlertDefines();
Testcontainers.exposeHostPorts(port);
vector = new GenericContainer<>(DockerImageName.parse(VECTOR_IMAGE))
.withExposedPorts(VECTOR_PORT)
.withCopyFileToContainer(MountableFile.forClasspathResource("vector.yml"), VECTOR_CONFIG_PATH)
.withCommand("--config", "/etc/vector/vector.yml", "--verbose")
.withLogConsumer(outputFrame -> log.info("Vector: {}", outputFrame.getUtf8String()))
.withNetwork(Network.newNetwork())
.withEnv(ENV_HERTZBEAT_PORT, String.valueOf(port))
.waitingFor(Wait.forListeningPort())
.withStartupTimeout(CONTAINER_STARTUP_TIMEOUT);
vector.start();
}
@Test
void testPeriodicLogAlertWithIndividualAlert() {
List<SingleAlert> capturedAlerts = new ArrayList<>();
doAnswer(invocation -> {
SingleAlert alert = invocation.getArgument(0);
capturedAlerts.add(alert);
return null;
}).when(alarmCommonReduce).reduceAndSendAlarm(any(SingleAlert.class));
periodicAlertRuleScheduler.updateSchedule(errorCountAlertByIndividual);
// Wait for periodic individual alert to be generated through AlarmCommonReduce
await().atMost(Duration.ofSeconds(60))
.pollInterval(Duration.ofSeconds(3))
.untilAsserted(() -> assertFalse(capturedAlerts.isEmpty(),
"Should have generated at least one periodic individual alert"));
// Verify alert properties
SingleAlert firstAlert = capturedAlerts.get(0);
assertNotNull(firstAlert, "First periodic alert should not be null");
assertEquals(CommonConstants.ALERT_STATUS_FIRING, firstAlert.getStatus(), "Alert should be in firing status");
assertNotNull(firstAlert.getLabels(), "Alert should have labels");
assertTrue(firstAlert.getLabels().containsKey(CommonConstants.LABEL_ALERT_SEVERITY), "Alert should have severity label");
assertEquals(CommonConstants.ALERT_SEVERITY_CRITICAL, firstAlert.getLabels().get(CommonConstants.LABEL_ALERT_SEVERITY), "Alert should have critical severity");
}
@Test
void testPeriodicLogAlertWithGroupAlert() {
List<List<SingleAlert>> capturedGroupAlerts = new ArrayList<>();
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
List<SingleAlert> alerts = invocation.getArgument(1);
capturedGroupAlerts.add(alerts);
return null;
}).when(alarmCommonReduce).reduceAndSendAlarmGroup(anyMap(), anyList());
periodicAlertRuleScheduler.updateSchedule(errorCountAlertByGroup);
await().atMost(Duration.ofSeconds(60))
.pollInterval(Duration.ofSeconds(3))
.untilAsserted(() -> assertFalse(capturedGroupAlerts.isEmpty(),
"Should have generated periodic error count group alert"));
List<SingleAlert> groupAlerts = capturedGroupAlerts.get(0);
assertNotNull(groupAlerts, "Group alerts should not be null");
assertFalse(groupAlerts.isEmpty(), "Group alerts should not be empty");
SingleAlert anyAlert = groupAlerts.get(0);
assertEquals(CommonConstants.ALERT_STATUS_FIRING, anyAlert.getStatus(), "Alert should be in firing status");
assertNotNull(anyAlert.getLabels(), "Alert should have labels");
assertEquals(CommonConstants.ALERT_SEVERITY_CRITICAL, anyAlert.getLabels().get(CommonConstants.LABEL_ALERT_SEVERITY), "Alert should have critical severity");
assertTrue(anyAlert.getTriggerTimes() >= 1, "Alert should indicate aggregated trigger times");
}
/**
* Setup test alert definitions for periodic log processing
*/
private void setupTestAlertDefines() {
// group
errorCountAlertByGroup = AlertDefine.builder()
.id(10L)
.name("periodic_error_count_alert_group")
.type(CommonConstants.LOG_ALERT_THRESHOLD_TYPE_PERIODIC)
.expr("SELECT COUNT(*) as error_count FROM hertzbeat_logs WHERE time_unix_nano > NOW() - INTERVAL '10 minute'")
.period(10) // Faster schedule for tests
.template("High error count detected: {{ error_count }} errors in last period")
.datasource("sql")
.enable(true)
.build();
Map<String, String> errorLabelsByGroup = new HashMap<>();
errorLabelsByGroup.put(CommonConstants.LABEL_ALERT_SEVERITY, CommonConstants.ALERT_SEVERITY_CRITICAL);
errorLabelsByGroup.put(CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_GROUP);
errorLabelsByGroup.put("type", "error_count");
errorCountAlertByGroup.setLabels(errorLabelsByGroup);
// individual
errorCountAlertByIndividual = AlertDefine.builder()
.id(11L)
.name("periodic_error_count_alert_individual")
.type(CommonConstants.LOG_ALERT_THRESHOLD_TYPE_PERIODIC)
.expr("SELECT COUNT(*) as error_count, severity_text FROM hertzbeat_logs "
+ "WHERE severity_text = 'ERROR' AND time_unix_nano > NOW() - INTERVAL '5 minute' GROUP BY severity_text HAVING COUNT(*) > 2")
.period(10) // Faster schedule for tests
.template("High error count detected: {{ error_count }} errors in last period")
.datasource("sql")
.enable(true)
.build();
Map<String, String> errorLabelsByIndividual = new HashMap<>();
errorLabelsByIndividual.put(CommonConstants.LABEL_ALERT_SEVERITY, CommonConstants.ALERT_SEVERITY_CRITICAL);
errorLabelsByIndividual.put(CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_INDIVIDUAL);
errorLabelsByIndividual.put("type", "error_count");
errorCountAlertByIndividual.setLabels(errorLabelsByIndividual);
}
}
@@ -0,0 +1,201 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.alert;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.common.cache.CacheFactory;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.AlertDefine;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.MountableFile;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.doAnswer;
/**
* E2E tests for real-time log alert processing.
*/
@SpringBootTest(classes = org.apache.hertzbeat.manager.Manager.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Slf4j
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class LogRealTimeAlertE2eTest {
private static final String VECTOR_IMAGE = "timberio/vector:latest-alpine";
private static final int VECTOR_PORT = 8686;
private static final String VECTOR_CONFIG_PATH = "/etc/vector/vector.yml";
private static final String ENV_HERTZBEAT_PORT = "HERTZBEAT_PORT";
private static final Duration CONTAINER_STARTUP_TIMEOUT = Duration.ofSeconds(120);
@LocalServerPort
private int port;
private final List<SingleAlert> capturedAlerts = new ArrayList<>();
private final ArrayList<List<SingleAlert>> capturedGroupAlerts = new ArrayList<>();
@SpyBean
private AlarmCommonReduce alarmCommonReduce;
static GenericContainer<?> vector;
@BeforeAll
void setUpAll() {
// Setup test alert definitions
setupTestAlertDefines();
// Expose host ports for testcontainers
Testcontainers.exposeHostPorts(port);
vector = new GenericContainer<>(DockerImageName.parse(VECTOR_IMAGE))
.withExposedPorts(VECTOR_PORT)
.withCopyFileToContainer(MountableFile.forClasspathResource("vector.yml"), VECTOR_CONFIG_PATH)
.withCommand("--config", "/etc/vector/vector.yml", "--verbose")
.withLogConsumer(outputFrame -> log.info("Vector: {}", outputFrame.getUtf8String()))
.withNetwork(Network.newNetwork())
.withEnv(ENV_HERTZBEAT_PORT, String.valueOf(port))
.waitingFor(Wait.forListeningPort())
.withStartupTimeout(CONTAINER_STARTUP_TIMEOUT);
vector.start();
}
@Test
void testRealTimeLogAlertWithIndividualAlert() {
doAnswer(invocation -> {
SingleAlert alert = invocation.getArgument(0);
capturedAlerts.add(alert);
return null;
}).when(alarmCommonReduce).reduceAndSendAlarm(any(SingleAlert.class));
// Clear any existing captured alerts
capturedAlerts.clear();
// Wait for real alert to be generated through AlarmCommonReduce
await().atMost(Duration.ofSeconds(60))
.pollInterval(Duration.ofSeconds(2))
.untilAsserted(() -> assertFalse(capturedAlerts.isEmpty(),
"Should have generated at least one alert for error logs"));
// Verify alert properties
SingleAlert firstAlert = capturedAlerts.get(0);
assertNotNull(firstAlert, "First alert should not be null");
assertEquals(CommonConstants.ALERT_STATUS_FIRING, firstAlert.getStatus(), "Alert should be in firing status");
assertNotNull(firstAlert.getLabels(), "Alert should have labels");
assertTrue(firstAlert.getLabels().containsKey(CommonConstants.LABEL_ALERT_SEVERITY), "Alert should have severity label");
assertEquals(CommonConstants.ALERT_SEVERITY_CRITICAL, firstAlert.getLabels().get(CommonConstants.LABEL_ALERT_SEVERITY), "Alert should have critical severity");
}
@Test
void testRealTimeLogAlertWithGroupAlert() {
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
List<SingleAlert> alerts = invocation.getArgument(1);
capturedGroupAlerts.add(alerts);
return null;
}).when(alarmCommonReduce).reduceAndSendAlarmGroup(anyMap(), anyList());
// Clear any existing captured alerts
capturedGroupAlerts.clear();
// Wait for group alert to be generated via AlarmCommonReduce
await().atMost(Duration.ofSeconds(60))
.pollInterval(Duration.ofSeconds(3))
.untilAsserted(() -> assertFalse(capturedGroupAlerts.isEmpty(),
"Should have generated high frequency warning group alert"));
List<SingleAlert> groupAlerts = capturedGroupAlerts.get(0);
assertNotNull(groupAlerts, "Group alerts should not be null");
assertTrue(groupAlerts.size() >= 2, "Group should contain at least 5 alerts");
SingleAlert anyAlert = groupAlerts.get(0);
assertEquals(CommonConstants.ALERT_STATUS_FIRING, anyAlert.getStatus(), "Alert should be in firing status");
assertNotNull(anyAlert.getLabels(), "Alert should have labels");
assertEquals(CommonConstants.ALERT_SEVERITY_WARNING, anyAlert.getLabels().get(CommonConstants.LABEL_ALERT_SEVERITY), "Alert should have warning severity");
assertTrue(anyAlert.getTriggerTimes() >= 2, "Alert should indicate high frequency trigger");
}
/**
* Setup test alert definitions for real-time log processing
*/
private void setupTestAlertDefines() {
List<AlertDefine> alertDefines = new ArrayList<>();
// Create error log alert definition
AlertDefine errorLogAlert = AlertDefine.builder()
.id(1L)
.name("error_log_alert")
.type(CommonConstants.LOG_ALERT_THRESHOLD_TYPE_REALTIME)
.expr("log.severityText == 'ERROR'")
.period(10) // 10 seconds window
.times(1) // Trigger on first occurrence
.template("Error detected: {{ body }}")
.enable(true)
.build();
Map<String, String> errorLabels = new HashMap<>();
errorLabels.put(CommonConstants.LABEL_ALERT_SEVERITY, CommonConstants.ALERT_SEVERITY_CRITICAL);
errorLabels.put(CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_INDIVIDUAL);
errorLogAlert.setLabels(errorLabels);
alertDefines.add(errorLogAlert);
// Create high frequency warning alert definition
AlertDefine highFrequencyWarning = AlertDefine.builder()
.id(2L)
.name("high_frequency_warning")
.type(CommonConstants.LOG_ALERT_THRESHOLD_TYPE_REALTIME)
.expr("log.severityText == 'ERROR'")
.period(10)
.times(2)
.template("High frequency warnings detected: {{ __rows__ }} warnings in 30 seconds")
.enable(true)
.build();
Map<String, String> warningLabels = new HashMap<>();
warningLabels.put(CommonConstants.LABEL_ALERT_SEVERITY, CommonConstants.ALERT_SEVERITY_WARNING);
warningLabels.put(CommonConstants.ALERT_MODE_LABEL, CommonConstants.ALERT_MODE_GROUP);
highFrequencyWarning.setLabels(warningLabels);
alertDefines.add(highFrequencyWarning);
// Set alert definitions in cache
CacheFactory.setLogAlertDefineCache(alertDefines);
}
}
@@ -0,0 +1,109 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.ingestion;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.MountableFile;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* E2E tests for log ingestion.
*/
@SpringBootTest(classes = org.apache.hertzbeat.manager.Manager.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Slf4j
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class LogIngestionE2eTest {
private static final String VECTOR_IMAGE = "timberio/vector:latest-alpine";
private static final int VECTOR_PORT = 8686;
private static final String VECTOR_CONFIG_PATH = "/etc/vector/vector.yml";
private static final String ENV_HERTZBEAT_PORT = "HERTZBEAT_PORT";
private static final Duration CONTAINER_STARTUP_TIMEOUT = Duration.ofSeconds(120);
@LocalServerPort
private int port;
@Autowired
private CommonDataQueue commonDataQueue;
static GenericContainer<?> vector;
@BeforeAll
void setUpAll() {
Testcontainers.exposeHostPorts(port);
vector = new GenericContainer<>(DockerImageName.parse(VECTOR_IMAGE))
.withExposedPorts(VECTOR_PORT)
.withCopyFileToContainer(MountableFile.forClasspathResource("vector.yml"), VECTOR_CONFIG_PATH)
.withCommand("--config", "/etc/vector/vector.yml", "--verbose")
.withLogConsumer(outputFrame -> log.info("Vector: {}", outputFrame.getUtf8String()))
.withNetwork(Network.newNetwork())
.withEnv(ENV_HERTZBEAT_PORT, String.valueOf(port))
.waitingFor(Wait.forListeningPort())
.withStartupTimeout(CONTAINER_STARTUP_TIMEOUT);
vector.start();
}
@Test
void testLogIngestion() {
// Start polling for log entries from the queue
List<LogEntry> capturedLogs = new ArrayList<>();
// Wait for Vector to generate and send demo logs to HertzBeat
await().atMost(Duration.ofSeconds(60))
.pollInterval(Duration.ofSeconds(3))
.untilAsserted(() -> {
try {
LogEntry logEntry = commonDataQueue.pollLogEntry();
if (logEntry != null) {
capturedLogs.add(logEntry);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Test interrupted", e);
}
});
// Verify the captured logs
assertFalse(capturedLogs.isEmpty(), "No logs were captured from Vector");
LogEntry firstLog = capturedLogs.get(0);
assertNotNull(firstLog, "First log should not be null");
assertNotNull(firstLog.getBody(), "Log body should not be null");
assertNotNull(firstLog.getSeverityText(), "Severity text should not be null");
}
}
@@ -0,0 +1,166 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.storage;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeDbDataStorage;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.MountableFile;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* E2E tests for GreptimeDB log storage.
*/
@SpringBootTest(classes = org.apache.hertzbeat.manager.Manager.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Slf4j
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class GreptimeLogStorageE2eTest {
private static final String VECTOR_IMAGE = "timberio/vector:latest-alpine";
private static final int VECTOR_PORT = 8686;
private static final String VECTOR_CONFIG_PATH = "/etc/vector/vector.yml";
private static final String ENV_HERTZBEAT_PORT = "HERTZBEAT_PORT";
private static final String GREPTIME_IMAGE = "greptime/greptimedb:latest";
private static final int GREPTIME_HTTP_PORT = 4000;
private static final int GREPTIME_GRPC_PORT = 4001;
private static final Duration CONTAINER_STARTUP_TIMEOUT = Duration.ofSeconds(120);
@LocalServerPort
private int port;
@Autowired
private CommonDataQueue commonDataQueue;
@Autowired
private GreptimeDbDataStorage greptimeDbDataStorage;
static GenericContainer<?> vector;
static GenericContainer<?> greptimedb;
static {
greptimedb = new GenericContainer<>(DockerImageName.parse(GREPTIME_IMAGE))
.withExposedPorts(GREPTIME_HTTP_PORT, GREPTIME_GRPC_PORT)
.withCommand("standalone", "start",
"--http-addr", "0.0.0.0:" + GREPTIME_HTTP_PORT,
"--rpc-bind-addr", "0.0.0.0:" + GREPTIME_GRPC_PORT)
.waitingFor(Wait.forListeningPorts(GREPTIME_HTTP_PORT, GREPTIME_GRPC_PORT))
.withStartupTimeout(CONTAINER_STARTUP_TIMEOUT);
greptimedb.start();
}
@DynamicPropertySource
static void greptimeProps(DynamicPropertyRegistry r) {
r.add("warehouse.store.jpa.enabled", () -> "false");
r.add("warehouse.store.greptime.enabled", () -> "true");
r.add("warehouse.store.greptime.http-endpoint", () -> "http://localhost:" + greptimedb.getMappedPort(GREPTIME_HTTP_PORT));
r.add("warehouse.store.greptime.grpc-endpoints", () -> "localhost:" + greptimedb.getMappedPort(GREPTIME_GRPC_PORT));
r.add("warehouse.store.greptime.username", () -> "");
r.add("warehouse.store.greptime.password", () -> "");
}
@BeforeAll
void setUpAll() {
// Expose host ports for testcontainers
Testcontainers.exposeHostPorts(port);
vector = new GenericContainer<>(DockerImageName.parse(VECTOR_IMAGE))
.withExposedPorts(VECTOR_PORT)
.withCopyFileToContainer(MountableFile.forClasspathResource("vector.yml"), VECTOR_CONFIG_PATH)
.withCommand("--config", "/etc/vector/vector.yml", "--verbose")
.withLogConsumer(outputFrame -> log.info("Vector: {}", outputFrame.getUtf8String()))
.withNetwork(Network.newNetwork())
.withEnv(ENV_HERTZBEAT_PORT, String.valueOf(port))
.waitingFor(Wait.forListeningPort())
.withStartupTimeout(CONTAINER_STARTUP_TIMEOUT);
vector.start();
}
@Test
void testLogStorageToGreptimeDb() {
List<LogEntry> capturedLogs = new ArrayList<>();
// Wait for Vector to generate and send logs to HertzBeat
await().atMost(Duration.ofSeconds(30))
.pollInterval(Duration.ofSeconds(3))
.untilAsserted(() -> {
// Poll log entries from the queue (non-blocking)
try {
LogEntry logEntry = commonDataQueue.pollLogEntry();
if (logEntry != null) {
capturedLogs.add(logEntry);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Test interrupted", e);
}
// Assert that we have captured at least some logs
assertFalse(capturedLogs.isEmpty(), "Should have captured at least one log entry");
});
// Verify the captured logs
assertFalse(capturedLogs.isEmpty(), "No logs were captured from Vector");
LogEntry firstLog = capturedLogs.get(0);
assertNotNull(firstLog, "First log should not be null");
assertNotNull(firstLog.getBody(), "Log body should not be null");
assertNotNull(firstLog.getSeverityText(), "Severity text should not be null");
// Additional wait to ensure logs are persisted to GreptimeDB
await().atMost(Duration.ofSeconds(30))
.pollInterval(Duration.ofSeconds(2))
.untilAsserted(() -> {
// Query GreptimeDB directly to verify data persistence
List<LogEntry> storedLogs = queryStoredLogs();
assertFalse(storedLogs.isEmpty(), "Should have logs stored in GreptimeDB");
});
}
/**
* Helper method to query stored logs directly from GreptimeDB
*/
private List<LogEntry> queryStoredLogs() {
long endTime = System.currentTimeMillis();
long startTime = endTime - Duration.ofMinutes(5).toMillis(); // Look back 5 minutes
return greptimeDbDataStorage.queryLogsByMultipleConditions(
startTime, endTime, null, null, null, null);
}
}
@@ -0,0 +1,117 @@
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Vector configuration for HertzBeat E2E testing
api:
enabled: true
address: "0.0.0.0:8686"
sources:
generate_syslog:
type: "demo_logs"
format: "syslog"
count: 100000
interval: 1
transforms:
remap_syslog:
inputs: ["generate_syslog"]
type: "remap"
source: |
syslog = parse_syslog!(.message)
severity_text = if includes(["emerg", "err", "crit", "alert"], syslog.severity) {
"ERROR"
} else if syslog.severity == "warning" {
"WARN"
} else if syslog.severity == "debug" {
"DEBUG"
} else if includes(["info", "notice"], syslog.severity) {
"INFO"
} else {
syslog.severity
}
severity_number = if includes(["emerg", "err", "crit", "alert"], syslog.severity) {
17
} else if syslog.severity == "warning" {
13
} else if syslog.severity == "debug" {
5
} else if includes(["info", "notice"], syslog.severity) {
9
} else {
9
}
.resourceLogs = [{
"resource": {
"attributes": [
{ "key": "source_type", "value": { "stringValue": .source_type } },
{ "key": "service.name", "value": { "stringValue": syslog.appname } },
{ "key": "host.hostname", "value": { "stringValue": syslog.hostname } }
]
},
"scopeLogs": [{
"scope": {
"name": syslog.msgid
},
"logRecords": [{
"timeUnixNano": to_unix_timestamp!(syslog.timestamp, unit: "nanoseconds"),
"body": { "stringValue": syslog.message },
"severityText": severity_text,
"severityNumber": severity_number,
"attributes": [
{ "key": "syslog.procid", "value": { "stringValue": to_string(syslog.procid) } },
{ "key": "syslog.facility", "value": { "stringValue": syslog.facility } },
{ "key": "syslog.version", "value": { "stringValue": to_string(syslog.version) } }
]
}]
}]
}]
del(.message)
del(.timestamp)
del(.service)
del(.source_type)
sinks:
# Debug sink to log all processed data
debug_console:
inputs: [ "remap_syslog" ]
type: console
encoding:
codec: json
# Send to HertzBeat
emit_syslog:
inputs: [ "remap_syslog" ]
type: opentelemetry
protocol:
type: http
uri: "http://host.testcontainers.internal:${HERTZBEAT_PORT:-1157}/api/logs/ingest/otlp"
method: post
encoding:
codec: json
framing:
method: newline_delimited
headers:
content-type: application/json
auth:
strategy: basic
user: admin
password: hertzbeat
+1
View File
@@ -31,6 +31,7 @@
<module>hertzbeat-collector-common-e2e</module>
<module>hertzbeat-collector-kafka-e2e</module>
<module>hertzbeat-collector-basic-e2e</module>
<module>hertzbeat-log-e2e</module>
</modules>
<properties>
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat</artifactId>
<version>2.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>hertzbeat-log</artifactId>
<name>${project.artifactId}</name>
<dependencies>
<!-- common -->
<dependency>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat-base</artifactId>
<scope>provided</scope>
</dependency>
<!-- warehouse -->
<dependency>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat-warehouse</artifactId>
</dependency>
<!-- spring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope>
</dependency>
<!-- swagger -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<scope>provided</scope>
</dependency>
<!-- opentelemetry proto -->
<dependency>
<groupId>io.opentelemetry.proto</groupId>
<artifactId>opentelemetry-proto</artifactId>
</dependency>
<!-- protobuf dependencies for OTLP processing -->
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
</dependency>
<!-- protobuf java util -->
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,109 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.apache.hertzbeat.log.service.LogProtocolAdapter;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Log Ingestion Controller
*/
@Tag(name = "Log Ingestion Controller")
@RestController
@RequestMapping(path = "/api/logs", produces = "application/json")
@Slf4j
public class LogIngestionController {
private static final String DEFAULT_PROTOCOL = "otlp";
private final List<LogProtocolAdapter> protocolAdapters;
public LogIngestionController(List<LogProtocolAdapter> protocolAdapters) {
this.protocolAdapters = protocolAdapters;
}
/**
* Receive log payload pushed from external system specifying the log protocol.
* Examples:
* - POST /api/logs/ingest/otlp (content body is OTLP JSON)
*
* @param protocol log protocol identifier
* @param content raw request body
*/
@PostMapping("/ingest/{protocol}")
public ResponseEntity<Message<Void>> ingestExternLog(@PathVariable("protocol") String protocol,
@RequestBody String content) {
log.info("Receive extern log from protocol: {}, content length: {}", protocol, content == null ? 0 : content.length());
if (!StringUtils.hasText(protocol)) {
protocol = DEFAULT_PROTOCOL; // Default to OTLP if no protocol specified
}
for (LogProtocolAdapter adapter : protocolAdapters) {
if (adapter.supportProtocol().equalsIgnoreCase(protocol)) {
try {
adapter.ingest(content);
return ResponseEntity.ok(Message.success("Add extern log success"));
} catch (Exception e) {
log.error("Add extern log failed: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Message.fail(CommonConstants.FAIL_CODE, "Add extern log failed: " + e.getMessage()));
}
}
}
log.warn("Not support extern log from protocol: {}", protocol);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Message.fail(CommonConstants.FAIL_CODE, "Not support the " + protocol + " protocol log"));
}
/**
* Receive default log payload (when protocol is not specified).
* It will look for a service whose supportProtocol() returns "otlp".
*/
@PostMapping("/ingest")
public ResponseEntity<Message<Void>> ingestDefaultExternLog(@RequestBody String content) {
log.info("Receive default extern log content, length: {}", content == null ? 0 : content.length());
LogProtocolAdapter adapter = protocolAdapters.stream()
.filter(item -> DEFAULT_PROTOCOL.equalsIgnoreCase(item.supportProtocol()))
.findFirst()
.orElse(null);
if (adapter != null) {
try {
adapter.ingest(content);
return ResponseEntity.ok(Message.success("Add extern log success"));
} catch (Exception e) {
log.error("Add extern log failed: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Message.fail(CommonConstants.FAIL_CODE, "Add extern log failed: " + e.getMessage()));
}
}
log.error("Not support default extern log protocol");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Message.fail(CommonConstants.FAIL_CODE, "Not support the default protocol log"));
}
}
@@ -0,0 +1,64 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataWriter;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static org.apache.hertzbeat.common.constants.CommonConstants.FAIL_CODE;
/**
* Controller for managing log entries in HertzBeat.
*/
@RestController
@RequestMapping(path = "/api/logs", produces = "application/json")
@Tag(name = "Log Management Controller")
@Slf4j
public class LogManagerController {
private final HistoryDataWriter historyDataWriter;
public LogManagerController(HistoryDataWriter historyDataWriter) {
this.historyDataWriter = historyDataWriter;
}
@DeleteMapping
@Operation(summary = "Batch delete logs",
description = "Batch delete logs by time timestamps. Deletes multiple log entries based on their Unix nanosecond timestamps.")
public ResponseEntity<Message<String>> batchDelete(
@Parameter(description = "List of Unix nanosecond timestamps for logs to delete", example = "1640995200000000000")
@RequestParam(required = false) List<Long> timeUnixNanos) {
boolean result = historyDataWriter.batchDeleteLogs(timeUnixNanos);
if (result) {
return ResponseEntity.ok(Message.success("Logs deleted successfully"));
} else {
return ResponseEntity.ok(Message.fail(FAIL_CODE, "Failed to delete logs"));
}
}
}
@@ -0,0 +1,226 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataReader;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.apache.hertzbeat.common.entity.log.LogEntry;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Log query and statistics APIs for UI consumption
*/
@RestController
@RequestMapping(path = "/api/logs", produces = "application/json")
@Tag(name = "Log Query Controller")
@Slf4j
public class LogQueryController {
private final HistoryDataReader historyDataReader;
public LogQueryController(HistoryDataReader historyDataReader) {
this.historyDataReader = historyDataReader;
}
@GetMapping("/list")
@Operation(summary = "Query logs by time range with optional filters",
description = "Query logs by [start,end] in ms and optional filters with pagination. Returns paginated log entries sorted by timestamp in descending order.")
public ResponseEntity<Message<Page<LogEntry>>> list(
@Parameter(description = "Start timestamp in milliseconds (Unix timestamp)", example = "1640995200000")
@RequestParam(value = "start", required = false) Long start,
@Parameter(description = "End timestamp in milliseconds (Unix timestamp)", example = "1641081600000")
@RequestParam(value = "end", required = false) Long end,
@Parameter(description = "Trace ID for distributed tracing", example = "1234567890abcdef")
@RequestParam(value = "traceId", required = false) String traceId,
@Parameter(description = "Span ID for distributed tracing", example = "abcdef1234567890")
@RequestParam(value = "spanId", required = false) String spanId,
@Parameter(description = "Log severity number (1-24 according to OpenTelemetry standard)", example = "9")
@RequestParam(value = "severityNumber", required = false) Integer severityNumber,
@Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO")
@RequestParam(value = "severityText", required = false) String severityText,
@Parameter(description = "Page index starting from 0", example = "0")
@RequestParam(value = "pageIndex", required = false, defaultValue = "0") Integer pageIndex,
@Parameter(description = "Number of items per page", example = "20")
@RequestParam(value = "pageSize", required = false, defaultValue = "20") Integer pageSize) {
Page<LogEntry> result = getPagedLogs(start, end, traceId, spanId, severityNumber, severityText, pageIndex, pageSize);
return ResponseEntity.ok(Message.success(result));
}
@GetMapping("/stats/overview")
@Operation(summary = "Log overview statistics",
description = "Overall counts and basic statistics with filters. Provides counts by severity levels according to OpenTelemetry standard.")
public ResponseEntity<Message<Map<String, Object>>> overviewStats(
@Parameter(description = "Start timestamp in milliseconds (Unix timestamp)", example = "1640995200000")
@RequestParam(value = "start", required = false) Long start,
@Parameter(description = "End timestamp in milliseconds (Unix timestamp)", example = "1641081600000")
@RequestParam(value = "end", required = false) Long end,
@Parameter(description = "Trace ID for distributed tracing", example = "1234567890abcdef")
@RequestParam(value = "traceId", required = false) String traceId,
@Parameter(description = "Span ID for distributed tracing", example = "abcdef1234567890")
@RequestParam(value = "spanId", required = false) String spanId,
@Parameter(description = "Log severity number (1-24 according to OpenTelemetry standard)", example = "9")
@RequestParam(value = "severityNumber", required = false) Integer severityNumber,
@Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO")
@RequestParam(value = "severityText", required = false) String severityText) {
List<LogEntry> logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText);
Map<String, Object> overview = new HashMap<>();
overview.put("totalCount", logs.size());
// Count by severity levels according to OpenTelemetry standard
// TRACE: 1-4, DEBUG: 5-8, INFO: 9-12, WARN: 13-16, ERROR: 17-20, FATAL: 21-24
long fatalCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 21 && log.getSeverityNumber() <= 24).count();
long errorCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 17 && log.getSeverityNumber() <= 20).count();
long warnCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 13 && log.getSeverityNumber() <= 16).count();
long infoCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 9 && log.getSeverityNumber() <= 12).count();
long debugCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 5 && log.getSeverityNumber() <= 8).count();
long traceCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 1 && log.getSeverityNumber() <= 4).count();
overview.put("fatalCount", fatalCount);
overview.put("errorCount", errorCount);
overview.put("warnCount", warnCount);
overview.put("infoCount", infoCount);
overview.put("debugCount", debugCount);
overview.put("traceCount", traceCount);
return ResponseEntity.ok(Message.success(overview));
}
@GetMapping("/stats/trace-coverage")
@Operation(summary = "Trace coverage statistics",
description = "Statistics about trace information availability. Shows how many logs have trace IDs, span IDs, or both for distributed tracing analysis.")
public ResponseEntity<Message<Map<String, Object>>> traceCoverageStats(
@Parameter(description = "Start timestamp in milliseconds (Unix timestamp)", example = "1640995200000")
@RequestParam(value = "start", required = false) Long start,
@Parameter(description = "End timestamp in milliseconds (Unix timestamp)", example = "1641081600000")
@RequestParam(value = "end", required = false) Long end,
@Parameter(description = "Trace ID for distributed tracing", example = "1234567890abcdef")
@RequestParam(value = "traceId", required = false) String traceId,
@Parameter(description = "Span ID for distributed tracing", example = "abcdef1234567890")
@RequestParam(value = "spanId", required = false) String spanId,
@Parameter(description = "Log severity number (1-24 according to OpenTelemetry standard)", example = "9")
@RequestParam(value = "severityNumber", required = false) Integer severityNumber,
@Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO")
@RequestParam(value = "severityText", required = false) String severityText) {
List<LogEntry> logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText);
Map<String, Object> result = new HashMap<>();
// Trace coverage statistics
long withTraceId = logs.stream().filter(log -> log.getTraceId() != null && !log.getTraceId().isEmpty()).count();
long withSpanId = logs.stream().filter(log -> log.getSpanId() != null && !log.getSpanId().isEmpty()).count();
long withBothTraceAndSpan = logs.stream().filter(log ->
log.getTraceId() != null && !log.getTraceId().isEmpty()
&& log.getSpanId() != null && !log.getSpanId().isEmpty()).count();
long withoutTrace = logs.size() - withTraceId;
Map<String, Long> traceCoverage = new HashMap<>();
traceCoverage.put("withTrace", withTraceId);
traceCoverage.put("withoutTrace", withoutTrace);
traceCoverage.put("withSpan", withSpanId);
traceCoverage.put("withBothTraceAndSpan", withBothTraceAndSpan);
result.put("traceCoverage", traceCoverage);
return ResponseEntity.ok(Message.success(result));
}
@GetMapping("/stats/trend")
@Operation(summary = "Log trend over time",
description = "Count logs by hour intervals with filters. Groups logs by hour and provides time-series data for trend analysis.")
public ResponseEntity<Message<Map<String, Object>>> trendStats(
@Parameter(description = "Start timestamp in milliseconds (Unix timestamp)", example = "1640995200000")
@RequestParam(value = "start", required = false) Long start,
@Parameter(description = "End timestamp in milliseconds (Unix timestamp)", example = "1641081600000")
@RequestParam(value = "end", required = false) Long end,
@Parameter(description = "Trace ID for distributed tracing", example = "1234567890abcdef")
@RequestParam(value = "traceId", required = false) String traceId,
@Parameter(description = "Span ID for distributed tracing", example = "abcdef1234567890")
@RequestParam(value = "spanId", required = false) String spanId,
@Parameter(description = "Log severity number (1-24 according to OpenTelemetry standard)", example = "9")
@RequestParam(value = "severityNumber", required = false) Integer severityNumber,
@Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO")
@RequestParam(value = "severityText", required = false) String severityText) {
List<LogEntry> logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText);
// Group by hour
Map<String, Long> hourlyStats = logs.stream()
.filter(log -> log.getTimeUnixNano() != null)
.collect(Collectors.groupingBy(
log -> {
long timestampMs = log.getTimeUnixNano() / 1_000_000L;
LocalDateTime dateTime = LocalDateTime.ofInstant(
Instant.ofEpochMilli(timestampMs),
ZoneId.systemDefault());
return dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00"));
},
Collectors.counting()));
Map<String, Object> result = new HashMap<>();
result.put("hourlyStats", hourlyStats);
return ResponseEntity.ok(Message.success(result));
}
private List<LogEntry> getFilteredLogs(Long start, Long end, String traceId, String spanId,
Integer severityNumber, String severityText) {
// Use the new multi-condition query method
return historyDataReader.queryLogsByMultipleConditions(start, end, traceId, spanId, severityNumber, severityText);
}
private Page<LogEntry> getPagedLogs(Long start, Long end, String traceId, String spanId,
Integer severityNumber, String severityText, Integer pageIndex, Integer pageSize) {
// Calculate pagination parameters
int offset = pageIndex * pageSize;
// Get total count and paginated data
long totalElements = historyDataReader.countLogsByMultipleConditions(start, end, traceId, spanId, severityNumber, severityText);
List<LogEntry> pagedLogs = historyDataReader.queryLogsByMultipleConditionsWithPagination(
start, end, traceId, spanId, severityNumber, severityText, offset, pageSize);
// Create PageRequest (sorted by timestamp descending)
Sort sort = Sort.by(Sort.Direction.DESC, "timeUnixNano");
PageRequest pageRequest = PageRequest.of(pageIndex, pageSize, sort);
// Return Spring Data Page object
return new PageImpl<>(pagedLogs, pageRequest, totalElements);
}
}
@@ -0,0 +1,57 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.hertzbeat.log.controller;
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
import org.apache.hertzbeat.common.util.SnowFlakeIdGenerator;
import org.apache.hertzbeat.log.notice.LogSseFilterCriteria;
import org.apache.hertzbeat.log.notice.LogSseManager;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import io.swagger.v3.oas.annotations.Operation;
/**
* SSE controller for log streaming with filtering support
*/
@RestController
@RequestMapping(path = "/api/logs/sse", produces = {TEXT_EVENT_STREAM_VALUE})
public class LogSseController {
private final LogSseManager emitterManager;
public LogSseController(LogSseManager emitterManager) {
this.emitterManager = emitterManager;
}
/**
* Subscribe to log events with optional filtering
* @param filterCriteria Filter criteria for log events (all parameters are optional)
* @return SSE emitter for streaming log events
*/
@GetMapping(path = "/subscribe")
@Operation(summary = "Subscribe to log events with optional filtering", description = "Subscribe to log events with optional filtering")
public SseEmitter subscribe(@ModelAttribute LogSseFilterCriteria filterCriteria) {
Long clientId = SnowFlakeIdGenerator.generateId();
return emitterManager.createEmitter(clientId, filterCriteria);
}
}

Some files were not shown because too many files have changed in this diff Show More