diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/AlerterWorkerPool.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/AlerterWorkerPool.java index 86b9437c3be..23426ddcf06 100644 --- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/AlerterWorkerPool.java +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/AlerterWorkerPool.java @@ -35,10 +35,12 @@ public class AlerterWorkerPool { private ThreadPoolExecutor workerExecutor; private ThreadPoolExecutor notifyExecutor; + private ThreadPoolExecutor logWorkerExecutor; public AlerterWorkerPool() { initWorkExecutor(); initNotifyExecutor(); + initLogWorkerExecutor(); } private void initWorkExecutor() { @@ -77,6 +79,21 @@ private void initNotifyExecutor() { new ThreadPoolExecutor.AbortPolicy()); } + private void initLogWorkerExecutor() { + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setUncaughtExceptionHandler((thread, throwable) -> { + log.error("Alerter logWorkerExecutor has uncaughtException."); + log.error(throwable.getMessage(), throwable); + }) + .setDaemon(true) + .setNameFormat("log-worker-%d") + .build(); + logWorkerExecutor = new ThreadPoolExecutor(10, 10, 10, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(1000), + threadFactory, + new ThreadPoolExecutor.AbortPolicy()); + } + /** * Run the alerter task * @param runnable task @@ -96,4 +113,13 @@ public void executeNotify(Runnable runnable) throws RejectedExecutionException { notifyExecutor.execute(runnable); } + /** + * Executes the given runnable task using the logWorkerExecutor. + * + * @param runnable the task to be executed + * @throws RejectedExecutionException if the task cannot be accepted for execution + */ + public void executeLogJob(Runnable runnable) throws RejectedExecutionException { + logWorkerExecutor.execute(runnable); + } } diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/JexlExprCalculator.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/JexlExprCalculator.java new file mode 100644 index 00000000000..5e8c9f89e6b --- /dev/null +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/JexlExprCalculator.java @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hertzbeat.alert.calculate; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.jexl3.JexlException; +import org.apache.commons.jexl3.JexlExpression; +import org.apache.hertzbeat.common.util.JexlExpressionRunner; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * JexlExprCalculator is a utility class for evaluating JEXL expressions + */ +@Slf4j +@Component +public class JexlExprCalculator { + /** + * Execute an alert expression + * @param fieldValueMap The field value map for expression evaluation + * @param expr The expression to evaluate + * @param ignoreJexlException Whether to ignore JEXL exceptions + * @return true if the expression matches, false otherwise + */ + public boolean execAlertExpression(Map fieldValueMap, String expr, boolean ignoreJexlException) { + Boolean match; + JexlExpression expression; + try { + expression = JexlExpressionRunner.compile(expr); + } catch (JexlException jexlException) { + log.warn("Alarm Rule: {} Compile Error: {}.", expr, jexlException.getMessage()); + throw jexlException; + } catch (Exception e) { + log.error("Alarm Rule: {} Unknown Error: {}.", expr, e.getMessage()); + throw e; + } + + try { + match = (Boolean) JexlExpressionRunner.evaluate(expression, fieldValueMap); + } catch (JexlException jexlException) { + if (ignoreJexlException) { + log.debug("Alarm Rule: {} Run Error: {}.", expr, jexlException.getMessage()); + } else { + log.error("Alarm Rule: {} Run Error: {}.", expr, jexlException.getMessage()); + } + throw jexlException; + } catch (Exception e) { + log.error("Alarm Rule: {} Unknown Error: {}.", expr, e.getMessage()); + throw e; + } + return match != null && match; + } + +} diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/periodic/LogPeriodicAlertCalculator.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/periodic/LogPeriodicAlertCalculator.java new file mode 100644 index 00000000000..eee041434d6 --- /dev/null +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/periodic/LogPeriodicAlertCalculator.java @@ -0,0 +1,269 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.alert.calculate.periodic; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.hertzbeat.alert.calculate.AlarmCacheManager; +import org.apache.hertzbeat.alert.calculate.JexlExprCalculator; +import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce; +import org.apache.hertzbeat.alert.service.DataSourceService; +import org.apache.hertzbeat.alert.util.AlertTemplateUtil; +import org.apache.hertzbeat.common.constants.CommonConstants; +import org.apache.hertzbeat.common.entity.alerter.AlertDefine; +import org.apache.hertzbeat.common.entity.alerter.SingleAlert; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +/** + * Log Periodic Alert Calculator + */ +@Slf4j +@Component +public class LogPeriodicAlertCalculator { + + private static final String ROWS = "__rows__"; + private static final String ALERT_MODE_LABEL = "alert_mode"; + private static final String ALERT_MODE_GROUP = "group"; + private static final String ALERT_MODE_INDIVIDUAL = "individual"; + + private final DataSourceService dataSourceService; + private final AlarmCommonReduce alarmCommonReduce; + private final AlarmCacheManager alarmCacheManager; + private final JexlExprCalculator jexlExprCalculator; + + + public LogPeriodicAlertCalculator(DataSourceService dataSourceService, AlarmCommonReduce alarmCommonReduce, + AlarmCacheManager alarmCacheManager, JexlExprCalculator jexlExprCalculator) { + this.alarmCommonReduce = alarmCommonReduce; + this.alarmCacheManager = alarmCacheManager; + this.jexlExprCalculator = jexlExprCalculator; + this.dataSourceService = dataSourceService; + } + + public void calculate(AlertDefine define) { + if (!define.isEnable() || StringUtils.isEmpty(define.getExpr())) { + log.error("Log define {} is disabled or expression is empty", define.getName()); + return; + } + try { + doCalculate(define); + } catch (Exception e) { + log.error("Calculate periodic define {} failed: {}", define.getName(), e.getMessage()); + } + } + + private void doCalculate(AlertDefine define) { + try { + // Log-based queries are SQL queries with log-specific expressions + List> results = dataSourceService.query(define.getDatasource(), define.getQueryExpr()); + results = this.calculateLogThreshold(results, define.getExpr()); + + // If no match the expr threshold, the results item map {'value': null} should be null and others field keep + // If results has multi list, should trigger multi alert + if (CollectionUtils.isEmpty(results)) { + return; + } + afterThresholdRuleMatch(results, define); + } catch (Exception ignored) { + // Ignore the query exception eg: no result, timeout, etc + } + } + + /** + * Calculate log threshold evaluation + * @param results Query results from log datasource + * @param expression Alert expression for log analysis + * @return Filtered results that match the log threshold + */ + private List> calculateLogThreshold(List> results, String expression) { + if (CollectionUtils.isEmpty(results)) { + return List.of(); + } + List> newResults = new ArrayList<>(results.size()); + for (Map result : results) { + HashMap fieldMap = new HashMap<>(result); + fieldMap.put(ROWS, results.size()); + boolean match = jexlExprCalculator.execAlertExpression(fieldMap, expression, true); + if (match) { + newResults.add(result); + } + } + return newResults; + } + + + private String getAlertMode(AlertDefine alertDefine) { + String mode = null; + if (alertDefine.getLabels() != null) { + mode = alertDefine.getLabels().get(ALERT_MODE_LABEL); + } + if (mode == null || mode.isEmpty()) { + return ALERT_MODE_GROUP; // Default to group mode if not specified + } else { + return mode; + } + } + + /** + * Handle alert after threshold rule match + */ + private void afterThresholdRuleMatch(List> alertContext, AlertDefine define) { + // Determine alert mode from configuration + String alertMode = getAlertMode(define); + + long currentTime = System.currentTimeMillis(); + + switch (alertMode) { + case ALERT_MODE_INDIVIDUAL: + // Generate individual alerts for each matching log + for (Map context : alertContext) { + generateIndividualAlert(define, context, currentTime); + } + break; + + case ALERT_MODE_GROUP: + // Generate a single alert group for all matching logs + generateGroupAlert(define, alertContext, currentTime); + break; + default: + log.warn("Unknown alert mode for define {}: {}", define.getName(), alertMode); + } + } + + private void generateIndividualAlert(AlertDefine define, Map context, long currentTime) { + + Map alertLabels = new HashMap<>(8); + + Map commonFingerPrints = createCommonFingerprints(define); + alertLabels.putAll(commonFingerPrints); + addContextToMap(context, alertLabels); + + Map fieldValueMap = createFieldValueMap(context, define); + Map alertAnnotations = createAlertAnnotations(define, fieldValueMap); + // Create and send group alert + SingleAlert alert = SingleAlert.builder() + .labels(alertLabels) + .annotations(alertAnnotations) + .content(AlertTemplateUtil.render(define.getTemplate(), fieldValueMap)) + .status(CommonConstants.ALERT_STATUS_FIRING) + .triggerTimes(1) + .startAt(currentTime) + .activeAt(currentTime) + .build(); + + alarmCommonReduce.reduceAndSendAlarm(alert.clone()); + + log.debug("Generated individual alert for define: {}", define.getName()); + } + + private void addContextToMap(Map context, Map alertLabels) { + for (Map.Entry entry : context.entrySet()) { + if (entry.getValue() != null) { + alertLabels.put(entry.getKey(), entry.getValue().toString()); + } + } + } + + private void generateGroupAlert(AlertDefine define, List> alertContext, long currentTime) { + + List alerts = new ArrayList<>(alertContext.size()); + + // Create fingerprints for group alert + Map commonFingerPrints = createCommonFingerprints(define); + + // Add context information to fingerprints + commonFingerPrints.put(ROWS, String.valueOf(alertContext.size())); + commonFingerPrints.put(ALERT_MODE_LABEL, ALERT_MODE_GROUP); + + for (Map context : alertContext) { + + Map alertLabels = new HashMap<>(8); + + alertLabels.putAll(commonFingerPrints); + // add the context to commonFingerPrints + addContextToMap(context, alertLabels); + + Map fieldValueMap = createFieldValueMap(context, define); + Map alertAnnotations = createAlertAnnotations(define, fieldValueMap); + // Create and send group alert + SingleAlert alert = SingleAlert.builder() + .labels(alertLabels) + .annotations(alertAnnotations) + .content(AlertTemplateUtil.render(define.getTemplate(), fieldValueMap)) + .status(CommonConstants.ALERT_STATUS_FIRING) + .triggerTimes(alertContext.size()) + .startAt(currentTime) + .activeAt(currentTime) + .build(); + alerts.add(alert.clone()); + } + alarmCommonReduce.reduceAndSendAlarmGroup(commonFingerPrints, alerts); + + log.debug("Generated group alert for define: {} with {} matching data", + define.getName(), alertContext.size()); + } + + + + private Map createCommonFingerprints(AlertDefine define) { + Map fingerprints = new HashMap<>(8); + fingerprints.put(CommonConstants.LABEL_ALERT_NAME, define.getName()); + fingerprints.put(CommonConstants.LABEL_DEFINE_ID, String.valueOf(define.getId())); + + if (define.getLabels() != null) { + fingerprints.putAll(define.getLabels()); + } + + return fingerprints; + } + + private Map createFieldValueMap(Map context, AlertDefine define) { + Map fieldValueMap = new HashMap<>(8); + for (Map.Entry entry : context.entrySet()) { + if (entry.getValue() != null) { + fieldValueMap.put(entry.getKey(), entry.getValue().toString()); + } + } + if (define.getLabels() != null) { + fieldValueMap.putAll(define.getLabels()); + } + + return fieldValueMap; + } + + private Map createAlertAnnotations(AlertDefine define, Map fieldValueMap) { + Map annotations = new HashMap<>(8); + + if (define.getAnnotations() != null) { + for (Map.Entry entry : define.getAnnotations().entrySet()) { + annotations.put(entry.getKey(), + AlertTemplateUtil.render(entry.getValue(), fieldValueMap)); + } + } + + return annotations; + } + +} \ No newline at end of file diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/PeriodicAlertCalculator.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/periodic/MetricsPeriodicAlertCalculator.java similarity index 64% rename from hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/PeriodicAlertCalculator.java rename to hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/periodic/MetricsPeriodicAlertCalculator.java index aab937667c1..124c2d59aae 100644 --- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/PeriodicAlertCalculator.java +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/periodic/MetricsPeriodicAlertCalculator.java @@ -15,11 +15,12 @@ * limitations under the License. */ -package org.apache.hertzbeat.alert.calculate; +package org.apache.hertzbeat.alert.calculate.periodic; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.hertzbeat.alert.calculate.AlarmCacheManager; import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce; import org.apache.hertzbeat.alert.service.DataSourceService; import org.apache.hertzbeat.alert.util.AlertTemplateUtil; @@ -34,12 +35,11 @@ import java.util.Map; /** - * Periodic Alert Calculator + * Metrics Periodic Alert Calculator */ - @Slf4j @Component -public class PeriodicAlertCalculator { +public class MetricsPeriodicAlertCalculator { private static final String VALUE = "__value__"; private static final String TIMESTAMP = "__timestamp__"; @@ -48,71 +48,82 @@ public class PeriodicAlertCalculator { private final AlarmCommonReduce alarmCommonReduce; private final AlarmCacheManager alarmCacheManager; - public PeriodicAlertCalculator(DataSourceService dataSourceService, AlarmCommonReduce alarmCommonReduce, - AlarmCacheManager alarmCacheManager) { - this.dataSourceService = dataSourceService; + public MetricsPeriodicAlertCalculator(DataSourceService dataSourceService, AlarmCommonReduce alarmCommonReduce, + AlarmCacheManager alarmCacheManager) { this.alarmCommonReduce = alarmCommonReduce; this.alarmCacheManager = alarmCacheManager; + this.dataSourceService = dataSourceService; } - + + /** + * Calculate alerts for the given alert definition + * @param define The alert definition to calculate + */ public void calculate(AlertDefine define) { if (!define.isEnable() || StringUtils.isEmpty(define.getExpr())) { log.error("Periodic define {} is disabled or expression is empty", define.getName()); return; } + long currentTimeMilli = System.currentTimeMillis(); try { - // for prometheus is instant promql query, for db is sql query - // result: [{'value': 100, 'timestamp': 1343554, 'instance': 'node1'},{'value': 200, 'timestamp': 1343555, 'instance': 'node2'}] - // the return result should be matched with threshold - try { - List> results = dataSourceService.calculate( - define.getDatasource(), - define.getExpr() - ); - // if no match the expr threshold, the results item map {'value': null} should be null and others field keep - // if results has multi list, should trigger multi alert - if (CollectionUtils.isEmpty(results)) { - return; - } - for (Map result : results) { - Map fingerPrints = new HashMap<>(8); - // here use the alert name as finger, not care the alert name may be changed - fingerPrints.put(CommonConstants.LABEL_DEFINE_ID, String.valueOf(define.getId())); - fingerPrints.put(CommonConstants.LABEL_ALERT_NAME, define.getName()); - fingerPrints.putAll(define.getLabels()); - for (Map.Entry entry : result.entrySet()) { - if (entry.getValue() != null && !VALUE.equals(entry.getKey()) - && !TIMESTAMP.equals(entry.getKey())) { - fingerPrints.put(entry.getKey(), entry.getValue().toString()); - } - } - if (result.get(VALUE) == null) { - // recovery the alert - handleRecoveredAlert(define.getId(), fingerPrints); - continue; + doCalculate(define, currentTimeMilli); + } catch (Exception e) { + log.error("Calculate periodic define {} failed: {}", define.getName(), e.getMessage()); + } + } + + private void doCalculate(AlertDefine define, long currentTimeMilli) { + try { + List> results = dataSourceService.calculate( + define.getDatasource(), + define.getExpr() + ); + // If no match the expr threshold, the results item map {'value': null} should be null and others field keep + // If results has multi list, should trigger multi alert + if (CollectionUtils.isEmpty(results)) { + return; + } + + for (Map result : results) { + Map fingerPrints = new HashMap<>(8); + // Here use the alert name as finger, not care the alert name may be changed + fingerPrints.put(CommonConstants.LABEL_DEFINE_ID, String.valueOf(define.getId())); + fingerPrints.put(CommonConstants.LABEL_ALERT_NAME, define.getName()); + fingerPrints.putAll(define.getLabels()); + for (Map.Entry entry : result.entrySet()) { + if (entry.getValue() != null && !VALUE.equals(entry.getKey()) + && !TIMESTAMP.equals(entry.getKey())) { + fingerPrints.put(entry.getKey(), entry.getValue().toString()); } - Map fieldValueMap = new HashMap<>(8); - fieldValueMap.putAll(define.getLabels()); - fieldValueMap.put(CommonConstants.LABEL_ALERT_NAME, define.getName()); - for (Map.Entry entry : result.entrySet()) { - if (entry.getValue() != null) { - fieldValueMap.put(entry.getKey(), entry.getValue()); - } + } + + if (result.get(VALUE) == null) { + // Recovery the alert + handleRecoveredAlert(define.getId(), fingerPrints); + continue; + } + + Map fieldValueMap = new HashMap<>(8); + fieldValueMap.putAll(define.getLabels()); + fieldValueMap.put(CommonConstants.LABEL_ALERT_NAME, define.getName()); + for (Map.Entry entry : result.entrySet()) { + if (entry.getValue() != null) { + fieldValueMap.put(entry.getKey(), entry.getValue()); } - afterThresholdRuleMatch(currentTimeMilli, fingerPrints, fieldValueMap, define); } - } catch (Exception ignored) { - // ignore the query exception eg: no result, timeout, etc - return; + afterThresholdRuleMatch(currentTimeMilli, fingerPrints, fieldValueMap, define); } - } catch (Exception e) { - log.error("Calculate periodic define {} failed: {}", define.getName(), e.getMessage()); + } catch (Exception ignored) { + // Ignore the query exception eg: no result, timeout, etc } } + /** + * Handle alert after threshold rule match + */ private void afterThresholdRuleMatch(long currentTimeMilli, Map fingerPrints, - Map fieldValueMap, AlertDefine define) { + Map fieldValueMap, AlertDefine define) { Long defineId = define.getId(); String fingerprint = AlertUtil.calculateFingerprint(fingerPrints); SingleAlert existingAlert = alarmCacheManager.getPending(defineId, fingerprint); @@ -124,7 +135,6 @@ private void afterThresholdRuleMatch(long currentTimeMilli, Map // First time triggering alert, create new alert and set to pending status SingleAlert newAlert = SingleAlert.builder() .labels(labels) - // todo render var content in annotations .annotations(define.getAnnotations()) .content(AlertTemplateUtil.render(define.getTemplate(), fieldValueMap)) .status(CommonConstants.ALERT_STATUS_PENDING) @@ -158,11 +168,13 @@ private void afterThresholdRuleMatch(long currentTimeMilli, Map } } + /** + * Handle recovered alert + */ private void handleRecoveredAlert(Long defineId, Map fingerprints) { String fingerprint = AlertUtil.calculateFingerprint(fingerprints); SingleAlert firingAlert = alarmCacheManager.removeFiring(defineId, fingerprint); if (firingAlert != null) { - // todo consider multi times to tig for resolved alert firingAlert.setTriggerTimes(1); firingAlert.setEndAt(System.currentTimeMillis()); firingAlert.setStatus(CommonConstants.ALERT_STATUS_RESOLVED); @@ -171,4 +183,4 @@ private void handleRecoveredAlert(Long defineId, Map fingerprint alarmCacheManager.removePending(defineId, fingerprint); } -} +} \ No newline at end of file diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/PeriodicAlertRuleScheduler.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/periodic/PeriodicAlertRuleScheduler.java similarity index 68% rename from hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/PeriodicAlertRuleScheduler.java rename to hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/periodic/PeriodicAlertRuleScheduler.java index 95a3b69208e..978fb3d234a 100644 --- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/PeriodicAlertRuleScheduler.java +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/periodic/PeriodicAlertRuleScheduler.java @@ -15,10 +15,13 @@ * limitations under the License. */ -package org.apache.hertzbeat.alert.calculate; +package org.apache.hertzbeat.alert.calculate.periodic; -import static org.apache.hertzbeat.common.constants.CommonConstants.ALERT_THRESHOLD_TYPE_PERIODIC; +import static org.apache.hertzbeat.common.constants.CommonConstants.LOG_ALERT_THRESHOLD_TYPE_PERIODIC; +import static org.apache.hertzbeat.common.constants.CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_PERIODIC; import com.google.common.util.concurrent.ThreadFactoryBuilder; + +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -39,13 +42,15 @@ @Component public class PeriodicAlertRuleScheduler implements CommandLineRunner { - private final PeriodicAlertCalculator calculator; + private final MetricsPeriodicAlertCalculator metricsCalculator; + private final LogPeriodicAlertCalculator logCalculator; private final AlertDefineDao alertDefineDao; private final ScheduledExecutorService scheduledExecutor; private final Map> scheduledFutures; - public PeriodicAlertRuleScheduler(PeriodicAlertCalculator calculator, AlertDefineDao alertDefineDao) { - this.calculator = calculator; + public PeriodicAlertRuleScheduler(MetricsPeriodicAlertCalculator metricsCalculator, LogPeriodicAlertCalculator logCalculator, AlertDefineDao alertDefineDao) { + this.metricsCalculator = metricsCalculator; + this.logCalculator = logCalculator; this.alertDefineDao = alertDefineDao; ThreadFactory threadFactory = new ThreadFactoryBuilder() .setUncaughtExceptionHandler((thread, throwable) -> { @@ -76,9 +81,14 @@ public void updateSchedule(AlertDefine rule) { return; } cancelSchedule(rule.getId()); - if (rule.getType().equals(ALERT_THRESHOLD_TYPE_PERIODIC)) { + if (rule.getType().equals(METRIC_ALERT_THRESHOLD_TYPE_PERIODIC) + || rule.getType().equals(LOG_ALERT_THRESHOLD_TYPE_PERIODIC)) { ScheduledFuture future = scheduledExecutor.scheduleAtFixedRate(() -> { - calculator.calculate(rule); + if (rule.getType().equals(METRIC_ALERT_THRESHOLD_TYPE_PERIODIC)) { + metricsCalculator.calculate(rule); + } else if (rule.getType().equals(LOG_ALERT_THRESHOLD_TYPE_PERIODIC)) { + logCalculator.calculate(rule); + } }, 0, rule.getPeriod(), java.util.concurrent.TimeUnit.SECONDS); scheduledFutures.put(rule.getId(), future); } @@ -87,7 +97,11 @@ public void updateSchedule(AlertDefine rule) { @Override public void run(String... args) throws Exception { log.info("Starting periodic alert rule scheduler..."); - List periodicRules = alertDefineDao.findAlertDefinesByTypeAndEnableTrue(ALERT_THRESHOLD_TYPE_PERIODIC); + List metricsPeriodicRules = alertDefineDao.findAlertDefinesByTypeAndEnableTrue(METRIC_ALERT_THRESHOLD_TYPE_PERIODIC); + List logPeriodicRules = alertDefineDao.findAlertDefinesByTypeAndEnableTrue(LOG_ALERT_THRESHOLD_TYPE_PERIODIC); + List periodicRules = new ArrayList<>(metricsPeriodicRules.size() + logPeriodicRules.size()); + periodicRules.addAll(metricsPeriodicRules); + periodicRules.addAll(logPeriodicRules); for (AlertDefine rule : periodicRules) { updateSchedule(rule); } diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/RealTimeAlertCalculator.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/MetricsRealTimeAlertCalculator.java similarity index 87% rename from hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/RealTimeAlertCalculator.java rename to hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/MetricsRealTimeAlertCalculator.java index ffdd9edfc57..8dd44014187 100644 --- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/RealTimeAlertCalculator.java +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/MetricsRealTimeAlertCalculator.java @@ -15,8 +15,7 @@ * limitations under the License. */ -package org.apache.hertzbeat.alert.calculate; - +package org.apache.hertzbeat.alert.calculate.realtime; import java.util.Collections; import java.util.HashMap; @@ -28,10 +27,10 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.jexl3.JexlException; -import org.apache.commons.jexl3.JexlExpression; import org.apache.commons.lang3.StringUtils; import org.apache.hertzbeat.alert.AlerterWorkerPool; +import org.apache.hertzbeat.alert.calculate.AlarmCacheManager; +import org.apache.hertzbeat.alert.calculate.JexlExprCalculator; import org.apache.hertzbeat.alert.dao.SingleAlertDao; import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce; import org.apache.hertzbeat.alert.service.AlertDefineService; @@ -43,7 +42,6 @@ import org.apache.hertzbeat.common.entity.message.CollectRep; import org.apache.hertzbeat.common.queue.CommonDataQueue; import org.apache.hertzbeat.common.util.CommonUtil; -import org.apache.hertzbeat.common.util.JexlExpressionRunner; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; @@ -53,10 +51,10 @@ */ @Component @Slf4j -public class RealTimeAlertCalculator { - +public class MetricsRealTimeAlertCalculator { + private static final int CALCULATE_THREADS = 3; - + private static final String KEY_INSTANCE = "__instance__"; private static final String KEY_INSTANCE_NAME = "__instancename__"; private static final String KEY_INSTANCE_HOST = "__instancehost__"; @@ -81,38 +79,47 @@ public class RealTimeAlertCalculator { private final AlertDefineService alertDefineService; private final AlarmCommonReduce alarmCommonReduce; private final AlarmCacheManager alarmCacheManager; + private final JexlExprCalculator jexlExprCalculator; + @Autowired - public RealTimeAlertCalculator(AlerterWorkerPool workerPool, CommonDataQueue dataQueue, - AlertDefineService alertDefineService, SingleAlertDao singleAlertDao, - AlarmCommonReduce alarmCommonReduce, AlarmCacheManager alarmCacheManager) { - this(workerPool, dataQueue, alertDefineService, singleAlertDao, alarmCommonReduce, alarmCacheManager, true); + public MetricsRealTimeAlertCalculator(AlerterWorkerPool workerPool, CommonDataQueue dataQueue, + AlertDefineService alertDefineService, SingleAlertDao singleAlertDao, + AlarmCommonReduce alarmCommonReduce, AlarmCacheManager alarmCacheManager, + JexlExprCalculator jexlExprCalculator) { + this(workerPool, dataQueue, alertDefineService, singleAlertDao, alarmCommonReduce, alarmCacheManager, jexlExprCalculator, true); } /** - * Constructor for RealTimeAlertCalculator with a toggle to control whether to start alert calculation threads. + * Constructor for MetricsRealTimeAlertCalculator with a toggle to control whether to start alert calculation threads. * * @param workerPool The worker pool used for concurrent alert calculation. * @param dataQueue The queue from which metric data is pulled and pushed. * @param alertDefineService The service providing alert definition rules. * @param singleAlertDao The DAO for fetching persisted alert states from storage. * @param alarmCommonReduce The component responsible for reducing and sending alerts. + * @param alarmCacheManager The cache manager for managing alert states. * @param start If true, the alert calculation threads will start automatically; * set to false to disable thread start (useful for unit testing). */ - public RealTimeAlertCalculator(AlerterWorkerPool workerPool, CommonDataQueue dataQueue, - AlertDefineService alertDefineService, SingleAlertDao singleAlertDao, - AlarmCommonReduce alarmCommonReduce, AlarmCacheManager alarmCacheManager, boolean start) { + public MetricsRealTimeAlertCalculator(AlerterWorkerPool workerPool, CommonDataQueue dataQueue, + AlertDefineService alertDefineService, SingleAlertDao singleAlertDao, + AlarmCommonReduce alarmCommonReduce, AlarmCacheManager alarmCacheManager, + JexlExprCalculator jexlExprCalculator, boolean start) { this.workerPool = workerPool; this.dataQueue = dataQueue; this.alarmCommonReduce = alarmCommonReduce; this.alertDefineService = alertDefineService; this.alarmCacheManager = alarmCacheManager; + this.jexlExprCalculator = jexlExprCalculator; if (start) { startCalculate(); } } + /** + * Start the alert calculation threads + */ public void startCalculate() { Runnable runnable = () -> { while (!Thread.currentThread().isInterrupted()) { @@ -132,7 +139,7 @@ public void startCalculate() { } } - private void calculate(CollectRep.MetricsData metricsData) { + protected void calculate(CollectRep.MetricsData metricsData) { long currentTimeMilli = System.currentTimeMillis(); String instance = String.valueOf(metricsData.getId()); String instanceName = metricsData.getInstanceName(); @@ -146,7 +153,7 @@ private void calculate(CollectRep.MetricsData metricsData) { int code = metricsData.getCode().getNumber(); Map labels = metricsData.getLabels(); Map annotations = metricsData.getAnnotations(); - List thresholds = this.alertDefineService.getRealTimeAlertDefines(); + List thresholds = this.alertDefineService.getMetricsRealTimeAlertDefines(); // Filter thresholds by app, metrics, labels and instance thresholds = filterThresholdsByAppAndMetrics(thresholds, app, metrics, labels, instance, priority); if (thresholds.isEmpty()) { @@ -198,7 +205,7 @@ private void calculate(CollectRep.MetricsData metricsData) { { // trigger the expr before the metrics data, due the available up down or others try { - boolean match = execAlertExpression(fieldValueMap, expr, true); + boolean match = jexlExprCalculator.execAlertExpression(fieldValueMap, expr, true); try { if (match) { // If the threshold rule matches, the number of times the threshold has been triggered is determined and an alarm is triggered @@ -253,7 +260,7 @@ private void calculate(CollectRep.MetricsData metricsData) { } } try { - boolean match = execAlertExpression(fieldValueMap, expr, false); + boolean match = jexlExprCalculator.execAlertExpression(fieldValueMap, expr, false); try { if (match) { afterThresholdRuleMatch(defineId, currentTimeMilli, fingerPrints, fieldValueMap, define, annotations); @@ -274,8 +281,9 @@ private void calculate(CollectRep.MetricsData metricsData) { * @param thresholds Alert definitions to filter * @param app Current app name * @param metrics Current metrics name + * @param labels Current labels * @param instance Current instance id - * @param priority Current priority + * @param priority Current priority * @return Filtered alert definitions */ public List filterThresholdsByAppAndMetrics(List thresholds, String app, String metrics, Map labels, String instance, int priority) { @@ -291,7 +299,7 @@ public List filterThresholdsByAppAndMetrics(List thres if (!appMatcher.find() || !app.equals(appMatcher.group(1))) { return false; } - + // Extract and check available - required if (priority != 0) { Matcher availableMatcher = AVAILABLE_PATTERN.matcher(expr); @@ -313,7 +321,7 @@ public List filterThresholdsByAppAndMetrics(List thres if (!instanceMatcher.find() && !labelMatcher.find()) { return true; } - + // Reset matcher to check all instances instanceMatcher.reset(); labelMatcher.reset(); @@ -336,6 +344,9 @@ public List filterThresholdsByAppAndMetrics(List thres .collect(Collectors.toList()); } + /** + * Handle recovered alert + */ private void handleRecoveredAlert(Long defineId, Map fingerprints) { String fingerprint = AlertUtil.calculateFingerprint(fingerprints); SingleAlert firingAlert = alarmCacheManager.removeFiring(defineId, fingerprint); @@ -349,9 +360,14 @@ private void handleRecoveredAlert(Long defineId, Map fingerprint alarmCacheManager.removePending(defineId, fingerprint); } + + /** + * Handle alert after threshold rule match + */ private void afterThresholdRuleMatch(long defineId, long currentTimeMilli, Map fingerPrints, Map fieldValueMap, AlertDefine define, Map annotations) { + // fingerprint for the padding cache String fingerprint = AlertUtil.calculateFingerprint(fingerPrints); SingleAlert existingAlert = alarmCacheManager.getPending(defineId, fingerprint); fieldValueMap.putAll(define.getLabels()); @@ -377,11 +393,11 @@ private void afterThresholdRuleMatch(long defineId, long currentTimeMilli, Map= requiredTimes) { // Reached trigger times threshold, change to firing status @@ -407,35 +423,6 @@ private void afterThresholdRuleMatch(long defineId, long currentTimeMilli, Map fieldValueMap, String expr, boolean ignoreJexlException) { - Boolean match; - JexlExpression expression; - try { - expression = JexlExpressionRunner.compile(expr); - } catch (JexlException jexlException) { - log.warn("Alarm Rule: {} Compile Error: {}.", expr, jexlException.getMessage()); - throw jexlException; - } catch (Exception e) { - log.error("Alarm Rule: {} Unknown Error: {}.", expr, e.getMessage()); - throw e; - } - - try { - match = (Boolean) JexlExpressionRunner.evaluate(expression, fieldValueMap); - } catch (JexlException jexlException) { - if (ignoreJexlException) { - log.debug("Alarm Rule: {} Run Error: {}.", expr, jexlException.getMessage()); - } else { - log.error("Alarm Rule: {} Run Error: {}.", expr, jexlException.getMessage()); - } - throw jexlException; - } catch (Exception e) { - log.error("Alarm Rule: {} Unknown Error: {}.", expr, e.getMessage()); - throw e; - } - return match != null && match; - } - private Set kvLabelsToKvStringSet(Map labels) { if (labels == null || labels.isEmpty()) { return Collections.singleton(""); diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/WindowedLogRealTimeAlertCalculator.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/WindowedLogRealTimeAlertCalculator.java new file mode 100644 index 00000000000..bceed5e3c0a --- /dev/null +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/WindowedLogRealTimeAlertCalculator.java @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.alert.calculate.realtime; + +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.alert.calculate.realtime.window.LogWorker; +import org.apache.hertzbeat.alert.calculate.realtime.window.TimeService; +import org.apache.hertzbeat.common.entity.log.LogEntry; +import org.apache.hertzbeat.common.queue.CommonDataQueue; +import org.springframework.stereotype.Component; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * WindowedLogRealTimeAlertCalculator - Single entry point for log stream processing + * Responsible for: + * 1. Reading from original log stream + * 2. Extracting event timestamps + * 3. Maintaining maxTimestamp for watermark generation + * 4. Distributing logs to workers + */ +@Component +@Slf4j +public class WindowedLogRealTimeAlertCalculator implements Runnable { + + private static final int CALCULATE_THREADS = 3; + + private final CommonDataQueue dataQueue; + private final TimeService timeService; + private ThreadPoolExecutor dispatcherExecutor; + private final LogWorker logWorker; + + public WindowedLogRealTimeAlertCalculator(CommonDataQueue dataQueue, TimeService timeService, LogWorker logWorker) { + this.dataQueue = dataQueue; + this.timeService = timeService; + this.logWorker = logWorker; + } + + @Override + public void run() { + while (!Thread.currentThread().isInterrupted()) { + try { + LogEntry logEntry = dataQueue.pollLogEntry(); + if (logEntry != null) { + processLogEntry(logEntry); + dataQueue.sendLogEntryToStorage(logEntry); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + log.error("Error in log dispatch loop: {}", e.getMessage(), e); + } + } + } + + private void processLogEntry(LogEntry logEntry) { + // Extract event timestamp + long eventTimestamp = extractEventTimestamp(logEntry); + + // Update max timestamp + timeService.updateMaxTimestamp(eventTimestamp); + logWorker.reduceAndSendLogTask(logEntry); + } + + private long extractEventTimestamp(LogEntry logEntry) { + if (logEntry.getTimeUnixNano() != null && logEntry.getTimeUnixNano() != 0) { + return logEntry.getTimeUnixNano() / 1_000_000; // Convert to milliseconds + } + if (logEntry.getObservedTimeUnixNano() != null && logEntry.getObservedTimeUnixNano() != 0) { + return logEntry.getObservedTimeUnixNano() / 1_000_000; // Convert to milliseconds + } + return System.currentTimeMillis(); + } + + @PostConstruct + public void start() { + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setUncaughtExceptionHandler((thread, throwable) -> { + log.error("Alerter workerExecutor has uncaughtException."); + log.error(throwable.getMessage(), throwable); + }) + .setDaemon(true) + .setNameFormat("log-dispatcher-%d") + .build(); + // Create dispatcher thread executor + this.dispatcherExecutor = new ThreadPoolExecutor( + CALCULATE_THREADS, + CALCULATE_THREADS, + 10, + TimeUnit.SECONDS, + new LinkedBlockingQueue<>(), + threadFactory, + new ThreadPoolExecutor.AbortPolicy() + ); + for (int i = 0; i < CALCULATE_THREADS; i++) { + dispatcherExecutor.execute(this); + } + } + + @PreDestroy + public void stop() { + if (dispatcherExecutor != null) { + dispatcherExecutor.shutdownNow(); + } + } +} \ No newline at end of file diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/AlarmEvaluator.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/AlarmEvaluator.java new file mode 100644 index 00000000000..180855dea75 --- /dev/null +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/AlarmEvaluator.java @@ -0,0 +1,320 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.alert.calculate.realtime.window; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce; +import org.springframework.stereotype.Component; + +import org.apache.hertzbeat.alert.util.AlertTemplateUtil; +import org.apache.hertzbeat.common.constants.CommonConstants; +import org.apache.hertzbeat.common.entity.alerter.AlertDefine; +import org.apache.hertzbeat.common.entity.alerter.SingleAlert; +import org.apache.hertzbeat.common.entity.log.LogEntry; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Alarm Evaluator - Final alarm logic trigger + * Responsible for: + * 1. Receiving closed window data from WindowAggregator + * 2. Applying alert logic (count threshold, alert mode) + * 3. Generating individual alerts or alert groups + * 4. Sending alerts to AlarmCommonReduce + */ +@Component +@Slf4j +public class AlarmEvaluator { + + private static final String ALERT_MODE_LABEL = "alert_mode"; + private static final String WINDOW_START_TIME = "window_start_time"; + private static final String WINDOW_END_TIME = "window_end_time"; + private static final String MATCHING_LOGS_COUNT = "matching_logs_count"; + private static final String ALERT_MODE_GROUP = "group"; + private static final String ALERT_MODE_INDIVIDUAL = "individual"; + + private final AlarmCommonReduce alarmCommonReduce; + private ThreadPoolExecutor workerExecutor; + + public AlarmEvaluator(AlarmCommonReduce alarmCommonReduce) { + this.alarmCommonReduce = alarmCommonReduce; + initAlarmEvaluator(); + } + + public void initAlarmEvaluator() { + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setUncaughtExceptionHandler((thread, throwable) -> { + log.error("alerter-reduce-worker has uncaughtException."); + log.error(throwable.getMessage(), throwable); + }) + .setDaemon(true) + .setNameFormat("alerter-reduce-worker-%d") + .build(); + workerExecutor = new ThreadPoolExecutor(2, + 10, + 10, + TimeUnit.SECONDS, + new LinkedBlockingQueue<>(), + threadFactory, + new ThreadPoolExecutor.AbortPolicy()); + } + + public void sendAndProcessWindowData(WindowAggregator.WindowData windowData) { + workerExecutor.execute(processWindowData(windowData)); + } + + private Runnable processWindowData(WindowAggregator.WindowData windowData) { + return () -> { + AlertDefine alertDefine = windowData.getAlertDefine(); + List matchingLogs = windowData.getMatchingLogs(); + + if (matchingLogs.isEmpty()) { + return; + } + + // Check if count threshold is met + int requiredTimes = alertDefine.getTimes() != null ? alertDefine.getTimes() : 1; + if (matchingLogs.size() < requiredTimes) { + log.debug("Window {} has {} matching logs, but requires {} times", + windowData.getWindowKey(), matchingLogs.size(), requiredTimes); + return; + } + + // Determine alert mode from configuration + String alertMode = getAlertMode(alertDefine); + + long currentTime = System.currentTimeMillis(); + + switch (alertMode) { + case ALERT_MODE_INDIVIDUAL: + // Generate individual alerts for each matching log + for (MatchingLogEvent matchingLog : matchingLogs) { + generateIndividualAlert(matchingLog, currentTime); + } + break; + + case ALERT_MODE_GROUP: + // Generate a single alert group for all matching logs + generateGroupAlert(windowData, matchingLogs, currentTime); + break; + default: + log.warn("Unknown alert mode for define {}: {}", alertDefine.getName(), alertMode); + } + }; + } + + private void generateIndividualAlert(MatchingLogEvent matchingLog, long currentTime) { + AlertDefine define = matchingLog.getAlertDefine(); + LogEntry logEntry = matchingLog.getLogEntry(); + + Map alertLabels = new HashMap<>(8); + + // Create fingerprints for group alert + Map commonFingerPrints = createCommonFingerprints(define); + alertLabels.putAll(commonFingerPrints); + // add the log data to commonFingerPrints + addLogEntryToMap(logEntry, alertLabels); + + Map fieldValueMap = createFieldValueMap(logEntry, define); + Map alertAnnotations = createAlertAnnotations(define, fieldValueMap); + // Create and send group alert + SingleAlert alert = SingleAlert.builder() + .labels(alertLabels) + .annotations(alertAnnotations) + .content(AlertTemplateUtil.render(define.getTemplate(), fieldValueMap)) + .status(CommonConstants.ALERT_STATUS_FIRING) + .triggerTimes(1) + .startAt(currentTime) + .activeAt(currentTime) + .build(); + + alarmCommonReduce.reduceAndSendAlarm(alert.clone()); + + log.debug("Generated individual alert for define: {}", define.getName()); + } + + private void generateGroupAlert(WindowAggregator.WindowData windowData, + List matchingLogs, long currentTime) { + + List alerts = new ArrayList<>(matchingLogs.size()); + AlertDefine define = windowData.getAlertDefine(); + + // Create fingerprints for group alert + Map commonFingerPrints = createCommonFingerprints(define); + + // Add window information to fingerprints + commonFingerPrints.put(WINDOW_START_TIME, String.valueOf(windowData.getStartTime())); + commonFingerPrints.put(WINDOW_END_TIME, String.valueOf(windowData.getEndTime())); + commonFingerPrints.put(ALERT_MODE_LABEL, ALERT_MODE_GROUP); + commonFingerPrints.put(MATCHING_LOGS_COUNT, String.valueOf(matchingLogs.size())); + + for (MatchingLogEvent event: matchingLogs) { + LogEntry logEntry = event.getLogEntry(); + + Map alertLabels = new HashMap<>(8); + + alertLabels.putAll(commonFingerPrints); + // add the log data to commonFingerPrints + addLogEntryToMap(logEntry, alertLabels); + + Map fieldValueMap = createFieldValueMap(logEntry, define); + Map alertAnnotations = createAlertAnnotations(define, fieldValueMap); + // Create and send group alert + SingleAlert alert = SingleAlert.builder() + .labels(alertLabels) + .annotations(alertAnnotations) + .content(AlertTemplateUtil.render(define.getTemplate(), fieldValueMap)) + .status(CommonConstants.ALERT_STATUS_FIRING) + .triggerTimes(matchingLogs.size()) + .startAt(currentTime) + .activeAt(currentTime) + .build(); + alerts.add(alert.clone()); + } + alarmCommonReduce.reduceAndSendAlarmGroup(commonFingerPrints, alerts); + + log.debug("Generated group alert for define: {} with {} matching logs", + define.getName(), matchingLogs.size()); + } + + private String getAlertMode(AlertDefine alertDefine) { + String mode = null; + if (alertDefine.getLabels() != null) { + mode = alertDefine.getLabels().get(ALERT_MODE_LABEL); + } + if (mode == null || mode.isEmpty()) { + return ALERT_MODE_GROUP; // Default to group mode if not specified + } else { + return mode; + } + } + + private Map createCommonFingerprints(AlertDefine define) { + Map fingerprints = new HashMap<>(8); + fingerprints.put(CommonConstants.LABEL_ALERT_NAME, define.getName()); + fingerprints.put(CommonConstants.LABEL_DEFINE_ID, String.valueOf(define.getId())); + + if (define.getLabels() != null) { + fingerprints.putAll(define.getLabels()); + } + + return fingerprints; + } + + private Map createFieldValueMap(LogEntry logEntry, AlertDefine define) { + Map fieldValueMap = new HashMap<>(8); + fieldValueMap.put("log", logEntry); + + if (define.getLabels() != null) { + fieldValueMap.putAll(define.getLabels()); + } + + return fieldValueMap; + } + + private Map createAlertAnnotations(AlertDefine define, Map fieldValueMap) { + Map annotations = new HashMap<>(8); + + if (define.getAnnotations() != null) { + for (Map.Entry entry : define.getAnnotations().entrySet()) { + annotations.put(entry.getKey(), + AlertTemplateUtil.render(entry.getValue(), fieldValueMap)); + } + } + + return annotations; + } + + /** + * Add the content from LogEntry object (except timestamp) to commonFingerPrints + * + * @param logEntry log entry object + * @param context context + */ + private void addLogEntryToMap(LogEntry logEntry, Map context) { + // Add basic fields + if (logEntry.getSeverityNumber() != null) { + context.put("severityNumber", String.valueOf(logEntry.getSeverityNumber())); + } + if (logEntry.getSeverityText() != null) { + context.put("severityText", logEntry.getSeverityText()); + } + if (logEntry.getBody() != null) { + context.put("body", String.valueOf(logEntry.getBody())); + } + if (logEntry.getDroppedAttributesCount() != null) { + context.put("droppedAttributesCount", String.valueOf(logEntry.getDroppedAttributesCount())); + } + if (logEntry.getTraceId() != null) { + context.put("traceId", logEntry.getTraceId()); + } + if (logEntry.getSpanId() != null) { + context.put("spanId", logEntry.getSpanId()); + } + if (logEntry.getTraceFlags() != null) { + context.put("traceFlags", String.valueOf(logEntry.getTraceFlags())); + } + + // Add attributes + if (logEntry.getAttributes() != null && !logEntry.getAttributes().isEmpty()) { + for (Map.Entry entry : logEntry.getAttributes().entrySet()) { + if (entry.getValue() != null) { + context.put("attr_" + entry.getKey(), String.valueOf(entry.getValue())); + } + } + } + + // Add resource + if (logEntry.getResource() != null && !logEntry.getResource().isEmpty()) { + for (Map.Entry entry : logEntry.getResource().entrySet()) { + if (entry.getValue() != null) { + context.put("resource_" + entry.getKey(), String.valueOf(entry.getValue())); + } + } + } + + // Add instrumentationScope + if (logEntry.getInstrumentationScope() != null) { + LogEntry.InstrumentationScope scope = logEntry.getInstrumentationScope(); + if (scope.getName() != null) { + context.put("scope_name", scope.getName()); + } + if (scope.getVersion() != null) { + context.put("scope_version", scope.getVersion()); + } + if (scope.getDroppedAttributesCount() != null) { + context.put("scope_droppedAttributesCount", String.valueOf(scope.getDroppedAttributesCount())); + } + if (scope.getAttributes() != null && !scope.getAttributes().isEmpty()) { + for (Map.Entry entry : scope.getAttributes().entrySet()) { + if (entry.getValue() != null) { + context.put("scope_attr_" + entry.getKey(), String.valueOf(entry.getValue())); + } + } + } + } + } +} \ No newline at end of file diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/LogWorker.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/LogWorker.java new file mode 100644 index 00000000000..16f25c62b41 --- /dev/null +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/LogWorker.java @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.alert.calculate.realtime.window; + +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.alert.AlerterWorkerPool; +import org.apache.hertzbeat.alert.calculate.JexlExprCalculator; +import org.apache.hertzbeat.alert.service.AlertDefineService; +import org.apache.hertzbeat.common.entity.alerter.AlertDefine; +import org.apache.hertzbeat.common.entity.log.LogEntry; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * LogWorker Responsible for: + * 1. Receiving logs from WindowedLogRealTimeAlertCalculator + * 2. Evaluating alert expressions against logs + * 3. Sending matching logs to WindowAggregator + */ +@Slf4j +@Component +public class LogWorker { + + private static final String LOG_PREFIX = "log"; + + private final AlertDefineService alertDefineService; + private final JexlExprCalculator jexlExprCalculator; + private final WindowAggregator windowAggregator; + private final AlerterWorkerPool workerPool; + + @Autowired + public LogWorker(AlertDefineService alertDefineService, + JexlExprCalculator jexlExprCalculator, WindowAggregator windowAggregator, + AlerterWorkerPool workerPool) { + this.alertDefineService = alertDefineService; + this.jexlExprCalculator = jexlExprCalculator; + this.windowAggregator = windowAggregator; + this.workerPool = workerPool; + } + + public void reduceAndSendLogTask(LogEntry logEntry) { + workerPool.executeLogJob(reduceLogTask(logEntry)); + } + + public Runnable reduceLogTask(LogEntry logEntry) { + return () -> { + try { + processLogEntry(logEntry); + } catch (Exception e) { + log.error("Error processing log entry in worker: {}", e.getMessage(), e); + } + }; + } + + private void processLogEntry(LogEntry logEntry) { + // Get all log alert definitions + List alertDefines = alertDefineService.getLogRealTimeAlertDefines(); + + // Create context for expression evaluation + Map context = new HashMap<>(8); + context.put(LOG_PREFIX, logEntry); + + // Process each alert definition + for (AlertDefine define : alertDefines) { + if (define.getExpr() == null || define.getExpr().trim().isEmpty()) { + continue; + } + + try { + // Evaluate alert expression + boolean match = jexlExprCalculator.execAlertExpression(context, define.getExpr(), false); + + if (match) { + // Create matching log event and send to WindowAggregator + MatchingLogEvent event = createMatchingLogEvent(logEntry, define); + windowAggregator.addMatchingLog(event); + } + } catch (Exception e) { + log.warn("Error evaluating expression for alert define {}: {}", define.getName(), e.getMessage()); + } + } + } + + private MatchingLogEvent createMatchingLogEvent(LogEntry logEntry, AlertDefine define) { + long eventTimestamp = extractEventTimestamp(logEntry); + + return MatchingLogEvent.builder() + .logEntry(logEntry) + .alertDefine(define) + .eventTimestamp(eventTimestamp) + .workerTimestamp(System.currentTimeMillis()) + .build(); + } + + private long extractEventTimestamp(LogEntry logEntry) { + if (logEntry.getTimeUnixNano() != null && logEntry.getTimeUnixNano() != 0) { + return logEntry.getTimeUnixNano() / 1_000_000; // Convert to milliseconds + } + if (logEntry.getObservedTimeUnixNano() != null && logEntry.getObservedTimeUnixNano() != 0) { + return logEntry.getObservedTimeUnixNano() / 1_000_000; // Convert to milliseconds + } + return System.currentTimeMillis(); + } +} \ No newline at end of file diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/MatchingLogEvent.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/MatchingLogEvent.java new file mode 100644 index 00000000000..34f9adda25e --- /dev/null +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/MatchingLogEvent.java @@ -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.calculate.realtime.window; + +import lombok.Builder; +import lombok.Data; +import org.apache.hertzbeat.common.entity.alerter.AlertDefine; +import org.apache.hertzbeat.common.entity.log.LogEntry; + +/** + * Represents a log entry that matched an alert expression + * Used for communication between LogWorker and WindowAggregator + */ +@Data +@Builder +public class MatchingLogEvent { + + private LogEntry logEntry; + + private AlertDefine alertDefine; + + private long eventTimestamp; + + private long workerTimestamp; +} \ No newline at end of file diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/TimeService.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/TimeService.java new file mode 100644 index 00000000000..2dbe6998f7d --- /dev/null +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/TimeService.java @@ -0,0 +1,190 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.alert.calculate.realtime.window; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Watermark Generator responsible for: + * 1. Receiving maxTimestamp updates from WindowedLogRealTimeAlertCalculator + * 2. Calculating watermarks based on configurable delay + * 3. Broadcasting watermarks to all subscribers (WindowAggregator) + */ +@Component +@Slf4j +public class TimeService { + + private static final long DEFAULT_WATERMARK_DELAY_MS = 30_000; // 30 seconds + private static final long WATERMARK_BROADCAST_INTERVAL_MS = 5_000; // 5 seconds + + private final AtomicLong maxTimestamp = new AtomicLong(0); + private final AtomicLong currentWatermark = new AtomicLong(0); + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + private ScheduledExecutorService scheduler; + + public TimeService(List initialListeners) { + listeners.addAll(initialListeners); + } + + @PostConstruct + public void start() { + // Create internal scheduled executor + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setUncaughtExceptionHandler((thread, throwable) -> { + log.error("TimeService scheduler has uncaughtException."); + log.error(throwable.getMessage(), throwable); + }) + .setDaemon(true) + .setNameFormat("timeservice-scheduler-%d") + .build(); + + this.scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); + + // Start watermark broadcast scheduler + scheduler.scheduleAtFixedRate( + this::broadcastWatermark, + 0, + WATERMARK_BROADCAST_INTERVAL_MS, + TimeUnit.MILLISECONDS + ); + + log.info("TimeService started with watermark delay: {}ms", DEFAULT_WATERMARK_DELAY_MS); + } + + @PreDestroy + public void stop() { + if (scheduler != null && !scheduler.isShutdown()) { + log.info("Shutting down TimeService scheduler..."); + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) { + log.warn("TimeService scheduler did not terminate within 10 seconds, forcing shutdown"); + scheduler.shutdownNow(); + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + log.error("TimeService scheduler did not terminate"); + } + } + } catch (InterruptedException e) { + log.warn("Interrupted while waiting for TimeService scheduler to terminate"); + scheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + log.info("TimeService stopped"); + } + + /** + * Update max timestamp from WindowedLogRealTimeAlertCalculator + */ + public void updateMaxTimestamp(long timestamp) { + long currentMax = maxTimestamp.get(); + if (timestamp > currentMax) { + maxTimestamp.compareAndSet(currentMax, timestamp); + } + } + + /** + * Add watermark listener + */ + public void addWatermarkListener(WatermarkListener listener) { + listeners.add(listener); + } + + /** + * Remove watermark listener + */ + public void removeWatermarkListener(WatermarkListener listener) { + listeners.remove(listener); + } + + /** + * Get current watermark + */ + public long getCurrentWatermark() { + return currentWatermark.get(); + } + + /** + * Calculate and broadcast watermark + */ + private void broadcastWatermark() { + + try { + long maxTs = maxTimestamp.get(); + if (maxTs < 0) { + return; + } + // Calculate watermark: maxTimestamp - delay + long newWatermark = maxTs - DEFAULT_WATERMARK_DELAY_MS; + long currentWm = currentWatermark.get(); + + // Only advance watermark (monotonic property) + if (newWatermark <= currentWm) { + return; + } + if (currentWatermark.compareAndSet(currentWm, newWatermark)) { + // Broadcast to all listeners + Watermark watermark = new Watermark(newWatermark); + for (WatermarkListener listener : listeners) { + try { + listener.onWatermark(watermark); + } catch (Exception e) { + log.error("Error notifying watermark listener: {}", e.getMessage(), e); + } + } + log.debug("Broadcast watermark: {} (maxTimestamp: {})", newWatermark, maxTs); + } + } catch (Exception e) { + log.error("Error in watermark broadcast: {}", e.getMessage(), e); + } + } + + /** + * Watermark data class + */ + @Data + @AllArgsConstructor + @Getter + public static class Watermark { + private final long timestamp; + } + + /** + * Interface for watermark listeners + */ + public interface WatermarkListener { + void onWatermark(Watermark watermark); + } +} \ No newline at end of file diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/WindowAggregator.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/WindowAggregator.java new file mode 100644 index 00000000000..935461e50b1 --- /dev/null +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/calculate/realtime/window/WindowAggregator.java @@ -0,0 +1,251 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.alert.calculate.realtime.window; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.common.entity.alerter.AlertDefine; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + + +/** + * Window Aggregator Responsible for: + * 1. Managing window data structure for all active windows + * 2. Receiving matching logs from Workers + * 3. Receiving watermarks from TimeService + * 4. Sending closed windows to AlarmEvaluator + */ +@Component +@Slf4j +public class WindowAggregator implements TimeService.WatermarkListener, Runnable { + + private static final long DEFAULT_WINDOW_SIZE_MS = 1 * 60 * 1000; // 1 minutes + + private final AlarmEvaluator alarmEvaluator; + private final BlockingQueue eventQueue = new LinkedBlockingQueue<>(); + private final Map activeWindows = new HashMap<>(); + private final Object windowLock = new Object(); + + private ExecutorService aggregatorExecutor; + + public WindowAggregator(AlarmEvaluator alarmEvaluator) { + this.alarmEvaluator = alarmEvaluator; + } + + public void addMatchingLog(MatchingLogEvent event) { + try { + eventQueue.put(event); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while adding matching log to aggregator"); + } + } + + @Override + public void onWatermark(TimeService.Watermark watermark) { + List closedWindows; + + synchronized (windowLock) { + closedWindows = new ArrayList<>(); + + // Find windows that should be closed based on watermark + Iterator> iterator = activeWindows.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + WindowData windowData = entry.getValue(); + + // Close window if its end time <= watermark timestamp + if (windowData.getEndTime() <= watermark.getTimestamp()) { + closedWindows.add(windowData); + iterator.remove(); + + log.debug("Closing window: {} with {} matching logs", + entry.getKey(), windowData.getMatchingLogs().size()); + } + } + } + + for (WindowData windowData : closedWindows) { + alarmEvaluator.sendAndProcessWindowData(windowData); + } + } + + @Override + public void run() { + while (!Thread.currentThread().isInterrupted()) { + try { + MatchingLogEvent event = eventQueue.take(); + processMatchingLogEvent(event); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + log.error("Error processing matching log event: {}", e.getMessage(), e); + } + } + } + + private void processMatchingLogEvent(MatchingLogEvent event) { + + // Determine window size from alert define (if specified) or use default + long windowSizeMs = getWindowSize(event.getAlertDefine()); + + // Calculate window boundaries + long eventTime = event.getEventTimestamp(); + long windowStart = (eventTime / windowSizeMs) * windowSizeMs; + long windowEnd = windowStart + windowSizeMs; + + // Create window key + WindowKey windowKey = new WindowKey( + event.getAlertDefine().getId(), + windowStart, + windowEnd + ); + synchronized (windowLock) { + // Get or create window data + WindowData windowData = activeWindows.computeIfAbsent(windowKey, + key -> new WindowData(key, event.getAlertDefine())); + // Add matching log to window + windowData.addMatchingLog(event); + log.debug("Added matching log to window: {} (total logs: {})", + windowKey, windowData.getMatchingLogs().size()); + } + } + + private long getWindowSize(AlertDefine alertDefine) { + // Check if alert define has custom window size configuration + if (alertDefine.getPeriod() != null) { + return alertDefine.getPeriod() * 1000; // Convert seconds to milliseconds + } + log.info("Using default window size of {} ms for alert define: {}", + DEFAULT_WINDOW_SIZE_MS, alertDefine.getName()); + return DEFAULT_WINDOW_SIZE_MS; + } + + @PostConstruct + public void start() { + // Create internal executor + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setUncaughtExceptionHandler((thread, throwable) -> { + log.error("WindowAggregator executor has uncaughtException."); + log.error(throwable.getMessage(), throwable); + }) + .setDaemon(true) + .setNameFormat("window-aggregator-%d") + .build(); + + aggregatorExecutor = Executors.newSingleThreadExecutor(threadFactory); + + // Submit aggregation task + aggregatorExecutor.submit(this); + + log.info("WindowAggregator started"); + } + + @PreDestroy + public void stop() { + if (aggregatorExecutor != null && !aggregatorExecutor.isShutdown()) { + log.info("Shutting down WindowAggregator executor..."); + aggregatorExecutor.shutdown(); + try { + if (!aggregatorExecutor.awaitTermination(10, TimeUnit.SECONDS)) { + log.warn("WindowAggregator executor did not terminate within 10 seconds, forcing shutdown"); + aggregatorExecutor.shutdownNow(); + if (!aggregatorExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + log.error("WindowAggregator executor did not terminate"); + } + } + } catch (InterruptedException e) { + log.warn("Interrupted while waiting for WindowAggregator executor to terminate"); + aggregatorExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + log.info("WindowAggregator stopped"); + } + + /** + * Window key for identifying unique windows + */ + @Data + @Getter + @AllArgsConstructor + public static class WindowKey { + private final long alertDefineId; + private final long startTime; + private final long endTime; + } + + /** + * Window data container + */ + public static class WindowData { + @Getter + private final WindowKey windowKey; + @Getter + private final AlertDefine alertDefine; + private final List matchingLogs = new ArrayList<>(); + @Getter + private final long createdTime; + + public WindowData(WindowKey windowKey, AlertDefine alertDefine) { + this.windowKey = windowKey; + this.alertDefine = alertDefine; + this.createdTime = System.currentTimeMillis(); + } + + public void addMatchingLog(MatchingLogEvent event) { + matchingLogs.add(event); + } + + public List getMatchingLogs() { + return new ArrayList<>(matchingLogs); + } + + /** + * Get start time of the window + * @return start time + */ + public long getStartTime() { return windowKey.getStartTime(); } + + /** + * Get end time of the window + * @return end time + */ + public long getEndTime() { return windowKey.getEndTime(); } + } +} \ No newline at end of file diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmCommonReduce.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmCommonReduce.java index bbaf7b12984..0ef336921ac 100644 --- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmCommonReduce.java +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmCommonReduce.java @@ -18,6 +18,8 @@ package org.apache.hertzbeat.alert.reduce; import com.google.common.util.concurrent.ThreadFactoryBuilder; + +import java.util.List; import java.util.Map; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; @@ -66,6 +68,22 @@ private void initWorkExecutor() { public void reduceAndSendAlarm(SingleAlert alert) { workerExecutor.execute(reduceAlarmTask(alert)); } + + public void reduceAndSendAlarmGroup(Map groupLabels, List alerts) { + workerExecutor.execute(() -> { + try { + // Generate alert fingerprint + for (SingleAlert alert : alerts) { + String fingerprint = generateAlertFingerprint(alert.getLabels()); + alert.setFingerprint(fingerprint); + } + // Process the group alert + alarmGroupReduce.processGroupAlert(groupLabels, alerts); + } catch (Exception e) { + log.error("Reduce alarm group failed: {}", e.getMessage()); + } + }); + } Runnable reduceAlarmTask(SingleAlert alert) { return () -> { diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduce.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduce.java index 112c83291a1..94c6cb873e2 100644 --- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduce.java +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/reduce/AlarmGroupReduce.java @@ -159,7 +159,20 @@ public void processGroupAlert(SingleAlert alert) { sendSingleAlert(alert); } } - + + public void processGroupAlert(Map groupLabels, List alertList) { + GroupAlert groupAlert = GroupAlert.builder() + .groupKey(generateGroupKey(groupLabels)) + .groupLabels(groupLabels) + .commonLabels(extractCommonLabels(alertList)) + .commonAnnotations(extractCommonAnnotations(alertList)) + .alerts(alertList) + .status(CommonConstants.ALERT_STATUS_FIRING) + .build(); + + alarmInhibitReduce.inhibitAlarm(groupAlert); + } + private boolean hasRequiredLabels(Map labels, List requiredLabels) { return requiredLabels.stream().allMatch(labels::containsKey); } diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/AlertDefineService.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/AlertDefineService.java index 261b121cae9..6b8692ae526 100644 --- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/AlertDefineService.java +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/AlertDefineService.java @@ -22,6 +22,7 @@ import org.apache.hertzbeat.common.support.exception.AlertExpressionException; import org.springframework.data.domain.Page; import org.springframework.web.multipart.MultipartFile; +import org.apache.hertzbeat.common.constants.CommonConstants; import java.util.List; import java.util.Map; @@ -106,10 +107,23 @@ public interface AlertDefineService { void importConfig(MultipartFile file) throws Exception; /** - * Get the real-time alarm definition list - * @return Real-time alarm definition list + * Get the real-time metrics alarm definition list + * @return Real-time metrics alarm definition list */ - List getRealTimeAlertDefines(); + List getMetricsRealTimeAlertDefines(); + + /** + * Get the real-time log alarm definition list + * @return Real-time log alarm definition list + */ + List getLogRealTimeAlertDefines(); + + /** + * Get the alarm definition list by type + * @param type Alarm definition type, must be one of the constants defined in {@link CommonConstants} + * @return List of enabled alarm definitions matching the specified type, empty list if none found + */ + List getAlertDefinesByType(String type); /** * Get define preview diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/DataSourceService.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/DataSourceService.java index 96dda0c45a9..8baca0b733b 100644 --- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/DataSourceService.java +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/DataSourceService.java @@ -32,4 +32,12 @@ public interface DataSourceService { * @return result */ List> calculate(String datasource, String expr); + + /** + * query result set from db + * @param datasource sql or promql + * @param expr query expr + * @return result + */ + List> query(String datasource, String expr); } diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertDefineServiceImpl.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertDefineServiceImpl.java index 8cce4294b87..679f580c207 100644 --- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertDefineServiceImpl.java +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/AlertDefineServiceImpl.java @@ -24,7 +24,7 @@ import jakarta.persistence.criteria.Predicate; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; -import org.apache.hertzbeat.alert.calculate.PeriodicAlertRuleScheduler; +import org.apache.hertzbeat.alert.calculate.periodic.PeriodicAlertRuleScheduler; import org.apache.hertzbeat.alert.dao.AlertDefineDao; import org.apache.hertzbeat.alert.service.AlertDefineImExportService; import org.apache.hertzbeat.alert.service.AlertDefineService; @@ -87,7 +87,9 @@ public AlertDefineServiceImpl(List alertDefineImExpo @Override public void validate(AlertDefine alertDefine, boolean isModify) throws IllegalArgumentException { if (StringUtils.hasText(alertDefine.getExpr())) { - if (CommonConstants.ALERT_THRESHOLD_TYPE_REALTIME.equals(alertDefine.getType())) { + if (CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_REALTIME.equals(alertDefine.getType()) + || CommonConstants.LOG_ALERT_THRESHOLD_TYPE_REALTIME.equals(alertDefine.getType()) + || CommonConstants.LOG_ALERT_THRESHOLD_TYPE_PERIODIC.equals(alertDefine.getType())) { try { JexlExpressionRunner.compile(alertDefine.getExpr()); } catch (Exception e) { @@ -214,11 +216,21 @@ public void importConfig(MultipartFile file) throws Exception { } @Override - public List getRealTimeAlertDefines() { - List alertDefines = CacheFactory.getAlertDefineCache(); + public List getMetricsRealTimeAlertDefines() { + List alertDefines = CacheFactory.getMetricsAlertDefineCache(); if (alertDefines == null) { - alertDefines = alertDefineDao.findAlertDefinesByTypeAndEnableTrue(CommonConstants.ALERT_THRESHOLD_TYPE_REALTIME); - CacheFactory.setAlertDefineCache(alertDefines); + alertDefines = alertDefineDao.findAlertDefinesByTypeAndEnableTrue(CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_REALTIME); + CacheFactory.setMetricsAlertDefineCache(alertDefines); + } + return alertDefines; + } + + @Override + public List getLogRealTimeAlertDefines() { + List alertDefines = CacheFactory.getLogAlertDefineCache(); + if (alertDefines == null) { + alertDefines = alertDefineDao.findAlertDefinesByTypeAndEnableTrue(CommonConstants.LOG_ALERT_THRESHOLD_TYPE_REALTIME); + CacheFactory.setLogAlertDefineCache(alertDefines); } return alertDefines; } @@ -229,11 +241,35 @@ public List> getDefinePreview(String datasource, String type return Collections.emptyList(); } switch (type) { - case CommonConstants.ALERT_THRESHOLD_TYPE_PERIODIC: + case CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_PERIODIC: return dataSourceService.calculate(datasource, expr); + case CommonConstants.LOG_ALERT_THRESHOLD_TYPE_PERIODIC: + // todo support alert expr preview + return dataSourceService.query(datasource, expr); default: log.error("Get define preview unsupported type: {}", type); return Collections.emptyList(); } } + + @Override + public List getAlertDefinesByType(String type) { + if (!StringUtils.hasText(type)) { + throw new IllegalArgumentException("Alert definition type cannot be null or empty"); + } + + switch (type) { + case CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_REALTIME: + case CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_PERIODIC: + case CommonConstants.LOG_ALERT_THRESHOLD_TYPE_REALTIME: + case CommonConstants.LOG_ALERT_THRESHOLD_TYPE_PERIODIC: + // Valid type, proceed with query + break; + default: + throw new IllegalArgumentException("Unsupported alert definition type: " + type); + } + + // Query enabled alert definitions by type + return alertDefineDao.findAlertDefinesByTypeAndEnableTrue(type); + } } diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java index d41c4a8671f..d27bd61d695 100644 --- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/DataSourceServiceImpl.java @@ -95,6 +95,29 @@ public List> calculate(String datasource, String expr) { } } + @Override + public List> query(String datasource, String expr) { + if (!StringUtils.hasText(expr)) { + throw new IllegalArgumentException("Empty expression"); + } + if (executors == null || executors.isEmpty()) { + throw new IllegalArgumentException(bundle.getString("alerter.datasource.executor.not.found")); + } + QueryExecutor executor = executors.stream().filter(e -> e.support(datasource)).findFirst().orElse(null); + + if (executor == null) { + throw new IllegalArgumentException("Unsupported datasource: " + datasource); + } + // replace all white space + expr = expr.replaceAll("\\s+", " "); + try { + return executor.execute(expr); + } catch (Exception e) { + log.error("Error executing query on datasource {}: {}", datasource, e.getMessage()); + throw new RuntimeException("Query execution failed", e); + } + } + private List> evaluate(String expr, QueryExecutor executor) { CommonTokenStream tokens = tokenStreamCache.get(expr, this::createTokenStream); AlertExpressionParser parser = new AlertExpressionParser(tokens); diff --git a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/util/AlertTemplateUtil.java b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/util/AlertTemplateUtil.java index a673813ded1..491fe8ed57c 100644 --- a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/util/AlertTemplateUtil.java +++ b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/util/AlertTemplateUtil.java @@ -17,6 +17,8 @@ package org.apache.hertzbeat.alert.util; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -33,10 +35,11 @@ private AlertTemplateUtil() { } /** - * Match the variable ${key} + * Match the variable ${key} or ${key.property.subproperty} * eg: Alert, the instance: ${instance} metrics: ${metrics} is over flow. + * eg: Log alert: ${log.attributes.level} - ${log.message} */ - private static final Pattern PATTERN = Pattern.compile("\\$\\{(\\w+)\\}"); + private static final Pattern PATTERN = Pattern.compile("\\$\\{([\\w.]+)}"); public static String render(String template, Map replaceData) { if (!StringUtils.hasText(template)) { @@ -50,7 +53,8 @@ public static String render(String template, Map replaceData) { Matcher matcher = PATTERN.matcher(template); StringBuilder builder = new StringBuilder(); while (matcher.find()) { - Object objectValue = replaceData.getOrDefault(matcher.group(1), "NullValue"); + String propertyPath = matcher.group(1); + Object objectValue = getNestedProperty(replaceData, propertyPath); String value = objectValue != null ? objectValue.toString() : "NullValue"; matcher.appendReplacement(builder, Matcher.quoteReplacement(value)); } @@ -61,4 +65,116 @@ public static String render(String template, Map replaceData) { return template; } } + + /** + * Get nested property value from object using property path like "log.attributes.demo" + * @param replaceData the root data map + * @param propertyPath the property path, e.g., "log.attributes.demo" + * @return the property value or null if not found + */ + private static Object getNestedProperty(Map replaceData, String propertyPath) { + if (propertyPath == null || propertyPath.isEmpty()) { + return null; + } + + String[] parts = propertyPath.split("\\."); + Object current = replaceData.get(parts[0]); + + if (current == null) { + return null; + } + + // If only one part, return the direct value + if (parts.length == 1) { + return current; + } + + // Navigate through the property path + for (int i = 1; i < parts.length; i++) { + current = getPropertyValue(current, parts[i]); + if (current == null) { + return null; + } + } + + return current; + } + + /** + * Get property value from an object using reflection + * @param obj the object + * @param propertyName the property name + * @return the property value or null if not found + */ + private static Object getPropertyValue(Object obj, String propertyName) { + if (obj == null || propertyName == null) { + return null; + } + + try { + // Handle Map objects + if (obj instanceof Map) { + return ((Map) obj).get(propertyName); + } + + Class clazz = obj.getClass(); + + // Try getter method first (getPropertyName or isPropertyName for boolean) + String getterName = "get" + capitalize(propertyName); + String booleanGetterName = "is" + capitalize(propertyName); + + try { + Method getter = clazz.getMethod(getterName); + return getter.invoke(obj); + } catch (NoSuchMethodException e) { + // Try boolean getter + try { + Method booleanGetter = clazz.getMethod(booleanGetterName); + return booleanGetter.invoke(obj); + } catch (NoSuchMethodException ignored) { + // Continue to field access + } + } + + // Try direct field access + try { + Field field = clazz.getDeclaredField(propertyName); + field.setAccessible(true); + return field.get(obj); + } catch (NoSuchFieldException ignored) { + // Field not found + } + + // Try parent classes + Class superClass = clazz.getSuperclass(); + while (superClass != null && !superClass.equals(Object.class)) { + try { + Field field = superClass.getDeclaredField(propertyName); + field.setAccessible(true); + return field.get(obj); + } catch (NoSuchFieldException ignored) { + // Continue with parent class + } + superClass = superClass.getSuperclass(); + } + + } catch (Exception e) { + log.debug("Failed to access property '{}' on object of type {}: {}", + propertyName, obj.getClass().getSimpleName(), e.getMessage()); + } + + return null; + } + + /** + * Capitalize the first letter of a string + * @param str the input string + * @return the capitalized string + */ + private static String capitalize(String str) { + if (str == null || str.isEmpty()) { + return str; + } + return str.substring(0, 1).toUpperCase() + str.substring(1); + } } diff --git a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/PeriodicAlertCalculatorTest.java b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/periodic/MetricsPeriodicAlertCalculatorTest.java similarity index 95% rename from hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/PeriodicAlertCalculatorTest.java rename to hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/periodic/MetricsPeriodicAlertCalculatorTest.java index 9acb1be0ece..ea879303def 100644 --- a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/PeriodicAlertCalculatorTest.java +++ b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/periodic/MetricsPeriodicAlertCalculatorTest.java @@ -15,8 +15,9 @@ * limitations under the License. */ -package org.apache.hertzbeat.alert.calculate; +package org.apache.hertzbeat.alert.calculate.periodic; +import org.apache.hertzbeat.alert.calculate.AlarmCacheManager; import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce; import org.apache.hertzbeat.alert.service.DataSourceService; import org.apache.hertzbeat.common.constants.CommonConstants; @@ -50,10 +51,10 @@ import static org.mockito.Mockito.when; /** - * Test case for {@link PeriodicAlertCalculator} + * Test case for {@link MetricsPeriodicAlertCalculator} */ @ExtendWith(MockitoExtension.class) -class PeriodicAlertCalculatorTest { +class MetricsPeriodicAlertCalculatorTest { @Mock private DataSourceService dataSourceService; @@ -65,7 +66,7 @@ class PeriodicAlertCalculatorTest { private AlarmCacheManager alarmCacheManager; @InjectMocks - private PeriodicAlertCalculator periodicAlertCalculator; + private MetricsPeriodicAlertCalculator periodicAlertCalculator; private AlertDefine rule; diff --git a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/RealTimeAlertCalculatorMatchTest.java b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/realtime/MetricsRealTimeAlertCalculatorMatchTest.java similarity index 90% rename from hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/RealTimeAlertCalculatorMatchTest.java rename to hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/realtime/MetricsRealTimeAlertCalculatorMatchTest.java index 729b8dc213c..e5e4b38a2a6 100644 --- a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/RealTimeAlertCalculatorMatchTest.java +++ b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/realtime/MetricsRealTimeAlertCalculatorMatchTest.java @@ -15,10 +15,12 @@ * limitations under the License. */ -package org.apache.hertzbeat.alert.calculate; +package org.apache.hertzbeat.alert.calculate.realtime; import com.google.common.collect.Lists; import org.apache.hertzbeat.alert.AlerterWorkerPool; +import org.apache.hertzbeat.alert.calculate.AlarmCacheManager; +import org.apache.hertzbeat.alert.calculate.JexlExprCalculator; import org.apache.hertzbeat.alert.dao.SingleAlertDao; import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce; import org.apache.hertzbeat.alert.service.AlertDefineService; @@ -48,7 +50,7 @@ /** * */ -public class RealTimeAlertCalculatorMatchTest { +public class MetricsRealTimeAlertCalculatorMatchTest { private final AlerterWorkerPool workerPool = new AlerterWorkerPool(); @@ -67,19 +69,20 @@ public class RealTimeAlertCalculatorMatchTest { @Mock private AlarmCacheManager alarmCacheManager; - private RealTimeAlertCalculator realTimeAlertCalculator; + private MetricsRealTimeAlertCalculator metricsRealTimeAlertCalculator; @BeforeEach public void setUp() { MockitoAnnotations.openMocks(this); when(singleAlertDao.querySingleAlertsByStatus(any())).thenReturn(new ArrayList<>()); - realTimeAlertCalculator = new RealTimeAlertCalculator( + metricsRealTimeAlertCalculator = new MetricsRealTimeAlertCalculator( workerPool, dataQueue, alertDefineService, singleAlertDao, alarmCommonReduce, alarmCacheManager, + new JexlExprCalculator(), false ); } @@ -99,7 +102,7 @@ void testFilterThresholdsByAppAndMetrics_withInstanceExpr_HasSpace() { List allDefines = Collections.singletonList(matchDefine); - List filtered = realTimeAlertCalculator.filterThresholdsByAppAndMetrics(allDefines, app, "", Map.of(), instanceId, priority); + List filtered = metricsRealTimeAlertCalculator.filterThresholdsByAppAndMetrics(allDefines, app, "", Map.of(), instanceId, priority); // It should filter out 999999999. assertEquals(1, filtered.size()); @@ -145,10 +148,10 @@ void testPrometheusReplaceMultipleJobsApp() throws InterruptedException { List allDefines = Collections.singletonList(matchDefine); - when(alertDefineService.getRealTimeAlertDefines()).thenReturn(allDefines); + when(alertDefineService.getMetricsRealTimeAlertDefines()).thenReturn(allDefines); when(dataQueue.pollMetricsDataToAlerter()).thenReturn(metricsData).thenThrow(new InterruptedException()); - realTimeAlertCalculator.startCalculate(); + metricsRealTimeAlertCalculator.startCalculate(); Thread.sleep(3000); @@ -189,10 +192,10 @@ void testPrometheusReplaceApp() throws InterruptedException { List allDefines = Collections.singletonList(matchDefine); - when(alertDefineService.getRealTimeAlertDefines()).thenReturn(allDefines); + when(alertDefineService.getMetricsRealTimeAlertDefines()).thenReturn(allDefines); when(dataQueue.pollMetricsDataToAlerter()).thenReturn(metricsData).thenThrow(new InterruptedException()); - realTimeAlertCalculator.startCalculate(); + metricsRealTimeAlertCalculator.startCalculate(); Thread.sleep(3000); @@ -239,10 +242,10 @@ void testCalculateWithNormalApp() throws InterruptedException { List allDefines = Collections.singletonList(matchDefine); - when(alertDefineService.getRealTimeAlertDefines()).thenReturn(allDefines); + when(alertDefineService.getMetricsRealTimeAlertDefines()).thenReturn(allDefines); when(dataQueue.pollMetricsDataToAlerter()).thenReturn(metricsData).thenThrow(new InterruptedException()); - realTimeAlertCalculator.startCalculate(); + metricsRealTimeAlertCalculator.startCalculate(); Thread.sleep(3000); diff --git a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/RealTimeAlertCalculatorTest.java b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/realtime/MetricsRealTimeAlertCalculatorTest.java similarity index 89% rename from hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/RealTimeAlertCalculatorTest.java rename to hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/realtime/MetricsRealTimeAlertCalculatorTest.java index bdcc34ff776..3d4b41164df 100644 --- a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/RealTimeAlertCalculatorTest.java +++ b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/calculate/realtime/MetricsRealTimeAlertCalculatorTest.java @@ -15,11 +15,13 @@ * limitations under the License. */ -package org.apache.hertzbeat.alert.calculate; +package org.apache.hertzbeat.alert.calculate.realtime; import static org.junit.jupiter.api.Assertions.assertEquals; import org.apache.hertzbeat.alert.AlerterWorkerPool; +import org.apache.hertzbeat.alert.calculate.AlarmCacheManager; +import org.apache.hertzbeat.alert.calculate.JexlExprCalculator; import org.apache.hertzbeat.alert.dao.SingleAlertDao; import org.apache.hertzbeat.alert.reduce.AlarmCommonReduce; import org.apache.hertzbeat.alert.service.AlertDefineService; @@ -35,9 +37,9 @@ import java.util.Map; -class RealTimeAlertCalculatorTest { +class MetricsRealTimeAlertCalculatorTest { - private RealTimeAlertCalculator calculator; + private MetricsRealTimeAlertCalculator calculator; @BeforeEach void setUp() { @@ -51,7 +53,8 @@ void setUp() { Mockito.when(mockDao.querySingleAlertsByStatus(Mockito.anyString())) .thenReturn(Collections.emptyList()); - calculator = new RealTimeAlertCalculator(mockPool, mockQueue, mockAlertDefineService, mockDao, mockReduce, alarmCacheManager, false); + calculator = new MetricsRealTimeAlertCalculator(mockPool, mockQueue, mockAlertDefineService + , mockDao, mockReduce, alarmCacheManager, new JexlExprCalculator(), false); } @Test diff --git a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/AlertDefineServiceTest.java b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/AlertDefineServiceTest.java index 49ffd3120ee..2e3df491faa 100644 --- a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/AlertDefineServiceTest.java +++ b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/service/AlertDefineServiceTest.java @@ -18,7 +18,7 @@ package org.apache.hertzbeat.alert.service; import com.google.common.collect.Lists; -import org.apache.hertzbeat.alert.calculate.PeriodicAlertRuleScheduler; +import org.apache.hertzbeat.alert.calculate.periodic.PeriodicAlertRuleScheduler; import org.apache.hertzbeat.alert.dao.AlertDefineDao; import org.apache.hertzbeat.alert.service.impl.AlertDefineServiceImpl; import org.apache.hertzbeat.common.entity.alerter.AlertDefine; @@ -39,8 +39,8 @@ import java.util.Map; import java.util.Optional; -import static org.apache.hertzbeat.common.constants.CommonConstants.ALERT_THRESHOLD_TYPE_PERIODIC; -import static org.apache.hertzbeat.common.constants.CommonConstants.ALERT_THRESHOLD_TYPE_REALTIME; +import static org.apache.hertzbeat.common.constants.CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_PERIODIC; +import static org.apache.hertzbeat.common.constants.CommonConstants.METRIC_ALERT_THRESHOLD_TYPE_REALTIME; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -164,14 +164,14 @@ void getDefinePreview() { } }; when(dataSourceService.calculate(eq("promql"), eq(expr))).thenReturn(Lists.newArrayList(countValue1)); - List> result = alertDefineService.getDefinePreview("promql", ALERT_THRESHOLD_TYPE_PERIODIC, expr); + List> result = alertDefineService.getDefinePreview("promql", METRIC_ALERT_THRESHOLD_TYPE_PERIODIC, expr); assertNotNull(result); assertEquals(1307, result.get(0).get("__value__")); - result = alertDefineService.getDefinePreview("promql", ALERT_THRESHOLD_TYPE_PERIODIC, null); + result = alertDefineService.getDefinePreview("promql", METRIC_ALERT_THRESHOLD_TYPE_PERIODIC, null); assertEquals(0, result.size()); - result = alertDefineService.getDefinePreview("promql", ALERT_THRESHOLD_TYPE_REALTIME, null); + result = alertDefineService.getDefinePreview("promql", METRIC_ALERT_THRESHOLD_TYPE_REALTIME, null); assertEquals(0, result.size()); } diff --git a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/util/AlertTemplateUtilTest.java b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/util/AlertTemplateUtilTest.java index 096a76e22ee..79b782e7e48 100644 --- a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/util/AlertTemplateUtilTest.java +++ b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/util/AlertTemplateUtilTest.java @@ -87,4 +87,199 @@ void renderSpecialCharacters() { String expectedResult = "The price is $100 and the path is C:\\Users"; assertEquals(expectedResult, AlertTemplateUtil.render(template, param)); } + + @Test + void renderNestedMapProperties() { + Map param = new HashMap<>(); + Map log = new HashMap<>(); + Map attributes = new HashMap<>(); + + attributes.put("level", "ERROR"); + attributes.put("thread", "main"); + log.put("attributes", attributes); + log.put("message", "Connection failed"); + log.put("timestamp", "2024-01-01T10:00:00Z"); + + param.put("log", log); + param.put("instance", "server-01"); + + String template = "Log alert: ${log.attributes.level} - ${log.message} on ${instance}"; + String expectedResult = "Log alert: ERROR - Connection failed on server-01"; + assertEquals(expectedResult, AlertTemplateUtil.render(template, param)); + } + + @Test + void renderMultiLevelNestedProperties() { + Map param = new HashMap<>(); + Map config = new HashMap<>(); + Map database = new HashMap<>(); + Map connection = new HashMap<>(); + + connection.put("host", "localhost"); + connection.put("port", 3306); + database.put("connection", connection); + database.put("name", "hertzbeat"); + config.put("database", database); + + param.put("config", config); + + String template = "Database: ${config.database.name} at ${config.database.connection.host}:${config.database.connection.port}"; + String expectedResult = "Database: hertzbeat at localhost:3306"; + assertEquals(expectedResult, AlertTemplateUtil.render(template, param)); + } + + @Test + void renderNonExistentNestedProperties() { + Map param = new HashMap<>(); + Map log = new HashMap<>(); + + log.put("message", "Test message"); + param.put("log", log); + + // Test non-existent nested property + String template = "Log alert: ${log.nonexistent.property} - ${log.message}"; + String expectedResult = "Log alert: NullValue - Test message"; + assertEquals(expectedResult, AlertTemplateUtil.render(template, param)); + } + + @Test + void renderNonExistentTopLevelProperty() { + Map param = new HashMap<>(); + param.put("existing", "value"); + + String template = "Value: ${nonexistent.property}"; + String expectedResult = "Value: NullValue"; + assertEquals(expectedResult, AlertTemplateUtil.render(template, param)); + } + + @Test + void renderNestedPropertiesWithNullValues() { + Map param = new HashMap<>(); + Map data = new HashMap<>(); + + data.put("value", null); + data.put("description", "Test data"); + param.put("data", data); + + String template = "Data: ${data.value} - ${data.description}"; + String expectedResult = "Data: NullValue - Test data"; + assertEquals(expectedResult, AlertTemplateUtil.render(template, param)); + } + + @Test + void renderComplexNestedStructure() { + Map param = new HashMap<>(); + Map alert = new HashMap<>(); + Map monitor = new HashMap<>(); + Map target = new HashMap<>(); + Map metadata = new HashMap<>(); + + metadata.put("version", "1.0"); + metadata.put("author", "system"); + target.put("host", "192.168.1.100"); + target.put("port", 8080); + target.put("metadata", metadata); + monitor.put("name", "HTTP Monitor"); + monitor.put("target", target); + alert.put("monitor", monitor); + alert.put("severity", "CRITICAL"); + + param.put("alert", alert); + + String template = "Alert: ${alert.severity} from ${alert.monitor.name} targeting ${alert.monitor.target.host}:${alert.monitor.target.port} (v${alert.monitor.target.metadata.version})"; + String expectedResult = "Alert: CRITICAL from HTTP Monitor targeting 192.168.1.100:8080 (v1.0)"; + assertEquals(expectedResult, AlertTemplateUtil.render(template, param)); + } + + @Test + void renderWithDifferentDataTypes() { + Map param = new HashMap<>(); + Map metrics = new HashMap<>(); + + metrics.put("cpu", 85.5); + metrics.put("memory", 1024); + metrics.put("active", true); + metrics.put("count", 42L); + + param.put("metrics", metrics); + + String template = "CPU: ${metrics.cpu}%, Memory: ${metrics.memory}MB, Active: ${metrics.active}, Count: ${metrics.count}"; + String expectedResult = "CPU: 85.5%, Memory: 1024MB, Active: true, Count: 42"; + assertEquals(expectedResult, AlertTemplateUtil.render(template, param)); + } + + // Helper classes for testing object property access + public static class TestObject { + private String name; + private int value; + private TestNestedObject nested; + + public TestObject(String name, int value) { + this.name = name; + this.value = value; + } + + public String getName() { + return name; + } + + public int getValue() { + return value; + } + + public TestNestedObject getNested() { + return nested; + } + + public void setNested(TestNestedObject nested) { + this.nested = nested; + } + } + + public static class TestNestedObject { + private String description; + private boolean enabled; + + public TestNestedObject(String description, boolean enabled) { + this.description = description; + this.enabled = enabled; + } + + public String getDescription() { + return description; + } + + public boolean isEnabled() { + return enabled; + } + } + + @Test + void renderWithObjectProperties() { + Map param = new HashMap<>(); + TestNestedObject nested = new TestNestedObject("Test description", true); + TestObject obj = new TestObject("TestName", 123); + obj.setNested(nested); + + param.put("object", obj); + + String template = "Object: ${object.name} (value: ${object.value}) - ${object.nested.description}, enabled: ${object.nested.enabled}"; + String expectedResult = "Object: TestName (value: 123) - Test description, enabled: true"; + assertEquals(expectedResult, AlertTemplateUtil.render(template, param)); + } + + @Test + void renderWithMixedMapAndObjectProperties() { + Map param = new HashMap<>(); + Map config = new HashMap<>(); + TestObject obj = new TestObject("ConfigObject", 456); + + config.put("object", obj); + config.put("type", "mixed"); + param.put("config", config); + + String template = "Config type: ${config.type}, object name: ${config.object.name}, value: ${config.object.value}"; + String expectedResult = "Config type: mixed, object name: ConfigObject, value: 456"; + assertEquals(expectedResult, AlertTemplateUtil.render(template, param)); + } } diff --git a/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/export/NettyDataQueue.java b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/export/NettyDataQueue.java index 1e5830e5262..2328496b916 100644 --- a/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/export/NettyDataQueue.java +++ b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/export/NettyDataQueue.java @@ -19,6 +19,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.hertzbeat.collector.dispatch.entrance.internal.CollectJobService; +import org.apache.hertzbeat.common.entity.log.LogEntry; import org.apache.hertzbeat.common.entity.message.CollectRep; import org.apache.hertzbeat.common.queue.CommonDataQueue; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -87,4 +88,24 @@ public void sendMetricsDataToStorage(CollectRep.MetricsData metricsData) { public void sendServiceDiscoveryData(CollectRep.MetricsData metricsData) { collectJobService.sendAsyncServiceDiscoveryData(metricsData); } + + @Override + public void sendLogEntry(LogEntry logEntry) { + + } + + @Override + public LogEntry pollLogEntry() throws InterruptedException { + return null; + } + + @Override + public void sendLogEntryToStorage(LogEntry logEntry) { + + } + + @Override + public LogEntry pollLogEntryToStorage() throws InterruptedException { + return null; + } } diff --git a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/cache/CacheFactory.java b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/cache/CacheFactory.java index 5f8776b72f2..a58321ba698 100644 --- a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/cache/CacheFactory.java +++ b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/cache/CacheFactory.java @@ -82,26 +82,58 @@ public static void clearAlertSilenceCache() { } /** - * get alert define cache + * get metrics alert define cache * @return caffeine cache */ @SuppressWarnings("unchecked") - public static List getAlertDefineCache() { - return (List) COMMON_CACHE.get(CommonConstants.CACHE_ALERT_DEFINE); + public static List getMetricsAlertDefineCache() { + return (List) COMMON_CACHE.get(CommonConstants.METRIC_CACHE_ALERT_DEFINE); } /** - * set alert define cache + * set metrics alert define cache * @param alertDefines alert defines */ - public static void setAlertDefineCache(List alertDefines) { - COMMON_CACHE.put(CommonConstants.CACHE_ALERT_DEFINE, alertDefines); + public static void setMetricsAlertDefineCache(List alertDefines) { + COMMON_CACHE.put(CommonConstants.METRIC_CACHE_ALERT_DEFINE, alertDefines); } + /** + * clear metrics alert define cache + */ + public static void clearMetricsAlertDefineCache() { + COMMON_CACHE.remove(CommonConstants.METRIC_CACHE_ALERT_DEFINE); + } + + /** + * get log alert define cache + * @return caffeine cache + */ + @SuppressWarnings("unchecked") + public static List getLogAlertDefineCache() { + return (List) COMMON_CACHE.get(CommonConstants.LOG_CACHE_ALERT_DEFINE); + } + + /** + * set log alert define cache + * @param alertDefines alert defines + */ + public static void setLogAlertDefineCache(List alertDefines) { + COMMON_CACHE.put(CommonConstants.LOG_CACHE_ALERT_DEFINE, alertDefines); + } + + /** + * clear log alert define cache + */ + public static void clearLogAlertDefineCache() { + COMMON_CACHE.remove(CommonConstants.LOG_CACHE_ALERT_DEFINE); + } + /** * clear alert define cache */ public static void clearAlertDefineCache() { - COMMON_CACHE.remove(CommonConstants.CACHE_ALERT_DEFINE); + clearLogAlertDefineCache(); + clearMetricsAlertDefineCache(); } } diff --git a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/config/CommonProperties.java b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/config/CommonProperties.java index b35fb54ce71..0c7186fc305 100644 --- a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/config/CommonProperties.java +++ b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/config/CommonProperties.java @@ -115,6 +115,11 @@ public static class RedisProperties { */ private String alertsDataQueueName; + /** + * Queue name for log entry data + */ + private String logEntryQueueName; + } /** @@ -140,5 +145,13 @@ public static class KafkaProperties extends BaseKafkaProperties { * alerts data topic */ private String alertsDataTopic; + /** + * log entry data topic + */ + private String logEntryDataTopic; + /** + * log entry data to storage topic + */ + private String logEntryDataToStorageTopic; } } diff --git a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/constants/CommonConstants.java b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/constants/CommonConstants.java index 592d31b7310..4ec8efcde51 100644 --- a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/constants/CommonConstants.java +++ b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/constants/CommonConstants.java @@ -153,14 +153,24 @@ public interface CommonConstants { String ALERT_STATUS_PENDING = "pending"; /** - * alert threshold type: realtime + * metric alert threshold type: realtime */ - String ALERT_THRESHOLD_TYPE_REALTIME = "realtime"; + String METRIC_ALERT_THRESHOLD_TYPE_REALTIME = "realtime_metric"; /** - * alert threshold type: periodic + * metric alert threshold type: periodic */ - String ALERT_THRESHOLD_TYPE_PERIODIC = "periodic"; + String METRIC_ALERT_THRESHOLD_TYPE_PERIODIC = "periodic_metric"; + + /** + * log alert threshold type: realtime + */ + String LOG_ALERT_THRESHOLD_TYPE_REALTIME = "realtime_log"; + + /** + * log alert threshold type: periodic + */ + String LOG_ALERT_THRESHOLD_TYPE_PERIODIC = "periodic_log"; /** * Field parameter type: number @@ -238,9 +248,14 @@ public interface CommonConstants { String CACHE_ALERT_SILENCE = "alert_silence"; /** - * cache key alert define + * cache key metric alert define + */ + String METRIC_CACHE_ALERT_DEFINE = "metric_alert_define"; + + /** + * cache key log alert define */ - String CACHE_ALERT_DEFINE = "alert_define"; + String LOG_CACHE_ALERT_DEFINE = "log_alert_define"; /** * cache key alert converge diff --git a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/entity/alerter/AlertDefine.java b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/entity/alerter/AlertDefine.java index de058ec5314..6e087db6e27 100644 --- a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/entity/alerter/AlertDefine.java +++ b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/entity/alerter/AlertDefine.java @@ -65,7 +65,7 @@ public class AlertDefine { @NotNull private String name; - @Schema(title = "Rule Type: realtime, periodic", example = "0") + @Schema(title = "Rule Type: realtime_metric, periodic_metric, realtime_log, periodic_log", example = "realtime_metric") private String type; @Schema(title = "Alarm Threshold Expr", example = "usage>90", accessMode = READ_WRITE) @@ -73,7 +73,7 @@ public class AlertDefine { @Column(length = 2048) private String expr; - @Schema(title = "Execution Period (seconds) - For periodic rules", example = "300") + @Schema(title = "Execution Period/ Window Size (seconds) - For periodic rules/ For log realtime", example = "300") private Integer period; @Schema(title = "Alarm Trigger Times.The alarm is triggered only after the required number of times is reached", @@ -88,7 +88,7 @@ public class AlertDefine { @Schema(title = "Annotations", example = "summary: High CPU usage") @Convert(converter = JsonMapAttributeConverter.class) - @Column(length = 4096) + @Column(length = 2048) private Map annotations; @Schema(title = "Alert Content Template", example = "Instance {{ $labels.instance }} CPU usage is {{ $value }}%") @@ -100,6 +100,11 @@ public class AlertDefine { @Size(max = 100) private String datasource; + @Schema(title = "Query Expression", example = "SELECT * FROM metrics WHERE value > 90") + @Size(max = 2048) + @Column(length = 2048) + private String queryExpr; + @Schema(title = "Is Enabled", example = "true") private boolean enable = true; diff --git a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/entity/log/LogEntry.java b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/entity/log/LogEntry.java new file mode 100644 index 00000000000..970d04e7357 --- /dev/null +++ b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/entity/log/LogEntry.java @@ -0,0 +1,172 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.common.entity.log; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.Map; + +/** + * OpenTelemetry Log Entry entity based on OpenTelemetry log data model specification. + * + * @see OpenTelemetry Log Data Model + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LogEntry { + + /** + * Time when the event occurred. + * Value is UNIX Epoch time in nanoseconds since 00:00:00 UTC on 1 January 1970. + */ + private Long timeUnixNano; + + /** + * Time when the event was observed. + * Value is UNIX Epoch time in nanoseconds since 00:00:00 UTC on 1 January 1970. + */ + private Long observedTimeUnixNano; + + /** + * Numerical value of the severity. + * Smaller numerical values correspond to less severe events (such as debug events), + * larger numerical values correspond to more severe events (such as errors and critical events). + */ + private Integer severityNumber; + + /** + * The severity text (also known as log level). + * This is the original string representation of the severity as it is known at the source. + */ + private String severityText; + + /** + * A value containing the body of the log record. + * Can be for example a human-readable string message (including multi-line) + * or it can be a structured data composed of arrays and maps of other values. + */ + private Object body; + + /** + * Additional information about the specific event occurrence. + * Unlike the Resource field, these are NOT fixed for the lifetime of the process. + */ + private Map attributes; + + /** + * Dropped attributes count. + * Number of attributes that were discarded due to limits being exceeded. + */ + private Integer droppedAttributesCount; + + /** + * A unique identifier for a trace. + * All spans from the same trace share the same trace_id. + * The ID is a 16-byte array represented as a hex string. + */ + private String traceId; + + /** + * A unique identifier for a span within a trace. + * The ID is an 8-byte array represented as a hex string. + */ + private String spanId; + + /** + * Trace flag as defined in W3C Trace Context specification. + * At the time of writing the specification defines one flag - the SAMPLED flag. + */ + private Integer traceFlags; + + /** + * Resource information. + * Information about the entity producing the telemetry. + */ + private Map resource; + + /** + * Instrumentation Scope information. + * Information about the instrumentation scope that emitted the log. + */ + private InstrumentationScope instrumentationScope; + + /** + * Instrumentation Scope information for logs. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class InstrumentationScope { + + /** + * The name of the instrumentation scope. + * This should be the fully-qualified name of the instrumentation library. + */ + private String name; + + /** + * The version of the instrumentation scope. + */ + private String version; + + /** + * Additional attributes that describe the scope. + */ + private Map attributes; + + /** + * Number of attributes that were discarded due to limits being exceeded. + */ + private Integer droppedAttributesCount; + } + + /** + * Convenience method to get timestamp as Instant. + */ + public Instant getTimestamp() { + return timeUnixNano != null ? Instant.ofEpochSecond(timeUnixNano / 1_000_000_000L, timeUnixNano % 1_000_000_000L) : null; + } + + /** + * Convenience method to get observed timestamp as Instant. + */ + public Instant getObservedTimestamp() { + return observedTimeUnixNano != null ? Instant.ofEpochSecond(observedTimeUnixNano / 1_000_000_000L, observedTimeUnixNano % 1_000_000_000L) : null; + } + + /** + * Convenience method to set timestamp from Instant. + */ + public void setTimestamp(Instant timestamp) { + this.timeUnixNano = timestamp != null ? timestamp.getEpochSecond() * 1_000_000_000L + timestamp.getNano() : null; + } + + /** + * Convenience method to set observed timestamp from Instant. + */ + public void setObservedTimestamp(Instant timestamp) { + this.observedTimeUnixNano = timestamp != null ? timestamp.getEpochSecond() * 1_000_000_000L + timestamp.getNano() : null; + } +} diff --git a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/CommonDataQueue.java b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/CommonDataQueue.java index fd234fa27d4..4abbc29b145 100644 --- a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/CommonDataQueue.java +++ b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/CommonDataQueue.java @@ -17,6 +17,7 @@ package org.apache.hertzbeat.common.queue; +import org.apache.hertzbeat.common.entity.log.LogEntry; import org.apache.hertzbeat.common.entity.message.CollectRep; /** @@ -62,4 +63,32 @@ public interface CommonDataQueue { * @param metricsData service discovery data */ void sendServiceDiscoveryData(CollectRep.MetricsData metricsData); + + /** + * send log entry to queue + * @param logEntry log entry data based on OpenTelemetry log data model + * @throws InterruptedException when sending is interrupted + */ + void sendLogEntry(LogEntry logEntry); + + /** + * poll log entry from queue + * @return log entry data + * @throws InterruptedException when poll timeout + */ + LogEntry pollLogEntry() throws InterruptedException; + + /** + * send log entry to storage queue + * @param logEntry log entry data based on OpenTelemetry log data model + * @throws InterruptedException when sending is interrupted + */ + void sendLogEntryToStorage(LogEntry logEntry); + + /** + * poll log entry from storage queue + * @return log entry data + * @throws InterruptedException when poll timeout + */ + LogEntry pollLogEntryToStorage() throws InterruptedException; } diff --git a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/impl/InMemoryCommonDataQueue.java b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/impl/InMemoryCommonDataQueue.java index 396935f2d05..bb8f61a6cfa 100644 --- a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/impl/InMemoryCommonDataQueue.java +++ b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/impl/InMemoryCommonDataQueue.java @@ -22,6 +22,7 @@ import java.util.concurrent.LinkedBlockingQueue; import lombok.extern.slf4j.Slf4j; import org.apache.hertzbeat.common.constants.DataQueueConstants; +import org.apache.hertzbeat.common.entity.log.LogEntry; import org.apache.hertzbeat.common.entity.message.CollectRep; import org.apache.hertzbeat.common.queue.CommonDataQueue; import org.springframework.beans.factory.DisposableBean; @@ -46,17 +47,23 @@ public class InMemoryCommonDataQueue implements CommonDataQueue, DisposableBean private final LinkedBlockingQueue metricsDataToAlertQueue; private final LinkedBlockingQueue metricsDataToStorageQueue; private final LinkedBlockingQueue serviceDiscoveryDataQueue; + private final LinkedBlockingQueue logEntryQueue; + private final LinkedBlockingQueue logEntryToStorageQueue; public InMemoryCommonDataQueue() { metricsDataToAlertQueue = new LinkedBlockingQueue<>(); metricsDataToStorageQueue = new LinkedBlockingQueue<>(); serviceDiscoveryDataQueue = new LinkedBlockingQueue<>(); + logEntryQueue = new LinkedBlockingQueue<>(); + logEntryToStorageQueue = new LinkedBlockingQueue<>(); } public Map getQueueSizeMetricsInfo() { Map metrics = new HashMap<>(8); metrics.put("metricsDataToAlertQueue", metricsDataToAlertQueue.size()); metrics.put("metricsDataToStorageQueue", metricsDataToStorageQueue.size()); + metrics.put("logEntryQueue", logEntryQueue.size()); + metrics.put("logEntryToStorageQueue", logEntryToStorageQueue.size()); return metrics; } @@ -90,10 +97,32 @@ public void sendServiceDiscoveryData(CollectRep.MetricsData metricsData) { serviceDiscoveryDataQueue.offer(metricsData); } + @Override + public void sendLogEntry(LogEntry logEntry) { + logEntryQueue.offer(logEntry); + } + + @Override + public LogEntry pollLogEntry() throws InterruptedException { + return logEntryQueue.take(); + } + + @Override + public void sendLogEntryToStorage(LogEntry logEntry) { + logEntryToStorageQueue.offer(logEntry); + } + + @Override + public LogEntry pollLogEntryToStorage() throws InterruptedException { + return logEntryToStorageQueue.take(); + } + @Override public void destroy() { metricsDataToAlertQueue.clear(); metricsDataToStorageQueue.clear(); serviceDiscoveryDataQueue.clear(); + logEntryQueue.clear(); + logEntryToStorageQueue.clear(); } } diff --git a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/impl/KafkaCommonDataQueue.java b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/impl/KafkaCommonDataQueue.java index d829178b0db..b29b8dbbac3 100644 --- a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/impl/KafkaCommonDataQueue.java +++ b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/impl/KafkaCommonDataQueue.java @@ -26,8 +26,11 @@ import lombok.extern.slf4j.Slf4j; import org.apache.hertzbeat.common.config.CommonProperties; import org.apache.hertzbeat.common.constants.DataQueueConstants; +import org.apache.hertzbeat.common.entity.log.LogEntry; import org.apache.hertzbeat.common.entity.message.CollectRep; import org.apache.hertzbeat.common.queue.CommonDataQueue; +import org.apache.hertzbeat.common.serialize.KafkaLogEntryDeserializer; +import org.apache.hertzbeat.common.serialize.KafkaLogEntrySerializer; import org.apache.hertzbeat.common.serialize.KafkaMetricsDataDeserializer; import org.apache.hertzbeat.common.serialize.KafkaMetricsDataSerializer; import org.apache.kafka.clients.consumer.ConsumerConfig; @@ -59,14 +62,20 @@ public class KafkaCommonDataQueue implements CommonDataQueue, DisposableBean { private final ReentrantLock metricDataToAlertLock = new ReentrantLock(); private final ReentrantLock metricDataToStorageLock = new ReentrantLock(); private final ReentrantLock serviceDiscoveryDataLock = new ReentrantLock(); + private final ReentrantLock logEntryLock = new ReentrantLock(); + private final ReentrantLock logEntryToStorageLock = new ReentrantLock(); private final LinkedBlockingQueue metricsDataToAlertQueue; private final LinkedBlockingQueue metricsDataToStorageQueue; private final LinkedBlockingQueue serviceDiscoveryDataQueue; + private final LinkedBlockingQueue logEntryQueue; + private final LinkedBlockingQueue logEntryToStorageQueue; private final CommonProperties.KafkaProperties kafka; private KafkaProducer metricsDataProducer; + private KafkaProducer logEntryProducer; private KafkaConsumer metricsDataToAlertConsumer; private KafkaConsumer metricsDataToStorageConsumer; private KafkaConsumer serviceDiscoveryDataConsumer; + private KafkaConsumer logEntryConsumer; public KafkaCommonDataQueue(CommonProperties properties) { if (properties == null || properties.getQueue() == null || properties.getQueue().getKafka() == null) { @@ -77,6 +86,8 @@ public KafkaCommonDataQueue(CommonProperties properties) { metricsDataToAlertQueue = new LinkedBlockingQueue<>(); metricsDataToStorageQueue = new LinkedBlockingQueue<>(); serviceDiscoveryDataQueue = new LinkedBlockingQueue<>(); + logEntryQueue = new LinkedBlockingQueue<>(); + logEntryToStorageQueue = new LinkedBlockingQueue<>(); initDataQueue(); } @@ -87,6 +98,7 @@ private void initDataQueue() { producerConfig.put(ProducerConfig.ACKS_CONFIG, "all"); producerConfig.put(ProducerConfig.RETRIES_CONFIG, 3); metricsDataProducer = new KafkaProducer<>(producerConfig, new LongSerializer(), new KafkaMetricsDataSerializer()); + logEntryProducer = new KafkaProducer<>(producerConfig, new LongSerializer(), new KafkaLogEntrySerializer()); Map consumerConfig = new HashMap<>(4); consumerConfig.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getServers()); @@ -111,6 +123,11 @@ private void initDataQueue() { serviceDiscoveryDataConsumer = new KafkaConsumer<>(serviceDiscoveryDataConsumerConfig, new LongDeserializer(), new KafkaMetricsDataDeserializer()); serviceDiscoveryDataConsumer.subscribe(Collections.singletonList(kafka.getServiceDiscoveryDataTopic())); + + Map logEntryConsumerConfig = new HashMap<>(consumerConfig); + logEntryConsumerConfig.put("group.id", "log-entry-consumer"); + logEntryConsumer = new KafkaConsumer<>(logEntryConsumerConfig, new LongDeserializer(), new KafkaLogEntryDeserializer()); + logEntryConsumer.subscribe(Collections.singletonList(kafka.getLogEntryDataTopic())); } catch (Exception e) { log.error("please config common.queue.kafka props correctly", e); throw e; @@ -192,6 +209,50 @@ public void sendServiceDiscoveryData(CollectRep.MetricsData metricsData) { } } + @Override + public void sendLogEntry(LogEntry logEntry) { + if (logEntryProducer != null) { + try { + ProducerRecord record = new ProducerRecord<>(kafka.getLogEntryDataTopic(), logEntry); + logEntryProducer.send(record); + } catch (Exception e) { + log.error("Failed to send LogEntry to Kafka: {}", e.getMessage()); + // Fallback to memory queue if Kafka fails + logEntryQueue.offer(logEntry); + } + } else { + log.warn("logEntryProducer is not enabled, using memory queue"); + logEntryQueue.offer(logEntry); + } + } + + @Override + public LogEntry pollLogEntry() throws InterruptedException { + return genericPollDataFunction(logEntryQueue, logEntryConsumer, logEntryLock); + } + + @Override + public void sendLogEntryToStorage(LogEntry logEntry) { + if (logEntryProducer != null) { + try { + ProducerRecord record = new ProducerRecord<>(kafka.getLogEntryDataToStorageTopic(), logEntry); + logEntryProducer.send(record); + } catch (Exception e) { + log.error("Failed to send LogEntry to storage via Kafka: {}", e.getMessage()); + // Fallback to memory queue if Kafka fails + logEntryToStorageQueue.offer(logEntry); + } + } else { + log.warn("logEntryProducer is not enabled, using memory queue for storage"); + logEntryToStorageQueue.offer(logEntry); + } + } + + @Override + public LogEntry pollLogEntryToStorage() throws InterruptedException { + return genericPollDataFunction(logEntryToStorageQueue, logEntryConsumer, logEntryToStorageLock); + } + @Override public void destroy() throws Exception { if (metricsDataProducer != null) { @@ -206,5 +267,11 @@ public void destroy() throws Exception { if (serviceDiscoveryDataConsumer != null) { serviceDiscoveryDataConsumer.close(); } + if (logEntryProducer != null) { + logEntryProducer.close(); + } + if (logEntryConsumer != null) { + logEntryConsumer.close(); + } } } diff --git a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/impl/RedisCommonDataQueue.java b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/impl/RedisCommonDataQueue.java index 696350f0ca4..9bf51facd59 100644 --- a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/impl/RedisCommonDataQueue.java +++ b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/queue/impl/RedisCommonDataQueue.java @@ -24,8 +24,10 @@ import lombok.extern.slf4j.Slf4j; import org.apache.hertzbeat.common.config.CommonProperties; import org.apache.hertzbeat.common.constants.DataQueueConstants; +import org.apache.hertzbeat.common.entity.log.LogEntry; import org.apache.hertzbeat.common.entity.message.CollectRep; import org.apache.hertzbeat.common.queue.CommonDataQueue; +import org.apache.hertzbeat.common.serialize.RedisLogEntryCodec; import org.apache.hertzbeat.common.serialize.RedisMetricsDataCodec; import org.springframework.beans.factory.DisposableBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -46,9 +48,13 @@ public class RedisCommonDataQueue implements CommonDataQueue, DisposableBean { private final RedisClient redisClient; private final StatefulRedisConnection connection; private final RedisCommands syncCommands; + private final StatefulRedisConnection logEntryConnection; + private final RedisCommands logEntrySyncCommands; private final String metricsDataQueueNameToStorage; private final String metricsDataQueueNameForServiceDiscovery; private final String metricsDataQueueNameToAlerter; + private final String logEntryQueueName; + private final String logEntryToStorageQueueName; private final CommonProperties.RedisProperties redisProperties; public RedisCommonDataQueue(CommonProperties properties) { @@ -69,9 +75,14 @@ public RedisCommonDataQueue(CommonProperties properties) { RedisMetricsDataCodec codec = new RedisMetricsDataCodec(); this.connection = redisClient.connect(codec); this.syncCommands = connection.sync(); + RedisLogEntryCodec logCodec = new RedisLogEntryCodec(); + this.logEntryConnection = redisClient.connect(logCodec); + this.logEntrySyncCommands = logEntryConnection.sync(); this.metricsDataQueueNameToStorage = redisProperties.getMetricsDataQueueNameToPersistentStorage(); this.metricsDataQueueNameForServiceDiscovery = redisProperties.getMetricsDataQueueNameForServiceDiscovery(); this.metricsDataQueueNameToAlerter = redisProperties.getMetricsDataQueueNameToAlerter(); + this.logEntryQueueName = redisProperties.getLogEntryQueueName(); + this.logEntryToStorageQueueName = redisProperties.getLogEntryQueueName() + "_storage"; } @Override @@ -131,9 +142,48 @@ public void sendServiceDiscoveryData(CollectRep.MetricsData metricsData) { } } + @Override + public void sendLogEntry(LogEntry logEntry) { + try { + logEntrySyncCommands.lpush(logEntryQueueName, logEntry); + } catch (Exception e) { + log.error("Failed to send LogEntry to Redis: {}", e.getMessage()); + } + } + + @Override + public LogEntry pollLogEntry() throws InterruptedException { + try { + return logEntrySyncCommands.rpop(logEntryQueueName); + } catch (Exception e) { + log.error("Failed to poll LogEntry from Redis: {}", e.getMessage()); + throw new InterruptedException("Failed to poll LogEntry from Redis"); + } + } + + @Override + public void sendLogEntryToStorage(LogEntry logEntry) { + try { + logEntrySyncCommands.lpush(logEntryToStorageQueueName, logEntry); + } catch (Exception e) { + log.error("Failed to send LogEntry to storage via Redis: {}", e.getMessage()); + } + } + + @Override + public LogEntry pollLogEntryToStorage() throws InterruptedException { + try { + return logEntrySyncCommands.rpop(logEntryToStorageQueueName); + } catch (Exception e) { + log.error("Failed to poll LogEntry from storage via Redis: {}", e.getMessage()); + throw new InterruptedException("Failed to poll LogEntry from storage via Redis"); + } + } + @Override public void destroy() { connection.close(); + logEntryConnection.close(); redisClient.shutdown(); } diff --git a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/serialize/KafkaLogEntryDeserializer.java b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/serialize/KafkaLogEntryDeserializer.java new file mode 100644 index 00000000000..ef19f4c74c5 --- /dev/null +++ b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/serialize/KafkaLogEntryDeserializer.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.common.serialize; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.common.entity.log.LogEntry; +import org.apache.kafka.common.header.Headers; +import org.apache.kafka.common.serialization.Deserializer; + +/** + * Kafka LogEntry deserializer using JSON + */ +@Slf4j +public class KafkaLogEntryDeserializer implements Deserializer { + + private final ObjectMapper objectMapper; + + public KafkaLogEntryDeserializer() { + this.objectMapper = new ObjectMapper(); + } + + @Override + public void configure(Map configs, boolean isKey) { + Deserializer.super.configure(configs, isKey); + } + + @Override + public LogEntry deserialize(String topic, byte[] data) { + if (data == null || data.length == 0) { + log.warn("Empty data received for topic: {}", topic); + return null; + } + try { + String jsonString = new String(data, StandardCharsets.UTF_8); + return objectMapper.readValue(jsonString, LogEntry.class); + } catch (JsonProcessingException e) { + log.error("Failed to deserialize LogEntry from JSON for topic: {}", topic, e); + return null; + } + } + + @Override + public LogEntry deserialize(String topic, Headers headers, byte[] data) { + return deserialize(topic, data); + } + + @Override + public void close() { + Deserializer.super.close(); + } +} \ No newline at end of file diff --git a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/serialize/KafkaLogEntrySerializer.java b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/serialize/KafkaLogEntrySerializer.java new file mode 100644 index 00000000000..e64b7588a2e --- /dev/null +++ b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/serialize/KafkaLogEntrySerializer.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.common.serialize; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.common.entity.log.LogEntry; +import org.apache.kafka.common.header.Headers; +import org.apache.kafka.common.serialization.Serializer; + +/** + * Kafka LogEntry serializer using JSON + */ +@Slf4j +public class KafkaLogEntrySerializer implements Serializer { + + private final ObjectMapper objectMapper; + + public KafkaLogEntrySerializer() { + this.objectMapper = new ObjectMapper(); + } + + @Override + public void configure(Map configs, boolean isKey) { + Serializer.super.configure(configs, isKey); + } + + @Override + public byte[] serialize(String topic, LogEntry logEntry) { + if (logEntry == null) { + log.warn("LogEntry is null for topic: {}", topic); + return null; + } + try { + String jsonString = objectMapper.writeValueAsString(logEntry); + return jsonString.getBytes(StandardCharsets.UTF_8); + } catch (JsonProcessingException e) { + log.error("Failed to serialize LogEntry to JSON for topic: {}", topic, e); + return null; + } + } + + @Override + public byte[] serialize(String topic, Headers headers, LogEntry logEntry) { + return serialize(topic, logEntry); + } + + @Override + public void close() { + Serializer.super.close(); + } +} \ No newline at end of file diff --git a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/serialize/RedisLogEntryCodec.java b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/serialize/RedisLogEntryCodec.java new file mode 100644 index 00000000000..82d4312ca3d --- /dev/null +++ b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/serialize/RedisLogEntryCodec.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.common.serialize; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.lettuce.core.codec.RedisCodec; +import io.netty.buffer.Unpooled; +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.common.entity.log.LogEntry; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +/** + * Redis LogEntry codec using JSON serialization + */ +@Slf4j +public class RedisLogEntryCodec implements RedisCodec { + + private final ObjectMapper objectMapper; + + public RedisLogEntryCodec() { + this.objectMapper = new ObjectMapper(); + } + + @Override + public String decodeKey(ByteBuffer byteBuffer) { + return Unpooled.wrappedBuffer(byteBuffer).toString(StandardCharsets.UTF_8); + } + + @Override + public LogEntry decodeValue(ByteBuffer byteBuffer) { + if (byteBuffer == null || !byteBuffer.hasRemaining()) { + return null; + } + try { + String jsonString = Unpooled.wrappedBuffer(byteBuffer).toString(StandardCharsets.UTF_8); + return objectMapper.readValue(jsonString, LogEntry.class); + } catch (Exception e) { + log.error("Failed to decode LogEntry from JSON: {}", e.getMessage()); + return null; + } + } + + @Override + public ByteBuffer encodeKey(String s) { + return ByteBuffer.wrap(s.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public ByteBuffer encodeValue(LogEntry logEntry) { + try { + String jsonString = objectMapper.writeValueAsString(logEntry); + return ByteBuffer.wrap(jsonString.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + log.error("Failed to encode LogEntry to JSON: {}", e.getMessage()); + return null; + } + } +} \ No newline at end of file diff --git a/hertzbeat-common/src/test/java/org/apache/hertzbeat/common/queue/impl/KafkaCommonDataQueueTest.java b/hertzbeat-common/src/test/java/org/apache/hertzbeat/common/queue/impl/KafkaCommonDataQueueTest.java index 206a63dfc36..f756122dcab 100644 --- a/hertzbeat-common/src/test/java/org/apache/hertzbeat/common/queue/impl/KafkaCommonDataQueueTest.java +++ b/hertzbeat-common/src/test/java/org/apache/hertzbeat/common/queue/impl/KafkaCommonDataQueueTest.java @@ -70,6 +70,8 @@ void setUp() throws Exception { // Set all required topics when(kafkaProperties.getMetricsDataTopic()).thenReturn("metricsDataTopic"); + when(kafkaProperties.getLogEntryDataTopic()).thenReturn("logEntryDataTopic"); + when(kafkaProperties.getLogEntryDataToStorageTopic()).thenReturn("logEntryDataToStorageTopic"); when(kafkaProperties.getAlertsDataTopic()).thenReturn("alertsDataTopic"); when(kafkaProperties.getMetricsDataToStorageTopic()).thenReturn("metricsDataToStorageTopic"); when(kafkaProperties.getServiceDiscoveryDataTopic()).thenReturn("serviceDiscoveryDataTopic"); diff --git a/hertzbeat-common/src/test/java/org/apache/hertzbeat/common/queue/impl/RedisCommonDataQueueTest.java b/hertzbeat-common/src/test/java/org/apache/hertzbeat/common/queue/impl/RedisCommonDataQueueTest.java index 846b399515b..2f9b1499b61 100644 --- a/hertzbeat-common/src/test/java/org/apache/hertzbeat/common/queue/impl/RedisCommonDataQueueTest.java +++ b/hertzbeat-common/src/test/java/org/apache/hertzbeat/common/queue/impl/RedisCommonDataQueueTest.java @@ -28,7 +28,9 @@ import io.lettuce.core.api.StatefulRedisConnection; import io.lettuce.core.api.sync.RedisCommands; import org.apache.hertzbeat.common.config.CommonProperties; +import org.apache.hertzbeat.common.entity.log.LogEntry; import org.apache.hertzbeat.common.entity.message.CollectRep; +import org.apache.hertzbeat.common.serialize.RedisLogEntryCodec; import org.apache.hertzbeat.common.serialize.RedisMetricsDataCodec; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -46,6 +48,9 @@ class RedisCommonDataQueueTest { @Mock private StatefulRedisConnection connection; + @Mock + private StatefulRedisConnection logEntryConnection; + @Mock private RedisCommands syncCommands; @@ -71,6 +76,8 @@ public void setUp() { try (MockedStatic mockedRedisClient = mockStatic(RedisClient.class)) { mockedRedisClient.when(() -> RedisClient.create(any(RedisURI.class))).thenReturn(redisClient); when(redisClient.connect(any(RedisMetricsDataCodec.class))).thenReturn(connection); + when(redisClient.connect(any(RedisLogEntryCodec.class))).thenReturn(logEntryConnection); + when(logEntryConnection.sync()).thenReturn(mock(RedisCommands.class)); when(connection.sync()).thenReturn(syncCommands); redisCommonDataQueue = new RedisCommonDataQueue(commonProperties); diff --git a/hertzbeat-log/pom.xml b/hertzbeat-log/pom.xml new file mode 100644 index 00000000000..e1859501b1d --- /dev/null +++ b/hertzbeat-log/pom.xml @@ -0,0 +1,72 @@ + + + + + org.apache.hertzbeat + hertzbeat + 2.0-SNAPSHOT + + 4.0.0 + + hertzbeat-log + ${project.artifactId} + + + + + org.apache.hertzbeat + hertzbeat-base + provided + + + + org.apache.hertzbeat + hertzbeat-warehouse + + + + org.springframework.boot + spring-boot-starter-web + provided + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + provided + + + + io.opentelemetry.proto + opentelemetry-proto + + + + com.google.protobuf + protobuf-java + + + + com.google.protobuf + protobuf-java-util + + + + \ No newline at end of file diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/LogIngestionController.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/LogIngestionController.java new file mode 100644 index 00000000000..125b10904da --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/LogIngestionController.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.log.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import java.util.List; + +import org.apache.hertzbeat.common.constants.CommonConstants; +import org.apache.hertzbeat.common.entity.dto.Message; +import org.apache.hertzbeat.log.service.LogProtocolAdapter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Log Ingestion Controller + */ +@Tag(name = "Log Ingestion Controller") +@RestController +@RequestMapping(path = "/api/logs", produces = "application/json") +@Slf4j +public class LogIngestionController { + + private static final String DEFAULT_PROTOCOL = "otlp"; + private final List protocolAdapters; + + public LogIngestionController(List protocolAdapters) { + this.protocolAdapters = protocolAdapters; + } + + /** + * Receive log payload pushed from external system specifying the log protocol. + * Examples: + * - POST /api/logs/ingest/otlp (content body is OTLP JSON) + * + * @param protocol log protocol identifier + * @param content raw request body + */ + @PostMapping("/ingest/{protocol}") + public ResponseEntity> ingestExternLog(@PathVariable("protocol") String protocol, + @RequestBody String content) { + log.info("Receive extern log from protocol: {}, content length: {}", protocol, content == null ? 0 : content.length()); + if (!StringUtils.hasText(protocol)) { + protocol = DEFAULT_PROTOCOL; // Default to OTLP if no protocol specified + } + for (LogProtocolAdapter adapter : protocolAdapters) { + if (adapter.supportProtocol().equalsIgnoreCase(protocol)) { + try { + adapter.ingest(content); + return ResponseEntity.ok(Message.success("Add extern log success")); + } catch (Exception e) { + log.error("Add extern log failed: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Message.fail(CommonConstants.FAIL_CODE, "Add extern log failed: " + e.getMessage())); + } + } + } + log.warn("Not support extern log from protocol: {}", protocol); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Message.fail(CommonConstants.FAIL_CODE, "Not support the " + protocol + " protocol log")); + } + + /** + * Receive default log payload (when protocol is not specified). + * It will look for a service whose supportProtocol() returns "otlp". + */ + @PostMapping("/ingest") + public ResponseEntity> ingestDefaultExternLog(@RequestBody String content) { + log.info("Receive default extern log content, length: {}", content == null ? 0 : content.length()); + LogProtocolAdapter adapter = protocolAdapters.stream() + .filter(item -> DEFAULT_PROTOCOL.equalsIgnoreCase(item.supportProtocol())) + .findFirst() + .orElse(null); + if (adapter != null) { + try { + adapter.ingest(content); + return ResponseEntity.ok(Message.success("Add extern log success")); + } catch (Exception e) { + log.error("Add extern log failed: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Message.fail(CommonConstants.FAIL_CODE, "Add extern log failed: " + e.getMessage())); + } + } + log.error("Not support default extern log protocol"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Message.fail(CommonConstants.FAIL_CODE, "Not support the default protocol log")); + } +} diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/LogManagerController.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/LogManagerController.java new file mode 100644 index 00000000000..1b93dd51e27 --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/LogManagerController.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.log.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.common.entity.dto.Message; +import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataWriter; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static org.apache.hertzbeat.common.constants.CommonConstants.FAIL_CODE; + +/** + * Controller for managing log entries in HertzBeat. + */ +@RestController +@RequestMapping("/api/logs") +@Tag(name = "Log Management Controller") +@Slf4j +public class LogManagerController { + + private final HistoryDataWriter historyDataWriter; + + public LogManagerController(HistoryDataWriter historyDataWriter) { + this.historyDataWriter = historyDataWriter; + } + + @DeleteMapping + @Operation(summary = "Batch delete logs", + description = "Batch delete logs by time timestamps. Deletes multiple log entries based on their Unix nanosecond timestamps.") + public ResponseEntity> batchDelete( + @Parameter(description = "List of Unix nanosecond timestamps for logs to delete", example = "1640995200000000000") + @RequestParam(required = false) List timeUnixNanos) { + boolean result = historyDataWriter.batchDeleteLogs(timeUnixNanos); + if (result) { + return ResponseEntity.ok(Message.success("Logs deleted successfully")); + } else { + return ResponseEntity.ok(Message.fail(FAIL_CODE, "Failed to delete logs")); + } + } +} diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/LogQueryController.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/LogQueryController.java new file mode 100644 index 00000000000..d8932ffde38 --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/LogQueryController.java @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.log.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.common.entity.dto.Message; +import org.apache.hertzbeat.common.entity.log.LogEntry; +import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataWriter; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Log query and statistics APIs for UI consumption + */ +@RestController +@RequestMapping("/api/logs") +@Tag(name = "Log Query Controller") +@Slf4j +public class LogQueryController { + + private final HistoryDataWriter historyDataWriter; + + public LogQueryController(HistoryDataWriter historyDataWriter) { + this.historyDataWriter = historyDataWriter; + } + + @GetMapping("/list") + @Operation(summary = "Query logs by time range with optional filters", + description = "Query logs by [start,end] in ms and optional filters with pagination. Returns paginated log entries sorted by timestamp in descending order.") + public ResponseEntity>> list( + @Parameter(description = "Start timestamp in milliseconds (Unix timestamp)", example = "1640995200000") + @RequestParam(value = "start", required = false) Long start, + @Parameter(description = "End timestamp in milliseconds (Unix timestamp)", example = "1641081600000") + @RequestParam(value = "end", required = false) Long end, + @Parameter(description = "Trace ID for distributed tracing", example = "1234567890abcdef") + @RequestParam(value = "traceId", required = false) String traceId, + @Parameter(description = "Span ID for distributed tracing", example = "abcdef1234567890") + @RequestParam(value = "spanId", required = false) String spanId, + @Parameter(description = "Log severity number (1-24 according to OpenTelemetry standard)", example = "9") + @RequestParam(value = "severityNumber", required = false) Integer severityNumber, + @Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO") + @RequestParam(value = "severityText", required = false) String severityText, + @Parameter(description = "Page index starting from 0", example = "0") + @RequestParam(value = "pageIndex", required = false, defaultValue = "0") Integer pageIndex, + @Parameter(description = "Number of items per page", example = "20") + @RequestParam(value = "pageSize", required = false, defaultValue = "20") Integer pageSize) { + Page result = getPagedLogs(start, end, traceId, spanId, severityNumber, severityText, pageIndex, pageSize); + return ResponseEntity.ok(Message.success(result)); + } + + @GetMapping("/stats/overview") + @Operation(summary = "Log overview statistics", + description = "Overall counts and basic statistics with filters. Provides counts by severity levels according to OpenTelemetry standard.") + public ResponseEntity>> overviewStats( + @Parameter(description = "Start timestamp in milliseconds (Unix timestamp)", example = "1640995200000") + @RequestParam(value = "start", required = false) Long start, + @Parameter(description = "End timestamp in milliseconds (Unix timestamp)", example = "1641081600000") + @RequestParam(value = "end", required = false) Long end, + @Parameter(description = "Trace ID for distributed tracing", example = "1234567890abcdef") + @RequestParam(value = "traceId", required = false) String traceId, + @Parameter(description = "Span ID for distributed tracing", example = "abcdef1234567890") + @RequestParam(value = "spanId", required = false) String spanId, + @Parameter(description = "Log severity number (1-24 according to OpenTelemetry standard)", example = "9") + @RequestParam(value = "severityNumber", required = false) Integer severityNumber, + @Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO") + @RequestParam(value = "severityText", required = false) String severityText) { + List logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText); + + Map overview = new HashMap<>(); + overview.put("totalCount", logs.size()); + + // Count by severity levels according to OpenTelemetry standard + // TRACE: 1-4, DEBUG: 5-8, INFO: 9-12, WARN: 13-16, ERROR: 17-20, FATAL: 21-24 + long fatalCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 21 && log.getSeverityNumber() <= 24).count(); + long errorCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 17 && log.getSeverityNumber() <= 20).count(); + long warnCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 13 && log.getSeverityNumber() <= 16).count(); + long infoCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 9 && log.getSeverityNumber() <= 12).count(); + long debugCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 5 && log.getSeverityNumber() <= 8).count(); + long traceCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 1 && log.getSeverityNumber() <= 4).count(); + + overview.put("fatalCount", fatalCount); + overview.put("errorCount", errorCount); + overview.put("warnCount", warnCount); + overview.put("infoCount", infoCount); + overview.put("debugCount", debugCount); + overview.put("traceCount", traceCount); + + return ResponseEntity.ok(Message.success(overview)); + } + + @GetMapping("/stats/trace-coverage") + @Operation(summary = "Trace coverage statistics", + description = "Statistics about trace information availability. Shows how many logs have trace IDs, span IDs, or both for distributed tracing analysis.") + public ResponseEntity>> traceCoverageStats( + @Parameter(description = "Start timestamp in milliseconds (Unix timestamp)", example = "1640995200000") + @RequestParam(value = "start", required = false) Long start, + @Parameter(description = "End timestamp in milliseconds (Unix timestamp)", example = "1641081600000") + @RequestParam(value = "end", required = false) Long end, + @Parameter(description = "Trace ID for distributed tracing", example = "1234567890abcdef") + @RequestParam(value = "traceId", required = false) String traceId, + @Parameter(description = "Span ID for distributed tracing", example = "abcdef1234567890") + @RequestParam(value = "spanId", required = false) String spanId, + @Parameter(description = "Log severity number (1-24 according to OpenTelemetry standard)", example = "9") + @RequestParam(value = "severityNumber", required = false) Integer severityNumber, + @Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO") + @RequestParam(value = "severityText", required = false) String severityText) { + List logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText); + + Map result = new HashMap<>(); + + // Trace coverage statistics + long withTraceId = logs.stream().filter(log -> log.getTraceId() != null && !log.getTraceId().isEmpty()).count(); + long withSpanId = logs.stream().filter(log -> log.getSpanId() != null && !log.getSpanId().isEmpty()).count(); + long withBothTraceAndSpan = logs.stream().filter(log -> + log.getTraceId() != null && !log.getTraceId().isEmpty() + && log.getSpanId() != null && !log.getSpanId().isEmpty()).count(); + long withoutTrace = logs.size() - withTraceId; + + Map traceCoverage = new HashMap<>(); + traceCoverage.put("withTrace", withTraceId); + traceCoverage.put("withoutTrace", withoutTrace); + traceCoverage.put("withSpan", withSpanId); + traceCoverage.put("withBothTraceAndSpan", withBothTraceAndSpan); + + result.put("traceCoverage", traceCoverage); + return ResponseEntity.ok(Message.success(result)); + } + + @GetMapping("/stats/trend") + @Operation(summary = "Log trend over time", + description = "Count logs by hour intervals with filters. Groups logs by hour and provides time-series data for trend analysis.") + public ResponseEntity>> trendStats( + @Parameter(description = "Start timestamp in milliseconds (Unix timestamp)", example = "1640995200000") + @RequestParam(value = "start", required = false) Long start, + @Parameter(description = "End timestamp in milliseconds (Unix timestamp)", example = "1641081600000") + @RequestParam(value = "end", required = false) Long end, + @Parameter(description = "Trace ID for distributed tracing", example = "1234567890abcdef") + @RequestParam(value = "traceId", required = false) String traceId, + @Parameter(description = "Span ID for distributed tracing", example = "abcdef1234567890") + @RequestParam(value = "spanId", required = false) String spanId, + @Parameter(description = "Log severity number (1-24 according to OpenTelemetry standard)", example = "9") + @RequestParam(value = "severityNumber", required = false) Integer severityNumber, + @Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO") + @RequestParam(value = "severityText", required = false) String severityText) { + List logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText); + + // Group by hour + Map hourlyStats = logs.stream() + .filter(log -> log.getTimeUnixNano() != null) + .collect(Collectors.groupingBy( + log -> { + long timestampMs = log.getTimeUnixNano() / 1_000_000L; + LocalDateTime dateTime = LocalDateTime.ofInstant( + Instant.ofEpochMilli(timestampMs), + ZoneId.systemDefault()); + return dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00")); + }, + Collectors.counting())); + + Map result = new HashMap<>(); + result.put("hourlyStats", hourlyStats); + return ResponseEntity.ok(Message.success(result)); + } + + private List getFilteredLogs(Long start, Long end, String traceId, String spanId, + Integer severityNumber, String severityText) { + // Use the new multi-condition query method + return historyDataWriter.queryLogsByMultipleConditions(start, end, traceId, spanId, severityNumber, severityText); + } + + private Page getPagedLogs(Long start, Long end, String traceId, String spanId, + Integer severityNumber, String severityText, Integer pageIndex, Integer pageSize) { + // Calculate pagination parameters + int offset = pageIndex * pageSize; + + // Get total count and paginated data + long totalElements = historyDataWriter.countLogsByMultipleConditions(start, end, traceId, spanId, severityNumber, severityText); + List pagedLogs = historyDataWriter.queryLogsByMultipleConditionsWithPagination( + start, end, traceId, spanId, severityNumber, severityText, offset, pageSize); + + // Create PageRequest (sorted by timestamp descending) + Sort sort = Sort.by(Sort.Direction.DESC, "timeUnixNano"); + PageRequest pageRequest = PageRequest.of(pageIndex, pageSize, sort); + + // Return Spring Data Page object + return new PageImpl<>(pagedLogs, pageRequest, totalElements); + } +} + + diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/LogSseController.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/LogSseController.java new file mode 100644 index 00000000000..ba76865ce0c --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/LogSseController.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hertzbeat.log.controller; + +import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE; +import org.apache.hertzbeat.common.util.SnowFlakeIdGenerator; +import org.apache.hertzbeat.log.notice.LogSseFilterCriteria; +import org.apache.hertzbeat.log.notice.LogSseManager; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import io.swagger.v3.oas.annotations.Operation; + +/** + * SSE controller for log streaming with filtering support + */ +@RestController +@RequestMapping(path = "/api/log/sse", produces = {TEXT_EVENT_STREAM_VALUE}) +public class LogSseController { + + private final LogSseManager emitterManager; + + public LogSseController(LogSseManager emitterManager) { + this.emitterManager = emitterManager; + } + + /** + * Subscribe to log events with optional filtering + * @param filterCriteria Filter criteria for log events (all parameters are optional) + * @return SSE emitter for streaming log events + */ + @GetMapping(path = "/subscribe") + @Operation(summary = "Subscribe to log events with optional filtering", description = "Subscribe to log events with optional filtering") + public SseEmitter subscribe(@ModelAttribute LogSseFilterCriteria filterCriteria) { + Long clientId = SnowFlakeIdGenerator.generateId(); + return emitterManager.createEmitter(clientId, filterCriteria); + } +} \ No newline at end of file diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/notice/LogSseFilterCriteria.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/notice/LogSseFilterCriteria.java new file mode 100644 index 00000000000..6a7453a0e80 --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/notice/LogSseFilterCriteria.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hertzbeat.log.notice; + +import lombok.Data; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import org.apache.hertzbeat.common.entity.log.LogEntry; +import org.springframework.util.StringUtils; +import io.swagger.v3.oas.annotations.media.Schema; +import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_WRITE; + +/** + * Log filtering criteria for SSE (Server-Sent Events) log streaming + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@Schema(description = "Log filtering criteria for SSE (Server-Sent Events) log streaming") +public class LogSseFilterCriteria { + /** + * Numerical value of the severity. + * Smaller numerical values correspond to less severe events (such as debug events), + * larger numerical values correspond to more severe events (such as errors and critical events). + */ + @Schema(description = "Numerical value of the severity.", example = "1", accessMode = READ_WRITE) + private Integer severityNumber; + + /** + * The severity text (also known as log level). + * This is the original string representation of the severity as it is known at the source. + */ + @Schema(description = "The severity text (also known as log level).", example = "INFO", accessMode = READ_WRITE) + private String severityText; + + /** + * A unique identifier for a trace. + * All spans from the same trace share the same trace_id. + * The ID is a 16-byte array represented as a hex string. + */ + @Schema(description = "A unique identifier for a trace.", example = "1234567890", accessMode = READ_WRITE) + private String traceId; + + /** + * A unique identifier for a span within a trace. + * The ID is an 8-byte array represented as a hex string. + */ + @Schema(description = "A unique identifier for a span.", example = "1234567890", accessMode = READ_WRITE) + private String spanId; + + + /** + * Core filtering logic to determine if a log entry matches the criteria + * @param log Log entry to be checked + * @return boolean Whether the log entry matches the filter criteria + */ + public boolean matches(LogEntry log) { + // Check severity text match + if (StringUtils.hasText(severityText) && !severityText.equalsIgnoreCase(log.getSeverityText())) { + return false; + } + + // Check severity number match (if both are present) + if (severityNumber != null && log.getSeverityNumber() != null + && !severityNumber.equals(log.getSeverityNumber())) { + return false; + } + + // Check trace ID match + if (StringUtils.hasText(traceId) && !traceId.equalsIgnoreCase(log.getTraceId())) { + return false; + } + + // Check span ID match + if (StringUtils.hasText(spanId) && !spanId.equalsIgnoreCase(log.getSpanId())) { + return false; + } + return true; + } +} \ No newline at end of file diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/notice/LogSseManager.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/notice/LogSseManager.java new file mode 100644 index 00000000000..6f497bdccbe --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/notice/LogSseManager.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hertzbeat.log.notice; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.common.entity.log.LogEntry; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * SSE manager for log + */ +@Component +@Slf4j +public class LogSseManager { + private final Map emitters = new ConcurrentHashMap<>(); + + /** + * Create a new SSE emitter for a client with specified filters + * @param clientId The unique identifier for the client + * @param filters The filters to apply to the log data + * @return The SSE emitter + */ + public SseEmitter createEmitter(Long clientId, LogSseFilterCriteria filters) { + SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); + emitter.onCompletion(() -> removeEmitter(clientId)); + emitter.onTimeout(() -> removeEmitter(clientId)); + emitter.onError((ex) -> removeEmitter(clientId)); + + SseSubscriber subscriber = new SseSubscriber(emitter, filters); + emitters.put(clientId, subscriber); + return emitter; + } + + /** + * Broadcast log data to all subscribers + * @param logEntry The log data to broadcast + */ + @Async + public void broadcast(LogEntry logEntry) { + emitters.forEach((clientId, subscriber) -> { + try { + // Check if the log entry matches the subscriber's filter criteria + if (subscriber.filters == null || subscriber.filters.matches(logEntry)) { + subscriber.emitter.send(SseEmitter.event() + .id(String.valueOf(System.currentTimeMillis())) + .name("LOG_EVENT") + .data(logEntry)); + } + } catch (IOException | IllegalStateException e) { + subscriber.emitter.complete(); + removeEmitter(clientId); + } catch (Exception exception) { + log.error("Failed to broadcast log to client: {}", exception.getMessage()); + subscriber.emitter.complete(); + removeEmitter(clientId); + } + }); + } + + private void removeEmitter(Long clientId) { + emitters.remove(clientId); + } + + /** + * SSE subscriber + */ + @Data + @AllArgsConstructor + @NoArgsConstructor + private static class SseSubscriber { + /** + * The SSE emitter for streaming log events + */ + SseEmitter emitter; + /** + * The filters for streaming log events + */ + LogSseFilterCriteria filters; + } +} diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/LogProtocolAdapter.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/LogProtocolAdapter.java new file mode 100644 index 00000000000..c12987e2e44 --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/LogProtocolAdapter.java @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.log.service; + +/** + * Adapter interface for ingesting logs pushed via different protocols + * (e.g. OTLP, Loki, Filebeat, Vector). + * Implementations should: + * 1. Parse raw HTTP payload of their protocol. + * 2. Convert data to LogEntry. + * 3. Forward / persist it to downstream pipeline. + */ +public interface LogProtocolAdapter { + + /** + * Ingest raw log payload pushed from external system. + * + * @param content raw request body string + */ + void ingest(String content); + + /** + * Identifier of the protocol this adapter supports ("otlp", "vector", etc.) + */ + String supportProtocol(); +} \ No newline at end of file diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/impl/OtlpLogProtocolAdapter.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/impl/OtlpLogProtocolAdapter.java new file mode 100644 index 00000000000..27ca1a24efe --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/impl/OtlpLogProtocolAdapter.java @@ -0,0 +1,261 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.log.service.impl; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.util.JsonFormat; +import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest; +import io.opentelemetry.proto.common.v1.AnyValue; +import io.opentelemetry.proto.common.v1.KeyValue; +import io.opentelemetry.proto.common.v1.KeyValueList; +import io.opentelemetry.proto.logs.v1.LogRecord; +import io.opentelemetry.proto.logs.v1.ResourceLogs; +import io.opentelemetry.proto.logs.v1.ScopeLogs; +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.common.entity.log.LogEntry; +import org.apache.hertzbeat.common.queue.CommonDataQueue; +import org.apache.hertzbeat.log.notice.LogSseManager; +import org.apache.hertzbeat.log.service.LogProtocolAdapter; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Adapter for OpenTelemetry OTLP/HTTP JSON log ingestion. + */ +@Slf4j +@Service +public class OtlpLogProtocolAdapter implements LogProtocolAdapter { + + private static final String PROTOCOL_NAME = "otlp"; + + private final CommonDataQueue commonDataQueue; + private final LogSseManager logSseManager; + + public OtlpLogProtocolAdapter(CommonDataQueue commonDataQueue, LogSseManager logSseManager) { + this.commonDataQueue = commonDataQueue; + this.logSseManager = logSseManager; + } + + @Override + public void ingest(String content) { + if (content == null || content.isEmpty()) { + log.warn("Received empty OTLP log payload - skip processing."); + return; + } + ExportLogsServiceRequest.Builder builder = ExportLogsServiceRequest.newBuilder(); + try { + JsonFormat.parser().ignoringUnknownFields().merge(content, builder); + ExportLogsServiceRequest request = builder.build(); + + // Extract LogEntry instances from the request + List logEntries = extractLogEntries(request); + log.debug("Successfully extracted {} log entries from OTLP payload {}", logEntries.size(), content); + + logEntries.forEach(entry -> { + commonDataQueue.sendLogEntry(entry); + logSseManager.broadcast(entry); + log.info("Log entry sent to queue: {}", entry); + }); + + } catch (InvalidProtocolBufferException e) { + log.error("Failed to parse OTLP log payload: {}", e.getMessage()); + throw new IllegalArgumentException("Invalid OTLP log content", e); + } + } + + /** + * Extract LogEntry instances from ExportLogsServiceRequest. + * + * @param request the OTLP export logs service request + * @return list of extracted log entries + */ + private List extractLogEntries(ExportLogsServiceRequest request) { + List logEntries = new ArrayList<>(); + + for (ResourceLogs resourceLogs : request.getResourceLogsList()) { + // Extract resource attributes + Map resourceAttributes = extractAttributes( + resourceLogs.getResource().getAttributesList() + ); + + for (ScopeLogs scopeLogs : resourceLogs.getScopeLogsList()) { + // Extract instrumentation scope information + LogEntry.InstrumentationScope instrumentationScope = extractInstrumentationScope(scopeLogs); + + for (LogRecord logRecord : scopeLogs.getLogRecordsList()) { + LogEntry logEntry = convertLogRecordToLogEntry( + logRecord, + resourceAttributes, + instrumentationScope + ); + logEntries.add(logEntry); + } + } + } + + return logEntries; + } + + /** + * Convert OpenTelemetry LogRecord to LogEntry. + */ + private LogEntry convertLogRecordToLogEntry( + LogRecord logRecord, + Map resourceAttributes, + LogEntry.InstrumentationScope instrumentationScope) { + + return LogEntry.builder() + .timeUnixNano(logRecord.getTimeUnixNano()) + .observedTimeUnixNano(logRecord.getObservedTimeUnixNano()) + .severityNumber(logRecord.getSeverityNumberValue()) + .severityText(logRecord.getSeverityText()) + .body(extractBody(logRecord.getBody())) + .attributes(extractAttributes(logRecord.getAttributesList())) + .droppedAttributesCount(logRecord.getDroppedAttributesCount()) + .traceId(bytesToHex(logRecord.getTraceId().toByteArray())) + .spanId(bytesToHex(logRecord.getSpanId().toByteArray())) + .traceFlags(logRecord.getFlags()) + .resource(resourceAttributes) + .instrumentationScope(instrumentationScope) + .build(); + } + + /** + * Extract instrumentation scope information from ScopeLogs. + */ + private LogEntry.InstrumentationScope extractInstrumentationScope(ScopeLogs scopeLogs) { + if (!scopeLogs.hasScope()) { + return null; + } + + var scope = scopeLogs.getScope(); + return LogEntry.InstrumentationScope.builder() + .name(scope.getName()) + .version(scope.getVersion()) + .attributes(extractAttributes(scope.getAttributesList())) + .droppedAttributesCount(scope.getDroppedAttributesCount()) + .build(); + } + + /** + * Extract attributes from a list of KeyValue pairs. + */ + private Map extractAttributes(List keyValueList) { + if (keyValueList == null || keyValueList.isEmpty()) { + return new HashMap<>(); + } + + AnyValue anyValue = AnyValue.newBuilder() + .setKvlistValue(KeyValueList.newBuilder() + .addAllValues(keyValueList) + .build()) + .build(); + Object extractedAnyValue = extractAnyValue(anyValue); + if (extractedAnyValue instanceof Map genericMap) { + Map resultMap = new HashMap<>(); + for (Map.Entry entry : genericMap.entrySet()) { + if (entry.getKey() instanceof String) { + resultMap.put((String) entry.getKey(), entry.getValue()); + } + } + return resultMap; + } else { + return new HashMap<>(); + } + } + + /** + * Extract body content from AnyValue. + */ + private Object extractBody(AnyValue body) { + return extractAnyValue(body); + } + + /** + * Extract value from OpenTelemetry AnyValue. + */ + private Object extractAnyValue(AnyValue anyValue) { + switch (anyValue.getValueCase()) { + case STRING_VALUE: + return anyValue.getStringValue(); + case BOOL_VALUE: + return anyValue.getBoolValue(); + case INT_VALUE: + return anyValue.getIntValue(); + case DOUBLE_VALUE: + return anyValue.getDoubleValue(); + case ARRAY_VALUE: + List arrayList = new ArrayList<>(); + for (AnyValue item : anyValue.getArrayValue().getValuesList()) { + arrayList.add(extractAnyValue(item)); + } + return arrayList; + case KVLIST_VALUE: + Map kvMap = new HashMap<>(); + for (KeyValue kv : anyValue.getKvlistValue().getValuesList()) { + kvMap.put(normalizeKey(kv.getKey()), extractAnyValue(kv.getValue())); + } + return kvMap; + case BYTES_VALUE: + return anyValue.getBytesValue().toByteArray(); + case VALUE_NOT_SET: + default: + return null; + } + } + + /** + * Normalize key by replacing dots and spaces with underscores. + * + * @param key the original key + * @return normalized key with dots and spaces replaced by underscores + */ + private String normalizeKey(String key) { + if (key == null) { + return null; + } + return key.replace(".", "_").replace(" ", "_"); + } + + /** + * Convert byte array to hex string. + */ + private String bytesToHex(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + return null; + } + StringBuilder hexString = new StringBuilder(); + for (byte b : bytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } + + @Override + public String supportProtocol() { + return PROTOCOL_NAME; + } +} \ No newline at end of file diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/LogIngestionControllerTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/LogIngestionControllerTest.java new file mode 100644 index 00000000000..1e41c23b792 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/LogIngestionControllerTest.java @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.log.controller; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +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.result.MockMvcResultHandlers.print; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; + +import org.apache.hertzbeat.common.constants.CommonConstants; +import org.apache.hertzbeat.common.entity.log.LogEntry; +import org.apache.hertzbeat.common.util.JsonUtil; +import org.apache.hertzbeat.log.service.LogProtocolAdapter; +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.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +/** + * Unit test for {@link LogIngestionController} + */ +@ExtendWith(MockitoExtension.class) +class LogIngestionControllerTest { + + private MockMvc mockMvc; + + @Mock + private LogProtocolAdapter otlpAdapter; + + private LogIngestionController logIngestionController; + + @BeforeEach + void setUp() { + List adapters = Arrays.asList(otlpAdapter); + this.logIngestionController = new LogIngestionController(adapters); + this.mockMvc = MockMvcBuilders.standaloneSetup(logIngestionController).build(); + } + + @Test + void testIngestExternLogWithOtlpProtocol() throws Exception { + LogEntry logEntry = LogEntry.builder() + .timeUnixNano(1734005477630L) + .severityNumber(1) + .severityText("INFO") + .body("Test log message") + .attributes(new HashMap<>()) + .build(); + + when(otlpAdapter.supportProtocol()).thenReturn("otlp"); + + mockMvc.perform( + MockMvcRequestBuilders + .post("/api/logs/ingest/otlp") + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.toJson(logEntry)) + ) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE)) + .andExpect(jsonPath("$.msg").value("Add extern log success")) + .andReturn(); + } + + @Test + void testIngestExternLogWithUnsupportedProtocol() throws Exception { + String unsupportedLogContent = "{\"message\":\"Unsupported protocol log\"}"; + + when(otlpAdapter.supportProtocol()).thenReturn("otlp"); + + mockMvc.perform( + MockMvcRequestBuilders + .post("/api/logs/ingest/unsupported") + .contentType(MediaType.APPLICATION_JSON) + .content(unsupportedLogContent) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE)) + .andExpect(jsonPath("$.msg").value("Not support the unsupported protocol log")); + } + + @Test + void testIngestDefaultExternLog() throws Exception { + LogEntry logEntry = LogEntry.builder() + .timeUnixNano(1734005477630L) + .severityNumber(2) + .severityText("WARN") + .body("Default protocol log message") + .attributes(new HashMap<>()) + .build(); + + when(otlpAdapter.supportProtocol()).thenReturn("otlp"); + + mockMvc.perform( + MockMvcRequestBuilders + .post("/api/logs/ingest") + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.toJson(logEntry)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE)) + .andExpect(jsonPath("$.msg").value("Add extern log success")); + } + + @Test + void testIngestDefaultExternLogWithAdapterException() throws Exception { + String logContent = "{\"message\":\"Default log message that will cause exception\"}"; + + when(otlpAdapter.supportProtocol()).thenReturn("otlp"); + Mockito.doThrow(new IllegalArgumentException("Invalid log format")).when(otlpAdapter).ingest(anyString()); + + mockMvc.perform( + MockMvcRequestBuilders + .post("/api/logs/ingest") + .contentType(MediaType.APPLICATION_JSON) + .content(logContent) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE)) + .andExpect(jsonPath("$.msg").value("Add extern log failed: Invalid log format")); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/OtlpLogProtocolAdapterTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/OtlpLogProtocolAdapterTest.java new file mode 100644 index 00000000000..4f72671e8e4 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/OtlpLogProtocolAdapterTest.java @@ -0,0 +1,296 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.log.service.impl; + +import com.google.protobuf.ByteString; +import com.google.protobuf.util.JsonFormat; +import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest; +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.resource.v1.Resource; +import io.opentelemetry.proto.common.v1.InstrumentationScope; +import org.apache.hertzbeat.common.entity.log.LogEntry; +import org.apache.hertzbeat.common.queue.CommonDataQueue; +import org.apache.hertzbeat.log.notice.LogSseManager; +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 java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Unit tests for OtlpLogProtocolAdapter. + */ +@ExtendWith(MockitoExtension.class) +class OtlpLogProtocolAdapterTest { + + @Mock + private CommonDataQueue commonDataQueue; + + @Mock + private LogSseManager logSseManager; + + private OtlpLogProtocolAdapter adapter; + + @BeforeEach + void setUp() { + adapter = new OtlpLogProtocolAdapter(commonDataQueue, logSseManager); + } + + @Test + void testIngestWithNullContent() { + adapter.ingest(null); + verifyNoInteractions(commonDataQueue, logSseManager); + } + + @Test + void testIngestWithEmptyContent() { + adapter.ingest(""); + verifyNoInteractions(commonDataQueue, logSseManager); + } + + @Test + void testIngestWithValidOtlpLogData() throws Exception { + String otlpPayload = createValidOtlpLogPayload(); + + adapter.ingest(otlpPayload); + + ArgumentCaptor logEntryCaptor = ArgumentCaptor.forClass(LogEntry.class); + verify(commonDataQueue, times(1)).sendLogEntry(logEntryCaptor.capture()); + verify(logSseManager, times(1)).broadcast(logEntryCaptor.capture()); + + LogEntry capturedEntry = logEntryCaptor.getValue(); + assertNotNull(capturedEntry); + assertEquals("test-service", capturedEntry.getResource().get("service_name")); + assertEquals("test-version", capturedEntry.getResource().get("service_version")); + assertEquals("test-scope", capturedEntry.getInstrumentationScope().getName()); + assertEquals("1.0.0", capturedEntry.getInstrumentationScope().getVersion()); + assertEquals("test log message", capturedEntry.getBody()); + assertEquals("INFO", capturedEntry.getSeverityText()); + assertEquals(9, capturedEntry.getSeverityNumber()); + } + + @Test + void testIngestWithMultipleLogRecords() throws Exception { + String otlpPayload = createOtlpPayloadWithMultipleLogs(); + + adapter.ingest(otlpPayload); + + verify(commonDataQueue, times(2)).sendLogEntry(any(LogEntry.class)); + verify(logSseManager, times(2)).broadcast(any(LogEntry.class)); + } + + @Test + void testIngestWithComplexAttributes() throws Exception { + String otlpPayload = createOtlpPayloadWithComplexAttributes(); + + adapter.ingest(otlpPayload); + + verify(commonDataQueue, times(1)).sendLogEntry(any(LogEntry.class)); + verify(logSseManager, times(1)).broadcast(any(LogEntry.class)); + + ArgumentCaptor logEntryCaptor = ArgumentCaptor.forClass(LogEntry.class); + verify(commonDataQueue).sendLogEntry(logEntryCaptor.capture()); + + LogEntry capturedEntry = logEntryCaptor.getValue(); + assertNotNull(capturedEntry); + + Map attributes = capturedEntry.getAttributes(); + assertEquals("string_value", attributes.get("string_attr")); + assertEquals(true, attributes.get("bool_attr")); + assertEquals(42L, attributes.get("int_attr")); + assertEquals(3.14, attributes.get("double_attr")); + + List arrayAttr = (List) attributes.get("array_attr"); + assertNotNull(arrayAttr); + assertEquals(3, arrayAttr.size()); + assertEquals("item1", arrayAttr.get(0)); + assertEquals("item2", arrayAttr.get(1)); + assertEquals("item3", arrayAttr.get(2)); + } + + @Test + void testIngestWithTraceAndSpanIds() throws Exception { + String otlpPayload = createOtlpPayloadWithTraceSpanIds(); + + adapter.ingest(otlpPayload); + + verify(commonDataQueue, times(1)).sendLogEntry(any(LogEntry.class)); + + ArgumentCaptor logEntryCaptor = ArgumentCaptor.forClass(LogEntry.class); + verify(commonDataQueue).sendLogEntry(logEntryCaptor.capture()); + + LogEntry capturedEntry = logEntryCaptor.getValue(); + assertEquals("1234567890abcdef1234567890abcdef", capturedEntry.getTraceId()); + assertEquals("1234567890abcdef", capturedEntry.getSpanId()); + assertEquals(1, capturedEntry.getTraceFlags()); + } + + @Test + void testIngestWithInvalidJsonContent() { + String invalidJson = "{ invalid json content }"; + + assertThrows(IllegalArgumentException.class, () -> adapter.ingest(invalidJson)); + verifyNoInteractions(commonDataQueue, logSseManager); + } + + @Test + void testIngestWithEmptyResourceLogs() throws Exception { + String otlpPayload = createEmptyResourceLogsPayload(); + + adapter.ingest(otlpPayload); + + verifyNoInteractions(commonDataQueue, logSseManager); + } + + private String createValidOtlpLogPayload() throws Exception { + ExportLogsServiceRequest request = ExportLogsServiceRequest.newBuilder() + .addResourceLogs(ResourceLogs.newBuilder() + .setResource(Resource.newBuilder() + .addAttributes(KeyValue.newBuilder() + .setKey("service.name") + .setValue(AnyValue.newBuilder().setStringValue("test-service").build()) + .build()) + .addAttributes(KeyValue.newBuilder() + .setKey("service.version") + .setValue(AnyValue.newBuilder().setStringValue("test-version").build()) + .build()) + .build()) + .addScopeLogs(ScopeLogs.newBuilder() + .setScope(InstrumentationScope.newBuilder() + .setName("test-scope") + .setVersion("1.0.0") + .build()) + .addLogRecords(LogRecord.newBuilder() + .setTimeUnixNano(System.currentTimeMillis() * 1_000_000) + .setObservedTimeUnixNano(System.currentTimeMillis() * 1_000_000) + .setSeverityNumberValue(9) + .setSeverityText("INFO") + .setBody(AnyValue.newBuilder().setStringValue("test log message").build()) + .build()) + .build()) + .build()) + .build(); + + return JsonFormat.printer().print(request); + } + + private String createOtlpPayloadWithMultipleLogs() throws Exception { + ExportLogsServiceRequest request = ExportLogsServiceRequest.newBuilder() + .addResourceLogs(ResourceLogs.newBuilder() + .setResource(Resource.newBuilder().build()) + .addScopeLogs(ScopeLogs.newBuilder() + .setScope(InstrumentationScope.newBuilder().build()) + .addLogRecords(LogRecord.newBuilder() + .setTimeUnixNano(System.currentTimeMillis() * 1_000_000) + .setBody(AnyValue.newBuilder().setStringValue("first log").build()) + .build()) + .addLogRecords(LogRecord.newBuilder() + .setTimeUnixNano(System.currentTimeMillis() * 1_000_000) + .setBody(AnyValue.newBuilder().setStringValue("second log").build()) + .build()) + .build()) + .build()) + .build(); + + return JsonFormat.printer().print(request); + } + + private String createOtlpPayloadWithComplexAttributes() throws Exception { + ExportLogsServiceRequest request = ExportLogsServiceRequest.newBuilder() + .addResourceLogs(ResourceLogs.newBuilder() + .setResource(Resource.newBuilder().build()) + .addScopeLogs(ScopeLogs.newBuilder() + .setScope(InstrumentationScope.newBuilder().build()) + .addLogRecords(LogRecord.newBuilder() + .setTimeUnixNano(System.currentTimeMillis() * 1_000_000) + .setBody(AnyValue.newBuilder().setStringValue("complex attributes test").build()) + .addAttributes(KeyValue.newBuilder() + .setKey("string.attr") + .setValue(AnyValue.newBuilder().setStringValue("string_value").build()) + .build()) + .addAttributes(KeyValue.newBuilder() + .setKey("bool.attr") + .setValue(AnyValue.newBuilder().setBoolValue(true).build()) + .build()) + .addAttributes(KeyValue.newBuilder() + .setKey("int.attr") + .setValue(AnyValue.newBuilder().setIntValue(42).build()) + .build()) + .addAttributes(KeyValue.newBuilder() + .setKey("double.attr") + .setValue(AnyValue.newBuilder().setDoubleValue(3.14).build()) + .build()) + .addAttributes(KeyValue.newBuilder() + .setKey("array.attr") + .setValue(AnyValue.newBuilder() + .setArrayValue(io.opentelemetry.proto.common.v1.ArrayValue.newBuilder() + .addValues(AnyValue.newBuilder().setStringValue("item1").build()) + .addValues(AnyValue.newBuilder().setStringValue("item2").build()) + .addValues(AnyValue.newBuilder().setStringValue("item3").build()) + .build()) + .build()) + .build()) + .build()) + .build()) + .build()) + .build(); + + return JsonFormat.printer().print(request); + } + + private String createOtlpPayloadWithTraceSpanIds() throws Exception { + ExportLogsServiceRequest request = ExportLogsServiceRequest.newBuilder() + .addResourceLogs(ResourceLogs.newBuilder() + .setResource(Resource.newBuilder().build()) + .addScopeLogs(ScopeLogs.newBuilder() + .setScope(InstrumentationScope.newBuilder().build()) + .addLogRecords(LogRecord.newBuilder() + .setTimeUnixNano(System.currentTimeMillis() * 1_000_000) + .setBody(AnyValue.newBuilder().setStringValue("trace test").build()) + .setTraceId(ByteString.fromHex("1234567890abcdef1234567890abcdef")) + .setSpanId(ByteString.fromHex("1234567890abcdef")) + .setFlags(1) + .build()) + .build()) + .build()) + .build(); + + return JsonFormat.printer().print(request); + } + + private String createEmptyResourceLogsPayload() throws Exception { + ExportLogsServiceRequest request = ExportLogsServiceRequest.newBuilder().build(); + return JsonFormat.printer().print(request); + } +} diff --git a/hertzbeat-manager/pom.xml b/hertzbeat-manager/pom.xml index f2290d4c826..6140b34d7a6 100644 --- a/hertzbeat-manager/pom.xml +++ b/hertzbeat-manager/pom.xml @@ -89,11 +89,16 @@ org.apache.hertzbeat hertzbeat-grafana - + org.apache.hertzbeat hertzbeat-otel + + + org.apache.hertzbeat + hertzbeat-log + org.springframework.boot diff --git a/hertzbeat-manager/src/main/resources/db/migration/h2/V173__update_column.sql b/hertzbeat-manager/src/main/resources/db/migration/h2/V173__update_column.sql index 469a4b32fed..bc5e8d24eac 100644 --- a/hertzbeat-manager/src/main/resources/db/migration/h2/V173__update_column.sql +++ b/hertzbeat-manager/src/main/resources/db/migration/h2/V173__update_column.sql @@ -19,3 +19,21 @@ -- Modify message column to TEXT (H2 TEXT is equivalent to CLOB) ALTER TABLE HZB_STATUS_PAGE_INCIDENT_CONTENT ALTER COLUMN message CLOB; + +-- Update type from 'realtime' to 'realtime_metric' +UPDATE HZB_ALERT_DEFINE +SET type = 'realtime_metric' +WHERE type = 'realtime'; + +-- Update type from 'periodic' to 'periodic_metric' +UPDATE HZB_ALERT_DEFINE +SET type = 'periodic_metric' +WHERE type = 'periodic'; + +-- Modify annotations column length from 4096 to 2048 +ALTER TABLE HZB_ALERT_DEFINE +ALTER COLUMN annotations VARCHAR(2048); + +-- Add query_expr column if not exists +ALTER TABLE HZB_ALERT_DEFINE +ADD COLUMN IF NOT EXISTS query_expr VARCHAR(2048); diff --git a/hertzbeat-manager/src/main/resources/db/migration/mysql/V173__update_column.sql b/hertzbeat-manager/src/main/resources/db/migration/mysql/V173__update_column.sql index 196ce0dd5ea..9ff9f07f0b6 100644 --- a/hertzbeat-manager/src/main/resources/db/migration/mysql/V173__update_column.sql +++ b/hertzbeat-manager/src/main/resources/db/migration/mysql/V173__update_column.sql @@ -17,8 +17,42 @@ -- ensure every sql can rerun without error --- Modify hzb_status_page_incident_content table columns to TEXT type to resolve MySQL row size limit issue +-- Update hzb_alert_define table type column to support log monitoring and modify annotations/query_expr columns +DELIMITER // +CREATE PROCEDURE UpdateAlertDefineColumns() +BEGIN + DECLARE table_exists INT; + DECLARE column_exists INT; + SELECT COUNT(*) INTO table_exists + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'hzb_alert_define'; + + IF table_exists = 1 THEN + UPDATE hzb_alert_define + SET type = 'realtime_metric' + WHERE type = 'realtime'; + + UPDATE hzb_alert_define + SET type = 'periodic_metric' + WHERE type = 'periodic'; + + ALTER TABLE hzb_alert_define + MODIFY COLUMN annotations VARCHAR(2048); + + SELECT COUNT(*) INTO column_exists + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'hzb_alert_define' AND COLUMN_NAME = 'query_expr'; + + IF column_exists = 0 THEN + ALTER TABLE hzb_alert_define + ADD COLUMN query_expr VARCHAR(2048); + END IF; + END IF; +END // +DELIMITER ; + +-- Modify hzb_status_page_incident_content table columns to TEXT type to resolve MySQL row size limit issue DELIMITER // CREATE PROCEDURE ModifyStatusIncidentContentColumns() BEGIN @@ -44,9 +78,10 @@ BEGIN END IF; END IF; END // - DELIMITER ; +CALL UpdateAlertDefineColumns(); +DROP PROCEDURE IF EXISTS UpdateAlertDefineColumns; CALL ModifyStatusIncidentContentColumns(); DROP PROCEDURE IF EXISTS ModifyStatusIncidentContentColumns; COMMIT; \ No newline at end of file diff --git a/hertzbeat-manager/src/main/resources/db/migration/postgresql/V173__update_column.sql b/hertzbeat-manager/src/main/resources/db/migration/postgresql/V173__update_column.sql index 16836c2aaab..223b40a6a04 100644 --- a/hertzbeat-manager/src/main/resources/db/migration/postgresql/V173__update_column.sql +++ b/hertzbeat-manager/src/main/resources/db/migration/postgresql/V173__update_column.sql @@ -17,7 +17,26 @@ -- ensure every sql can rerun without error +-- Update type from 'realtime' to 'realtime_metric' +UPDATE HZB_ALERT_DEFINE +SET type = 'realtime_metric' +WHERE type = 'realtime'; + +-- Update type from 'periodic' to 'periodic_metric' +UPDATE HZB_ALERT_DEFINE +SET type = 'periodic_metric' +WHERE type = 'periodic'; + +-- Modify annotations column length from 4096 to 2048 +ALTER TABLE HZB_ALERT_DEFINE +ALTER COLUMN annotations TYPE VARCHAR(2048); + +-- Add query_expr column +ALTER TABLE HZB_ALERT_DEFINE +ADD COLUMN IF NOT EXISTS query_expr VARCHAR(2048); + -- Modify message column to TEXT ALTER TABLE HZB_STATUS_PAGE_INCIDENT_CONTENT ALTER COLUMN message TYPE TEXT; -commit; \ No newline at end of file +commit; + diff --git a/hertzbeat-manager/src/main/resources/sureness.yml b/hertzbeat-manager/src/main/resources/sureness.yml index 72467f30b8c..84888062a26 100644 --- a/hertzbeat-manager/src/main/resources/sureness.yml +++ b/hertzbeat-manager/src/main/resources/sureness.yml @@ -73,12 +73,14 @@ resourceRole: # eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth. excludedResource: - /api/alert/sse/**===* + - /api/log/sse/**===* - /api/account/auth/**===* - /api/i18n/**===get - /api/apps/hierarchy===get - /api/push/**===* - /api/status/page/public/**===* - /api/manager/sse/**===* + - /api/logs/ingest/**===* # web ui resource - /===get - /assets/**===get diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/ManagerTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/ManagerTest.java index de3cfdf750b..9c1858fb2cd 100644 --- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/ManagerTest.java +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/ManagerTest.java @@ -23,7 +23,7 @@ import javax.naming.NamingException; import org.apache.hertzbeat.alert.AlerterProperties; import org.apache.hertzbeat.alert.AlerterWorkerPool; -import org.apache.hertzbeat.alert.calculate.RealTimeAlertCalculator; +import org.apache.hertzbeat.alert.calculate.realtime.MetricsRealTimeAlertCalculator; import org.apache.hertzbeat.alert.controller.AlertDefineController; import org.apache.hertzbeat.alert.controller.AlertDefinesController; import org.apache.hertzbeat.alert.controller.AlertsController; @@ -72,7 +72,7 @@ void testAutoImport() { assertNotNull(ctx.getBean(AlertDefineController.class)); assertNotNull(ctx.getBean(AlerterWorkerPool.class)); assertNotNull(ctx.getBean(AlerterProperties.class)); - assertNotNull(ctx.getBean(RealTimeAlertCalculator.class)); + assertNotNull(ctx.getBean(MetricsRealTimeAlertCalculator.class)); assertNotNull(ctx.getBean(AlertsController.class)); assertNotNull(ctx.getBean(AlertDefinesController.class)); diff --git a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/GreptimePromqlQueryExecutor.java b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/GreptimePromqlQueryExecutor.java index 5be5ae4e2a3..1a877c83766 100644 --- a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/GreptimePromqlQueryExecutor.java +++ b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/GreptimePromqlQueryExecutor.java @@ -28,14 +28,14 @@ /** * query executor for victor metrics */ -@Component +@Component("greptimePromqlQueryExecutor") @ConditionalOnProperty(prefix = "warehouse.store.greptime", name = "enabled", havingValue = "true") @Slf4j public class GreptimePromqlQueryExecutor extends PromqlQueryExecutor { private static final String QUERY_PATH = "/v1/prometheus"; - private static final String Datasource = "Greptime"; + private static final String Datasource = "Greptime-promql"; private final GreptimeProperties greptimeProperties; diff --git a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/GreptimeSqlQueryExecutor.java b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/GreptimeSqlQueryExecutor.java new file mode 100644 index 00000000000..7e4c64341bd --- /dev/null +++ b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/GreptimeSqlQueryExecutor.java @@ -0,0 +1,129 @@ +/* + * 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.warehouse.db; + +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.common.constants.NetworkConstants; +import org.apache.hertzbeat.common.constants.SignConstants; +import org.apache.hertzbeat.common.util.Base64Util; +import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties; +import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeSqlQueryContent; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpEntity; +import org.springframework.http.MediaType; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * query executor for GreptimeDB SQL + */ +@Slf4j +@Component("greptimeSqlQueryExecutor") +@ConditionalOnProperty(prefix = "warehouse.store.greptime", name = "enabled", havingValue = "true") +public class GreptimeSqlQueryExecutor extends SqlQueryExecutor { + + private static final String QUERY_PATH = "/v1/sql"; + private static final String DATASOURCE = "Greptime-sql"; + + private final GreptimeProperties greptimeProperties; + + + public GreptimeSqlQueryExecutor(GreptimeProperties greptimeProperties, RestTemplate restTemplate) { + super(restTemplate, new SqlQueryExecutor.HttpSqlProperties(greptimeProperties.httpEndpoint() + QUERY_PATH, + greptimeProperties.username(), greptimeProperties.password())); + this.greptimeProperties = greptimeProperties; + } + + @Override + public List> execute(String queryString) { + List> results = new LinkedList<>(); + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + if (StringUtils.hasText(greptimeProperties.username()) + && StringUtils.hasText(greptimeProperties.password())) { + String authStr = greptimeProperties.username() + ":" + greptimeProperties.password(); + String encodedAuth = Base64Util.encode(authStr); + headers.add(HttpHeaders.AUTHORIZATION, NetworkConstants.BASIC + SignConstants.BLANK + encodedAuth); + } + + String requestBody = "sql=" + queryString; + HttpEntity httpEntity = new HttpEntity<>(requestBody, headers); + + String url = greptimeProperties.httpEndpoint() + QUERY_PATH; + if (StringUtils.hasText(greptimeProperties.database())) { + url += "?db=" + greptimeProperties.database(); + } + + ResponseEntity responseEntity = restTemplate.exchange(url, + HttpMethod.POST, httpEntity, GreptimeSqlQueryContent.class); + + if (responseEntity.getStatusCode().is2xxSuccessful()) { + GreptimeSqlQueryContent responseBody = responseEntity.getBody(); + if (responseBody != null && responseBody.getCode() == 0 + && responseBody.getOutput() != null && !responseBody.getOutput().isEmpty()) { + + for (GreptimeSqlQueryContent.Output output : responseBody.getOutput()) { + if (output.getRecords() != null && output.getRecords().getRows() != null) { + GreptimeSqlQueryContent.Output.Records.Schema schema = output.getRecords().getSchema(); + List> rows = output.getRecords().getRows(); + + for (List row : rows) { + Map rowMap = new HashMap<>(); + if (schema != null && schema.getColumnSchemas() != null) { + for (int i = 0; i < Math.min(schema.getColumnSchemas().size(), row.size()); i++) { + String columnName = schema.getColumnSchemas().get(i).getName(); + Object value = row.get(i); + rowMap.put(columnName, value); + } + } else { + for (int i = 0; i < row.size(); i++) { + rowMap.put("col_" + i, row.get(i)); + } + } + results.add(rowMap); + } + } + } + } + } else { + log.error("query metrics data from greptime failed. {}", responseEntity); + } + } catch (Exception e) { + log.error("query metrics data from greptime error. {}", e.getMessage(), e); + } + return results; + } + + @Override + public String getDatasource() { + return DATASOURCE; + } +} diff --git a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/SqlQueryExecutor.java b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/SqlQueryExecutor.java index c4a2d8e4bd9..6d5478b88b5 100644 --- a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/SqlQueryExecutor.java +++ b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/db/SqlQueryExecutor.java @@ -25,6 +25,7 @@ import static org.apache.hertzbeat.warehouse.constants.WarehouseConstants.SQL; import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; import java.util.List; import java.util.Map; @@ -36,11 +37,23 @@ public abstract class SqlQueryExecutor implements QueryExecutor { private static final String supportQueryLanguage = SQL; + protected final RestTemplate restTemplate; + protected final SqlQueryExecutor.HttpSqlProperties httpSqlProperties; + + SqlQueryExecutor(RestTemplate restTemplate, SqlQueryExecutor.HttpSqlProperties httpSqlProperties) { + this.restTemplate = restTemplate; + this.httpSqlProperties = httpSqlProperties; + } /** - * record class for sql connection + * record class for sql http connection */ - protected record ConnectorSqlProperties () {} + protected record HttpSqlProperties( + String url, + String username, + String password + ) { + } @Override public List> execute(String query) { diff --git a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/DataStorageDispatch.java b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/DataStorageDispatch.java index 222b644f07a..802711065e1 100644 --- a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/DataStorageDispatch.java +++ b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/DataStorageDispatch.java @@ -22,6 +22,7 @@ import jakarta.persistence.PersistenceContext; import lombok.extern.slf4j.Slf4j; import org.apache.hertzbeat.common.constants.CommonConstants; +import org.apache.hertzbeat.common.entity.log.LogEntry; import org.apache.hertzbeat.common.entity.manager.Monitor; import org.apache.hertzbeat.common.entity.message.CollectRep; import org.apache.hertzbeat.common.queue.CommonDataQueue; @@ -62,6 +63,7 @@ public DataStorageDispatch(CommonDataQueue commonDataQueue, this.historyDataWriter = historyDataWriter; this.pluginRunner = pluginRunner; startPersistentDataStorage(); + startLogDataStorage(); } protected void startPersistentDataStorage() { @@ -89,6 +91,32 @@ protected void startPersistentDataStorage() { }; workerPool.executeJob(runnable); } + + protected void startLogDataStorage() { + Runnable runnable = () -> { + Thread.currentThread().setName("warehouse-log-data-storage"); + while (!Thread.currentThread().isInterrupted()) { + try { + LogEntry logEntry = commonDataQueue.pollLogEntryToStorage(); + if (logEntry == null) { + continue; + } + historyDataWriter.ifPresent(dataWriter -> { + try { + dataWriter.saveLogData(logEntry); + } catch (Exception e) { + log.error("Failed to save log entry: {}", e.getMessage(), e); + } + }); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.error("Error in log data storage thread: {}", e.getMessage(), e); + } + } + }; + workerPool.executeJob(runnable); + } protected void calculateMonitorStatus(CollectRep.MetricsData metricsData) { if (metricsData.getPriority() == 0) { diff --git a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/HistoryDataWriter.java b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/HistoryDataWriter.java index b9c12ba95d5..f3308e7f9f7 100644 --- a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/HistoryDataWriter.java +++ b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/HistoryDataWriter.java @@ -17,8 +17,11 @@ package org.apache.hertzbeat.warehouse.store.history.tsdb; +import org.apache.hertzbeat.common.entity.log.LogEntry; import org.apache.hertzbeat.common.entity.message.CollectRep; +import java.util.List; + /** * history data writer */ @@ -34,4 +37,71 @@ public interface HistoryDataWriter { * @param metricsData metrics data */ void saveData(CollectRep.MetricsData metricsData); + + /** + * default save log data + * @param logEntry log entry + */ + default void saveLogData(LogEntry logEntry) { + throw new UnsupportedOperationException("save log data is not supported"); + } + + /** + * Query logs with multiple filter conditions + * @param startTime start time in milliseconds + * @param endTime end time in milliseconds + * @param traceId trace ID filter + * @param spanId span ID filter + * @param severityNumber severity number filter + * @param severityText severity text filter + * @return filtered log entries + */ + default List queryLogsByMultipleConditions(Long startTime, Long endTime, String traceId, + String spanId, Integer severityNumber, + String severityText) { + throw new UnsupportedOperationException("query logs by multiple conditions is not supported"); + } + + /** + * Query logs with multiple filter conditions and pagination + * @param startTime start time in milliseconds + * @param endTime end time in milliseconds + * @param traceId trace ID filter + * @param spanId span ID filter + * @param severityNumber severity number filter + * @param severityText severity text filter + * @param offset pagination offset + * @param limit pagination limit + * @return filtered log entries with pagination + */ + default List queryLogsByMultipleConditionsWithPagination(Long startTime, Long endTime, String traceId, + String spanId, Integer severityNumber, + String severityText, Integer offset, Integer limit) { + throw new UnsupportedOperationException("query logs by multiple conditions with pagination is not supported"); + } + + /** + * Count logs with multiple filter conditions + * @param startTime start time in milliseconds + * @param endTime end time in milliseconds + * @param traceId trace ID filter + * @param spanId span ID filter + * @param severityNumber severity number filter + * @param severityText severity text filter + * @return count of matching log entries + */ + default long countLogsByMultipleConditions(Long startTime, Long endTime, String traceId, + String spanId, Integer severityNumber, + String severityText) { + throw new UnsupportedOperationException("count logs by multiple conditions is not supported"); + } + + /** + * Batch delete logs by time timestamps + * @param timeUnixNanos list of time timestamps to delete + * @return true if deletion is successful, false otherwise + */ + default boolean batchDeleteLogs(List timeUnixNanos) { + throw new UnsupportedOperationException("batch delete logs is not supported"); + } } diff --git a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/GreptimeDbDataStorage.java b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/GreptimeDbDataStorage.java index 0da96056868..a8241117862 100644 --- a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/GreptimeDbDataStorage.java +++ b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/GreptimeDbDataStorage.java @@ -37,6 +37,7 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalAmount; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; import java.util.List; @@ -44,6 +45,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.math.NumberUtils; @@ -51,12 +53,14 @@ import org.apache.hertzbeat.common.constants.MetricDataConstants; import org.apache.hertzbeat.common.entity.arrow.RowWrapper; import org.apache.hertzbeat.common.entity.dto.Value; +import org.apache.hertzbeat.common.entity.log.LogEntry; import org.apache.hertzbeat.common.entity.message.CollectRep; import org.apache.hertzbeat.common.util.Base64Util; import org.apache.hertzbeat.common.util.JsonUtil; import org.apache.hertzbeat.common.util.TimePeriodUtil; import org.apache.hertzbeat.warehouse.store.history.tsdb.AbstractHistoryDataStorage; import org.apache.hertzbeat.warehouse.store.history.tsdb.vm.PromQlQueryContent; +import org.apache.hertzbeat.warehouse.db.GreptimeSqlQueryExecutor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -81,6 +85,7 @@ public class GreptimeDbDataStorage extends AbstractHistoryDataStorage { private static final String LABEL_KEY_NAME = "__name__"; private static final String LABEL_KEY_FIELD = "__field__"; private static final String LABEL_KEY_INSTANCE = "instance"; + private static final String LOG_TABLE_NAME = "hertzbeat_logs"; private GreptimeDB greptimeDb; @@ -88,13 +93,17 @@ public class GreptimeDbDataStorage extends AbstractHistoryDataStorage { private final RestTemplate restTemplate; - public GreptimeDbDataStorage(GreptimeProperties greptimeProperties, RestTemplate restTemplate) { + private final GreptimeSqlQueryExecutor greptimeSqlQueryExecutor; + + public GreptimeDbDataStorage(GreptimeProperties greptimeProperties, RestTemplate restTemplate, + GreptimeSqlQueryExecutor greptimeSqlQueryExecutor) { if (greptimeProperties == null) { log.error("init error, please config Warehouse GreptimeDB props in application.yml"); throw new IllegalArgumentException("please config Warehouse GreptimeDB props"); } this.restTemplate = restTemplate; this.greptimeProperties = greptimeProperties; + this.greptimeSqlQueryExecutor = greptimeSqlQueryExecutor; serverAvailable = initGreptimeDbClient(greptimeProperties); } @@ -292,4 +301,296 @@ public void destroy() { this.greptimeDb = null; } } + + @Override + public void saveLogData(LogEntry logEntry) { + if (!isServerAvailable()) { + return; + } + + try { + // Create table schema + TableSchema.Builder tableSchemaBuilder = TableSchema.newBuilder(LOG_TABLE_NAME); + tableSchemaBuilder.addTimestamp("time_unix_nano", DataType.TimestampNanosecond) + .addField("observed_time_unix_nano", DataType.TimestampNanosecond) + .addField("severity_number", DataType.Int32) + .addField("severity_text", DataType.String) + .addField("body", DataType.Json) + .addField("trace_id", DataType.String) + .addField("span_id", DataType.String) + .addField("trace_flags", DataType.Int32) + .addField("attributes", DataType.Json) + .addField("resource", DataType.Json) + .addField("instrumentation_scope", DataType.Json) + .addField("dropped_attributes_count", DataType.Int32); + + Table table = Table.from(tableSchemaBuilder.build()); + + // Convert LogEntry to table row + Object[] values = new Object[] { + logEntry.getTimeUnixNano() != null ? logEntry.getTimeUnixNano() : System.nanoTime(), + logEntry.getObservedTimeUnixNano() != null ? logEntry.getObservedTimeUnixNano() : System.nanoTime(), + logEntry.getSeverityNumber(), + logEntry.getSeverityText(), + JsonUtil.toJson(logEntry.getBody()), + logEntry.getTraceId(), + logEntry.getSpanId(), + logEntry.getTraceFlags(), + JsonUtil.toJson(logEntry.getAttributes()), + JsonUtil.toJson(logEntry.getResource()), + JsonUtil.toJson(logEntry.getInstrumentationScope()), + logEntry.getDroppedAttributesCount() + }; + + table.addRow(values); + + // Write to GreptimeDB + CompletableFuture> writeFuture = greptimeDb.write(table); + Result result = writeFuture.get(10, TimeUnit.SECONDS); + + if (result.isOk()) { + log.debug("[warehouse greptime-log] Write successful"); + } else { + log.warn("[warehouse greptime-log] Write failed: {}", result.getErr()); + } + } catch (Exception e) { + log.error("[warehouse greptime-log] Error saving log entry", e); + } + } + + @Override + public List queryLogsByMultipleConditions(Long startTime, Long endTime, String traceId, + String spanId, Integer severityNumber, + String severityText) { + try { + StringBuilder sql = new StringBuilder("SELECT * FROM ").append(LOG_TABLE_NAME); + buildWhereConditions(sql, startTime, endTime, traceId, spanId, severityNumber, severityText); + sql.append(" ORDER BY time_unix_nano DESC"); + + List> rows = greptimeSqlQueryExecutor.execute(sql.toString()); + return mapRowsToLogEntries(rows); + } catch (Exception e) { + log.error("[warehouse greptime-log] queryLogsByMultipleConditions error: {}", e.getMessage(), e); + return List.of(); + } + } + + @Override + public List queryLogsByMultipleConditionsWithPagination(Long startTime, Long endTime, String traceId, + String spanId, Integer severityNumber, + String severityText, Integer offset, Integer limit) { + try { + StringBuilder sql = new StringBuilder("SELECT * FROM ").append(LOG_TABLE_NAME); + buildWhereConditions(sql, startTime, endTime, traceId, spanId, severityNumber, severityText); + sql.append(" ORDER BY time_unix_nano DESC"); + + // Add pagination + if (limit != null && limit > 0) { + sql.append(" LIMIT ").append(limit); + if (offset != null && offset > 0) { + sql.append(" OFFSET ").append(offset); + } + } + + List> rows = greptimeSqlQueryExecutor.execute(sql.toString()); + return mapRowsToLogEntries(rows); + } catch (Exception e) { + log.error("[warehouse greptime-log] queryLogsByMultipleConditionsWithPagination error: {}", e.getMessage(), e); + return List.of(); + } + } + + @Override + public long countLogsByMultipleConditions(Long startTime, Long endTime, String traceId, + String spanId, Integer severityNumber, + String severityText) { + try { + StringBuilder sql = new StringBuilder("SELECT COUNT(*) as count FROM ").append(LOG_TABLE_NAME); + buildWhereConditions(sql, startTime, endTime, traceId, spanId, severityNumber, severityText); + + List> rows = greptimeSqlQueryExecutor.execute(sql.toString()); + if (rows != null && !rows.isEmpty()) { + Object countObj = rows.get(0).get("count"); + if (countObj instanceof Number) { + return ((Number) countObj).longValue(); + } + } + return 0; + } catch (Exception e) { + log.error("[warehouse greptime-log] countLogsByMultipleConditions error: {}", e.getMessage(), e); + return 0; + } + } + + private static long msToNs(Long ms) { + return ms * 1_000_000L; + } + + private static String safeString(String input) { + if (input == null) { + return ""; + } + return input.replace("'", "''"); + } + + /** + * build WHERE conditions + * @param sql SQL builder + * @param startTime start time + * @param endTime end time + * @param traceId trace id + * @param spanId span id + * @param severityNumber severity number + */ + private void buildWhereConditions(StringBuilder sql, Long startTime, Long endTime, String traceId, + String spanId, Integer severityNumber, String severityText) { + List conditions = new ArrayList<>(); + + // Time range condition + if (startTime != null && endTime != null) { + conditions.add("time_unix_nano >= " + msToNs(startTime) + " AND time_unix_nano <= " + msToNs(endTime)); + } + + // TraceId condition + if (StringUtils.hasText(traceId)) { + conditions.add("trace_id = '" + safeString(traceId) + "'"); + } + + // SpanId condition + if (StringUtils.hasText(spanId)) { + conditions.add("span_id = '" + safeString(spanId) + "'"); + } + + // Severity condition + if (severityNumber != null) { + conditions.add("severity_number = " + severityNumber); + } + + // SeverityText condition + if (StringUtils.hasText(severityText)) { + conditions.add("severity_text = '" + safeString(severityText) + "'"); + } + + // Add WHERE clause if there are conditions + if (!conditions.isEmpty()) { + sql.append(" WHERE ").append(String.join(" AND ", conditions)); + } + } + + private List mapRowsToLogEntries(List> rows) { + List list = new LinkedList<>(); + if (rows == null || rows.isEmpty()) { + return list; + } + for (Map row : rows) { + try { + LogEntry.InstrumentationScope scope = null; + Object scopeObj = row.get("instrumentation_scope"); + if (scopeObj instanceof String scopeStr && StringUtils.hasText(scopeStr)) { + try { + scope = JsonUtil.fromJson(scopeStr, LogEntry.InstrumentationScope.class); + } catch (Exception ignore) { + scope = null; + } + } + + Object bodyObj = parseJsonMaybe(row.get("body")); + Map attributes = castToMap(parseJsonMaybe(row.get("attributes"))); + Map resource = castToMap(parseJsonMaybe(row.get("resource"))); + + LogEntry entry = LogEntry.builder() + .timeUnixNano(castToLong(row.get("time_unix_nano"))) + .observedTimeUnixNano(castToLong(row.get("observed_time_unix_nano"))) + .severityNumber(castToInteger(row.get("severity_number"))) + .severityText(castToString(row.get("severity_text"))) + .body(bodyObj) + .traceId(castToString(row.get("trace_id"))) + .spanId(castToString(row.get("span_id"))) + .traceFlags(castToInteger(row.get("trace_flags"))) + .attributes(attributes) + .resource(resource) + .instrumentationScope(scope) + .droppedAttributesCount(castToInteger(row.get("dropped_attributes_count"))) + .build(); + list.add(entry); + } catch (Exception e) { + log.warn("[warehouse greptime-log] map row to LogEntry error: {}", e.getMessage()); + } + } + return list; + } + + private static Object parseJsonMaybe(Object value) { + if (value == null) return null; + if (value instanceof Map) return value; + if (value instanceof String str) { + String s = str.trim(); + if ((s.startsWith("{") && s.endsWith("}")) || (s.startsWith("[") && s.endsWith("]"))) { + try { + return JsonUtil.fromJson(s, Object.class); + } catch (Exception e) { + return s; + } + } + return s; + } + return value; + } + + @SuppressWarnings("unchecked") + private static Map castToMap(Object obj) { + if (obj instanceof Map) { + return (Map) obj; + } + return null; + } + + private static Long castToLong(Object obj) { + if (obj == null) return null; + if (obj instanceof Number n) return n.longValue(); + try { + return Long.parseLong(String.valueOf(obj)); + } catch (Exception e) { + return null; + } + } + + private static Integer castToInteger(Object obj) { + if (obj == null) return null; + if (obj instanceof Number n) return n.intValue(); + try { + return Integer.parseInt(String.valueOf(obj)); + } catch (Exception e) { + return null; + } + } + + private static String castToString(Object obj) { + return obj == null ? null : String.valueOf(obj); + } + + @Override + public boolean batchDeleteLogs(List timeUnixNanos) { + if (!isServerAvailable() || timeUnixNanos == null || timeUnixNanos.isEmpty()) { + return false; + } + + try { + StringBuilder sql = new StringBuilder("DELETE FROM ").append(LOG_TABLE_NAME).append(" WHERE time_unix_nano IN ("); + sql.append(timeUnixNanos.stream() + .filter(time -> time != null) + .map(String::valueOf) + .collect(Collectors.joining(", "))); + sql.append(")"); + + greptimeSqlQueryExecutor.execute(sql.toString()); + log.info("[warehouse greptime-log] Batch delete executed successfully for {} logs", timeUnixNanos.size()); + return true; + + } catch (Exception e) { + log.error("[warehouse greptime-log] batchDeleteLogs error: {}", e.getMessage(), e); + return false; + } + } + } diff --git a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/GreptimeSqlQueryContent.java b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/GreptimeSqlQueryContent.java new file mode 100644 index 00000000000..7efa79fcc44 --- /dev/null +++ b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/GreptimeSqlQueryContent.java @@ -0,0 +1,93 @@ +/* + * 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.warehouse.store.history.tsdb.greptime; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * GreptimeDB SQL query content + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class GreptimeSqlQueryContent { + + private int code; + + private List output; + + @JsonProperty("execution_time_ms") + private long executionTimeMs; + + /** + * Represents the output of a GreptimeDB SQL query. + */ + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class Output { + + private Records records; + + /** + * Represents the records returned by a GreptimeDB SQL query. + */ + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class Records { + + private Schema schema; + + private List> rows; + + /** + * Represents the schema of the records returned by a GreptimeDB SQL query. + */ + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class Schema { + + @JsonProperty("column_schemas") + private List columnSchemas; + + /** + * Represents a column schema in the records returned by a GreptimeDB SQL query. + */ + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class ColumnSchema { + + private String name; + + @JsonProperty("data_type") + private String dataType; + } + } + } + } +} \ No newline at end of file diff --git a/material/licenses/LICENSE b/material/licenses/LICENSE index 216ef39fdec..4428fe93cde 100644 --- a/material/licenses/LICENSE +++ b/material/licenses/LICENSE @@ -438,6 +438,7 @@ The text of each license is the standard Apache 2.0 license. https://mvnrepository.com/artifact/io.opentelemetry.instrumentation/opentelemetry-spring-boot-starter-2.15.0 Apache-2.0 https://mvnrepository.com/artifact/io.opentelemetry.instrumentation/opentelemetry-logback-appender-1.0 Apache-2.0 https://mvnrepository.com/artifact/org.apache.zookeeper/zookeeper/3.9.3 Apache-2.0 + https://mvnrepository.com/artifact/io.opentelemetry.proto/opentelemetry-proto/1.7.0-alpha Apache-2.0 ======================================================================== BSD-2-Clause licenses diff --git a/material/licenses/backend/LICENSE b/material/licenses/backend/LICENSE index 492f0f3f8aa..826b50efcaf 100644 --- a/material/licenses/backend/LICENSE +++ b/material/licenses/backend/LICENSE @@ -436,6 +436,7 @@ The text of each license is the standard Apache 2.0 license. https://mvnrepository.com/artifact/com.google.flatbuffers/flatbuffers-java/1.12.0 https://mvnrepository.com/artifact/com.vesoft/client/3.6.0 https://mvnrepository.com/artifact/org.apache.zookeeper/zookeeper/3.9.3 Apache-2.0 + https://mvnrepository.com/artifact/io.opentelemetry.proto/opentelemetry-proto/1.7.0-alpha Apache-2.0 ======================================================================== diff --git a/pom.xml b/pom.xml index e4cb2839143..8e30571f929 100644 --- a/pom.xml +++ b/pom.xml @@ -92,6 +92,7 @@ hertzbeat-e2e hertzbeat-base hertzbeat-mcp + hertzbeat-log hertzbeat-ai-agent @@ -179,6 +180,7 @@ 2.13.1 2.15.0 2.14.0-alpha + 1.3.1-alpha @@ -237,12 +239,18 @@ hertzbeat-grafana ${hertzbeat.version} - + org.apache.hertzbeat hertzbeat-otel ${hertzbeat.version} + + + org.apache.hertzbeat + hertzbeat-log + ${hertzbeat.version} + org.apache.hertzbeat @@ -491,6 +499,17 @@ pom import + + io.opentelemetry.proto + opentelemetry-proto + ${opentelemetry-proto.version} + + + com.google.protobuf + protobuf-java + + + diff --git a/web-app/src/app/pojo/AlertDefine.ts b/web-app/src/app/pojo/AlertDefine.ts index bfaf6dbf0fe..25474d2b353 100644 --- a/web-app/src/app/pojo/AlertDefine.ts +++ b/web-app/src/app/pojo/AlertDefine.ts @@ -20,11 +20,12 @@ export class AlertDefine { id!: number; name!: string; - // realtime, periodic - type: string = 'realtime'; + // realtime_metric, periodic_metric, realtime_log, periodic_log + type: string = 'realtime_metric'; // datasource when type is periodic, promql | sql datasource: string = 'promql'; expr!: string; + queryExpr!: string; // unit second period: number = 300; times: number = 3; diff --git a/web-app/src/app/pojo/LogEntry.ts b/web-app/src/app/pojo/LogEntry.ts new file mode 100644 index 00000000000..be866605ceb --- /dev/null +++ b/web-app/src/app/pojo/LogEntry.ts @@ -0,0 +1,37 @@ +/* + * 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. + */ + +export class LogEntry { + timeUnixNano?: number; + observedTimeUnixNano?: number; + severityNumber?: number; + severityText?: string; + body?: any; + attributes?: { [key: string]: any }; + droppedAttributesCount?: number; + traceId?: string; + spanId?: string; + traceFlags?: number; + resource?: { [key: string]: any }; + instrumentationScope?: { + name?: string; + version?: string; + attributes?: { [key: string]: any }; + }; +} diff --git a/web-app/src/app/routes/alert/alert-setting/alert-setting.component.html b/web-app/src/app/routes/alert/alert-setting/alert-setting.component.html index 13bd055b587..7a3259e3db0 100644 --- a/web-app/src/app/routes/alert/alert-setting/alert-setting.component.html +++ b/web-app/src/app/routes/alert/alert-setting/alert-setting.component.html @@ -113,11 +113,17 @@ {{ data.name }} - - {{ 'alert.setting.type.realtime' | i18n }} + + {{ 'alert.setting.type.realtime.metric' | i18n }} - - {{ 'alert.setting.type.periodic' | i18n }} + + {{ 'alert.setting.type.periodic.metric' | i18n }} + + + {{ 'alert.setting.type.realtime.log' | i18n }} + + + {{ 'alert.setting.type.periodic.log' | i18n }} @@ -141,7 +147,7 @@ nz-tooltip nzType="primary" [nzLoading]="isLoadingEdit === data.id" - [nzTooltipTitle]="data.type == 'realtime' ? ('alert.setting.edit.realtime' | i18n) : ('alert.setting.edit.periodic' | i18n)" + [nzTooltipTitle]="getEditTooltipTitle(data) | i18n" > @@ -174,15 +180,7 @@ - + + + + {{ 'alert.setting.datatype' | i18n }} + + + + + + + + + {{ 'alert.setting.target' | i18n }} @@ -216,7 +239,7 @@ > - + {{ 'alert.setting.rule' | i18n }} @@ -247,7 +270,7 @@ - +
@@ -289,7 +312,7 @@
- + @@ -331,7 +354,7 @@ - + {{ 'alert.setting.bind.monitors' | i18n }} @@ -409,7 +432,7 @@ - + {{ 'alert.setting.preview.expr' | i18n }} @@ -419,7 +442,219 @@ - + + + + {{ 'alert.setting.rule' | i18n }} + + + + + + + + + + + + +
+ + + + +
    +
  • + {{ item.value }} + {{ item.label }} + + {{ + item.type === 0 + ? ('alert.setting.number' | i18n) + : item.type === 1 + ? ('alert.setting.string' | i18n) + : ('alert.setting.object' | i18n) + }} + +
  • + +
  • + {{ op.value }} + {{ op.description | i18n }} +
  • +
+
+ + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + {{ field.name ? field.name : field.value }} + + {{ + field.type === 0 + ? ('alert.setting.number' | i18n) + : field.type === 1 + ? ('alert.setting.string' | i18n) + : field.type === 2 + ? ('alert.setting.object' | i18n) + : field.type === 3 + ? ('alert.setting.time' | i18n) + : ('alert.setting.object' | i18n) + }} + + + {{ field.unit }} + + + + + +
+ . + +
+
+
+ + + + + + + + +
+
+
+ + + + {{ 'alert.setting.preview.expr' | i18n }} + + + +
{{ define.expr }}
+
+
+
+ {{ 'alert.setting.rule' | i18n }} @@ -429,11 +664,15 @@ + - + + @@ -444,7 +683,7 @@ nz-input name="periodic_expr" id="periodic_expr" - [placeholder]="'alert.setting.promql.tip' | i18n" + [placeholder]="define.datasource == 'promql' ? ('alert.setting.promql.tip' | i18n) : ('alert.setting.sql.tip' | i18n)" > @@ -458,12 +697,76 @@ [nzData]="previewData" [nzSize]="'small'" [nzLoading]="previewTableLoading" - [nzScroll]="previewData.length > 3 ? { x: '1240px', y: '180px' } : { x: '1240px' }" + [nzScroll]="previewData.length > 3 ? { y: '180px' } : {}" + [nzShowPagination]="false" + > + + + + {{ column.title }} + + + + + + + {{ data[column.key] }} + + + + + + + + + + {{ 'alert.setting.log.query' | i18n }} + + + + + + + + + + + + + + + + + + +
+ - + {{ column.title }} @@ -479,7 +782,27 @@
- + + + + {{ 'alert.setting.log.expr' | i18n }} + + + + + + + + {{ 'alert.setting.period' | i18n }} @@ -497,6 +820,24 @@ {{ 'common.time.unit.second' | i18n }} + + + {{ 'alert.setting.window' | i18n }} + + + + + {{ 'common.time.unit.second' | i18n }} + + {{ 'alert.severity' | i18n }} @@ -516,7 +857,25 @@ - + + + {{ 'alert.mode' | i18n }} + + + + + + + + + {{ 'alert.setting.times' | i18n }} @@ -531,18 +890,28 @@
-
-
-
- {{ env.name }} - {{ env.description | i18n }} +
+
+
+
+ {{ env.name }} + {{ env.description | i18n }} +
-
-
-
- {{ '${' + metric.value + '}' }} - {{ metric.label }} +
+
+ {{ '${' + metric.value + '}' }} + {{ metric.label }} +
+
+
+
+
+
+ {{ '${' + field.value + '}' }} + {{ field.label }} +
@@ -605,7 +974,6 @@ - EXCEL [nzTitle]="'alert.setting.new' | i18n" (nzOnCancel)="onSelectTypeModalCancel()" [nzFooter]="null" - nzWidth="460px" + nzWidth="500px" >
diff --git a/web-app/src/app/routes/alert/alert-setting/alert-setting.component.ts b/web-app/src/app/routes/alert/alert-setting/alert-setting.component.ts index d21a3efd52a..71547547c6c 100644 --- a/web-app/src/app/routes/alert/alert-setting/alert-setting.component.ts +++ b/web-app/src/app/routes/alert/alert-setting/alert-setting.component.ts @@ -59,7 +59,11 @@ export class AlertSettingComponent implements OnInit { this.qbFormCtrl = this.formBuilder.control(this.qbData, this.qbValidator); this.qbFormCtrl.valueChanges.subscribe(() => { this.userExpr = this.ruleset2expr(this.qbFormCtrl.value); - this.updateFinalExpr(); + if (this.define.type === 'realtime_log') { + this.updateLogFinalExpr(); + } else { + this.updateFinalExpr(); + } }); } @ViewChild('defineForm', { static: false }) defineForm!: NgForm; @@ -126,12 +130,84 @@ export class AlertSettingComponent implements OnInit { ]; isSelectTypeModalVisible = false; + dataType: string = 'metric'; // Default to metric + alertType: string = 'realtime'; // Default to realtime previewData: any[] = []; previewColumns: Array<{ title: string; key: string; width?: string }> = []; previewTableLoading = false; + /** + * Initialize log fields(todo: from backend api) + */ + initLogFields() { + this.logFields = [ + // Basic log fields + { value: 'log.timeUnixNano', label: 'Time (Unix Nano)', type: 0, unit: 'ns' }, + { value: 'log.observedTimeUnixNano', label: 'Observed Time (Unix Nano)', type: 0, unit: 'ns' }, + { value: 'log.severityNumber', label: 'Severity Number', type: 0 }, + { value: 'log.severityText', label: 'Severity Text', type: 1 }, + { value: 'log.body', label: 'Body', type: 1 }, + { value: 'log.droppedAttributesCount', label: 'Dropped Attributes Count', type: 0 }, + { value: 'log.traceId', label: 'Trace ID', type: 1 }, + { value: 'log.spanId', label: 'Span ID', type: 1 }, + { value: 'log.traceFlags', label: 'Trace Flags', type: 0 }, + // Object fields - will be handled dynamically + { value: 'log.attributes', label: 'Attributes', type: 2, isObject: true }, + { value: 'log.resource', label: 'Resource', type: 2, isObject: true }, + { value: 'log.instrumentationScope.name', label: 'Instrumentation Scope Name', type: 1 }, + { value: 'log.instrumentationScope.version', label: 'Instrumentation Scope Version', type: 1 }, + { value: 'log.instrumentationScope.attributes', label: 'Instrumentation Scope Attributes', type: 2, isObject: true }, + { value: 'log.instrumentationScope.droppedAttributesCount', label: 'Instrumentation Scope Dropped Attributes Count', type: 0 } + ]; + } + + updateLogQbConfig() { + if (this.define.type === 'realtime_log') { + let fields: any = {}; + this.logFields.forEach(item => { + fields[item.value] = { + name: item.label, + type: item.type, + unit: item.unit, + operators: this.getOperatorsByType(item.type) + }; + }); + this.qbConfig = { ...this.qbConfig, fields }; + } + } + + updateLogFinalExpr(): void { + if (this.define.type === 'realtime_log') { + this.define.expr = this.userExpr; + } + } + + onLogFieldChange(fieldValue: string, rule: any, onChange: any): void { + rule.field = fieldValue; + // Clear object attribute when field changes + rule.objectAttribute = ''; + onChange(fieldValue, rule); + } + + onMetricsFieldChange(fieldValue: string, rule: any, onChange: any): void { + rule.field = fieldValue; + onChange(fieldValue, rule); + } + + isObjectField(fieldValue: string): boolean { + if (!fieldValue) return false; + const field = this.logFields.find(f => f.value === fieldValue); + return !!(field && field.isObject); + } + + onObjectAttributeChange(attributeValue: string, rule: any, onChange: any): void { + rule.objectAttribute = attributeValue; + onChange(rule.field, rule); + } + ngOnInit(): void { + this.initLogFields(); this.loadAlertDefineTable(); // query monitoring hierarchy const getHierarchy$ = this.appDefineSvc @@ -247,9 +323,10 @@ export class AlertSettingComponent implements OnInit { onSelectAlertType(type: string) { this.isSelectTypeModalVisible = false; + this.alertType = type; this.define = new AlertDefine(); - this.define.type = type; this.severity = ''; + this.alertMode = ''; this.userExpr = ''; this.selectedMonitorIds = new Set(); this.selectedLabels = new Set(); @@ -257,12 +334,38 @@ export class AlertSettingComponent implements OnInit { if (type === 'periodic') { this.define.period = 300; } + this.updateAlertDefineType(); this.resetQbDataDefault(); this.isManageModalAdd = true; this.isManageModalVisible = true; this.isManageModalOkLoading = false; } + onDataTypeChange() { + this.updateAlertDefineType(); + } + + private updateAlertDefineType() { + // Combine main type with data type + if (this.alertType === 'realtime' && this.dataType === 'metric') { + this.define.type = 'realtime_metric'; + } else if (this.alertType === 'realtime' && this.dataType === 'log') { + this.define.type = 'realtime_log'; + this.updateLogQbConfig(); + } else if (this.alertType === 'periodic' && this.dataType === 'metric') { + this.define.type = 'periodic_metric'; + } else if (this.alertType === 'periodic' && this.dataType === 'log') { + this.define.type = 'periodic_log'; + this.updateLogQbConfig(); + } + + // Reset form state when switching data source type + this.userExpr = ''; + this.cascadeValues = []; + this.currentMetrics = []; + this.resetQbDataDefault(); + } + onSelectTypeModalCancel() { this.isSelectTypeModalVisible = false; } @@ -469,6 +572,8 @@ export class AlertSettingComponent implements OnInit { isExpr = false; userExpr!: string; severity!: string; + alertMode!: string; + logFields: any[] = []; editAlertDefine(alertDefineId: number) { if (this.isLoadingEdit !== -1) return; @@ -482,6 +587,7 @@ export class AlertSettingComponent implements OnInit { finalize(() => { getDefine$.unsubscribe(); this.isLoadingEdit = -1; + this.extractDataTypeAndAlertType(this.define.type); this.isManageModalVisible = true; this.clearPreview(); }) @@ -493,15 +599,18 @@ export class AlertSettingComponent implements OnInit { if (this.define.labels && this.define.labels['severity']) { this.severity = this.define.labels['severity']; } - // Set default period for periodic alert if not set - if (this.define.type === 'periodic' && !this.define.period) { + if (this.define.labels && this.define.labels['alert_mode']) { + this.alertMode = this.define.labels['alert_mode']; + } + // Set default period for periodic_metric alert if not set + if (this.define.type === 'periodic_metric' && !this.define.period) { this.define.period = 300; } - // Set default type as realtime if not set + // Set default type as realtime_metric if not set if (!this.define.type) { - this.define.type = 'realtime'; + this.define.type = 'realtime_metric'; } - if (this.define.type == 'realtime') { + if (this.define.type == 'realtime_metric') { // Parse expression to cascade values this.cascadeValues = this.exprToCascadeValues(this.define.expr); this.userExpr = this.exprToUserExpr(this.define.expr); @@ -516,6 +625,12 @@ export class AlertSettingComponent implements OnInit { this.tryParseThresholdExpr(this.userExpr); } }); + } else if (this.define.type == 'realtime_log') { + // Initialize log fields and parse expression + this.updateLogQbConfig(); + this.userExpr = this.define.expr || ''; + // Try to parse expression as visual rules + this.tryParseLogThresholdExpr(this.userExpr); } } else { this.notifySvc.error(this.i18nSvc.fanyi('common.notify.query-fail'), message.msg); @@ -528,10 +643,19 @@ export class AlertSettingComponent implements OnInit { } private getOperatorsByType(type: number): string[] { + let numericOperators = ['>', '<', '==', '!=', '<=', '>=', 'exists', '!exists']; + let stringOperators = ['equals', '!equals', 'contains', '!contains', 'matches', '!matches', 'exists', '!exists']; + let objectOperators = [...numericOperators, ...stringOperators]; + if (type === 0 || type === 3) { - return ['>', '<', '==', '!=', '<=', '>=', 'exists', '!exists']; + // Numeric and time types + return numericOperators; } else if (type === 1) { - return ['equals', '!equals', 'contains', '!contains', 'matches', '!matches', 'exists', '!exists']; + // String types + return stringOperators; + } else if (type === 2) { + // Object types (for log attributes, resource, etc.) + return objectOperators; } return []; } @@ -539,10 +663,16 @@ export class AlertSettingComponent implements OnInit { private rule2expr(rule: Rule): string { if (!rule.field) return ''; + // Get the effective field name (including object attributes for log fields) + let effectiveField = rule.field; + if (this.define.type === 'realtime_log' && (rule as any).objectAttribute) { + effectiveField = `${rule.field}.${(rule as any).objectAttribute}`; + } + switch (rule.operator) { case 'exists': case '!exists': - return `${rule.operator}(${rule.field})`; + return `${rule.operator}(${effectiveField})`; case 'equals': case '!equals': @@ -550,7 +680,7 @@ export class AlertSettingComponent implements OnInit { case '!contains': case 'matches': case '!matches': - return `${rule.operator}(${rule.field}, "${rule.value}")`; + return `${rule.operator}(${effectiveField}, "${rule.value}")`; case '>': case '>=': @@ -559,10 +689,10 @@ export class AlertSettingComponent implements OnInit { case '==': case '!=': // 如果字段包含方法调用 - if (rule.field.includes('.') && rule.field.includes('()')) { - return `${rule.field} ${rule.operator} ${rule.value}`; + if (effectiveField.includes('.') && effectiveField.includes('()')) { + return `${effectiveField} ${rule.operator} ${rule.value}`; } - return `${rule.field} ${rule.operator} ${rule.value}`; + return `${effectiveField} ${rule.operator} ${rule.value}`; default: return ''; @@ -734,10 +864,12 @@ export class AlertSettingComponent implements OnInit { const existsMatch = expr.match(/^(!)?exists\(([^)]+)\)$/); if (existsMatch) { const [_, not, field] = existsMatch; + const parsedRule = this.parseLogFieldAndAttribute(field); return { - field, - operator: not ? '!exists' : 'exists' - }; + field: parsedRule.field, + operator: not ? '!exists' : 'exists', + ...(parsedRule.objectAttribute && { objectAttribute: parsedRule.objectAttribute }) + } as any; } // Parse string functions (equals, contains, matches) @@ -745,22 +877,26 @@ export class AlertSettingComponent implements OnInit { if (funcMatch) { const [_, not, field, value] = funcMatch; const func = expr.match(/equals|contains|matches/)?.[0] || ''; + const parsedRule = this.parseLogFieldAndAttribute(field); return { - field, + field: parsedRule.field, operator: not ? `!${func}` : func, - value - }; + value, + ...(parsedRule.objectAttribute && { objectAttribute: parsedRule.objectAttribute }) + } as any; } // Parse numeric comparisons - const compareMatch = expr.match(/^(\w+(?:\.\w+)*)\s*([><=!]+)\s*(-?\d+(?:\.\d+)?)$/); + const compareMatch = expr.match(/^([\w.]+)\s*([><=!]+)\s*(-?\d+(?:\.\d+)?)$/); if (compareMatch) { const [_, field, operator, value] = compareMatch; + const parsedRule = this.parseLogFieldAndAttribute(field); return { - field, + field: parsedRule.field, operator, - value: Number(value) - }; + value: Number(value), + ...(parsedRule.objectAttribute && { objectAttribute: parsedRule.objectAttribute }) + } as any; } return null; @@ -770,6 +906,24 @@ export class AlertSettingComponent implements OnInit { } } + private parseLogFieldAndAttribute(fullField: string): { field: string; objectAttribute?: string } { + if (this.define.type !== 'realtime_log') { + return { field: fullField }; + } + + // Check if this is an object field with attribute + const objectFields = ['log.attributes', 'log.resource', 'log.instrumentationScope.attributes']; + + for (const objField of objectFields) { + if (fullField.startsWith(`${objField}.`)) { + const attribute = fullField.substring(objField.length + 1); + return { field: objField, objectAttribute: attribute }; + } + } + + return { field: fullField }; + } + getOperatorLabelByType = (operator: string) => { switch (operator) { case 'equals': @@ -861,6 +1015,13 @@ export class AlertSettingComponent implements OnInit { this.define.labels = { ...this.define.labels, severity: this.severity }; } + onAlertModeChange() { + if (!this.define.labels) { + this.define.labels = {}; + } + this.define.labels = { ...this.define.labels, alert_mode: this.alertMode }; + } + onManageModalCancel() { this.cascadeValues = []; this.isExpr = false; @@ -1210,6 +1371,30 @@ export class AlertSettingComponent implements OnInit { } } + private tryParseLogThresholdExpr(expr: string | undefined): void { + if (!expr || !expr.trim()) { + this.resetQbDataDefault(); + this.isExpr = false; + return; + } + + try { + // First try to parse as visual rules for log expressions + const ruleset = this.expr2ruleset(expr); + if (ruleset && ruleset.rules && ruleset.rules.length > 0) { + this.resetQbData(ruleset); + this.isExpr = false; + return; + } + + // If cannot parse as visual rules, switch to expression mode + this.isExpr = true; + } catch (e) { + console.warn('Failed to parse log threshold expr:', e); + this.isExpr = true; + } + } + public updateFinalExpr(): void { const baseExpr = this.cascadeValuesToExpr(this.cascadeValues); const monitorBindExpr = this.generateMonitorBindExpr(); @@ -1489,6 +1674,75 @@ export class AlertSettingComponent implements OnInit { }); } + onPreviewLogQueryExpr(): void { + if (!this.define.queryExpr) { + this.clearPreview(); + this.previewTableLoading = false; + return; + } + this.previewTableLoading = true; + + this.alertDefineSvc.getMonitorsDefinePreview(this.define.datasource, this.define.type, this.define.queryExpr).subscribe({ + next: res => { + if (res.code === 15 || res.code === 1 || res.code === 4) { + this.message.error(res.msg || 'Expression parsing exception'); + this.clearPreview(); + this.previewTableLoading = false; + return; + } + if (res.code === 0 && Array.isArray(res.data)) { + // Process log data for table display + this.processLogPreviewData(res.data); + } else { + this.clearPreview(); + } + this.previewTableLoading = false; + }, + error: err => { + this.clearPreview(); + this.previewTableLoading = false; + this.message.error('Failed to get preview data.'); + } + }); + } + + private processLogPreviewData(data: any[]): void { + if (!data || data.length === 0) { + this.clearPreview(); + return; + } + + // Get all unique keys from the log data to create columns + const allKeys = new Set(); + data.forEach(item => { + Object.keys(item).forEach(key => allKeys.add(key)); + }); + + // Create columns based on actual query result keys - use the key as both title and key + this.previewColumns = Array.from(allKeys).map(key => ({ + title: key, + key: key + })); + + // Process data for display + this.previewData = data.map(item => { + const processedItem: any = {}; + for (const [key, value] of Object.entries(item)) { + if (value != null) { + // Format complex objects as JSON strings for display + if (typeof value === 'object') { + processedItem[key] = JSON.stringify(value); + } else { + processedItem[key] = String(value); + } + } else { + processedItem[key] = ''; + } + } + return processedItem; + }); + } + private filterEmptyFields(mapData: Record): Record { return Object.entries(mapData).reduce>((acc, [key, value]) => { if (value == null) return acc; @@ -1502,4 +1756,39 @@ export class AlertSettingComponent implements OnInit { this.previewData = []; this.previewColumns = []; } + + /** + * Get the edit tooltip title i18n key based on the data type. + */ + getEditTooltipTitle(data: any): string { + if (data.type === 'realtime') { + return 'alert.setting.edit.realtime'; + } else { + return 'alert.setting.edit.periodic'; + } + } + + /** + * Get the modal title i18n key based on the add/edit mode and data type. + * Similar to a computed property in Vue. + */ + get modalTitle(): string { + if (this.isManageModalAdd) { + if (this.alertType === 'periodic') { + return 'alert.setting.new.periodic'; + } else { + return 'alert.setting.new.realtime'; + } + } else { + if (this.alertType === 'periodic') { + return 'alert.setting.edit.periodic'; + } else { + return 'alert.setting.edit.realtime'; + } + } + } + private extractDataTypeAndAlertType(type: string): void { + this.dataType = type.split('_')[1] || 'metric'; + this.alertType = type.split('_')[0] || 'realtime'; + } } diff --git a/web-app/src/app/routes/log/log-integration/log-integration.component.html b/web-app/src/app/routes/log/log-integration/log-integration.component.html new file mode 100644 index 00000000000..9e3deba70f0 --- /dev/null +++ b/web-app/src/app/routes/log/log-integration/log-integration.component.html @@ -0,0 +1,85 @@ + + + + + + +
+
+

{{ 'log.integration.source' | i18n }}

+
+
+ + {{ source.name }} +
+
+
+
+ +
+

{{ selectedSource.name }}

+ +
+ +
+
+
+ + +
+
+

{{ 'log.integration.token.desc' | i18n }}

+
+
+
+ +
+
+ {{ token }} +
+
+
+

{{ 'log.integration.token.notice' | i18n }}

+
+
+
diff --git a/web-app/src/app/routes/log/log-integration/log-integration.component.less b/web-app/src/app/routes/log/log-integration/log-integration.component.less new file mode 100644 index 00000000000..7ae298020a8 --- /dev/null +++ b/web-app/src/app/routes/log/log-integration/log-integration.component.less @@ -0,0 +1,84 @@ +@import "~src/styles/theme"; + +.log-integration-container { + display: flex; + height: 100%; + background: @common-background-color; + border-radius: 4px; + + .data-sources { + width: 240px; + border-right: 1px solid #f0f0f0; + padding: 16px; + background: @common-background-color; + + h2 { + margin-bottom: 16px; + font-size: 16px; + font-weight: 500; + } + + .source-list { + .source-item { + display: flex; + align-items: center; + padding: 12px; + cursor: pointer; + border-radius: 4px; + transition: all 0.3s; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.active { + background: rgba(0, 0, 0, 0.1); + } + + img { + width: 24px; + height: 24px; + margin-right: 8px; + } + } + } + } + + .doc-content { + flex: 1; + padding: 24px; + overflow-y: auto; + background: @common-background-color; + + h2 { + margin-bottom: 24px; + font-size: 20px; + font-weight: 500; + } + } +} + +[data-theme='dark'] { + :host { + .log-integration-container { + background: @common-background-color-dark; + .data-sources { + background: @common-background-color-dark; + } + .doc-content { + background: @common-background-color-dark; + } + .source-list { + .source-item { + &:hover { + background: rgba(255, 255, 255, 0.4); + } + + &.active { + background: rgba(255, 255, 255, 0.6); + } + } + } + } + } +} \ No newline at end of file diff --git a/web-app/src/app/routes/log/log-integration/log-integration.component.spec.ts b/web-app/src/app/routes/log/log-integration/log-integration.component.spec.ts new file mode 100644 index 00000000000..be8ff0b2398 --- /dev/null +++ b/web-app/src/app/routes/log/log-integration/log-integration.component.spec.ts @@ -0,0 +1,41 @@ +/* + * 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. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LogIntegrationComponent } from './log-integration.component'; + +describe('LogIntegrationComponent', () => { + let component: LogIntegrationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LogIntegrationComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(LogIntegrationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web-app/src/app/routes/log/log-integration/log-integration.component.ts b/web-app/src/app/routes/log/log-integration/log-integration.component.ts new file mode 100644 index 00000000000..f057925b587 --- /dev/null +++ b/web-app/src/app/routes/log/log-integration/log-integration.component.ts @@ -0,0 +1,163 @@ +/* + * 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. + */ + +import { CommonModule } from '@angular/common'; +import { HttpClient, HttpClientModule } from '@angular/common/http'; +import { Component, Inject, OnInit } from '@angular/core'; +import { Router, ActivatedRoute } from '@angular/router'; +import { I18NService } from '@core'; +import { ALAIN_I18N_TOKEN, I18nPipe } from '@delon/theme'; +import { SharedModule } from '@shared'; +import { NzDividerComponent } from 'ng-zorro-antd/divider'; +import { NzModalService } from 'ng-zorro-antd/modal'; +import { NzNotificationService } from 'ng-zorro-antd/notification'; +import { MarkdownModule } from 'ngx-markdown'; + +import { AuthService } from '../../../service/auth.service'; + +interface DataSource { + id: string; + name: string; + icon: string; +} + +const MARKDOWN_DOC_PATH = './assets/doc/log-integration'; + +@Component({ + selector: 'app-log-integration', + standalone: true, + imports: [CommonModule, I18nPipe, MarkdownModule, HttpClientModule, NzDividerComponent, SharedModule], + templateUrl: './log-integration.component.html', + styleUrl: './log-integration.component.less' +}) +export class LogIntegrationComponent implements OnInit { + dataSources: DataSource[] = [ + { + id: 'otlp', + name: this.i18nSvc.fanyi('log.integration.source.otlp'), + icon: 'assets/img/integration/otlp.svg' + } + ]; + + selectedSource: DataSource | null = null; + markdownContent: string = ''; + token: string = ''; + isModalVisible: boolean = false; + generateLoading: boolean = false; + + constructor( + private http: HttpClient, + private authSvc: AuthService, + @Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService, + private notifySvc: NzNotificationService, + private modal: NzModalService, + private router: Router, + private route: ActivatedRoute + ) {} + + ngOnInit() { + this.route.params.subscribe(params => { + const sourceId = params['source']; + if (sourceId) { + // Find matching data source + const source = this.dataSources.find(s => s.id === sourceId); + if (source) { + this.selectedSource = source; + } else { + // If no matching source found, use the first one as default + this.selectedSource = this.dataSources[0]; + this.router.navigate(['/log/integration/', this.selectedSource.id]); + } + } else { + // When no route params, use the first data source + this.selectedSource = this.dataSources[0]; + this.router.navigate(['/log/integration/', this.selectedSource.id]); + } + + if (this.selectedSource) { + this.loadMarkdownContent(this.selectedSource); + } + }); + } + + selectSource(source: DataSource) { + this.selectedSource = source; + this.loadMarkdownContent(source); + this.router.navigate(['/log/integration', source.id]); + } + + public generateToken() { + this.generateLoading = true; + this.authSvc.generateToken().subscribe(message => { + if (message.code === 0) { + this.token = message.data?.token; + this.isModalVisible = true; + } else { + this.notifySvc.warning('Failed to generate token', message.msg); + } + this.generateLoading = false; + }); + } + + handleCancel(): void { + this.isModalVisible = false; + this.token = ''; + } + + handleOk(): void { + this.isModalVisible = false; + this.token = ''; + } + + private loadMarkdownContent(source: DataSource) { + const lang = this.i18nSvc.currentLang; + const path = `${MARKDOWN_DOC_PATH}/${source.id}.${lang}.md`; + + this.http.get(path, { responseType: 'text' }).subscribe({ + next: content => { + this.markdownContent = content; + }, + error: error => { + const enPath = `${MARKDOWN_DOC_PATH}/${source.id}.en-US.md`; + this.http.get(enPath, { responseType: 'text' }).subscribe(content => (this.markdownContent = content)); + } + }); + } + + copyToken() { + const el = document.createElement('textarea'); + el.value = this.token; + document.body.appendChild(el); + el.select(); + document.execCommand('copy'); + document.body.removeChild(el); + if (navigator && navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard + .writeText(this.token) + .then(() => { + this.notifySvc.success(this.i18nSvc.fanyi('common.notify.copy-success'), this.i18nSvc.fanyi('log.integration.token.notice')); + }) + .catch(() => { + this.notifySvc.error(this.i18nSvc.fanyi('common.notify.copy-fail'), this.i18nSvc.fanyi('log.integration.token.notice')); + }); + } else { + this.notifySvc.error(this.i18nSvc.fanyi('common.notify.copy-fail'), this.i18nSvc.fanyi('log.integration.token.notice')); + } + } +} diff --git a/web-app/src/app/routes/log/log-manage/log-manage.component.html b/web-app/src/app/routes/log/log-manage/log-manage.component.html new file mode 100644 index 00000000000..5394fd781d0 --- /dev/null +++ b/web-app/src/app/routes/log/log-manage/log-manage.component.html @@ -0,0 +1,358 @@ + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + + + +
+ +
+
+
+
+ + +
+ +
+
+ + + + + +
+
+ + + + + +
+
+ + + + + +
+
+ + + + + +
+
+ + + + + +
+
+ + + + + +
+
+ + +
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ + + + + + + + + {{ 'log.manage.table.column.time' | i18n }} + {{ + 'log.manage.table.column.observed-time' | i18n + }} + {{ 'log.manage.table.column.severity' | i18n }} + {{ 'log.manage.table.column.body' | i18n }} + {{ + 'log.manage.table.column.attributes' | i18n + }} + {{ 'log.manage.table.column.resource' | i18n }} + {{ 'log.manage.table.column.trace-id' | i18n }} + {{ 'log.manage.table.column.span-id' | i18n }} + {{ + 'log.manage.table.column.trace-flags' | i18n + }} + {{ + 'log.manage.table.column.instrumentation' | i18n + }} + {{ + 'log.manage.table.column.dropped-count' | i18n + }} + + + + + + + + + + {{ (i.timeUnixNano || 0) / 1000000 | date : 'yyyy-MM-dd HH:mm:ss' }} + + + + + {{ (i.observedTimeUnixNano || 0) / 1000000 | date : 'yyyy-MM-dd HH:mm:ss' }} + + - + + + + {{ i.severityText || i.severityNumber || '-' }} + + + +
+ {{ getBodyText(i.body) }} +
+ + +
+ {{ getObjectText(i.attributes) }} +
+ - + + +
+ {{ getObjectText(i.resource) }} +
+ - + + + {{ i.traceId | slice : 0 : 8 }}... + - + + + {{ i.spanId | slice : 0 : 8 }}... + - + + + {{ i.traceFlags }} + - + + +
+ {{ i.instrumentationScope.name }} + ({{ i.instrumentationScope.version }}) +
+ - + + + {{ i.droppedAttributesCount }} + - + + + +
+ + + + +
+ + +
+
+ {{ 'log.manage.severity-text' | i18n }}: + + {{ selectedLogEntry.severityText || selectedLogEntry.severityNumber || '未知' }} + +
+
+ {{ 'log.manage.timestamp' | i18n }}: + {{ formatTimestamp(selectedLogEntry.timeUnixNano) }} +
+
+ {{ 'log.manage.trace-id' | i18n }}: + {{ selectedLogEntry.traceId }} +
+
+ {{ 'log.manage.span-id' | i18n }}: + {{ selectedLogEntry.spanId }} +
+
+
+ + + +
+ +
{{ getLogEntryJson(selectedLogEntry) }}
+
+
+
+
+
diff --git a/web-app/src/app/routes/log/log-manage/log-manage.component.less b/web-app/src/app/routes/log/log-manage/log-manage.component.less new file mode 100644 index 00000000000..71361bddfdc --- /dev/null +++ b/web-app/src/app/routes/log/log-manage/log-manage.component.less @@ -0,0 +1,82 @@ +.manager-card { + .filters-container { + padding: 8px 0; + margin-bottom: 24px; + } +} + +.log-body { + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.trace-id, .span-id { + font-family: 'Courier New', monospace; + font-size: 12px; + color: #666; +} + +.attributes-text, .resource-text { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + cursor: pointer; +} + +.text-muted { + color: #999; + font-style: italic; +} + +nz-statistic { + text-align: center; +} + +.column-control-container { + width: 280px; + max-height: 400px; + overflow-y: auto; + .column-control-reset-button { + text-align: center; + } +} + +.log-details-modal { + .basic-info-card { + margin-bottom: 16px; + .info-row { + margin-bottom: 8px; + } + .info-label { + display: inline-block; + width: 100px; + font-weight: bold; + } + .info-value { + font-family: monospace; + word-break: break-all; + } + } + .json-content { + position: relative; + .copy-button { + position: absolute; + top: 8px; + right: 8px; + z-index: 1; + } + .pre { + background: #f5f5f5; + padding: 16px; + border-radius: 4px; + overflow-x: auto; + margin: 0; + padding-top: 40px; + } + } +} diff --git a/web-app/src/app/routes/log/log-manage/log-manage.component.spec.ts b/web-app/src/app/routes/log/log-manage/log-manage.component.spec.ts new file mode 100644 index 00000000000..e7e11e6ca21 --- /dev/null +++ b/web-app/src/app/routes/log/log-manage/log-manage.component.spec.ts @@ -0,0 +1,43 @@ +/* + * 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. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LogManageComponent } from './log-manage.component'; + +describe('LogManageComponent', () => { + let component: LogManageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [LogManageComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LogManageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web-app/src/app/routes/log/log-manage/log-manage.component.ts b/web-app/src/app/routes/log/log-manage/log-manage.component.ts new file mode 100644 index 00000000000..d468e03f793 --- /dev/null +++ b/web-app/src/app/routes/log/log-manage/log-manage.component.ts @@ -0,0 +1,566 @@ +/* + * 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. + */ + +import { CommonModule } from '@angular/common'; +import { Component, Inject, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { I18NService } from '@core'; +import { ALAIN_I18N_TOKEN } from '@delon/theme'; +import { SharedModule } from '@shared'; +import { EChartsOption } from 'echarts'; +import { NzButtonModule } from 'ng-zorro-antd/button'; +import { NzCardModule } from 'ng-zorro-antd/card'; +import { NzCheckboxModule } from 'ng-zorro-antd/checkbox'; +import { NzCollapseModule } from 'ng-zorro-antd/collapse'; +import { NzDatePickerModule } from 'ng-zorro-antd/date-picker'; +import { NzDividerModule } from 'ng-zorro-antd/divider'; +import { NzEmptyModule } from 'ng-zorro-antd/empty'; +import { NzIconModule } from 'ng-zorro-antd/icon'; +import { NzInputModule } from 'ng-zorro-antd/input'; +import { NzListModule } from 'ng-zorro-antd/list'; +import { NzMessageService } from 'ng-zorro-antd/message'; +import { NzModalService, NzModalModule } from 'ng-zorro-antd/modal'; +import { NzPopoverModule } from 'ng-zorro-antd/popover'; +import { NzSpaceModule } from 'ng-zorro-antd/space'; +import { NzStatisticModule } from 'ng-zorro-antd/statistic'; +import { NzTableModule } from 'ng-zorro-antd/table'; +import { NzTagModule } from 'ng-zorro-antd/tag'; +import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; +import { NgxEchartsModule } from 'ngx-echarts'; + +import { LogEntry } from '../../../pojo/LogEntry'; +import { LogService } from '../../../service/log.service'; + +@Component({ + selector: 'app-log-manage', + standalone: true, + imports: [ + CommonModule, + FormsModule, + SharedModule, + NzCardModule, + NzTableModule, + NzDatePickerModule, + NzInputModule, + NzButtonModule, + NzTagModule, + NzToolTipModule, + NzEmptyModule, + NgxEchartsModule, + NzStatisticModule, + NzSpaceModule, + NzIconModule, + NzDividerModule, + NzCollapseModule, + NzModalModule, + NzCheckboxModule, + NzPopoverModule, + NzListModule + ], + templateUrl: './log-manage.component.html', + styleUrl: './log-manage.component.less' +}) +export class LogManageComponent implements OnInit { + constructor( + private logSvc: LogService, + private msg: NzMessageService, + private modal: NzModalService, + @Inject(ALAIN_I18N_TOKEN) private i18n: I18NService + ) {} + + // filters + timeRange: Date[] = []; + severityNumber?: number; + severityText?: string; + traceId: string = ''; + spanId: string = ''; + + // table with pagination + loading = false; + data: LogEntry[] = []; + pageIndex = 1; + pageSize = 20; + totalElements = 0; + totalPages = 0; + + // charts + severityOption!: EChartsOption; + trendOption!: EChartsOption; + traceCoverageOption!: EChartsOption; + severityInstance: any; + trendInstance: any; + traceCoverageInstance: any; + + // Modal state + isModalVisible: boolean = false; + selectedLogEntry: LogEntry | null = null; + + // Statistics visibility control + showStatistics: boolean = false; + + // Batch selection for table + checked = false; + indeterminate = false; + setOfCheckedId = new Set(); + + // overview stats + overviewStats: any = { + totalCount: 0, + fatalCount: 0, + errorCount: 0, + warnCount: 0, + infoCount: 0, + debugCount: 0, + traceCount: 0 + }; + + // column visibility + columnVisibility = { + time: { visible: true, label: this.i18n.fanyi('log.manage.table.column.time') }, + observedTime: { visible: true, label: this.i18n.fanyi('log.manage.table.column.observed-time') }, + severity: { visible: true, label: this.i18n.fanyi('log.manage.table.column.severity') }, + body: { visible: true, label: this.i18n.fanyi('log.manage.table.column.body') }, + attributes: { visible: true, label: this.i18n.fanyi('log.manage.table.column.attributes') }, + resource: { visible: true, label: this.i18n.fanyi('log.manage.table.column.resource') }, + traceId: { visible: true, label: this.i18n.fanyi('log.manage.table.column.trace-id') }, + spanId: { visible: true, label: this.i18n.fanyi('log.manage.table.column.span-id') }, + traceFlags: { visible: true, label: this.i18n.fanyi('log.manage.table.column.trace-flags') }, + instrumentation: { visible: true, label: this.i18n.fanyi('log.manage.table.column.instrumentation') }, + droppedCount: { visible: true, label: this.i18n.fanyi('log.manage.table.column.dropped-count') } + }; + + // column control visible + columnControlVisible = false; + + ngOnInit(): void { + this.initChartThemes(); + this.query(); + } + + initChartThemes() { + this.severityOption = { + tooltip: { trigger: 'axis' }, + xAxis: { type: 'category', data: ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL'] }, + yAxis: { type: 'value' }, + series: [ + { + type: 'bar', + data: [0, 0, 0, 0, 0, 0], + itemStyle: { + color: function (params: any) { + const colors = ['#d9d9d9', '#52c41a', '#1890ff', '#faad14', '#ff4d4f', '#722ed1']; + return colors[params.dataIndex]; + } + } + } + ] + }; + + this.trendOption = { + tooltip: { trigger: 'axis' }, + xAxis: { type: 'category', data: [] }, + yAxis: { type: 'value' }, + series: [ + { + type: 'line', + data: [], + smooth: true, + areaStyle: { opacity: 0.3 } + } + ] + }; + + this.traceCoverageOption = { + tooltip: { trigger: 'item', formatter: '{b}: {c}' }, + series: [ + { + type: 'pie', + radius: ['40%', '70%'], + data: [ + { name: this.i18n.fanyi('log.manage.chart.trace-coverage.with-trace'), value: 0, itemStyle: { color: '#52c41a' } }, + { name: this.i18n.fanyi('log.manage.chart.trace-coverage.without-trace'), value: 0, itemStyle: { color: '#ff4d4f' } }, + { name: this.i18n.fanyi('log.manage.chart.trace-coverage.with-span'), value: 0, itemStyle: { color: '#1890ff' } }, + { name: this.i18n.fanyi('log.manage.chart.trace-coverage.complete-trace-info'), value: 0, itemStyle: { color: '#722ed1' } } + ], + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.5)' + } + } + } + ] + }; + } + + onSeverityChartInit(ec: any) { + this.severityInstance = ec; + } + onTrendChartInit(ec: any) { + this.trendInstance = ec; + } + onTraceCoverageChartInit(ec: any) { + this.traceCoverageInstance = ec; + } + + refreshSeverityChartFromOverview(overviewStats: any) { + const traceCount = overviewStats.traceCount || 0; + const debugCount = overviewStats.debugCount || 0; + const infoCount = overviewStats.infoCount || 0; + const warnCount = overviewStats.warnCount || 0; + const errorCount = overviewStats.errorCount || 0; + const fatalCount = overviewStats.fatalCount || 0; + + const data = [traceCount, debugCount, infoCount, warnCount, errorCount, fatalCount]; + const option: EChartsOption = { + ...this.severityOption, + series: [ + { + ...(this.severityOption.series as any)?.[0], + data + } + ] + }; + this.severityOption = option; + if (this.severityInstance) this.severityInstance.setOption(option); + } + + refreshTrendChart(hourlyStats: Record) { + const sortedHours = Object.keys(hourlyStats).sort(); + const data = sortedHours.map(hour => hourlyStats[hour]); + const option: EChartsOption = { + ...this.trendOption, + xAxis: { + type: 'category', + data: sortedHours + }, + series: [{ ...(this.trendOption.series as any)?.[0], data }] + }; + this.trendOption = option; + if (this.trendInstance) this.trendInstance.setOption(option); + } + + refreshTraceCoverageChart(traceCoverageData: any) { + const coverage = traceCoverageData.traceCoverage || {}; + const data = [ + { + name: this.i18n.fanyi('log.manage.chart.trace-coverage.with-trace'), + value: coverage.withTrace || 0, + itemStyle: { color: '#52c41a' } + }, + { + name: this.i18n.fanyi('log.manage.chart.trace-coverage.without-trace'), + value: coverage.withoutTrace || 0, + itemStyle: { color: '#ff4d4f' } + }, + { + name: this.i18n.fanyi('log.manage.chart.trace-coverage.with-span'), + value: coverage.withSpan || 0, + itemStyle: { color: '#1890ff' } + }, + { + name: this.i18n.fanyi('log.manage.chart.trace-coverage.complete-trace-info'), + value: coverage.withBothTraceAndSpan || 0, + itemStyle: { color: '#722ed1' } + } + ]; + const option: EChartsOption = { + ...this.traceCoverageOption, + series: [{ ...(this.traceCoverageOption.series as any)?.[0], data }] + }; + this.traceCoverageOption = option; + if (this.traceCoverageInstance) this.traceCoverageInstance.setOption(option); + } + + query() { + this.loading = true; + const start = this.timeRange?.[0]?.getTime(); + const end = this.timeRange?.[1]?.getTime(); + + const obs = this.logSvc.list( + start, + end, + this.traceId, + this.spanId, + this.severityNumber, + this.severityText, + this.pageIndex - 1, + this.pageSize + ); + + obs.subscribe({ + next: message => { + if (message.code === 0) { + const pageData = message.data; + this.data = pageData.content; + this.totalElements = pageData.totalElements; + this.totalPages = pageData.totalPages; + this.pageIndex = pageData.number + 1; + + // Clear selection when data changes + this.setOfCheckedId.clear(); + this.refreshCheckedStatus(); + + this.loadStatsWithFilters(); + } else { + this.msg.warning(message.msg || this.i18n.fanyi('common.notify.query-fail')); + } + this.loading = false; + }, + error: () => { + this.loading = false; + this.msg.error(this.i18n.fanyi('common.notify.query-fail')); + } + }); + } + + loadStatsWithFilters() { + const start = this.timeRange?.[0]?.getTime(); + const end = this.timeRange?.[1]?.getTime(); + const traceId = this.traceId || undefined; + const spanId = this.spanId || undefined; + const severity = this.severityNumber || undefined; + const severityText = this.severityText || undefined; + + this.logSvc.overviewStats(start, end, traceId, spanId, severity, severityText).subscribe({ + next: message => { + if (message.code === 0) { + this.overviewStats = message.data || {}; + this.refreshSeverityChartFromOverview(this.overviewStats); + } + } + }); + + this.logSvc.traceCoverageStats(start, end, traceId, spanId, severity, severityText).subscribe({ + next: message => { + if (message.code === 0) { + this.refreshTraceCoverageChart(message.data || {}); + } + } + }); + + this.logSvc.trendStats(start, end, traceId, spanId, severity, severityText).subscribe({ + next: message => { + if (message.code === 0) { + this.refreshTrendChart(message.data?.hourlyStats || {}); + } + } + }); + } + + clearFilters() { + this.timeRange = []; + this.severityNumber = undefined; + this.traceId = ''; + this.spanId = ''; + this.severityText = ''; + this.pageIndex = 1; + this.query(); + } + + toggleStatistics() { + this.showStatistics = !this.showStatistics; + } + + // Batch selection methods + updateCheckedSet(id: string, checked: boolean): void { + if (checked) { + this.setOfCheckedId.add(id); + } else { + this.setOfCheckedId.delete(id); + } + } + + onItemChecked(id: string, checked: boolean): void { + this.updateCheckedSet(id, checked); + this.refreshCheckedStatus(); + } + + onAllChecked(checked: boolean): void { + this.data.forEach(item => this.updateCheckedSet(this.getLogId(item), checked)); + this.refreshCheckedStatus(); + } + + refreshCheckedStatus(): void { + this.checked = this.data.every(item => this.setOfCheckedId.has(this.getLogId(item))); + this.indeterminate = this.data.some(item => this.setOfCheckedId.has(this.getLogId(item))) && !this.checked; + } + + getLogId(item: LogEntry): string { + return `${item.timeUnixNano}_${item.traceId || 'no-trace'}`; + } + + batchDelete(): void { + if (this.setOfCheckedId.size === 0) { + this.msg.warning(this.i18n.fanyi('common.notify.no-select-delete')); + return; + } + + const selectedItems = this.data.filter(item => this.setOfCheckedId.has(this.getLogId(item))); + + this.modal.confirm({ + nzTitle: this.i18n.fanyi('common.confirm.delete-batch'), + nzOkText: this.i18n.fanyi('common.button.delete'), + nzOkDanger: true, + nzCancelText: this.i18n.fanyi('common.button.cancel'), + nzOnOk: () => { + this.performBatchDelete(selectedItems); + } + }); + } + + performBatchDelete(selectedItems: LogEntry[]): void { + const timeUnixNanos = selectedItems.filter(item => item.timeUnixNano != null).map(item => item.timeUnixNano!); + + if (timeUnixNanos.length === 0) { + this.msg.warning(this.i18n.fanyi('common.notify.no-select-delete')); + return; + } + + this.logSvc.batchDelete(timeUnixNanos).subscribe({ + next: message => { + if (message.code === 0) { + this.msg.success(this.i18n.fanyi('common.notify.delete-success')); + this.setOfCheckedId.clear(); + this.refreshCheckedStatus(); + this.query(); + } else { + this.msg.error(message.msg || this.i18n.fanyi('common.notify.delete-fail')); + } + }, + error: () => { + this.msg.error(this.i18n.fanyi('common.notify.delete-fail')); + } + }); + } + + onTablePageChange(params: { pageIndex: number; pageSize: number; sort: any; filter: any }) { + this.pageIndex = params.pageIndex; + this.pageSize = params.pageSize; + this.query(); + } + + getSeverityColor(severityNumber?: number): string { + if (!severityNumber) return 'default'; + if (severityNumber >= 21 && severityNumber <= 24) return 'purple'; // FATAL + if (severityNumber >= 17 && severityNumber <= 20) return 'red'; // ERROR + if (severityNumber >= 13 && severityNumber <= 16) return 'orange'; // WARN + if (severityNumber >= 9 && severityNumber <= 12) return 'blue'; // INFO + if (severityNumber >= 5 && severityNumber <= 8) return 'green'; // DEBUG + if (severityNumber >= 1 && severityNumber <= 4) return 'default'; // TRACE + return 'default'; + } + + getBodyText(body: any): string { + if (!body) return ''; + if (typeof body === 'string') return body.length > 100 ? `${body.substring(0, 100)}...` : body; + if (typeof body === 'object') { + const str = JSON.stringify(body); + return str.length > 100 ? `${str.substring(0, 100)}...` : str; + } + return String(body); + } + + getObjectText(obj: any): string { + if (!obj) return ''; + if (typeof obj === 'object') { + const keys = Object.keys(obj); + if (keys.length === 0) return ''; + if (keys.length === 1) { + return `${keys[0]}: ${obj[keys[0]]}`; + } + return `${keys[0]}: ${obj[keys[0]]} (+${keys.length - 1} more)`; + } + return String(obj); + } + + getInstrumentationText(scope: any): string { + if (!scope) return ''; + const parts = []; + if (scope.name) parts.push(`Name: ${scope.name}`); + if (scope.version) parts.push(`Version: ${scope.version}`); + if (scope.attributes && Object.keys(scope.attributes).length > 0) { + parts.push(`Attributes: ${Object.keys(scope.attributes).length} items`); + } + return parts.join('\n'); + } + + // Modal methods + showLogDetails(logEntry: LogEntry): void { + this.selectedLogEntry = logEntry; + this.isModalVisible = true; + } + + handleModalCancel(): void { + this.isModalVisible = false; + this.selectedLogEntry = null; + } + + getLogEntryJson(logEntry: LogEntry): string { + return JSON.stringify(logEntry, null, 2); + } + + formatTimestamp(timeUnixNano: number | undefined): string { + if (!timeUnixNano) return ''; + return new Date(timeUnixNano / 1000000).toLocaleString(); + } + + copyToClipboard(text: string): void { + navigator.clipboard + .writeText(text) + .then(() => { + this.msg.success(this.i18n.fanyi('common.notify.copy-success')); + }) + .catch(err => { + console.error('Failed to copy: ', err); + this.msg.error(this.i18n.fanyi('common.notify.copy-fail')); + }); + } + + toggleColumnVisibility(column: string): void { + if (this.columnVisibility[column as keyof typeof this.columnVisibility]) { + this.columnVisibility[column as keyof typeof this.columnVisibility].visible = + !this.columnVisibility[column as keyof typeof this.columnVisibility].visible; + } + } + + getVisibleColumnsCount(): number { + return Object.values(this.columnVisibility).filter(col => col.visible).length; + } + + getColumnKeys(): string[] { + return Object.keys(this.columnVisibility); + } + + getColumnVisibility(): any { + return this.columnVisibility; + } + + resetColumns(): void { + Object.keys(this.columnVisibility).forEach(key => { + this.columnVisibility[key as keyof typeof this.columnVisibility].visible = true; + }); + this.msg.success(this.i18n.fanyi('common.notify.operate-success')); + } + + toggleColumnControl(): void { + this.columnControlVisible = !this.columnControlVisible; + } +} diff --git a/web-app/src/app/routes/log/log-routing.module.ts b/web-app/src/app/routes/log/log-routing.module.ts new file mode 100644 index 00000000000..7ba2eb7916e --- /dev/null +++ b/web-app/src/app/routes/log/log-routing.module.ts @@ -0,0 +1,38 @@ +/* + * 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. + */ + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { LogIntegrationComponent } from './log-integration/log-integration.component'; +import { LogManageComponent } from './log-manage/log-manage.component'; +import { LogStreamComponent } from './log-stream/log-stream.component'; + +const routes: Routes = [ + { path: '', component: LogIntegrationComponent }, + { path: 'integration/:source', component: LogIntegrationComponent }, + { path: 'stream', component: LogStreamComponent }, + { path: 'manage', component: LogManageComponent } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class LogRoutingModule {} diff --git a/web-app/src/app/routes/log/log-stream/log-stream.component.html b/web-app/src/app/routes/log/log-stream/log-stream.component.html new file mode 100644 index 00000000000..e60d8553b6f --- /dev/null +++ b/web-app/src/app/routes/log/log-stream/log-stream.component.html @@ -0,0 +1,255 @@ + + + + + + +
+ + + +
+ +
+ + + {{ + isConnected + ? ('log.stream.connected' | i18n) + : isConnecting + ? ('log.stream.connecting' | i18n) + : ('log.stream.disconnected' | i18n) + }} + + + {{ logEntries.length }} {{ 'log.stream.logs' | i18n }} +
+ + +
+ + + + + + + +
+
+ + +
+ +
+
+ + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + +
+ +
+ +
+
+
+ +
+ +
+ +
+ + +
+
+
+ + {{ logEntry.original.severityText || ('log.stream.unknown' | i18n) }} + + + + {{ formatTimestamp(logEntry.timestamp!) }} + +
+ +
+ {{ getLogEntryJson(logEntry.original) }} +
+ +
+ +
+
+
+
+
+
+ + + + +
+ + +
+
+ {{ 'log.stream.severity' | i18n }} + + {{ selectedLogEntry.original.severityText || ('log.stream.unknown' | i18n) }} + +
+
+ {{ 'log.stream.timestamp' | i18n }} + {{ formatTimestamp(selectedLogEntry.timestamp!) }} +
+
+ {{ 'log.stream.trace-id-full' | i18n }} + {{ selectedLogEntry.original.traceId }} +
+
+ {{ 'log.stream.span-id-full' | i18n }} + {{ selectedLogEntry.original.spanId }} +
+
+
+ + + +
+
{{ getLogEntryJson(selectedLogEntry.original) }}
+
+
+
+
+
diff --git a/web-app/src/app/routes/log/log-stream/log-stream.component.less b/web-app/src/app/routes/log/log-stream/log-stream.component.less new file mode 100644 index 00000000000..a74529f24aa --- /dev/null +++ b/web-app/src/app/routes/log/log-stream/log-stream.component.less @@ -0,0 +1,422 @@ +/* + * 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. + */ + +@import "~src/styles/theme"; + +.log-stream-container { + background: @common-background-color; + + .header-card { + margin-bottom: 16px; + + .header-content { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 16px; + + .connection-status { + display: flex; + align-items: center; + gap: 12px; + + .status-tag { + font-weight: 500; + font-size: 14px; + padding: 4px 12px; + border-radius: 4px; + + i { + margin-right: 6px; + } + } + + .log-count { + color: #666; + font-size: 14px; + font-weight: 500; + background: #f0f0f0; + padding: 4px 12px; + border-radius: 4px; + } + } + + .control-buttons { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + + button { + border-radius: 4px; + + i { + margin-right: 6px; + } + } + + nz-switch { + margin-left: 8px; + } + } + } + + .filter-content { + .filter-controls { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + + .filter-item { + display: flex; + align-items: center; + gap: 8px; + + .filter-label { + font-weight: 500; + color: #666; + font-size: 14px; + white-space: nowrap; + margin-bottom: 0; + } + + .filter-select { + width: 140px; + } + + .filter-input { + width: 180px; + } + } + + .filter-actions { + margin-left: auto; + } + } + } + + .error-alert { + margin-top: 16px; + border-radius: 4px; + } + } + + .log-container { + max-height: 60vh; + overflow-y: auto; + background: @common-background-color; + position: relative; + margin-top: 24px; + + &.paused { + opacity: 0.8; + } + + .loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: #666; + + .loading-text { + margin-top: 16px; + font-size: 16px; + } + } + + .empty-state { + padding: 60px 20px; + } + + .log-entry { + border-bottom: 1px solid #f0f0f0; + padding: 0px 12px; + background: @common-background-color; + cursor: pointer; + + &.new-entry { + background: #e6f7ff; + border-left: 4px solid #1890ff; + } + + &:hover { + background: #fafafa; + } + + &:last-child { + border-bottom: none; + } + + .log-content { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 0; + + .log-meta { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + + .severity-tag { + font-weight: 500; + font-size: 11px; + border-radius: 3px; + padding: 1px 6px; + min-width: 50px; + text-align: center; + } + + .timestamp { + color: #666; + font-size: 12px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + background: #f5f5f5; + padding: 2px 8px; + border-radius: 4px; + } + } + + .log-message { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 13px; + line-height: 1.4; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: #f8f9fa; + padding: 6px 12px; + border-radius: 4px; + border-left: 3px solid #e9ecef; + flex: 1; + min-width: 0; + } + + .log-actions { + flex-shrink: 0; + + button { + border: none; + box-shadow: none; + color: #666; + + &:hover { + color: #1890ff; + background: #e6f7ff; + } + } + } + } + } + + .pause-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + + .pause-message { + text-align: center; + padding: 20px; + background: @common-background-color; + border-radius: 4px; + border: 1px solid #e9ecef; + + .pause-icon { + font-size: 32px; + color: #faad14; + margin-bottom: 12px; + display: block; + } + + span { + display: block; + font-size: 16px; + color: #666; + margin-bottom: 16px; + font-weight: 500; + } + + button { + border-radius: 4px; + height: 36px; + padding: 0 20px; + font-weight: 500; + } + } + } + } +} + +// Responsive design +@media (max-width: 768px) { + .log-stream-container { + padding: 12px; + + .header-card { + .header-content { + flex-direction: column; + align-items: stretch; + + .connection-status { + justify-content: center; + } + + .control-buttons { + justify-content: center; + } + } + + .filter-content .filter-controls { + flex-direction: column; + align-items: stretch; + gap: 12px; + + .filter-item { + flex-direction: column; + align-items: stretch; + gap: 4px; + + .filter-label { + font-size: 12px; + } + + .filter-select, + .filter-input { + width: 100%; + } + } + + .filter-actions { + margin-left: 0; + text-align: center; + } + } + } + + .log-container { + max-height: 60vh; + + .log-entry { + padding: 8px 12px; + + .log-content { + flex-direction: column; + align-items: flex-start; + gap: 8px; + + .log-meta { + width: 100%; + } + + .log-message { + width: 100%; + font-size: 12px; + } + + .log-actions { + width: 100%; + text-align: right; + } + } + } + } + } +} + +// Modal styles +.log-details-modal { + .basic-info { + .info-row { + display: flex; + align-items: center; + margin-bottom: 12px; + + &:last-child { + margin-bottom: 0; + } + + .info-label { + font-weight: 500; + color: #666; + min-width: 100px; + margin-right: 12px; + } + + span { + color: #333; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 13px; + } + + nz-tag { + font-size: 12px; + } + } + } + + .message-content { + pre { + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 4px; + padding: 12px; + margin: 0; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 13px; + line-height: 1.5; + color: #333; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; + } + } + + .json-content { + pre { + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 4px; + padding: 12px; + margin: 0; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + line-height: 1.4; + color: #333; + white-space: pre-wrap; + word-break: break-word; + max-height: 400px; + overflow-y: auto; + } + } +} + diff --git a/web-app/src/app/routes/log/log-stream/log-stream.component.spec.ts b/web-app/src/app/routes/log/log-stream/log-stream.component.spec.ts new file mode 100644 index 00000000000..393c1c9310d --- /dev/null +++ b/web-app/src/app/routes/log/log-stream/log-stream.component.spec.ts @@ -0,0 +1,41 @@ +/* + * 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. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LogStreamComponent } from './log-stream.component'; + +describe('LogStreamComponent', () => { + let component: LogStreamComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LogStreamComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(LogStreamComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web-app/src/app/routes/log/log-stream/log-stream.component.ts b/web-app/src/app/routes/log/log-stream/log-stream.component.ts new file mode 100644 index 00000000000..c5861a386ff --- /dev/null +++ b/web-app/src/app/routes/log/log-stream/log-stream.component.ts @@ -0,0 +1,401 @@ +/* + * 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. + */ + +import { CommonModule } from '@angular/common'; +import { Component, Inject, OnDestroy, OnInit, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { I18NService } from '@core'; +import { ALAIN_I18N_TOKEN } from '@delon/theme'; +import { SharedModule } from '@shared'; +import { NzAlertModule } from 'ng-zorro-antd/alert'; +import { NzButtonModule } from 'ng-zorro-antd/button'; +import { NzCardModule } from 'ng-zorro-antd/card'; +import { NzDividerComponent } from 'ng-zorro-antd/divider'; +import { NzEmptyModule } from 'ng-zorro-antd/empty'; +import { NzInputModule } from 'ng-zorro-antd/input'; +import { NzModalModule } from 'ng-zorro-antd/modal'; +import { NzSelectModule } from 'ng-zorro-antd/select'; +import { NzSwitchModule } from 'ng-zorro-antd/switch'; +import { NzTagModule } from 'ng-zorro-antd/tag'; +import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; + +import { LogEntry } from '../../../pojo/LogEntry'; + +interface ExtendedLogEntry { + original: LogEntry; + isNew?: boolean; + timestamp?: Date; +} + +@Component({ + selector: 'app-log-stream', + standalone: true, + imports: [ + CommonModule, + SharedModule, + FormsModule, + NzCardModule, + NzInputModule, + NzSelectModule, + NzButtonModule, + NzTagModule, + NzToolTipModule, + NzSwitchModule, + NzAlertModule, + NzEmptyModule, + NzModalModule, + NzDividerComponent + ], + templateUrl: './log-stream.component.html', + styleUrl: './log-stream.component.less' +}) +export class LogStreamComponent implements OnInit, OnDestroy, AfterViewInit { + // SSE connection and state + private eventSource!: EventSource; + isConnected: boolean = false; + isConnecting: boolean = false; + + // Log data + logEntries: ExtendedLogEntry[] = []; + maxLogEntries: number = 1000; + isPaused: boolean = false; + + // Filter properties + filterSeverityNumber: string = ''; + filterSeverityText: string = ''; + filterTraceId: string = ''; + filterSpanId: string = ''; + + // UI state + showFilters: boolean = true; + + // Modal state + isModalVisible: boolean = false; + selectedLogEntry: ExtendedLogEntry | null = null; + + // Auto scroll state + userScrolled: boolean = false; + private scrollTimeout: any; + private scrollDebounceTimeout: any; + private isNearBottom: boolean = true; + + // ViewChild for log container + @ViewChild('logContainer', { static: false }) logContainerRef!: ElementRef; + + constructor(@Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService) {} + + ngOnInit(): void { + this.connectToLogStream(); + } + + ngAfterViewInit(): void { + this.setupScrollListener(); + } + + ngOnDestroy(): void { + this.disconnectFromLogStream(); + this.cleanupScrollListener(); + } + + onReconnect(): void { + this.logEntries = []; + this.connectToLogStream(); + } + + private connectToLogStream(): void { + if (this.eventSource) { + this.disconnectFromLogStream(); + } + + this.isConnecting = true; + + // Build filter parameters + const filterParams = this.buildFilterParams(); + const url = `/api/log/sse/subscribe${filterParams ? `?${filterParams}` : ''}`; + + try { + this.eventSource = new EventSource(url); + + this.eventSource.onopen = () => { + this.isConnected = true; + this.isConnecting = false; + }; + + this.eventSource.addEventListener('LOG_EVENT', (evt: MessageEvent) => { + if (!this.isPaused) { + try { + const logEntry: LogEntry = JSON.parse(evt.data); + this.addLogEntry(logEntry); + } catch (error) { + console.error('Error parsing log data:', error); + } + } + }); + + this.eventSource.onerror = error => { + console.error('Log stream connection error:', error); + this.isConnected = false; + this.isConnecting = false; + + // Auto-reconnect after 5 seconds + setTimeout(() => { + if (!this.isConnected) { + this.connectToLogStream(); + } + }, 5000); + }; + } catch (error) { + this.isConnecting = false; + console.error('Failed to create EventSource:', error); + } + } + + private disconnectFromLogStream(): void { + if (this.eventSource) { + this.eventSource.close(); + this.isConnected = false; + this.isConnecting = false; + } + } + + private buildFilterParams(): string { + const params = new URLSearchParams(); + + if (this.filterSeverityNumber && this.filterSeverityNumber.trim()) { + params.append('severityNumber', this.filterSeverityNumber); + } + + if (this.filterSeverityText && this.filterSeverityText.trim()) { + params.append('severityText', this.filterSeverityText); + } + + if (this.filterTraceId && this.filterTraceId.trim()) { + params.append('traceId', this.filterTraceId); + } + + if (this.filterSpanId && this.filterSpanId.trim()) { + params.append('spanId', this.filterSpanId); + } + + return params.toString(); + } + + private addLogEntry(logEntry: LogEntry): void { + const extendedEntry: ExtendedLogEntry = { + original: logEntry, + isNew: true, + timestamp: logEntry.timeUnixNano ? new Date(logEntry.timeUnixNano / 1000000) : new Date() + }; + + this.logEntries.unshift(extendedEntry); + + // Limit the number of log entries + if (this.logEntries.length > this.maxLogEntries) { + this.logEntries = this.logEntries.slice(0, this.maxLogEntries); + } + + // Remove new indicator after animation + setTimeout(() => { + const index = this.logEntries.findIndex(entry => entry === extendedEntry); + if (index !== -1) { + this.logEntries[index].isNew = false; + } + }, 1000); + + // Auto scroll to top if enabled and user hasn't scrolled away + if (!this.userScrolled) { + this.scheduleAutoScroll(); + } + } + + private setupScrollListener(): void { + if (this.logContainerRef?.nativeElement) { + const container = this.logContainerRef.nativeElement; + + container.addEventListener('scroll', () => { + // Debounce scroll events for better performance + if (this.scrollDebounceTimeout) { + clearTimeout(this.scrollDebounceTimeout); + } + + this.scrollDebounceTimeout = setTimeout(() => { + this.handleScroll(); + }, 100); + }); + } + } + + private cleanupScrollListener(): void { + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + } + if (this.scrollDebounceTimeout) { + clearTimeout(this.scrollDebounceTimeout); + } + } + + private handleScroll(): void { + if (!this.logContainerRef?.nativeElement) return; + + const container = this.logContainerRef.nativeElement; + const scrollTop = container.scrollTop; + + // Check if user is near the top (within 20px for more precise detection) + this.isNearBottom = scrollTop <= 20; + + // If user scrolls away from top, mark as user scrolled + if (!this.isNearBottom) { + this.userScrolled = true; + } else { + // If user scrolls back to top, reset the flag + this.userScrolled = false; + } + } + + private scheduleAutoScroll(): void { + // Clear existing timeout + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + } + + // Schedule scroll with longer delay to ensure DOM update + this.scrollTimeout = setTimeout(() => { + this.performAutoScroll(); + }, 100); + } + + private performAutoScroll(): void { + if (!this.logContainerRef?.nativeElement || this.userScrolled) { + return; + } + + const container = this.logContainerRef.nativeElement; + + // Use smooth scroll for better UX + container.scrollTo({ + top: 0, + behavior: 'smooth' + }); + } + + // Event handlers + onApplyFilters(): void { + this.logEntries = []; // Clear existing logs + this.connectToLogStream(); // Reconnect with new filters + } + + onFilterChange(): void { + this.logEntries = []; // Clear existing logs + this.connectToLogStream(); // Reconnect with new filters + } + + onClearFilters(): void { + this.filterSeverityNumber = ''; + this.filterSeverityText = ''; + this.filterTraceId = ''; + this.filterSpanId = ''; + this.logEntries = []; + this.connectToLogStream(); + } + + onTogglePause(): void { + this.isPaused = !this.isPaused; + } + + onClearLogs(): void { + this.logEntries = []; + this.userScrolled = false; + this.isNearBottom = true; + } + + onToggleFilters(): void { + this.showFilters = !this.showFilters; + } + + // Add method to manually scroll to top + scrollToTop(): void { + this.userScrolled = false; + this.isNearBottom = true; + this.scheduleAutoScroll(); + } + + // Utility methods + getSeverityColor(severityNumber: number | undefined): string { + if (!severityNumber) { + return 'default'; + } + + // Based on OpenTelemetry specification: + // 1-4: TRACE, 5-8: DEBUG, 9-12: INFO, 13-16: WARN, 17-20: ERROR, 21-24: FATAL + if (severityNumber >= 1 && severityNumber <= 4) { + return 'default'; // TRACE + } else if (severityNumber >= 5 && severityNumber <= 8) { + return 'blue'; // DEBUG + } else if (severityNumber >= 9 && severityNumber <= 12) { + return 'green'; // INFO + } else if (severityNumber >= 13 && severityNumber <= 16) { + return 'orange'; // WARN + } else if (severityNumber >= 17 && severityNumber <= 20) { + return 'red'; // ERROR + } else if (severityNumber >= 21 && severityNumber <= 24) { + return 'volcano'; // FATAL + } else { + return 'default'; // Unknown + } + } + + formatTimestamp(timestamp: Date): string { + return timestamp.toLocaleString(); + } + + copyToClipboard(text: string): void { + navigator.clipboard + .writeText(text) + .then(() => { + console.log('Copied to clipboard'); + }) + .catch(err => { + console.error('Failed to copy: ', err); + }); + } + + trackByLogEntry(index: number, logEntry: ExtendedLogEntry): any { + return logEntry.original.timeUnixNano || index; + } + + // Modal methods + showLogDetails(logEntry: ExtendedLogEntry): void { + this.selectedLogEntry = logEntry; + this.isModalVisible = true; + } + + handleModalOk(): void { + this.isModalVisible = false; + this.selectedLogEntry = null; + } + + handleModalCancel(): void { + this.isModalVisible = false; + this.selectedLogEntry = null; + } + + getLogEntryJson(logEntry: LogEntry): string { + return JSON.stringify(logEntry, null, 2); + } +} diff --git a/web-app/src/app/routes/log/log.module.ts b/web-app/src/app/routes/log/log.module.ts new file mode 100644 index 00000000000..1f117409421 --- /dev/null +++ b/web-app/src/app/routes/log/log.module.ts @@ -0,0 +1,52 @@ +/* + * 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. + */ + +import { NgModule, Type } from '@angular/core'; +import { SharedModule } from '@shared'; +import { NzBreadCrumbModule } from 'ng-zorro-antd/breadcrumb'; +import { NzDatePickerModule } from 'ng-zorro-antd/date-picker'; +import { NzEmptyModule } from 'ng-zorro-antd/empty'; +import { NzPaginationModule } from 'ng-zorro-antd/pagination'; +import { NzTableModule } from 'ng-zorro-antd/table'; +import { NzTagModule } from 'ng-zorro-antd/tag'; + +import { LogIntegrationComponent } from './log-integration/log-integration.component'; +import { LogManageComponent } from './log-manage/log-manage.component'; +import { LogRoutingModule } from './log-routing.module'; +import { LogStreamComponent } from './log-stream/log-stream.component'; + +const COMPONENTS: Array> = []; + +@NgModule({ + imports: [ + SharedModule, + LogRoutingModule, + NzTableModule, + NzTagModule, + NzDatePickerModule, + NzPaginationModule, + NzEmptyModule, + NzBreadCrumbModule, + LogIntegrationComponent, + LogStreamComponent, + LogManageComponent + ], + declarations: COMPONENTS +}) +export class LogModule {} diff --git a/web-app/src/app/routes/routes-routing.module.ts b/web-app/src/app/routes/routes-routing.module.ts index bdc9b4c1085..b1ff83b0082 100644 --- a/web-app/src/app/routes/routes-routing.module.ts +++ b/web-app/src/app/routes/routes-routing.module.ts @@ -27,6 +27,7 @@ const routes: Routes = [ loadChildren: () => import('./monitor/monitor.module').then(m => m.MonitorModule), data: { titleI18n: 'menu.monitor.center' } }, + { path: 'log', loadChildren: () => import('./log/log.module').then(m => m.LogModule) }, { path: 'alert', loadChildren: () => import('./alert/alert.module').then(m => m.AlertModule) }, { path: 'setting', loadChildren: () => import('./setting/setting.module').then(m => m.SettingModule) } ] diff --git a/web-app/src/app/service/log.service.ts b/web-app/src/app/service/log.service.ts new file mode 100644 index 00000000000..2a4a0a19aae --- /dev/null +++ b/web-app/src/app/service/log.service.ts @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { LogEntry } from '../pojo/LogEntry'; +import { Message } from '../pojo/Message'; +import { Page } from '../pojo/Page'; + +// endpoints +const logs_list_uri = '/logs/list'; +const logs_stats_overview_uri = '/logs/stats/overview'; +const logs_stats_trend_uri = '/logs/stats/trend'; +const logs_stats_trace_coverage_uri = '/logs/stats/trace-coverage'; +const logs_batch_delete_uri = '/logs'; + +@Injectable({ providedIn: 'root' }) +export class LogService { + constructor(private http: HttpClient) {} + + public list( + start?: number, + end?: number, + traceId?: string, + spanId?: string, + severityNumber?: number, + severityText?: string, + pageIndex: number = 0, + pageSize: number = 20 + ): Observable>> { + let params = new HttpParams(); + if (start != null) params = params.set('start', start); + if (end != null) params = params.set('end', end); + if (traceId) params = params.set('traceId', traceId); + if (spanId) params = params.set('spanId', spanId); + if (severityNumber != null) params = params.set('severityNumber', severityNumber); + if (severityText) params = params.set('severityText', severityText); + params = params.set('pageIndex', pageIndex); + params = params.set('pageSize', pageSize); + return this.http.get>(logs_list_uri, { params }); + } + + public overviewStats( + start?: number, + end?: number, + traceId?: string, + spanId?: string, + severityNumber?: number, + severityText?: string + ): Observable> { + let params = new HttpParams(); + if (start != null) params = params.set('start', start); + if (end != null) params = params.set('end', end); + if (traceId) params = params.set('traceId', traceId); + if (spanId) params = params.set('spanId', spanId); + if (severityNumber != null) params = params.set('severityNumber', severityNumber); + if (severityText) params = params.set('severityText', severityText); + return this.http.get>(logs_stats_overview_uri, { params }); + } + + public trendStats( + start?: number, + end?: number, + traceId?: string, + spanId?: string, + severityNumber?: number, + severityText?: string + ): Observable> { + let params = new HttpParams(); + if (start != null) params = params.set('start', start); + if (end != null) params = params.set('end', end); + if (traceId) params = params.set('traceId', traceId); + if (spanId) params = params.set('spanId', spanId); + if (severityNumber != null) params = params.set('severityNumber', severityNumber); + if (severityText) params = params.set('severityText', severityText); + return this.http.get>(logs_stats_trend_uri, { params }); + } + + public traceCoverageStats( + start?: number, + end?: number, + traceId?: string, + spanId?: string, + severityNumber?: number, + severityText?: string + ): Observable> { + let params = new HttpParams(); + if (start != null) params = params.set('start', start); + if (end != null) params = params.set('end', end); + if (traceId) params = params.set('traceId', traceId); + if (spanId) params = params.set('spanId', spanId); + if (severityNumber != null) params = params.set('severityNumber', severityNumber); + if (severityText) params = params.set('severityText', severityText); + return this.http.get>(logs_stats_trace_coverage_uri, { params }); + } + + public batchDelete(timeUnixNanos: number[]): Observable> { + let httpParams = new HttpParams(); + timeUnixNanos.forEach(timeUnixNano => { + httpParams = httpParams.append('timeUnixNanos', timeUnixNano); + }); + const options = { params: httpParams }; + return this.http.delete>(logs_batch_delete_uri, options); + } +} diff --git a/web-app/src/assets/app-data.json b/web-app/src/assets/app-data.json index 5f55c39b36e..bc7102bf880 100644 --- a/web-app/src/assets/app-data.json +++ b/web-app/src/assets/app-data.json @@ -136,6 +136,32 @@ } ] }, + { + "text": "Log", + "i18n": "menu.log", + "group": true, + "hideInBreadcrumb": true, + "children": [ + { + "text": "Integration", + "i18n": "menu.log.integration", + "icon": "anticon-api", + "link": "/log/integration/otlp" + }, + { + "text": "Stream", + "i18n": "menu.log.stream", + "icon": "anticon-file-text", + "link": "/log/stream" + }, + { + "text": "Manage", + "i18n": "menu.log.manage", + "icon": "anticon-database", + "link": "/log/manage" + } + ] + }, { "text": "Alarm", "i18n": "menu.alert", diff --git a/web-app/src/assets/doc/log-integration/otlp.en-US.md b/web-app/src/assets/doc/log-integration/otlp.en-US.md new file mode 100644 index 00000000000..387472c53fc --- /dev/null +++ b/web-app/src/assets/doc/log-integration/otlp.en-US.md @@ -0,0 +1,105 @@ +> HertzBeat supports OpenTelemetry Logs Protocol (OTLP), allowing external systems to push log data to the HertzBeat log platform via OTLP. + +### API Endpoint + +`POST /api/logs/ingest/otlp` + +Or use the default endpoint (automatically uses OTLP protocol): + +`POST /api/logs/ingest` + +### Request Headers + +- `Content-Type`: `application/json` +- `Authorization`: `Bearer {token}` + +### Request Body + +Supports standard OTLP JSON format log data: + +```json +{ + "resourceLogs": [ + { + "resource": { + "attributes": [ + { + "key": "service.name", + "value": { + "stringValue": "my-service" + } + }, + { + "key": "service.version", + "value": { + "stringValue": "1.0.0" + } + } + ] + }, + "scopeLogs": [ + { + "scope": { + "name": "my-logger", + "version": "1.0.0" + }, + "logRecords": [ + { + "timeUnixNano": "1640995200000000000", + "severityNumber": 9, + "severityText": "INFO", + "body": { + "stringValue": "This is a log message" + }, + "attributes": [ + { + "key": "user.id", + "value": { + "stringValue": "12345" + } + } + ] + } + ] + } + ] + } + ] +} +``` + +### Configuration Examples + +#### OpenTelemetry Collector Configuration + +```yaml +exporters: + otlphttp: + logs_endpoint: http://{hertzbeat_host}:1157/api/logs/ingest/otlp + compression: none + encoding: json +``` + +### Configuration Verification + +1. Configure external systems to send OTLP logs to HertzBeat specified interface +2. Check received log data in HertzBeat log platform +3. Verify log data format and content correctness + +### Common Issues + +#### Log Sending Failures +- Ensure HertzBeat service address is accessible from external systems +- Check if Token is correctly configured +- Verify request header Content-Type is set to application/json + +#### Log Format Errors +- Ensure sending standard OTLP JSON format +- Check timestamp format is nanosecond precision +- Verify severityNumber value range (1-24) + +#### Performance Optimization Tips +- Use batch processing to send logs, reducing network requests +- Set appropriate log levels, avoid sending too many DEBUG logs + +For more information, please refer to [OpenTelemetry Logs Specification](https://opentelemetry.io/docs/specs/otel/logs/) \ No newline at end of file diff --git a/web-app/src/assets/doc/log-integration/otlp.zh-CN.md b/web-app/src/assets/doc/log-integration/otlp.zh-CN.md new file mode 100644 index 00000000000..a52064b5bbb --- /dev/null +++ b/web-app/src/assets/doc/log-integration/otlp.zh-CN.md @@ -0,0 +1,105 @@ +> HertzBeat 支持 OpenTelemetry Logs Protocol (OTLP) 协议,外部系统可以通过 OTLP 方式将日志数据推送到 HertzBeat 日志平台。 + +### 接口端点 + +`POST /api/logs/ingest/otlp` + +或使用默认接口(自动使用OTLP协议): + +`POST /api/logs/ingest` + +### 请求头 + +- `Content-Type`: `application/json` +- `Authorization`: `Bearer {token}` + +### 请求体 + +支持标准的 OTLP JSON 格式日志数据: + +```json +{ + "resourceLogs": [ + { + "resource": { + "attributes": [ + { + "key": "service.name", + "value": { + "stringValue": "my-service" + } + }, + { + "key": "service.version", + "value": { + "stringValue": "1.0.0" + } + } + ] + }, + "scopeLogs": [ + { + "scope": { + "name": "my-logger", + "version": "1.0.0" + }, + "logRecords": [ + { + "timeUnixNano": "1640995200000000000", + "severityNumber": 9, + "severityText": "INFO", + "body": { + "stringValue": "This is a log message" + }, + "attributes": [ + { + "key": "user.id", + "value": { + "stringValue": "12345" + } + } + ] + } + ] + } + ] + } + ] +} +``` + +### 配置示例 + +#### OpenTelemetry Collector 配置 + +```yaml +exporters: + otlphttp: + logs_endpoint: http://{hertzbeat_host}:1157/api/logs/ingest/otlp + compression: none + encoding: json +``` + +### 配置验证 + +1. 配置外部系统发送OTLP日志到HertzBeat指定接口 +2. 在HertzBeat日志平台中查看接收到的日志数据 +3. 验证日志数据格式和内容是否正确 + +### 常见问题 + +#### 日志发送失败 +- 确保HertzBeat服务地址可以被外部系统访问 +- 检查Token是否正确配置 +- 验证请求头Content-Type设置为application/json + +#### 日志格式错误 +- 确保发送的是标准OTLP JSON格式 +- 检查时间戳格式是否为纳秒精度 +- 验证severityNumber值范围(1-24) + +#### 性能优化建议 +- 使用批处理方式发送日志,减少网络请求 +- 合理设置日志级别,避免发送过多DEBUG日志 + +更多信息请参考 [OpenTelemetry日志规范](https://opentelemetry.io/docs/specs/otel/logs/) \ No newline at end of file diff --git a/web-app/src/assets/i18n/en-US.json b/web-app/src/assets/i18n/en-US.json index 94e4c07afd3..85a8de8e8ed 100644 --- a/web-app/src/assets/i18n/en-US.json +++ b/web-app/src/assets/i18n/en-US.json @@ -240,6 +240,10 @@ "alert.setting.connect.label": "Label Associate", "alert.setting.connect.label.input": "Input Label", "alert.setting.connect.label.empty": "No matching monitoring objects found, please add labels", + "alert.setting.datatype": "Data Type", + "alert.setting.datatype.tip": "Select the data type for this threshold rule", + "alert.setting.datatype.metric": "Metric", + "alert.setting.datatype.log": "Log", "alert.setting.filter.name": "Input name to filter...", "alert.setting.filter.labels": "Input labels to filter...", "alert.setting.default": "Global Default", @@ -271,6 +275,7 @@ "alert.setting.new.periodic": "New Periodic Threshold", "alert.setting.new.realtime": "New RealTime Threshold", "alert.setting.number": "Numeric", + "alert.setting.object": "Object", "alert.setting.operator": "Supported operator functions", "alert.setting.period": "Execution Period", "alert.setting.period.placeholder": "Please enter execution period, minimum 60 seconds", @@ -284,6 +289,7 @@ "alert.setting.rule.label": "Graphical setting alarm threshold rules, support multiple rules &&", "alert.setting.rule.metric.place-holder": "Please select metric", "alert.setting.rule.numeric-value.place-holder": "Please input numeric", + "alert.setting.rule.object.attribute.placeholder": "Please input object attribute", "alert.setting.rule.operator": "Operator", "alert.setting.rule.operator.exists": "value exists", "alert.setting.rule.operator.no-exists": "no value exists", @@ -294,6 +300,12 @@ "alert.setting.rule.operator.str-no-equals": "not equals", "alert.setting.rule.operator.str-no-matches": "not matches", "alert.setting.rule.string-value.place-holder": "Please input string", + "alert.setting.log.query": "Log Query", + "alert.setting.log.query.tip": "Configure log query conditions to filter logs that need to be monitored", + "alert.setting.log.query.placeholder": "Please enter log query conditions", + "alert.setting.log.expr": "Log Alert Expression", + "alert.setting.log.expr.tip": "Configure log alert trigger conditions based on query results", + "alert.setting.log.expr.placeholder": "Please enter alert expression, e.g.: count > 10", "alert.setting.rule.switch-expr.0": "Template Threshold", "alert.setting.rule.switch-expr.1": "Coding Threshold", "alert.setting.search": "Search Threshold", @@ -327,15 +339,24 @@ "alert.setting.times.tip": "Set how many times the threshold is triggered before sending an alert", "alert.setting.trigger": "Trigger alarms", "alert.setting.type": "Threshold Type", - "alert.setting.type.periodic": "Periodic", - "alert.setting.type.periodic.desc": "Periodically execute PromQL queries to trigger threshold alerts.", - "alert.setting.type.realtime": "RealTime", - "alert.setting.type.realtime.desc": "Real-time metric calculation with instant alert on threshold breach.", + "alert.setting.type.periodic": "Periodic Threshold", + "alert.setting.type.periodic.desc": "Execute queries periodically to trigger threshold alerts.", + "alert.setting.type.periodic.log": "Log Periodic", + "alert.setting.type.periodic.metric": "Metric Periodic", + "alert.setting.type.realtime": "RealTime Threshold", + "alert.setting.type.realtime.desc": "Calculate data in real-time with instant alert on threshold breach", + "alert.setting.type.realtime.log": "Log RealTime", + "alert.setting.type.realtime.metric": "Metric RealTime", "alert.severity": "Alarm Severity", "alert.severity.0": "Emergency", "alert.severity.1": "Critical", "alert.severity.2": "Warning", "alert.severity.all": "All Severity", + "alert.mode": "Alert Mode", + "alert.mode.group": "Alert Group", + "alert.mode.individual": "Individual Alert", + "alert.setting.mode.tip": "Select alert mode, alert group mode will merge alerts with the same conditions, individual alert mode will send each alert separately", + "alert.notice.rule.mode.placeholder": "Please select alert mode", "alert.silence.delete": "Delete Silence Strategy", "alert.silence.edit": "Edit Silence Strategy", "alert.silence.labels": "Label Match", @@ -563,6 +584,98 @@ "label.value": "Label Value", "labels.help": "Labels are everywhere. We can apply labels in resource grouping, tag matching under rules and others. [Label Manage] is used for unified management of labels, including adding, deleting, editing, etc.
You can use labels to classify and manage monitoring resources, such as binding labels for production and testing environments separately.", "labels.help.link": "https://hertzbeat.apache.org/zh-cn/docs/", + "log.help.integration": "Unified log management, integrating and accessing log messages from external log sources for collection, storage, alerting, analysis, and more.", + "log.help.integration.link": "https://hertzbeat.apache.org", + "log.help.manage": "Log management and statistics, supporting operations such as log query, statistics, deletion, etc.", + "log.help.manage.link": "https://hertzbeat.apache.org", + "log.help.stream": "Real-time log stream monitoring interface for viewing real-time log entries, with filtering and search functions.", + "log.help.stream.link": "https://hertzbeat.apache.org", + "log.integration.source": "Integrated Log Source", + "log.integration.source.otlp": "OTLP Protocol", + "log.integration.token.desc": "The generated Token can be used to access the HertzBeat log access API", + "log.integration.token.new": "Click to generate Token", + "log.integration.token.notice": "This content will only be displayed once. Please keep your Token safe and do not disclose it to others.", + "log.integration.token.title": "Access Authentication Token", + "log.manage.batch-delete": "Batch Delete", + "log.manage.basic-information": "Basic Information", + "log.manage.clear": "Clear", + "log.manage.chart.log-trend": "Log Trend", + "log.manage.chart.severity-distribution": "Severity Distribution", + "log.manage.chart.trace-coverage": "Trace Coverage", + "log.manage.chart.trace-coverage.complete-trace-info": "Complete Trace Info", + "log.manage.chart.trace-coverage.with-span": "With Span ID", + "log.manage.chart.trace-coverage.with-trace": "With Trace ID", + "log.manage.chart.trace-coverage.without-trace": "Without Trace ID", + "log.manage.column-control": "Column Display Control", + "log.manage.column-setting": "Column Setting", + "log.manage.complete-json-data": "Complete JSON Data", + "log.manage.hide-statistics": "Hide Statistics", + "log.manage.log-entry-details": "Log Entry Details", + "log.manage.overview.debug-logs": "Debug Logs", + "log.manage.overview.error-logs": "Error Logs", + "log.manage.overview.fatal-logs": "Fatal Logs", + "log.manage.overview.info-logs": "Info Logs", + "log.manage.overview.total-logs": "Total Logs", + "log.manage.overview.warning-logs": "Warning Logs", + "log.manage.reset-all-columns": "Reset All Columns", + "log.manage.search": "Search", + "log.manage.severity-number": "Severity Number", + "log.manage.severity-text": "Severity", + "log.manage.show-statistics": "Show Statistics", + "log.manage.span-id": "Span ID", + "log.manage.table.column.attributes": "Attributes", + "log.manage.table.column.body": "Log Content", + "log.manage.table.column.dropped-count": "Dropped Count", + "log.manage.table.column.instrumentation": "Instrumentation", + "log.manage.table.column.observed-time": "Observed Time", + "log.manage.table.column.resource": "Resource", + "log.manage.table.column.severity": "Severity", + "log.manage.table.column.span-id": "Span ID", + "log.manage.table.column.time": "Time", + "log.manage.table.column.trace-flags": "Trace Flags", + "log.manage.table.column.trace-id": "Trace ID", + "log.manage.table.title": "Log List", + "log.manage.timestamp": "Timestamp", + "log.manage.title": "Log Management & Statistics", + "log.manage.trace-id": "Trace ID", + "log.stream.attributes": "Attributes", + "log.stream.basic-information": "Basic Information", + "log.stream.clear": "Clear", + "log.stream.clear-filters": "Clear Filters", + "log.stream.clear-filters-tooltip": "Clear All Filters", + "log.stream.complete-json-data": "Complete JSON Data", + "log.stream.connected": "Connected", + "log.stream.connecting": "Connecting...", + "log.stream.copy-message": "Copy Message", + "log.stream.disconnected": "Disconnected", + "log.stream.hide-filters": "Hide Filters", + "log.stream.live-logs": "Live Logs", + "log.stream.log-entry-details": "Log Entry Details", + "log.stream.logs": "Logs", + "log.stream.message": "Message", + "log.stream.no-logs": "No Available Logs", + "log.stream.pause": "Pause", + "log.stream.resource": "Resources", + "log.stream.resume": "Resume", + "log.stream.scroll-to-top": "Scroll to Top", + "log.stream.severity": "Severity:", + "log.stream.severity-number": "Severity Number:", + "log.stream.severity-number-placeholder": "Enter Severity Number", + "log.stream.severity-text": "Severity:", + "log.stream.severity-text-placeholder": "Enter Severity", + "log.stream.show-filters": "Show Filters", + "log.stream.span": "Span:", + "log.stream.span-id": "Span ID:", + "log.stream.span-id-full": "Span ID:", + "log.stream.span-id-placeholder": "Enter Span ID", + "log.stream.title": "Log Stream", + "log.stream.timestamp": "Timestamp:", + "log.stream.toggle-filters": "Toggle Filters", + "log.stream.trace": "Trace:", + "log.stream.trace-id": "Trace ID:", + "log.stream.trace-id-full": "Trace ID:", + "log.stream.trace-id-placeholder": "Enter Trace ID", + "log.stream.unknown": "Unknown", "menu.account": "Personal", "menu.account.binding": "Account Binding", "menu.account.center": "Personal Center", @@ -597,6 +710,10 @@ "menu.lang": "Language", "menu.link.guild": "User Guide", "menu.link.question": "FAQ", + "menu.log": "Log", + "menu.log.integration": "Integration", + "menu.log.manage": "Log Manage", + "menu.log.stream": "Log Stream", "menu.main": "Main", "menu.monitor": "Monitoring", "menu.monitor.bigdata": "Bigdata Monitor", diff --git a/web-app/src/assets/i18n/ja-JP.json b/web-app/src/assets/i18n/ja-JP.json index 63396822cb8..50bf68a15ae 100644 --- a/web-app/src/assets/i18n/ja-JP.json +++ b/web-app/src/assets/i18n/ja-JP.json @@ -102,7 +102,7 @@ "alert.integration.source.zabbix": "Zabbix", "alert.integration.source.alibabacloud-sls": "AlibabaCloud-SLS", "alert.integration.source.huaweicloud-ces": "Huawei Cloud Eye", - "alert.integration.source.volcengine":"火山エンジン監視", + "alert.integration.source.volcengine": "火山エンジン監視", "alert.integration.token.desc": "HertzBeat APIにアクセスするために生成したトークン。", "alert.integration.token.new": "トークンを生成するにはクリック", "alert.integration.token.notice": "トークンは一度だけ表示されます。トークンを安全に保管し、他人と共有しないでください。", @@ -240,13 +240,17 @@ "alert.setting.connect.label": "ラベル関連", "alert.setting.connect.label.input": "ラベルを入力", "alert.setting.connect.label.empty": "関連するモニターが見つかりません。ラベルを追加してください", + "alert.setting.datatype": "データタイプ", + "alert.setting.datatype.tip": "この閾値ルールのデータタイプを選択してください", + "alert.setting.datatype.metric": "メトリクス", + "alert.setting.datatype.log": "ログ", "alert.setting.filter.name": "名前を入力してフィルター...", "alert.setting.filter.labels": "ラベルを入力してフィルター...", "alert.setting.default": "グローバルデフォルト", "alert.setting.default.tip": "このアラーム閾値設定がグローバルにこのタイプの監視に適用されるかどうか", "alert.setting.delete": "閾値ルールを削除", "alert.setting.edit": "閾値ルールを編集", - "alert.setting.edit.periodic": "周期的な閾値を編集", + "alert.setting.edit.periodic": "周期閾値を編集", "alert.setting.edit.realtime": "リアルタイム閾値を編集", "alert.setting.enable": "閾値を有効化", "alert.setting.enable.tip": "このアラーム閾値設定が有効か無効か", @@ -268,9 +272,10 @@ "alert.setting.name": "ルール名", "alert.setting.name.tip": "ルール名はユニークである必要があります", "alert.setting.new": "新規閾値ルール", - "alert.setting.new.periodic": "新規周期的閾値", + "alert.setting.new.periodic": "新規周期閾値", "alert.setting.new.realtime": "新規リアルタイム閾値", "alert.setting.number": "数値", + "alert.setting.object": "オブジェクト", "alert.setting.operator": "サポートされている演算子関数", "alert.setting.period": "実行期間", "alert.setting.period.placeholder": "実行期間を入力してください。最小60秒", @@ -284,6 +289,7 @@ "alert.setting.rule.label": "グラフィカルにアラーム閾値ルールを設定。複数のルール && をサポート", "alert.setting.rule.metric.place-holder": "メトリクスを選択してください", "alert.setting.rule.numeric-value.place-holder": "数値を入力してください", + "alert.setting.rule.object.attribute.placeholder": "オブジェクト属性を入力してください", "alert.setting.rule.operator": "演算子", "alert.setting.rule.operator.exists": "値が存在する", "alert.setting.rule.operator.no-exists": "値が存在しない", @@ -294,6 +300,12 @@ "alert.setting.rule.operator.str-no-equals": "等しくない", "alert.setting.rule.operator.str-no-matches": "一致しない", "alert.setting.rule.string-value.place-holder": "文字列を入力してください", + "alert.setting.log.query": "ログクエリ", + "alert.setting.log.query.tip": "監視するログをフィルタリングするためのログクエリ条件を設定", + "alert.setting.log.query.placeholder": "ログクエリ条件を入力してください", + "alert.setting.log.expr": "ログアラート式", + "alert.setting.log.expr.tip": "クエリ結果に基づいてログアラートトリガー条件を設定", + "alert.setting.log.expr.placeholder": "アラート式を入力してください。例: count > 10", "alert.setting.rule.switch-expr.0": "テンプレート閾値", "alert.setting.rule.switch-expr.1": "コーディング閾値", "alert.setting.search": "閾値を検索", @@ -326,16 +338,16 @@ "alert.setting.times": "トリガー回数", "alert.setting.times.tip": "アラームを送信する前に閾値が何回トリガーされるかを設定", "alert.setting.trigger": "アラームをトリガー", - "alert.setting.type": "閾値タイプ", - "alert.setting.type.periodic": "周期的", - "alert.setting.type.periodic.desc": "PromQLクエリを定期的に実行して閾値アラートをトリガーします。", - "alert.setting.type.realtime": "リアルタイム", - "alert.setting.type.realtime.desc": "リアルタイムメトリクス計算と閾値超過時の即時アラート。", "alert.severity": "アラームの重大度", "alert.severity.0": "緊急", "alert.severity.1": "クリティカル", "alert.severity.2": "警告", "alert.severity.all": "すべての重大度", + "alert.mode": "アラートモード", + "alert.mode.group": "アラートグループ", + "alert.mode.individual": "個別アラート", + "alert.setting.mode.tip": "アラートモードを選択してください。アラートグループモードは同じ条件のアラートを統合し、個別アラートモードは各アラートを個別に送信します", + "alert.notice.rule.mode.placeholder": "アラートモードを選択してください", "alert.silence.delete": "サイレンス戦略を削除", "alert.silence.edit": "サイレンス戦略を編集", "alert.silence.labels": "タグ一致", @@ -563,6 +575,98 @@ "label.value": "ラベル値", "labels.help": "ラベルは至る所にあります。リソースグループ化、ルール下のタグマッチングなどにラベルを適用できます。[ラベル管理]は、ラベルの統一管理に使用され、新規、削除、編集などが可能です。
ラベルを使用して監視リソースを分類および管理できます。例えば、本番環境とテスト環境にラベルをバインドすることができます。", "labels.help.link": "https://hertzbeat.apache.org/zh-cn/docs/", + "log.help.integration": "ログを統合的に管理し、外部ログソースからのログメッセージを収集、保存、アラート、分析などを行います。", + "log.help.integration.link": "https://hertzbeat.apache.org", + "log.help.manage": "ログ管理と統計、ログの検索、統計、削除などの操作をサポートします。", + "log.help.manage.link": "https://hertzbeat.apache.org", + "log.help.stream": "リアルタイムログストリーム監視画面で、リアルタイムログエントリの閲覧、フィルタや検索機能を備えています。", + "log.help.stream.link": "https://hertzbeat.apache.org", + "log.integration.source": "統合ログソース", + "log.integration.source.otlp": "OTLPプロトコル", + "log.integration.token.desc": "生成されたトークンはHertzBeatログAPIへのアクセスに使用できます", + "log.integration.token.new": "クリックしてトークンを生成", + "log.integration.token.notice": "この内容は一度しか表示されません。トークンを大切に保管し、他人に漏らさないでください。", + "log.integration.token.title": "アクセス認証トークン", + "log.manage.batch-delete": "一括削除", + "log.manage.basic-information": "基本情報", + "log.manage.clear": "クリア", + "log.manage.chart.log-trend": "ログトレンド", + "log.manage.chart.severity-distribution": "重大度分布", + "log.manage.chart.trace-coverage": "トレースカバレッジ", + "log.manage.chart.trace-coverage.complete-trace-info": "完全なTrace情報", + "log.manage.chart.trace-coverage.with-span": "Span IDあり", + "log.manage.chart.trace-coverage.with-trace": "Trace IDあり", + "log.manage.chart.trace-coverage.without-trace": "Trace IDなし", + "log.manage.column-control": "列表示コントロール", + "log.manage.column-setting": "列設定", + "log.manage.complete-json-data": "完全なJSONデータ", + "log.manage.hide-statistics": "統計を非表示", + "log.manage.log-entry-details": "ログエントリ詳細", + "log.manage.overview.debug-logs": "デバッグログ", + "log.manage.overview.error-logs": "エラーログ", + "log.manage.overview.fatal-logs": "重大なログ", + "log.manage.overview.info-logs": "インフォログ", + "log.manage.overview.total-logs": "総ログ数", + "log.manage.overview.warning-logs": "警告ログ", + "log.manage.reset-all-columns": "すべての列をリセット", + "log.manage.search": "検索", + "log.manage.severity-number": "重大度番号", + "log.manage.severity-text": "重大度", + "log.manage.show-statistics": "統計を表示", + "log.manage.span-id": "Span ID", + "log.manage.table.column.attributes": "属性", + "log.manage.table.column.body": "ログ内容", + "log.manage.table.column.dropped-count": "ドロップ数", + "log.manage.table.column.instrumentation": "収集ツール", + "log.manage.table.column.observed-time": "観測時間", + "log.manage.table.column.resource": "リソース", + "log.manage.table.column.severity": "重大度", + "log.manage.table.column.span-id": "Span ID", + "log.manage.table.column.time": "時間", + "log.manage.table.column.trace-flags": "Traceフラグ", + "log.manage.table.column.trace-id": "Trace ID", + "log.manage.table.title": "ログリスト", + "log.manage.timestamp": "タイムスタンプ", + "log.manage.title": "ログ管理と統計", + "log.manage.trace-id": "Trace ID", + "log.stream.attributes": "属性", + "log.stream.basic-information": "基本情報", + "log.stream.clear": "クリア", + "log.stream.clear-filters": "フィルターをクリア", + "log.stream.clear-filters-tooltip": "すべてのフィルターをクリア", + "log.stream.complete-json-data": "完全なJSONデータ", + "log.stream.connected": "接続済み", + "log.stream.connecting": "接続中...", + "log.stream.copy-message": "メッセージをコピー", + "log.stream.disconnected": "切断されました", + "log.stream.hide-filters": "フィルターを非表示", + "log.stream.live-logs": "リアルタイムログ", + "log.stream.log-entry-details": "ログエントリ詳細", + "log.stream.logs": "件のログ", + "log.stream.message": "メッセージ", + "log.stream.no-logs": "利用可能なログはありません", + "log.stream.pause": "一時停止", + "log.stream.resource": "件のリソース", + "log.stream.resume": "再開", + "log.stream.scroll-to-top": "トップへスクロール", + "log.stream.severity": "重大度:", + "log.stream.severity-number": "重大度番号:", + "log.stream.severity-number-placeholder": "重大度番号を入力", + "log.stream.severity-text": "重大度:", + "log.stream.severity-text-placeholder": "重大度を入力", + "log.stream.show-filters": "フィルターを表示", + "log.stream.span": "スパン:", + "log.stream.span-id": "Span ID:", + "log.stream.span-id-full": "Span ID:", + "log.stream.span-id-placeholder": "Span IDを入力", + "log.stream.title": "ログストリーム", + "log.stream.timestamp": "タイムスタンプ:", + "log.stream.toggle-filters": "フィルターを切り替え", + "log.stream.trace": "トレース:", + "log.stream.trace-id": "Trace ID:", + "log.stream.trace-id-full": "Trace ID:", + "log.stream.trace-id-placeholder": "Trace IDを入力", + "log.stream.unknown": "不明", "menu.account": "個人", "menu.account.binding": "アカウントバインディング", "menu.account.center": "個人センター", @@ -597,6 +701,10 @@ "menu.lang": "言語", "menu.link.guild": "ユーザーガイド", "menu.link.question": "FAQ", + "menu.log": "ログ", + "menu.log.integration": "統合", + "menu.log.manage": "ログ管理", + "menu.log.stream": "ログストリーム", "menu.main": "メイン", "menu.monitor": "監視", "menu.monitor.bigdata": "ビッグデータ監視", diff --git a/web-app/src/assets/i18n/pt-BR.json b/web-app/src/assets/i18n/pt-BR.json index e156a0e28ea..aa393a34cdd 100644 --- a/web-app/src/assets/i18n/pt-BR.json +++ b/web-app/src/assets/i18n/pt-BR.json @@ -282,7 +282,15 @@ }, "question.link": "https://hertzbeat.apache.org/docs/help/issue/", "alert.setting.new": "Nova Regra de Limite", + "alert.setting.new.periodic": "Nova Regra de Limite Periódica", + "alert.setting.new.realtime": "Nova Regra de Limite em Tempo Real", "alert.setting.edit": "Editar Regra de Limite", + "alert.setting.edit.periodic": "Editar Limite Periódico", + "alert.setting.edit.realtime": "Editar Limite em Tempo Real", + "alert.setting.datatype": "Tipo de Dados", + "alert.setting.datatype.tip": "Selecione o tipo de dados para esta regra de limite", + "alert.setting.datatype.metric": "Métrica", + "alert.setting.datatype.log": "Logs", "alert.setting.delete": "Excluir Regra de Limite", "alert.setting.export": "Exportar Regra", "alert.setting.import": "Importar Regra", @@ -292,10 +300,12 @@ "alert.setting.trigger": "Disparar alarmes e atualizar o status do monitor", "alert.setting.rule": "Regra de Limite", "alert.setting.number": "Numérico", + "alert.setting.object": "Objeto", "alert.setting.string": "Texto", "alert.setting.time": "Tempo", "alert.setting.rule.label": "Configuração gráfica de regras de limite de alarme, suporta múltiplas regras &&", "alert.setting.rule.metric.place-holder": "Selecione a métrica", + "alert.setting.rule.object.attribute.placeholder": "Digite o atributo do objeto", "alert.setting.rule.switch-expr.0": "Limite de Modelo", "alert.setting.rule.switch-expr.1": "Limite de Codificação", "alert.setting.rule.operator": "Operador", @@ -308,6 +318,12 @@ "alert.setting.rule.operator.exists": "valor existe", "alert.setting.rule.operator.no-exists": "valor não existe", "alert.setting.rule.string-value.place-holder": "Digite o texto", + "alert.setting.log.query": "Consulta de Log", + "alert.setting.log.query.tip": "Configure condições de consulta de log para filtrar logs que precisam ser monitorados", + "alert.setting.log.query.placeholder": "Digite as condições de consulta de log", + "alert.setting.log.expr": "Expressão de Alerta de Log", + "alert.setting.log.expr.tip": "Configure condições de disparo de alerta de log baseadas nos resultados da consulta", + "alert.setting.log.expr.placeholder": "Digite a expressão de alerta, ex: count > 10", "alert.setting.rule.numeric-value.place-holder": "Digite o número", "alert.setting.times": "Número de Disparos", "alert.setting.times.tip": "Defina quantas vezes o limite deve ser disparado antes de enviar um alerta", @@ -522,13 +538,17 @@ "alert.integration.token.title": "Token de autenticação de acesso", "alert.setting.name": "Nome do limite", "alert.setting.type": "Tipo de limite", + "alert.setting.type.periodic": "Limite Periódico", + "alert.setting.type.periodic.desc": "Executa consultas periodicamente para disparar alertas de limite.", + "alert.setting.type.periodic.log": "Logs Periódicos", + "alert.setting.type.periodic.metric": "Métrica Periódicas", + "alert.setting.type.realtime": "Limite em Tempo Real", + "alert.setting.type.realtime.desc": "Calcula dados em tempo real com alerta instantâneo ao ultrapassar o limite.", + "alert.setting.type.realtime.log": "Logs em Tempo Real", + "alert.setting.type.realtime.metric": "Métrica em Tempo Real", "alert.setting.name.tip": "O nome da regra de limite precisa ser exclusivo", - "alert.setting.new.periodic": "Adicionar novo limite do plano", - "alert.setting.new.realtime": "Adicionado limite em tempo real", "alert.setting.period": "Ciclo de execução", "alert.setting.period.placeholder": "Insira o período de execução, mínimo de 60 segundos", - "alert.setting.edit.periodic": "Editar limiar do plano", - "alert.setting.edit.realtime": "Editar limites em tempo real", "alert.setting.bind.available": "Monitoramento opcional", "alert.setting.bind.manage": "Monitoramento relacionado", "alert.setting.bind.monitors": "Monitoramento relacionado", @@ -549,6 +569,11 @@ "alert.severity.1": "Alarme sério", "alert.severity.2": "Alerta de aviso", "alert.severity.all": "Todos", + "alert.mode": "Modo de Alerta", + "alert.mode.group": "Grupo de Alerta", + "alert.mode.individual": "Alerta Individual", + "alert.setting.mode.tip": "Selecione o modo de alerta, o modo de grupo de alerta irá mesclar alertas com as mesmas condições, o modo de alerta individual irá enviar cada alerta separadamente", + "alert.notice.rule.mode.placeholder": "Por favor selecione o modo de alerta", "alert.status": "Estado do alarme", "alert.status.all": "Todos os status", "alert.status.firing": "Alarmante", @@ -602,8 +627,104 @@ "dashboard.monitors.sub-title": "A Distribuição dos Monitores", "dashboard.monitors.formatter": " Monitores ", "dashboard.monitors.distribute": "Distribuição do Monitor", + "log.help.integration": "Gerencie logs de forma unificada, integre e colete mensagens de logs de fontes externas, realize coleta, armazenamento, alertas e análise.", + "log.help.integration.link": "https://hertzbeat.apache.org", + "log.help.manage": "Gerenciamento e estatísticas de logs, suporta consulta, estatísticas e exclusão de logs.", + "log.help.manage.link": "https://hertzbeat.apache.org", + "log.help.stream": "Interface de monitoramento de fluxo de logs em tempo real, usada para visualizar entradas de logs em tempo real, com funções de filtragem e pesquisa.", + "log.help.stream.link": "https://hertzbeat.apache.org", + "log.integration.source": "Fonte de log integrada", + "log.integration.source.otlp": "Protocolo OTLP", + "log.integration.token.desc": "O Token gerado pode ser usado para acessar a API de integração de logs do HertzBeat", + "log.integration.token.new": "Clique para gerar Token", + "log.integration.token.notice": "Este conteúdo será exibido apenas uma vez, por favor, guarde seu Token com segurança e não o compartilhe com outros", + "log.integration.token.title": "Token de autenticação de acesso", + "log.manage.batch-delete": "Excluir em lote", + "log.manage.basic-information": "Informações básicas", + "log.manage.clear": "Limpar", + "log.manage.chart.log-trend": "Tendência de logs", + "log.manage.chart.severity-distribution": "Distribuição de severidade", + "log.manage.chart.trace-coverage": "Cobertura de rastreamento", + "log.manage.chart.trace-coverage.complete-trace-info": "Informações completas de Trace", + "log.manage.chart.trace-coverage.with-span": "Com Span ID", + "log.manage.chart.trace-coverage.with-trace": "Com Trace ID", + "log.manage.chart.trace-coverage.without-trace": "Sem Trace ID", + "log.manage.column-control": "Controle de exibição de colunas", + "log.manage.column-setting": "Configuração de colunas", + "log.manage.complete-json-data": "Dados JSON completos", + "log.manage.hide-statistics": "Ocultar estatísticas", + "log.manage.log-entry-details": "Detalhes da entrada de log", + "log.manage.overview.debug-logs": "Logs de depuração", + "log.manage.overview.error-logs": "Logs de erro", + "log.manage.overview.fatal-logs": "Logs críticos", + "log.manage.overview.info-logs": "Logs informativos", + "log.manage.overview.total-logs": "Total de logs", + "log.manage.overview.warning-logs": "Logs de aviso", + "log.manage.reset-all-columns": "Redefinir todas as colunas", + "log.manage.search": "Pesquisar", + "log.manage.severity-number": "Número de severidade", + "log.manage.severity-text": "Severidade", + "log.manage.show-statistics": "Mostrar estatísticas", + "log.manage.span-id": "Span ID", + "log.manage.table.column.attributes": "Atributos", + "log.manage.table.column.body": "Conteúdo do log", + "log.manage.table.column.dropped-count": "Contagem descartada", + "log.manage.table.column.instrumentation": "Ferramenta de coleta", + "log.manage.table.column.observed-time": "Hora de observação", + "log.manage.table.column.resource": "Fonte", + "log.manage.table.column.severity": "Severidade", + "log.manage.table.column.span-id": "Span ID", + "log.manage.table.column.time": "Hora", + "log.manage.table.column.trace-flags": "Marca de Trace", + "log.manage.table.column.trace-id": "Trace ID", + "log.manage.table.title": "Lista de logs", + "log.manage.timestamp": "Timestamp", + "log.manage.title": "Gerenciamento e estatísticas de logs", + "log.manage.trace-id": "Trace ID", + "log.stream.attributes": "atributos", + "log.stream.basic-information": "Informações básicas", + "log.stream.clear": "Limpar", + "log.stream.clear-filters": "Limpar filtros", + "log.stream.clear-filters-tooltip": "Limpar todos os filtros", + "log.stream.complete-json-data": "Dados JSON completos", + "log.stream.connected": "Conectado", + "log.stream.connecting": "Conectando...", + "log.stream.copy-message": "Copiar mensagem", + "log.stream.disconnected": "Desconectado", + "log.stream.hide-filters": "Ocultar filtros", + "log.stream.live-logs": "Logs em tempo real", + "log.stream.log-entry-details": "Detalhes da entrada de log", + "log.stream.logs": "logs", + "log.stream.message": "Mensagem", + "log.stream.no-logs": "Nenhum log disponível", + "log.stream.pause": "Pausar", + "log.stream.resource": "fontes", + "log.stream.resume": "Retomar", + "log.stream.scroll-to-top": "Rolar para o topo", + "log.stream.severity": "Severidade:", + "log.stream.severity-number": "Número de severidade:", + "log.stream.severity-number-placeholder": "Digite o número de severidade", + "log.stream.severity-text": "Severidade:", + "log.stream.severity-text-placeholder": "Digite a severidade", + "log.stream.show-filters": "Mostrar filtros", + "log.stream.span": "Span:", + "log.stream.span-id": "Span ID:", + "log.stream.span-id-full": "Span ID:", + "log.stream.span-id-placeholder": "Digite o Span ID", + "log.stream.title": "Fluxo de log", + "log.stream.timestamp": "Timestamp:", + "log.stream.toggle-filters": "Alternar filtros", + "log.stream.trace": "Trace:", + "log.stream.trace-id": "Trace ID:", + "log.stream.trace-id-full": "Trace ID:", + "log.stream.trace-id-placeholder": "Digite o Trace ID", + "log.stream.unknown": "Desconhecido", "menu.link.question": "FAQ", "menu.link.guild": "Guia do Usuário", + "menu.log": "Log", + "menu.log.integration": "Integração", + "menu.log.manage": "Gerenciamento de Log", + "menu.log.stream": "Fluxo de Log", "menu.account": "Página pessoal", "menu.account.binding": "Vinculação de conta", "menu.account.center": "Centro pessoal", diff --git a/web-app/src/assets/i18n/zh-CN.json b/web-app/src/assets/i18n/zh-CN.json index a49177d37bc..dbc1fe348ad 100644 --- a/web-app/src/assets/i18n/zh-CN.json +++ b/web-app/src/assets/i18n/zh-CN.json @@ -102,7 +102,7 @@ "alert.integration.source.zabbix": "Zabbix", "alert.integration.source.alibabacloud-sls": "阿里云日志服务 SLS", "alert.integration.source.huaweicloud-ces": "华为云监控服务", - "alert.integration.source.volcengine":"火山引擎云监控", + "alert.integration.source.volcengine": "火山引擎云监控", "alert.integration.token.desc": "生成的 Token 可用于访问 HertzBeat API", "alert.integration.token.new": "点击生成 Token", "alert.integration.token.notice": "此内容只会展示一次,请妥善保管您的 Token,不要泄露给他人", @@ -240,13 +240,17 @@ "alert.setting.connect.label": "标签关联", "alert.setting.connect.label.input": "输入标签", "alert.setting.connect.label.empty": "暂无匹配的监控对象,请添加标签", + "alert.setting.datatype": "数据类型", + "alert.setting.datatype.tip": "选择此阈值规则的数据类型", + "alert.setting.datatype.metric": "指标", + "alert.setting.datatype.log": "日志", "alert.setting.filter.name": "输入名称进行过滤...", "alert.setting.filter.labels": "输入标签进行过滤...", "alert.setting.default": "应用全局", "alert.setting.default.tip": "此告警阈值配置是否应用于全局所有此类型监控", "alert.setting.delete": "删除阈值规则", "alert.setting.edit": "编辑阈值规则", - "alert.setting.edit.periodic": "编辑计划阈值", + "alert.setting.edit.periodic": "编辑周期阈值", "alert.setting.edit.realtime": "编辑实时阈值", "alert.setting.enable": "启用阈值", "alert.setting.enable.tip": "此告警阈值配置开启生效或关闭", @@ -268,9 +272,10 @@ "alert.setting.name": "阈值名称", "alert.setting.name.tip": "阈值规则名称,需要有唯一性", "alert.setting.new": "新增阈值", - "alert.setting.new.periodic": "新增计划阈值", + "alert.setting.new.periodic": "新增周期阈值", "alert.setting.new.realtime": "新增实时阈值", "alert.setting.number": "数值型", + "alert.setting.object": "对象", "alert.setting.operator": "支持操作符函数", "alert.setting.period": "执行周期", "alert.setting.period.placeholder": "请输入执行周期,最小60秒", @@ -284,6 +289,7 @@ "alert.setting.rule.label": "图形化选择设置指标触发告警规则,支持多规则组合", "alert.setting.rule.metric.place-holder": "请选择指标", "alert.setting.rule.numeric-value.place-holder": "请输入匹配数值", + "alert.setting.rule.object.attribute.placeholder": "请输入对象属性", "alert.setting.rule.operator": "运算符", "alert.setting.rule.operator.exists": "存在值", "alert.setting.rule.operator.no-exists": "不存在值", @@ -294,6 +300,12 @@ "alert.setting.rule.operator.str-no-equals": "不等于", "alert.setting.rule.operator.str-no-matches": "不匹配", "alert.setting.rule.string-value.place-holder": "请输入匹配字符串", + "alert.setting.log.query": "日志查询", + "alert.setting.log.query.tip": "配置日志查询条件,用于筛选需要监控的日志", + "alert.setting.log.query.placeholder": "请输入日志查询条件", + "alert.setting.log.expr": "日志告警表达式", + "alert.setting.log.expr.tip": "配置日志告警触发条件,基于查询结果进行告警判断", + "alert.setting.log.expr.placeholder": "请输入告警表达式,例如: count > 10", "alert.setting.rule.switch-expr.0": "可视化", "alert.setting.rule.switch-expr.1": "表达式", "alert.setting.search": "搜索阈值", @@ -327,15 +339,27 @@ "alert.setting.times.tip": "设置触发阈值多少次之后才会发送告警", "alert.setting.trigger": "异常时触发告警", "alert.setting.type": "阈值类型", - "alert.setting.type.periodic": "计划周期", - "alert.setting.type.periodic.desc": "周期性执行 PromQL 查询触发阈值告警", - "alert.setting.type.realtime": "实时计算", - "alert.setting.type.realtime.desc": "实时计算指标数据,触发阈值时立即告警", + "alert.setting.type.periodic": "周期阈值", + "alert.setting.type.periodic.desc": "周期性执行查询,触发阈值告警。", + "alert.setting.type.periodic.log": "日志周期", + "alert.setting.type.periodic.metric": "指标周期", + "alert.setting.type.realtime": "实时阈值", + "alert.setting.type.realtime.desc": "实时计算数据,触发阈值时立即告警", + "alert.setting.type.realtime.log": "日志实时", + "alert.setting.type.realtime.metric": "指标实时", + "alert.setting.window": "计算窗口", + "alert.setting.window.tip": "实时阈值计算窗口时间,单位秒", + "alert.setting.window.placeholder": "请输入计算窗口时间", "alert.severity": "告警级别", "alert.severity.0": "紧急告警", "alert.severity.1": "严重告警", "alert.severity.2": "警告告警", "alert.severity.all": "全部级别", + "alert.mode": "告警模式", + "alert.mode.group": "告警组", + "alert.mode.individual": "单条告警", + "alert.setting.mode.tip": "选择告警模式,告警组模式会将相同条件的告警合并,单条告警模式会分别发送每条告警", + "alert.notice.rule.mode.placeholder": "请选择告警模式", "alert.silence.delete": "删除静默策略", "alert.silence.edit": "编辑静默策略", "alert.silence.labels": "匹配标签", @@ -563,6 +587,98 @@ "label.value": "标签值", "labels.help": "标签无处不在,我们可以应用标签在资源分组,规则下的标签匹配等场景。标签管理用于对标签的统一管理维护,包含新增,删除,编辑等操作。
例如:您可以使用标签对监控资源进行分类管理,给资源分别绑定生产环境、测试环境的标签,在告警通知时通过标签匹配不同的通知人。", "labels.help.link": "https://hertzbeat.apache.org/zh-cn/docs/", + "log.help.integration": "统一管理日志,集成接入外部日志源的日志消息,对其进行收集,存储,告警和分析等。", + "log.help.integration.link": "https://hertzbeat.apache.org", + "log.help.manage": "日志管理与统计,支持对日志的查询,统计,删除等操作。", + "log.help.manage.link": "https://hertzbeat.apache.org", + "log.help.stream": "实时日志流监控界面,用于查看实时日志条目,具备过滤和搜索功能。", + "log.help.stream.link": "https://hertzbeat.apache.org", + "log.integration.source": "集成日志源", + "log.integration.source.otlp": "OTLP 协议", + "log.integration.token.desc": "生成的 Token 可用于访问 HertzBeat 日志接入API", + "log.integration.token.new": "点击生成 Token", + "log.integration.token.notice": "此内容只会展示一次,请妥善保管您的 Token,不要泄露给他人", + "log.integration.token.title": "访问认证 Token", + "log.manage.batch-delete": "批量删除", + "log.manage.basic-information": "基本信息", + "log.manage.clear": "清空", + "log.manage.chart.log-trend": "日志趋势", + "log.manage.chart.severity-distribution": "严重程度分布", + "log.manage.chart.trace-coverage": "跟踪覆盖率", + "log.manage.chart.trace-coverage.complete-trace-info": "完整 Trace 信息", + "log.manage.chart.trace-coverage.with-span": "有 Span ID", + "log.manage.chart.trace-coverage.with-trace": "有 Trace ID", + "log.manage.chart.trace-coverage.without-trace": "无 Trace ID", + "log.manage.column-control": "列显示控制", + "log.manage.column-setting": "列设置", + "log.manage.complete-json-data": "完整JSON数据", + "log.manage.hide-statistics": "隐藏统计", + "log.manage.log-entry-details": "日志条目详情", + "log.manage.overview.debug-logs": "调试日志", + "log.manage.overview.error-logs": "错误日志", + "log.manage.overview.fatal-logs": "严重日志", + "log.manage.overview.info-logs": "普通日志", + "log.manage.overview.total-logs": "总日志", + "log.manage.overview.warning-logs": "警告日志", + "log.manage.reset-all-columns": "重置所有列", + "log.manage.search": "搜索", + "log.manage.severity-number": "严重程度编号", + "log.manage.severity-text": "严重程度", + "log.manage.show-statistics": "显示统计", + "log.manage.span-id": "Span ID", + "log.manage.table.column.attributes": "属性", + "log.manage.table.column.body": "日志内容", + "log.manage.table.column.dropped-count": "丢弃计数", + "log.manage.table.column.instrumentation": "采集工具", + "log.manage.table.column.observed-time": "观察时间", + "log.manage.table.column.resource": "来源", + "log.manage.table.column.severity": "严重程度", + "log.manage.table.column.span-id": "Span ID", + "log.manage.table.column.time": "时间", + "log.manage.table.column.trace-flags": "Trace 标记", + "log.manage.table.column.trace-id": "Trace ID", + "log.manage.table.title": "日志列表", + "log.manage.timestamp": "时间戳", + "log.manage.title": "日志管理与统计", + "log.manage.trace-id": "Trace ID", + "log.stream.attributes": "个属性", + "log.stream.basic-information": "基本信息", + "log.stream.clear": "清除", + "log.stream.clear-filters": "清除过滤器", + "log.stream.clear-filters-tooltip": "清除所有过滤器", + "log.stream.complete-json-data": "完整JSON数据", + "log.stream.connected": "已连接", + "log.stream.connecting": "连接中...", + "log.stream.copy-message": "复制消息", + "log.stream.disconnected": "已断开", + "log.stream.hide-filters": "隐藏过滤器", + "log.stream.live-logs": "实时日志", + "log.stream.log-entry-details": "日志条目详情", + "log.stream.logs": "条日志", + "log.stream.message": "消息", + "log.stream.no-logs": "暂无可用日志", + "log.stream.pause": "暂停", + "log.stream.resource": "个源", + "log.stream.resume": "恢复", + "log.stream.scroll-to-top": "滚动到顶部", + "log.stream.severity": "严重程度:", + "log.stream.severity-number": "严重程度编号:", + "log.stream.severity-number-placeholder": "输入严重程度编号", + "log.stream.severity-text": "严重程度:", + "log.stream.severity-text-placeholder": "输入严重程度", + "log.stream.show-filters": "显示过滤器", + "log.stream.span": "跨度:", + "log.stream.span-id": "Span ID:", + "log.stream.span-id-full": "Span ID:", + "log.stream.span-id-placeholder": "输入Span ID", + "log.stream.title": "日志流", + "log.stream.timestamp": "时间戳:", + "log.stream.toggle-filters": "切换过滤器", + "log.stream.trace": "跟踪:", + "log.stream.trace-id": "Trace ID:", + "log.stream.trace-id-full": "Trace ID:", + "log.stream.trace-id-placeholder": "输入Trace ID", + "log.stream.unknown": "未知", "menu.account": "个人页", "menu.account.binding": "账号绑定", "menu.account.center": "个人中心", @@ -585,6 +701,10 @@ "menu.alert.integration": "集成接入", "menu.alert.setting": "阈值规则", "menu.alert.silence": "告警静默", + "menu.log": "日志", + "menu.log.integration": "集成接入", + "menu.log.manage": "日志管理", + "menu.log.stream": "日志流", "menu.clear.local.storage": "清理本地缓存", "menu.dashboard": "仪表盘", "menu.extras": "更多", diff --git a/web-app/src/assets/i18n/zh-TW.json b/web-app/src/assets/i18n/zh-TW.json index cb3e5744333..1adf13d2a4b 100644 --- a/web-app/src/assets/i18n/zh-TW.json +++ b/web-app/src/assets/i18n/zh-TW.json @@ -102,7 +102,7 @@ "alert.integration.source.zabbix": "Zabbix", "alert.integration.source.alibabacloud-sls": "阿里雲端日誌服務 SLS", "alert.integration.source.huaweicloud-ces": "華為雲監控服務", - "alert.integration.source.volcengine":"火山引擎監控", + "alert.integration.source.volcengine": "火山引擎監控", "alert.integration.token.desc": "生成的 Token 可用于访问 HertzBeat API", "alert.integration.token.new": "点击生成 Token", "alert.integration.token.notice": "此内容只会展示一次,请妥善保管您的 Token,不要泄露给他人", @@ -245,8 +245,8 @@ "alert.setting.default.tip": "此告警阈值配置是否應用于全局所有此類型監控", "alert.setting.delete": "刪除阈值規則", "alert.setting.edit": "編輯阈值規則", - "alert.setting.edit.periodic": "编辑计划阈值", - "alert.setting.edit.realtime": "编辑实时阈值", + "alert.setting.edit.periodic": "編輯周期閾值", + "alert.setting.edit.realtime": "編輯實時阈值", "alert.setting.enable": "啓用閾值", "alert.setting.enable.tip": "此告警阈值配置開啓生效或關閉", "alert.setting.export": "導出規則", @@ -267,9 +267,10 @@ "alert.setting.name": "阈值名称", "alert.setting.name.tip": "阈值规则名称,需要有唯一性", "alert.setting.new": "新增阈值規則", - "alert.setting.new.periodic": "新增计划阈值", - "alert.setting.new.realtime": "新增实时阈值", + "alert.setting.new.periodic": "新增周期阈值", + "alert.setting.new.realtime": "新增實時阈值", "alert.setting.number": "數值型", + "alert.setting.object": "對象", "alert.setting.operator": "支持操作符函數", "alert.setting.period": "執行週期", "alert.setting.period.placeholder": "請輸入執行週期,最小60秒", @@ -283,6 +284,7 @@ "alert.setting.rule.label": "圖形化選擇設置指標觸發告警規則,支援多規則與", "alert.setting.rule.metric.place-holder": "請選擇指標", "alert.setting.rule.numeric-value.place-holder": "請輸入匹配數值", + "alert.setting.rule.object.attribute.placeholder": "請輸入對象屬性", "alert.setting.rule.operator": "運算符", "alert.setting.rule.operator.exists": "存在值", "alert.setting.rule.operator.no-exists": "不存在值", @@ -293,6 +295,12 @@ "alert.setting.rule.operator.str-no-equals": "不等於", "alert.setting.rule.operator.str-no-matches": "不匹配", "alert.setting.rule.string-value.place-holder": "請輸入匹配字符串", + "alert.setting.log.query": "日誌查詢", + "alert.setting.log.query.tip": "配置日誌查詢條件,用於篩選需要監控的日誌", + "alert.setting.log.query.placeholder": "請輸入日誌查詢條件", + "alert.setting.log.expr": "日誌告警表達式", + "alert.setting.log.expr.tip": "配置日誌告警觸發條件,基於查詢結果進行告警判斷", + "alert.setting.log.expr.placeholder": "請輸入告警表達式,例如: count > 10", "alert.setting.rule.switch-expr.0": "閾值模版", "alert.setting.rule.switch-expr.1": "閾值表達式", "alert.setting.search": "搜尋閾值", @@ -326,15 +334,24 @@ "alert.setting.times.tip": "設置觸發阈值多少次之後才會發送告警", "alert.setting.trigger": "異常時觸發告警", "alert.setting.type": "阈值类型", - "alert.setting.type.periodic": "计划周期", - "alert.setting.type.periodic.desc": "週期性執行 PromQL 查詢觸發閾值警報", - "alert.setting.type.realtime": "实时计算", - "alert.setting.type.realtime.desc": "即時計算指標數據,觸發閾值時立即告警", + "alert.setting.type.periodic": "周期阈值", + "alert.setting.type.periodic.desc": "周期性执行查询,触发阈值告警。", + "alert.setting.type.periodic.log": "日志周期", + "alert.setting.type.periodic.metric": "指标周期", + "alert.setting.type.realtime": "实时阈值", + "alert.setting.type.realtime.desc": "即時計算數據,觸發閾值時立即告警", + "alert.setting.type.realtime.log": "日志实时", + "alert.setting.type.realtime.metric": "指标实时", "alert.severity": "告警級別", "alert.severity.0": "緊急告警", "alert.severity.1": "嚴重告警", "alert.severity.2": "警告告警", "alert.severity.all": "全部級別", + "alert.mode": "告警模式", + "alert.mode.group": "告警組", + "alert.mode.individual": "單條告警", + "alert.setting.mode.tip": "選擇告警模式,告警組模式會將相同條件的告警合併,單條告警模式會分別發送每條告警", + "alert.notice.rule.mode.placeholder": "請選擇告警模式", "alert.silence.delete": "刪除靜默策略", "alert.silence.edit": "編輯靜默策略", "alert.silence.labels": "匹配標籤", @@ -562,6 +579,98 @@ "label.value": "標簽值", "labels.help": "標簽無處不在,我們可以應用標簽在資源分組,規則下的標簽匹配等場景。標簽管理用于對標簽的統壹管理維護,包含新增,刪除,編輯等操作。
例如:您可以使用標簽對監控資源進行分類管理,給資源分別綁定生産環境、測試環境的標簽,在告警通知時通過標簽匹配不同的通知人。", "labels.help.link": "https://hertzbeat.apache.org/zh-cn/docs/", + "log.help.integration": "統一管理日誌,整合接入外部日誌源的日誌訊息,對其進行收集、儲存、告警和分析等。", + "log.help.integration.link": "https://hertzbeat.apache.org", + "log.help.manage": "日誌管理與統計,支援對日誌的查詢、統計、刪除等操作。", + "log.help.manage.link": "https://hertzbeat.apache.org", + "log.help.stream": "即時日誌流監控介面,用於查看即時日誌條目,具備過濾和搜尋功能。", + "log.help.stream.link": "https://hertzbeat.apache.org", + "log.integration.source": "整合日誌源", + "log.integration.source.otlp": "OTLP 協議", + "log.integration.token.desc": "生成的 Token 可用於存取 HertzBeat 日誌接入 API", + "log.integration.token.new": "點擊生成 Token", + "log.integration.token.notice": "此內容只會顯示一次,請妥善保管您的 Token,不要洩漏給他人", + "log.integration.token.title": "存取認證 Token", + "log.manage.batch-delete": "批次刪除", + "log.manage.basic-information": "基本資訊", + "log.manage.clear": "清空", + "log.manage.chart.log-trend": "日誌趨勢", + "log.manage.chart.severity-distribution": "嚴重程度分布", + "log.manage.chart.trace-coverage": "追蹤覆蓋率", + "log.manage.chart.trace-coverage.complete-trace-info": "完整 Trace 資訊", + "log.manage.chart.trace-coverage.with-span": "有 Span ID", + "log.manage.chart.trace-coverage.with-trace": "有 Trace ID", + "log.manage.chart.trace-coverage.without-trace": "無 Trace ID", + "log.manage.column-control": "欄位顯示控制", + "log.manage.column-setting": "欄位設定", + "log.manage.complete-json-data": "完整JSON資料", + "log.manage.hide-statistics": "隱藏統計", + "log.manage.log-entry-details": "日誌條目詳情", + "log.manage.overview.debug-logs": "除錯日誌", + "log.manage.overview.error-logs": "錯誤日誌", + "log.manage.overview.fatal-logs": "嚴重日誌", + "log.manage.overview.info-logs": "一般日誌", + "log.manage.overview.total-logs": "總日誌", + "log.manage.overview.warning-logs": "警告日誌", + "log.manage.reset-all-columns": "重設所有欄位", + "log.manage.search": "搜尋", + "log.manage.severity-number": "嚴重程度編號", + "log.manage.severity-text": "嚴重程度", + "log.manage.show-statistics": "顯示統計", + "log.manage.span-id": "Span ID", + "log.manage.table.column.attributes": "屬性", + "log.manage.table.column.body": "日誌內容", + "log.manage.table.column.dropped-count": "丟棄計數", + "log.manage.table.column.instrumentation": "採集工具", + "log.manage.table.column.observed-time": "觀察時間", + "log.manage.table.column.resource": "來源", + "log.manage.table.column.severity": "嚴重程度", + "log.manage.table.column.span-id": "Span ID", + "log.manage.table.column.time": "時間", + "log.manage.table.column.trace-flags": "Trace 標記", + "log.manage.table.column.trace-id": "Trace ID", + "log.manage.table.title": "日誌列表", + "log.manage.timestamp": "時間戳", + "log.manage.title": "日誌管理與統計", + "log.manage.trace-id": "Trace ID", + "log.stream.attributes": "個屬性", + "log.stream.basic-information": "基本資訊", + "log.stream.clear": "清除", + "log.stream.clear-filters": "清除過濾器", + "log.stream.clear-filters-tooltip": "清除所有過濾器", + "log.stream.complete-json-data": "完整JSON資料", + "log.stream.connected": "已連線", + "log.stream.connecting": "連線中...", + "log.stream.copy-message": "複製訊息", + "log.stream.disconnected": "已斷線", + "log.stream.hide-filters": "隱藏過濾器", + "log.stream.live-logs": "即時日誌", + "log.stream.log-entry-details": "日誌條目詳情", + "log.stream.logs": "條日誌", + "log.stream.message": "訊息", + "log.stream.no-logs": "暫無可用日誌", + "log.stream.pause": "暫停", + "log.stream.resource": "個來源", + "log.stream.resume": "恢復", + "log.stream.scroll-to-top": "捲動到頂部", + "log.stream.severity": "嚴重程度:", + "log.stream.severity-number": "嚴重程度編號:", + "log.stream.severity-number-placeholder": "輸入嚴重程度編號", + "log.stream.severity-text": "嚴重程度:", + "log.stream.severity-text-placeholder": "輸入嚴重程度", + "log.stream.show-filters": "顯示過濾器", + "log.stream.span": "跨度:", + "log.stream.span-id": "Span ID:", + "log.stream.span-id-full": "Span ID:", + "log.stream.span-id-placeholder": "輸入Span ID", + "log.stream.title": "日誌流", + "log.stream.timestamp": "時間戳:", + "log.stream.toggle-filters": "切換過濾器", + "log.stream.trace": "追蹤:", + "log.stream.trace-id": "Trace ID:", + "log.stream.trace-id-full": "Trace ID:", + "log.stream.trace-id-placeholder": "輸入Trace ID", + "log.stream.unknown": "未知", "menu.account": "個人頁", "menu.account.binding": "賬號綁定", "menu.account.center": "個人中心", @@ -596,6 +705,10 @@ "menu.lang": "語言", "menu.link.guild": "使用指南", "menu.link.question": "常見問題", + "menu.log": "日誌", + "menu.log.integration": "整合", + "menu.log.manage": "日誌管理", + "menu.log.stream": "日誌流", "menu.main": "主導航", "menu.monitor": "監控", "menu.monitor.bigdata": "大數據監控", diff --git a/web-app/src/assets/img/integration/otlp.svg b/web-app/src/assets/img/integration/otlp.svg new file mode 100644 index 00000000000..df48fd61185 --- /dev/null +++ b/web-app/src/assets/img/integration/otlp.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file