Integrate hybrid collector backend

This commit is contained in:
Logic
2026-07-30 03:18:47 +08:00
286 changed files with 17900 additions and 1900 deletions
+19 -4
View File
@@ -21,10 +21,15 @@ on:
pull_request:
paths:
- '.github/workflows/hybrid-collector-release.yml'
- '.github/workflows/nightly-build.yml'
- 'pom.xml'
- 'hertzbeat-collector/**'
- 'hertzbeat-common-core/**'
- 'hertzbeat-manager/**'
- 'hertzbeat-observability/**'
- 'hertzbeat-otel/**'
- 'hertzbeat-otel-runtime/**'
- 'hertzbeat-startup/**'
- 'script/assembly/collector/**'
- 'script/ci/*hybrid-collector*'
- 'script/ci/*otel-runtime*'
@@ -57,6 +62,8 @@ jobs:
- name: Verify source and dependency contracts
run: |
python3 script/ci/test_verify_hybrid_collector_release_content.py -v
python3 script/ci/test_verify_hybrid_collector_jvm_package.py -v
python3 script/ci/test_verify_otel_runtime_sbom_platform.py -v
python3 script/ci/test_prepare_hybrid_collector_native_container_context.py -v
python3 script/ci/test_hybrid_collector_systemd_install.py -v
mkdir -p target/release-policy
@@ -73,11 +80,15 @@ jobs:
- name: Verify Java runtime and Spring AOT
run: |
./script/ci/verify-hybrid-collector-java.sh
make -C hertzbeat-otel-runtime release-assets
./mvnw -pl hertzbeat-collector/hertzbeat-collector-collector -am \
-Pcluster -DskipTests package
archive=$(find dist -maxdepth 1 -type f -name 'apache-hertzbeat-collector-*-bin.tar.gz' -print -quit)
test -n "$archive"
sh ./script/ci/verify-hybrid-collector-jvm-package.sh "$archive" generic
-Pruntime -DskipTests package
set -- dist/apache-hertzbeat-collector-*-bin.tar.gz
test "$#" -eq 1 && test -f "$1"
sh ./script/ci/verify-hybrid-collector-jvm-package.sh "$1" generic
set -- dist/apache-hertzbeat-collector-*-bin-linux_amd64.tar.gz
test "$#" -eq 1 && test -f "$1"
sh ./script/ci/verify-hybrid-collector-jvm-package.sh "$1" linux-amd64
source-policy:
strategy:
@@ -188,6 +199,10 @@ jobs:
push: false
outputs: type=oci,dest=/tmp/hybrid-collector.oci.tar
tags: apache/hertzbeat-collector:native-test
- name: Verify multi-platform native image layers
run: |
python3 script/ci/verify-hybrid-collector-release-content.py \
--container-image /tmp/hybrid-collector.oci.tar
- uses: actions/upload-artifact@v4
with:
name: hybrid-collector-native-oci
+44 -5
View File
@@ -42,6 +42,14 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: actions/setup-go@v6
with:
go-version-file: hertzbeat-otel-runtime/go.mod
cache-dependency-path: hertzbeat-otel-runtime/go.mod
- uses: actions/setup-java@v4
with:
distribution: zulu
java-version: 25
- name: Build the Frontend
run: |
corepack enable
@@ -49,13 +57,33 @@ jobs:
cd web-app
pnpm install --frozen-lockfile
pnpm build
- name: Build and verify managed Collector runtimes
run: |
make -C hertzbeat-otel-runtime release-assets
./script/ci/verify-otel-runtime-package-layout.sh
- name: Build the Backend
run: |
mvn clean install
mvn clean package -Prelease -DskipTests
cd hertzbeat-collector
mvn clean package -Pcluster -DskipTests
./mvnw -pl hertzbeat-collector/hertzbeat-collector-collector -am \
-Pruntime -DskipTests package
- name: Verify Linux Collector release content
run: |
set -- dist/apache-hertzbeat-collector-*-bin-linux_amd64.tar.gz
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
echo "expected exactly one generated linux-amd64 Hybrid Collector archive" >&2
exit 1
fi
./script/ci/verify-hybrid-collector-jvm-package.sh "$1" linux-amd64
set -- dist/apache-hertzbeat-collector-*-bin-linux_arm64.tar.gz
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
echo "expected exactly one generated linux-arm64 Hybrid Collector archive" >&2
exit 1
fi
./script/ci/verify-hybrid-collector-jvm-package.sh "$1" linux-arm64
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
@@ -74,11 +102,22 @@ jobs:
push: true
tags: apache/hertzbeat:nightly
- name: Build and Push Collector
- name: Build Collector image for verification
uses: docker/build-push-action@v6
with:
context: ./dist
file: ./script/docker/collector/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: apache/hertzbeat-collector:nightly
outputs: type=oci,dest=/tmp/hybrid-collector-nightly.oci.tar
- name: Verify final Collector image content
run: python3 script/ci/verify-hybrid-collector-release-content.py --container-image /tmp/hybrid-collector-nightly.oci.tar
- name: Push verified Collector image
run: |
sudo apt-get update
sudo apt-get install -y skopeo
skopeo copy --all --authfile "$HOME/.docker/config.json" \
oci-archive:/tmp/hybrid-collector-nightly.oci.tar \
docker://apache/hertzbeat-collector:nightly
+3 -2
View File
@@ -34,8 +34,9 @@ filter. Such a request is rejected instead of silently applying ambiguous constr
- Metrics console request and returned context.
- Historical log list, surrounding context, overview, trace coverage, trend, and group-by queries.
- Live log SSE filtering.
- Trace list, overview, and group-by queries. Trace detail and span endpoints already address an
exact trace identifier and do not add these discovery filters.
- Trace list, overview, group-by, detail, and span queries. Detail and span requests retain the
exact trace identifier while also applying the supplied discovery context; an optional `spanId`
further narrows the row set.
Storage access remains behind the existing query services and storage adapters. Controllers only
bind the HTTP parameters and construct the storage-neutral context; they contain no database query
@@ -30,8 +30,11 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
/**
* SSE manager for alert
@@ -39,50 +42,94 @@ import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Component
public class AlertSseManager implements ApplicationListener<ContextClosedEvent> {
private static final long RECONNECT_TIME_MILLIS = 3_000L;
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
private final Object lifecycleMonitor = new Object();
private final AtomicLong eventSequence = new AtomicLong(System.currentTimeMillis());
private final Supplier<SseEmitter> emitterFactory;
private boolean closing;
public AlertSseManager() {
this(() -> new SseEmitter(Long.MAX_VALUE));
}
AlertSseManager(Supplier<SseEmitter> emitterFactory) {
this.emitterFactory = Objects.requireNonNull(emitterFactory);
}
/**
* Opens a reconnectable stream. The ready event is a convergence trigger:
* clients must reread canonical alert state rather than expect replay from
* this in-memory stream.
*/
public SseEmitter createEmitter(Long clientId) {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
emitter.onCompletion(() -> removeEmitter(clientId));
emitter.onTimeout(() -> removeEmitter(clientId));
emitter.onError((ex) -> removeEmitter(clientId));
SseEmitter emitter = emitterFactory.get();
emitter.onCompletion(() -> removeEmitter(clientId, emitter));
emitter.onTimeout(() -> removeEmitter(clientId, emitter));
emitter.onError((ex) -> removeEmitter(clientId, emitter));
SseEmitter replacedEmitter;
synchronized (lifecycleMonitor) {
if (closing) {
emitter.complete();
tryComplete(emitter);
return emitter;
}
emitters.put(clientId, emitter);
replacedEmitter = emitters.put(clientId, emitter);
}
if (replacedEmitter != null && replacedEmitter != emitter) {
tryCompleteAndClean(clientId, replacedEmitter);
}
try {
emitter.send(SseEmitter.event()
.name("ALERT_STREAM_READY")
.data("{}")
.reconnectTime(RECONNECT_TIME_MILLIS));
} catch (IOException | IllegalStateException exception) {
tryCompleteAndClean(clientId, emitter);
}
return emitter;
}
@Async
public void broadcast(String data) {
broadcast(data, "ALERT_EVENT");
}
@Async
public void broadcastGroupMutation(String data) {
broadcast(data, "ALERT_GROUP_MUTATION");
}
private void broadcast(String data, String eventName) {
String eventId = String.valueOf(eventSequence.incrementAndGet());
emitters.forEach((clientId, emitter) -> {
try {
emitter.send(SseEmitter.event()
.id(String.valueOf(System.currentTimeMillis()))
.name("ALERT_EVENT")
.id(eventId)
.name(eventName)
.data(data));
} catch (IOException | IllegalStateException e) {
tryCompleteAndClean(clientId, emitter);
} catch (Exception exception) {
log.error("Failed to broadcast alert data to client: {}", exception.getMessage());
log.error("Failed to broadcast alert data to client: {}",
exception.getClass().getSimpleName());
tryCompleteAndClean(clientId, emitter);
}
});
}
private void tryCompleteAndClean(Long clientId, SseEmitter emitter) {
tryComplete(emitter);
removeEmitter(clientId, emitter);
}
private void tryComplete(SseEmitter emitter) {
try {
Optional.ofNullable(emitter).ifPresent(ResponseBodyEmitter::complete);
} catch (Throwable e) {
log.debug("Failed to complete emitter for client {}: {}", clientId, e.getMessage());
log.debug("Failed to complete alert emitter: {}", e.getClass().getSimpleName());
}
// execute clear
removeEmitter(clientId);
}
@Override
@@ -98,7 +145,7 @@ public class AlertSseManager implements ApplicationListener<ContextClosedEvent>
activeEmitters.forEach(this::tryCompleteAndClean);
}
private void removeEmitter(Long clientId) {
emitters.remove(clientId);
private void removeEmitter(Long clientId, SseEmitter emitter) {
emitters.remove(clientId, emitter);
}
}
@@ -17,15 +17,13 @@
package org.apache.hertzbeat.alert.controller;
import static org.apache.hertzbeat.common.constants.CommonConstants.MONITOR_NOT_EXIST_CODE;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.Objects;
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
import org.apache.hertzbeat.alert.dto.AlertInhibitResponse;
import org.apache.hertzbeat.alert.service.AlertInhibitService;
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
@@ -50,30 +48,24 @@ public class AlertInhibitController {
@PostMapping
@Operation(summary = "New Alarm Inhibit", description = "Added an alarm Inhibit")
public ResponseEntity<Message<Void>> addNewAlertInhibit(@Valid @RequestBody AlertInhibit alertInhibit) {
alertInhibitService.validate(alertInhibit, false);
alertInhibitService.addAlertInhibit(alertInhibit);
return ResponseEntity.ok(Message.success("Add success"));
public ResponseEntity<Message<AlertInhibitResponse>> addNewAlertInhibit(
@RequestBody AlertInhibitRequest request) {
return ResponseEntity.ok(Message.success(alertInhibitService.create(request)));
}
@PutMapping
@Operation(summary = "Modifying an Alarm Inhibit", description = "Modify an existing alarm Inhibit")
public ResponseEntity<Message<Void>> modifyAlertInhibit(@Valid @RequestBody AlertInhibit alertInhibit) {
alertInhibitService.validate(alertInhibit, true);
alertInhibitService.modifyAlertInhibit(alertInhibit);
return ResponseEntity.ok(Message.success("Modify success"));
public ResponseEntity<Message<AlertInhibitResponse>> modifyAlertInhibit(
@RequestBody AlertInhibitRequest request) {
return ResponseEntity.ok(Message.success(alertInhibitService.update(request)));
}
@GetMapping(path = "/{id}")
@Operation(summary = "Querying Alarm Inhibit",
description = "You can obtain alarm Inhibit information based on the alarm Inhibit ID")
public ResponseEntity<Message<AlertInhibit>> getAlertInhibit(
public ResponseEntity<Message<AlertInhibitResponse>> getAlertInhibit(
@Parameter(description = "Alarm Inhibit ID", example = "6565463543") @PathVariable("id") long id) {
AlertInhibit alertInhibit = alertInhibitService.getAlertInhibit(id);
return Objects.isNull(alertInhibit)
? ResponseEntity.ok(Message.fail(MONITOR_NOT_EXIST_CODE, "AlertInhibit not exist."))
: ResponseEntity.ok(Message.success(alertInhibit));
return ResponseEntity.ok(Message.success(alertInhibitService.get(id)));
}
}
@@ -0,0 +1,67 @@
/*
* 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.controller;
import static org.apache.hertzbeat.common.constants.CommonConstants.FAIL_CODE;
import static org.apache.hertzbeat.common.constants.CommonConstants.MONITOR_NOT_EXIST_CODE;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.service.AlertInhibitNotFoundException;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** Safe and non-reflective failure mapping for the two existing alert-inhibit controllers. */
@Slf4j
@RestControllerAdvice(assignableTypes = {AlertInhibitController.class, AlertInhibitsController.class})
public class AlertInhibitControllerAdvice {
@ExceptionHandler(AlertInhibitNotFoundException.class)
public ResponseEntity<Message<Void>> missing() {
return ResponseEntity.ok(Message.fail(MONITOR_NOT_EXIST_CODE, "AlertInhibit not exist."));
}
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<Message<Void>> unavailable(DataAccessException exception) {
log.error("Alert inhibit storage unavailable: {}", exception.getClass().getSimpleName());
return ResponseEntity.ok(Message.fail(FAIL_CODE, "Alert inhibit storage unavailable"));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Message<Void>> invalid() {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Message.fail(FAIL_CODE, "Invalid alert inhibit request"));
}
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentNotValidException.class})
public ResponseEntity<Message<Void>> malformed() {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Message.fail(FAIL_CODE, "Invalid alert inhibit request"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Message<Void>> error(Exception exception) {
log.error("Alert inhibit operation error: {}", exception.getClass().getSimpleName());
return ResponseEntity.ok(Message.fail(FAIL_CODE, "Alert inhibit operation error"));
}
}
@@ -23,11 +23,11 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.HashSet;
import java.util.List;
import org.apache.hertzbeat.alert.dto.AlertInhibitDeleteResponse;
import org.apache.hertzbeat.alert.dto.AlertInhibitPageResponse;
import org.apache.hertzbeat.alert.service.AlertInhibitService;
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -49,28 +49,25 @@ public class AlertInhibitsController {
@GetMapping
@Operation(summary = "Query the alarm inhibit list",
description = "You can obtain the list of alarm inhibit by querying filter items")
public ResponseEntity<Message<Page<AlertInhibit>>> getAlertInhibits(
public ResponseEntity<Message<AlertInhibitPageResponse>> getAlertInhibits(
@Parameter(description = "Alarm Inhibit ID", example = "6565463543") @RequestParam(required = false) List<Long> ids,
@Parameter(description = "Search Name", example = "x") @RequestParam(required = false) String search,
@Parameter(description = "Sort field, default id", example = "id") @RequestParam(defaultValue = "id") String sort,
@Parameter(description = "Sort mode: asc: ascending, desc: descending", example = "desc") @RequestParam(defaultValue = "desc") String order,
@Parameter(description = "List current page", example = "0") @RequestParam(defaultValue = "0") int pageIndex,
@Parameter(description = "Number of list pages", example = "8") @RequestParam(defaultValue = "8") int pageSize) {
Page<AlertInhibit> alertInhibitPage = alertInhibitService.getAlertInhibits(ids, search, sort, order, pageIndex, pageSize);
return ResponseEntity.ok(Message.success(alertInhibitPage));
return ResponseEntity.ok(Message.success(
alertInhibitService.list(ids, search, sort, order, pageIndex, pageSize)));
}
@DeleteMapping
@Operation(summary = "Delete alarm inhibit in batches",
description = "Delete alarm inhibit in batches based on the alarm inhibit ID list")
public ResponseEntity<Message<Void>> deleteAlertDefines(
public ResponseEntity<Message<AlertInhibitDeleteResponse>> deleteAlertDefines(
@Parameter(description = "Alarm Inhibit IDs", example = "6565463543") @RequestParam(required = false) List<Long> ids
) {
if (ids != null && !ids.isEmpty()) {
alertInhibitService.deleteAlertInhibits(new HashSet<>(ids));
}
Message<Void> message = Message.success();
return ResponseEntity.ok(message);
return ResponseEntity.ok(Message.success(
alertInhibitService.delete(ids == null ? null : new HashSet<>(ids))));
}
}
@@ -0,0 +1,67 @@
/*
* 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.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.CatalogResponse;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.IntegrationGuide;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationRequestException;
import org.apache.hertzbeat.alert.integration.service.AlertIntegrationCatalogService;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Read-only catalog for external alert integrations.
*/
@Tag(name = "External Alert Integration Catalog API")
@RestController
@RequestMapping("/api/alerts/integrations")
public class AlertIntegrationCatalogController {
private final AlertIntegrationCatalogService service;
public AlertIntegrationCatalogController(AlertIntegrationCatalogService service) {
this.service = service;
}
@GetMapping
@Operation(summary = "List external alert integrations")
public ResponseEntity<Message<CatalogResponse>> catalog() {
return ResponseEntity.ok(Message.success(service.catalog()));
}
@GetMapping("/{source}")
@Operation(summary = "Render one external alert integration")
public ResponseEntity<Message<IntegrationGuide>> render(@PathVariable String source) {
return ResponseEntity.ok(Message.success(service.render(source)));
}
@ExceptionHandler(AlertIntegrationRequestException.class)
public ResponseEntity<Message<Void>> handleRequestFailure(AlertIntegrationRequestException exception) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Message.fail(CommonConstants.FAIL_CODE, exception.errorCode().code()));
}
}
@@ -49,7 +49,8 @@ public class AlertSilenceControllerAdvice {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Message<Void>> invalid() {
return ResponseEntity.ok(Message.fail(FAIL_CODE, "Invalid alert silence request"));
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Message.fail(FAIL_CODE, "Invalid alert silence request"));
}
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentNotValidException.class})
@@ -0,0 +1,49 @@
/*
* 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.controller;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.apache.hertzbeat.alert.dto.AlertSummary;
import org.apache.hertzbeat.alert.service.AlertService;
import org.apache.hertzbeat.common.entity.dto.Message;
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.RestController;
/**
* Dashboard alert summary read API.
*/
@Tag(name = "Alarm Summary API")
@RestController
@RequestMapping(path = "/api/alerts/summary", produces = {APPLICATION_JSON_VALUE})
@RequiredArgsConstructor
public class AlertSummaryController {
private final AlertService alertService;
@GetMapping
@Operation(summary = "Get alarm statistics", description = "Get alarm statistics information")
public ResponseEntity<Message<AlertSummary>> getAlertsSummary() {
return ResponseEntity.ok(Message.success(alertService.getAlertsSummary()));
}
}
@@ -17,13 +17,18 @@
package org.apache.hertzbeat.alert.controller;
import static org.apache.hertzbeat.common.constants.CommonConstants.FAIL_CODE;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.HashSet;
import java.util.List;
import org.apache.hertzbeat.alert.dto.AlertSummary;
import org.apache.hertzbeat.alert.dto.AlertGroupEvidence;
import org.apache.hertzbeat.alert.service.AlertGroupEvidenceRequestException;
import org.apache.hertzbeat.alert.service.AlertGroupEvidenceService;
import org.apache.hertzbeat.alert.service.AlertGroupNotFoundException;
import org.apache.hertzbeat.alert.service.AlertGroupStatusNotSupportedException;
import org.apache.hertzbeat.alert.service.AlertService;
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
@@ -47,9 +52,21 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping(path = "/api/alerts", produces = {APPLICATION_JSON_VALUE})
public class AlertsController {
private static final String ALERT_GROUP_NOT_FOUND_MESSAGE = "Alert group was not found.";
private static final String ALERT_GROUP_DELETE_FAILED_MESSAGE = "Alert group delete failed.";
private static final String ALERT_GROUP_STATUS_NOT_SUPPORTED_MESSAGE = "Alert group status is not supported.";
private static final String ALERT_GROUP_STATUS_UPDATE_FAILED_MESSAGE = "Alert group status update failed.";
private static final String INVALID_ALERT_GROUP_EVIDENCE_REQUEST_MESSAGE =
"Invalid alert group evidence request.";
private static final String ALERT_GROUP_EVIDENCE_QUERY_FAILED_MESSAGE =
"Alert group evidence query failed.";
@Autowired
private AlertService alertService;
@Autowired
private AlertGroupEvidenceService alertGroupEvidenceService;
@GetMapping
@Operation(summary = "Query Alarms")
public ResponseEntity<Message<Page<SingleAlert>>> getAlerts(
@@ -81,15 +98,34 @@ public class AlertsController {
return ResponseEntity.ok(Message.success(alertPage));
}
@GetMapping("/group/evidence")
@Operation(summary = "Query canonical alert group evidence by ID")
public ResponseEntity<Message<AlertGroupEvidence>> getGroupAlertEvidence(
@Parameter(description = "Alert group ID list", example = "6565463543")
@RequestParam(required = false) List<String> ids) {
try {
return ResponseEntity.ok(Message.success(alertGroupEvidenceService.getEvidence(ids)));
} catch (AlertGroupEvidenceRequestException exception) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, INVALID_ALERT_GROUP_EVIDENCE_REQUEST_MESSAGE));
} catch (Exception exception) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, ALERT_GROUP_EVIDENCE_QUERY_FAILED_MESSAGE));
}
}
@DeleteMapping("/group")
@Operation(summary = "Delete group alarms in batches", description = "according to the alarm ID list to delete the alarm information in batches")
public ResponseEntity<Message<Void>> deleteAlerts(
@Parameter(description = "Alarm List ID", example = "6565463543") @RequestParam(required = false) List<Long> ids) {
if (ids != null && !ids.isEmpty()) {
alertService.deleteGroupAlerts(new HashSet<>(ids));
try {
if (ids != null && !ids.isEmpty()) {
alertService.deleteGroupAlerts(new HashSet<>(ids));
}
return ResponseEntity.ok(Message.success());
} catch (AlertGroupNotFoundException exception) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, ALERT_GROUP_NOT_FOUND_MESSAGE));
} catch (Exception exception) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, ALERT_GROUP_DELETE_FAILED_MESSAGE));
}
Message<Void> message = Message.success();
return ResponseEntity.ok(message);
}
@PutMapping(path = "/group/status/{status}")
@@ -98,11 +134,18 @@ public class AlertsController {
public ResponseEntity<Message<Void>> applyAlertDefinesStatus(
@Parameter(description = "Alarm status value", example = "acknowledged") @PathVariable String status,
@Parameter(description = "Alarm List IDS", example = "6565463543") @RequestParam(required = false) List<Long> ids) {
if (ids != null && status != null && !ids.isEmpty()) {
alertService.editGroupAlertStatus(status, ids);
try {
if (ids != null && status != null && !ids.isEmpty()) {
alertService.editGroupAlertStatus(status, ids);
}
return ResponseEntity.ok(Message.success());
} catch (AlertGroupStatusNotSupportedException exception) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, ALERT_GROUP_STATUS_NOT_SUPPORTED_MESSAGE));
} catch (AlertGroupNotFoundException exception) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, ALERT_GROUP_NOT_FOUND_MESSAGE));
} catch (Exception exception) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, ALERT_GROUP_STATUS_UPDATE_FAILED_MESSAGE));
}
Message<Void> message = Message.success();
return ResponseEntity.ok(message);
}
@PutMapping(path = "/status/{status}")
@@ -118,12 +161,4 @@ public class AlertsController {
return ResponseEntity.ok(message);
}
@GetMapping(path = "/summary")
@Operation(summary = "Get alarm statistics", description = "Get alarm statistics information")
public ResponseEntity<Message<AlertSummary>> getAlertsSummary() {
AlertSummary alertSummary = alertService.getAlertsSummary();
Message<AlertSummary> message = Message.success(alertSummary);
return ResponseEntity.ok(message);
}
}
@@ -31,6 +31,7 @@ import org.apache.hertzbeat.alert.dto.NoticeReceiverOptionResponse;
import org.apache.hertzbeat.alert.dto.NoticeReceiverRequest;
import org.apache.hertzbeat.alert.dto.NoticeReceiverResponse;
import org.apache.hertzbeat.alert.service.NoticeReceiverContractService;
import org.apache.hertzbeat.alert.service.NoticeTemplateMutationException;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
@@ -58,6 +59,12 @@ import org.springframework.web.bind.annotation.RestController;
@Slf4j
public class NoticeConfigController {
private static final String NOTICE_TEMPLATE_INVALID_MESSAGE = "Notice template request is invalid.";
private static final String NOTICE_TEMPLATE_NOT_FOUND_MESSAGE = "Notice template was not found.";
private static final String NOTICE_TEMPLATE_READ_ONLY_MESSAGE = "Preset notice templates are read-only.";
private static final String NOTICE_TEMPLATE_STORAGE_UNAVAILABLE_MESSAGE = "Notice template storage is unavailable.";
private static final String NOTICE_TEMPLATE_OPERATION_FAILED_MESSAGE = "Notice template operation failed.";
@Autowired
private NoticeConfigService noticeConfigService;
@@ -202,28 +209,47 @@ public class NoticeConfigController {
@PostMapping(path = "/template")
@Operation(summary = "Add a notification template", description = "Add a notification template")
public ResponseEntity<Message<Void>> addNewNoticeTemplate(@Valid @RequestBody NoticeTemplate noticeTemplate) {
noticeConfigService.addNoticeTemplate(noticeTemplate);
return ResponseEntity.ok(Message.success("Add success"));
try {
noticeConfigService.addNoticeTemplate(noticeTemplate);
return ResponseEntity.ok(Message.success("Add success"));
} catch (NoticeTemplateMutationException exception) {
return templateMutationFailure(exception);
} catch (DataAccessException exception) {
return templateStorageUnavailable("create", exception);
} catch (Exception exception) {
return templateOperationFailed("create", exception);
}
}
@PutMapping(path = "/template")
@Operation(summary = "Modify existing notification template information", description = "Modify existing notification template information")
public ResponseEntity<Message<Void>> editNoticeTemplate(@Valid @RequestBody NoticeTemplate noticeTemplate) {
noticeConfigService.editNoticeTemplate(noticeTemplate);
return ResponseEntity.ok(Message.success("Edit success"));
try {
noticeConfigService.editNoticeTemplate(noticeTemplate);
return ResponseEntity.ok(Message.success("Edit success"));
} catch (NoticeTemplateMutationException exception) {
return templateMutationFailure(exception);
} catch (DataAccessException exception) {
return templateStorageUnavailable("update", exception);
} catch (Exception exception) {
return templateOperationFailed("update", exception);
}
}
@DeleteMapping(path = "/template/{id}")
@Operation(summary = "Delete existing notification template information", description = "Delete existing notification template information")
public ResponseEntity<Message<Void>> deleteNoticeTemplate(
@Parameter(description = "en: Notification template ID", example = "6565463543") @PathVariable("id") final Long templateId) {
// Returns success if it does not exist or if the deletion is successful
Optional<NoticeTemplate> noticeTemplate = noticeConfigService.getNoticeTemplatesById(templateId);
if (noticeTemplate.isEmpty()) {
return ResponseEntity.ok(Message.success("The specified notification template could not be queried, please check whether the parameters are correct"));
try {
noticeConfigService.deleteNoticeTemplate(templateId);
return ResponseEntity.ok(Message.success("Delete success"));
} catch (NoticeTemplateMutationException exception) {
return templateMutationFailure(exception);
} catch (DataAccessException exception) {
return templateStorageUnavailable("delete", exception);
} catch (Exception exception) {
return templateOperationFailed("delete", exception);
}
noticeConfigService.deleteNoticeTemplate(templateId);
return ResponseEntity.ok(Message.success("Delete success"));
}
@GetMapping(path = "/templates")
@@ -234,15 +260,27 @@ public class NoticeConfigController {
@Parameter(description = "Whether it is a preset template", example = "true") @RequestParam(defaultValue = "true") final boolean preset,
@Parameter(description = "List current page", example = "0") @RequestParam(defaultValue = "0") final int pageIndex,
@Parameter(description = "Number of list pages", example = "8") @RequestParam(defaultValue = "8") final int pageSize) {
Page<NoticeTemplate> templatePage = noticeConfigService.getNoticeTemplates(name, preset, pageIndex, pageSize);
return ResponseEntity.ok(Message.success(templatePage));
try {
Page<NoticeTemplate> templatePage = noticeConfigService.getNoticeTemplates(name, preset, pageIndex, pageSize);
return ResponseEntity.ok(Message.success(templatePage));
} catch (DataAccessException exception) {
return templateStorageUnavailable("list", exception);
} catch (Exception exception) {
return templateOperationFailed("list", exception);
}
}
@GetMapping(path = "/templates/all")
@Operation(summary = "Get a list of all message notification templates",
description = "Get a list of all message notification templates")
public ResponseEntity<Message<List<NoticeTemplate>>> getAllTemplates() {
return ResponseEntity.ok(Message.success(noticeConfigService.getAllNoticeTemplates()));
try {
return ResponseEntity.ok(Message.success(noticeConfigService.getAllNoticeTemplates()));
} catch (DataAccessException exception) {
return templateStorageUnavailable("list all", exception);
} catch (Exception exception) {
return templateOperationFailed("list all", exception);
}
}
@GetMapping(path = "/template/{id}")
@@ -250,11 +288,17 @@ public class NoticeConfigController {
description = "Get the notification template information based on the template ID")
public ResponseEntity<Message<NoticeTemplate>> getTemplateById(
@Parameter(description = "en: Notification template ID", example = "6565463543") @PathVariable("id") final Long templateId) {
Optional<NoticeTemplate> noticeTemplate = noticeConfigService.getNoticeTemplatesById(templateId);
if (noticeTemplate.isEmpty()) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, "The specified notification template could not be queried, please check whether the parameters are correct or refresh the page"));
try {
Optional<NoticeTemplate> noticeTemplate = noticeConfigService.getNoticeTemplatesById(templateId);
if (noticeTemplate.isEmpty()) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, NOTICE_TEMPLATE_NOT_FOUND_MESSAGE));
}
return ResponseEntity.ok(Message.success(noticeTemplate.get()));
} catch (DataAccessException exception) {
return templateStorageUnavailable("detail", exception);
} catch (Exception exception) {
return templateOperationFailed("detail", exception);
}
return ResponseEntity.ok(Message.success(noticeTemplate.get()));
}
@PostMapping(path = "/receiver/send-test-msg")
@@ -273,6 +317,25 @@ public class NoticeConfigController {
}
}
private ResponseEntity<Message<Void>> templateMutationFailure(NoticeTemplateMutationException exception) {
String message = switch (exception.getReason()) {
case INVALID_REQUEST -> NOTICE_TEMPLATE_INVALID_MESSAGE;
case NOT_FOUND -> NOTICE_TEMPLATE_NOT_FOUND_MESSAGE;
case READ_ONLY -> NOTICE_TEMPLATE_READ_ONLY_MESSAGE;
};
return ResponseEntity.ok(Message.fail(FAIL_CODE, message));
}
private <T> ResponseEntity<Message<T>> templateStorageUnavailable(String operation, Exception exception) {
log.error("notice template {} storage unavailable: {}", operation, exception.getClass().getSimpleName());
return ResponseEntity.ok(Message.fail(FAIL_CODE, NOTICE_TEMPLATE_STORAGE_UNAVAILABLE_MESSAGE));
}
private <T> ResponseEntity<Message<T>> templateOperationFailed(String operation, Exception exception) {
log.error("notice template {} failed: {}", operation, exception.getClass().getSimpleName());
return ResponseEntity.ok(Message.fail(FAIL_CODE, NOTICE_TEMPLATE_OPERATION_FAILED_MESSAGE));
}
private <T> ResponseEntity<Message<T>> receiverStorageUnavailable(String operation, Exception exception) {
log.error("receiver {} storage unavailable: {}", operation, exception.getClass().getSimpleName());
return ResponseEntity.ok(Message.fail(FAIL_CODE, "Receiver storage unavailable"));
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.alert.dao;
import java.util.HashSet;
import java.util.List;
import org.apache.hertzbeat.alert.dto.AlertGroupStatusEvidence;
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@@ -60,4 +61,13 @@ public interface GroupAlertDao extends JpaRepository<GroupAlert, Long>, JpaSpeci
* @return group alerts
*/
List<GroupAlert> findGroupAlertsByIdIn(HashSet<Long> ids);
/**
* Find only persisted status evidence for the requested group IDs.
* @param ids requested group IDs
* @return minimal ID/status projections
*/
@Query("select new org.apache.hertzbeat.alert.dto.AlertGroupStatusEvidence(alert.id, alert.status) "
+ "from GroupAlert alert where alert.id in :ids")
List<AlertGroupStatusEvidence> findStatusEvidenceByIdIn(@Param("ids") List<Long> ids);
}
@@ -17,13 +17,21 @@
package org.apache.hertzbeat.alert.dao;
import jakarta.persistence.LockModeType;
import java.util.Optional;
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* Query all enabled notification policies
*/
public interface NoticeTemplateDao extends JpaRepository<NoticeTemplate, Long>, JpaSpecificationExecutor<NoticeTemplate> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select template from NoticeTemplate template where template.id = :id")
Optional<NoticeTemplate> findByIdForUpdate(@Param("id") Long id);
}
@@ -37,6 +37,8 @@ public class AlertDefineDTO {
private String name;
@Excel(name = "Type")
private String type;
@Excel(name = "Datasource")
private String datasource;
@Excel(name = "Expr")
private String expr;
@Excel(name = "Period")
@@ -0,0 +1,29 @@
/*
* 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.dto;
import java.util.List;
/**
* Canonical group-alert evidence for a bounded set of requested IDs.
*/
public record AlertGroupEvidence(
List<AlertGroupStatusEvidence> groups,
List<Long> missingIds,
long observedAt) {
}
@@ -0,0 +1,24 @@
/*
* 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.dto;
/**
* Minimal persisted state for one existing alert group.
*/
public record AlertGroupStatusEvidence(Long id, String status) {
}
@@ -0,0 +1,24 @@
/*
* 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.dto;
import java.util.Set;
/** Authoritative batch-delete outcome. */
public record AlertInhibitDeleteResponse(String status, Set<Long> deletedIds, Set<Long> missingIds) {
}
@@ -0,0 +1,29 @@
/*
* 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.dto;
import java.util.List;
/** Stable pagination response independent of the persistence entity. */
public record AlertInhibitPageResponse(
List<AlertInhibitResponse> content,
long totalElements,
int totalPages,
int number,
int size) {
}
@@ -0,0 +1,40 @@
/*
* 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.dto;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import java.util.List;
import java.util.Map;
import lombok.Data;
/** Persistence-safe create/update input for an alert inhibit rule. */
@Data
public class AlertInhibitRequest {
private Long id;
private String name;
private Map<String, String> sourceLabels;
private Map<String, String> targetLabels;
private List<String> equalLabels;
private Boolean enable;
@JsonAnySetter
public void rejectUnknownField(String name, Object value) {
throw new IllegalArgumentException("Unsupported alert inhibit field: " + name);
}
}
@@ -0,0 +1,36 @@
/*
* 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.dto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/** Explicit read model for an alert inhibit rule. */
public record AlertInhibitResponse(
Long id,
String name,
Map<String, String> sourceLabels,
Map<String, String> targetLabels,
List<String> equalLabels,
boolean enable,
String creator,
String modifier,
LocalDateTime gmtCreate,
LocalDateTime gmtUpdate) {
}
@@ -27,6 +27,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
@@ -39,11 +40,15 @@ public class AlertExpressionEvalVisitor extends AlertExpressionBaseVisitor<List<
private static final String VALUE = "__value__";
private static final String TIMESTAMP = "__timestamp__";
private final QueryExecutor executor;
private final Function<String, List<Map<String, Object>>> executeQuery;
private final CommonTokenStream tokens;
public AlertExpressionEvalVisitor(QueryExecutor executor, CommonTokenStream tokens) {
this.executor = executor;
this(executor, tokens, false);
}
public AlertExpressionEvalVisitor(QueryExecutor executor, CommonTokenStream tokens, boolean preview) {
this.executeQuery = preview ? executor::executePreview : executor::execute;
this.tokens = tokens;
}
@@ -248,13 +253,13 @@ public class AlertExpressionEvalVisitor extends AlertExpressionBaseVisitor<List<
@Override
public List<Map<String, Object>> visitPromqlExpr(AlertExpressionParser.PromqlExprContext ctx) {
String rawPromql = tokens.getText(ctx.promql());
return executor.execute(rawPromql);
return executeQuery.apply(rawPromql);
}
@Override
public List<Map<String, Object>> visitSqlExpr(AlertExpressionParser.SqlExprContext ctx) {
String rawSql = tokens.getText(ctx.selectSql());
return executor.execute(rawSql);
return executeQuery.apply(rawSql);
}
@Override
@@ -269,7 +274,7 @@ public class AlertExpressionEvalVisitor extends AlertExpressionBaseVisitor<List<
private List<Map<String, Object>> callSqlOrPromql(String text) {
String script = text.substring(1, text.length() - 1);
return executor.execute(script);
return executeQuery.apply(script);
}
/**
@@ -319,4 +324,4 @@ public class AlertExpressionEvalVisitor extends AlertExpressionBaseVisitor<List<
default -> false;
};
}
}
}
@@ -0,0 +1,114 @@
/*
* 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.integration.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
/**
* Structured read contract for external alert integrations.
*/
public final class AlertIntegrationApiContract {
private AlertIntegrationApiContract() {
}
/**
* How honestly the current guide can describe a runnable integration.
*/
public enum Readiness {
@JsonProperty("ready")
READY,
@JsonProperty("configuration_required")
CONFIGURATION_REQUIRED,
@JsonProperty("guide_blocked")
GUIDE_BLOCKED
}
/**
* Safe public error codes.
*/
public enum RequestErrorCode {
SOURCE_UNSUPPORTED("external_alert_source_unsupported"),
GUIDE_UNAVAILABLE("external_alert_guide_unavailable");
private final String code;
RequestErrorCode(String code) {
this.code = code;
}
public String code() {
return code;
}
}
/**
* Catalog response.
*/
public record CatalogResponse(List<CatalogItem> items) {
public CatalogResponse {
items = List.copyOf(items);
}
}
/**
* Lightweight catalog row.
*/
public record CatalogItem(
String source,
String displayNameKey,
String iconKey,
Readiness readiness,
List<String> limitations) {
public CatalogItem {
limitations = List.copyOf(limitations);
}
}
/**
* One rendered, token-free integration guide.
*/
public record IntegrationGuide(
String source,
String displayNameKey,
String iconKey,
String method,
String ingressPath,
String payloadShape,
Map<String, String> requiredHeaders,
List<String> requiredFields,
List<String> steps,
List<String> snippets,
String acknowledgement,
Readiness readiness,
List<String> limitations) {
public IntegrationGuide {
requiredHeaders = Map.copyOf(requiredHeaders);
requiredFields = List.copyOf(requiredFields);
steps = List.copyOf(steps);
snippets = List.copyOf(snippets);
limitations = List.copyOf(limitations);
}
public CatalogItem toCatalogItem() {
return new CatalogItem(source, displayNameKey, iconKey, readiness, limitations);
}
}
}
@@ -0,0 +1,45 @@
/*
* 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.integration.api;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.RequestErrorCode;
/**
* Cause-free request failure with a stable safe public code.
*/
public class AlertIntegrationRequestException extends IllegalArgumentException {
private final RequestErrorCode errorCode;
private AlertIntegrationRequestException(RequestErrorCode errorCode) {
super(errorCode.code());
this.errorCode = errorCode;
}
public static AlertIntegrationRequestException sourceUnsupported() {
return new AlertIntegrationRequestException(RequestErrorCode.SOURCE_UNSUPPORTED);
}
public static AlertIntegrationRequestException guideUnavailable() {
return new AlertIntegrationRequestException(RequestErrorCode.GUIDE_UNAVAILABLE);
}
public RequestErrorCode errorCode() {
return errorCode;
}
}
@@ -0,0 +1,26 @@
/*
* 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.integration.guide;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.IntegrationGuide;
/**
* Maps one registered ingress bean source to its public integration guide.
*/
public record AlertIntegrationDescriptor(String ingressSource, IntegrationGuide guide) {
}
@@ -0,0 +1,266 @@
/*
* 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.integration.guide;
import static org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.CONFIGURATION_REQUIRED;
import static org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.GUIDE_BLOCKED;
import static org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.READY;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.IntegrationGuide;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness;
import org.springframework.stereotype.Component;
/**
* Frozen structured descriptors for the currently registered alert ingress adapters.
*/
@Component
public class AlertIntegrationDescriptorRegistry {
private static final String ACKNOWLEDGEMENT = "alert.integration.ack.accepted_for_processing";
private static final String AUTHORIZATION_REQUIRED =
"alert.integration.limit.bearer_configuration_required";
private final List<AlertIntegrationDescriptor> descriptors;
private final Set<String> ingressSources;
private final Map<String, AlertIntegrationDescriptor> byPublicSource;
public AlertIntegrationDescriptorRegistry() {
this(officialDescriptors());
}
AlertIntegrationDescriptorRegistry(List<AlertIntegrationDescriptor> descriptors) {
LinkedHashMap<String, AlertIntegrationDescriptor> ingressIndex = new LinkedHashMap<>();
LinkedHashMap<String, AlertIntegrationDescriptor> publicIndex = new LinkedHashMap<>();
for (AlertIntegrationDescriptor descriptor : descriptors) {
if (descriptor == null || descriptor.guide() == null
|| ingressIndex.put(descriptor.ingressSource(), descriptor) != null
|| publicIndex.put(descriptor.guide().source(), descriptor) != null) {
throw new IllegalStateException("Duplicate or invalid alert integration descriptor");
}
}
this.descriptors = List.copyOf(descriptors);
this.ingressSources = Set.copyOf(ingressIndex.keySet());
this.byPublicSource = Map.copyOf(publicIndex);
}
public List<AlertIntegrationDescriptor> descriptors() {
return descriptors;
}
public Set<String> ingressSources() {
return ingressSources;
}
public AlertIntegrationDescriptor findByPublicSource(String source) {
return byPublicSource.get(source);
}
public static AlertIntegrationDescriptorRegistry official() {
return new AlertIntegrationDescriptorRegistry();
}
private static List<AlertIntegrationDescriptor> officialDescriptors() {
return List.of(
descriptor("default", guide(
"webhook",
"hertzbeat",
"/api/alerts/report",
"single_alert",
List.of("labels", "content", "status", "startAt"),
List.of(
"alert.integration.webhook.step.create_token",
"alert.integration.webhook.step.configure_request",
"alert.integration.webhook.step.verify_alert"),
List.of("""
{
"labels": {"alertname": "HighCPUUsage", "instance": "server-1"},
"annotations": {"summary": "High CPU usage"},
"content": "CPU usage exceeded the configured threshold.",
"status": "firing",
"triggerTimes": 1,
"startAt": 1736580031832,
"activeAt": 1736580031832,
"endAt": null
}"""),
READY,
List.of())),
descriptor("prometheus", guide(
"prometheus",
"prometheus",
"/api/v2/alerts",
"prometheus_alert_array",
List.of("[].labels", "[].annotations", "[].startsAt", "[].endsAt"),
List.of(
"alert.integration.prometheus.step.create_token",
"alert.integration.prometheus.step.configure_alertmanager_target",
"alert.integration.prometheus.step.verify_alert"),
List.of("""
[
{
"labels": {"alertname": "HighCPUUsage", "instance": "server-1"},
"annotations": {"summary": "High CPU usage"},
"startsAt": "2026-01-01T00:00:00Z",
"endsAt": "0001-01-01T00:00:00Z"
}
]"""),
READY,
List.of())),
descriptor("alertmanager", guide(
"alertmanager",
"prometheus",
"/api/alerts/report/alertmanager",
"alertmanager_webhook",
List.of("alerts", "alerts[].labels", "alerts[].startsAt", "alerts[].endsAt"),
List.of(
"alert.integration.alertmanager.step.create_token",
"alert.integration.alertmanager.step.configure_webhook",
"alert.integration.alertmanager.step.verify_alert"),
List.of("""
{
"status": "firing",
"alerts": [
{
"labels": {"alertname": "HighCPUUsage", "instance": "server-1"},
"annotations": {"summary": "High CPU usage"},
"startsAt": "2026-01-01T00:00:00Z",
"endsAt": "0001-01-01T00:00:00Z"
}
]
}"""),
READY,
List.of())),
descriptor("skywalking", constrainedGuide(
"skywalking",
"skywalking",
"skywalking_alert_array",
List.of("[].alarmMessage", "[].startTime", "[].tags"),
CONFIGURATION_REQUIRED)),
descriptor("uptime-kuma", constrainedGuide(
"uptime-kuma",
"uptime-kuma",
"uptime_kuma_webhook",
List.of("heartbeat.status", "heartbeat.time", "monitor.id", "monitor.name"),
CONFIGURATION_REQUIRED)),
descriptor("zabbix", guide(
"zabbix",
"zabbix",
"/api/alerts/report/zabbix",
"single_alert",
List.of("labels", "content", "status", "startAt"),
List.of("alert.integration.zabbix.step.correct_guide_required"),
List.of(),
GUIDE_BLOCKED,
List.of(
"alert.integration.limit.zabbix.authorization_missing",
"alert.integration.limit.zabbix.response_contract_mismatch",
"alert.integration.limit.zabbix.recovery_time_semantics"))),
descriptor("tencent", constrainedGuide(
"tencent",
"tencent",
"tencent_cloud_webhook",
List.of(
"alarmStatus",
"alarmType",
"firstOccurTime",
"alarmObjInfo",
"alarmPolicyInfo.conditions"),
CONFIGURATION_REQUIRED)),
descriptor("alibabacloud-sls", constrainedGuide(
"alibabacloud-sls",
"alibabacloud",
"alibaba_cloud_sls_webhook",
List.of("alert_name", "status", "fire_time", "alert_time", "region", "project"),
CONFIGURATION_REQUIRED)),
descriptor("huaweicloud-ces", constrainedGuide(
"huaweicloud-ces",
"huaweicloud",
"huawei_cloud_smn_webhook",
List.of(
"signature",
"signing_cert_url",
"type",
"message",
"timestamp",
"topic_urn"),
CONFIGURATION_REQUIRED)),
descriptor("volcengine", constrainedGuide(
"volcengine",
"volcengine",
"volcengine_webhook",
List.of("Type"),
CONFIGURATION_REQUIRED)));
}
private static AlertIntegrationDescriptor descriptor(String ingressSource, IntegrationGuide guide) {
return new AlertIntegrationDescriptor(ingressSource, guide);
}
private static IntegrationGuide constrainedGuide(
String source,
String iconKey,
String payloadShape,
List<String> requiredFields,
Readiness readiness) {
return guide(
source,
iconKey,
"/api/alerts/report/" + source,
payloadShape,
requiredFields,
List.of("alert.integration.step.configure_bearer_capable_callback"),
List.of(),
readiness,
List.of(AUTHORIZATION_REQUIRED));
}
private static IntegrationGuide guide(
String source,
String iconKey,
String ingressPath,
String payloadShape,
List<String> requiredFields,
List<String> steps,
List<String> snippets,
Readiness readiness,
List<String> limitations) {
return new IntegrationGuide(
source,
"alert.integration.source." + source,
iconKey,
"POST",
ingressPath,
payloadShape,
requiredHeaders(),
requiredFields,
steps,
snippets,
ACKNOWLEDGEMENT,
readiness,
limitations);
}
private static Map<String, String> requiredHeaders() {
LinkedHashMap<String, String> headers = new LinkedHashMap<>();
headers.put("Authorization", "Bearer {token}");
return headers;
}
}
@@ -0,0 +1,81 @@
/*
* 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.integration.service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.CatalogResponse;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.IntegrationGuide;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationRequestException;
import org.apache.hertzbeat.alert.integration.guide.AlertIntegrationDescriptor;
import org.apache.hertzbeat.alert.integration.guide.AlertIntegrationDescriptorRegistry;
import org.apache.hertzbeat.alert.service.ExternAlertService;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
/**
* Builds alert integration read models from the registered ingress adapters.
*/
@Service
public class AlertIntegrationCatalogService {
private final List<ExternAlertService> externAlertServices;
private final AlertIntegrationDescriptorRegistry descriptorRegistry;
public AlertIntegrationCatalogService(
List<ExternAlertService> externAlertServices,
AlertIntegrationDescriptorRegistry descriptorRegistry) {
this.externAlertServices = List.copyOf(externAlertServices);
this.descriptorRegistry = descriptorRegistry;
}
public CatalogResponse catalog() {
requireAlignedDescriptors();
return new CatalogResponse(descriptorRegistry.descriptors().stream()
.map(AlertIntegrationDescriptor::guide)
.map(IntegrationGuide::toCatalogItem)
.toList());
}
public IntegrationGuide render(String source) {
if (!StringUtils.hasText(source)) {
throw AlertIntegrationRequestException.sourceUnsupported();
}
String normalizedSource = source.trim();
AlertIntegrationDescriptor descriptor = descriptorRegistry.findByPublicSource(normalizedSource);
if (descriptor == null) {
throw AlertIntegrationRequestException.sourceUnsupported();
}
requireAlignedDescriptors();
return descriptor.guide();
}
private void requireAlignedDescriptors() {
Map<String, ExternAlertService> servicesBySource = new LinkedHashMap<>();
for (ExternAlertService service : externAlertServices) {
if (service == null || !StringUtils.hasText(service.supportSource())
|| servicesBySource.put(service.supportSource(), service) != null) {
throw AlertIntegrationRequestException.guideUnavailable();
}
}
if (!descriptorRegistry.ingressSources().equals(servicesBySource.keySet())) {
throw AlertIntegrationRequestException.guideUnavailable();
}
}
}
@@ -0,0 +1,24 @@
/*
* 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.service;
/**
* Indicates that an alert-group evidence request violates its bounded ID contract.
*/
public class AlertGroupEvidenceRequestException extends RuntimeException {
}
@@ -0,0 +1,98 @@
/*
* 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.service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.hertzbeat.alert.dao.GroupAlertDao;
import org.apache.hertzbeat.alert.dto.AlertGroupEvidence;
import org.apache.hertzbeat.alert.dto.AlertGroupStatusEvidence;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/**
* Bounded canonical evidence query for persisted alert groups.
*/
@Service
public class AlertGroupEvidenceService {
private static final int MAX_RAW_IDS = 100;
private static final Set<String> SUPPORTED_STATUSES = Set.of(
CommonConstants.ALERT_STATUS_FIRING,
CommonConstants.ALERT_STATUS_PENDING,
CommonConstants.ALERT_STATUS_ACKNOWLEDGED,
CommonConstants.ALERT_STATUS_RESOLVED);
private final GroupAlertDao groupAlertDao;
public AlertGroupEvidenceService(GroupAlertDao groupAlertDao) {
this.groupAlertDao = groupAlertDao;
}
@Transactional(readOnly = true)
public AlertGroupEvidence getEvidence(List<String> ids) {
List<Long> requestedIds = normalizeIds(ids);
Map<Long, String> foundStatuses = new HashMap<>();
for (AlertGroupStatusEvidence evidence : groupAlertDao.findStatusEvidenceByIdIn(requestedIds)) {
requireSupportedStatus(evidence.status());
if (evidence.id() == null || foundStatuses.putIfAbsent(evidence.id(), evidence.status()) != null) {
throw new IllegalStateException();
}
}
List<AlertGroupStatusEvidence> groups = requestedIds.stream()
.filter(foundStatuses::containsKey)
.map(id -> new AlertGroupStatusEvidence(id, foundStatuses.get(id)))
.toList();
List<Long> missingIds = requestedIds.stream()
.filter(id -> !foundStatuses.containsKey(id))
.toList();
return new AlertGroupEvidence(groups, missingIds, System.currentTimeMillis());
}
private static List<Long> normalizeIds(List<String> ids) {
if (ids == null || ids.isEmpty() || ids.size() > MAX_RAW_IDS) {
throw new AlertGroupEvidenceRequestException();
}
try {
List<Long> normalizedIds = ids.stream()
.map(id -> {
if (!StringUtils.hasText(id)) {
throw new AlertGroupEvidenceRequestException();
}
return Long.parseLong(id.trim());
})
.toList();
if (normalizedIds.stream().anyMatch(id -> id <= 0)) {
throw new AlertGroupEvidenceRequestException();
}
return normalizedIds.stream().distinct().sorted().toList();
} catch (NumberFormatException exception) {
throw new AlertGroupEvidenceRequestException();
}
}
private static void requireSupportedStatus(String status) {
if (status == null || !SUPPORTED_STATUSES.contains(status)) {
throw new AlertGroupStatusNotSupportedException();
}
}
}
@@ -0,0 +1,95 @@
/*
* 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.service;
import java.util.Collection;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.config.AlertSseManager;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* Publishes safe alert group mutation events after authoritative state commits.
*/
@Slf4j
@Component
public class AlertGroupMutationPublisher {
private final AlertSseManager alertSseManager;
public AlertGroupMutationPublisher(AlertSseManager alertSseManager) {
this.alertSseManager = alertSseManager;
}
public void publishStatusChanged(Collection<Long> ids, String status) {
publishAfterCommit(ids, status, GroupMutation.STATUS_CHANGED);
}
public void publishDeleted(Collection<Long> ids) {
publishAfterCommit(ids, null, GroupMutation.DELETED);
}
private void publishAfterCommit(Collection<Long> ids, String status, GroupMutation mutation) {
if (ids == null || ids.isEmpty()) {
return;
}
List<Long> sortedIds = ids.stream().distinct().sorted().toList();
AlertGroupMutationEvent event =
new AlertGroupMutationEvent(sortedIds.get(0), sortedIds, status, mutation.eventName);
String payload = JsonUtil.toJson(event);
Runnable publication = () -> safelyBroadcast(payload);
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
// AlertService is transactional in production. Immediate publication is the explicit
// boundary for direct non-transactional calls such as maintenance tools and unit tests.
publication.run();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
publication.run();
}
});
}
private void safelyBroadcast(String payload) {
try {
alertSseManager.broadcastGroupMutation(payload);
} catch (RuntimeException exception) {
log.warn("Failed to broadcast committed alert mutation: {}",
exception.getClass().getSimpleName());
}
}
private enum GroupMutation {
STATUS_CHANGED("GROUP_STATUS_CHANGED"),
DELETED("GROUP_DELETED");
private final String eventName;
GroupMutation(String eventName) {
this.eventName = eventName;
}
}
private record AlertGroupMutationEvent(Long id, List<Long> ids, String status, String mutation) {
}
}
@@ -0,0 +1,24 @@
/*
* 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.service;
/**
* Indicates that at least one requested alert group does not exist.
*/
public class AlertGroupNotFoundException extends RuntimeException {
}
@@ -0,0 +1,24 @@
/*
* 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.service;
/**
* Indicates that a requested group-alert status transition is not supported.
*/
public class AlertGroupStatusNotSupportedException extends RuntimeException {
}
@@ -0,0 +1,135 @@
/*
* 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.service;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
import org.apache.hertzbeat.alert.dto.AlertInhibitResponse;
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
import org.springframework.stereotype.Component;
/** Validates the public contract and maps it to the persistence model. */
@Component
public class AlertInhibitContractMapper {
private static final int MAX_MATCHERS = 32;
private static final int MAX_EQUAL_LABELS = 32;
private static final int MAX_LABEL_KEY_LENGTH = 64;
private static final int MAX_LABEL_VALUE_LENGTH = 256;
private static final int MAX_LABEL_CONTENT_LENGTH = 1800;
public AlertInhibit toNewEntity(AlertInhibitRequest request) {
if (request == null || request.getId() != null) {
throw new IllegalArgumentException("id is not allowed when creating an alert inhibit");
}
return apply(request, new AlertInhibit());
}
public AlertInhibit toExistingEntity(AlertInhibitRequest request, AlertInhibit existing) {
if (request == null) {
throw new IllegalArgumentException("Alert inhibit request is required");
}
Long id = requirePositiveId(request.getId());
if (!id.equals(existing.getId())) {
throw new IllegalArgumentException("Alert inhibit identity does not match");
}
AlertInhibit target = new AlertInhibit();
target.setId(existing.getId());
target.setCreator(existing.getCreator());
target.setModifier(existing.getModifier());
target.setGmtCreate(existing.getGmtCreate());
target.setGmtUpdate(existing.getGmtUpdate());
return apply(request, target);
}
public AlertInhibitResponse toResponse(AlertInhibit entity) {
return new AlertInhibitResponse(entity.getId(), entity.getName(),
copyMap(entity.getSourceLabels()), copyMap(entity.getTargetLabels()),
entity.getEqualLabels() == null ? List.of() : List.copyOf(entity.getEqualLabels()),
Boolean.TRUE.equals(entity.getEnable()), entity.getCreator(), entity.getModifier(),
entity.getGmtCreate(), entity.getGmtUpdate());
}
public Long requirePositiveId(Long id) {
if (id == null || id <= 0) {
throw new IllegalArgumentException("A positive alert inhibit id is required");
}
return id;
}
private AlertInhibit apply(AlertInhibitRequest request, AlertInhibit target) {
String name = StringUtils.trimToNull(request.getName());
if (name == null || name.length() > 100) {
throw new IllegalArgumentException("Alert inhibit name is invalid");
}
if (request.getEnable() == null) {
throw new IllegalArgumentException("Alert inhibit enable is required");
}
target.setName(name);
target.setEnable(request.getEnable());
target.setSourceLabels(validateMatchers(request.getSourceLabels()));
target.setTargetLabels(validateMatchers(request.getTargetLabels()));
target.setEqualLabels(validateEqualLabels(request.getEqualLabels()));
return target;
}
private Map<String, String> validateMatchers(Map<String, String> matchers) {
if (matchers == null || matchers.isEmpty() || matchers.size() > MAX_MATCHERS) {
throw new IllegalArgumentException("Alert inhibit matchers are invalid");
}
Map<String, String> normalized = new LinkedHashMap<>();
int contentLength = 0;
for (Map.Entry<String, String> entry : matchers.entrySet()) {
String key = StringUtils.trimToNull(entry.getKey());
String value = StringUtils.trimToNull(entry.getValue());
if (key == null || key.length() > MAX_LABEL_KEY_LENGTH
|| value == null || value.length() > MAX_LABEL_VALUE_LENGTH) {
throw new IllegalArgumentException("Alert inhibit matcher is invalid");
}
contentLength += key.length() + value.length();
normalized.put(key, value);
}
if (contentLength > MAX_LABEL_CONTENT_LENGTH) {
throw new IllegalArgumentException("Alert inhibit matchers are too large");
}
return normalized;
}
private List<String> validateEqualLabels(List<String> equalLabels) {
if (equalLabels == null || equalLabels.isEmpty() || equalLabels.size() > MAX_EQUAL_LABELS) {
throw new IllegalArgumentException("Alert inhibit equal labels are invalid");
}
Set<String> normalized = new LinkedHashSet<>();
for (String candidate : equalLabels) {
String label = StringUtils.trimToNull(candidate);
if (label == null || label.length() > MAX_LABEL_KEY_LENGTH || !normalized.add(label)) {
throw new IllegalArgumentException("Alert inhibit equal label is invalid");
}
}
return List.copyOf(normalized);
}
private Map<String, String> copyMap(Map<String, String> labels) {
return labels == null ? Map.of() : Map.copyOf(labels);
}
}
@@ -0,0 +1,26 @@
/*
* 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.service;
/** Named missing-record boundary for alert inhibit operations. */
public class AlertInhibitNotFoundException extends RuntimeException {
public AlertInhibitNotFoundException() {
super();
}
}
@@ -0,0 +1,26 @@
/*
* 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.service;
/** Named uncertain-write boundary for alert inhibit operations. */
public class AlertInhibitOperationException extends RuntimeException {
public AlertInhibitOperationException(String message) {
super(message);
}
}
@@ -19,60 +19,24 @@ package org.apache.hertzbeat.alert.service;
import java.util.List;
import java.util.Set;
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
import org.springframework.data.domain.Page;
import org.apache.hertzbeat.alert.dto.AlertInhibitDeleteResponse;
import org.apache.hertzbeat.alert.dto.AlertInhibitPageResponse;
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
import org.apache.hertzbeat.alert.dto.AlertInhibitResponse;
/**
* management interface service for alert inhibit
*/
public interface AlertInhibitService {
/**
* Verify the correctness of the request data parameters
* @param alertInhibit AlertInhibit
* @param isModify whether modify
* @throws IllegalArgumentException A checksum parameter error is thrown
*/
void validate(AlertInhibit alertInhibit, boolean isModify) throws IllegalArgumentException;
/**
* New AlertInhibit
* @param alertInhibit AlertInhibit Entity
* @throws RuntimeException Added procedure exception throwing
*/
void addAlertInhibit(AlertInhibit alertInhibit) throws RuntimeException;
AlertInhibitResponse create(AlertInhibitRequest request);
/**
* Modifying an AlertInhibit
* @param alertInhibit Alarm definition Entity
* @throws RuntimeException Exception thrown during modification
*/
void modifyAlertInhibit(AlertInhibit alertInhibit) throws RuntimeException;
AlertInhibitResponse update(AlertInhibitRequest request);
/**
* Obtain AlertInhibit information
* @param inhibitId AlertInhibit ID
* @return AlertInhibit
* @throws RuntimeException An exception was thrown during the query
*/
AlertInhibit getAlertInhibit(long inhibitId) throws RuntimeException;
AlertInhibitResponse get(long inhibitId);
AlertInhibitDeleteResponse delete(Set<Long> inhibitIds);
/**
* Delete AlertInhibit in batches
* @param inhibitIds AlertInhibit IDs
* @throws RuntimeException Exception thrown during deletion
*/
void deleteAlertInhibits(Set<Long> inhibitIds) throws RuntimeException;
/**
* Dynamic conditional query
* @param inhibitIds Alarm Silence ID
* @param search Search Name
* @param sort Sort field
* @param order Sort mode: asc: ascending, desc: descending
* @param pageIndex List current page
* @param pageSize Number of list pages
* @return The query results
*/
Page<AlertInhibit> getAlertInhibits(List<Long> inhibitIds, String search, String sort, String order, int pageIndex, int pageSize);
AlertInhibitPageResponse list(List<Long> inhibitIds, String search, String sort, String order,
int pageIndex, int pageSize);
}
@@ -39,6 +39,9 @@ public class AlertSilenceContractMapper {
private static final int MAX_MATCHER_CONTENT_LENGTH = 1800;
public AlertSilence toNewEntity(AlertSilenceRequest request) {
if (request == null) {
throw new IllegalArgumentException("Alert silence request is required");
}
if (request.getId() != null) {
throw new IllegalArgumentException("id is not allowed when creating an alert silence");
}
@@ -46,6 +49,9 @@ public class AlertSilenceContractMapper {
}
public AlertSilence toExistingEntity(AlertSilenceRequest request, AlertSilence existing) {
if (request == null) {
throw new IllegalArgumentException("Alert silence request is required");
}
Long id = requirePositiveId(request.getId());
if (!id.equals(existing.getId())) {
throw new IllegalArgumentException("Alert silence identity does not match");
@@ -33,6 +33,14 @@ public interface DataSourceService {
*/
List<Map<String, Object>> calculate(String datasource, String expr);
/**
* Execute a preview calculation while preserving query execution failures.
* @param datasource datasource
* @param expr query expr
* @return preview result
*/
List<Map<String, Object>> calculatePreview(String datasource, String expr);
/**
* query result set from db
* @param datasource sql or promql
@@ -50,6 +58,15 @@ public interface DataSourceService {
*/
List<Map<String, Object>> query(String datasource, String expr, String alertType);
/**
* Execute an alert preview query without exposing executor failure details.
* @param datasource sql or promql
* @param expr query expr
* @param alertType alert rule type
* @return preview rows
*/
List<Map<String, Object>> queryPreview(String datasource, String expr, String alertType);
/**
* Get available datasource executors status
* @return map containing available executors by type (promql, sql)
@@ -0,0 +1,45 @@
/*
* 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.service;
import java.util.Objects;
/**
* Classifies a rejected notice-template mutation without carrying request data.
*/
public class NoticeTemplateMutationException extends RuntimeException {
private final Reason reason;
public NoticeTemplateMutationException(Reason reason) {
this.reason = Objects.requireNonNull(reason);
}
public Reason getReason() {
return reason;
}
/**
* Stable mutation rejection reasons.
*/
public enum Reason {
INVALID_REQUEST,
NOT_FOUND,
READ_ONLY
}
}
@@ -155,6 +155,7 @@ public class AlertDefineExcelImExportServiceImpl extends AlertDefineAbstractImEx
alertDefineDTO.setAnnotations(JsonUtil.fromJson(getCellValueAsString(row.getCell(6)), typeReference));
alertDefineDTO.setTemplate(getCellValueAsString(row.getCell(7)));
alertDefineDTO.setEnable(getCellValueAsBoolean(row.getCell(8)));
alertDefineDTO.setDatasource(getCellValueAsString(row.getCell(9)));
return alertDefineDTO;
}
@@ -186,7 +187,9 @@ public class AlertDefineExcelImExportServiceImpl extends AlertDefineAbstractImEx
CellStyle cellStyle = workbook.createCellStyle();
cellStyle.setAlignment(HorizontalAlignment.CENTER);
// set header
String[] headers = {"Name", "Type", "Expr", "Period", "Times", "Labels", "Annotations", "Template", "Enable"};
String[] headers = {
"Name", "Type", "Expr", "Period", "Times", "Labels", "Annotations", "Template", "Enable", "Datasource"
};
Row headerRow = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
Cell cell = headerRow.createCell(i);
@@ -227,6 +230,9 @@ public class AlertDefineExcelImExportServiceImpl extends AlertDefineAbstractImEx
Cell enableCell = row.createCell(8);
enableCell.setCellValue(alertDefineDTO.getEnable());
enableCell.setCellStyle(cellStyle);
Cell datasourceCell = row.createCell(9);
datasourceCell.setCellValue(alertDefineDTO.getDatasource());
datasourceCell.setCellStyle(cellStyle);
}
workbook.write(os);
os.close();
@@ -284,22 +284,32 @@ public class AlertDefineServiceImpl implements AlertDefineService {
if (!StringUtils.hasText(expr) || !StringUtils.hasText(datasource) || !StringUtils.hasText(type)) {
return Collections.emptyList();
}
List<Map<String, Object>> preview;
switch (type) {
case CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_PERIODIC:
return dataSourceService.calculate(datasource, expr);
preview = dataSourceService.calculatePreview(datasource, expr);
break;
case CommonConstants.LOG_ALERT_THRESHOLD_TYPE_PERIODIC:
// todo support alert expr preview
return dataSourceService.query(datasource, expr, type);
preview = dataSourceService.queryPreview(datasource, expr, type);
break;
case CommonConstants.TRACE_ALERT_THRESHOLD_TYPE_PERIODIC:
List<Map<String, Object>> tracePreview = dataSourceService.query(datasource, expr, type);
List<Map<String, Object>> tracePreview = dataSourceService.queryPreview(datasource, expr, type);
validateTracePreview(tracePreview);
return tracePreview;
preview = tracePreview;
break;
case CommonConstants.LOG_ALERT_THRESHOLD_TYPE_REALTIME:
return validateRealtimeExpressionPreview(type, expr);
preview = validateRealtimeExpressionPreview(type, expr);
break;
default:
log.error("Get define preview unsupported type: {}", type);
return Collections.emptyList();
preview = Collections.emptyList();
break;
}
if (preview == null || preview.size() <= CommonConstants.ALERT_PREVIEW_RESULT_LIMIT) {
return preview == null ? Collections.emptyList() : preview;
}
return List.copyOf(preview.subList(0, CommonConstants.ALERT_PREVIEW_RESULT_LIMIT));
}
private List<Map<String, Object>> validateRealtimeExpressionPreview(String type, String expr) {
@@ -19,14 +19,22 @@ package org.apache.hertzbeat.alert.service.impl;
import static org.apache.hertzbeat.common.constants.ExportFileConstants.YamlFile.FILE_SUFFIX;
import static org.apache.hertzbeat.common.constants.ExportFileConstants.YamlFile.TYPE;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.dto.AlertDefineDTO;
import org.apache.hertzbeat.alert.dto.ExportAlertDefineDTO;
import org.springframework.stereotype.Service;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.apache.hertzbeat.common.util.export.YamlExportUtils;
import org.springframework.stereotype.Service;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
import org.yaml.snakeyaml.nodes.Tag;
/**
* Configure the import and export Yaml format.
@@ -36,6 +44,14 @@ import org.yaml.snakeyaml.Yaml;
@Service
public class AlertDefineYamlImExportServiceImpl extends AlertDefineAbstractImExportServiceImpl {
private static final int MAX_CODE_POINTS = 1_048_576;
private static final int MAX_NESTING_DEPTH = 50;
private static final int MAX_ALIASES_FOR_COLLECTIONS = 20;
private static final Tag LEGACY_EXPORT_ALERT_DEFINE_TAG = new Tag(ExportAlertDefineDTO.class);
private static final Tag LEGACY_ALERT_DEFINE_TAG = new Tag(AlertDefineDTO.class);
private static final Set<Tag> ALLOWED_LEGACY_TAGS =
Set.of(LEGACY_EXPORT_ALERT_DEFINE_TAG, LEGACY_ALERT_DEFINE_TAG);
@Override
public String type() {
return TYPE;
@@ -48,14 +64,50 @@ public class AlertDefineYamlImExportServiceImpl extends AlertDefineAbstractImExp
@Override
public List<ExportAlertDefineDTO> parseImport(InputStream is) {
Yaml yaml = new Yaml();
return yaml.load(is);
LoaderOptions loaderOptions = new LoaderOptions();
loaderOptions.setCodePointLimit(MAX_CODE_POINTS);
loaderOptions.setNestingDepthLimit(MAX_NESTING_DEPTH);
loaderOptions.setMaxAliasesForCollections(MAX_ALIASES_FOR_COLLECTIONS);
loaderOptions.setTagInspector(ALLOWED_LEGACY_TAGS::contains);
Yaml yaml = new Yaml(new LegacyAlertDefineSafeConstructor(loaderOptions));
Object payload = yaml.load(is);
if (!(payload instanceof List<?> records)) {
throw new IllegalArgumentException("Alert define YAML must contain a list");
}
return records.stream().map(AlertDefineYamlImExportServiceImpl::toExportAlertDefine).toList();
}
@Override
public void writeOs(List<ExportAlertDefineDTO> exportAlertDefineList, OutputStream os) {
List<Object> portableRecords = exportAlertDefineList.stream()
.map(AlertDefineYamlImExportServiceImpl::toPortableRecord)
.toList();
YamlExportUtils.exportWriteOs(portableRecords, os);
}
YamlExportUtils.exportWriteOs(exportAlertDefineList, os);
private static Object toPortableRecord(ExportAlertDefineDTO record) {
Map<?, ?> portableRecord = JsonUtil.convertValueQuietly(record, Map.class);
if (portableRecord == null) {
throw new IllegalArgumentException("Alert define YAML contains an invalid export record");
}
return portableRecord;
}
private static ExportAlertDefineDTO toExportAlertDefine(Object record) {
ExportAlertDefineDTO alertDefine = JsonUtil.convertValueQuietly(record, ExportAlertDefineDTO.class);
if (alertDefine == null || alertDefine.getAlertDefine() == null) {
throw new IllegalArgumentException("Alert define YAML contains an invalid record");
}
return alertDefine;
}
private static final class LegacyAlertDefineSafeConstructor extends SafeConstructor {
private LegacyAlertDefineSafeConstructor(LoaderOptions loaderOptions) {
super(loaderOptions);
yamlConstructors.put(LEGACY_EXPORT_ALERT_DEFINE_TAG, new ConstructYamlMap());
yamlConstructors.put(LEGACY_ALERT_DEFINE_TAG, new ConstructYamlMap());
}
}
}
@@ -20,66 +20,122 @@ package org.apache.hertzbeat.alert.service.impl;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.dao.AlertInhibitDao;
import org.apache.hertzbeat.alert.dto.AlertInhibitDeleteResponse;
import org.apache.hertzbeat.alert.dto.AlertInhibitPageResponse;
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
import org.apache.hertzbeat.alert.dto.AlertInhibitResponse;
import org.apache.hertzbeat.alert.reduce.AlarmInhibitReduce;
import org.apache.hertzbeat.alert.service.AlertInhibitContractMapper;
import org.apache.hertzbeat.alert.service.AlertInhibitNotFoundException;
import org.apache.hertzbeat.alert.service.AlertInhibitOperationException;
import org.apache.hertzbeat.alert.service.AlertInhibitService;
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
import org.springframework.beans.factory.annotation.Autowired;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/**
* management interface service implement for alert inhibit
*/
@Service
@Transactional(rollbackFor = Exception.class)
@Slf4j
public class AlertInhibitServiceImpl implements AlertInhibitService {
@Autowired
private AlertInhibitDao alertInhibitDao;
@Autowired
private AlarmInhibitReduce alarmInhibitReduce;
private static final Set<String> SORT_FIELDS = Set.of("id", "name", "enable", "gmtCreate", "gmtUpdate");
private static final int MAX_PAGE_SIZE = 100;
@Override
public void validate(AlertInhibit alertInhibit, boolean isModify) throws IllegalArgumentException {
// todo
private final AlertInhibitDao alertInhibitDao;
private final AlarmInhibitReduce alarmInhibitReduce;
private final AlertInhibitContractMapper mapper;
public AlertInhibitServiceImpl(AlertInhibitDao alertInhibitDao, AlarmInhibitReduce alarmInhibitReduce,
AlertInhibitContractMapper mapper) {
this.alertInhibitDao = alertInhibitDao;
this.alarmInhibitReduce = alarmInhibitReduce;
this.mapper = mapper;
}
@Override
public void addAlertInhibit(AlertInhibit alertInhibit) throws RuntimeException {
alertInhibitDao.save(alertInhibit);
public AlertInhibitResponse create(AlertInhibitRequest request) {
AlertInhibit saved = alertInhibitDao.save(mapper.toNewEntity(request));
if (saved == null || saved.getId() == null) {
throw new AlertInhibitOperationException("Alert inhibit create did not return an identity");
}
AlertInhibit authoritative = alertInhibitDao.findById(saved.getId())
.orElseThrow(() -> new AlertInhibitOperationException("Alert inhibit create did not converge"));
refreshAlertInhibitsCache();
return mapper.toResponse(authoritative);
}
@Override
public void modifyAlertInhibit(AlertInhibit alertInhibit) throws RuntimeException {
alertInhibitDao.save(alertInhibit);
public AlertInhibitResponse update(AlertInhibitRequest request) {
Long id = mapper.requirePositiveId(request == null ? null : request.getId());
AlertInhibit existing = alertInhibitDao.findById(id).orElseThrow(AlertInhibitNotFoundException::new);
alertInhibitDao.save(mapper.toExistingEntity(request, existing));
AlertInhibit authoritative = alertInhibitDao.findById(id)
.orElseThrow(() -> new AlertInhibitOperationException("Alert inhibit update did not converge"));
refreshAlertInhibitsCache();
return mapper.toResponse(authoritative);
}
@Override
public AlertInhibit getAlertInhibit(long inhibitId) throws RuntimeException {
return alertInhibitDao.findById(inhibitId).orElse(null);
@Transactional(readOnly = true)
public AlertInhibitResponse get(long inhibitId) {
mapper.requirePositiveId(inhibitId);
return alertInhibitDao.findById(inhibitId).map(mapper::toResponse)
.orElseThrow(AlertInhibitNotFoundException::new);
}
@Override
public void deleteAlertInhibits(Set<Long> inhibitIds) throws RuntimeException {
alertInhibitDao.deleteAlertInhibitsByIdIn(inhibitIds);
public AlertInhibitDeleteResponse delete(Set<Long> inhibitIds) {
Set<Long> requested = validateIds(inhibitIds);
Set<Long> existing = ids(alertInhibitDao.findAllById(requested));
Set<Long> missing = new LinkedHashSet<>(requested);
missing.removeAll(existing);
if (!existing.isEmpty()) {
alertInhibitDao.deleteAlertInhibitsByIdIn(existing);
}
Set<Long> remaining = ids(alertInhibitDao.findAllById(requested));
if (!remaining.isEmpty()) {
throw new AlertInhibitOperationException("Alert inhibit delete did not converge");
}
refreshAlertInhibitsCache();
String status = existing.isEmpty() ? "missing" : missing.isEmpty() ? "deleted" : "partial";
return new AlertInhibitDeleteResponse(status, Set.copyOf(existing), Set.copyOf(missing));
}
@Override
public Page<AlertInhibit> getAlertInhibits(List<Long> inhibitIds, String search, String sort, String order, int pageIndex, int pageSize) {
@Transactional(readOnly = true)
public AlertInhibitPageResponse list(List<Long> inhibitIds, String search, String sort, String order,
int pageIndex, int pageSize) {
List<Long> ids = inhibitIds == null ? null : List.copyOf(validateIds(new LinkedHashSet<>(inhibitIds)));
String query = StringUtils.trimToNull(search);
if (query != null && query.length() > 100) {
throw new IllegalArgumentException("Alert inhibit search is too long");
}
if (!SORT_FIELDS.contains(sort) || !("asc".equalsIgnoreCase(order) || "desc".equalsIgnoreCase(order))) {
throw new IllegalArgumentException("Alert inhibit sort is invalid");
}
if (pageIndex < 0 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
throw new IllegalArgumentException("Alert inhibit page is invalid");
}
Page<AlertInhibit> page = alertInhibitDao.findAll(specification(ids, query),
PageRequest.of(pageIndex, pageSize, Sort.by(Sort.Direction.fromString(order), sort)));
List<AlertInhibitResponse> content = page.getContent().stream().map(mapper::toResponse).toList();
return new AlertInhibitPageResponse(content, page.getTotalElements(), page.getTotalPages(),
page.getNumber(), page.getSize());
}
private Specification<AlertInhibit> specification(List<Long> inhibitIds, String search) {
Specification<AlertInhibit> specification = (root, query, criteriaBuilder) -> {
List<Predicate> andList = new ArrayList<>();
if (inhibitIds != null && !inhibitIds.isEmpty()) {
@@ -89,11 +145,11 @@ public class AlertInhibitServiceImpl implements AlertInhibitService {
}
andList.add(inPredicate);
}
if (StringUtils.hasText(search)) {
if (search != null) {
Predicate predicate = criteriaBuilder.or(
criteriaBuilder.like(
criteriaBuilder.lower(root.get("name")),
"%" + search.toLowerCase() + "%"
"%" + search.toLowerCase(Locale.ROOT) + "%"
)
);
andList.add(predicate);
@@ -101,9 +157,22 @@ public class AlertInhibitServiceImpl implements AlertInhibitService {
Predicate[] predicates = new Predicate[andList.size()];
return criteriaBuilder.and(andList.toArray(predicates));
};
Sort sortExp = Sort.by(new Sort.Order(Sort.Direction.fromString(order), sort));
PageRequest pageRequest = PageRequest.of(pageIndex, pageSize, sortExp);
return alertInhibitDao.findAll(specification, pageRequest);
return specification;
}
private Set<Long> validateIds(Set<Long> inhibitIds) {
if (inhibitIds == null || inhibitIds.isEmpty()) {
throw new IllegalArgumentException("Alert inhibit ids are required");
}
LinkedHashSet<Long> result = new LinkedHashSet<>();
inhibitIds.forEach(id -> result.add(mapper.requirePositiveId(id)));
return result;
}
private Set<Long> ids(Iterable<AlertInhibit> inhibits) {
Set<Long> ids = new LinkedHashSet<>();
inhibits.forEach(inhibit -> ids.add(inhibit.getId()));
return ids;
}
private void refreshAlertInhibitsCache() {
@@ -18,10 +18,8 @@
package org.apache.hertzbeat.alert.service.impl;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.dto.AlertManagerExternAlert;
import org.apache.hertzbeat.alert.dto.PrometheusExternAlert;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
@@ -36,7 +34,6 @@ import org.springframework.util.StringUtils;
/**
* Alertmanager external alarm service impl
*/
@Slf4j
@Service
public class AlertManagerExternAlertService implements ExternAlertService {
@@ -47,55 +44,50 @@ public class AlertManagerExternAlertService implements ExternAlertService {
@Override
public void addExternAlert(String content) {
AlertManagerExternAlert alert = JsonUtil.fromJsonQuietly(content, AlertManagerExternAlert.class);
if (alert == null) {
log.warn("Parse alertmanager external alert content failed");
return;
}
List<PrometheusExternAlert> alerts = alert.getAlerts();
if (alerts == null || alerts.isEmpty()) {
log.warn("Received alertmanager external alert without alerts");
return;
}
for (PrometheusExternAlert prometheusAlert : alerts) {
Map<String, String> annotations = prometheusAlert.getAnnotations();
if (annotations == null) {
annotations = new HashMap<>(8);
}
if (StringUtils.hasText(prometheusAlert.getGeneratorURL())) {
annotations.put("generatorURL", prometheusAlert.getGeneratorURL());
}
String description = annotations.get("description");
if (description == null) {
description = annotations.get("summary");
}
if (description == null) {
description = annotations.values().stream().findFirst().orElse("");
}
Map<String, String> labels = prometheusAlert.getLabels();
if (labels == null) {
labels = new HashMap<>(8);
}
labels.put("__source__", "alertmanager");
String status = CommonConstants.ALERT_STATUS_FIRING;
Instant now = Instant.now();
Instant endsAt = prometheusAlert.getEndsAt();
if (endsAt != null && endsAt.getEpochSecond() > 0 && endsAt.isBefore(now)) {
status = CommonConstants.ALERT_STATUS_RESOLVED;
}
SingleAlert singleAlert = SingleAlert.builder()
.content(description)
.status(status)
.activeAt(CommonConstants.ALERT_STATUS_FIRING.equals(status) ? Instant.now().toEpochMilli() : null)
.startAt(prometheusAlert.getStartsAt() != null ? prometheusAlert.getStartsAt().toEpochMilli() : Instant.now().toEpochMilli())
.endAt(CommonConstants.ALERT_STATUS_RESOLVED.equals(status) ? prometheusAlert.getEndsAt().toEpochMilli() : null)
.labels(labels)
.annotations(prometheusAlert.getAnnotations())
.triggerTimes(1)
.build();
AlertManagerExternAlert alert = ExternalAlertIngressValidator.requirePresent(
JsonUtil.fromJsonQuietly(content, AlertManagerExternAlert.class));
List<PrometheusExternAlert> alerts =
ExternalAlertIngressValidator.requireBatch(alert.getAlerts());
List<SingleAlert> singleAlerts = alerts.stream()
.map(this::toSingleAlert)
.toList();
singleAlerts.forEach(alarmCommonReduce::reduceAndSendAlarm);
}
alarmCommonReduce.reduceAndSendAlarm(singleAlert);
private SingleAlert toSingleAlert(PrometheusExternAlert prometheusAlert) {
Map<String, String> annotations =
ExternalAlertIngressValidator.normalizeAnnotations(prometheusAlert.getAnnotations());
if (StringUtils.hasText(prometheusAlert.getGeneratorURL())) {
annotations.put("generatorURL", prometheusAlert.getGeneratorURL());
}
String description = annotations.get("description");
if (description == null) {
description = annotations.get("summary");
}
if (description == null) {
description = annotations.values().stream().findFirst().orElse("");
}
Map<String, String> labels =
ExternalAlertIngressValidator.requireBusinessLabels(prometheusAlert.getLabels());
labels.put("__source__", "alertmanager");
String status = CommonConstants.ALERT_STATUS_FIRING;
Instant now = Instant.now();
Instant endsAt = prometheusAlert.getEndsAt();
if (endsAt != null && endsAt.getEpochSecond() > 0 && endsAt.isBefore(now)) {
status = CommonConstants.ALERT_STATUS_RESOLVED;
}
return ExternalAlertIngressValidator.normalize(SingleAlert.builder()
.content(description)
.status(status)
.activeAt(CommonConstants.ALERT_STATUS_FIRING.equals(status) ? Instant.now().toEpochMilli() : null)
.startAt(prometheusAlert.getStartsAt() != null
? prometheusAlert.getStartsAt().toEpochMilli() : Instant.now().toEpochMilli())
.endAt(CommonConstants.ALERT_STATUS_RESOLVED.equals(status)
? prometheusAlert.getEndsAt().toEpochMilli() : null)
.labels(labels)
.annotations(annotations)
.triggerTimes(1)
.build());
}
@Override
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
@@ -32,6 +33,9 @@ import org.apache.hertzbeat.alert.dao.GroupAlertDao;
import org.apache.hertzbeat.alert.dao.SingleAlertDao;
import org.apache.hertzbeat.alert.dto.AlertSummary;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.AlertGroupMutationPublisher;
import org.apache.hertzbeat.alert.service.AlertGroupNotFoundException;
import org.apache.hertzbeat.alert.service.AlertGroupStatusNotSupportedException;
import org.apache.hertzbeat.alert.service.AlertService;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
@@ -63,6 +67,9 @@ public class AlertServiceImpl implements AlertService {
@Autowired
private AlarmCommonReduce alarmCommonReduce;
@Autowired
private AlertGroupMutationPublisher alertGroupMutationPublisher;
@Override
public Page<SingleAlert> getSingleAlerts(String status, String search, String sort, String order, int pageIndex, int pageSize) {
Specification<SingleAlert> specification = (root, query, criteriaBuilder) -> {
@@ -166,12 +173,17 @@ public class AlertServiceImpl implements AlertService {
@Override
public void deleteGroupAlerts(HashSet<Long> ids) {
if (ids.contains(null)) {
throw new AlertGroupNotFoundException();
}
List<GroupAlert> groupAlerts = groupAlertDao.findGroupAlertsByIdIn(ids);
requireExactGroupAlertTargets(ids, groupAlerts);
for (GroupAlert groupAlert : groupAlerts) {
List<String> firingAlerts = groupAlert.getAlertFingerprints();
singleAlertDao.deleteSingleAlertsByFingerprintIn(firingAlerts);
}
groupAlertDao.deleteGroupAlertsByIdIn(ids);
alertGroupMutationPublisher.publishDeleted(ids);
}
@Override
@@ -184,10 +196,13 @@ public class AlertServiceImpl implements AlertService {
if (!StringUtils.hasText(status) || ids == null || ids.isEmpty()) {
return;
}
List<GroupAlert> groupAlerts = groupAlertDao.findAllById(ids);
if (groupAlerts.isEmpty()) {
return;
requireSupportedGroupAlertStatus(status);
List<Long> requestedIds = ids.stream().distinct().toList();
if (requestedIds.contains(null)) {
throw new AlertGroupNotFoundException();
}
List<GroupAlert> groupAlerts = groupAlertDao.findAllById(requestedIds);
requireExactGroupAlertTargets(requestedIds, groupAlerts);
long now = Instant.now().toEpochMilli();
List<String> fingerprints = groupAlerts.stream()
.map(GroupAlert::getAlertFingerprints)
@@ -217,6 +232,23 @@ public class AlertServiceImpl implements AlertService {
if (!singleAlerts.isEmpty()) {
singleAlertDao.saveAll(singleAlerts);
}
alertGroupMutationPublisher.publishStatusChanged(requestedIds, status);
}
private static void requireSupportedGroupAlertStatus(String status) {
if (!CommonConstants.ALERT_STATUS_FIRING.equals(status)
&& !CommonConstants.ALERT_STATUS_ACKNOWLEDGED.equals(status)
&& !CommonConstants.ALERT_STATUS_RESOLVED.equals(status)) {
throw new AlertGroupStatusNotSupportedException();
}
}
private static void requireExactGroupAlertTargets(Collection<Long> requestedIds, List<GroupAlert> groupAlerts) {
List<Long> foundIds = groupAlerts.stream().map(GroupAlert::getId).distinct().toList();
// Batch mutations are all-or-nothing so clients cannot treat a partial update as authoritative success.
if (foundIds.size() != requestedIds.size() || !foundIds.containsAll(requestedIds)) {
throw new AlertGroupNotFoundException();
}
}
@Override
@@ -73,7 +73,7 @@ public class AlertSilenceServiceImpl implements AlertSilenceService {
@Override
public AlertSilenceResponse update(AlertSilenceRequest request) {
Long id = mapper.requirePositiveId(request.getId());
Long id = mapper.requirePositiveId(request == null ? null : request.getId());
AlertSilence existing = alertSilenceDao.findById(id).orElseThrow(AlertSilenceNotFoundException::new);
alertSilenceDao.save(mapper.toExistingEntity(request, existing));
AlertSilence authoritative = alertSilenceDao.findById(id)
@@ -61,6 +61,8 @@ public class DataSourceServiceImpl implements DataSourceService {
private static final List<String> TRACE_ALLOWED_TABLES = List.of("hertzbeat_apm_red_1m", "hzb_traces");
private static final String PREVIEW_QUERY_EXECUTION_FAILED = "Preview query execution failed";
protected ResourceBundle bundle = ResourceBundleUtil.getBundle("alerter");
@Setter
@@ -85,6 +87,15 @@ public class DataSourceServiceImpl implements DataSourceService {
@Override
public List<Map<String, Object>> calculate(String datasource, String expr) {
return calculate(datasource, expr, false);
}
@Override
public List<Map<String, Object>> calculatePreview(String datasource, String expr) {
return calculate(datasource, expr, true);
}
private List<Map<String, Object>> calculate(String datasource, String expr, boolean strict) {
if (!StringUtils.hasText(expr)) {
throw new IllegalArgumentException("Empty expression");
}
@@ -99,11 +110,19 @@ public class DataSourceServiceImpl implements DataSourceService {
// replace all white space
expr = expr.replaceAll("\\s+", " ");
try {
return evaluate(expr, executor);
return evaluate(expr, executor, strict);
} catch (AlertExpressionException ae) {
if (strict) {
log.warn("Alert preview calculation rejected for datasource {}", datasource);
throw new AlertExpressionException(PREVIEW_QUERY_EXECUTION_FAILED);
}
log.error("Calculate query parse error, datasource: {}, expr: {}, msg: {}", datasource, expr, ae.getMessage(), ae);
throw ae;
} catch (Exception e) {
if (strict) {
log.warn("Alert preview calculation execution failed for datasource {}", datasource);
throw new AlertExpressionException(PREVIEW_QUERY_EXECUTION_FAILED);
}
log.error("Error executing query on datasource {}: {}", datasource, e.getMessage());
throw new RuntimeException("Query execution failed", e);
}
@@ -116,6 +135,15 @@ public class DataSourceServiceImpl implements DataSourceService {
@Override
public List<Map<String, Object>> query(String datasource, String expr, String alertType) {
return query(datasource, expr, alertType, false);
}
@Override
public List<Map<String, Object>> queryPreview(String datasource, String expr, String alertType) {
return query(datasource, expr, alertType, true);
}
private List<Map<String, Object>> query(String datasource, String expr, String alertType, boolean strict) {
if (!StringUtils.hasText(expr)) {
throw new IllegalArgumentException("Empty expression");
}
@@ -136,13 +164,27 @@ public class DataSourceServiceImpl implements DataSourceService {
}
try {
return executor.execute(expr);
String executableExpression = strict && isSqlDatasource(datasource) ? limitPreviewSql(expr) : expr;
return strict ? executor.executePreview(executableExpression) : executor.execute(executableExpression);
} catch (Exception e) {
if (strict) {
log.warn("Alert preview query execution failed for datasource {}", datasource);
throw new AlertExpressionException(PREVIEW_QUERY_EXECUTION_FAILED);
}
log.error("Error executing query on datasource {}: {}", datasource, e.getMessage());
throw new AlertExpressionException(e.getMessage());
}
}
private String limitPreviewSql(String sql) {
String boundedSql = sql.stripTrailing();
if (boundedSql.endsWith(";")) {
boundedSql = boundedSql.substring(0, boundedSql.length() - 1).stripTrailing();
}
return "SELECT * FROM (" + boundedSql + ") AS hertzbeat_preview LIMIT "
+ CommonConstants.ALERT_PREVIEW_RESULT_LIMIT;
}
/**
* Check if the datasource is SQL-based
*/
@@ -169,7 +211,7 @@ public class DataSourceServiceImpl implements DataSourceService {
return logSqlSecurityValidator;
}
private List<Map<String, Object>> evaluate(String expr, QueryExecutor executor) {
private List<Map<String, Object>> evaluate(String expr, QueryExecutor executor, boolean strict) {
CommonTokenStream tokens = createTokenStream(expr);
AlertExpressionParser parser = new AlertExpressionParser(tokens);
ParseTree tree = expressionCache.get(expr, e -> parser.expr());
@@ -178,7 +220,7 @@ public class DataSourceServiceImpl implements DataSourceService {
if (tokens.index() > 0 && tokens.LA(1) != Token.EOF) {
throw new AlertExpressionException(bundle.getString("alerter.calculate.parse.error"));
}
AlertExpressionEvalVisitor visitor = new AlertExpressionEvalVisitor(executor, tokens);
AlertExpressionEvalVisitor visitor = new AlertExpressionEvalVisitor(executor, tokens, strict);
return visitor.visit(tree);
}
@@ -18,7 +18,6 @@
package org.apache.hertzbeat.alert.service.impl;
import java.time.Instant;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.ExternAlertService;
import org.apache.hertzbeat.common.constants.CommonConstants;
@@ -30,7 +29,6 @@ import org.springframework.stereotype.Service;
/**
* Default external alarm service impl
*/
@Slf4j
@Service
public class DefaultExternAlertService implements ExternAlertService {
@@ -39,11 +37,8 @@ public class DefaultExternAlertService implements ExternAlertService {
@Override
public void addExternAlert(String content) {
SingleAlert alert = JsonUtil.fromJsonQuietly(content, SingleAlert.class);
if (alert == null) {
log.warn("Failed to parse default external alert content");
throw new IllegalArgumentException("parse extern alert content failed!");
}
SingleAlert alert = ExternalAlertIngressValidator.normalize(
JsonUtil.fromJsonQuietly(content, SingleAlert.class));
alert.setId(null);
String status = alert.getStatus();
if (status == null) {
@@ -0,0 +1,71 @@
/*
* 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.service.impl;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
/**
* Validates and normalizes the synchronous boundary before an external alert
* is accepted for asynchronous processing.
*/
final class ExternalAlertIngressValidator {
private static final String ALERT_REJECTED = "external_alert_rejected";
private ExternalAlertIngressValidator() {
}
static <T> T requirePresent(T value) {
if (value == null) {
throw rejected();
}
return value;
}
static <T> List<T> requireBatch(List<T> values) {
if (values == null || values.isEmpty() || values.stream().anyMatch(value -> value == null)) {
throw rejected();
}
return List.copyOf(values);
}
static SingleAlert normalize(SingleAlert alert) {
requirePresent(alert);
alert.setLabels(requireBusinessLabels(alert.getLabels()));
alert.setAnnotations(normalizeAnnotations(alert.getAnnotations()));
return alert;
}
static Map<String, String> requireBusinessLabels(Map<String, String> labels) {
if (labels == null || labels.isEmpty()) {
throw rejected();
}
return new HashMap<>(labels);
}
static Map<String, String> normalizeAnnotations(Map<String, String> annotations) {
return annotations == null ? new HashMap<>(8) : new HashMap<>(annotations);
}
private static IllegalArgumentException rejected() {
return new IllegalArgumentException(ALERT_REJECTED);
}
}
@@ -30,6 +30,7 @@ import org.apache.hertzbeat.alert.notice.AlertNoticeDispatch;
import org.apache.hertzbeat.alert.dao.NoticeReceiverDao;
import org.apache.hertzbeat.alert.dao.NoticeRuleDao;
import org.apache.hertzbeat.alert.dao.NoticeTemplateDao;
import org.apache.hertzbeat.alert.service.NoticeTemplateMutationException;
import org.apache.hertzbeat.alert.service.NoticeConfigService;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.springframework.beans.factory.annotation.Autowired;
@@ -131,12 +132,12 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine
} else {
// Query custom templates
Specification<NoticeTemplate> specification = (root, query, criteriaBuilder) -> {
Predicate predicate = criteriaBuilder.conjunction();
Predicate predicate = criteriaBuilder.equal(root.get("preset"), false);
if (StringUtils.isNotBlank(name)) {
Predicate predicateName = criteriaBuilder.like(
criteriaBuilder.lower(root.get("name")), "%" + name.toLowerCase() + "%"
);
predicate = criteriaBuilder.and(predicateName);
predicate = criteriaBuilder.and(predicate, predicateName);
}
return predicate;
};
@@ -272,19 +273,49 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine
@Override
public void addNoticeTemplate(NoticeTemplate noticeTemplate) {
noticeTemplateDao.save(noticeTemplate);
if (noticeTemplate.getId() != null || noticeTemplate.isPreset()) {
throw new NoticeTemplateMutationException(NoticeTemplateMutationException.Reason.INVALID_REQUEST);
}
NoticeTemplate customTemplate = NoticeTemplate.builder()
.name(noticeTemplate.getName())
.type(noticeTemplate.getType())
.preset(false)
.content(noticeTemplate.getContent())
.build();
noticeTemplateDao.save(customTemplate);
clearNoticeRulesCache();
}
@Override
public void editNoticeTemplate(NoticeTemplate noticeTemplate) {
noticeTemplateDao.save(noticeTemplate);
if (noticeTemplate.getId() == null) {
throw new NoticeTemplateMutationException(NoticeTemplateMutationException.Reason.INVALID_REQUEST);
}
NoticeTemplate persisted = noticeTemplateDao.findByIdForUpdate(noticeTemplate.getId())
.orElseThrow(() -> new NoticeTemplateMutationException(
NoticeTemplateMutationException.Reason.NOT_FOUND));
if (persisted.isPreset() || noticeTemplate.isPreset()) {
throw new NoticeTemplateMutationException(NoticeTemplateMutationException.Reason.READ_ONLY);
}
persisted.setName(noticeTemplate.getName());
persisted.setType(noticeTemplate.getType());
persisted.setContent(noticeTemplate.getContent());
noticeTemplateDao.save(persisted);
clearNoticeRulesCache();
}
@Override
public void deleteNoticeTemplate(Long templateId) {
noticeTemplateDao.deleteById(templateId);
if (templateId == null) {
throw new NoticeTemplateMutationException(NoticeTemplateMutationException.Reason.INVALID_REQUEST);
}
NoticeTemplate persisted = noticeTemplateDao.findByIdForUpdate(templateId)
.orElseThrow(() -> new NoticeTemplateMutationException(
NoticeTemplateMutationException.Reason.NOT_FOUND));
if (persisted.isPreset()) {
throw new NoticeTemplateMutationException(NoticeTemplateMutationException.Reason.READ_ONLY);
}
noticeTemplateDao.delete(persisted);
clearNoticeRulesCache();
}
@@ -19,10 +19,8 @@ package org.apache.hertzbeat.alert.service.impl;
import tools.jackson.core.type.TypeReference;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.dto.PrometheusExternAlert;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.ExternAlertService;
@@ -36,7 +34,6 @@ import org.springframework.util.StringUtils;
/**
* Prometheus external alarm service impl
*/
@Slf4j
@Service
public class PrometheusExternAlertService implements ExternAlertService {
@@ -48,48 +45,44 @@ public class PrometheusExternAlertService implements ExternAlertService {
public void addExternAlert(String content) {
TypeReference<List<PrometheusExternAlert>> typeReference = new TypeReference<>() {};
List<PrometheusExternAlert> alerts = JsonUtil.fromJsonQuietly(content, typeReference);
if (alerts == null || alerts.isEmpty()) {
log.warn("Failed to parse Prometheus external alert content");
return;
}
for (PrometheusExternAlert alert : alerts) {
Map<String, String> annotations = alert.getAnnotations();
if (annotations == null) {
annotations = new HashMap<>(8);
}
if (StringUtils.hasText(alert.getGeneratorURL())) {
annotations.put("generatorURL", alert.getGeneratorURL());
}
String description = annotations.get("description");
if (description == null) {
description = annotations.get("summary");
}
if (description == null) {
description = annotations.values().stream().findFirst().orElse("");
}
Map<String, String> labels = alert.getLabels();
if (labels == null) {
labels = new HashMap<>(8);
}
labels.put("__source__", "prometheus");
String status = CommonConstants.ALERT_STATUS_FIRING;
if (alert.getEndsAt() != null && alert.getEndsAt().isBefore(Instant.now())) {
status = CommonConstants.ALERT_STATUS_RESOLVED;
}
SingleAlert singleAlert = SingleAlert.builder()
.content(description)
.status(status)
.activeAt(CommonConstants.ALERT_STATUS_FIRING.equals(status) ? Instant.now().toEpochMilli() : null)
.startAt(alert.getStartsAt() != null ? alert.getStartsAt().toEpochMilli() : Instant.now().toEpochMilli())
.endAt(CommonConstants.ALERT_STATUS_RESOLVED.equals(status) ? alert.getEndsAt().toEpochMilli() : null)
.labels(labels)
.annotations(alert.getAnnotations())
.triggerTimes(1)
.build();
List<PrometheusExternAlert> alerts = ExternalAlertIngressValidator.requireBatch(
JsonUtil.fromJsonQuietly(content, typeReference));
List<SingleAlert> singleAlerts = alerts.stream()
.map(this::toSingleAlert)
.toList();
singleAlerts.forEach(alarmCommonReduce::reduceAndSendAlarm);
}
alarmCommonReduce.reduceAndSendAlarm(singleAlert);
private SingleAlert toSingleAlert(PrometheusExternAlert alert) {
Map<String, String> annotations =
ExternalAlertIngressValidator.normalizeAnnotations(alert.getAnnotations());
if (StringUtils.hasText(alert.getGeneratorURL())) {
annotations.put("generatorURL", alert.getGeneratorURL());
}
String description = annotations.get("description");
if (description == null) {
description = annotations.get("summary");
}
if (description == null) {
description = annotations.values().stream().findFirst().orElse("");
}
Map<String, String> labels =
ExternalAlertIngressValidator.requireBusinessLabels(alert.getLabels());
labels.put("__source__", "prometheus");
String status = CommonConstants.ALERT_STATUS_FIRING;
if (alert.getEndsAt() != null && alert.getEndsAt().isBefore(Instant.now())) {
status = CommonConstants.ALERT_STATUS_RESOLVED;
}
return ExternalAlertIngressValidator.normalize(SingleAlert.builder()
.content(description)
.status(status)
.activeAt(CommonConstants.ALERT_STATUS_FIRING.equals(status) ? Instant.now().toEpochMilli() : null)
.startAt(alert.getStartsAt() != null ? alert.getStartsAt().toEpochMilli() : Instant.now().toEpochMilli())
.endAt(CommonConstants.ALERT_STATUS_RESOLVED.equals(status) ? alert.getEndsAt().toEpochMilli() : null)
.labels(labels)
.annotations(annotations)
.triggerTimes(1)
.build());
}
@Override
@@ -17,7 +17,6 @@
package org.apache.hertzbeat.alert.service.impl;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.ExternAlertService;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
@@ -28,7 +27,6 @@ import org.springframework.stereotype.Service;
/**
* zabbix external alarm service impl
*/
@Slf4j
@Service
public class ZabbixExternAlertServiceImpl implements ExternAlertService {
@@ -37,11 +35,8 @@ public class ZabbixExternAlertServiceImpl implements ExternAlertService {
@Override
public void addExternAlert(String content) {
SingleAlert alert = JsonUtil.fromJsonQuietly(content, SingleAlert.class);
if (alert == null) {
log.warn("Failed to parse Zabbix external alert content");
return;
}
SingleAlert alert = ExternalAlertIngressValidator.normalize(
JsonUtil.fromJsonQuietly(content, SingleAlert.class));
alarmCommonReduce.reduceAndSendAlarm(alert);
}
@@ -33,6 +33,7 @@ import org.junit.jupiter.api.function.Executable;
class AlertIntegrationRouteAuthorizationConfigTest {
private static final String SOURCE_POST_RULE = " - /api/alerts/**===post===[admin,user]";
private static final String ALERT_READ_RULE = " - /api/alerts/**===get===[admin,user,guest]";
private static final String PROMETHEUS_POST_RULE = " - /api/v2/alerts===post===[admin,user]";
private static final List<String> SURENESS_CONFIGS = List.of(
"hertzbeat-startup/src/main/resources/sureness.yml",
@@ -56,6 +57,7 @@ class AlertIntegrationRouteAuthorizationConfigTest {
private static void assertRules(String config) throws IOException {
List<String> lines = Files.readAllLines(repoRoot().resolve(config));
assertTrue(lines.contains(ALERT_READ_RULE), () -> config + " must protect alert reads");
assertTrue(lines.contains(SOURCE_POST_RULE), () -> config + " must protect source ingestion");
assertTrue(lines.contains(PROMETHEUS_POST_RULE), () -> config + " must protect Prometheus ingestion");
assertFalse(lines.stream().anyMatch(line -> line.contains("/api/alerts/report") && line.endsWith("===*")),
@@ -0,0 +1,87 @@
/*
* 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.config;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
class AlertNoiseRouteAuthorizationConfigTest {
private static final String GET_RULE = " - /api/alert/**===get===[admin,user,guest]";
private static final String ALERT_PREVIEW_GET_RULE =
" - /api/alert/define/preview/**===get===[admin,user]";
private static final String POST_RULE = " - /api/alert/**===post===[admin,user]";
private static final String PUT_RULE = " - /api/alert/**===put===[admin,user]";
private static final String DELETE_RULE = " - /api/alert/**===delete===[admin]";
private static final List<String> SURENESS_CONFIGS = List.of(
"hertzbeat-startup/src/main/resources/sureness.yml",
"hertzbeat-manager/src/test/resources/sureness.yml",
"hertzbeat-e2e/hertzbeat-observability-e2e/src/test/resources/sureness.yml",
"script/sureness.yml",
"script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml",
"script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml",
"script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml",
"script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml",
"script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml");
@Test
void everyNoiseManagementRouteUsesTheExpectedRoleMatrix() {
List<Executable> checks = new ArrayList<>();
for (String config : SURENESS_CONFIGS) {
checks.add(() -> assertRules(config));
}
assertAll(checks);
}
private static void assertRules(String config) throws IOException {
List<String> lines = Files.readAllLines(repoRoot().resolve(config));
assertTrue(lines.contains(ALERT_PREVIEW_GET_RULE),
() -> config + " must restrict alert preview telemetry to authors");
assertTrue(lines.contains(GET_RULE), () -> config + " must allow authenticated alert reads");
assertTrue(lines.indexOf(ALERT_PREVIEW_GET_RULE) < lines.indexOf(GET_RULE),
() -> config + " must match the alert preview rule before the wildcard read");
assertTrue(lines.contains(POST_RULE), () -> config + " must protect alert creates");
assertTrue(lines.contains(PUT_RULE), () -> config + " must protect alert updates");
assertTrue(lines.contains(DELETE_RULE), () -> config + " must restrict alert deletes");
assertFalse(lines.stream().anyMatch(line -> line.contains("/api/alert/inhibit") && line.endsWith("===*")),
() -> config + " must not bypass inhibit authentication");
assertFalse(lines.stream().anyMatch(line -> line.contains("/api/alert/silence") && line.endsWith("===*")),
() -> config + " must not bypass silence authentication");
}
private static Path repoRoot() {
Path current = Paths.get("").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("hertzbeat-alerter/pom.xml"))) {
current = current.getParent();
}
if (current == null) {
throw new IllegalStateException("Cannot locate HertzBeat repository root");
}
return current;
}
}
@@ -17,77 +17,217 @@
package org.apache.hertzbeat.alert.config;
import org.junit.jupiter.api.BeforeEach;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* alert sse manager test
* Alert SSE delivery and reconnection contract tests.
*/
public class AlertSseManagerTest {
class AlertSseManagerTest {
private AlertSseManager alertSseManager;
@Test
void newSubscriberReceivesImmediateReconnectContract() {
RecordingSseEmitter emitter = new RecordingSseEmitter();
AlertSseManager manager = new AlertSseManager(() -> emitter);
@BeforeEach
void setUp() {
alertSseManager = new AlertSseManager();
assertEquals(emitter, manager.createEmitter(1L));
assertEquals(1, emitter.events.size());
String event = eventText(emitter.events.get(0));
assertTrue(event.contains("event:ALERT_STREAM_READY"));
assertTrue(event.contains("retry:3000"));
assertTrue(event.contains("data:{}"));
}
@Test
void testCompleteThrowsException() throws Exception {
SseEmitter emitter = alertSseManager.createEmitter(1L);
assertNotNull(emitter);
void broadcastDeliversNamedEventsWithDistinctIds() {
RecordingSseEmitter emitter = new RecordingSseEmitter();
AlertSseManager manager = new AlertSseManager(() -> emitter);
manager.createEmitter(1L);
Map<Long, SseEmitter> emitters = new HashMap<>();
SseEmitter spyEmitter = mock(SseEmitter.class);
doThrow(new IllegalStateException("Simulated output stream error")).when(spyEmitter).send(any(SseEmitter.SseEventBuilder.class));
doThrow(new RuntimeException("Complete failed")).when(spyEmitter).complete();
emitters.put(1L, spyEmitter);
manager.broadcast("{\"id\":7,\"status\":\"firing\"}");
manager.broadcast("{\"id\":7,\"status\":\"acknowledged\"}");
Field emittersField = AlertSseManager.class.getDeclaredField("emitters");
emittersField.setAccessible(true);
emittersField.set(alertSseManager, emitters);
assertThrows(RuntimeException.class, () -> alertSseManager.broadcast("{\"id\":1,\"content\":\"Test alert\"}"));
Map<Long, SseEmitter> currentEmitters = (Map<Long, SseEmitter>) emittersField.get(alertSseManager);
assertFalse(currentEmitters.containsKey(1L), "Emitter must be removed even when complete() throws");
assertEquals(3, emitter.events.size());
String first = eventText(emitter.events.get(1));
String second = eventText(emitter.events.get(2));
assertTrue(first.contains("event:ALERT_EVENT"));
assertTrue(first.contains("{\"id\":7,\"status\":\"firing\"}"));
assertTrue(second.contains("event:ALERT_EVENT"));
assertTrue(second.contains("{\"id\":7,\"status\":\"acknowledged\"}"));
assertNotEquals(eventId(first), eventId(second));
}
@Test
void closesActiveEmittersBeforeApplicationShutdown() throws Exception {
SseEmitter emitter = mock(SseEmitter.class);
Map<Long, SseEmitter> emitters = emitters(alertSseManager);
emitters.put(1L, emitter);
void groupMutationUsesExplicitEventNameAndSharedLogicalEventId() {
RecordingSseEmitter firstEmitter = new RecordingSseEmitter();
RecordingSseEmitter secondEmitter = new RecordingSseEmitter();
Queue<RecordingSseEmitter> emitters = new ArrayDeque<>(List.of(firstEmitter, secondEmitter));
AlertSseManager manager = new AlertSseManager(emitters::remove);
manager.createEmitter(1L);
manager.createEmitter(2L);
alertSseManager.onApplicationEvent(new ContextClosedEvent(mock(ConfigurableApplicationContext.class)));
alertSseManager.createEmitter(2L);
manager.broadcastGroupMutation("{\"id\":7,\"mutation\":\"GROUP_DELETED\"}");
verify(emitter).complete();
assertFalse(emitters.containsKey(1L));
assertFalse(emitters.containsKey(2L), "Shutdown must not retain a concurrent late subscriber");
String first = eventText(firstEmitter.events.get(1));
String second = eventText(secondEmitter.events.get(1));
assertTrue(first.contains("event:ALERT_GROUP_MUTATION"));
assertTrue(second.contains("event:ALERT_GROUP_MUTATION"));
assertEquals(eventId(first), eventId(second));
}
@SuppressWarnings("unchecked")
private Map<Long, SseEmitter> emitters(AlertSseManager manager) throws Exception {
Field emittersField = AlertSseManager.class.getDeclaredField("emitters");
emittersField.setAccessible(true);
return (Map<Long, SseEmitter>) emittersField.get(manager);
@Test
void failedConnectionCanReconnectAndReceiveLaterAlerts() {
RecordingSseEmitter failedEmitter = new RecordingSseEmitter();
RecordingSseEmitter reconnectedEmitter = new RecordingSseEmitter();
AtomicReference<RecordingSseEmitter> current = new AtomicReference<>(failedEmitter);
AlertSseManager manager = new AlertSseManager(current::get);
manager.createEmitter(1L);
failedEmitter.failSends = true;
manager.broadcast("{\"id\":7,\"status\":\"firing\"}");
assertTrue(failedEmitter.completed);
current.set(reconnectedEmitter);
manager.createEmitter(1L);
manager.broadcast("{\"id\":7,\"status\":\"resolved\"}");
assertEquals(2, reconnectedEmitter.events.size());
assertTrue(eventText(reconnectedEmitter.events.get(1)).contains("\"status\":\"resolved\""));
}
@Test
void replacedConnectionCannotRemoveNewSameClientOwner() {
RecordingSseEmitter oldEmitter = new RecordingSseEmitter();
RecordingSseEmitter newEmitter = new RecordingSseEmitter();
Queue<RecordingSseEmitter> emitters = new ArrayDeque<>(List.of(oldEmitter, newEmitter));
AlertSseManager manager = new AlertSseManager(emitters::remove);
manager.createEmitter(1L);
manager.createEmitter(1L);
oldEmitter.signalCompletion();
manager.broadcast("{\"id\":7,\"status\":\"resolved\"}");
assertTrue(oldEmitter.completed);
assertEquals(2, newEmitter.events.size());
assertTrue(eventText(newEmitter.events.get(1)).contains("\"status\":\"resolved\""));
}
@Test
void unexpectedSendFailureDoesNotLogExceptionOrAlertBody() {
String privateDetail = "private-exception-and-alert-body";
RecordingSseEmitter emitter = new RecordingSseEmitter();
AlertSseManager manager = new AlertSseManager(() -> emitter);
Logger logger = (Logger) LoggerFactory.getLogger(AlertSseManager.class);
Level originalLevel = logger.getLevel();
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
logger.setLevel(Level.DEBUG);
try {
manager.createEmitter(1L);
emitter.runtimeFailure = new UnsupportedOperationException(privateDetail);
emitter.completeFailure = new IllegalArgumentException(privateDetail);
manager.broadcast("{\"content\":\"" + privateDetail + "\"}");
String logs = appender.list.stream()
.map(ILoggingEvent::getFormattedMessage)
.reduce("", String::concat);
assertFalse(logs.contains(privateDetail));
assertTrue(logs.contains(UnsupportedOperationException.class.getSimpleName()));
assertTrue(logs.contains(IllegalArgumentException.class.getSimpleName()));
} finally {
logger.setLevel(originalLevel);
logger.detachAppender(appender);
appender.stop();
}
}
@Test
void closesActiveEmittersAndRejectsLateSubscribersDuringShutdown() {
RecordingSseEmitter activeEmitter = new RecordingSseEmitter();
RecordingSseEmitter lateEmitter = new RecordingSseEmitter();
Queue<RecordingSseEmitter> emitters = new ArrayDeque<>(List.of(activeEmitter, lateEmitter));
AlertSseManager manager = new AlertSseManager(emitters::remove);
manager.createEmitter(1L);
manager.onApplicationEvent(new ContextClosedEvent(mock(ConfigurableApplicationContext.class)));
manager.createEmitter(2L);
manager.broadcast("{\"id\":7,\"status\":\"resolved\"}");
assertTrue(activeEmitter.completed);
assertTrue(lateEmitter.completed);
assertEquals(1, activeEmitter.events.size());
assertTrue(lateEmitter.events.isEmpty());
}
private static String eventText(SseEmitter.SseEventBuilder event) {
StringBuilder text = new StringBuilder();
event.build().forEach(part -> text.append(part.getData()));
return text.toString();
}
private static String eventId(String event) {
return event.lines()
.filter(line -> line.startsWith("id:"))
.findFirst()
.orElseThrow();
}
private static final class RecordingSseEmitter extends SseEmitter {
private final List<SseEventBuilder> events = new ArrayList<>();
private boolean failSends;
private boolean completed;
private RuntimeException runtimeFailure;
private RuntimeException completeFailure;
private Runnable completionCallback;
@Override
public void send(SseEventBuilder builder) throws IOException {
if (runtimeFailure != null) {
throw runtimeFailure;
}
if (failSends) {
throw new IOException("private alert payload");
}
events.add(builder);
}
@Override
public void complete() {
if (completeFailure != null) {
throw completeFailure;
}
completed = true;
}
@Override
public void onCompletion(Runnable callback) {
completionCallback = callback;
}
private void signalCompletion() {
completionCallback.run();
}
}
}
@@ -1,10 +1,10 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* 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
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
@@ -18,116 +18,118 @@
package org.apache.hertzbeat.alert.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
import java.util.List;
import java.util.Map;
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
import org.apache.hertzbeat.alert.dto.AlertInhibitResponse;
import org.apache.hertzbeat.alert.service.AlertInhibitNotFoundException;
import org.apache.hertzbeat.alert.service.AlertInhibitOperationException;
import org.apache.hertzbeat.alert.service.AlertInhibitService;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
import org.apache.hertzbeat.common.util.JsonUtil;
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 org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.HashMap;
/**
* test case for {@link AlertInhibitControllerTest}
*/
@ExtendWith(MockitoExtension.class)
public class AlertInhibitControllerTest {
class AlertInhibitControllerTest {
private MockMvc mockMvc;
@Mock
private AlertInhibitService alertInhibitService;
private AlertInhibitService service;
@InjectMocks
private AlertInhibitController alertInhibitController;
private AlertInhibit alertInhibit;
private AlertInhibitController controller;
@BeforeEach
void setUp() {
this.mockMvc = standaloneSetup(alertInhibitController).build();
HashMap<String, String> sourceLabels = new HashMap<>();
HashMap<String, String> targetLabels = new HashMap<>();
alertInhibit = AlertInhibit.builder()
.id(1L)
.name("test")
.sourceLabels(sourceLabels)
.targetLabels(targetLabels)
.creator("test")
.build();
mockMvc = standaloneSetup(controller).setControllerAdvice(new AlertInhibitControllerAdvice()).build();
}
@Test
void testAddNewAlertInhibit() throws Exception {
void createAndUpdateReturnAuthoritativeExplicitRecords() throws Exception {
AlertInhibitResponse response = response();
when(service.create(any(AlertInhibitRequest.class))).thenReturn(response);
when(service.update(any(AlertInhibitRequest.class))).thenReturn(response);
doNothing().when(alertInhibitService).validate(any(AlertInhibit.class), eq(false));
doNothing().when(alertInhibitService).addAlertInhibit(any(AlertInhibit.class));
mockMvc.perform(post("/api/alert/inhibit").contentType(MediaType.APPLICATION_JSON).content(createBody()))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.id").value(7));
mockMvc.perform(put("/api/alert/inhibit").contentType(MediaType.APPLICATION_JSON)
.content(createBody().replaceFirst("\\{", "{\"id\":7,")))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.name").value("Host suppression"));
}
mockMvc.perform(post("/api/alert/inhibit")
.contentType(MediaType.APPLICATION_JSON)
.content(JsonUtil.toJson(alertInhibit)))
.andExpect(status().isOk())
@Test
void createRejectsAuditFieldsWithoutReflectingValues() throws Exception {
String body = """
{"name":"Host suppression","enable":true,
"sourceLabels":{"severity":"critical"},"targetLabels":{"severity":"warning"},
"equalLabels":["instance"],"creator":"attacker"}
""";
mockMvc.perform(post("/api/alert/inhibit").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$").value(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("attacker"))));
}
@Test
void detailDistinguishesMissingUnavailableAndErrorWithoutReflectingMessages() throws Exception {
when(service.get(1L)).thenThrow(new AlertInhibitNotFoundException());
when(service.get(2L)).thenThrow(new DataAccessResourceFailureException("matcher-sentinel"));
when(service.get(3L)).thenThrow(new IllegalStateException("body-sentinel"));
when(service.get(7L)).thenReturn(response());
mockMvc.perform(get("/api/alert/inhibit/7")).andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.msg").value("Add success"));
}
@Test
void testModifyAlertInhibit() throws Exception {
doNothing().when(alertInhibitService).validate(any(AlertInhibit.class), eq(true));
doNothing().when(alertInhibitService).modifyAlertInhibit(any(AlertInhibit.class));
mockMvc.perform(put("/api/alert/inhibit")
.contentType(MediaType.APPLICATION_JSON)
.content(JsonUtil.toJson(alertInhibit)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.msg").value("Modify success"));
}
@Test
void testGetAlertInhibitExists() throws Exception {
when(alertInhibitService.getAlertInhibit(1L)).thenReturn(alertInhibit);
mockMvc.perform(get("/api/alert/inhibit/{id}", 1L)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(alertInhibit.getId()));
}
@Test
void testGetAlertInhibitNotExists() throws Exception {
when(alertInhibitService.getAlertInhibit(1L)).thenReturn(null);
mockMvc.perform(get("/api/alert/inhibit/{id}", 1L)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(7));
mockMvc.perform(get("/api/alert/inhibit/1")).andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.MONITOR_NOT_EXIST_CODE))
.andExpect(jsonPath("$.msg").value("AlertInhibit not exist."));
mockMvc.perform(get("/api/alert/inhibit/2")).andExpect(status().isOk())
.andExpect(jsonPath("$.msg").value("Alert inhibit storage unavailable"));
mockMvc.perform(get("/api/alert/inhibit/3")).andExpect(status().isOk())
.andExpect(jsonPath("$.msg").value("Alert inhibit operation error"))
.andExpect(jsonPath("$").value(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("body-sentinel"))));
}
@Test
void uncertainCreateReturnsSafeNonValidationOutcome() throws Exception {
when(service.create(any(AlertInhibitRequest.class)))
.thenThrow(new AlertInhibitOperationException("write-sentinel"));
mockMvc.perform(post("/api/alert/inhibit").contentType(MediaType.APPLICATION_JSON).content(createBody()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Alert inhibit operation error"))
.andExpect(jsonPath("$").value(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("write-sentinel"))));
}
private String createBody() {
return """
{"name":"Host suppression","enable":true,
"sourceLabels":{"severity":"critical"},"targetLabels":{"severity":"warning"},
"equalLabels":["instance"]}
""";
}
private AlertInhibitResponse response() {
return new AlertInhibitResponse(7L, "Host suppression", Map.of("severity", "critical"),
Map.of("severity", "warning"), List.of("instance"), true, null, null, null, null);
}
}
@@ -0,0 +1,78 @@
/*
* 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.controller;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
import java.util.List;
import java.util.Set;
import org.apache.hertzbeat.alert.dto.AlertInhibitDeleteResponse;
import org.apache.hertzbeat.alert.dto.AlertInhibitPageResponse;
import org.apache.hertzbeat.alert.service.AlertInhibitService;
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 org.springframework.test.web.servlet.MockMvc;
@ExtendWith(MockitoExtension.class)
class AlertInhibitsControllerTest {
private MockMvc mockMvc;
@Mock
private AlertInhibitService service;
@InjectMocks
private AlertInhibitsController controller;
@BeforeEach
void setUp() {
mockMvc = standaloneSetup(controller).setControllerAdvice(new AlertInhibitControllerAdvice()).build();
}
@Test
void listAndDeleteReturnExplicitContracts() throws Exception {
when(service.list(null, null, "id", "desc", 0, 8))
.thenReturn(new AlertInhibitPageResponse(List.of(), 0, 0, 0, 8));
when(service.delete(Set.of(7L, 8L)))
.thenReturn(new AlertInhibitDeleteResponse("partial", Set.of(7L), Set.of(8L)));
mockMvc.perform(get("/api/alert/inhibits"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.totalElements").value(0));
mockMvc.perform(delete("/api/alert/inhibits?ids=7&ids=8"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("partial"))
.andExpect(jsonPath("$.data.deletedIds[0]").value(7))
.andExpect(jsonPath("$.data.missingIds[0]").value(8));
}
@Test
void emptyDeleteReturnsStableValidationFailure() throws Exception {
when(service.delete(null)).thenThrow(new IllegalArgumentException("ids-sentinel"));
mockMvc.perform(delete("/api/alert/inhibits")).andExpect(status().isBadRequest())
.andExpect(jsonPath("$.msg").value("Invalid alert inhibit request"))
.andExpect(jsonPath("$").value(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("ids-sentinel"))));
}
}
@@ -0,0 +1,113 @@
/*
* 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.controller;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.List;
import java.util.Map;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.CatalogItem;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.CatalogResponse;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.IntegrationGuide;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationRequestException;
import org.apache.hertzbeat.alert.integration.service.AlertIntegrationCatalogService;
import org.apache.hertzbeat.common.constants.CommonConstants;
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 org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
@ExtendWith(MockitoExtension.class)
class AlertIntegrationCatalogControllerTest {
private MockMvc mockMvc;
@Mock
private AlertIntegrationCatalogService service;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders
.standaloneSetup(new AlertIntegrationCatalogController(service))
.build();
}
@Test
void exposesTheUnversionedCatalogEndpoint() throws Exception {
when(service.catalog()).thenReturn(new CatalogResponse(List.of(
new CatalogItem(
"webhook",
"alert.integration.source.webhook",
"hertzbeat",
org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.READY,
List.of()))));
mockMvc.perform(MockMvcRequestBuilders.get("/api/alerts/integrations"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.items[0].source").value("webhook"))
.andExpect(jsonPath("$.data.items[0].readiness").value("ready"));
}
@Test
void exposesTheUnversionedRenderEndpoint() throws Exception {
when(service.render("webhook")).thenReturn(new IntegrationGuide(
"webhook",
"alert.integration.source.webhook",
"hertzbeat",
"POST",
"/api/alerts/report",
"single_alert",
Map.of("Authorization", "Bearer {token}"),
List.of("labels"),
List.of("alert.integration.webhook.step.configure_request"),
List.of("{\"labels\":{\"alertname\":\"HighCPUUsage\"}}"),
"alert.integration.ack.accepted_for_processing",
Readiness.READY,
List.of()));
mockMvc.perform(MockMvcRequestBuilders.get("/api/alerts/integrations/webhook"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.source").value("webhook"))
.andExpect(jsonPath("$.data.requiredHeaders.Authorization").value("Bearer {token}"))
.andExpect(jsonPath("$.data.readiness").value("ready"));
}
@Test
void unknownSourcesReturnSafeStableErrors() throws Exception {
String privateSource = "Bearer-private-source";
when(service.render(privateSource)).thenThrow(
AlertIntegrationRequestException.sourceUnsupported());
mockMvc.perform(MockMvcRequestBuilders.get("/api/alerts/integrations/{source}", privateSource))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("external_alert_source_unsupported"))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString(privateSource))));
}
}
@@ -19,13 +19,19 @@ package org.apache.hertzbeat.alert.controller;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.List;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.ExternAlertService;
import org.apache.hertzbeat.alert.service.impl.AlertManagerExternAlertService;
import org.apache.hertzbeat.alert.service.impl.DefaultExternAlertService;
import org.apache.hertzbeat.alert.service.impl.PrometheusExternAlertService;
import org.apache.hertzbeat.alert.service.impl.ZabbixExternAlertServiceImpl;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -34,8 +40,10 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.test.util.ReflectionTestUtils;
/**
* Unit contract for {@link AlertReportController}.
@@ -50,6 +58,9 @@ class AlertReportControllerTest {
@Mock
private ExternAlertService externAlertService;
@Mock
private AlarmCommonReduce alarmCommonReduce;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders
@@ -135,6 +146,37 @@ class AlertReportControllerTest {
.andExpect(jsonPath("$.msg").value("external_alert_rejected"));
}
@Test
void realIngressRejectsMalformedEmptyAndUnprocessablePayloadsSafely() throws Exception {
MockMvc realIngress = MockMvcBuilders
.standaloneSetup(new AlertReportController(realIngressServices()))
.build();
String privateBody = "Bearer-private-token private-test-source /secret/private-body";
List<RequestBuilder> rejectedRequests = List.of(
post("/api/alerts/report", privateBody),
post("/api/alerts/report", "{}"),
post("/api/v2/alerts", privateBody),
post("/api/v2/alerts", "[]"),
post("/api/alerts/report/alertmanager", privateBody),
post("/api/alerts/report/alertmanager", "{\"alerts\":[]}"),
post("/api/alerts/report/zabbix", privateBody),
post("/api/alerts/report/zabbix", "{}"));
for (RequestBuilder request : rejectedRequests) {
realIngress.perform(request)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("external_alert_rejected"))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("Bearer-private-token"))))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("private-test-source"))))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("/secret/private-body"))));
}
verifyNoInteractions(alarmCommonReduce);
}
@Test
void unsupportedOrUnavailableSourceReturnsStableFailureEnvelope() throws Exception {
String privateSource = "Bearer-private-source";
@@ -170,4 +212,23 @@ class AlertReportControllerTest {
mockMvc.perform(MockMvcRequestBuilders.delete("/api/alerts/report"))
.andExpect(status().isMethodNotAllowed());
}
private List<ExternAlertService> realIngressServices() {
return List.of(
withReducer(new DefaultExternAlertService()),
withReducer(new PrometheusExternAlertService()),
withReducer(new AlertManagerExternAlertService()),
withReducer(new ZabbixExternAlertServiceImpl()));
}
private <T extends ExternAlertService> T withReducer(T service) {
ReflectionTestUtils.setField(service, "alarmCommonReduce", alarmCommonReduce);
return service;
}
private static RequestBuilder post(String path, String body) {
return MockMvcRequestBuilders.post(path)
.contentType(MediaType.APPLICATION_JSON)
.content(body);
}
}
@@ -63,4 +63,14 @@ class AlertSilencesControllerTest {
mockMvc.perform(delete("/api/alert/silences?ids=7&ids=8"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("partial"));
}
@Test
void invalidDeleteReturnsExplicitClientRejection() throws Exception {
when(service.delete(null)).thenThrow(new IllegalArgumentException("ids-sentinel"));
mockMvc.perform(delete("/api/alert/silences")).andExpect(status().isBadRequest())
.andExpect(jsonPath("$.msg").value("Invalid alert silence request"))
.andExpect(jsonPath("$").value(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("ids-sentinel"))));
}
}
@@ -17,14 +17,23 @@
package org.apache.hertzbeat.alert.controller;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.LongStream;
import org.apache.hertzbeat.alert.dto.AlertGroupEvidence;
import org.apache.hertzbeat.alert.dto.AlertGroupStatusEvidence;
import org.apache.hertzbeat.alert.dto.AlertSummary;
import org.apache.hertzbeat.alert.service.AlertGroupEvidenceRequestException;
import org.apache.hertzbeat.alert.service.AlertGroupEvidenceService;
import org.apache.hertzbeat.alert.service.AlertGroupNotFoundException;
import org.apache.hertzbeat.alert.service.AlertGroupStatusNotSupportedException;
import org.apache.hertzbeat.alert.service.AlertService;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
@@ -55,15 +64,21 @@ class AlertsControllerTest {
@InjectMocks
private AlertsController alertsController;
@InjectMocks
private AlertSummaryController alertSummaryController;
@Mock
private AlertService alertService;
@Mock
private AlertGroupEvidenceService alertGroupEvidenceService;
private List<Long> ids;
@BeforeEach
void setUp() {
this.mockMvc = MockMvcBuilders.standaloneSetup(alertsController).build();
this.mockMvc = MockMvcBuilders.standaloneSetup(alertsController, alertSummaryController).build();
ids = LongStream.rangeClosed(1, 10).boxed().collect(Collectors.toList());
}
@@ -108,6 +123,60 @@ class AlertsControllerTest {
.andReturn();
}
@Test
void getGroupAlertEvidenceReturnsFrozenSchema() throws Exception {
List<String> requestedIds = List.of("2", "1", "3", "1");
AlertGroupEvidence evidence = new AlertGroupEvidence(
List.of(
new AlertGroupStatusEvidence(1L, CommonConstants.ALERT_STATUS_FIRING),
new AlertGroupStatusEvidence(2L, CommonConstants.ALERT_STATUS_PENDING)),
List.of(3L),
123456789L);
Mockito.when(alertGroupEvidenceService.getEvidence(requestedIds)).thenReturn(evidence);
mockMvc.perform(MockMvcRequestBuilders.get("/api/alerts/group/evidence")
.param("ids", "2", "1", "3", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.groups.length()").value(2))
.andExpect(jsonPath("$.data.groups[0].id").value(1))
.andExpect(jsonPath("$.data.groups[0].status").value("firing"))
.andExpect(jsonPath("$.data.groups[1].id").value(2))
.andExpect(jsonPath("$.data.groups[1].status").value("pending"))
.andExpect(jsonPath("$.data.missingIds[0]").value(3))
.andExpect(jsonPath("$.data.observedAt").value(123456789L));
Mockito.verify(alertGroupEvidenceService).getEvidence(requestedIds);
}
@Test
void getGroupAlertEvidenceInvalidRequestReturnsStableSafeFailure() throws Exception {
Mockito.when(alertGroupEvidenceService.getEvidence(List.of("-6565463543")))
.thenThrow(new AlertGroupEvidenceRequestException());
mockMvc.perform(MockMvcRequestBuilders.get("/api/alerts/group/evidence")
.param("ids", "-6565463543"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Invalid alert group evidence request."))
.andExpect(content().string(not(containsString("6565463543"))));
}
@Test
void getGroupAlertEvidenceFailureDoesNotExposeExceptionDetails() throws Exception {
Mockito.when(alertGroupEvidenceService.getEvidence(List.of("7")))
.thenThrow(new IllegalStateException(
"token=private-evidence-token payload=private-alert-payload"));
mockMvc.perform(MockMvcRequestBuilders.get("/api/alerts/group/evidence")
.param("ids", "7"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Alert group evidence query failed."))
.andExpect(content().string(not(containsString("private-evidence-token"))))
.andExpect(content().string(not(containsString("private-alert-payload"))));
}
@Test
void deleteGroupAlerts() throws Exception {
mockMvc.perform(
@@ -121,6 +190,38 @@ class AlertsControllerTest {
.andReturn();
}
@Test
void deleteGroupAlertsMissingTargetReturnsStableSafeFailure() throws Exception {
HashSet<Long> missingIds = new HashSet<>(List.of(6565463543L));
Mockito.doThrow(new AlertGroupNotFoundException())
.when(alertService).deleteGroupAlerts(missingIds);
mockMvc.perform(MockMvcRequestBuilders
.delete("/api/alerts/group")
.param("ids", "6565463543"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Alert group was not found."))
.andExpect(content().string(not(containsString("6565463543"))));
}
@Test
void deleteGroupAlertsGenericFailureDoesNotExposeExceptionDetails() throws Exception {
HashSet<Long> ids = new HashSet<>(List.of(7L));
Mockito.doThrow(new IllegalStateException(
"token=private-delete-token payload=private-alert-payload"))
.when(alertService).deleteGroupAlerts(ids);
mockMvc.perform(MockMvcRequestBuilders
.delete("/api/alerts/group")
.param("ids", "7"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Alert group delete failed."))
.andExpect(content().string(not(containsString("private-delete-token"))))
.andExpect(content().string(not(containsString("private-alert-payload"))));
}
@Test
void applyGroupAlertStatus() throws Exception {
mockMvc.perform(
@@ -147,6 +248,62 @@ class AlertsControllerTest {
.andReturn();
}
@Test
void applyGroupAlertStatusMissingTargetReturnsStableSafeFailure() throws Exception {
Mockito.doThrow(new AlertGroupNotFoundException())
.when(alertService).editGroupAlertStatus("acknowledged", List.of(6565463543L));
mockMvc.perform(MockMvcRequestBuilders
.put("/api/alerts/group/status/acknowledged")
.param("ids", "6565463543"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Alert group was not found."))
.andExpect(content().string(not(containsString("6565463543"))));
}
@Test
void applyGroupAlertStatusGenericFailureDoesNotExposeExceptionDetails() throws Exception {
Mockito.doThrow(new IllegalStateException(
"token=private-alert-token payload=private-alert-payload"))
.when(alertService).editGroupAlertStatus("resolved", List.of(7L));
mockMvc.perform(MockMvcRequestBuilders
.put("/api/alerts/group/status/resolved")
.param("ids", "7"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Alert group status update failed."))
.andExpect(content().string(not(containsString("private-alert-token"))))
.andExpect(content().string(not(containsString("private-alert-payload"))));
}
@Test
void applyGroupAlertStatusRejectsUnsupportedPathValueWithStableSafeFailure() throws Exception {
Mockito.doThrow(new AlertGroupStatusNotSupportedException())
.when(alertService).editGroupAlertStatus("private-arbitrary-status", List.of(6565463543L));
mockMvc.perform(MockMvcRequestBuilders
.put("/api/alerts/group/status/private-arbitrary-status")
.param("ids", "6565463543"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Alert group status is not supported."))
.andExpect(content().string(not(containsString("private-arbitrary-status"))))
.andExpect(content().string(not(containsString("6565463543"))));
}
@Test
void applyGroupAlertStatusKeepsEmptyIdsAsLegacySuccessNoOp() throws Exception {
mockMvc.perform(MockMvcRequestBuilders
.put("/api/alerts/group/status/private-arbitrary-status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(content().json("{\"data\":null,\"msg\":null,\"code\":0}"));
Mockito.verifyNoInteractions(alertService);
}
@Test
void applySingleAlertAcknowledgedStatus() throws Exception {
mockMvc.perform(
@@ -42,6 +42,7 @@ import org.apache.hertzbeat.alert.dto.NoticeReceiverRequest;
import org.apache.hertzbeat.alert.dto.NoticeReceiverResponse;
import org.apache.hertzbeat.alert.service.NoticeReceiverContractMapper;
import org.apache.hertzbeat.alert.service.NoticeReceiverContractService;
import org.apache.hertzbeat.alert.service.NoticeTemplateMutationException;
import org.apache.hertzbeat.alert.service.impl.NoticeConfigServiceImpl;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
@@ -431,6 +432,24 @@ class NoticeConfigControllerTest {
verify(noticeConfigService).addNoticeTemplate(noticeTemplate);
}
@Test
void addNewNoticeTemplateReturnsStableSafeInvalidRequestFailure() throws Exception {
NoticeTemplate noticeTemplate = getNoticeTemplate();
noticeTemplate.setName("private-template-payload");
Mockito.doThrow(new NoticeTemplateMutationException(
NoticeTemplateMutationException.Reason.INVALID_REQUEST))
.when(noticeConfigService).addNoticeTemplate(noticeTemplate);
this.mockMvc.perform(post("/api/notice/template")
.contentType(MediaType.APPLICATION_JSON)
.content(JsonUtil.toJson(noticeTemplate)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Notice template request is invalid."))
.andExpect(content().string(not(containsString("private-template-payload"))))
.andExpect(content().string(not(containsString("87584674384"))));
}
@Test
void editNoticeTemplate() throws Exception {
NoticeTemplate noticeTemplate = getNoticeTemplate();
@@ -447,10 +466,26 @@ class NoticeConfigControllerTest {
verify(noticeConfigService).editNoticeTemplate(noticeTemplate);
}
@Test
void editNoticeTemplateReturnsStableSafeNotFoundFailure() throws Exception {
NoticeTemplate noticeTemplate = getNoticeTemplate();
noticeTemplate.setId(87584674384L);
Mockito.doThrow(new NoticeTemplateMutationException(
NoticeTemplateMutationException.Reason.NOT_FOUND))
.when(noticeConfigService).editNoticeTemplate(noticeTemplate);
this.mockMvc.perform(put("/api/notice/template")
.contentType(MediaType.APPLICATION_JSON)
.content(JsonUtil.toJson(noticeTemplate)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Notice template was not found."))
.andExpect(content().string(not(containsString("87584674384"))));
}
@Test
void deleteNoticeTemplate_Success() throws Exception {
Long templateId = 1L;
when(noticeConfigService.getNoticeTemplatesById(templateId)).thenReturn(Optional.of(new NoticeTemplate()));
mockMvc.perform(delete("/api/notice/template/{id}", templateId))
.andExpect(status().isOk())
@@ -463,14 +498,30 @@ class NoticeConfigControllerTest {
@Test
void deleteNoticeTemplate_NotFound() throws Exception {
Long templateId = 1L;
when(noticeConfigService.getNoticeTemplatesById(templateId)).thenReturn(Optional.empty());
Mockito.doThrow(new NoticeTemplateMutationException(
NoticeTemplateMutationException.Reason.NOT_FOUND))
.when(noticeConfigService).deleteNoticeTemplate(templateId);
mockMvc.perform(delete("/api/notice/template/{id}", templateId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.msg").value("The specified notification template could not be queried, please check whether the parameters are correct"));
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Notice template was not found."));
Mockito.verify(noticeConfigService, Mockito.never()).deleteNoticeTemplate(templateId);
Mockito.verify(noticeConfigService).deleteNoticeTemplate(templateId);
}
@Test
void deleteNoticeTemplateReturnsStableSafeReadOnlyFailure() throws Exception {
long templateId = 87584674384L;
Mockito.doThrow(new NoticeTemplateMutationException(
NoticeTemplateMutationException.Reason.READ_ONLY))
.when(noticeConfigService).deleteNoticeTemplate(templateId);
mockMvc.perform(delete("/api/notice/template/{id}", templateId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Preset notice templates are read-only."))
.andExpect(content().string(not(containsString("87584674384"))));
}
@Test
@@ -510,6 +561,18 @@ class NoticeConfigControllerTest {
.andExpect(jsonPath("$.data.number").value(0));
}
@Test
void getTemplatesReturnsStableSafeStorageFailure() throws Exception {
when(noticeConfigService.getNoticeTemplates(null, true, 0, 8))
.thenThrow(new DataAccessResourceFailureException("private-storage-token"));
this.mockMvc.perform(get("/api/notice/templates"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("Notice template storage is unavailable."))
.andExpect(content().string(not(containsString("private-storage-token"))));
}
@Test
void testGetTemplatesById() throws Exception {
// Mock the service response
@@ -525,7 +588,7 @@ class NoticeConfigControllerTest {
this.mockMvc.perform(get("/api/notice/template/{id}", 25857585858L))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").value("The specified notification template could not be queried, please check whether the parameters are correct or refresh the page"));
.andExpect(jsonPath("$.msg").value("Notice template was not found."));
}
@Test
@@ -0,0 +1,165 @@
/*
* 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.integration.service;
import static org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.CONFIGURATION_REQUIRED;
import static org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.GUIDE_BLOCKED;
import static org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.Readiness.READY;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationApiContract.IntegrationGuide;
import org.apache.hertzbeat.alert.integration.api.AlertIntegrationRequestException;
import org.apache.hertzbeat.alert.integration.guide.AlertIntegrationDescriptorRegistry;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
import org.apache.hertzbeat.alert.service.ExternAlertService;
import org.apache.hertzbeat.alert.service.impl.AlertManagerExternAlertService;
import org.apache.hertzbeat.alert.service.impl.AlibabaCloudSlsExternAlertService;
import org.apache.hertzbeat.alert.service.impl.DefaultExternAlertService;
import org.apache.hertzbeat.alert.service.impl.HuaweiCloudExternAlertService;
import org.apache.hertzbeat.alert.service.impl.PrometheusExternAlertService;
import org.apache.hertzbeat.alert.service.impl.SkyWalkingExternAlertService;
import org.apache.hertzbeat.alert.service.impl.TencentExternAlertService;
import org.apache.hertzbeat.alert.service.impl.UptimeKumaExternAlertServiceImpl;
import org.apache.hertzbeat.alert.service.impl.VolcEngineExternAlertService;
import org.apache.hertzbeat.alert.service.impl.ZabbixExternAlertServiceImpl;
import org.junit.jupiter.api.Test;
class AlertIntegrationCatalogServiceTest {
private static final List<String> PUBLIC_SOURCE_ORDER = List.of(
"webhook", "prometheus", "alertmanager", "skywalking", "uptime-kuma", "zabbix", "tencent",
"alibabacloud-sls", "huaweicloud-ces", "volcengine");
private static final String PRIVATE_SOURCE = "private-test-source";
private static final String PRIVATE_TOKEN = "private-test-token";
@Test
void derivesStablePublicCatalogFromRegisteredIngressBeans() {
AlertIntegrationCatalogService service = service(services());
assertEquals(PUBLIC_SOURCE_ORDER, service.catalog().items().stream()
.map(item -> item.source())
.toList());
assertFalse(service.catalog().items().stream().anyMatch(item -> "default".equals(item.source())));
assertFalse(service.catalog().toString().contains(PRIVATE_SOURCE));
assertFalse(service.catalog().toString().contains(PRIVATE_TOKEN));
for (String source : PUBLIC_SOURCE_ORDER) {
IntegrationGuide guide = service.render(source);
assertEquals(source, guide.source());
assertEquals(Map.of("Authorization", "Bearer {token}"), guide.requiredHeaders());
assertFalse(guide.toString().contains(PRIVATE_SOURCE));
assertFalse(guide.toString().contains(PRIVATE_TOKEN));
}
}
@Test
void rendersHonestReadyAndBlockedSourceContracts() {
AlertIntegrationCatalogService service = service(services());
IntegrationGuide webhook = service.render("webhook");
assertEquals(READY, webhook.readiness());
assertEquals("POST", webhook.method());
assertEquals("/api/alerts/report", webhook.ingressPath());
assertEquals("single_alert", webhook.payloadShape());
assertEquals("Bearer {token}", webhook.requiredHeaders().get("Authorization"));
assertTrue(webhook.requiredFields().contains("labels"));
assertTrue(webhook.snippets().stream().anyMatch(snippet -> snippet.contains("\"labels\"")));
IntegrationGuide prometheus = service.render("prometheus");
assertEquals(READY, prometheus.readiness());
assertEquals("/api/v2/alerts", prometheus.ingressPath());
assertEquals("Bearer {token}", prometheus.requiredHeaders().get("Authorization"));
assertTrue(prometheus.snippets().stream().anyMatch(snippet -> snippet.trim().startsWith("[")));
IntegrationGuide alertmanager = service.render("alertmanager");
assertEquals(READY, alertmanager.readiness());
assertEquals("/api/alerts/report/alertmanager", alertmanager.ingressPath());
assertEquals("Bearer {token}", alertmanager.requiredHeaders().get("Authorization"));
assertTrue(alertmanager.snippets().stream().anyMatch(snippet -> snippet.contains("\"alerts\"")));
assertEquals(GUIDE_BLOCKED, service.render("zabbix").readiness());
assertTrue(service.render("zabbix").limitations().contains(
"alert.integration.limit.zabbix.response_contract_mismatch"));
assertEquals(CONFIGURATION_REQUIRED, service.render("skywalking").readiness());
assertEquals(CONFIGURATION_REQUIRED, service.render("huaweicloud-ces").readiness());
}
@Test
void rejectsRegistryAndBeanDriftWithSafeStableErrors() {
List<ExternAlertService> missingBean = new ArrayList<>(services());
missingBean.removeLast();
AlertIntegrationRequestException missingFailure = assertThrows(
AlertIntegrationRequestException.class, () -> service(missingBean).catalog());
assertEquals("external_alert_guide_unavailable", missingFailure.getMessage());
List<ExternAlertService> extraBean = new ArrayList<>(services());
ExternAlertService unsupported = mock(ExternAlertService.class);
org.mockito.Mockito.when(unsupported.supportSource()).thenReturn("private-source-name");
extraBean.add(unsupported);
AlertIntegrationRequestException extraFailure = assertThrows(
AlertIntegrationRequestException.class, () -> service(extraBean).catalog());
assertEquals("external_alert_guide_unavailable", extraFailure.getMessage());
}
@Test
void unknownSourcesUseSafeStableErrors() {
AlertIntegrationRequestException failure = assertThrows(
AlertIntegrationRequestException.class,
() -> service(services()).render(PRIVATE_SOURCE));
assertEquals("external_alert_source_unsupported", failure.getMessage());
assertFalse(failure.getMessage().contains(PRIVATE_SOURCE));
assertFalse(failure.getMessage().contains(PRIVATE_TOKEN));
}
@Test
void blankSourcesUseSafeStableErrors() {
AlertIntegrationCatalogService service = service(services());
for (String source : List.of("", " ", "\t")) {
AlertIntegrationRequestException failure = assertThrows(
AlertIntegrationRequestException.class, () -> service.render(source));
assertEquals("external_alert_source_unsupported", failure.getMessage());
}
}
private static AlertIntegrationCatalogService service(List<ExternAlertService> services) {
return new AlertIntegrationCatalogService(services, AlertIntegrationDescriptorRegistry.official());
}
private static List<ExternAlertService> services() {
AlarmCommonReduce reducer = mock(AlarmCommonReduce.class);
return List.of(
new DefaultExternAlertService(),
new AlertManagerExternAlertService(),
new PrometheusExternAlertService(),
new SkyWalkingExternAlertService(),
new UptimeKumaExternAlertServiceImpl(),
new ZabbixExternAlertServiceImpl(),
new TencentExternAlertService(),
new AlibabaCloudSlsExternAlertService(reducer),
new HuaweiCloudExternAlertService(reducer),
new VolcEngineExternAlertService(reducer));
}
}
@@ -18,6 +18,7 @@
package org.apache.hertzbeat.alert.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
@@ -93,6 +94,7 @@ public class AlertDefineExcelImExportServiceTest {
assertEquals(Map.of("key", "value"), alertDefineDTO.getAnnotations());
assertEquals("template1", alertDefineDTO.getTemplate());
assertTrue(alertDefineDTO.getEnable());
assertNull(alertDefineDTO.getDatasource());
}
}
@@ -104,6 +106,7 @@ public class AlertDefineExcelImExportServiceTest {
AlertDefineDTO alertDefineDTO = new AlertDefineDTO();
alertDefineDTO.setName("app1");
alertDefineDTO.setType("metric1");
alertDefineDTO.setDatasource("lifecycle-promql");
alertDefineDTO.setExpr("expr1");
alertDefineDTO.setPeriod(10);
alertDefineDTO.setTimes(1);
@@ -129,6 +132,7 @@ public class AlertDefineExcelImExportServiceTest {
assertEquals("Annotations", headerRow.getCell(6).getStringCellValue());
assertEquals("Template", headerRow.getCell(7).getStringCellValue());
assertEquals("Enable", headerRow.getCell(8).getStringCellValue());
assertEquals("Datasource", headerRow.getCell(9).getStringCellValue());
Row dataRow = resultSheet.getRow(1);
assertEquals("app1", dataRow.getCell(0).getStringCellValue());
@@ -140,6 +144,11 @@ public class AlertDefineExcelImExportServiceTest {
assertEquals(JsonUtil.toJson(Map.of("key", "value")), dataRow.getCell(6).getStringCellValue());
assertEquals("template1", dataRow.getCell(7).getStringCellValue());
assertTrue(dataRow.getCell(8).getBooleanCellValue());
assertEquals("lifecycle-promql", dataRow.getCell(9).getStringCellValue());
List<ExportAlertDefineDTO> parsed = alertDefineExcelImExportService.parseImport(
new ByteArrayInputStream(outputStream.toByteArray()));
assertEquals("lifecycle-promql", parsed.getFirst().getAlertDefine().getDatasource());
}
}
}
@@ -152,4 +161,4 @@ public class AlertDefineExcelImExportServiceTest {
}
}
}
}
@@ -57,6 +57,7 @@ class AlertDefineJsonImExportServiceTest {
AlertDefineDTO alertDefine = new AlertDefineDTO();
alertDefine.setName("App1");
alertDefine.setType("realtime");
alertDefine.setDatasource("lifecycle-promql");
alertDefine.setExpr("Expr1");
alertDefine.setPeriod(3000);
alertDefine.setTimes(3);
@@ -77,6 +78,7 @@ class AlertDefineJsonImExportServiceTest {
assertEquals(1, result.size());
assertEquals("App1", result.get(0).getAlertDefine().getName());
assertEquals("realtime", result.get(0).getAlertDefine().getType());
assertNull(result.get(0).getAlertDefine().getDatasource());
}
@Test
@@ -98,6 +100,10 @@ class AlertDefineJsonImExportServiceTest {
assertNotNull(result);
assertTrue(result.contains("App1"));
assertTrue(result.contains("realtime"));
assertTrue(result.contains("\"datasource\":\"lifecycle-promql\""));
List<ExportAlertDefineDTO> parsed = service.parseImport(
new ByteArrayInputStream(outputStream.toByteArray()));
assertEquals("lifecycle-promql", parsed.getFirst().getAlertDefine().getDatasource());
}
@Test
@@ -183,7 +183,7 @@ class AlertDefineServiceTest {
put("status", "200");
}
};
when(dataSourceService.calculate(eq("promql"), eq(expr))).thenReturn(Lists.newArrayList(countValue1));
when(dataSourceService.calculatePreview(eq("promql"), eq(expr))).thenReturn(Lists.newArrayList(countValue1));
List<Map<String, Object>> result = alertDefineService.getDefinePreview("promql", METRIC_ALERT_THRESHOLD_TYPE_PERIODIC, expr);
assertNotNull(result);
assertEquals(1307, result.get(0).get("__value__"));
@@ -195,11 +195,35 @@ class AlertDefineServiceTest {
assertEquals(0, result.size());
}
@Test
void getDefinePreviewPreservesDatasourceExecutionFailure() {
String expr = "up > 0";
when(dataSourceService.calculatePreview("promql", expr))
.thenThrow(new AlertExpressionException("Preview query execution failed"));
assertThrows(AlertExpressionException.class,
() -> alertDefineService.getDefinePreview("promql", METRIC_ALERT_THRESHOLD_TYPE_PERIODIC, expr));
}
@Test
void getDefinePreviewCapsReturnedTelemetryRows() {
String expr = "up > 0";
List<Map<String, Object>> rows = java.util.stream.IntStream.range(0, 101)
.mapToObj(index -> Map.<String, Object>of("__value__", index))
.toList();
when(dataSourceService.calculatePreview("promql", expr)).thenReturn(rows);
List<Map<String, Object>> result =
alertDefineService.getDefinePreview("promql", METRIC_ALERT_THRESHOLD_TYPE_PERIODIC, expr);
assertEquals(100, result.size());
}
@Test
void getDefinePreviewSupportsPeriodicTraceSql() {
String expr = "SELECT service_name, 0.2 AS __value__ FROM hertzbeat_apm_red_1m";
Map<String, Object> row = Map.of("service_name", "checkout", "__value__", 0.2D);
when(dataSourceService.query(eq("sql"), eq(expr), eq(TRACE_ALERT_THRESHOLD_TYPE_PERIODIC)))
when(dataSourceService.queryPreview(eq("sql"), eq(expr), eq(TRACE_ALERT_THRESHOLD_TYPE_PERIODIC)))
.thenReturn(List.of(row));
List<Map<String, Object>> result =
@@ -209,6 +233,22 @@ class AlertDefineServiceTest {
assertEquals(0.2D, result.get(0).get("__value__"));
}
@Test
void getDefinePreviewPreservesPeriodicLogAndTraceExecutionFailures() {
String logExpr = "SELECT * FROM hertzbeat_logs";
String traceExpr = "SELECT __value__ FROM hertzbeat_apm_red_1m";
AlertExpressionException safeFailure = new AlertExpressionException("Preview query execution failed");
when(dataSourceService.queryPreview("sql", logExpr, LOG_ALERT_THRESHOLD_TYPE_PERIODIC))
.thenThrow(safeFailure);
when(dataSourceService.queryPreview("sql", traceExpr, TRACE_ALERT_THRESHOLD_TYPE_PERIODIC))
.thenThrow(safeFailure);
assertThrows(AlertExpressionException.class,
() -> alertDefineService.getDefinePreview("sql", LOG_ALERT_THRESHOLD_TYPE_PERIODIC, logExpr));
assertThrows(AlertExpressionException.class,
() -> alertDefineService.getDefinePreview("sql", TRACE_ALERT_THRESHOLD_TYPE_PERIODIC, traceExpr));
}
@Test
void getDefinePreviewValidatesRealtimeLogExpression() {
HistoryDataReader historyDataReader = Mockito.mock(HistoryDataReader.class);
@@ -257,7 +297,7 @@ class AlertDefineServiceTest {
@Test
void getDefinePreviewRejectsPeriodicTraceSqlWithoutValueColumn() {
String expr = "SELECT service_name FROM hertzbeat_apm_red_1m";
when(dataSourceService.query(eq("sql"), eq(expr), eq(TRACE_ALERT_THRESHOLD_TYPE_PERIODIC)))
when(dataSourceService.queryPreview(eq("sql"), eq(expr), eq(TRACE_ALERT_THRESHOLD_TYPE_PERIODIC)))
.thenReturn(List.of(Map.of("service_name", "checkout")));
AlertExpressionException exception = assertThrows(AlertExpressionException.class,
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.alert.service;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@@ -37,13 +38,11 @@ import java.util.List;
import org.apache.hertzbeat.alert.dto.AlertDefineDTO;
import org.apache.hertzbeat.alert.dto.ExportAlertDefineDTO;
import org.apache.hertzbeat.alert.service.impl.AlertDefineYamlImExportServiceImpl;
import org.apache.hertzbeat.common.util.JsonUtil;
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.junit.jupiter.MockitoExtension;
import org.yaml.snakeyaml.Yaml;
/**
* test case for {@link AlertDefineYamlImExportServiceImpl}
@@ -77,6 +76,7 @@ class AlertDefineYamlImExportServiceTest {
AlertDefineDTO alertDefine = new AlertDefineDTO();
alertDefine.setName("App1");
alertDefine.setType("realtime");
alertDefine.setDatasource("lifecycle-promql");
alertDefine.setPeriod(3000);
alertDefine.setTimes(3);
alertDefine.setExpr("Expr1");
@@ -96,12 +96,9 @@ class AlertDefineYamlImExportServiceTest {
assertNotNull(result);
assertEquals(1, result.size());
InputStream inputStream = new ByteArrayInputStream(JsonUtil.toJson(alertDefineList)
.getBytes(StandardCharsets.UTF_8));
Yaml yaml = new Yaml();
assertEquals(yaml.load(inputStream), result);
assertEquals("App1", result.getFirst().getAlertDefine().getName());
assertEquals("realtime", result.getFirst().getAlertDefine().getType());
assertNull(result.getFirst().getAlertDefine().getDatasource());
}
@Test
@@ -125,6 +122,39 @@ class AlertDefineYamlImExportServiceTest {
}
}
@Test
void testParseImportRejectsUnknownJavaGlobalTags() {
InputStream taggedInput = new ByteArrayInputStream(
"- !!java.net.URL {}\n"
.getBytes(StandardCharsets.UTF_8));
assertThrows(RuntimeException.class, () -> service.parseImport(taggedInput));
}
@Test
void testParseImportSupportsLegacyHertzBeatDtoTags() {
String legacyYaml =
"""
- !!org.apache.hertzbeat.alert.dto.ExportAlertDefineDTO
alertDefine: !!org.apache.hertzbeat.alert.dto.AlertDefineDTO
name: Legacy
type: periodic
datasource: legacy-promql
expr: up == 0
period: 60
times: 2
enable: false
template: Legacy template
""";
List<ExportAlertDefineDTO> result = service.parseImport(
new ByteArrayInputStream(legacyYaml.getBytes(StandardCharsets.UTF_8)));
assertEquals(1, result.size());
assertEquals("Legacy", result.getFirst().getAlertDefine().getName());
assertEquals("legacy-promql", result.getFirst().getAlertDefine().getDatasource());
}
@Test
void testWriteOs() {
@@ -135,6 +165,10 @@ class AlertDefineYamlImExportServiceTest {
assertTrue(yamlOutput.contains("name: App1"));
assertTrue(yamlOutput.contains("type: realtime"));
assertTrue(yamlOutput.contains("expr: Expr1"));
assertTrue(yamlOutput.contains("datasource: lifecycle-promql"));
List<ExportAlertDefineDTO> parsed = service.parseImport(
new ByteArrayInputStream(outputStream.toByteArray()));
assertEquals("lifecycle-promql", parsed.getFirst().getAlertDefine().getDatasource());
}
@Test
@@ -0,0 +1,125 @@
/*
* 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.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.apache.hertzbeat.alert.dao.GroupAlertDao;
import org.apache.hertzbeat.alert.dto.AlertGroupEvidence;
import org.apache.hertzbeat.alert.dto.AlertGroupStatusEvidence;
import org.apache.hertzbeat.common.constants.CommonConstants;
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;
/**
* Bounded canonical alert-group evidence query contracts.
*/
@ExtendWith(MockitoExtension.class)
class AlertGroupEvidenceServiceTest {
@Mock
private GroupAlertDao groupAlertDao;
@InjectMocks
private AlertGroupEvidenceService evidenceService;
@Test
void sortsGroupsAndMissingIdsWithoutChildHydration() {
List<String> rawIds = List.of("4", "1", "5", "2", "3", "1");
List<Long> normalizedIds = List.of(1L, 2L, 3L, 4L, 5L);
when(groupAlertDao.findStatusEvidenceByIdIn(normalizedIds)).thenReturn(List.of(
new AlertGroupStatusEvidence(4L, CommonConstants.ALERT_STATUS_RESOLVED),
new AlertGroupStatusEvidence(2L, CommonConstants.ALERT_STATUS_PENDING),
new AlertGroupStatusEvidence(1L, CommonConstants.ALERT_STATUS_FIRING),
new AlertGroupStatusEvidence(3L, CommonConstants.ALERT_STATUS_ACKNOWLEDGED)));
long before = System.currentTimeMillis();
AlertGroupEvidence result = evidenceService.getEvidence(rawIds);
long after = System.currentTimeMillis();
assertEquals(List.of(
new AlertGroupStatusEvidence(1L, CommonConstants.ALERT_STATUS_FIRING),
new AlertGroupStatusEvidence(2L, CommonConstants.ALERT_STATUS_PENDING),
new AlertGroupStatusEvidence(3L, CommonConstants.ALERT_STATUS_ACKNOWLEDGED),
new AlertGroupStatusEvidence(4L, CommonConstants.ALERT_STATUS_RESOLVED)), result.groups());
assertEquals(List.of(5L), result.missingIds());
assertTrue(result.observedAt() >= before);
assertTrue(result.observedAt() <= after);
verify(groupAlertDao).findStatusEvidenceByIdIn(normalizedIds);
verify(groupAlertDao, never()).findAllById(any());
}
@Test
void rejectsInvalidIdsBeforeQuery() {
List<List<String>> invalidRequests = new ArrayList<>();
invalidRequests.add(null);
invalidRequests.add(List.of());
invalidRequests.add(List.of(""));
invalidRequests.add(List.of("0"));
invalidRequests.add(List.of("-1"));
invalidRequests.add(List.of("not-a-number"));
for (List<String> invalidRequest : invalidRequests) {
assertThrows(AlertGroupEvidenceRequestException.class,
() -> evidenceService.getEvidence(invalidRequest));
}
verifyNoInteractions(groupAlertDao);
}
@Test
void limitsRawEntriesBeforeDeduplication() {
List<String> repeatedIds = Collections.nCopies(101, "1");
assertThrows(AlertGroupEvidenceRequestException.class,
() -> evidenceService.getEvidence(repeatedIds));
verifyNoInteractions(groupAlertDao);
}
@Test
void rejectsUnknownPersistedStatus() {
when(groupAlertDao.findStatusEvidenceByIdIn(List.of(1L))).thenReturn(List.of(
new AlertGroupStatusEvidence(1L, "private-unknown-status")));
assertThrows(AlertGroupStatusNotSupportedException.class,
() -> evidenceService.getEvidence(List.of("1")));
}
@Test
void doesNotHideDuplicateDaoRows() {
when(groupAlertDao.findStatusEvidenceByIdIn(List.of(1L))).thenReturn(List.of(
new AlertGroupStatusEvidence(1L, CommonConstants.ALERT_STATUS_FIRING),
new AlertGroupStatusEvidence(1L, CommonConstants.ALERT_STATUS_RESOLVED)));
assertThrows(IllegalStateException.class,
() -> evidenceService.getEvidence(List.of("1")));
}
}
@@ -0,0 +1,165 @@
/*
* 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.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import java.util.List;
import java.util.Map;
import org.apache.hertzbeat.alert.config.AlertSseManager;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import tools.jackson.core.type.TypeReference;
/**
* Transaction and safe-payload contracts for alert group mutation publication.
*/
class AlertGroupMutationPublisherTest {
@Test
void statusRefreshPublishesSafeSortedPayloadOnlyAfterCommit() {
AlertSseManager manager = Mockito.mock(AlertSseManager.class);
AlertGroupMutationPublisher publisher = new AlertGroupMutationPublisher(manager);
TransactionSynchronizationManager.initSynchronization();
try {
publisher.publishStatusChanged(List.of(2L, 1L, 2L), "acknowledged");
verify(manager, never()).broadcastGroupMutation(anyString());
TransactionSynchronizationManager.getSynchronizations()
.forEach(TransactionSynchronization::afterCommit);
assertMutationEvent(manager, List.of(1L, 2L), "acknowledged", "GROUP_STATUS_CHANGED");
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void deleteTombstonePublishesOnlyAfterCommit() {
AlertSseManager manager = Mockito.mock(AlertSseManager.class);
AlertGroupMutationPublisher publisher = new AlertGroupMutationPublisher(manager);
TransactionSynchronizationManager.initSynchronization();
try {
publisher.publishDeleted(List.of(2L, 1L));
verify(manager, never()).broadcastGroupMutation(anyString());
TransactionSynchronizationManager.getSynchronizations()
.forEach(TransactionSynchronization::afterCommit);
assertMutationEvent(manager, List.of(1L, 2L), null, "GROUP_DELETED");
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void rolledBackTransactionDoesNotBroadcast() {
AlertSseManager manager = Mockito.mock(AlertSseManager.class);
AlertGroupMutationPublisher publisher = new AlertGroupMutationPublisher(manager);
TransactionSynchronizationManager.initSynchronization();
try {
publisher.publishDeleted(List.of(1L));
TransactionSynchronizationManager.getSynchronizations()
.forEach(synchronization ->
synchronization.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
verify(manager, never()).broadcastGroupMutation(anyString());
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void emptyOrNullTargetsDoNotRegisterOrBroadcast() {
AlertSseManager manager = Mockito.mock(AlertSseManager.class);
AlertGroupMutationPublisher publisher = new AlertGroupMutationPublisher(manager);
TransactionSynchronizationManager.initSynchronization();
try {
publisher.publishDeleted(List.of());
publisher.publishStatusChanged(null, "acknowledged");
assertTrue(TransactionSynchronizationManager.getSynchronizations().isEmpty());
verify(manager, never()).broadcastGroupMutation(anyString());
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void nonTransactionalBroadcastFailureDoesNotLeakPayloadOrExceptionMessage() {
String privateDetail = "private-mutation-body-and-exception";
AlertSseManager manager = Mockito.mock(AlertSseManager.class);
doThrow(new IllegalStateException(privateDetail))
.when(manager).broadcastGroupMutation(anyString());
AlertGroupMutationPublisher publisher = new AlertGroupMutationPublisher(manager);
Logger logger = (Logger) LoggerFactory.getLogger(AlertGroupMutationPublisher.class);
Level originalLevel = logger.getLevel();
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
logger.setLevel(Level.DEBUG);
try {
publisher.publishDeleted(List.of(1L));
String logs = appender.list.stream()
.map(ILoggingEvent::getFormattedMessage)
.reduce("", String::concat);
assertFalse(logs.contains(privateDetail));
assertTrue(logs.contains(IllegalStateException.class.getSimpleName()));
} finally {
logger.setLevel(originalLevel);
logger.detachAppender(appender);
appender.stop();
}
}
private static void assertMutationEvent(
AlertSseManager manager, List<Long> ids, String status, String mutation) {
ArgumentCaptor<String> payload = ArgumentCaptor.forClass(String.class);
verify(manager).broadcastGroupMutation(payload.capture());
Map<String, Object> event = JsonUtil.fromJson(payload.getValue(), new TypeReference<>() {
});
assertEquals(ids.get(0).longValue(), ((Number) event.get("id")).longValue());
List<Long> eventIds = ((List<?>) event.get("ids")).stream()
.map(Number.class::cast)
.map(Number::longValue)
.toList();
assertEquals(ids, eventIds);
assertEquals(status, event.get("status"));
assertEquals(mutation, event.get("mutation"));
assertNull(event.get("content"));
assertNull(event.get("annotations"));
}
}
@@ -0,0 +1,65 @@
/*
* 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.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.List;
import java.util.Map;
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
import org.junit.jupiter.api.Test;
class AlertInhibitContractMapperTest {
private final AlertInhibitContractMapper mapper = new AlertInhibitContractMapper();
@Test
void createRejectsIdentityAndRequiresOperationalMatchers() {
AlertInhibitRequest request = request();
request.setId(7L);
assertThrows(IllegalArgumentException.class, () -> mapper.toNewEntity(request));
request.setId(null);
request.setSourceLabels(Map.of());
assertThrows(IllegalArgumentException.class, () -> mapper.toNewEntity(request));
}
@Test
void normalizesSafeExplicitRequest() {
AlertInhibitRequest request = request();
request.setName(" Host severity suppression ");
var entity = mapper.toNewEntity(request);
assertEquals("Host severity suppression", entity.getName());
assertEquals(Map.of("severity", "critical"), entity.getSourceLabels());
assertEquals(Map.of("severity", "warning"), entity.getTargetLabels());
assertEquals(List.of("instance"), entity.getEqualLabels());
}
private AlertInhibitRequest request() {
AlertInhibitRequest request = new AlertInhibitRequest();
request.setName("Host severity suppression");
request.setEnable(true);
request.setSourceLabels(Map.of("severity", "critical"));
request.setTargetLabels(Map.of("severity", "warning"));
request.setEqualLabels(List.of("instance"));
return request;
}
}
@@ -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.alert.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.apache.hertzbeat.alert.dao.AlertInhibitDao;
import org.apache.hertzbeat.alert.dto.AlertInhibitRequest;
import org.apache.hertzbeat.alert.reduce.AlarmInhibitReduce;
import org.apache.hertzbeat.alert.service.impl.AlertInhibitServiceImpl;
import org.apache.hertzbeat.common.entity.alerter.AlertInhibit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.domain.Specification;
@ExtendWith(MockitoExtension.class)
class AlertInhibitServiceTest {
@Mock
private AlertInhibitDao dao;
@Mock
private AlarmInhibitReduce reducer;
private AlertInhibitServiceImpl service;
@BeforeEach
void setUp() {
service = new AlertInhibitServiceImpl(dao, reducer, new AlertInhibitContractMapper());
}
@Test
void createAndUpdateUseAuthoritativeRereads() {
AlertInhibit saved = entity(7L, "draft");
AlertInhibit authoritative = entity(7L, "authoritative");
when(dao.save(any(AlertInhibit.class))).thenReturn(saved);
when(dao.findById(7L)).thenReturn(Optional.of(authoritative));
when(dao.findAlertInhibitsByEnableIsTrue()).thenReturn(List.of(authoritative));
assertEquals("authoritative", service.create(request(null)).name());
AlertInhibit existing = entity(7L, "existing");
AlertInhibit updated = entity(7L, "updated");
when(dao.findById(7L)).thenReturn(Optional.of(existing), Optional.of(updated));
assertEquals("updated", service.update(request(7L)).name());
verify(reducer, org.mockito.Mockito.times(2)).refreshInhibitRules(List.of(authoritative));
}
@Test
void missingUpdateDoesNotInsert() {
when(dao.findById(7L)).thenReturn(Optional.empty());
assertThrows(AlertInhibitNotFoundException.class, () -> service.update(request(7L)));
verify(dao, never()).save(any());
}
@Test
void deleteRereadsAndReportsMissingIds() {
when(dao.findAllById(Set.of(7L, 8L))).thenReturn(List.of(entity(7L, "existing")), List.of());
when(dao.findAlertInhibitsByEnableIsTrue()).thenReturn(List.of());
var result = service.delete(Set.of(7L, 8L));
assertEquals("partial", result.status());
assertEquals(Set.of(7L), result.deletedIds());
assertEquals(Set.of(8L), result.missingIds());
InOrder order = inOrder(dao);
order.verify(dao).findAllById(Set.of(7L, 8L));
order.verify(dao).deleteAlertInhibitsByIdIn(Set.of(7L));
order.verify(dao).findAllById(Set.of(7L, 8L));
}
@Test
void uncertainDeleteLeavesCacheUnpublished() {
when(dao.findAllById(Set.of(7L))).thenReturn(List.of(entity(7L, "existing")),
List.of(entity(7L, "remaining")));
assertThrows(AlertInhibitOperationException.class, () -> service.delete(Set.of(7L)));
verify(reducer, never()).refreshInhibitRules(any());
}
@Test
void listMapsExplicitPageAndRejectsUnsafeControls() {
AlertInhibit listed = entity(7L, "listed");
when(dao.findAll(any(Specification.class), any(PageRequest.class)))
.thenReturn(new PageImpl<>(List.of(listed), PageRequest.of(0, 8), 1));
var result = service.list(null, " listed ", "id", "desc", 0, 8);
assertEquals(1, result.totalElements());
assertEquals("listed", result.content().getFirst().name());
assertThrows(IllegalArgumentException.class,
() -> service.list(null, null, "sourceLabels", "desc", 0, 8));
assertThrows(IllegalArgumentException.class,
() -> service.list(null, null, "id", "desc", -1, 8));
}
private AlertInhibitRequest request(Long id) {
AlertInhibitRequest request = new AlertInhibitRequest();
request.setId(id);
request.setName("Host suppression");
request.setEnable(true);
request.setSourceLabels(Map.of("severity", "critical"));
request.setTargetLabels(Map.of("severity", "warning"));
request.setEqualLabels(List.of("instance"));
return request;
}
private AlertInhibit entity(Long id, String name) {
return AlertInhibit.builder().id(id).name(name).enable(true)
.sourceLabels(Map.of("severity", "critical"))
.targetLabels(Map.of("severity", "warning"))
.equalLabels(List.of("instance")).build();
}
}
@@ -21,8 +21,13 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.Collections;
import java.util.HashSet;
@@ -46,6 +51,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* Test case for {@link AlertService}
@@ -61,6 +67,9 @@ class AlertServiceTest {
@Mock
private AlarmCommonReduce alarmCommonReduce;
@Mock
private AlertGroupMutationPublisher alertGroupMutationPublisher;
@InjectMocks
private AlertServiceImpl alertService;
@@ -73,10 +82,49 @@ class AlertServiceTest {
HashSet<Long> ids = new HashSet<>();
ids.add(1L);
ids.add(2L);
List<GroupAlert> groupAlerts = List.of(
GroupAlert.builder().id(1L).alertFingerprints(List.of()).build(),
GroupAlert.builder().id(2L).alertFingerprints(List.of()).build());
when(groupAlertDao.findGroupAlertsByIdIn(ids)).thenReturn(groupAlerts);
assertDoesNotThrow(() -> alertService.deleteGroupAlerts(ids));
verify(groupAlertDao, times(1)).deleteGroupAlertsByIdIn(ids);
}
@Test
void deleteGroupAlertsRejectsPartialMissingTargetsBeforeDeletes() {
HashSet<Long> ids = new HashSet<>(List.of(1L, 2L));
GroupAlert existingAlert = GroupAlert.builder()
.id(1L)
.alertFingerprints(List.of("private-alert-fingerprint"))
.build();
when(groupAlertDao.findGroupAlertsByIdIn(ids)).thenReturn(List.of(existingAlert));
TransactionSynchronizationManager.initSynchronization();
try {
assertThrows(AlertGroupNotFoundException.class, () -> alertService.deleteGroupAlerts(ids));
assertTrue(TransactionSynchronizationManager.getSynchronizations().isEmpty());
verify(groupAlertDao, never()).deleteGroupAlertsByIdIn(ids);
verifyNoInteractions(singleAlertDao);
verifyNoInteractions(alertGroupMutationPublisher);
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void deleteGroupAlertsRequestsTombstoneAfterExactDelete() {
HashSet<Long> ids = new HashSet<>(List.of(2L, 1L));
List<GroupAlert> groupAlerts = List.of(
GroupAlert.builder().id(1L).alertFingerprints(List.of()).build(),
GroupAlert.builder().id(2L).alertFingerprints(List.of()).build());
when(groupAlertDao.findGroupAlertsByIdIn(ids)).thenReturn(groupAlerts);
alertService.deleteGroupAlerts(ids);
verify(groupAlertDao).deleteGroupAlertsByIdIn(ids);
verify(alertGroupMutationPublisher).publishDeleted(ids);
}
@Test
void editGroupAlertStatus() {
@@ -87,20 +135,31 @@ class AlertServiceTest {
.status(CommonConstants.ALERT_STATUS_RESOLVED)
.alertFingerprints(List.of("fingerprint-1"))
.build();
GroupAlert secondGroupAlert = GroupAlert.builder()
.id(2L)
.status(CommonConstants.ALERT_STATUS_RESOLVED)
.alertFingerprints(List.of())
.build();
GroupAlert thirdGroupAlert = GroupAlert.builder()
.id(3L)
.status(CommonConstants.ALERT_STATUS_RESOLVED)
.alertFingerprints(List.of())
.build();
List<GroupAlert> groupAlerts = List.of(groupAlert, secondGroupAlert, thirdGroupAlert);
SingleAlert singleAlert = SingleAlert.builder()
.id(1L)
.fingerprint("fingerprint-1")
.status(CommonConstants.ALERT_STATUS_RESOLVED)
.endAt(1L)
.build();
when(groupAlertDao.findAllById(ids)).thenReturn(List.of(groupAlert));
when(groupAlertDao.findAllById(ids)).thenReturn(groupAlerts);
when(singleAlertDao.findSingleAlertsByFingerprintIn(List.of("fingerprint-1"))).thenReturn(List.of(singleAlert));
assertDoesNotThrow(() -> alertService.editGroupAlertStatus(status, ids));
assertEquals(CommonConstants.ALERT_STATUS_FIRING, groupAlert.getStatus());
assertEquals(CommonConstants.ALERT_STATUS_FIRING, singleAlert.getStatus());
assertNull(singleAlert.getEndAt());
verify(groupAlertDao, times(1)).saveAll(List.of(groupAlert));
verify(groupAlertDao, times(1)).saveAll(groupAlerts);
verify(singleAlertDao, times(1)).saveAll(List.of(singleAlert));
}
@@ -157,6 +216,68 @@ class AlertServiceTest {
verify(singleAlertDao, times(1)).saveAll(List.of(singleAlert));
}
@Test
void editGroupAlertStatusRejectsPartialMissingTargetsBeforeWrites() {
List<Long> ids = List.of(1L, 2L);
GroupAlert existingAlert = GroupAlert.builder()
.id(1L)
.status(CommonConstants.ALERT_STATUS_FIRING)
.alertFingerprints(List.of("fingerprint-1"))
.build();
when(groupAlertDao.findAllById(ids)).thenReturn(List.of(existingAlert));
TransactionSynchronizationManager.initSynchronization();
try {
assertThrows(AlertGroupNotFoundException.class,
() -> alertService.editGroupAlertStatus(CommonConstants.ALERT_STATUS_RESOLVED, ids));
assertTrue(TransactionSynchronizationManager.getSynchronizations().isEmpty());
verify(groupAlertDao, never()).saveAll(anyList());
verifyNoInteractions(singleAlertDao);
verifyNoInteractions(alertGroupMutationPublisher);
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void editGroupAlertStatusRequestsRefreshAfterExactWrites() {
List<Long> ids = List.of(2L, 1L, 2L);
List<GroupAlert> groupAlerts = List.of(
GroupAlert.builder().id(1L).alertFingerprints(List.of()).build(),
GroupAlert.builder().id(2L).alertFingerprints(List.of()).build());
when(groupAlertDao.findAllById(List.of(2L, 1L))).thenReturn(groupAlerts);
alertService.editGroupAlertStatus(CommonConstants.ALERT_STATUS_ACKNOWLEDGED, ids);
verify(groupAlertDao).saveAll(groupAlerts);
verify(alertGroupMutationPublisher).publishStatusChanged(
List.of(2L, 1L), CommonConstants.ALERT_STATUS_ACKNOWLEDGED);
}
@Test
void editGroupAlertStatusRemainsIdempotentWhenStatusAlreadyApplied() {
List<Long> ids = List.of(1L);
GroupAlert groupAlert = GroupAlert.builder()
.id(1L)
.status(CommonConstants.ALERT_STATUS_ACKNOWLEDGED)
.alertFingerprints(List.of())
.build();
when(groupAlertDao.findAllById(ids)).thenReturn(List.of(groupAlert));
assertDoesNotThrow(() -> alertService.editGroupAlertStatus(CommonConstants.ALERT_STATUS_ACKNOWLEDGED, ids));
assertEquals(CommonConstants.ALERT_STATUS_ACKNOWLEDGED, groupAlert.getStatus());
verify(groupAlertDao).saveAll(List.of(groupAlert));
verifyNoInteractions(singleAlertDao);
}
@Test
void editGroupAlertStatusRejectsUnsupportedStatusBeforeQueriesOrWrites() {
assertThrows(AlertGroupStatusNotSupportedException.class,
() -> alertService.editGroupAlertStatus("private-arbitrary-status", List.of(1L)));
verifyNoInteractions(groupAlertDao, singleAlertDao);
}
@Test
void getGroupAlertsFiltersByServiceNamespaceAndEnvironmentLabels() {
GroupAlert matching = GroupAlert.builder()
@@ -227,4 +348,5 @@ class AlertServiceTest {
verify(singleAlertDao, times(1)).querySingleAlertsByStatus(CommonConstants.ALERT_STATUS_FIRING);
verify(singleAlertDao, times(1)).count();
}
}
@@ -82,6 +82,14 @@ class AlertSilenceContractMapperTest {
assertEquals("creator", updated.getCreator());
}
@Test
void nullRequestFailsValidation() {
var existing = org.apache.hertzbeat.common.entity.alerter.AlertSilence.builder().id(7L).build();
assertThrows(IllegalArgumentException.class, () -> mapper.toNewEntity(null));
assertThrows(IllegalArgumentException.class, () -> mapper.toExistingEntity(null, existing));
}
private AlertSilenceRequest request(byte type) {
AlertSilenceRequest request = new AlertSilenceRequest();
request.setName("Maintenance");
@@ -87,6 +87,11 @@ class AlertSilenceServiceTest {
assertThrows(AlertSilenceNotFoundException.class, () -> service.get(7L));
}
@Test
void nullUpdateIsRejectedAsValidation() {
assertThrows(IllegalArgumentException.class, () -> service.update(null));
}
@Test
void deleteRereadsAndReportsMissingIds() {
when(dao.findAllById(Set.of(7L, 8L))).thenReturn(List.of(entity(7L, "existing")), List.of());
@@ -73,6 +73,78 @@ class DataSourceServiceTest {
assertEquals(200.0, result.get(1).get("__value__"));
}
@Test
void calculatePreviewPreservesExecutorFailure() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("promql")).thenReturn(true);
when(mockExecutor.execute(anyString())).thenReturn(List.of());
when(mockExecutor.executePreview(anyString())).thenThrow(new IllegalStateException("preview backend unavailable"));
dataSourceService.setExecutors(List.of(mockExecutor));
AlertExpressionException exception = assertThrows(AlertExpressionException.class,
() -> dataSourceService.calculatePreview("promql", "node_cpu_seconds_total > 80"));
assertEquals("Preview query execution failed", exception.getMessage());
}
@Test
void queryPreviewReturnsSafeFailureWithoutExecutorDetails() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
when(mockExecutor.execute(anyString())).thenReturn(List.of());
when(mockExecutor.executePreview(anyString()))
.thenThrow(new IllegalStateException("private backend host and query details"));
dataSourceService.setExecutors(List.of(mockExecutor));
AlertExpressionException exception = assertThrows(AlertExpressionException.class,
() -> dataSourceService.queryPreview(
"sql", "SELECT * FROM hertzbeat_logs", LOG_ALERT_THRESHOLD_TYPE_PERIODIC));
assertEquals("Preview query execution failed", exception.getMessage());
}
@Test
void queryPreviewWrapsValidatedSqlWithAnOuterLimit() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
when(mockExecutor.executePreview(anyString())).thenReturn(List.of());
dataSourceService.setExecutors(List.of(mockExecutor));
dataSourceService.queryPreview(
"sql", "SELECT * FROM hertzbeat_logs LIMIT 10; ", LOG_ALERT_THRESHOLD_TYPE_PERIODIC);
verify(mockExecutor).executePreview(
"SELECT * FROM (SELECT * FROM hertzbeat_logs LIMIT 10) AS hertzbeat_preview LIMIT 100");
verify(mockExecutor, never()).execute(anyString());
}
@Test
void queryPreviewRejectsUnsafeSqlBeforeStrictExecution() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
dataSourceService.setExecutors(List.of(mockExecutor));
assertThrows(AlertExpressionException.class,
() -> dataSourceService.queryPreview(
"sql", "DROP TABLE hertzbeat_logs", LOG_ALERT_THRESHOLD_TYPE_PERIODIC));
verify(mockExecutor, never()).executePreview(anyString());
verify(mockExecutor, never()).execute(anyString());
}
@Test
void queryLeavesRegularSqlUnwrapped() {
QueryExecutor mockExecutor = Mockito.mock(QueryExecutor.class);
when(mockExecutor.support("sql")).thenReturn(true);
when(mockExecutor.execute(anyString())).thenReturn(List.of());
dataSourceService.setExecutors(List.of(mockExecutor));
dataSourceService.query(
"sql", "SELECT * FROM hertzbeat_logs LIMIT 10;", LOG_ALERT_THRESHOLD_TYPE_PERIODIC);
verify(mockExecutor).execute("SELECT * FROM hertzbeat_logs LIMIT 10;");
verify(mockExecutor, never()).executePreview(anyString());
}
@Test
void calculate2() {
List<Map<String, Object>> prometheusData = List.of(
@@ -17,10 +17,17 @@
package org.apache.hertzbeat.alert.service;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import org.apache.hertzbeat.alert.dao.NoticeReceiverDao;
import org.apache.hertzbeat.alert.dao.NoticeRuleDao;
import org.apache.hertzbeat.alert.dao.NoticeTemplateDao;
import org.apache.hertzbeat.alert.notice.AlertNoticeDispatch;
import org.apache.hertzbeat.alert.service.NoticeTemplateMutationException.Reason;
import org.apache.hertzbeat.alert.service.impl.NoticeConfigServiceImpl;
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
@@ -29,6 +36,7 @@ import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
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;
@@ -42,11 +50,15 @@ import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -158,6 +170,38 @@ class NoticeConfigServiceTest {
verify(noticeTemplateDao, times(1)).findAll(any(Specification.class), any(PageRequest.class));
}
@Test
@SuppressWarnings("unchecked")
void getCustomNoticeTemplatesCombinesPresetAndNameFilters() {
ArgumentCaptor<Specification<NoticeTemplate>> specificationCaptor =
ArgumentCaptor.forClass(Specification.class);
when(noticeTemplateDao.findAll(any(Specification.class), any(PageRequest.class)))
.thenReturn(Page.empty());
noticeConfigService.getNoticeTemplates("Template", false, 0, 8);
verify(noticeTemplateDao).findAll(specificationCaptor.capture(), any(PageRequest.class));
Root<NoticeTemplate> root = mock(Root.class);
CriteriaQuery<?> query = mock(CriteriaQuery.class);
CriteriaBuilder criteriaBuilder = mock(CriteriaBuilder.class);
Path<Boolean> presetPath = mock(Path.class);
Path<String> namePath = mock(Path.class);
Expression<String> loweredName = mock(Expression.class);
Predicate customPredicate = mock(Predicate.class);
Predicate namePredicate = mock(Predicate.class);
Predicate combinedPredicate = mock(Predicate.class);
when(root.<Boolean>get("preset")).thenReturn(presetPath);
when(root.<String>get("name")).thenReturn(namePath);
when(criteriaBuilder.equal(presetPath, false)).thenReturn(customPredicate);
when(criteriaBuilder.lower(namePath)).thenReturn(loweredName);
when(criteriaBuilder.like(loweredName, "%template%")).thenReturn(namePredicate);
when(criteriaBuilder.and(customPredicate, namePredicate)).thenReturn(combinedPredicate);
Predicate result = specificationCaptor.getValue().toPredicate(root, query, criteriaBuilder);
assertSame(combinedPredicate, result);
}
@Test
void getAllNoticeTemplates() {
when(noticeTemplateDao.findAll()).thenReturn(Arrays.asList(template1, template2));
@@ -216,23 +260,146 @@ class NoticeConfigServiceTest {
@Test
void addTemplate() {
final NoticeTemplate noticeTemplate = mock(NoticeTemplate.class);
final NoticeTemplate noticeTemplate = NoticeTemplate.builder()
.name("custom")
.type((byte) 1)
.content("content")
.build();
noticeConfigService.addNoticeTemplate(noticeTemplate);
verify(noticeTemplateDao, times(1)).save(noticeTemplate);
verify(noticeTemplateDao, times(1)).save(any(NoticeTemplate.class));
}
@Test
void addNoticeTemplateRejectsCallerOwnedIdentityAndPresetState() {
NoticeTemplate noticeTemplate = NoticeTemplate.builder()
.id(87584674384L)
.name("private-template")
.type((byte) 1)
.preset(true)
.content("private-template-payload")
.build();
NoticeTemplateMutationException exception = assertThrows(NoticeTemplateMutationException.class,
() -> noticeConfigService.addNoticeTemplate(noticeTemplate));
assertEquals(Reason.INVALID_REQUEST, exception.getReason());
verifyNoInteractions(noticeTemplateDao);
}
@Test
void editTemplate() {
final NoticeTemplate noticeTemplate = mock(NoticeTemplate.class);
final NoticeTemplate noticeTemplate = NoticeTemplate.builder()
.id(23342525L)
.name("updated")
.type((byte) 1)
.content("updated content")
.build();
NoticeTemplate persisted = NoticeTemplate.builder().id(23342525L).preset(false).build();
when(noticeTemplateDao.findByIdForUpdate(23342525L)).thenReturn(java.util.Optional.of(persisted));
noticeConfigService.editNoticeTemplate(noticeTemplate);
verify(noticeTemplateDao, times(1)).save(noticeTemplate);
verify(noticeTemplateDao, times(1)).save(persisted);
}
@Test
void editNoticeTemplateRejectsMissingTargetBeforeWrite() {
NoticeTemplate noticeTemplate = NoticeTemplate.builder()
.id(87584674384L)
.name("updated")
.type((byte) 1)
.content("updated content")
.build();
when(noticeTemplateDao.findByIdForUpdate(87584674384L)).thenReturn(java.util.Optional.empty());
NoticeTemplateMutationException exception = assertThrows(NoticeTemplateMutationException.class,
() -> noticeConfigService.editNoticeTemplate(noticeTemplate));
assertEquals(Reason.NOT_FOUND, exception.getReason());
verify(noticeTemplateDao, never()).save(any());
}
@Test
void editNoticeTemplateUpdatesOnlyTheLockedExactCustomTarget() {
NoticeTemplate persisted = NoticeTemplate.builder()
.id(87584674384L)
.name("existing")
.type((byte) 2)
.content("existing content")
.creator("trusted-creator")
.preset(false)
.build();
NoticeTemplate request = NoticeTemplate.builder()
.id(87584674384L)
.name("updated")
.type((byte) 1)
.content("updated content")
.creator("private-spoofed-creator")
.preset(false)
.build();
when(noticeTemplateDao.findByIdForUpdate(87584674384L)).thenReturn(java.util.Optional.of(persisted));
noticeConfigService.editNoticeTemplate(request);
assertEquals("updated", persisted.getName());
assertEquals((byte) 1, persisted.getType());
assertEquals("updated content", persisted.getContent());
assertEquals("trusted-creator", persisted.getCreator());
verify(noticeTemplateDao).save(persisted);
verify(noticeTemplateDao, never()).save(request);
}
@Test
void editNoticeTemplateRejectsPresetMutationBeforeWrite() {
NoticeTemplate persisted = NoticeTemplate.builder()
.id(87584674384L)
.preset(false)
.build();
NoticeTemplate request = NoticeTemplate.builder()
.id(87584674384L)
.preset(true)
.build();
when(noticeTemplateDao.findByIdForUpdate(87584674384L)).thenReturn(java.util.Optional.of(persisted));
NoticeTemplateMutationException exception = assertThrows(NoticeTemplateMutationException.class,
() -> noticeConfigService.editNoticeTemplate(request));
assertEquals(Reason.READ_ONLY, exception.getReason());
verify(noticeTemplateDao, never()).save(any());
}
@Test
void deleteTemplate() {
final Long templateId = 23342525L;
NoticeTemplate persisted = NoticeTemplate.builder().id(templateId).preset(false).build();
when(noticeTemplateDao.findByIdForUpdate(templateId)).thenReturn(java.util.Optional.of(persisted));
noticeConfigService.deleteNoticeTemplate(templateId);
verify(noticeTemplateDao, times(1)).deleteById(templateId);
verify(noticeTemplateDao, times(1)).delete(persisted);
}
@Test
void deleteNoticeTemplateRejectsMissingTargetBeforeWrite() {
long templateId = 87584674384L;
when(noticeTemplateDao.findByIdForUpdate(templateId)).thenReturn(java.util.Optional.empty());
NoticeTemplateMutationException exception = assertThrows(NoticeTemplateMutationException.class,
() -> noticeConfigService.deleteNoticeTemplate(templateId));
assertEquals(Reason.NOT_FOUND, exception.getReason());
verify(noticeTemplateDao, never()).delete(any(NoticeTemplate.class));
verify(noticeTemplateDao, never()).deleteById(any());
}
@Test
void deleteNoticeTemplateRejectsLockedPresetTargetBeforeWrite() {
long templateId = 87584674384L;
NoticeTemplate persisted = NoticeTemplate.builder().id(templateId).preset(true).build();
when(noticeTemplateDao.findByIdForUpdate(templateId)).thenReturn(java.util.Optional.of(persisted));
NoticeTemplateMutationException exception = assertThrows(NoticeTemplateMutationException.class,
() -> noticeConfigService.deleteNoticeTemplate(templateId));
assertEquals(Reason.READ_ONLY, exception.getReason());
verify(noticeTemplateDao, never()).delete(any(NoticeTemplate.class));
verify(noticeTemplateDao, never()).deleteById(any());
}
@Test
@@ -20,6 +20,7 @@ package org.apache.hertzbeat.alert.service.impl;
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.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
@@ -183,7 +184,7 @@ public class AlertManagerExternAlertServiceTest {
@Test
void testAddExternAlertWithInvalidContent() {
String invalidContent = "invalid json content";
externAlertService.addExternAlert(invalidContent);
assertThrows(IllegalArgumentException.class, () -> externAlertService.addExternAlert(invalidContent));
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
}
@@ -193,7 +194,8 @@ public class AlertManagerExternAlertServiceTest {
.groupKey("test-group-key")
.alerts(List.of()) // Empty alerts list
.build();
externAlertService.addExternAlert(JsonUtil.toJson(alertManagerAlert));
String content = JsonUtil.toJson(alertManagerAlert);
assertThrows(IllegalArgumentException.class, () -> externAlertService.addExternAlert(content));
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
}
@@ -211,19 +213,9 @@ public class AlertManagerExternAlertServiceTest {
.alerts(List.of(prometheusAlert))
.build();
final SingleAlert[] capturedAlert = new SingleAlert[1];
doAnswer(invocation -> {
capturedAlert[0] = invocation.getArgument(0);
return null;
}).when(alarmCommonReduce).reduceAndSendAlarm(any(SingleAlert.class));
externAlertService.addExternAlert(JsonUtil.toJson(alertManagerAlert));
verify(alarmCommonReduce, times(1)).reduceAndSendAlarm(any(SingleAlert.class));
assertNotNull(capturedAlert[0]);
assertNotNull(capturedAlert[0].getLabels());
assertEquals("alertmanager", capturedAlert[0].getLabels().get("__source__"));
String content = JsonUtil.toJson(alertManagerAlert);
assertThrows(IllegalArgumentException.class, () -> externAlertService.addExternAlert(content));
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
}
@Test
@@ -0,0 +1,133 @@
/*
* 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.service.impl;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.util.List;
import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class)
class ExternalAlertIngressValidationTest {
@Mock
private AlarmCommonReduce alarmCommonReduce;
private DefaultExternAlertService defaultService;
private PrometheusExternAlertService prometheusService;
private AlertManagerExternAlertService alertManagerService;
private ZabbixExternAlertServiceImpl zabbixService;
@BeforeEach
void setUp() {
defaultService = withReducer(new DefaultExternAlertService());
prometheusService = withReducer(new PrometheusExternAlertService());
alertManagerService = withReducer(new AlertManagerExternAlertService());
zabbixService = withReducer(new ZabbixExternAlertServiceImpl());
}
@Test
void rejectsMalformedAndEmptyPayloadsBeforeAsyncSubmission() {
assertThrows(IllegalArgumentException.class, () -> defaultService.addExternAlert("not-json"));
assertThrows(IllegalArgumentException.class, () -> prometheusService.addExternAlert("not-json"));
assertThrows(IllegalArgumentException.class, () -> prometheusService.addExternAlert("null"));
assertThrows(IllegalArgumentException.class, () -> prometheusService.addExternAlert("[]"));
assertThrows(IllegalArgumentException.class, () -> alertManagerService.addExternAlert("not-json"));
assertThrows(IllegalArgumentException.class, () -> alertManagerService.addExternAlert("null"));
assertThrows(IllegalArgumentException.class, () -> alertManagerService.addExternAlert("{}"));
assertThrows(IllegalArgumentException.class, () -> alertManagerService.addExternAlert("{\"alerts\":[]}"));
assertThrows(IllegalArgumentException.class, () -> zabbixService.addExternAlert("not-json"));
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
}
@Test
void rejectsMissingOrEmptyBusinessLabelsBeforeAddingSyntheticSource() {
assertThrows(IllegalArgumentException.class, () -> defaultService.addExternAlert("{}"));
assertThrows(IllegalArgumentException.class,
() -> defaultService.addExternAlert("{\"labels\":{}}"));
assertThrows(IllegalArgumentException.class, () -> prometheusService.addExternAlert("[{}]"));
assertThrows(IllegalArgumentException.class,
() -> prometheusService.addExternAlert("[{\"labels\":{}}]"));
assertThrows(IllegalArgumentException.class,
() -> alertManagerService.addExternAlert("{\"alerts\":[{}]}"));
assertThrows(IllegalArgumentException.class,
() -> alertManagerService.addExternAlert("{\"alerts\":[{\"labels\":{}}]}"));
assertThrows(IllegalArgumentException.class, () -> zabbixService.addExternAlert("{}"));
assertThrows(IllegalArgumentException.class, () -> zabbixService.addExternAlert("{\"labels\":{}}"));
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
}
@Test
void validatesCompleteBatchBeforeSubmittingAnyElement() {
String prometheusBatch = """
[
{"labels":{"alertname":"first"}},
null
]""";
String alertManagerBatch = """
{
"alerts":[
{"labels":{"alertname":"first"}},
null
]
}""";
assertThrows(IllegalArgumentException.class, () -> prometheusService.addExternAlert(prometheusBatch));
assertThrows(IllegalArgumentException.class, () -> alertManagerService.addExternAlert(alertManagerBatch));
verify(alarmCommonReduce, never()).reduceAndSendAlarm(any(SingleAlert.class));
}
@Test
void normalizesAnnotationsAndSubmitsProcessableAlerts() {
defaultService.addExternAlert("{\"labels\":{\"alertname\":\"default\"}}");
prometheusService.addExternAlert("[{\"labels\":{\"alertname\":\"prometheus\"}}]");
alertManagerService.addExternAlert(
"{\"alerts\":[{\"labels\":{\"alertname\":\"alertmanager\"}}]}");
zabbixService.addExternAlert("{\"labels\":{\"alertname\":\"zabbix\"}}");
ArgumentCaptor<SingleAlert> captor = ArgumentCaptor.forClass(SingleAlert.class);
verify(alarmCommonReduce, times(4)).reduceAndSendAlarm(captor.capture());
List<SingleAlert> submittedAlerts = captor.getAllValues();
for (SingleAlert alert : submittedAlerts) {
assertFalse(alert.getLabels().isEmpty());
assertNotNull(alert.getAnnotations());
}
}
private <T> T withReducer(T service) {
ReflectionTestUtils.setField(service, "alarmCommonReduce", alarmCommonReduce);
return service;
}
}
@@ -17,9 +17,14 @@
package org.apache.hertzbeat.base.dao;
import jakarta.persistence.LockModeType;
import org.apache.hertzbeat.common.entity.manager.GeneralConfig;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Component;
/**
@@ -28,7 +33,7 @@ import org.springframework.stereotype.Component;
* <p>This interface inherits the two interfaces JpaRepository and JpaSpecificationExecutor, providing basic CRUD operations and specification query capabilities.</p>
*/
@Component
public interface GeneralConfigDao extends JpaRepository<GeneralConfig, Long>, JpaSpecificationExecutor<GeneralConfig> {
public interface GeneralConfigDao extends JpaRepository<GeneralConfig, String>, JpaSpecificationExecutor<GeneralConfig> {
/**
* Query by type
@@ -36,4 +41,22 @@ public interface GeneralConfigDao extends JpaRepository<GeneralConfig, Long>, Jp
* @return Return the queried configuration information
*/
GeneralConfig findByType(String type);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select config from GeneralConfig config where config.type = :type")
GeneralConfig findByTypeForUpdate(@Param("type") String type);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update GeneralConfig config
set config.content = :content,
config.revision = :nextRevision,
config.gmtUpdate = CURRENT_TIMESTAMP
where config.type = :type
and config.revision = :expectedRevision
""")
int updateContentIfRevision(@Param("type") String type,
@Param("content") String content,
@Param("nextRevision") String nextRevision,
@Param("expectedRevision") String expectedRevision);
}
@@ -117,6 +117,9 @@ public class OtelRuntimeConfigRenderer {
if (properties.getInternalTelemetryPort() < 1 || properties.getInternalTelemetryPort() > 65535) {
throw new IllegalArgumentException("Runtime internal telemetry port is invalid");
}
long exporterTimeoutSeconds = OtelRuntimeGatewayPolicy.boundedTimeout(
properties.getOtlpHttpExporterTimeout(), "HTTP exporter request").toSeconds();
long exporterTimeoutMillis = properties.getOtlpHttpExporterTimeout().toMillis();
StringBuilder yaml = new StringBuilder("receivers:\n otlp:\n protocols:\n")
.append(" grpc:\n")
.append(" endpoint: ").append(yamlScalar(gateway.grpcEndpoint())).append('\n')
@@ -141,6 +144,7 @@ public class OtelRuntimeConfigRenderer {
headers:
Authorization: Bearer ${env:HERTZBEAT_OTLP_TOKEN}
compression: gzip
timeout: %ds
retry_on_failure:
enabled: true
initial_interval: 1s
@@ -153,6 +157,8 @@ public class OtelRuntimeConfigRenderer {
sizer: requests
queue_size: 2048
storage: file_storage
""".formatted(exporterTimeoutSeconds));
yaml.append("""
extensions:
health_check:
endpoint: 127.0.0.1:%d
@@ -169,6 +175,16 @@ public class OtelRuntimeConfigRenderer {
yaml.append("""
service:
telemetry:
resource:
attributes:
- name: service.name
value: hertzbeat-otel-runtime
- name: service.namespace
value: hertzbeat
- name: hertzbeat.collector.id
value: ${env:HERTZBEAT_COLLECTOR_ID}
- name: hertzbeat.workspace_id
value: ${env:HERTZBEAT_WORKSPACE_ID}
metrics:
level: basic
readers:
@@ -179,6 +195,18 @@ public class OtelRuntimeConfigRenderer {
port: %d
without_type_suffix: true
without_units: true
- periodic:
interval: 10000
timeout: %d
exporter:
otlp:
protocol: http/protobuf
endpoint: ${env:HERTZBEAT_OTLP_HTTP_ENDPOINT}/v1/metrics
headers:
- name: Authorization
value: Bearer ${env:HERTZBEAT_OTLP_TOKEN}
compression: gzip
timeout: %d
extensions: [%s]
pipelines:
metrics:
@@ -195,6 +223,8 @@ public class OtelRuntimeConfigRenderer {
exporters: [otlphttp]
""".formatted(
properties.getInternalTelemetryPort(),
exporterTimeoutMillis,
exporterTimeoutMillis,
gateway.enabled() ? "health_check, file_storage, bearertokenauth" : "health_check, file_storage",
metricsReceivers(desiredConfig.hostMetricsEnabled(), sources.prometheusTargets()),
commonProcessors,
@@ -0,0 +1,49 @@
/*
* 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.runtime.otel;
import java.net.URI;
import java.util.Locale;
/**
* Validates and canonicalizes the shared OTLP/HTTP export base endpoint.
*/
final class OtelRuntimeExportEndpointPolicy {
private OtelRuntimeExportEndpointPolicy() {
}
static String canonicalize(URI endpoint) {
if (endpoint == null || endpoint.isOpaque() || endpoint.getHost() == null
|| endpoint.getRawUserInfo() != null || endpoint.getPort() > 65535) {
throw new IllegalArgumentException("OTLP HTTP export endpoint must be an HTTP(S) server URI");
}
String scheme = endpoint.getScheme() == null ? "" : endpoint.getScheme().toLowerCase(Locale.ROOT);
if (!"http".equals(scheme) && !"https".equals(scheme)) {
throw new IllegalArgumentException("OTLP HTTP export endpoint must use HTTP or HTTPS");
}
if (endpoint.getRawQuery() != null || endpoint.getRawFragment() != null) {
throw new IllegalArgumentException("OTLP HTTP export endpoint must not contain a query or fragment");
}
String path = endpoint.getRawPath() == null ? "" : endpoint.getRawPath();
while (path.endsWith("/")) {
path = path.substring(0, path.length() - 1);
}
return scheme + "://" + endpoint.getRawAuthority() + path;
}
}
@@ -53,9 +53,9 @@ final class OtelRuntimeGatewayPolicy {
if (!gatewayEnabled && (!isLoopback(grpcEndpoint) || !isLoopback(httpEndpoint))) {
throw new IllegalArgumentException("Non-loopback OTLP listeners require explicit Gateway mode");
}
Duration readTimeout = timeout(properties.getOtlpReadTimeout(), "read");
Duration writeTimeout = timeout(properties.getOtlpWriteTimeout(), "write");
Duration idleTimeout = timeout(properties.getOtlpIdleTimeout(), "idle");
Duration readTimeout = boundedTimeout(properties.getOtlpReadTimeout(), "read");
Duration writeTimeout = boundedTimeout(properties.getOtlpWriteTimeout(), "write");
Duration idleTimeout = boundedTimeout(properties.getOtlpIdleTimeout(), "idle");
if (!gatewayEnabled) {
return new ResolvedGateway(grpcEndpoint, httpEndpoint, false,
readTimeout, writeTimeout, idleTimeout, null, null, null, null);
@@ -129,7 +129,7 @@ final class OtelRuntimeGatewayPolicy {
}
}
private static Duration timeout(Duration value, String label) {
static Duration boundedTimeout(Duration value, String label) {
if (value == null || value.compareTo(MINIMUM_TIMEOUT) < 0 || value.compareTo(MAXIMUM_TIMEOUT) > 0
|| value.getNano() != 0) {
throw new IllegalArgumentException("OTLP " + label + " timeout must be a whole second between 1s and 5m");
@@ -48,6 +48,8 @@ public class OtelRuntimeProperties {
private URI exportEndpoint = URI.create("http://127.0.0.1:1157/api/otlp");
private Duration otlpHttpExporterTimeout = Duration.ofSeconds(5);
private String token = "";
private String collectorId = "";
@@ -317,7 +317,8 @@ public class OtelRuntimeSupervisor implements SmartLifecycle, AutoCloseable, Col
Map<String, String> environment = new HashMap<>();
environment.put("HERTZBEAT_COLLECTOR_ID", properties.getCollectorId());
environment.put("HERTZBEAT_WORKSPACE_ID", properties.getWorkspaceId());
environment.put("HERTZBEAT_OTLP_HTTP_ENDPOINT", properties.getExportEndpoint().toString());
environment.put("HERTZBEAT_OTLP_HTTP_ENDPOINT",
OtelRuntimeExportEndpointPolicy.canonicalize(properties.getExportEndpoint()));
environment.put("HERTZBEAT_OTLP_TOKEN", properties.getToken());
environment.put("HERTZBEAT_OTEL_HEALTH_PORT", Integer.toString(properties.getHealthPort()));
environment.put("HERTZBEAT_OTEL_FILE_STORAGE_DIR", OtelRuntimeConfigRenderer.resolve(
@@ -34,7 +34,12 @@ import org.apache.hertzbeat.common.entity.dto.ManagedOtelRuntimeStatus.RuntimeTe
import org.apache.hertzbeat.common.entity.dto.ManagedOtelRuntimeStatus.SignalCounters;
import org.apache.hertzbeat.common.entity.dto.ManagedOtelRuntimeStatus.SignalGauges;
/** Reads the bounded loopback Prometheus view of the official Runtime's internal telemetry. */
/**
* Reads the bounded loopback Prometheus view for Java heartbeat/status observation.
*
* <p>This local scrape does not forward telemetry. The Runtime's official periodic OTLP reader performs direct
* internal-metrics export independently.</p>
*/
public class OtelRuntimeTelemetryClient {
private static final int MAXIMUM_RESPONSE_BYTES = 1024 * 1024;
@@ -69,6 +69,7 @@ collector:
host-metrics-interval: ${HERTZBEAT_OTEL_HOST_METRICS_INTERVAL:10s}
file-storage-directory: ${HERTZBEAT_OTEL_FILE_STORAGE_DIRECTORY:data/otel-runtime}
export-endpoint: ${HERTZBEAT_OTLP_HTTP_ENDPOINT:http://127.0.0.1:1157/api/otlp}
otlp-http-exporter-timeout: ${HERTZBEAT_OTLP_HTTP_EXPORTER_TIMEOUT:5s}
token: ${HERTZBEAT_OTLP_TOKEN:}
otlp-grpc-endpoint: ${HERTZBEAT_OTLP_GRPC_LISTEN_ENDPOINT:127.0.0.1:4317}
otlp-http-endpoint: ${HERTZBEAT_OTLP_HTTP_LISTEN_ENDPOINT:127.0.0.1:4318}
@@ -94,6 +94,7 @@ class OtelRuntimeConfigRendererTest {
assertTrue(yaml.contains("initial_interval: 1s"));
assertTrue(yaml.contains("max_interval: 30s"));
assertTrue(yaml.contains("max_elapsed_time: 0s"));
assertTrue(yaml.contains("compression: gzip\n timeout: 5s\n retry_on_failure:"));
assertTrue(yaml.contains(" file_storage:\n directory: ${env:HERTZBEAT_OTEL_FILE_STORAGE_DIR}"));
assertTrue(yaml.contains("timeout: 1s"));
assertTrue(yaml.contains("max_size: 67108864"));
@@ -130,6 +131,60 @@ class OtelRuntimeConfigRendererTest {
assertThrows(IllegalArgumentException.class, () -> new OtelRuntimeConfigRenderer().render(properties));
}
@Test
void rendersOnlyBoundedOtlpHttpExporterRequestTimeout() throws Exception {
OtelRuntimeProperties properties = new OtelRuntimeProperties();
properties.setHome(tempDir);
properties.setConfig(Path.of("conf/runtime.yaml"));
properties.setOtlpHttpExporterTimeout(Duration.ofSeconds(12));
String yaml = Files.readString(new OtelRuntimeConfigRenderer().render(properties));
assertTrue(yaml.contains("compression: gzip\n timeout: 12s\n retry_on_failure:"));
properties.setOtlpHttpExporterTimeout(Duration.ZERO);
assertThrows(IllegalArgumentException.class, () -> new OtelRuntimeConfigRenderer().render(properties));
properties.setOtlpHttpExporterTimeout(Duration.ofMillis(1500));
assertThrows(IllegalArgumentException.class, () -> new OtelRuntimeConfigRenderer().render(properties));
properties.setOtlpHttpExporterTimeout(Duration.ofMinutes(5).plusSeconds(1));
assertThrows(IllegalArgumentException.class, () -> new OtelRuntimeConfigRenderer().render(properties));
}
@Test
void rendersDirectAuthenticatedInternalMetricsOtlpReaderWithoutEmbeddingSecrets() throws Exception {
OtelRuntimeProperties properties = new OtelRuntimeProperties();
properties.setHome(tempDir);
properties.setConfig(Path.of("conf/runtime.yaml"));
properties.setToken("internal-metrics-secret-must-stay-in-environment");
properties.setOtlpHttpExporterTimeout(Duration.ofSeconds(12));
String yaml = Files.readString(new OtelRuntimeConfigRenderer().render(properties));
assertTrue(yaml.contains(" resource:\n"
+ " attributes:\n"
+ " - name: service.name\n"
+ " value: hertzbeat-otel-runtime\n"
+ " - name: service.namespace\n"
+ " value: hertzbeat\n"
+ " - name: hertzbeat.collector.id\n"
+ " value: ${env:HERTZBEAT_COLLECTOR_ID}\n"
+ " - name: hertzbeat.workspace_id\n"
+ " value: ${env:HERTZBEAT_WORKSPACE_ID}\n"));
assertTrue(yaml.contains(" - periodic:\n"
+ " interval: 10000\n"
+ " timeout: 12000\n"
+ " exporter:\n"
+ " otlp:\n"
+ " protocol: http/protobuf\n"
+ " endpoint: ${env:HERTZBEAT_OTLP_HTTP_ENDPOINT}/v1/metrics\n"
+ " headers:\n"
+ " - name: Authorization\n"
+ " value: Bearer ${env:HERTZBEAT_OTLP_TOKEN}\n"
+ " compression: gzip\n"
+ " timeout: 12000\n"));
assertFalse(yaml.contains(properties.getToken()));
}
@Test
void rendersExplicitEnvironmentCloudDetectionAndFixedNoisePreset() throws Exception {
OtelRuntimeProperties properties = new OtelRuntimeProperties();
@@ -38,6 +38,8 @@ class OtelRuntimeConfigurationTest {
assertTrue(context.isRunning());
OtelRuntimeSupervisor supervisor = context.getBean(OtelRuntimeSupervisor.class);
assertEquals(OtelRuntimeState.STOPPED, supervisor.snapshot().state());
assertEquals(Duration.ofSeconds(5),
context.getBean(OtelRuntimeProperties.class).getOtlpHttpExporterTimeout());
assertTrue(context.getBean(CollectorRuntimeStatusProvider.class) instanceof OtelRuntimeStatusProvider);
});
}
@@ -103,6 +105,7 @@ class OtelRuntimeConfigurationTest {
"collector.otel-runtime.otlp-grpc-endpoint=0.0.0.0:4317",
"collector.otel-runtime.otlp-http-endpoint=0.0.0.0:4318",
"collector.otel-runtime.otlp-max-request-mi-b=8",
"collector.otel-runtime.otlp-http-exporter-timeout=12s",
"collector.otel-runtime.otlp-read-timeout=20s",
"collector.otel-runtime.otlp-write-timeout=25s",
"collector.otel-runtime.otlp-idle-timeout=45s",
@@ -120,6 +123,7 @@ class OtelRuntimeConfigurationTest {
assertTrue(properties.isOtlpGatewayEnabled());
assertEquals("0.0.0.0:4317", properties.getOtlpGrpcEndpoint());
assertEquals(8, properties.getOtlpMaxRequestMiB());
assertEquals(Duration.ofSeconds(12), properties.getOtlpHttpExporterTimeout());
assertEquals(Duration.ofSeconds(20), properties.getOtlpReadTimeout());
assertEquals(512, properties.getRuntimeMemoryLimitMiB());
assertEquals(128, properties.getRuntimeMemorySpikeLimitMiB());
@@ -18,6 +18,7 @@
package org.apache.hertzbeat.collector.runtime.otel;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.protobuf.ByteString;
import io.grpc.ManagedChannel;
@@ -74,6 +75,12 @@ class OtelRuntimeProtocolIntegrationTest {
supervisor.start();
assertEquals(OtelRuntimeState.RUNNING, supervisor.snapshot().state());
// Prove direct official service.telemetry export before Java performs any loopback status scrape.
OtelRuntimeTestSupport.await(
() -> capture.bodies("metrics").stream().anyMatch(this::isRuntimeInternalMetricRequest),
Duration.ofSeconds(20));
assertTrue(capture.hasAuthorization("metrics", "Bearer runtime-direct-token"));
sendJsonSignals(properties.getOtlpHttpEndpoint());
SignalRequests protobuf = signalRequests("http-protobuf", 0x31);
@@ -130,6 +137,26 @@ class OtelRuntimeProtocolIntegrationTest {
return observed.state() == ManagedOtelRuntimeStatus.ValueState.AVAILABLE && observed.value() > 0;
}
private boolean isRuntimeInternalMetricRequest(byte[] body) {
try {
ExportMetricsServiceRequest request = ExportMetricsServiceRequest.parseFrom(body);
return request.getResourceMetricsList().stream().anyMatch(resourceMetrics -> {
boolean stableIdentity = resourceMetrics.getResource().getAttributesList().stream()
.anyMatch(attribute -> "service.name".equals(attribute.getKey())
&& "hertzbeat-otel-runtime".equals(attribute.getValue().getStringValue()))
&& resourceMetrics.getResource().getAttributesList().stream()
.anyMatch(attribute -> "hertzbeat.collector.id".equals(attribute.getKey())
&& "collector-protocol-integration".equals(attribute.getValue().getStringValue()));
boolean internalMetric = resourceMetrics.getScopeMetricsList().stream()
.flatMap(scopeMetrics -> scopeMetrics.getMetricsList().stream())
.anyMatch(metric -> metric.getName().startsWith("otelcol_"));
return stableIdentity && internalMetric;
});
} catch (Exception ignored) {
return false;
}
}
private static void sendJsonSignals(String endpoint) throws Exception {
long now = System.currentTimeMillis() * 1_000_000;
sendJson(endpoint, "metrics", """
@@ -72,7 +72,7 @@ class OtelRuntimeSupervisorTest {
properties.setCollectorId("collector-phase0");
properties.setWorkspaceId("workspace-phase0");
properties.setToken("token-phase0");
properties.setExportEndpoint(URI.create("http://127.0.0.1:1157/api/otlp"));
properties.setExportEndpoint(URI.create("http://127.0.0.1:1157/api/otlp/"));
properties.setRestartDelay(Duration.ZERO);
properties.setStartupTimeout(Duration.ofMillis(200));
properties.setHealthTimeout(Duration.ofMillis(50));
@@ -118,6 +118,8 @@ class OtelRuntimeSupervisorTest {
assertEquals("collector-phase0", environment.getValue().get("HERTZBEAT_COLLECTOR_ID"));
assertEquals("workspace-phase0", environment.getValue().get("HERTZBEAT_WORKSPACE_ID"));
assertEquals("token-phase0", environment.getValue().get("HERTZBEAT_OTLP_TOKEN"));
assertEquals("http://127.0.0.1:1157/api/otlp",
environment.getValue().get("HERTZBEAT_OTLP_HTTP_ENDPOINT"));
assertEquals(tempDir.resolve("data/otel-runtime").toString(),
environment.getValue().get("HERTZBEAT_OTEL_FILE_STORAGE_DIR"));
InOrder activationOrder = inOrder(launcher, configTransaction);
@@ -206,6 +208,19 @@ class OtelRuntimeSupervisorTest {
verify(launcher, never()).start(any(), any(), any(), any(), anyMap(), anyBoolean());
}
@Test
void rejectsExportEndpointQueryOrFragmentBeforeLaunchingRuntime() throws Exception {
properties.setExportEndpoint(URI.create("http://127.0.0.1:1157/api/otlp?tenant=unsafe#fragment"));
properties.setRestartDelay(Duration.ofHours(1));
supervisor = new OtelRuntimeSupervisor(properties, resolver, configTransaction, launcher, healthClient);
supervisor.start();
assertEquals(OtelRuntimeState.DEGRADED, supervisor.snapshot().state());
assertTrue(supervisor.snapshot().lastError().contains("query or fragment"));
verify(launcher, never()).start(any(), any(), any(), any(), anyMap(), anyBoolean());
}
@Test
void redactsCredentialsBeforeRecordingOrLoggingSupervisorFailure(CapturedOutput output) throws Exception {
properties.setRestartDelay(Duration.ofHours(1));
@@ -156,6 +156,12 @@ final class OtelRuntimeTestSupport {
.toList();
}
boolean hasAuthorization(String signal, String authorization) {
String path = "/api/otlp/v1/" + signal;
return requests.stream().anyMatch(request -> path.equals(request.path())
&& authorization.equals(request.authorization()));
}
private void capture(HttpExchange exchange) throws IOException {
try (exchange) {
byte[] request = exchange.getRequestBody().readAllBytes();
@@ -167,7 +173,10 @@ final class OtelRuntimeTestSupport {
}
if (retainPayloads) {
payloads.add(new String(request, StandardCharsets.ISO_8859_1));
requests.add(new CapturedRequest(exchange.getRequestURI().getPath(), request.clone()));
requests.add(new CapturedRequest(
exchange.getRequestURI().getPath(),
request.clone(),
exchange.getRequestHeaders().getFirst("Authorization")));
}
exchange.sendResponseHeaders(200, -1);
}
@@ -180,7 +189,7 @@ final class OtelRuntimeTestSupport {
}
}
private record CapturedRequest(String path, byte[] body) {
private record CapturedRequest(String path, byte[] body, String authorization) {
}
}
}
@@ -177,6 +177,11 @@ public interface CommonConstants {
*/
String TRACE_ALERT_THRESHOLD_TYPE_PERIODIC = "periodic_trace";
/**
* Maximum number of rows or series returned by an alert rule preview.
*/
int ALERT_PREVIEW_RESULT_LIMIT = 100;
/**
* Alert mode label key
*/
@@ -74,6 +74,11 @@ public interface ExportFileConstants {
* Export file suffix.
*/
String FILE_SUFFIX = ".yaml";
/**
* Alternative YAML file suffix.
*/
String FILE_SHORT_SUFFIX = ".yml";
}
}
@@ -247,4 +247,21 @@ public final class JsonUtil {
return null;
}
}
/**
* Convert a value without logging conversion details.
* @param fromValue source value
* @param toValueType target type
* @return converted value or null if conversion fails
*/
public static <T> T convertValueQuietly(Object fromValue, Class<T> toValueType) {
if (fromValue == null) {
return null;
}
try {
return OBJECT_MAPPER.convertValue(fromValue, toValueType);
} catch (RuntimeException exception) {
return null;
}
}
}
@@ -27,6 +27,7 @@ import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -60,6 +61,10 @@ public class GeneralConfig {
@Column(length = 8192)
private String content;
@Builder.Default
@Column(name = "config_revision", nullable = false, length = 36)
private String revision = UUID.randomUUID().toString();
@Schema(title = "The creator of this record", example = "tom", accessMode = READ_ONLY)
@CreatedBy
private String creator;
@@ -25,6 +25,7 @@ import org.apache.commons.lang3.StringUtils;
public final class AuthTokenRequestContext {
private static final ThreadLocal<String> WORKSPACE_ID = new ThreadLocal<>();
private static final ThreadLocal<String> AUTHENTICATED_WORKSPACE_ID = new ThreadLocal<>();
private static final ThreadLocal<String> COLLECTOR_ID = new ThreadLocal<>();
private AuthTokenRequestContext() {
@@ -43,6 +44,19 @@ public final class AuthTokenRequestContext {
return WORKSPACE_ID.get();
}
public static void bindAuthenticatedWorkspaceId(String workspaceId) {
String normalized = StringUtils.trimToNull(workspaceId);
if (normalized == null) {
AUTHENTICATED_WORKSPACE_ID.remove();
return;
}
AUTHENTICATED_WORKSPACE_ID.set(AuthTokenScopes.normalizeWorkspaceId(normalized));
}
public static String currentAuthenticatedWorkspaceId() {
return AUTHENTICATED_WORKSPACE_ID.get();
}
public static void bindCollectorId(String collectorId) {
String normalized = StringUtils.trimToNull(collectorId);
if (normalized == null) {
@@ -58,6 +72,7 @@ public final class AuthTokenRequestContext {
public static void clear() {
WORKSPACE_ID.remove();
AUTHENTICATED_WORKSPACE_ID.remove();
COLLECTOR_ID.remove();
}
}
@@ -17,6 +17,7 @@
package org.apache.hertzbeat.common.util;
import java.util.Locale;
import java.util.Map;
import org.apache.hertzbeat.common.constants.ExportFileConstants;
import org.springframework.util.StringUtils;
@@ -37,6 +38,7 @@ public final class FileUtil {
fileTypes = Map.of(
ExportFileConstants.JsonFile.FILE_SUFFIX, ExportFileConstants.JsonFile.TYPE,
ExportFileConstants.YamlFile.FILE_SUFFIX, ExportFileConstants.YamlFile.TYPE,
ExportFileConstants.YamlFile.FILE_SHORT_SUFFIX, ExportFileConstants.YamlFile.TYPE,
ExportFileConstants.ExcelFile.FILE_SUFFIX, ExportFileConstants.ExcelFile.TYPE
);
}
@@ -72,7 +74,7 @@ public final class FileUtil {
if (dotIndex == -1 || dotIndex == fileName.length() - 1) {
return "";
}
var fileNameExtension = fileName.substring(dotIndex);
var fileNameExtension = fileName.substring(dotIndex).toLowerCase(Locale.ROOT);
return fileTypes.get(fileNameExtension);
}
@@ -18,6 +18,7 @@
package org.apache.hertzbeat.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import org.apache.hertzbeat.common.constants.ExportFileConstants;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -70,4 +71,23 @@ class FileUtilTest {
assertEquals("", FileUtil.getFileType(emptyFile));
}
@Test
void getFileTypeSupportsYamlAliasAndCaseInsensitiveKnownExtensions() {
assertEquals(ExportFileConstants.YamlFile.TYPE, FileUtil.getFileType(file("test.yml")));
assertEquals(ExportFileConstants.YamlFile.TYPE, FileUtil.getFileType(file("test.YAML")));
assertEquals(ExportFileConstants.JsonFile.TYPE, FileUtil.getFileType(file("test.JSON")));
assertEquals(ExportFileConstants.ExcelFile.TYPE, FileUtil.getFileType(file("test.XLSX")));
}
@Test
void getFileTypeRejectsUnknownOrDisguisedExtensions() {
assertNull(FileUtil.getFileType(file("test.xls")));
assertNull(FileUtil.getFileType(file("test.yaml.txt")));
assertEquals("", FileUtil.getFileType(file("test")));
}
private static MockMultipartFile file(String originalFilename) {
return new MockMultipartFile("file", originalFilename, null, new byte[0]);
}
}
@@ -0,0 +1,318 @@
/*
* 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.observability.storage;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.manager.Collector;
import org.apache.hertzbeat.manager.dao.CollectorDao;
import org.apache.hertzbeat.manager.instrumentation.intake.CollectorIntakeAdvertisementCodec;
import org.apache.hertzbeat.manager.instrumentation.intake.CollectorIntakeAdvertisementRequest;
import org.apache.hertzbeat.manager.pojo.dto.CollectorInstrumentationIntake.Capability;
import org.apache.hertzbeat.manager.pojo.dto.CollectorInstrumentationIntake.Gateway;
import org.junit.jupiter.api.Test;
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.junit.jupiter.Testcontainers;
/**
* Proves the authenticated public HTTP boundary can ingest, detect, and query all three signals in Greptime.
*/
@SpringBootTest(
classes = org.apache.hertzbeat.startup.HertzBeatApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
"hertzbeat.otlp.grpc.enabled=false",
"otel.sdk.disabled=true",
"scheduler.server.enabled=false",
"spring.datasource.url=jdbc:h2:mem:hertzbeat-authenticated-greptime-e2e;MODE=MYSQL;DB_CLOSE_DELAY=-1",
"warehouse.store.duckdb.enabled=false",
"warehouse.store.greptime.enabled=true",
"warehouse.store.greptime.username=",
"warehouse.store.greptime.password="
})
@Testcontainers
class AuthenticatedGreptimeThreeSignalPublicApiE2eTest extends GreptimeThreeSignalE2eSupport {
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(20);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(REQUEST_TIMEOUT)
.build();
@LocalServerPort
private int serverPort;
@Autowired
private CollectorDao collectorDao;
@Test
void authenticatedPublicApiIngestsDetectsAndQueriesThreeSignalsInGreptime() throws Exception {
advertiseCollectorProfile();
long startedAt = System.currentTimeMillis() - 1_000;
long signalTimeNanos = System.currentTimeMillis() * 1_000_000L;
byte[] detectionBody = detectionBody(startedAt);
assertUnauthenticatedRequestsAreRejected(detectionBody);
String token = login();
postSignal("metrics", metrics(signalTimeNanos).toByteArray(), token);
postSignal("logs", logs(signalTimeNanos).toByteArray(), token);
postSignal("traces", traces(signalTimeNanos).toByteArray(), token);
JsonNode queryContext = awaitReceivedDetection(detectionBody, token);
awaitPublicQueries(queryContext, token);
}
private void assertUnauthenticatedRequestsAreRejected(byte[] detectionBody) throws Exception {
HttpResponse<byte[]> ingest = send(postProtobuf("/api/otlp/v1/metrics", metrics(
System.currentTimeMillis() * 1_000_000L).toByteArray(), null));
assertThat(ingest.statusCode()).isEqualTo(401);
HttpResponse<byte[]> detection = send(postJson("/api/instrumentation/detect", detectionBody, null));
assertThat(detection.statusCode()).isEqualTo(401);
HttpResponse<byte[]> query = send(get("/api/ingestion/otlp/metrics/console", null));
assertThat(query.statusCode()).isEqualTo(401);
}
private String login() throws Exception {
byte[] body = OBJECT_MAPPER.writeValueAsBytes(Map.of(
"type", 0,
"identifier", "admin",
"credential", "hertzbeat"));
HttpResponse<byte[]> response = send(postJson("/api/account/auth/form", body, null));
assertThat(response.statusCode()).isEqualTo(200);
JsonNode envelope = OBJECT_MAPPER.readTree(response.body());
assertThat(envelope.path("code").asInt()).isZero();
String token = envelope.path("data").path("token").asText();
assertThat(token).isNotBlank();
return token;
}
private void postSignal(String signal, byte[] payload, String token) throws Exception {
HttpResponse<byte[]> response = send(postProtobuf("/api/otlp/v1/" + signal, payload, token));
assertThat(response.statusCode()).isBetween(200, 299);
}
private JsonNode awaitReceivedDetection(byte[] detectionBody, String token) {
JsonNode[] received = new JsonNode[1];
await().atMost(Duration.ofSeconds(30)).pollInterval(Duration.ofSeconds(1)).untilAsserted(() -> {
HttpResponse<byte[]> response = send(postJson("/api/instrumentation/detect", detectionBody, token));
assertThat(response.statusCode()).isEqualTo(200);
JsonNode envelope = OBJECT_MAPPER.readTree(response.body());
assertThat(envelope.path("code").asInt()).isZero();
JsonNode data = envelope.path("data");
assertThat(data.path("signals").path("metrics").path("status").asText()).isEqualTo("received");
assertThat(data.path("signals").path("logs").path("status").asText()).isEqualTo("received");
assertThat(data.path("signals").path("traces").path("status").asText()).isEqualTo("received");
assertThat(data.path("queryJumps").isArray()).isTrue();
assertThat(data.path("queryJumps").size()).isEqualTo(3);
assertThat(data.path("queryJumps").findValuesAsText("enabled")).containsOnly("true");
received[0] = data.path("queryJumpContext");
});
return received[0];
}
private void awaitPublicQueries(JsonNode context, String token) {
await().atMost(Duration.ofSeconds(30)).pollInterval(Duration.ofSeconds(1)).untilAsserted(() -> {
assertMetricsQuery(context, token);
assertLogsQuery(context, token);
assertTracesQuery(context, token);
});
}
private void assertMetricsQuery(JsonNode context, String token) throws Exception {
Map<String, String> parameters = commonQueryParameters(context);
parameters.put("query", METRIC_QUERY);
parameters.put("step", "1s");
parameters.put("limit", "20");
JsonNode data = authenticatedGet("/api/ingestion/otlp/metrics/console", parameters, token);
assertThat(data.path("context").path("collectorId").asText()).isEqualTo(COLLECTOR_ID);
assertThat(data.path("context").path("instance").asText()).isEqualTo(INSTANCE_ID);
assertThat(data.path("context").path("endpoint").asText()).isEqualTo(ENDPOINT);
assertThat(data.path("stats").path("nonEmptySeries").asInt()).isPositive();
assertThat(data.path("results").path("frames").isArray()).isTrue();
assertThat(data.path("results").path("frames").size()).isPositive();
}
private void assertLogsQuery(JsonNode context, String token) throws Exception {
Map<String, String> parameters = commonQueryParameters(context);
parameters.put("traceId", TRACE_ID);
parameters.put("spanId", SPAN_ID);
parameters.put("severityText", "INFO");
parameters.put("search", LOG_BODY);
parameters.put("pageIndex", "0");
parameters.put("pageSize", "20");
JsonNode content = authenticatedGet("/api/logs/list", parameters, token).path("content");
assertThat(content.isArray()).isTrue();
assertThat(content.size()).isEqualTo(1);
JsonNode log = content.get(0);
assertThat(log.path("body").asText()).isEqualTo(LOG_BODY);
assertThat(log.path("traceId").asText()).isEqualTo(TRACE_ID);
assertThat(log.path("spanId").asText()).isEqualTo(SPAN_ID);
assertThat(log.path("resource").path("hertzbeat.collector.id").asText()).isEqualTo(COLLECTOR_ID);
assertThat(log.path("resource").path("service.instance.id").asText()).isEqualTo(INSTANCE_ID);
assertThat(log.path("attributes").path("http.route").asText()).isEqualTo(ENDPOINT);
}
private void assertTracesQuery(JsonNode context, String token) throws Exception {
Map<String, String> parameters = commonQueryParameters(context);
parameters.put("traceId", TRACE_ID);
parameters.put("operationName", SPAN_NAME);
parameters.put("spanScope", "root");
parameters.put("pageIndex", "0");
parameters.put("pageSize", "20");
JsonNode content = authenticatedGet("/api/traces/list", parameters, token).path("content");
assertThat(content.isArray()).isTrue();
assertThat(content.size()).isEqualTo(1);
JsonNode trace = content.get(0);
assertThat(trace.path("traceId").asText()).isEqualTo(TRACE_ID);
assertThat(trace.path("rootSpanId").asText()).isEqualTo(SPAN_ID);
assertThat(trace.path("serviceName").asText()).isEqualTo(SERVICE_NAME);
assertThat(trace.path("resourceAttributes").path("hertzbeat.collector.id").asText())
.isEqualTo(COLLECTOR_ID);
assertThat(trace.path("resourceAttributes").path("service.instance.id").asText())
.isEqualTo(INSTANCE_ID);
}
private JsonNode authenticatedGet(String path, Map<String, String> parameters, String token) throws Exception {
HttpResponse<byte[]> response = send(get(path + queryString(parameters), token));
assertThat(response.statusCode()).isEqualTo(200);
JsonNode envelope = OBJECT_MAPPER.readTree(response.body());
assertThat(envelope.path("code").asInt()).isZero();
return envelope.path("data");
}
private Map<String, String> commonQueryParameters(JsonNode context) {
Map<String, String> parameters = new LinkedHashMap<>();
parameters.put("start", context.path("startedAt").asText());
parameters.put("end", Long.toString(context.path("detectedAt").asLong() + 60_000));
parameters.put("serviceName", context.path("serviceName").asText());
parameters.put("serviceNamespace", context.path("serviceNamespace").asText());
parameters.put("environment", context.path("environment").asText());
parameters.put("collectorId", context.path("collectorId").asText());
parameters.put("instance", context.path("serviceInstanceId").asText());
parameters.put("endpoint", context.path("endpoint").asText());
return parameters;
}
private byte[] detectionBody(long startedAt) throws Exception {
Map<String, Object> service = Map.of(
"name", SERVICE_NAME,
"namespace", SERVICE_NAMESPACE,
"environment", ENVIRONMENT,
"serviceInstanceId", INSTANCE_ID,
"endpoint", ENDPOINT);
Map<String, Object> request = new LinkedHashMap<>();
request.put("schemaVersion", 2);
request.put("sourceKind", "quick_start");
request.put("recipeId", "opentelemetry_telemetrygen");
request.put("environment", "vm");
request.put("platform", "linux_amd64");
request.put("service", service);
request.put("intakeProfileId", "collector:" + COLLECTOR_ID);
request.put("startedAt", startedAt);
return OBJECT_MAPPER.writeValueAsBytes(request);
}
/**
* Collector persistence is deterministic test setup only. All behavior under proof starts at the public HTTP
* boundary; no ingestion, detection, or query service is invoked directly.
*/
private void advertiseCollectorProfile() {
String advertisement = new CollectorIntakeAdvertisementCodec().encode(
new CollectorIntakeAdvertisementRequest(
1,
Gateway.COLLECTOR,
java.util.List.of(Capability.OTLP_HTTP_PROTOBUF),
"http://127.0.0.1:4318",
null));
collectorDao.save(Collector.builder()
.name(COLLECTOR_ID)
.ip("127.0.0.1")
.status(CommonConstants.COLLECTOR_STATUS_ONLINE)
.instrumentationIntake(advertisement)
.build());
}
private HttpRequest postProtobuf(String path, byte[] body, String token) {
HttpRequest.Builder builder = request(path)
.header("Content-Type", "application/x-protobuf")
.header("Accept", "application/x-protobuf")
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
return authorize(builder, token).build();
}
private HttpRequest postJson(String path, byte[] body, String token) {
HttpRequest.Builder builder = request(path)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
return authorize(builder, token).build();
}
private HttpRequest get(String path, String token) {
return authorize(request(path).header("Accept", "application/json").GET(), token).build();
}
private HttpRequest.Builder request(String path) {
return HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:" + serverPort + path))
.timeout(REQUEST_TIMEOUT);
}
private HttpRequest.Builder authorize(HttpRequest.Builder builder, String token) {
if (token != null) {
builder.header("Authorization", "Bearer " + token);
}
return builder;
}
private HttpResponse<byte[]> send(HttpRequest request) throws Exception {
return httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
}
private String queryString(Map<String, String> parameters) {
StringBuilder query = new StringBuilder("?");
parameters.forEach((key, value) -> {
if (query.length() > 1) {
query.append('&');
}
query.append(URLEncoder.encode(key, StandardCharsets.UTF_8))
.append('=')
.append(URLEncoder.encode(value, StandardCharsets.UTF_8));
});
return query.toString();
}
}
@@ -0,0 +1,153 @@
/*
* 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.observability.storage;
import com.google.protobuf.ByteString;
import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest;
import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest;
import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest;
import io.opentelemetry.proto.common.v1.AnyValue;
import io.opentelemetry.proto.common.v1.KeyValue;
import io.opentelemetry.proto.logs.v1.LogRecord;
import io.opentelemetry.proto.logs.v1.ResourceLogs;
import io.opentelemetry.proto.logs.v1.ScopeLogs;
import io.opentelemetry.proto.metrics.v1.Gauge;
import io.opentelemetry.proto.metrics.v1.Metric;
import io.opentelemetry.proto.metrics.v1.NumberDataPoint;
import io.opentelemetry.proto.metrics.v1.ResourceMetrics;
import io.opentelemetry.proto.metrics.v1.ScopeMetrics;
import io.opentelemetry.proto.resource.v1.Resource;
import io.opentelemetry.proto.trace.v1.ResourceSpans;
import io.opentelemetry.proto.trace.v1.ScopeSpans;
import io.opentelemetry.proto.trace.v1.Span;
import java.time.Duration;
import java.util.HexFormat;
import java.util.List;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.utility.DockerImageName;
/** Shared real-Greptime container and deterministic OTLP payloads for three-signal E2E tests. */
abstract class GreptimeThreeSignalE2eSupport {
static final String SERVICE_NAME = "checkout-api";
static final String SERVICE_NAMESPACE = "commerce";
static final String ENVIRONMENT = "proof";
static final String COLLECTOR_ID = "collector-e2e";
static final String SERVER_PROFILE_ID = "server-e2e";
static final String INSTANCE_ID = "checkout-e2e-7d9";
static final String ENDPOINT = "/checkout";
static final String TRACE_ID = "0123456789abcdef0123456789abcdef";
static final String SPAN_ID = "0123456789abcdef";
static final String METRIC_NAME = "hertzbeat.e2e.requests";
static final String METRIC_QUERY = "hertzbeat_e2e_requests";
static final String LOG_BODY = "three-signal-e2e";
static final String SPAN_NAME = "GET /checkout";
private static final int GREPTIME_HTTP_PORT = 4000;
private static final int GREPTIME_GRPC_PORT = 4001;
@Container
@SuppressWarnings("resource")
static final GenericContainer<?> GREPTIME = new GenericContainer<>(
DockerImageName.parse("greptime/greptimedb:v1.0.1"))
.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(Duration.ofSeconds(120));
@DynamicPropertySource
static void greptimeProperties(DynamicPropertyRegistry registry) {
registry.add("warehouse.store.greptime.http-endpoint", () -> "http://" + GREPTIME.getHost()
+ ":" + GREPTIME.getMappedPort(GREPTIME_HTTP_PORT));
registry.add("warehouse.store.greptime.grpc-endpoints", () -> GREPTIME.getHost()
+ ":" + GREPTIME.getMappedPort(GREPTIME_GRPC_PORT));
}
static ExportMetricsServiceRequest metrics(long timeNanos) {
NumberDataPoint point = NumberDataPoint.newBuilder()
.setTimeUnixNano(timeNanos)
.setAsInt(1)
.addAttributes(attribute("http.route", ENDPOINT))
.build();
Metric metric = Metric.newBuilder()
.setName(METRIC_NAME)
.setGauge(Gauge.newBuilder().addDataPoints(point))
.build();
return ExportMetricsServiceRequest.newBuilder()
.addResourceMetrics(ResourceMetrics.newBuilder()
.setResource(resource())
.addScopeMetrics(ScopeMetrics.newBuilder().addMetrics(metric)))
.build();
}
static ExportLogsServiceRequest logs(long timeNanos) {
LogRecord record = LogRecord.newBuilder()
.setTimeUnixNano(timeNanos)
.setObservedTimeUnixNano(timeNanos)
.setSeverityText("INFO")
.setBody(AnyValue.newBuilder().setStringValue(LOG_BODY))
.setTraceId(ByteString.copyFrom(HexFormat.of().parseHex(TRACE_ID)))
.setSpanId(ByteString.copyFrom(HexFormat.of().parseHex(SPAN_ID)))
.addAttributes(attribute("http.route", ENDPOINT))
.build();
return ExportLogsServiceRequest.newBuilder()
.addResourceLogs(ResourceLogs.newBuilder()
.setResource(resource())
.addScopeLogs(ScopeLogs.newBuilder().addLogRecords(record)))
.build();
}
static ExportTraceServiceRequest traces(long timeNanos) {
Span span = Span.newBuilder()
.setTraceId(ByteString.copyFrom(HexFormat.of().parseHex(TRACE_ID)))
.setSpanId(ByteString.copyFrom(HexFormat.of().parseHex(SPAN_ID)))
.setName(SPAN_NAME)
.setKind(Span.SpanKind.SPAN_KIND_SERVER)
.setStartTimeUnixNano(timeNanos)
.setEndTimeUnixNano(timeNanos + 10_000_000L)
.addAttributes(attribute("http.route", ENDPOINT))
.build();
return ExportTraceServiceRequest.newBuilder()
.addResourceSpans(ResourceSpans.newBuilder()
.setResource(resource())
.addScopeSpans(ScopeSpans.newBuilder().addSpans(span)))
.build();
}
private static Resource resource() {
return Resource.newBuilder().addAllAttributes(List.of(
attribute("service.name", SERVICE_NAME),
attribute("service.namespace", SERVICE_NAMESPACE),
attribute("deployment.environment.name", ENVIRONMENT),
attribute("service.instance.id", INSTANCE_ID),
attribute("hertzbeat.collector.id", COLLECTOR_ID))).build();
}
private static KeyValue attribute(String key, String value) {
return KeyValue.newBuilder()
.setKey(key)
.setValue(AnyValue.newBuilder().setStringValue(value))
.build();
}
}

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