diff --git a/.github/pr-assets/angular-three-signals/logs-workbench.png b/.github/pr-assets/angular-three-signals/logs-workbench.png new file mode 100644 index 00000000000..83176915c6d Binary files /dev/null and b/.github/pr-assets/angular-three-signals/logs-workbench.png differ diff --git a/.github/pr-assets/angular-three-signals/metrics-workbench.png b/.github/pr-assets/angular-three-signals/metrics-workbench.png new file mode 100644 index 00000000000..2383a92f0c6 Binary files /dev/null and b/.github/pr-assets/angular-three-signals/metrics-workbench.png differ diff --git a/.github/pr-assets/angular-three-signals/otlp-onboarding.png b/.github/pr-assets/angular-three-signals/otlp-onboarding.png new file mode 100644 index 00000000000..7bb941e2a7b Binary files /dev/null and b/.github/pr-assets/angular-three-signals/otlp-onboarding.png differ diff --git a/.github/pr-assets/angular-three-signals/trace-waterfall.png b/.github/pr-assets/angular-three-signals/trace-waterfall.png new file mode 100644 index 00000000000..7d641db1a6d Binary files /dev/null and b/.github/pr-assets/angular-three-signals/trace-waterfall.png differ diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/constants/NetworkConstants.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/constants/NetworkConstants.java index 7c6d15e93bb..7f1f3647855 100644 --- a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/constants/NetworkConstants.java +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/constants/NetworkConstants.java @@ -17,6 +17,8 @@ package org.apache.hertzbeat.common.constants; +import java.time.Duration; + /** * Http Constants. */ @@ -56,11 +58,17 @@ public interface NetworkConstants { */ interface HttpClientConstants { - int READ_TIME_OUT = 6 * 1000; - int WRITE_TIME_OUT = 6 * 1000; - int CONNECT_TIME_OUT = 6 * 1000; + Duration READ_TIMEOUT = Duration.ofSeconds(6); + Duration WRITE_TIMEOUT = Duration.ofSeconds(6); + Duration CONNECT_TIMEOUT = Duration.ofSeconds(6); + Duration GREPTIME_QUERY_READ_TIMEOUT = Duration.ofSeconds(5); + Duration GREPTIME_QUERY_CONNECT_TIMEOUT = Duration.ofSeconds(2); + Duration GREPTIME_WRITE_READ_TIMEOUT = Duration.ofSeconds(3); + Duration GREPTIME_WRITE_CONNECT_TIMEOUT = Duration.ofSeconds(2); + Duration GREPTIME_INIT_READ_TIMEOUT = Duration.ofSeconds(10); + Duration GREPTIME_INIT_CONNECT_TIMEOUT = Duration.ofSeconds(3); int MAX_IDLE_CONNECTIONS = 20; - int KEEP_ALIVE_TIMEOUT = 30 * 1000; + Duration KEEP_ALIVE_TIMEOUT = Duration.ofSeconds(30); } } diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/LogQueryFilter.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/LogQueryFilter.java new file mode 100644 index 00000000000..ae36f3e7ae6 --- /dev/null +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/LogQueryFilter.java @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.common.entity.dto.observability; + +/** Entity-free OpenTelemetry log query context. */ +public record LogQueryFilter(Long start, Long end, String traceId, String spanId, Integer severityNumber, + String severityText, String search, String serviceName, String serviceNamespace, + String environment, String resourceFilter) { +} diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/MetricPoint.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/MetricPoint.java new file mode 100644 index 00000000000..70825d8df4d --- /dev/null +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/MetricPoint.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hertzbeat.common.entity.dto.observability; + +/** + * One OTLP metric sample. + * + * @param timestamp epoch milliseconds + * @param value numeric sample value + */ +public record MetricPoint(long timestamp, double value) { +} diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/MetricSeries.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/MetricSeries.java new file mode 100644 index 00000000000..b6c884d11e1 --- /dev/null +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/MetricSeries.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* + * 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.dto.observability; + +import java.util.List; +import java.util.Map; + +/** + * OTLP metric time series. + * + * @param labels series labels + * @param points ordered samples + */ +public record MetricSeries(Map labels, List points) { +} diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/OtlpMetricsConsole.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/OtlpMetricsConsole.java new file mode 100644 index 00000000000..db5b1f09ff6 --- /dev/null +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/OtlpMetricsConsole.java @@ -0,0 +1,32 @@ +/* + * 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.dto.observability; + +import java.util.List; + +/** + * OTLP metrics query result. + * + * @param query effective PromQL expression + * @param start query start in epoch milliseconds + * @param end query end in epoch milliseconds + * @param stepSeconds query resolution + * @param series returned time series + */ +public record OtlpMetricsConsole(String query, long start, long end, int stepSeconds, List series) { +} diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/OtlpMetricsInventory.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/OtlpMetricsInventory.java new file mode 100644 index 00000000000..8f2f1526417 --- /dev/null +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/OtlpMetricsInventory.java @@ -0,0 +1,47 @@ +/* + * 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. + */ + +/* + * 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.dto.observability; + +import java.util.List; + +/** + * Discoverable OTLP metric names. + * + * @param metricNames sorted metric names + */ +public record OtlpMetricsInventory(List metricNames) { +} diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/SignalPage.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/SignalPage.java new file mode 100644 index 00000000000..99933aef137 --- /dev/null +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/SignalPage.java @@ -0,0 +1,51 @@ +/* + * 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. + */ + +/* + * 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.dto.observability; + +import java.util.List; + +/** + * Stable pagination payload shared by the three signal workbenches. + * + * @param content current page content + * @param pageIndex zero-based page index + * @param pageSize requested page size + * @param totalElements total matching records + * @param row type + */ +public record SignalPage(List content, int pageIndex, int pageSize, long totalElements) { +} diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceDetail.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceDetail.java new file mode 100644 index 00000000000..87d433e0d1a --- /dev/null +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceDetail.java @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.common.entity.dto.observability; + +import java.util.List; + +/** Complete trace detail. */ +public record TraceDetail(TraceListItem summary, List spans) { +} diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceListItem.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceListItem.java new file mode 100644 index 00000000000..5cbb83da013 --- /dev/null +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceListItem.java @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.common.entity.dto.observability; + +import java.util.Map; + +/** Trace summary row. */ +public record TraceListItem(String traceId, String rootSpanId, String serviceName, String serviceNamespace, + String rootSpanName, long durationNanos, String status, long startTime, + int errorSpanCount, Map resourceAttributes) { +} diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceOverview.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceOverview.java new file mode 100644 index 00000000000..e892d965931 --- /dev/null +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceOverview.java @@ -0,0 +1,23 @@ +/* + * 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.dto.observability; + +/** Trace aggregate statistics. */ +public record TraceOverview(long totalCount, long errorCount, double errorRate, double averageDurationMillis, + double p95DurationMillis) { +} diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceSpanEvent.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceSpanEvent.java new file mode 100644 index 00000000000..d913dda23b2 --- /dev/null +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceSpanEvent.java @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.common.entity.dto.observability; + +import java.util.Map; + +/** Event emitted while a trace span is active. */ +public record TraceSpanEvent(String name, String time, Map attributes) { +} diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceSpanNode.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceSpanNode.java new file mode 100644 index 00000000000..7b5cfe324cf --- /dev/null +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceSpanNode.java @@ -0,0 +1,28 @@ +/* + * 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.dto.observability; + +import java.util.List; +import java.util.Map; + +/** Trace span used by the Angular waterfall. */ +public record TraceSpanNode(String traceId, String spanId, String parentSpanId, String spanName, + String serviceName, String status, String spanKind, String statusMessage, + long durationNanos, long startTime, Map resourceAttributes, + Map spanAttributes, List spanEvents) { +} diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/security/OtlpAccessTokenValidator.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/security/OtlpAccessTokenValidator.java new file mode 100644 index 00000000000..fbc4900204d --- /dev/null +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/security/OtlpAccessTokenValidator.java @@ -0,0 +1,28 @@ +/* + * 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.security; + +/** Validates an API token for OTLP ingestion without coupling the signal module to manager. */ +public interface OtlpAccessTokenValidator { + + /** + * @param token raw bearer token + * @return null when accepted, otherwise a safe rejection reason + */ + String validate(String token); +} diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/support/exception/StorageUnavailableException.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/support/exception/StorageUnavailableException.java new file mode 100644 index 00000000000..8435410456b --- /dev/null +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/support/exception/StorageUnavailableException.java @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.common.support.exception; + +/** Signals that a required backing store cannot currently serve a request. */ +public class StorageUnavailableException extends RuntimeException { + + public StorageUnavailableException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/alert/LogPeriodicAlertE2eTest.java b/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/alert/LogPeriodicAlertE2eTest.java index 451f8ce7783..9957ce417b0 100644 --- a/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/alert/LogPeriodicAlertE2eTest.java +++ b/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/alert/LogPeriodicAlertE2eTest.java @@ -63,13 +63,14 @@ @SpringBootTest(classes = org.apache.hertzbeat.startup.HertzBeatApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @TestPropertySource(properties = { "warehouse.store.duckdb.enabled=false", - "warehouse.store.greptime.enabled=true" + "warehouse.store.greptime.enabled=true", + "hertzbeat.otlp.grpc.enabled=false" }) @Slf4j @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class LogPeriodicAlertE2eTest { - private static final String VECTOR_IMAGE = "timberio/vector:latest-alpine"; + private static final String VECTOR_IMAGE = "timberio/vector:0.56.0-alpine"; private static final int VECTOR_PORT = 8686; private static final String VECTOR_CONFIG_PATH = "/etc/vector/vector.yml"; private static final String ENV_HERTZBEAT_PORT = "HERTZBEAT_PORT"; diff --git a/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/alert/LogRealTimeAlertE2eTest.java b/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/alert/LogRealTimeAlertE2eTest.java index f7f257bc9be..4ae973b343c 100644 --- a/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/alert/LogRealTimeAlertE2eTest.java +++ b/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/alert/LogRealTimeAlertE2eTest.java @@ -60,7 +60,7 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class LogRealTimeAlertE2eTest { - private static final String VECTOR_IMAGE = "timberio/vector:latest-alpine"; + private static final String VECTOR_IMAGE = "timberio/vector:0.56.0-alpine"; private static final int VECTOR_PORT = 8686; private static final String VECTOR_CONFIG_PATH = "/etc/vector/vector.yml"; private static final String ENV_HERTZBEAT_PORT = "HERTZBEAT_PORT"; diff --git a/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/ingestion/LogIngestionE2eTest.java b/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/ingestion/LogIngestionE2eTest.java index 30cab016d16..57feada98ed 100644 --- a/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/ingestion/LogIngestionE2eTest.java +++ b/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/ingestion/LogIngestionE2eTest.java @@ -49,7 +49,7 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class LogIngestionE2eTest { - private static final String VECTOR_IMAGE = "timberio/vector:latest-alpine"; + private static final String VECTOR_IMAGE = "timberio/vector:0.56.0-alpine"; private static final int VECTOR_PORT = 8686; private static final String VECTOR_CONFIG_PATH = "/etc/vector/vector.yml"; private static final String ENV_HERTZBEAT_PORT = "HERTZBEAT_PORT"; diff --git a/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/storage/GreptimeLogStorageE2eTest.java b/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/storage/GreptimeLogStorageE2eTest.java index 9a0fbaea312..d56a46ee6c6 100644 --- a/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/storage/GreptimeLogStorageE2eTest.java +++ b/hertzbeat-e2e/hertzbeat-log-e2e/src/test/java/org/apache/hertzbeat/log/storage/GreptimeLogStorageE2eTest.java @@ -52,13 +52,14 @@ @SpringBootTest(classes = org.apache.hertzbeat.startup.HertzBeatApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @TestPropertySource(properties = { "warehouse.store.duckdb.enabled=false", - "warehouse.store.greptime.enabled=true" + "warehouse.store.greptime.enabled=true", + "hertzbeat.otlp.grpc.enabled=false" }) @Slf4j @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class GreptimeLogStorageE2eTest { - private static final String VECTOR_IMAGE = "timberio/vector:latest-alpine"; + private static final String VECTOR_IMAGE = "timberio/vector:0.56.0-alpine"; private static final int VECTOR_PORT = 8686; private static final String VECTOR_CONFIG_PATH = "/etc/vector/vector.yml"; private static final String ENV_HERTZBEAT_PORT = "HERTZBEAT_PORT"; diff --git a/hertzbeat-e2e/hertzbeat-log-e2e/src/test/resources/sureness.yml b/hertzbeat-e2e/hertzbeat-log-e2e/src/test/resources/sureness.yml index f04c5a2f7d3..64228f740de 100644 --- a/hertzbeat-e2e/hertzbeat-log-e2e/src/test/resources/sureness.yml +++ b/hertzbeat-e2e/hertzbeat-log-e2e/src/test/resources/sureness.yml @@ -71,6 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin,user] - /api/logs/ingest/**===post===[admin,user] + - /api/otlp/**===post===[admin,user] + - /api/ingestion/otlp/**===get===[admin,user,guest] + - /api/logs/**===get===[admin,user,guest] + - /api/traces/**===get===[admin,user,guest] # config the resource restful api that need bypass auth protection # rule: api===method diff --git a/hertzbeat-log/pom.xml b/hertzbeat-log/pom.xml index 7bc9c65fe3c..e3cd19dc102 100644 --- a/hertzbeat-log/pom.xml +++ b/hertzbeat-log/pom.xml @@ -30,6 +30,7 @@ 4.2.0 + 1.56.1 @@ -71,6 +72,22 @@ com.google.protobuf protobuf-java-util + + + io.grpc + grpc-netty-shaded + ${grpc.version} + + + io.grpc + grpc-protobuf + ${grpc.version} + + + io.grpc + grpc-stub + ${grpc.version} + org.awaitility @@ -80,4 +97,4 @@ - \ No newline at end of file + diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/config/GreptimeSignalInitializer.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/config/GreptimeSignalInitializer.java new file mode 100644 index 00000000000..d6b2bc24225 --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/config/GreptimeSignalInitializer.java @@ -0,0 +1,102 @@ +/* + * 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.config; + +import java.nio.charset.StandardCharsets; +import org.apache.hertzbeat.warehouse.constants.WarehouseConstants; +import org.apache.hertzbeat.warehouse.db.GreptimeSqlQueryExecutor; +import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +/** Fails startup clearly when the required Greptime signal schema cannot be prepared. */ +@Component +@ConditionalOnProperty(prefix = "warehouse.store.greptime", name = "enabled", havingValue = "true") +public class GreptimeSignalInitializer { + + private static final String TRACE_SCHEMA = "greptime/tables/hzb_traces.sql"; + private static final String LOG_PIPELINE = "greptime/pipelines/hertzbeat_otlp_log_v1.yaml"; + private final GreptimeProperties greptimeProperties; + private final GreptimeSqlQueryExecutor sqlQueryExecutor; + private final RestTemplate restTemplate; + + public GreptimeSignalInitializer(GreptimeProperties greptimeProperties, + GreptimeSqlQueryExecutor sqlQueryExecutor, + @Qualifier(WarehouseConstants.GREPTIME_INIT_REST_TEMPLATE) + RestTemplate restTemplate) { + this.greptimeProperties = greptimeProperties; + this.sqlQueryExecutor = sqlQueryExecutor; + this.restTemplate = restTemplate; + } + + @EventListener(ApplicationReadyEvent.class) + public void initialize() { + try { + String traceSchema = new ClassPathResource(TRACE_SCHEMA) + .getContentAsString(StandardCharsets.UTF_8).strip(); + sqlQueryExecutor.execute(StringUtils.trimTrailingCharacter(traceSchema, ';')); + sqlQueryExecutor.execute("ALTER TABLE hzb_traces ADD COLUMN IF NOT EXISTS " + + "\"resource_attributes.service.namespace\" STRING NULL"); + sqlQueryExecutor.execute("ALTER TABLE hzb_traces ADD COLUMN IF NOT EXISTS " + + "\"resource_attributes.deployment.environment.name\" STRING NULL"); + uploadLogPipeline(new ClassPathResource(LOG_PIPELINE) + .getContentAsString(StandardCharsets.UTF_8)); + if (sqlQueryExecutor.execute("SELECT 1 AS ready").isEmpty()) { + throw new IllegalStateException("GreptimeDB readiness query returned no data"); + } + } catch (Exception exception) { + throw new IllegalStateException("GreptimeDB is required for the three-signal release but initialization failed", + exception); + } + } + + private void uploadLogPipeline(String pipeline) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + if (StringUtils.hasText(greptimeProperties.username()) && StringUtils.hasText(greptimeProperties.password())) { + headers.setBasicAuth(greptimeProperties.username(), greptimeProperties.password(), StandardCharsets.UTF_8); + } + HttpHeaders partHeaders = new HttpHeaders(); + partHeaders.setContentType(MediaType.parseMediaType("application/x-yaml")); + partHeaders.setContentDisposition(ContentDisposition.formData().name("file") + .filename("hertzbeat_otlp_log_v1.yaml").build()); + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", new HttpEntity<>(pipeline, partHeaders)); + String endpoint = StringUtils.trimTrailingCharacter(greptimeProperties.httpEndpoint(), '/') + + "/v1/pipelines/hertzbeat_otlp_log_v1"; + ResponseEntity response = restTemplate.exchange(endpoint, HttpMethod.POST, + new HttpEntity<>(body, headers), String.class); + if (!response.getStatusCode().is2xxSuccessful()) { + throw new IllegalStateException("GreptimeDB log pipeline upload failed: " + response.getStatusCode()); + } + } +} diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/config/OtlpGrpcServerConfig.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/config/OtlpGrpcServerConfig.java new file mode 100644 index 00000000000..9e6d3bc067c --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/config/OtlpGrpcServerConfig.java @@ -0,0 +1,201 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.log.config; + +import io.grpc.ForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.Server; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.Status; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.stub.StreamObserver; +import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest; +import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceResponse; +import io.opentelemetry.proto.collector.logs.v1.LogsServiceGrpc; +import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest; +import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceResponse; +import io.opentelemetry.proto.collector.metrics.v1.MetricsServiceGrpc; +import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest; +import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse; +import io.opentelemetry.proto.collector.trace.v1.TraceServiceGrpc; +import java.io.IOException; +import java.net.InetSocketAddress; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.common.security.OtlpAccessTokenValidator; +import org.apache.hertzbeat.log.service.OtlpSignalForwarder; +import org.apache.hertzbeat.log.service.SignalQueryRejectedException; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard.Workload; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +/** OTLP/gRPC listener for metrics, logs, and traces. */ +@Configuration +@ConditionalOnProperty(prefix = "warehouse.store.greptime", name = "enabled", havingValue = "true") +public class OtlpGrpcServerConfig { + + @Bean(initMethod = "start", destroyMethod = "stop") + @ConditionalOnProperty(prefix = "hertzbeat.otlp.grpc", name = "enabled", havingValue = "true", + matchIfMissing = true) + public OtlpGrpcServerRunner otlpGrpcServerRunner( + @Value("${hertzbeat.otlp.grpc.host:0.0.0.0}") String host, + @Value("${hertzbeat.otlp.grpc.port:4317}") int port, + OtlpSignalForwarder signalForwarder, + SignalWorkloadGuard workloadGuard, + ObjectProvider tokenValidatorProvider) { + OtlpAccessTokenValidator validator = tokenValidatorProvider.getIfAvailable( + () -> token -> "OTLP token validation is unavailable"); + ServerInterceptor interceptor = new BearerTokenInterceptor(validator); + return new OtlpGrpcServerRunner(host, port, signalForwarder, workloadGuard, interceptor); + } + + @Slf4j + @RequiredArgsConstructor + static final class OtlpGrpcServerRunner { + private final String host; + private final int port; + private final OtlpSignalForwarder signalForwarder; + private final SignalWorkloadGuard workloadGuard; + private final ServerInterceptor authInterceptor; + private Server server; + + public void start() throws IOException { + server = NettyServerBuilder.forAddress(new InetSocketAddress(host, port)) + .addService(ServerInterceptors.intercept(new MetricsService(signalForwarder, workloadGuard), + authInterceptor)) + .addService(ServerInterceptors.intercept(new LogsService(signalForwarder, workloadGuard), + authInterceptor)) + .addService(ServerInterceptors.intercept(new TracesService(signalForwarder, workloadGuard), + authInterceptor)) + .build().start(); + log.info("OTLP gRPC listener started on {}:{}", host, port); + } + + public void stop() { + if (server != null) { + server.shutdownNow(); + } + } + } + + @RequiredArgsConstructor + static final class MetricsService extends MetricsServiceGrpc.MetricsServiceImplBase { + private final OtlpSignalForwarder signalForwarder; + private final SignalWorkloadGuard workloadGuard; + + @Override + public void export(ExportMetricsServiceRequest request, + StreamObserver observer) { + try { + byte[] response = workloadGuard.execute(Workload.OTLP_WRITE, + () -> signalForwarder.forwardProtobuf("metrics", request.toByteArray())); + observer.onNext(response.length == 0 ? ExportMetricsServiceResponse.getDefaultInstance() + : ExportMetricsServiceResponse.parseFrom(response)); + observer.onCompleted(); + } catch (Exception exception) { + onError(observer, exception); + } + } + } + + @RequiredArgsConstructor + static final class LogsService extends LogsServiceGrpc.LogsServiceImplBase { + private final OtlpSignalForwarder signalForwarder; + private final SignalWorkloadGuard workloadGuard; + + @Override + public void export(ExportLogsServiceRequest request, StreamObserver observer) { + try { + byte[] response = workloadGuard.execute(Workload.OTLP_WRITE, + () -> signalForwarder.forwardProtobuf("logs", request.toByteArray())); + observer.onNext(response.length == 0 ? ExportLogsServiceResponse.getDefaultInstance() + : ExportLogsServiceResponse.parseFrom(response)); + observer.onCompleted(); + } catch (Exception exception) { + onError(observer, exception); + } + } + } + + @RequiredArgsConstructor + static final class TracesService extends TraceServiceGrpc.TraceServiceImplBase { + private final OtlpSignalForwarder signalForwarder; + private final SignalWorkloadGuard workloadGuard; + + @Override + public void export(ExportTraceServiceRequest request, StreamObserver observer) { + try { + byte[] response = workloadGuard.execute(Workload.OTLP_WRITE, + () -> signalForwarder.forwardProtobuf("traces", request.toByteArray())); + observer.onNext(response.length == 0 ? ExportTraceServiceResponse.getDefaultInstance() + : ExportTraceServiceResponse.parseFrom(response)); + observer.onCompleted(); + } catch (Exception exception) { + onError(observer, exception); + } + } + } + + private static void onError(StreamObserver observer, Exception exception) { + observer.onError(toGrpcStatus(exception).withDescription(exception.getMessage()).withCause(exception) + .asRuntimeException()); + } + + static Status toGrpcStatus(Exception exception) { + if (exception instanceof IllegalArgumentException) { + return Status.INVALID_ARGUMENT; + } + if (exception instanceof SignalQueryRejectedException) { + return Status.RESOURCE_EXHAUSTED; + } + return Status.UNAVAILABLE; + } + + @RequiredArgsConstructor + static final class BearerTokenInterceptor implements ServerInterceptor { + private static final Metadata.Key AUTHORIZATION = + Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER); + private final OtlpAccessTokenValidator tokenValidator; + + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, + ServerCallHandler next) { + String authorization = headers.get(AUTHORIZATION); + if (!StringUtils.hasText(authorization) || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) { + call.close(Status.UNAUTHENTICATED.withDescription("Missing OTLP access token"), new Metadata()); + return new ServerCall.Listener<>() { }; + } + String rejectReason = tokenValidator.validate(authorization.substring(7).trim()); + if (rejectReason != null) { + call.close(Status.UNAUTHENTICATED.withDescription(rejectReason), new Metadata()); + return new ServerCall.Listener<>() { }; + } + ServerCall.Listener listener = next.startCall(call, headers); + return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(listener) { }; + } + } +} 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 index 5d0bed1f458..07fb225d105 100644 --- 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 @@ -21,14 +21,9 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataReader; import org.springframework.data.domain.Page; @@ -38,7 +33,10 @@ import lombok.extern.slf4j.Slf4j; import org.apache.hertzbeat.common.entity.dto.Message; +import org.apache.hertzbeat.common.entity.dto.observability.LogQueryFilter; import org.apache.hertzbeat.common.entity.log.LogEntry; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard.Workload; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -54,10 +52,14 @@ @Slf4j public class LogQueryController { + private static final int MAX_PAGE_SIZE = 200; + private final HistoryDataReader historyDataReader; + private final SignalWorkloadGuard workloadGuard; - public LogQueryController(HistoryDataReader historyDataReader) { + public LogQueryController(HistoryDataReader historyDataReader, SignalWorkloadGuard workloadGuard) { this.historyDataReader = historyDataReader; + this.workloadGuard = workloadGuard; } @GetMapping("/list") @@ -78,12 +80,20 @@ public ResponseEntity>> list( @RequestParam(value = "severityText", required = false) String severityText, @Parameter(description = "Log content search keyword", example = "error") @RequestParam(value = "search", required = false) String search, + @RequestParam(value = "serviceName", required = false) String serviceName, + @RequestParam(value = "serviceNamespace", required = false) String serviceNamespace, + @RequestParam(value = "environment", required = false) String environment, + @RequestParam(value = "resource", required = false) String resource, @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, search, pageIndex, pageSize); - return ResponseEntity.ok(Message.success(result)); + return workloadGuard.execute(Workload.LOG_LIST, () -> { + LogQueryFilter filter = filter(start, end, traceId, spanId, severityNumber, severityText, search, + serviceName, serviceNamespace, environment, resource); + Page result = getPagedLogs(filter, pageIndex, pageSize); + return ResponseEntity.ok(Message.success(result)); + }); } @GetMapping("/stats/overview") @@ -103,29 +113,18 @@ public ResponseEntity>> overviewStats( @Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO") @RequestParam(value = "severityText", required = false) String severityText, @Parameter(description = "Log content search keyword", example = "error") - @RequestParam(value = "search", required = false) String search) { - List logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText, search); - - 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)); + @RequestParam(value = "search", required = false) String search, + @RequestParam(value = "serviceName", required = false) String serviceName, + @RequestParam(value = "serviceNamespace", required = false) String serviceNamespace, + @RequestParam(value = "environment", required = false) String environment, + @RequestParam(value = "resource", required = false) String resource) { + return workloadGuard.execute(Workload.LOG_AGGREGATE, + () -> doOverviewStats(filter(start, end, traceId, spanId, severityNumber, severityText, search, + serviceName, serviceNamespace, environment, resource))); + } + + private ResponseEntity>> doOverviewStats(LogQueryFilter filter) { + return ResponseEntity.ok(Message.success(historyDataReader.queryLogOverviewAggregate(filter))); } @GetMapping("/stats/trace-coverage") @@ -145,26 +144,19 @@ public ResponseEntity>> traceCoverageStats( @Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO") @RequestParam(value = "severityText", required = false) String severityText, @Parameter(description = "Log content search keyword", example = "error") - @RequestParam(value = "search", required = false) String search) { - List logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText, search); + @RequestParam(value = "search", required = false) String search, + @RequestParam(value = "serviceName", required = false) String serviceName, + @RequestParam(value = "serviceNamespace", required = false) String serviceNamespace, + @RequestParam(value = "environment", required = false) String environment, + @RequestParam(value = "resource", required = false) String resource) { + return workloadGuard.execute(Workload.LOG_AGGREGATE, + () -> doTraceCoverageStats(filter(start, end, traceId, spanId, severityNumber, severityText, search, + serviceName, serviceNamespace, environment, resource))); + } + private ResponseEntity>> doTraceCoverageStats(LogQueryFilter filter) { 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); + result.put("traceCoverage", historyDataReader.queryLogOverviewAggregate(filter).get("traceCoverage")); return ResponseEntity.ok(Message.success(result)); } @@ -185,49 +177,40 @@ public ResponseEntity>> trendStats( @Parameter(description = "Log severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)", example = "INFO") @RequestParam(value = "severityText", required = false) String severityText, @Parameter(description = "Log content search keyword", example = "error") - @RequestParam(value = "search", required = false) String search) { - List logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText, search); - - // 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())); + @RequestParam(value = "search", required = false) String search, + @RequestParam(value = "serviceName", required = false) String serviceName, + @RequestParam(value = "serviceNamespace", required = false) String serviceNamespace, + @RequestParam(value = "environment", required = false) String environment, + @RequestParam(value = "resource", required = false) String resource) { + return workloadGuard.execute(Workload.LOG_AGGREGATE, + () -> doTrendStats(filter(start, end, traceId, spanId, severityNumber, severityText, search, + serviceName, serviceNamespace, environment, resource))); + } + private ResponseEntity>> doTrendStats(LogQueryFilter filter) { Map result = new HashMap<>(); - result.put("hourlyStats", hourlyStats); + result.put("hourlyStats", historyDataReader.queryLogTrendAggregate(filter)); return ResponseEntity.ok(Message.success(result)); } - private List getFilteredLogs(Long start, Long end, String traceId, String spanId, - Integer severityNumber, String severityText, String search) { - // Use the new multi-condition query method - return historyDataReader.queryLogsByMultipleConditions(start, end, traceId, spanId, severityNumber, severityText, search); + private LogQueryFilter filter(Long start, Long end, String traceId, String spanId, Integer severityNumber, + String severityText, String search, String serviceName, String serviceNamespace, + String environment, String resource) { + return new LogQueryFilter(start, end, traceId, spanId, severityNumber, severityText, search, serviceName, + serviceNamespace, environment, resource); } - private Page getPagedLogs(Long start, Long end, String traceId, String spanId, - Integer severityNumber, String severityText, String search, - Integer pageIndex, Integer pageSize) { - // Calculate pagination parameters - int offset = pageIndex * pageSize; + private Page getPagedLogs(LogQueryFilter filter, Integer pageIndex, Integer pageSize) { + int effectivePage = Math.max(pageIndex == null ? 0 : pageIndex, 0); + int effectiveSize = Math.max(1, Math.min(pageSize == null ? 20 : pageSize, MAX_PAGE_SIZE)); + int offset = effectivePage * effectiveSize; - // Get total count and paginated data - long totalElements = historyDataReader.countLogsByMultipleConditions(start, end, traceId, spanId, severityNumber, severityText, search); - List pagedLogs = historyDataReader.queryLogsByMultipleConditionsWithPagination( - start, end, traceId, spanId, severityNumber, severityText, search, offset, pageSize); + long totalElements = historyDataReader.countObservabilityLogs(filter); + List pagedLogs = historyDataReader.queryObservabilityLogs(filter, offset, effectiveSize); - // Create PageRequest (sorted by timestamp descending) Sort sort = Sort.by(Sort.Direction.DESC, "timeUnixNano"); - PageRequest pageRequest = PageRequest.of(pageIndex, pageSize, sort); + PageRequest pageRequest = PageRequest.of(effectivePage, effectiveSize, sort); - // Return Spring Data Page object return new PageImpl<>(pagedLogs, pageRequest, totalElements); } -} \ No newline at end of file +} diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/OtlpSignalController.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/OtlpSignalController.java new file mode 100644 index 00000000000..a88e2d10eb5 --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/OtlpSignalController.java @@ -0,0 +1,80 @@ +/* + * 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.tags.Tag; +import java.nio.charset.StandardCharsets; +import org.apache.hertzbeat.log.service.OtlpSignalForwarder; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard.Workload; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Standard OTLP/HTTP gateway for all three signals. */ +@RestController +@ConditionalOnProperty(prefix = "warehouse.store.greptime", name = "enabled", havingValue = "true") +@RequestMapping("/api/otlp/v1") +@Tag(name = "OTLP Signal Controller") +public class OtlpSignalController { + + private final OtlpSignalForwarder signalForwarder; + private final SignalWorkloadGuard workloadGuard; + + public OtlpSignalController(OtlpSignalForwarder signalForwarder, SignalWorkloadGuard workloadGuard) { + this.signalForwarder = signalForwarder; + this.workloadGuard = workloadGuard; + } + + @PostMapping("/metrics") + @Operation(summary = "Ingest OTLP metrics") + public ResponseEntity metrics(@RequestBody byte[] content, @RequestHeader HttpHeaders headers) { + return forward("metrics", content, headers); + } + + @PostMapping("/logs") + @Operation(summary = "Ingest OTLP logs") + public ResponseEntity logs(@RequestBody byte[] content, @RequestHeader HttpHeaders headers) { + return forward("logs", content, headers); + } + + @PostMapping("/traces") + @Operation(summary = "Ingest OTLP traces") + public ResponseEntity traces(@RequestBody byte[] content, @RequestHeader HttpHeaders headers) { + return forward("traces", content, headers); + } + + private ResponseEntity forward(String signal, byte[] content, HttpHeaders headers) { + return workloadGuard.execute(Workload.OTLP_WRITE, + () -> signalForwarder.forwardHttp(signal, content, headers)); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity invalidPayload(IllegalArgumentException exception) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(exception.getMessage().getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/SignalWorkloadExceptionHandler.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/SignalWorkloadExceptionHandler.java new file mode 100644 index 00000000000..3cac628fc8a --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/SignalWorkloadExceptionHandler.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 static org.apache.hertzbeat.common.constants.CommonConstants.FAIL_CODE; + +import org.apache.hertzbeat.common.entity.dto.Message; +import org.apache.hertzbeat.common.support.exception.StorageUnavailableException; +import org.apache.hertzbeat.log.service.SignalQueryRejectedException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.RestClientException; + +/** Maps bounded signal workload rejection to a retryable HTTP response. */ +@RestControllerAdvice(basePackages = "org.apache.hertzbeat.log.controller") +@Order(Ordered.HIGHEST_PRECEDENCE) +public class SignalWorkloadExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleInvalidQuery(IllegalArgumentException exception) { + return ResponseEntity.badRequest().body(Message.fail(FAIL_CODE, exception.getMessage())); + } + + @ExceptionHandler(SignalQueryRejectedException.class) + public ResponseEntity> handleRejected(SignalQueryRejectedException exception) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .header(HttpHeaders.RETRY_AFTER, "1") + .body(Message.fail(FAIL_CODE, exception.getMessage())); + } + + @ExceptionHandler(StorageUnavailableException.class) + public ResponseEntity> handleStorageUnavailable(StorageUnavailableException exception) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .header(HttpHeaders.RETRY_AFTER, "1") + .body(Message.fail(FAIL_CODE, exception.getMessage())); + } + + @ExceptionHandler(RestClientException.class) + public ResponseEntity> handleStorageClientFailure(RestClientException exception) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .header(HttpHeaders.RETRY_AFTER, "1") + .body(Message.fail(FAIL_CODE, "GreptimeDB storage is unavailable")); + } +} diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/ThreeSignalQueryController.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/ThreeSignalQueryController.java new file mode 100644 index 00000000000..de95425c17b --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/ThreeSignalQueryController.java @@ -0,0 +1,136 @@ +/* + * 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.tags.Tag; +import org.apache.hertzbeat.common.entity.dto.Message; +import org.apache.hertzbeat.common.entity.dto.observability.OtlpMetricsConsole; +import org.apache.hertzbeat.common.entity.dto.observability.OtlpMetricsInventory; +import org.apache.hertzbeat.common.entity.dto.observability.SignalPage; +import org.apache.hertzbeat.common.entity.dto.observability.TraceDetail; +import org.apache.hertzbeat.common.entity.dto.observability.TraceListItem; +import org.apache.hertzbeat.common.entity.dto.observability.TraceOverview; +import org.apache.hertzbeat.log.service.ThreeSignalQueryService; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard.Workload; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** Public entity-free query APIs for OTLP metrics and traces. */ +@RestController +@ConditionalOnProperty(prefix = "warehouse.store.greptime", name = "enabled", havingValue = "true") +@Tag(name = "Three Signal Query Controller") +public class ThreeSignalQueryController { + + private final ThreeSignalQueryService queryService; + private final SignalWorkloadGuard workloadGuard; + + public ThreeSignalQueryController(ThreeSignalQueryService queryService, SignalWorkloadGuard workloadGuard) { + this.queryService = queryService; + this.workloadGuard = workloadGuard; + } + + @GetMapping(path = "/api/ingestion/otlp/metrics/console", produces = "application/json") + @Operation(summary = "Query OTLP metrics") + public ResponseEntity> metrics( + @RequestParam(value = "query", required = false) String query, + @RequestParam(value = "start", required = false) Long start, + @RequestParam(value = "end", required = false) Long end, + @RequestParam(value = "serviceName", required = false) String serviceName, + @RequestParam(value = "serviceNamespace", required = false) String serviceNamespace, + @RequestParam(value = "environment", required = false) String environment, + @RequestParam(value = "filter", required = false) String filter, + @RequestParam(value = "groupBy", required = false) String groupBy, + @RequestParam(value = "aggregation", required = false) String aggregation, + @RequestParam(value = "step", required = false) Integer step, + @RequestParam(value = "operationName", required = false) String operationName) { + return workloadGuard.execute(Workload.METRICS, () -> ResponseEntity.ok(Message.success( + queryService.queryMetrics(query, start, end, serviceName, serviceNamespace, environment, + filter, groupBy, aggregation, step, operationName)))); + } + + @GetMapping(path = "/api/ingestion/otlp/metrics/inventory", produces = "application/json") + @Operation(summary = "List OTLP metric names") + public ResponseEntity> metricInventory( + @RequestParam(value = "start", required = false) Long start, + @RequestParam(value = "end", required = false) Long end, + @RequestParam(value = "serviceName", required = false) String serviceName, + @RequestParam(value = "serviceNamespace", required = false) String serviceNamespace, + @RequestParam(value = "environment", required = false) String environment, + @RequestParam(value = "limit", defaultValue = "100") Integer limit) { + return workloadGuard.execute(Workload.METRICS, () -> ResponseEntity.ok(Message.success( + queryService.metricInventory(start, end, serviceName, serviceNamespace, environment, limit)))); + } + + @GetMapping(path = "/api/traces/list", produces = "application/json") + @Operation(summary = "Query traces") + public ResponseEntity>> traces( + @RequestParam(value = "start", required = false) Long start, + @RequestParam(value = "end", required = false) Long end, + @RequestParam(value = "traceId", required = false) String traceId, + @RequestParam(value = "errorOnly", required = false) Boolean errorOnly, + @RequestParam(value = "serviceName", required = false) String serviceName, + @RequestParam(value = "serviceNamespace", required = false) String serviceNamespace, + @RequestParam(value = "environment", required = false) String environment, + @RequestParam(value = "operationName", required = false) String operationName, + @RequestParam(value = "minDurationMs", required = false) Long minDurationMs, + @RequestParam(value = "maxDurationMs", required = false) Long maxDurationMs, + @RequestParam(value = "pageIndex", defaultValue = "0") Integer pageIndex, + @RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) { + return workloadGuard.execute(Workload.TRACES, () -> ResponseEntity.ok(Message.success( + queryService.queryTraces(start, end, traceId, errorOnly, serviceName, serviceNamespace, + environment, operationName, minDurationMs, maxDurationMs, pageIndex, pageSize)))); + } + + @GetMapping(path = "/api/traces/stats/overview", produces = "application/json") + @Operation(summary = "Get trace overview") + public ResponseEntity> traceOverview( + @RequestParam(value = "start", required = false) Long start, + @RequestParam(value = "end", required = false) Long end, + @RequestParam(value = "traceId", required = false) String traceId, + @RequestParam(value = "errorOnly", required = false) Boolean errorOnly, + @RequestParam(value = "serviceName", required = false) String serviceName, + @RequestParam(value = "serviceNamespace", required = false) String serviceNamespace, + @RequestParam(value = "environment", required = false) String environment, + @RequestParam(value = "operationName", required = false) String operationName, + @RequestParam(value = "minDurationMs", required = false) Long minDurationMs, + @RequestParam(value = "maxDurationMs", required = false) Long maxDurationMs) { + return workloadGuard.execute(Workload.TRACES, () -> ResponseEntity.ok(Message.success( + queryService.traceOverview(start, end, traceId, errorOnly, serviceName, serviceNamespace, + environment, operationName, minDurationMs, maxDurationMs)))); + } + + @GetMapping(path = "/api/traces/{traceId}", produces = "application/json") + @Operation(summary = "Get trace detail") + public ResponseEntity> traceDetail(@PathVariable("traceId") String traceId) { + return workloadGuard.execute(Workload.TRACES, + () -> ResponseEntity.ok(Message.success(queryService.traceDetail(traceId)))); + } + + @GetMapping(path = "/api/traces/{traceId}/spans", produces = "application/json") + @Operation(summary = "Get trace spans") + public ResponseEntity> traceSpans(@PathVariable("traceId") String traceId) { + return workloadGuard.execute(Workload.TRACES, + () -> ResponseEntity.ok(Message.success(queryService.traceDetail(traceId).spans()))); + } +} diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/OtlpSignalForwarder.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/OtlpSignalForwarder.java new file mode 100644 index 00000000000..b2305fafc3d --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/OtlpSignalForwarder.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.log.service; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; + +/** Forwards validated OTLP payloads to the configured GreptimeDB instance. */ +public interface OtlpSignalForwarder { + + ResponseEntity forwardHttp(String signal, byte[] content, HttpHeaders requestHeaders); + + byte[] forwardProtobuf(String signal, byte[] content); +} diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/SignalQueryRejectedException.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/SignalQueryRejectedException.java new file mode 100644 index 00000000000..40f43b1cbb3 --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/SignalQueryRejectedException.java @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.log.service; + +/** Raised when a signal workload exceeds its isolated concurrency budget. */ +public class SignalQueryRejectedException extends RuntimeException { + + public SignalQueryRejectedException(String workload) { + super("Signal workload is overloaded: " + workload); + } +} diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/SignalWorkloadGuard.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/SignalWorkloadGuard.java new file mode 100644 index 00000000000..f60677ad151 --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/SignalWorkloadGuard.java @@ -0,0 +1,90 @@ +/* + * 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; + +import java.time.Duration; +import java.util.EnumMap; +import java.util.Map; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import org.springframework.stereotype.Component; + +/** Isolates signal reads and writes with bounded concurrency and a short bounded queue. */ +@Component +public class SignalWorkloadGuard { + + private static final Duration QUEUE_WAIT = Duration.ofMillis(50); + private final Map slots = new EnumMap<>(Workload.class); + + public SignalWorkloadGuard() { + this(Map.of( + Workload.METRICS, new Limits(8, 16), + Workload.LOG_LIST, new Limits(8, 16), + Workload.LOG_AGGREGATE, new Limits(4, 8), + Workload.TRACES, new Limits(6, 12), + Workload.OTLP_WRITE, new Limits(16, 32))); + } + + SignalWorkloadGuard(Map limits) { + limits.forEach((workload, value) -> slots.put(workload, new Slot(value))); + } + + public T execute(Workload workload, Supplier operation) { + Slot slot = slots.get(workload); + if (slot == null || !slot.queue().tryAcquire()) { + throw new SignalQueryRejectedException(workload.name()); + } + boolean acquired = false; + try { + acquired = slot.active().tryAcquire(QUEUE_WAIT.toMillis(), TimeUnit.MILLISECONDS); + if (!acquired) { + throw new SignalQueryRejectedException(workload.name()); + } + return operation.get(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new SignalQueryRejectedException(workload.name()); + } finally { + slot.queue().release(); + if (acquired) { + slot.active().release(); + } + } + } + + /** Independently limited signal workload families. */ + public enum Workload { + METRICS, + LOG_LIST, + LOG_AGGREGATE, + TRACES, + OTLP_WRITE + } + + /** Active and waiting request limits for a workload family. */ + record Limits(int concurrency, int queueSize) { + } + + private record Slot(Semaphore active, Semaphore queue) { + + private Slot(Limits limits) { + this(new Semaphore(limits.concurrency(), true), new Semaphore(limits.queueSize(), true)); + } + } +} diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/ThreeSignalQueryService.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/ThreeSignalQueryService.java new file mode 100644 index 00000000000..5b5409f252e --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/ThreeSignalQueryService.java @@ -0,0 +1,47 @@ +/* + * 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; + +import org.apache.hertzbeat.common.entity.dto.observability.OtlpMetricsConsole; +import org.apache.hertzbeat.common.entity.dto.observability.OtlpMetricsInventory; +import org.apache.hertzbeat.common.entity.dto.observability.SignalPage; +import org.apache.hertzbeat.common.entity.dto.observability.TraceDetail; +import org.apache.hertzbeat.common.entity.dto.observability.TraceListItem; +import org.apache.hertzbeat.common.entity.dto.observability.TraceOverview; + +/** Entity-free query boundary for OTLP metrics and traces. */ +public interface ThreeSignalQueryService { + + OtlpMetricsConsole queryMetrics(String query, Long start, Long end, String serviceName, + String serviceNamespace, String environment, String filter, String groupBy, + String aggregation, Integer step, String operationName); + + OtlpMetricsInventory metricInventory(Long start, Long end, String serviceName, String serviceNamespace, + String environment, Integer limit); + + SignalPage queryTraces(Long start, Long end, String traceId, Boolean errorOnly, + String serviceName, String serviceNamespace, String environment, + String operationName, Long minDurationMs, Long maxDurationMs, + Integer pageIndex, Integer pageSize); + + TraceOverview traceOverview(Long start, Long end, String traceId, Boolean errorOnly, String serviceName, + String serviceNamespace, String environment, String operationName, + Long minDurationMs, Long maxDurationMs); + + TraceDetail traceDetail(String traceId); +} diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/impl/GreptimeOtlpSignalForwarder.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/impl/GreptimeOtlpSignalForwarder.java new file mode 100644 index 00000000000..766c58f1eca --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/impl/GreptimeOtlpSignalForwarder.java @@ -0,0 +1,219 @@ +/* + * 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.Message; +import com.google.protobuf.util.JsonFormat; +import io.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest; +import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest; +import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.zip.GZIPInputStream; +import org.apache.hertzbeat.log.service.OtlpSignalForwarder; +import org.apache.hertzbeat.common.util.JsonUtil; +import org.apache.hertzbeat.warehouse.constants.WarehouseConstants; +import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +/** Greptime native OTLP forwarder for metrics, logs, and traces. */ +@Service +@ConditionalOnProperty(prefix = "warehouse.store.greptime", name = "enabled", havingValue = "true") +public class GreptimeOtlpSignalForwarder implements OtlpSignalForwarder { + + private static final String PROTOBUF = "application/x-protobuf"; + private static final String GREPTIME_DATABASE_HEADER = "X-Greptime-DB-Name"; + private static final String GREPTIME_TRACE_TABLE_HEADER = "X-Greptime-Trace-Table-Name"; + private static final String GREPTIME_PIPELINE_HEADER = "X-Greptime-Pipeline-Name"; + private static final String GREPTIME_LOG_TABLE_HEADER = "X-Greptime-Log-Table-Name"; + private static final String GREPTIME_LOG_PIPELINE_HEADER = "X-Greptime-Log-Pipeline-Name"; + private static final String GREPTIME_PROMOTE_RESOURCE_HEADER = + "X-Greptime-OTLP-Metric-Promote-Resource-Attrs"; + private static final String PROMOTED_RESOURCE_ATTRIBUTES = String.join(";", List.of( + "service.name", "service.namespace", "service.version", "deployment.environment.name", + "host.name", "k8s.namespace.name", "k8s.pod.name")); + private static final Set SIGNALS = Set.of("metrics", "logs", "traces"); + private final GreptimeProperties greptimeProperties; + private final RestTemplate restTemplate; + + public GreptimeOtlpSignalForwarder(GreptimeProperties greptimeProperties, + @Qualifier(WarehouseConstants.GREPTIME_WRITE_REST_TEMPLATE) + RestTemplate restTemplate) { + this.greptimeProperties = greptimeProperties; + this.restTemplate = restTemplate; + } + + @Override + public ResponseEntity forwardHttp(String signal, byte[] content, HttpHeaders requestHeaders) { + HttpHeaders safeHeaders = requestHeaders == null ? new HttpHeaders() : requestHeaders; + byte[] normalized = maybeDecompress(content, safeHeaders); + MediaType contentType = safeHeaders.getContentType(); + byte[] protobuf = contentType != null && MediaType.APPLICATION_JSON.includes(contentType) + ? jsonToProtobuf(signal, normalized) : validateProtobuf(signal, normalized); + byte[] response = forwardProtobuf(signal, protobuf); + if (contentType != null && MediaType.APPLICATION_JSON.includes(contentType)) { + return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON) + .body("{}".getBytes(StandardCharsets.UTF_8)); + } + return ResponseEntity.ok().contentType(MediaType.parseMediaType(PROTOBUF)).body(response); + } + + @Override + public byte[] forwardProtobuf(String signal, byte[] content) { + String normalizedSignal = normalizeSignal(signal); + byte[] validContent = validateProtobuf(normalizedSignal, content); + HttpHeaders headers = greptimeHeaders(normalizedSignal); + ResponseEntity response = restTemplate.exchange( + endpoint(greptimeProperties.httpEndpoint(), "/v1/otlp/v1/" + normalizedSignal), + HttpMethod.POST, + new HttpEntity<>(validContent, headers), + byte[].class); + if (!response.getStatusCode().is2xxSuccessful()) { + throw new IllegalStateException("GreptimeDB rejected OTLP " + normalizedSignal); + } + return response.getBody() == null ? new byte[0] : response.getBody(); + } + + private HttpHeaders greptimeHeaders(String signal) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(PROTOBUF)); + headers.setAccept(List.of(MediaType.parseMediaType(PROTOBUF))); + headers.set(GREPTIME_DATABASE_HEADER, StringUtils.hasText(greptimeProperties.database()) + ? greptimeProperties.database() : "public"); + if ("metrics".equals(signal)) { + headers.set(GREPTIME_PROMOTE_RESOURCE_HEADER, PROMOTED_RESOURCE_ATTRIBUTES); + } else if ("traces".equals(signal)) { + headers.set(GREPTIME_TRACE_TABLE_HEADER, "hzb_traces"); + headers.set(GREPTIME_PIPELINE_HEADER, "greptime_trace_v1"); + } else { + headers.set(GREPTIME_LOG_TABLE_HEADER, WarehouseConstants.LOG_TABLE_NAME); + headers.set(GREPTIME_LOG_PIPELINE_HEADER, "hertzbeat_otlp_log_v1"); + } + if (StringUtils.hasText(greptimeProperties.username()) && StringUtils.hasText(greptimeProperties.password())) { + String credentials = greptimeProperties.username() + ":" + greptimeProperties.password(); + headers.setBasicAuth(Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8))); + } + return headers; + } + + private byte[] jsonToProtobuf(String signal, byte[] content) { + Message.Builder builder = switch (normalizeSignal(signal)) { + case "metrics" -> ExportMetricsServiceRequest.newBuilder(); + case "logs" -> ExportLogsServiceRequest.newBuilder(); + case "traces" -> ExportTraceServiceRequest.newBuilder(); + default -> throw new IllegalArgumentException("Unsupported OTLP signal"); + }; + try { + JsonFormat.parser().ignoringUnknownFields().merge(normalizeOtlpJson(content), builder); + return builder.build().toByteArray(); + } catch (InvalidProtocolBufferException exception) { + throw new IllegalArgumentException("Malformed OTLP " + signal + " JSON payload", exception); + } + } + + private String normalizeOtlpJson(byte[] content) { + String jsonText = new String(content, StandardCharsets.UTF_8); + if (!JsonUtil.isJsonStr(jsonText)) { + throw new IllegalArgumentException("Malformed OTLP JSON payload"); + } + Object json = JsonUtil.fromJson(jsonText, Object.class); + if (json == null) { + throw new IllegalArgumentException("Malformed OTLP JSON payload"); + } + normalizeIdBytes(json); + return JsonUtil.toJson(json); + } + + @SuppressWarnings("unchecked") + private void normalizeIdBytes(Object value) { + if (value instanceof Map rawMap) { + Map map = (Map) rawMap; + for (Map.Entry entry : map.entrySet()) { + Object child = entry.getValue(); + if (("traceId".equals(entry.getKey()) || "spanId".equals(entry.getKey()) + || "parentSpanId".equals(entry.getKey())) + && child instanceof String id && id.matches("[0-9a-fA-F]+") && id.length() % 2 == 0) { + entry.setValue(Base64.getEncoder().encodeToString(HexFormat.of().parseHex(id))); + } else { + normalizeIdBytes(child); + } + } + } else if (value instanceof List list) { + list.forEach(this::normalizeIdBytes); + } + } + + private byte[] validateProtobuf(String signal, byte[] content) { + byte[] safeContent = content == null ? new byte[0] : content; + try { + switch (normalizeSignal(signal)) { + case "metrics" -> ExportMetricsServiceRequest.parseFrom(safeContent); + case "logs" -> ExportLogsServiceRequest.parseFrom(safeContent); + case "traces" -> ExportTraceServiceRequest.parseFrom(safeContent); + default -> throw new IllegalArgumentException("Unsupported OTLP signal"); + } + return safeContent; + } catch (InvalidProtocolBufferException exception) { + throw new IllegalArgumentException("Malformed OTLP " + signal + " protobuf payload", exception); + } + } + + private byte[] maybeDecompress(byte[] content, HttpHeaders headers) { + byte[] safeContent = content == null ? new byte[0] : content; + if (!"gzip".equalsIgnoreCase(headers.getFirst(HttpHeaders.CONTENT_ENCODING))) { + return safeContent; + } + try (GZIPInputStream input = new GZIPInputStream(new ByteArrayInputStream(safeContent)); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + input.transferTo(output); + return output.toByteArray(); + } catch (IOException exception) { + throw new IllegalArgumentException("Malformed gzip OTLP payload", exception); + } + } + + private String normalizeSignal(String signal) { + String normalized = StringUtils.hasText(signal) ? signal.toLowerCase(Locale.ROOT) : ""; + if (!SIGNALS.contains(normalized)) { + throw new IllegalArgumentException("Unsupported OTLP signal"); + } + return normalized; + } + + private String endpoint(String base, String path) { + return StringUtils.trimTrailingCharacter(base, '/') + path; + } +} diff --git a/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/impl/GreptimeThreeSignalQueryService.java b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/impl/GreptimeThreeSignalQueryService.java new file mode 100644 index 00000000000..dd85d7d2c57 --- /dev/null +++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/service/impl/GreptimeThreeSignalQueryService.java @@ -0,0 +1,523 @@ +/* + * 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 java.net.URI; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.apache.hertzbeat.common.entity.dto.observability.MetricPoint; +import org.apache.hertzbeat.common.entity.dto.observability.MetricSeries; +import org.apache.hertzbeat.common.entity.dto.observability.OtlpMetricsConsole; +import org.apache.hertzbeat.common.entity.dto.observability.OtlpMetricsInventory; +import org.apache.hertzbeat.common.entity.dto.observability.SignalPage; +import org.apache.hertzbeat.common.entity.dto.observability.TraceDetail; +import org.apache.hertzbeat.common.entity.dto.observability.TraceListItem; +import org.apache.hertzbeat.common.entity.dto.observability.TraceOverview; +import org.apache.hertzbeat.common.entity.dto.observability.TraceSpanEvent; +import org.apache.hertzbeat.common.entity.dto.observability.TraceSpanNode; +import org.apache.hertzbeat.common.util.JsonUtil; +import org.apache.hertzbeat.common.support.exception.StorageUnavailableException; +import org.apache.hertzbeat.log.service.ThreeSignalQueryService; +import org.apache.hertzbeat.warehouse.db.GreptimeSqlQueryExecutor; +import org.apache.hertzbeat.warehouse.constants.WarehouseConstants; +import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.util.UriComponentsBuilder; +import tools.jackson.core.type.TypeReference; + +/** GreptimeDB implementation of the entity-free three-signal query contract. */ +@Service +@ConditionalOnProperty(prefix = "warehouse.store.greptime", name = "enabled", havingValue = "true") +public class GreptimeThreeSignalQueryService implements ThreeSignalQueryService { + + private static final String TRACE_TABLE = "hzb_traces"; + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z_:][A-Za-z0-9_:.-]*"); + private static final Pattern SAFE_LABEL = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + private static final Set AGGREGATIONS = Set.of("sum", "avg", "min", "max", "count"); + private static final long DEFAULT_WINDOW_MILLIS = 30 * 60 * 1000L; + private static final int DEFAULT_STEP_SECONDS = 30; + private static final int MAX_PAGE_SIZE = 200; + private final GreptimeProperties greptimeProperties; + private final GreptimeSqlQueryExecutor sqlQueryExecutor; + private final RestTemplate restTemplate; + + public GreptimeThreeSignalQueryService(GreptimeProperties greptimeProperties, + GreptimeSqlQueryExecutor sqlQueryExecutor, + @Qualifier(WarehouseConstants.GREPTIME_QUERY_REST_TEMPLATE) + RestTemplate restTemplate) { + this.greptimeProperties = greptimeProperties; + this.sqlQueryExecutor = sqlQueryExecutor; + this.restTemplate = restTemplate; + } + + @Override + public OtlpMetricsConsole queryMetrics(String query, Long start, Long end, String serviceName, + String serviceNamespace, String environment, String filter, + String groupBy, String aggregation, Integer step, String operationName) { + validateTimeRange(start, end); + long effectiveEnd = end == null ? Instant.now().toEpochMilli() : end; + long effectiveStart = start == null ? effectiveEnd - DEFAULT_WINDOW_MILLIS : start; + int effectiveStep = step == null ? DEFAULT_STEP_SECONDS : Math.max(1, Math.min(step, 3600)); + String effectiveQuery = buildPromql(query, serviceName, serviceNamespace, environment, filter, + groupBy, aggregation, operationName); + URI uri = UriComponentsBuilder.fromUriString(greptimeProperties.httpEndpoint()) + .path("/v1/prometheus/api/v1/query_range") + .queryParam("db", greptimeProperties.database()) + .queryParam("query", effectiveQuery) + .queryParam("start", effectiveStart / 1000.0) + .queryParam("end", effectiveEnd / 1000.0) + .queryParam("step", effectiveStep) + .build().encode().toUri(); + Map response = getPrometheus(uri); + return new OtlpMetricsConsole(effectiveQuery, effectiveStart, effectiveEnd, effectiveStep, + readMetricSeries(response)); + } + + @Override + public OtlpMetricsInventory metricInventory(Long start, Long end, String serviceName, String serviceNamespace, + String environment, Integer limit) { + int effectiveLimit = Math.max(1, Math.min(limit == null ? 100 : limit, 500)); + URI uri = UriComponentsBuilder.fromUriString(greptimeProperties.httpEndpoint()) + .path("/v1/prometheus/api/v1/label/__name__/values") + .queryParam("db", greptimeProperties.database()) + .build().encode().toUri(); + Map response = getPrometheus(uri); + Object rawData = response == null ? null : response.get("data"); + if (!(rawData instanceof List values)) { + return new OtlpMetricsInventory(List.of()); + } + List names = values.stream().map(String::valueOf).filter(StringUtils::hasText) + .sorted().limit(effectiveLimit).toList(); + return new OtlpMetricsInventory(names); + } + + private Map getPrometheus(URI uri) { + HttpHeaders headers = new HttpHeaders(); + if (StringUtils.hasText(greptimeProperties.username()) + && StringUtils.hasText(greptimeProperties.password())) { + headers.setBasicAuth(greptimeProperties.username(), greptimeProperties.password()); + } + try { + ResponseEntity response = restTemplate.exchange(uri, HttpMethod.GET, + new HttpEntity<>(headers), Map.class); + Map body = response.getBody() == null ? Map.of() : response.getBody(); + if ("error".equals(body.get("status"))) { + throw new IllegalArgumentException("Invalid PromQL query"); + } + return body; + } catch (IllegalArgumentException exception) { + throw exception; + } catch (HttpClientErrorException.BadRequest exception) { + throw new IllegalArgumentException("Invalid PromQL query", exception); + } catch (RestClientException exception) { + throw new StorageUnavailableException("GreptimeDB metrics storage is unavailable", exception); + } + } + + @Override + public SignalPage queryTraces(Long start, Long end, String traceId, Boolean errorOnly, + String serviceName, String serviceNamespace, String environment, + String operationName, Long minDurationMs, Long maxDurationMs, + Integer pageIndex, Integer pageSize) { + validateTraceRange(start, end, minDurationMs, maxDurationMs); + int effectivePage = Math.max(pageIndex == null ? 0 : pageIndex, 0); + int effectiveSize = Math.max(1, Math.min(pageSize == null ? 20 : pageSize, MAX_PAGE_SIZE)); + String filters = traceFilters(start, end, traceId, serviceName, serviceNamespace, environment, + operationName, minDurationMs, maxDurationMs); + String errorExpression = "SUM(CASE WHEN span_status_code IN ('STATUS_CODE_ERROR', 'ERROR') " + + "THEN 1 ELSE 0 END)"; + String rootSpan = "parent_span_id IS NULL OR parent_span_id = ''"; + StringBuilder grouped = new StringBuilder("SELECT trace_id, ") + .append(rootValue("span_id", "root_span_id", rootSpan)) + .append(", ").append(rootValue("service_name", "service_name", rootSpan)) + .append(", ").append(rootValue("\"resource_attributes.service.namespace\"", "service_namespace", rootSpan)) + .append(", ").append(rootValue("\"resource_attributes.deployment.environment.name\"", "environment", rootSpan)) + .append(", ").append(rootValue("span_name", "root_span_name", rootSpan)) + .append(", ").append(rootValue("duration_nano", "duration_nano", rootSpan)).append(", ") + .append("MIN(timestamp) AS start_time, ").append(errorExpression).append(" AS error_span_count, ") + .append("MAX(span_id) AS sampled_span_id FROM ").append(TRACE_TABLE); + if (StringUtils.hasText(filters)) { + grouped.append(" WHERE ").append(filters); + } + grouped.append(" GROUP BY trace_id"); + if (Boolean.TRUE.equals(errorOnly)) { + grouped.append(" HAVING ").append(errorExpression).append(" > 0"); + } + String sql = "SELECT *, COUNT(*) OVER () AS total_count FROM (" + grouped + + ") trace_page ORDER BY start_time DESC LIMIT " + effectiveSize + + " OFFSET " + effectivePage * effectiveSize; + List> rows = sqlQueryExecutor.execute(sql); + long total = rows.isEmpty() ? 0 : asLong(rows.getFirst().get("total_count")); + return new SignalPage<>(rows.stream().map(this::toTraceListItem).toList(), effectivePage, effectiveSize, total); + } + + @Override + public TraceOverview traceOverview(Long start, Long end, String traceId, Boolean errorOnly, String serviceName, + String serviceNamespace, String environment, String operationName, + Long minDurationMs, Long maxDurationMs) { + validateTraceRange(start, end, minDurationMs, maxDurationMs); + String filters = traceFilters(start, end, traceId, serviceName, serviceNamespace, environment, + operationName, minDurationMs, maxDurationMs); + String errorExpression = "SUM(CASE WHEN span_status_code IN ('STATUS_CODE_ERROR', 'ERROR') " + + "THEN 1 ELSE 0 END)"; + StringBuilder grouped = new StringBuilder("SELECT trace_id, MAX(duration_nano) AS duration_nano, ") + .append(errorExpression).append(" AS error_span_count FROM ").append(TRACE_TABLE); + if (StringUtils.hasText(filters)) { + grouped.append(" WHERE ").append(filters); + } + grouped.append(" GROUP BY trace_id"); + if (Boolean.TRUE.equals(errorOnly)) { + grouped.append(" HAVING ").append(errorExpression).append(" > 0"); + } + String sql = "SELECT COUNT(*) AS total_count, " + + "SUM(CASE WHEN error_span_count > 0 THEN 1 ELSE 0 END) AS error_count, " + + "AVG(duration_nano) AS average_duration, approx_percentile_cont(duration_nano, 0.95) AS p95_duration " + + "FROM (" + grouped + ") trace_overview"; + List> rows = sqlQueryExecutor.execute(sql); + if (rows.isEmpty()) { + return new TraceOverview(0, 0, 0, 0, 0); + } + Map row = rows.getFirst(); + long total = asLong(row.get("total_count")); + long errors = asLong(row.get("error_count")); + return new TraceOverview(total, errors, total == 0 ? 0 : (double) errors / total, + asDouble(row.get("average_duration")) / 1_000_000.0, + asDouble(row.get("p95_duration")) / 1_000_000.0); + } + + @Override + public TraceDetail traceDetail(String traceId) { + if (!StringUtils.hasText(traceId)) { + throw new IllegalArgumentException("traceId is required"); + } + String sql = "SELECT * FROM " + TRACE_TABLE + " WHERE trace_id = '" + escapeSql(traceId) + + "' ORDER BY timestamp ASC LIMIT 5000"; + List spans = sqlQueryExecutor.execute(sql).stream().map(this::toSpan).toList(); + if (spans.isEmpty()) { + return new TraceDetail(null, List.of()); + } + TraceSpanNode root = spans.stream().filter(span -> !StringUtils.hasText(span.parentSpanId())) + .findFirst().orElse(spans.getFirst()); + long start = spans.stream().mapToLong(TraceSpanNode::startTime).min().orElse(root.startTime()); + long end = spans.stream().mapToLong(span -> span.startTime() + span.durationNanos() / 1_000_000L) + .max().orElse(start + root.durationNanos() / 1_000_000L); + long duration = Math.max(root.durationNanos(), Math.max(0, end - start) * 1_000_000L); + int errors = (int) spans.stream().filter(span -> isError(span.status())).count(); + String namespace = root.resourceAttributes().getOrDefault("service.namespace", ""); + TraceListItem summary = new TraceListItem(traceId, root.spanId(), root.serviceName(), namespace, + root.spanName(), duration, errors > 0 ? "ERROR" : "OK", start, errors, + root.resourceAttributes()); + return new TraceDetail(summary, spans); + } + + private String buildPromql(String query, String serviceName, String serviceNamespace, String environment, + String filter, String groupBy, String aggregation, String operationName) { + String metric = StringUtils.hasText(query) ? query.trim() : "up"; + if (!SAFE_IDENTIFIER.matcher(metric).matches()) { + return metric; + } + Map labels = new LinkedHashMap<>(); + putIfText(labels, "service_name", serviceName); + putIfText(labels, "service_namespace", serviceNamespace); + putIfText(labels, "deployment_environment_name", environment); + putIfText(labels, "http_route", operationName); + parseFriendlyFilter(filter, labels); + String selector = metric + labels.entrySet().stream() + .map(entry -> entry.getKey() + "=\"" + escapePromql(entry.getValue()) + "\"") + .collect(java.util.stream.Collectors.joining(",", "{", "}")); + if (labels.isEmpty()) { + selector = metric; + } + String normalizedAggregation = StringUtils.hasText(aggregation) + ? aggregation.toLowerCase(Locale.ROOT) : null; + if (normalizedAggregation == null || !AGGREGATIONS.contains(normalizedAggregation)) { + return selector; + } + if (StringUtils.hasText(groupBy) && SAFE_LABEL.matcher(groupBy).matches()) { + return normalizedAggregation + " by (" + groupBy + ") (" + selector + ")"; + } + return normalizedAggregation + "(" + selector + ")"; + } + + private String rootValue(String column, String alias, String rootSpan) { + return "COALESCE(MAX(CASE WHEN " + rootSpan + " THEN " + column + " END), MAX(" + column + ")) AS " + alias; + } + + private void parseFriendlyFilter(String filter, Map labels) { + if (!StringUtils.hasText(filter)) { + return; + } + for (String clause : filter.split(",")) { + String[] pair = clause.split("=", 2); + if (pair.length != 2) { + continue; + } + String key = pair[0].trim().replace('.', '_'); + String value = pair[1].trim().replaceAll("^\"|\"$", ""); + if (SAFE_LABEL.matcher(key).matches() && StringUtils.hasText(value)) { + labels.put(key, value); + } + } + } + + private List readMetricSeries(Map response) { + if (response == null || !(response.get("data") instanceof Map data) + || !(data.get("result") instanceof List results)) { + return List.of(); + } + List series = new ArrayList<>(); + for (Object result : results) { + if (!(result instanceof Map item)) { + continue; + } + Map labels = new LinkedHashMap<>(); + if (item.get("metric") instanceof Map metricLabels) { + metricLabels.forEach((key, value) -> labels.put(String.valueOf(key), String.valueOf(value))); + } + List points = new ArrayList<>(); + if (item.get("values") instanceof List values) { + for (Object rawPoint : values) { + if (rawPoint instanceof List point && point.size() >= 2) { + points.add(new MetricPoint((long) (asDouble(point.get(0)) * 1000), asDouble(point.get(1)))); + } + } + } + series.add(new MetricSeries(labels, points)); + } + return series; + } + + private String traceFilters(Long start, Long end, String traceId, String serviceName, String serviceNamespace, + String environment, String operationName, Long minDurationMs, Long maxDurationMs) { + List filters = new ArrayList<>(); + if (start != null) { + filters.add("timestamp >= to_timestamp_millis(" + start + ")"); + } + if (end != null) { + filters.add("timestamp <= to_timestamp_millis(" + end + ")"); + } + addTextFilter(filters, "trace_id", traceId); + addTextFilter(filters, "service_name", serviceName); + addTextFilter(filters, "span_name", operationName); + addFlattenedResourceFilter(filters, "resource_attributes.service.namespace", serviceNamespace); + addFlattenedResourceFilter(filters, "resource_attributes.deployment.environment.name", environment); + if (minDurationMs != null) { + filters.add("duration_nano >= " + Math.max(0, minDurationMs) * 1_000_000L); + } + if (maxDurationMs != null) { + filters.add("duration_nano <= " + Math.max(0, maxDurationMs) * 1_000_000L); + } + return String.join(" AND ", filters); + } + + private void addTextFilter(List filters, String column, String value) { + if (StringUtils.hasText(value)) { + filters.add(column + " = '" + escapeSql(value) + "'"); + } + } + + private void addFlattenedResourceFilter(List filters, String column, String value) { + if (StringUtils.hasText(value) && !"all".equalsIgnoreCase(value)) { + filters.add("\"" + column + "\" = '" + escapeSql(value) + "'"); + } + } + + private TraceListItem toTraceListItem(Map row) { + int errors = (int) asLong(row.get("error_span_count")); + return new TraceListItem(asString(row.get("trace_id")), asString(row.get("root_span_id")), + asString(row.get("service_name")), asString(row.get("service_namespace")), + asString(row.get("root_span_name")), asLong(row.get("duration_nano")), + errors > 0 ? "ERROR" : "OK", asEpochMillis(row.get("start_time")), errors, + traceResourceAttributes(row)); + } + + private TraceSpanNode toSpan(Map row) { + return new TraceSpanNode(asString(row.get("trace_id")), asString(row.get("span_id")), + asString(row.get("parent_span_id")), asString(row.get("span_name")), + asString(row.get("service_name")), asString(row.get("span_status_code")), + asString(row.get("span_kind")), asString(row.get("span_status_message")), + asLong(row.get("duration_nano")), asEpochMillis(row.get("timestamp")), + traceResourceAttributes(row), traceSpanAttributes(row), + asSpanEvents(row.get("span_events"))); + } + + private Map traceResourceAttributes(Map row) { + Map attributes = new LinkedHashMap<>(asStringMap(row.get("resource_attributes"))); + row.forEach((key, value) -> putFlattenedAttribute(attributes, key, value, "resource_attributes.")); + putIfText(attributes, "service.namespace", asString(row.get("service_namespace"))); + putIfText(attributes, "deployment.environment.name", asString(row.get("environment"))); + return attributes; + } + + private Map traceSpanAttributes(Map row) { + Map attributes = new LinkedHashMap<>(asStringMap(row.get("span_attributes"))); + row.forEach((key, value) -> putFlattenedAttribute(attributes, key, value, "span_attributes.")); + return attributes; + } + + private void putFlattenedAttribute(Map target, String key, Object value, String prefix) { + if (key.startsWith(prefix) && value != null) { + putIfText(target, key.substring(prefix.length()), asString(value)); + } + } + + private List asSpanEvents(Object value) { + if (!StringUtils.hasText(asString(value)) && !(value instanceof List)) { + return List.of(); + } + List> events; + if (value instanceof List list) { + events = list.stream() + .filter(Map.class::isInstance) + .map(Map.class::cast) + .map(this::toStringKeyMap) + .toList(); + } else { + events = JsonUtil.fromJson(asString(value), new TypeReference<>() { }); + } + if (events == null) { + return List.of(); + } + return events.stream().map(event -> new TraceSpanEvent( + asString(event.get("name")), + asString(event.getOrDefault("time", event.get("timeUnixNano"))), + asObjectMap(event.get("attributes")))).toList(); + } + + private Map toStringKeyMap(Map source) { + Map result = new LinkedHashMap<>(); + source.forEach((key, item) -> result.put(String.valueOf(key), item)); + return result; + } + + private Map asObjectMap(Object value) { + if (value instanceof Map map) { + return toStringKeyMap(map); + } + return Collections.emptyMap(); + } + + private Map asStringMap(Object value) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + map.forEach((key, item) -> result.put(String.valueOf(key), String.valueOf(item))); + return result; + } + if (!StringUtils.hasText(asString(value))) { + return Collections.emptyMap(); + } + Map parsed = JsonUtil.fromJson(asString(value), new TypeReference<>() { }); + if (parsed == null) { + return Collections.emptyMap(); + } + Map result = new LinkedHashMap<>(); + parsed.forEach((key, item) -> result.put(key, String.valueOf(item))); + return result; + } + + private long asEpochMillis(Object value) { + if (value instanceof Number number) { + long timestamp = number.longValue(); + return timestamp > 10_000_000_000_000L ? timestamp / 1_000_000L : timestamp; + } + String text = asString(value); + try { + return Instant.parse(text).toEpochMilli(); + } catch (DateTimeParseException ignored) { + return asLong(value); + } + } + + private long asLong(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + try { + return (long) Double.parseDouble(asString(value)); + } catch (NumberFormatException ignored) { + return 0; + } + } + + private double asDouble(Object value) { + if (value instanceof Number number) { + return number.doubleValue(); + } + try { + return Double.parseDouble(asString(value)); + } catch (NumberFormatException ignored) { + return 0; + } + } + + private String asString(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private boolean isError(String status) { + return "ERROR".equalsIgnoreCase(status) || "STATUS_CODE_ERROR".equalsIgnoreCase(status); + } + + private void putIfText(Map target, String key, String value) { + if (StringUtils.hasText(value) && !"all".equalsIgnoreCase(value)) { + target.put(key, value.trim()); + } + } + + private String escapeSql(String value) { + return value.replace("'", "''"); + } + + private String escapePromql(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private void validateTraceRange(Long start, Long end, Long minDurationMs, Long maxDurationMs) { + validateTimeRange(start, end); + if ((minDurationMs != null && minDurationMs < 0) || (maxDurationMs != null && maxDurationMs < 0)) { + throw new IllegalArgumentException("Trace duration must not be negative"); + } + if (minDurationMs != null && maxDurationMs != null && minDurationMs > maxDurationMs) { + throw new IllegalArgumentException("Minimum trace duration must not exceed maximum duration"); + } + } + + private void validateTimeRange(Long start, Long end) { + if (start != null && end != null && start > end) { + throw new IllegalArgumentException("Query start time must not exceed end time"); + } + } +} diff --git a/hertzbeat-log/src/main/resources/greptime/pipelines/hertzbeat_otlp_log_v1.yaml b/hertzbeat-log/src/main/resources/greptime/pipelines/hertzbeat_otlp_log_v1.yaml new file mode 100644 index 00000000000..ac2ff301764 --- /dev/null +++ b/hertzbeat-log/src/main/resources/greptime/pipelines/hertzbeat_otlp_log_v1.yaml @@ -0,0 +1,71 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +version: 2 +processors: + - vrl: + source: | + .time_unix_nano = .Timestamp + .observed_time_unix_nano = .ObservedTimestamp + .trace_id = .TraceId + .span_id = .SpanId + .trace_flags = .TraceFlags + .severity_text = .SeverityText + .severity_number = .SeverityNumber + .body = .Body + .attributes = .LogAttributes + .resource = .ResourceAttributes + .instrumentation_scope = .InstrumentationScope + .dropped_attributes_count = .DroppedAttributesCount + . + - select: + fields: + - time_unix_nano + - observed_time_unix_nano + - trace_id + - span_id + - trace_flags + - severity_text + - severity_number + - body + - attributes + - resource + - instrumentation_scope + - dropped_attributes_count +transform: + - field: time_unix_nano + type: epoch, ns + index: timestamp + - field: observed_time_unix_nano + type: epoch, ns + - fields: + - trace_id + - span_id + type: string + index: skipping + - fields: + - attributes + - resource + - instrumentation_scope + type: json + - field: body + type: string + - field: severity_text + type: string + - fields: + - severity_number + - trace_flags + - dropped_attributes_count + type: int32 diff --git a/hertzbeat-log/src/main/resources/greptime/tables/hzb_traces.sql b/hertzbeat-log/src/main/resources/greptime/tables/hzb_traces.sql new file mode 100644 index 00000000000..64585ce31b8 --- /dev/null +++ b/hertzbeat-log/src/main/resources/greptime/tables/hzb_traces.sql @@ -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. + +CREATE TABLE IF NOT EXISTS hzb_traces ( + "timestamp" TIMESTAMP(9) TIME INDEX, + "timestamp_end" TIMESTAMP(9) NULL, + "duration_nano" BIGINT UNSIGNED NULL, + "trace_id" STRING NULL SKIPPING INDEX WITH(granularity = '10240', type = 'BLOOM'), + "span_id" STRING NULL SKIPPING INDEX WITH(granularity = '10240', type = 'BLOOM'), + "parent_span_id" STRING NULL SKIPPING INDEX WITH(granularity = '10240', type = 'BLOOM'), + "span_kind" STRING NULL, + "span_name" STRING NULL, + "span_status_code" STRING NULL, + "span_status_message" STRING NULL, + "trace_state" STRING NULL, + "scope_name" STRING NULL, + "scope_version" STRING NULL, + "service_name" STRING NULL SKIPPING INDEX WITH(granularity = '10240', type = 'BLOOM'), + "resource_attributes.service.namespace" STRING NULL, + "resource_attributes.deployment.environment.name" STRING NULL, + "resource_attributes" JSON NULL, + "span_attributes" JSON NULL, + "span_events" JSON NULL, + "span_links" JSON NULL, + PRIMARY KEY("service_name") +) WITH (append_mode = true, table_data_model = 'greptime_trace_v1'); diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/GreptimeSignalInitializerTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/GreptimeSignalInitializerTest.java new file mode 100644 index 00000000000..840d184f4cd --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/GreptimeSignalInitializerTest.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.log.config; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import org.mockito.InOrder; +import org.apache.hertzbeat.warehouse.db.GreptimeSqlQueryExecutor; +import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +@ExtendWith(MockitoExtension.class) +class GreptimeSignalInitializerTest { + + @Mock + private GreptimeSqlQueryExecutor sqlQueryExecutor; + + @Mock + private RestTemplate restTemplate; + + private GreptimeSignalInitializer initializer; + + @BeforeEach + void setUp() { + initializer = new GreptimeSignalInitializer( + new GreptimeProperties(true, "127.0.0.1:4001", "http://127.0.0.1:4000", + "public", "greptime", "secret"), + sqlQueryExecutor, + restTemplate); + } + + @Test + void shouldPrepareTraceSchemaPipelineAndReadiness() { + when(sqlQueryExecutor.execute(anyString())).thenAnswer(invocation -> + invocation.getArgument(0, String.class).startsWith("SELECT 1") + ? List.of(Map.of("ready", 1)) : List.of()); + when(restTemplate.exchange(anyString(), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok("ok")); + + initializer.initialize(); + + InOrder sqlOrder = org.mockito.Mockito.inOrder(sqlQueryExecutor); + sqlOrder.verify(sqlQueryExecutor).execute(org.mockito.ArgumentMatchers.contains( + "CREATE TABLE IF NOT EXISTS hzb_traces")); + sqlOrder.verify(sqlQueryExecutor).execute(org.mockito.ArgumentMatchers.startsWith( + "ALTER TABLE hzb_traces ADD COLUMN IF NOT EXISTS \"resource_attributes.service.namespace\"")); + sqlOrder.verify(sqlQueryExecutor).execute(org.mockito.ArgumentMatchers.startsWith( + "ALTER TABLE hzb_traces ADD COLUMN IF NOT EXISTS \"resource_attributes.deployment.environment.name\"")); + sqlOrder.verify(sqlQueryExecutor).execute("SELECT 1 AS ready"); + verify(restTemplate).exchange(eq("http://127.0.0.1:4000/v1/pipelines/hertzbeat_otlp_log_v1"), + eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class)); + } + + @Test + void shouldFailStartupWhenGreptimeIsUnavailable() { + when(sqlQueryExecutor.execute(anyString())).thenThrow(new IllegalStateException("offline")); + + assertThatThrownBy(initializer::initialize) + .isInstanceOf(IllegalStateException.class) + .hasMessage("GreptimeDB is required for the three-signal release but initialization failed") + .hasCauseInstanceOf(IllegalStateException.class); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/GreptimeSignalPipelineTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/GreptimeSignalPipelineTest.java new file mode 100644 index 00000000000..1e29119ec48 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/GreptimeSignalPipelineTest.java @@ -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. + */ + +package org.apache.hertzbeat.log.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +class GreptimeSignalPipelineTest { + + @Test + void shouldStoreOtlpBodyAsStringAndAttributesAsJson() throws Exception { + String pipeline = new ClassPathResource("greptime/pipelines/hertzbeat_otlp_log_v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(pipeline).contains("- field: body\n type: string"); + assertThat(pipeline).doesNotContain("- body\n - attributes"); + assertThat(pipeline).contains("- attributes\n - resource\n - instrumentation_scope\n type: json"); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/OtlpGrpcServerConfigTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/OtlpGrpcServerConfigTest.java new file mode 100644 index 00000000000..41245fe3914 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/OtlpGrpcServerConfigTest.java @@ -0,0 +1,107 @@ +/* + * 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.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.Server; +import io.grpc.ServerInterceptors; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.stub.MetadataUtils; +import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest; +import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceResponse; +import io.opentelemetry.proto.collector.metrics.v1.MetricsServiceGrpc; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import org.apache.hertzbeat.common.security.OtlpAccessTokenValidator; +import org.apache.hertzbeat.log.service.OtlpSignalForwarder; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard; +import org.apache.hertzbeat.log.service.SignalQueryRejectedException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class OtlpGrpcServerConfigTest { + + private Server server; + private ManagedChannel channel; + + @BeforeEach + void setUp() throws Exception { + OtlpSignalForwarder forwarder = mock(OtlpSignalForwarder.class); + SignalWorkloadGuard guard = mock(SignalWorkloadGuard.class); + OtlpAccessTokenValidator validator = token -> "probe-token".equals(token) ? null : "Invalid token"; + when(guard.execute(eq(SignalWorkloadGuard.Workload.OTLP_WRITE), any())).thenAnswer(invocation -> { + Supplier action = invocation.getArgument(1); + return action.get(); + }); + when(forwarder.forwardProtobuf(eq("metrics"), any())).thenReturn(new byte[0]); + server = NettyServerBuilder.forPort(0) + .addService(ServerInterceptors.intercept( + new OtlpGrpcServerConfig.MetricsService(forwarder, guard), + new OtlpGrpcServerConfig.BearerTokenInterceptor(validator))) + .build().start(); + channel = NettyChannelBuilder.forAddress("127.0.0.1", server.getPort()).usePlaintext().build(); + } + + @AfterEach + void tearDown() throws Exception { + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + + @Test + void shouldAcceptAuthorizedOtlpGrpcMetricsRequest() { + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER), "Bearer probe-token"); + ExportMetricsServiceResponse response = MetricsServiceGrpc.newBlockingStub(channel) + .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(headers)) + .export(ExportMetricsServiceRequest.getDefaultInstance()); + + assertThat(response).isEqualTo(ExportMetricsServiceResponse.getDefaultInstance()); + } + + @Test + void shouldRejectMissingOtlpGrpcBearerToken() { + assertThatThrownBy(() -> MetricsServiceGrpc.newBlockingStub(channel) + .export(ExportMetricsServiceRequest.getDefaultInstance())) + .isInstanceOfSatisfying(StatusRuntimeException.class, + exception -> assertThat(exception.getStatus().getCode()) + .isEqualTo(Status.Code.UNAUTHENTICATED)); + } + + @Test + void shouldMapGrpcFailuresWithoutHidingClientAndCapacityErrors() { + assertThat(OtlpGrpcServerConfig.toGrpcStatus(new IllegalArgumentException("payload"))) + .isEqualTo(Status.INVALID_ARGUMENT); + assertThat(OtlpGrpcServerConfig.toGrpcStatus(new SignalQueryRejectedException("OTLP_WRITE"))) + .isEqualTo(Status.RESOURCE_EXHAUSTED); + assertThat(OtlpGrpcServerConfig.toGrpcStatus(new IllegalStateException("storage"))) + .isEqualTo(Status.UNAVAILABLE); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/LogQueryControllerTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/LogQueryControllerTest.java index 8e3da88e409..6aba088ddff 100644 --- a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/LogQueryControllerTest.java +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/LogQueryControllerTest.java @@ -18,20 +18,20 @@ package org.apache.hertzbeat.log.controller; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; 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 java.util.Map; import org.apache.hertzbeat.common.constants.CommonConstants; import org.apache.hertzbeat.common.entity.log.LogEntry; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard; import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataReader; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -57,8 +57,10 @@ class LogQueryControllerTest { @BeforeEach void setUp() { - this.logQueryController = new LogQueryController(historyDataReader); - this.mockMvc = MockMvcBuilders.standaloneSetup(logQueryController).build(); + this.logQueryController = new LogQueryController(historyDataReader, new SignalWorkloadGuard()); + this.mockMvc = MockMvcBuilders.standaloneSetup(logQueryController) + .setControllerAdvice(new SignalWorkloadExceptionHandler()) + .build(); } @Test @@ -86,11 +88,8 @@ void testListLogsWithAllFilters() throws Exception { List mockLogs = Arrays.asList(logEntry1, logEntry2); - when(historyDataReader.countLogsByMultipleConditions(anyLong(), anyLong(), any(), - any(), any(), any(), any())).thenReturn(2L); - when(historyDataReader.queryLogsByMultipleConditionsWithPagination(anyLong(), anyLong(), - any(), any(), any(), any(), any(), anyInt(), anyInt())) - .thenReturn(mockLogs); + when(historyDataReader.countObservabilityLogs(any())).thenReturn(2L); + when(historyDataReader.queryObservabilityLogs(any(), any(), any())).thenReturn(mockLogs); mockMvc.perform( MockMvcRequestBuilders @@ -104,7 +103,6 @@ void testListLogsWithAllFilters() throws Exception { .param("pageIndex", "0") .param("pageSize", "20") ) - .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE)) .andExpect(jsonPath("$.data.content").isArray()) @@ -126,11 +124,8 @@ void testListLogsWithoutFilters() throws Exception { .build() ); - when(historyDataReader.countLogsByMultipleConditions(any(), any(), any(), - any(), any(), any(), any())).thenReturn(1L); - when(historyDataReader.queryLogsByMultipleConditionsWithPagination(any(), any(), - any(), any(), any(), any(), any(), eq(0), eq(20))) - .thenReturn(mockLogs); + when(historyDataReader.countObservabilityLogs(any())).thenReturn(1L); + when(historyDataReader.queryObservabilityLogs(any(), any(), any())).thenReturn(mockLogs); mockMvc.perform( MockMvcRequestBuilders @@ -143,27 +138,36 @@ void testListLogsWithoutFilters() throws Exception { } @Test - void testOverviewStatsWithMixedSeverityLogs() throws Exception { - // Create logs with different severity levels according to OpenTelemetry standard - List mockLogs = Arrays.asList( - // TRACE (1-4) - LogEntry.builder().severityNumber(2).build(), - // DEBUG (5-8) - LogEntry.builder().severityNumber(6).build(), - // INFO (9-12) - LogEntry.builder().severityNumber(9).build(), - LogEntry.builder().severityNumber(10).build(), - // WARN (13-16) - LogEntry.builder().severityNumber(14).build(), - // ERROR (17-20) - LogEntry.builder().severityNumber(17).build(), - LogEntry.builder().severityNumber(18).build(), - // FATAL (21-24) - LogEntry.builder().severityNumber(21).build() - ); + void shouldBoundLogPaginationBeforeReadingStorage() throws Exception { + when(historyDataReader.countObservabilityLogs(any())).thenReturn(0L); + when(historyDataReader.queryObservabilityLogs(any(), any(), any())).thenReturn(List.of()); - when(historyDataReader.queryLogsByMultipleConditions(any(), any(), any(), - any(), any(), any(), any())).thenReturn(mockLogs); + mockMvc.perform(MockMvcRequestBuilders.get("/api/logs/list") + .param("pageIndex", "-1") + .param("pageSize", "1000")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.number").value(0)) + .andExpect(jsonPath("$.data.size").value(200)); + + verify(historyDataReader).queryObservabilityLogs(any(), eq(0), eq(200)); + } + + @Test + void shouldReturnBadRequestForInvalidResourceExpression() throws Exception { + when(historyDataReader.countObservabilityLogs(any())) + .thenThrow(new IllegalArgumentException("Invalid resource filter expression")); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/logs/list") + .param("resource", "bad-expression")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE)); + } + + @Test + void testOverviewStatsWithMixedSeverityLogs() throws Exception { + when(historyDataReader.queryLogOverviewAggregate(any())).thenReturn(Map.of( + "totalCount", 8L, "traceCount", 1L, "debugCount", 1L, "infoCount", 2L, + "warnCount", 1L, "errorCount", 2L, "fatalCount", 1L)); mockMvc.perform( MockMvcRequestBuilders @@ -182,13 +186,7 @@ void testOverviewStatsWithMixedSeverityLogs() throws Exception { @Test void testOverviewStatsWithTimeRange() throws Exception { - List mockLogs = Arrays.asList( - LogEntry.builder().severityNumber(9).build(), - LogEntry.builder().severityNumber(17).build() - ); - - when(historyDataReader.queryLogsByMultipleConditions(eq(1734005477000L), eq(1734005478000L), - any(), any(), any(), any(), any())).thenReturn(mockLogs); + when(historyDataReader.queryLogOverviewAggregate(any())).thenReturn(Map.of("totalCount", 2L)); mockMvc.perform( MockMvcRequestBuilders @@ -203,21 +201,8 @@ void testOverviewStatsWithTimeRange() throws Exception { @Test void testTraceCoverageStats() throws Exception { - List mockLogs = Arrays.asList( - // With both trace and span - LogEntry.builder().traceId("trace1").spanId("span1").build(), - LogEntry.builder().traceId("trace2").spanId("span2").build(), - // With trace only - LogEntry.builder().traceId("trace3").spanId("").build(), - // With span only - LogEntry.builder().traceId("").spanId("span4").build(), - // Without trace info - LogEntry.builder().traceId("").spanId("").build(), - LogEntry.builder().build() // null values - ); - - when(historyDataReader.queryLogsByMultipleConditions(any(), any(), any(), - any(), any(), any(), any())).thenReturn(mockLogs); + when(historyDataReader.queryLogOverviewAggregate(any())).thenReturn(Map.of("traceCoverage", Map.of( + "withTrace", 3L, "withoutTrace", 3L, "withSpan", 3L, "withBothTraceAndSpan", 2L))); mockMvc.perform( MockMvcRequestBuilders @@ -233,18 +218,8 @@ void testTraceCoverageStats() throws Exception { @Test void testTrendStats() throws Exception { - // Create logs with timestamps that fall into different hours - List mockLogs = Arrays.asList( - // 2023-12-12 10:00 (1734005477630000000L nano = 1734005477630L ms) - LogEntry.builder().timeUnixNano(1734005477630000000L).build(), - // Same hour - LogEntry.builder().timeUnixNano(1734005477640000000L).build(), - // Next hour: 2023-12-12 11:00 (1734009077630000000L nano = 1734009077630L ms) - LogEntry.builder().timeUnixNano(1734009077630000000L).build() - ); - - when(historyDataReader.queryLogsByMultipleConditions(any(), any(), any(), - any(), any(), any(), any())).thenReturn(mockLogs); + when(historyDataReader.queryLogTrendAggregate(any())).thenReturn(Map.of( + "2023-12-12 10:00", 2L, "2023-12-12 11:00", 1L)); mockMvc.perform( MockMvcRequestBuilders @@ -257,13 +232,7 @@ void testTrendStats() throws Exception { @Test void testTrendStatsWithNullTimestamp() throws Exception { - List mockLogs = Arrays.asList( - LogEntry.builder().timeUnixNano(1734005477630000000L).build(), - LogEntry.builder().timeUnixNano(null).build() // This should be filtered out - ); - - when(historyDataReader.queryLogsByMultipleConditions(any(), any(), any(), - any(), any(), any(), any())).thenReturn(mockLogs); + when(historyDataReader.queryLogTrendAggregate(any())).thenReturn(Map.of("2023-12-12 10:00", 1L)); mockMvc.perform( MockMvcRequestBuilders diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/OtlpSignalControllerTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/OtlpSignalControllerTest.java new file mode 100644 index 00000000000..a3e48a14e04 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/OtlpSignalControllerTest.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.log.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.apache.hertzbeat.log.service.OtlpSignalForwarder; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +/** OTLP/HTTP route contract tests. */ +@ExtendWith(MockitoExtension.class) +class OtlpSignalControllerTest { + + @Mock + private OtlpSignalForwarder signalForwarder; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup( + new OtlpSignalController(signalForwarder, new SignalWorkloadGuard())).build(); + } + + @Test + void shouldRouteAllThreeSignals() throws Exception { + when(signalForwarder.forwardHttp(any(), any(), any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body("{}".getBytes())); + + for (String signal : new String[] {"metrics", "logs", "traces"}) { + mockMvc.perform(MockMvcRequestBuilders.post("/api/otlp/v1/" + signal) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()); + verify(signalForwarder).forwardHttp(eq(signal), any(), any(HttpHeaders.class)); + } + } + + @Test + void shouldReturnBadRequestForMalformedOtlpPayload() throws Exception { + when(signalForwarder.forwardHttp(eq("metrics"), any(), any())) + .thenThrow(new IllegalArgumentException("Malformed OTLP metrics JSON payload")); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/otlp/v1/metrics") + .contentType(MediaType.APPLICATION_JSON) + .content("{")) + .andExpect(status().isBadRequest()) + .andExpect(content().string("Malformed OTLP metrics JSON payload")); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/SignalWorkloadExceptionHandlerTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/SignalWorkloadExceptionHandlerTest.java new file mode 100644 index 00000000000..9e3a32fa6da --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/SignalWorkloadExceptionHandlerTest.java @@ -0,0 +1,59 @@ +/* + * 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.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.apache.hertzbeat.common.entity.dto.Message; +import org.apache.hertzbeat.log.service.SignalQueryRejectedException; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +/** Tests retryable overload responses. */ +class SignalWorkloadExceptionHandlerTest { + + @Test + void shouldReturnRetryableTooManyRequests() { + ResponseEntity> response = new SignalWorkloadExceptionHandler() + .handleRejected(new SignalQueryRejectedException("METRICS")); + + assertEquals(HttpStatus.TOO_MANY_REQUESTS, response.getStatusCode()); + assertEquals("1", response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER)); + } + + @Test + void shouldReturnBadRequestForInvalidAdvancedQuery() { + ResponseEntity> response = new SignalWorkloadExceptionHandler() + .handleInvalidQuery(new IllegalArgumentException("Invalid PromQL query")); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + void shouldTakePrecedenceOverTheManagerGlobalAdvice() { + Order order = SignalWorkloadExceptionHandler.class.getAnnotation(Order.class); + + assertNotNull(order); + assertEquals(Ordered.HIGHEST_PRECEDENCE, order.value()); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/ThreeSignalQueryControllerTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/ThreeSignalQueryControllerTest.java new file mode 100644 index 00000000000..a98c9fdd2e3 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/ThreeSignalQueryControllerTest.java @@ -0,0 +1,145 @@ +/* + * 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.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import java.util.Map; +import org.apache.hertzbeat.common.entity.dto.observability.OtlpMetricsConsole; +import org.apache.hertzbeat.common.entity.dto.observability.OtlpMetricsInventory; +import org.apache.hertzbeat.common.entity.dto.observability.SignalPage; +import org.apache.hertzbeat.common.entity.dto.observability.TraceDetail; +import org.apache.hertzbeat.common.entity.dto.observability.TraceListItem; +import org.apache.hertzbeat.common.entity.dto.observability.TraceOverview; +import org.apache.hertzbeat.common.entity.dto.observability.TraceSpanNode; +import org.apache.hertzbeat.log.service.ThreeSignalQueryService; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +/** Tests the entity-free public query contract. */ +@ExtendWith(MockitoExtension.class) +class ThreeSignalQueryControllerTest { + + private MockMvc mockMvc; + + @Mock + private ThreeSignalQueryService queryService; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup( + new ThreeSignalQueryController(queryService, new SignalWorkloadGuard())).build(); + } + + @Test + void shouldQueryMetricsWithoutEntityContext() throws Exception { + when(queryService.queryMetrics("http_server_duration", 1000L, 2000L, "checkout", "payments", + "prod", null, null, null, 60, null)) + .thenReturn(new OtlpMetricsConsole("http_server_duration", 1000L, 2000L, 60, List.of())); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/ingestion/otlp/metrics/console") + .param("query", "http_server_duration") + .param("start", "1000") + .param("end", "2000") + .param("serviceName", "checkout") + .param("serviceNamespace", "payments") + .param("environment", "prod") + .param("step", "60")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.query").value("http_server_duration")) + .andExpect(jsonPath("$.data.series").isArray()); + } + + @Test + void shouldExposeMetricInventory() throws Exception { + when(queryService.metricInventory(1000L, 2000L, "checkout", "payments", "prod", 50)) + .thenReturn(new OtlpMetricsInventory(List.of("http_server_duration"))); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/ingestion/otlp/metrics/inventory") + .param("start", "1000") + .param("end", "2000") + .param("serviceName", "checkout") + .param("serviceNamespace", "payments") + .param("environment", "prod") + .param("limit", "50")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.metricNames[0]").value("http_server_duration")); + } + + @Test + void shouldQueryTraceListAndDetail() throws Exception { + TraceListItem item = new TraceListItem("trace-1", "span-1", "checkout", "payments", "GET /cart", + 10_000_000L, "ERROR", 1000L, 1, Map.of("deployment.environment.name", "prod")); + when(queryService.queryTraces(1000L, 2000L, "trace-1", true, "checkout", "payments", "prod", + "GET /cart", 1L, 100L, 0, 20)) + .thenReturn(new SignalPage<>(List.of(item), 0, 20, 1)); + TraceSpanNode span = new TraceSpanNode("trace-1", "span-1", "", "GET /cart", "checkout", "ERROR", + "SERVER", "failed", 10_000_000L, 1000L, Map.of(), Map.of(), List.of()); + when(queryService.traceDetail("trace-1")).thenReturn(new TraceDetail(item, List.of(span))); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/traces/list") + .param("start", "1000") + .param("end", "2000") + .param("traceId", "trace-1") + .param("errorOnly", "true") + .param("serviceName", "checkout") + .param("serviceNamespace", "payments") + .param("environment", "prod") + .param("operationName", "GET /cart") + .param("minDurationMs", "1") + .param("maxDurationMs", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].traceId").value("trace-1")); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/traces/trace-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.spans[0].spanId").value("span-1")); + } + + @Test + void shouldExposeTraceOverview() throws Exception { + when(queryService.traceOverview(1000L, 2000L, "trace-1", true, "checkout", "payments", "prod", + "GET /cart", 1L, 100L)) + .thenReturn(new TraceOverview(4, 1, 0.25, 12.5, 24.0)); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/traces/stats/overview") + .param("start", "1000") + .param("end", "2000") + .param("traceId", "trace-1") + .param("errorOnly", "true") + .param("serviceName", "checkout") + .param("serviceNamespace", "payments") + .param("environment", "prod") + .param("operationName", "GET /cart") + .param("minDurationMs", "1") + .param("maxDurationMs", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(4)) + .andExpect(jsonPath("$.data.errorRate").value(0.25)); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/SignalWorkloadGuardTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/SignalWorkloadGuardTest.java new file mode 100644 index 00000000000..d8c92d7625b --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/SignalWorkloadGuardTest.java @@ -0,0 +1,63 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard.Limits; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard.Workload; +import org.junit.jupiter.api.Test; + +/** Tests bounded signal workload isolation and recovery. */ +class SignalWorkloadGuardTest { + + @Test + void shouldRejectOverflowAndRecoverAfterPressureStops() throws Exception { + SignalWorkloadGuard guard = new SignalWorkloadGuard(Map.of(Workload.METRICS, new Limits(1, 1))); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future active = executor.submit(() -> guard.execute(Workload.METRICS, () -> { + entered.countDown(); + try { + release.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + return "done"; + })); + entered.await(); + + assertThrows(SignalQueryRejectedException.class, + () -> guard.execute(Workload.METRICS, () -> "overflow")); + + release.countDown(); + assertEquals("done", active.get()); + assertEquals("recovered", guard.execute(Workload.METRICS, () -> "recovered")); + } finally { + executor.shutdownNow(); + } + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/GreptimeOtlpSignalForwarderTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/GreptimeOtlpSignalForwarderTest.java new file mode 100644 index 00000000000..07b4cd18151 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/GreptimeOtlpSignalForwarderTest.java @@ -0,0 +1,126 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest; +import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest; +import java.nio.charset.StandardCharsets; +import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +/** Greptime native OTLP forwarding tests. */ +@ExtendWith(MockitoExtension.class) +class GreptimeOtlpSignalForwarderTest { + + @Mock + private RestTemplate restTemplate; + + @Test + void shouldConvertJsonAndForwardMetricsWithResourcePromotion() { + when(restTemplate.exchange(eq("http://127.0.0.1:4000/v1/otlp/v1/metrics"), eq(HttpMethod.POST), + any(HttpEntity.class), eq(byte[].class))).thenReturn(ResponseEntity.ok(new byte[0])); + GreptimeOtlpSignalForwarder forwarder = new GreptimeOtlpSignalForwarder( + new GreptimeProperties(true, "127.0.0.1:4001", "http://127.0.0.1:4000", + "public", "greptime", "secret"), restTemplate); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = forwarder.forwardHttp("metrics", "{}".getBytes(StandardCharsets.UTF_8), + headers); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + ArgumentCaptor> request = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange(eq("http://127.0.0.1:4000/v1/otlp/v1/metrics"), eq(HttpMethod.POST), + request.capture(), eq(byte[].class)); + assertThat(request.getValue().getHeaders().getFirst("X-Greptime-OTLP-Metric-Promote-Resource-Attrs")) + .contains("service.name", "deployment.environment.name"); + assertThat(request.getValue().getBody()).isEqualTo(ExportMetricsServiceRequest.getDefaultInstance().toByteArray()); + } + + @Test + void shouldAcceptStandardHexTraceIdsInOtlpJson() throws Exception { + when(restTemplate.exchange(eq("http://127.0.0.1:4000/v1/otlp/v1/traces"), eq(HttpMethod.POST), + any(HttpEntity.class), eq(byte[].class))).thenReturn(ResponseEntity.ok(new byte[0])); + GreptimeOtlpSignalForwarder forwarder = new GreptimeOtlpSignalForwarder( + new GreptimeProperties(true, "127.0.0.1:4001", "http://127.0.0.1:4000", + "public", "greptime", "secret"), restTemplate); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String json = "{\"resourceSpans\":[{\"scopeSpans\":[{\"spans\":[{" + + "\"traceId\":\"0123456789abcdef0123456789abcdef\"," + + "\"spanId\":\"0123456789abcdef\"," + + "\"parentSpanId\":\"fedcba9876543210\",\"name\":\"probe\"}]}]}]}"; + + forwarder.forwardHttp("traces", json.getBytes(StandardCharsets.UTF_8), headers); + + ArgumentCaptor> request = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange(eq("http://127.0.0.1:4000/v1/otlp/v1/traces"), eq(HttpMethod.POST), + request.capture(), eq(byte[].class)); + ExportTraceServiceRequest parsed = ExportTraceServiceRequest.parseFrom(request.getValue().getBody()); + assertThat(parsed.getResourceSpans(0).getScopeSpans(0).getSpans(0).getTraceId().toByteArray()) + .containsExactly(java.util.HexFormat.of().parseHex("0123456789abcdef0123456789abcdef")); + assertThat(parsed.getResourceSpans(0).getScopeSpans(0).getSpans(0).getParentSpanId().toByteArray()) + .containsExactly(java.util.HexFormat.of().parseHex("fedcba9876543210")); + } + + @Test + void shouldRejectMalformedJsonBeforeCallingGreptime() { + GreptimeOtlpSignalForwarder forwarder = new GreptimeOtlpSignalForwarder( + new GreptimeProperties(true, "127.0.0.1:4001", "http://127.0.0.1:4000", + "public", "greptime", "secret"), restTemplate); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + assertThatThrownBy(() -> forwarder.forwardHttp("metrics", "{".getBytes(StandardCharsets.UTF_8), headers)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Malformed OTLP JSON payload"); + verifyNoInteractions(restTemplate); + } + + @Test + void shouldRejectMalformedProtobufBeforeCallingGreptime() { + GreptimeOtlpSignalForwarder forwarder = new GreptimeOtlpSignalForwarder( + new GreptimeProperties(true, "127.0.0.1:4001", "http://127.0.0.1:4000", + "public", "greptime", "secret"), restTemplate); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/x-protobuf")); + + assertThatThrownBy(() -> forwarder.forwardHttp("traces", new byte[] {(byte) 0xff}, headers)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Malformed OTLP traces protobuf payload"); + verifyNoInteractions(restTemplate); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/GreptimeThreeSignalQueryServiceTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/GreptimeThreeSignalQueryServiceTest.java new file mode 100644 index 00000000000..5a377ac1969 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/GreptimeThreeSignalQueryServiceTest.java @@ -0,0 +1,182 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.log.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import org.apache.hertzbeat.warehouse.db.GreptimeSqlQueryExecutor; +import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +/** Unit tests for Greptime-backed entity-free queries. */ +@ExtendWith(MockitoExtension.class) +class GreptimeThreeSignalQueryServiceTest { + + @Mock + private GreptimeSqlQueryExecutor sqlQueryExecutor; + + @Mock + private RestTemplate restTemplate; + + private GreptimeThreeSignalQueryService service; + + @BeforeEach + void setUp() { + service = new GreptimeThreeSignalQueryService( + new GreptimeProperties(true, "127.0.0.1:4001", "http://127.0.0.1:4000", + "public", "greptime", "secret"), + sqlQueryExecutor, + restTemplate); + } + + @Test + void shouldBuildResourceScopedPromqlWithoutEntityLabels() { + when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) + .thenReturn(ResponseEntity.ok(Map.of("data", Map.of("result", List.of())))); + + service.queryMetrics("http_server_duration", 1000L, 2000L, "checkout", "payments", "prod", + "http.method=GET", "service_name", "sum", 60, "GET /cart"); + + ArgumentCaptor uri = ArgumentCaptor.forClass(URI.class); + ArgumentCaptor> request = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange(uri.capture(), eq(HttpMethod.GET), request.capture(), eq(Map.class)); + assertThat(uri.getValue().toString()) + .contains("service_name", "checkout", "service_namespace", "payments") + .contains("deployment_environment_name", "prod") + .doesNotContain("entityId", "entityType", "hertzbeat_entity"); + assertThat(request.getValue().getHeaders().getFirst("Authorization")).startsWith("Basic "); + } + + @Test + void shouldAllowMetricsQueryWithoutAggregation() { + when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) + .thenReturn(ResponseEntity.ok(Map.of("data", Map.of("result", List.of())))); + + var result = service.queryMetrics("http_server_duration", 1000L, 2000L, null, null, null, + null, null, null, 60, null); + + assertThat(result.query()).isEqualTo("http_server_duration"); + } + + @Test + void shouldPreserveAdvancedPromql() { + when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) + .thenReturn(ResponseEntity.ok(Map.of("data", Map.of("result", List.of())))); + + var result = service.queryMetrics("sum by (service_name) (rate(http_requests_total[5m]))", + 1000L, 2000L, null, null, null, null, null, null, 60, null); + + assertThat(result.query()).isEqualTo("sum by (service_name) (rate(http_requests_total[5m]))"); + } + + @Test + void shouldExposeInvalidPromqlAsBadRequest() { + when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) + .thenReturn(ResponseEntity.ok(Map.of("status", "error", "error", "invalid parameter query"))); + + assertThatThrownBy(() -> service.queryMetrics("sum(", 1000L, 2000L, null, null, null, + null, null, null, 60, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid PromQL query"); + } + + @Test + void shouldBuildTraceSqlWithoutEntityOrWorkspacePredicates() { + when(sqlQueryExecutor.execute(any())).thenReturn(List.of()); + + service.queryTraces(1000L, 2000L, "trace-1", true, "checkout", "payments", "prod", + "GET /cart", 1L, 100L, 0, 20); + + ArgumentCaptor sql = ArgumentCaptor.forClass(String.class); + verify(sqlQueryExecutor).execute(sql.capture()); + assertThat(sql.getValue()) + .contains("trace_id = 'trace-1'", "service_name = 'checkout'", + "\"resource_attributes.service.namespace\"", "\"resource_attributes.deployment.environment.name\"", + "CASE WHEN parent_span_id IS NULL OR parent_span_id = '' THEN service_name END", + "CASE WHEN parent_span_id IS NULL OR parent_span_id = '' THEN span_name END", + "duration_nano >= 1000000", "duration_nano <= 100000000") + .doesNotContainIgnoringCase("entity") + .doesNotContainIgnoringCase("workspace"); + } + + @Test + void shouldRejectInvalidQueryRangesBeforeAccessingStorage() { + assertThatThrownBy(() -> service.queryMetrics("up", 2000L, 1000L, null, null, null, + null, null, null, 30, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Query start time must not exceed end time"); + assertThatThrownBy(() -> service.queryTraces(1000L, 2000L, null, false, null, null, null, + null, 20L, 10L, 0, 20)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Minimum trace duration must not exceed maximum duration"); + + verifyNoInteractions(restTemplate, sqlQueryExecutor); + } + + @Test + void shouldBuildTraceDurationFromTheFullSpanCoverage() { + Map root = new java.util.HashMap<>(spanRow("trace-1", "root", "", 1000L, 1_000_000_000L)); + root.put("span_events", "[{\"name\":\"retry.scheduled\",\"time\":\"2026-07-11T00:00:00Z\"," + + "\"attributes\":{\"retry.attempt\":1}}]"); + Map child = spanRow("trace-1", "child", "root", 1800L, 400_000_000L); + when(sqlQueryExecutor.execute(any())).thenReturn(List.of(root, child)); + + var detail = service.traceDetail("trace-1"); + + assertThat(detail.summary().durationNanos()).isEqualTo(1_200_000_000L); + assertThat(detail.spans()).hasSize(2); + assertThat(detail.spans().getFirst().spanEvents()).singleElement().satisfies(event -> { + assertThat(event.name()).isEqualTo("retry.scheduled"); + assertThat(event.attributes()).containsEntry("retry.attempt", 1); + }); + } + + private Map spanRow(String traceId, String spanId, String parentSpanId, + long timestamp, long durationNanos) { + return Map.ofEntries( + Map.entry("trace_id", traceId), + Map.entry("span_id", spanId), + Map.entry("parent_span_id", parentSpanId), + Map.entry("span_name", "GET /cart"), + Map.entry("service_name", "checkout"), + Map.entry("span_status_code", "STATUS_CODE_OK"), + Map.entry("span_kind", "SPAN_KIND_SERVER"), + Map.entry("span_status_message", ""), + Map.entry("duration_nano", durationNanos), + Map.entry("timestamp", timestamp), + Map.entry("span_events", "[]")); + } +} diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/RestTemplateConfig.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/RestTemplateConfig.java index 8930dd35cc4..79507648ad1 100644 --- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/RestTemplateConfig.java +++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/RestTemplateConfig.java @@ -21,8 +21,11 @@ import java.time.Duration; import java.util.Collections; import org.apache.hertzbeat.common.constants.NetworkConstants; +import org.apache.hertzbeat.warehouse.constants.WarehouseConstants; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; @@ -37,23 +40,69 @@ public class RestTemplateConfig { * Create RestTemplate with JDK native request factory and custom interceptors. */ @Bean - public RestTemplate restTemplate(ClientHttpRequestFactory factory) { + @Primary + public RestTemplate restTemplate( + @Qualifier("clientHttpRequestFactory") ClientHttpRequestFactory factory) { + return createRestTemplate(factory); + } + + @Bean(WarehouseConstants.GREPTIME_QUERY_REST_TEMPLATE) + public RestTemplate greptimeQueryRestTemplate( + @Qualifier("greptimeQueryClientHttpRequestFactory") ClientHttpRequestFactory factory) { + return createRestTemplate(factory); + } + + @Bean(WarehouseConstants.GREPTIME_WRITE_REST_TEMPLATE) + public RestTemplate greptimeWriteRestTemplate( + @Qualifier("greptimeWriteClientHttpRequestFactory") ClientHttpRequestFactory factory) { + return createRestTemplate(factory); + } + + @Bean(WarehouseConstants.GREPTIME_INIT_REST_TEMPLATE) + public RestTemplate greptimeInitRestTemplate( + @Qualifier("greptimeInitClientHttpRequestFactory") ClientHttpRequestFactory factory) { + return createRestTemplate(factory); + } + + private RestTemplate createRestTemplate(ClientHttpRequestFactory factory) { RestTemplate restTemplate = new RestTemplate(factory); restTemplate.setInterceptors(Collections.singletonList(new HeaderRequestInterceptor())); return restTemplate; } @Bean + @Primary public ClientHttpRequestFactory clientHttpRequestFactory() { + return createRequestFactory(NetworkConstants.HttpClientConstants.CONNECT_TIMEOUT, + NetworkConstants.HttpClientConstants.READ_TIMEOUT); + } + + @Bean("greptimeQueryClientHttpRequestFactory") + public ClientHttpRequestFactory greptimeQueryClientHttpRequestFactory() { + return createRequestFactory(NetworkConstants.HttpClientConstants.GREPTIME_QUERY_CONNECT_TIMEOUT, + NetworkConstants.HttpClientConstants.GREPTIME_QUERY_READ_TIMEOUT); + } + + @Bean("greptimeWriteClientHttpRequestFactory") + public ClientHttpRequestFactory greptimeWriteClientHttpRequestFactory() { + return createRequestFactory(NetworkConstants.HttpClientConstants.GREPTIME_WRITE_CONNECT_TIMEOUT, + NetworkConstants.HttpClientConstants.GREPTIME_WRITE_READ_TIMEOUT); + } + + @Bean("greptimeInitClientHttpRequestFactory") + public ClientHttpRequestFactory greptimeInitClientHttpRequestFactory() { + return createRequestFactory(NetworkConstants.HttpClientConstants.GREPTIME_INIT_CONNECT_TIMEOUT, + NetworkConstants.HttpClientConstants.GREPTIME_INIT_READ_TIMEOUT); + } + + private ClientHttpRequestFactory createRequestFactory(Duration connectTimeout, Duration readTimeout) { HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(NetworkConstants.HttpClientConstants.CONNECT_TIME_OUT)) + .connectTimeout(connectTimeout) .followRedirects(HttpClient.Redirect.NORMAL) .build(); JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient); - - factory.setReadTimeout(Duration.ofSeconds(NetworkConstants.HttpClientConstants.READ_TIME_OUT)); - + factory.setReadTimeout(readTimeout); return factory; } diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/ManagerOtlpAccessTokenValidator.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/ManagerOtlpAccessTokenValidator.java new file mode 100644 index 00000000000..ad5aa279e56 --- /dev/null +++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/ManagerOtlpAccessTokenValidator.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.manager.service.impl; + +import com.usthe.sureness.util.JsonWebTokenUtil; +import io.jsonwebtoken.Claims; +import java.util.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.hertzbeat.common.security.OtlpAccessTokenValidator; +import org.apache.hertzbeat.manager.service.AccountService; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +/** Validates managed HertzBeat API tokens used by the OTLP gRPC listener. */ +@Service +@RequiredArgsConstructor +public class ManagerOtlpAccessTokenValidator implements OtlpAccessTokenValidator { + + private final AccountService accountService; + + @Override + public String validate(String token) { + if (!StringUtils.hasText(token)) { + return "Missing OTLP access token"; + } + try { + Claims claims = JsonWebTokenUtil.parseJwt(token); + if (Boolean.TRUE.equals(claims.get("refresh", Boolean.class))) { + return "Refresh token is not allowed"; + } + if (Boolean.TRUE.equals(claims.get(AccountServiceImpl.CLAIM_MANAGED, Boolean.class))) { + String rejectReason = accountService.checkTokenStatus(token); + if (rejectReason != null) { + return rejectReason; + } + } + @SuppressWarnings("unchecked") + List roles = claims.get("roles", List.class); + String rejectReason = accountService.checkManagedTokenAccess(claims.getSubject(), + roles == null ? Collections.emptyList() : roles); + if (rejectReason == null) { + accountService.touchTokenLastUsedTime(token); + } + return rejectReason; + } catch (Exception exception) { + return "Invalid OTLP access token"; + } + } +} diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/impl/ManagerOtlpAccessTokenValidatorTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/impl/ManagerOtlpAccessTokenValidatorTest.java new file mode 100644 index 00000000000..d65a4473059 --- /dev/null +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/impl/ManagerOtlpAccessTokenValidatorTest.java @@ -0,0 +1,80 @@ +/* + * 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.manager.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.usthe.sureness.util.JsonWebTokenUtil; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.hertzbeat.manager.service.AccountService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ManagerOtlpAccessTokenValidatorTest { + + @Mock + private AccountService accountService; + + private ManagerOtlpAccessTokenValidator validator; + + @BeforeEach + void setUp() { + JsonWebTokenUtil.setDefaultSecretKey("CyaFv0bwq2Eik0jdrKUtsA6bx3sDJeFV643R" + + "LnfKefTjsIfJLBa2YkhEqEGtcHDTNe4CU6+98tVt4bisXQ13rbN0oxhUZR73M6EByXIO+SV5" + + "dKhaX0csgOCTlCxq20yhmUea6H6JIpSE2Rwp"); + validator = new ManagerOtlpAccessTokenValidator(accountService); + } + + @Test + void shouldRejectRefreshTokensBeforeAccountAccess() { + String token = JsonWebTokenUtil.issueJwt("admin", 3600L, new HashMap<>(Map.of("refresh", true))); + + assertThat(validator.validate(token)).isEqualTo("Refresh token is not allowed"); + verify(accountService, never()).touchTokenLastUsedTime(token); + } + + @Test + void shouldValidateManagedAccessTokenAndRecordUsage() { + String token = JsonWebTokenUtil.issueJwt("admin", 3600L, List.of("admin"), + new HashMap<>(Map.of(AccountServiceImpl.CLAIM_MANAGED, true))); + when(accountService.checkManagedTokenAccess("admin", List.of("admin"))).thenReturn(null); + + assertThat(validator.validate(token)).isNull(); + verify(accountService).checkTokenStatus(token); + verify(accountService).touchTokenLastUsedTime(token); + } + + @Test + void shouldRejectRevokedManagedTokenWithoutUpdatingUsage() { + String token = JsonWebTokenUtil.issueJwt("admin", 3600L, List.of("admin"), + new HashMap<>(Map.of(AccountServiceImpl.CLAIM_MANAGED, true))); + when(accountService.checkTokenStatus(token)).thenReturn("Token has been revoked"); + + assertThat(validator.validate(token)).isEqualTo("Token has been revoked"); + verify(accountService, never()).touchTokenLastUsedTime(token); + } +} diff --git a/hertzbeat-manager/src/test/resources/sureness.yml b/hertzbeat-manager/src/test/resources/sureness.yml index f04c5a2f7d3..64228f740de 100644 --- a/hertzbeat-manager/src/test/resources/sureness.yml +++ b/hertzbeat-manager/src/test/resources/sureness.yml @@ -71,6 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin,user] - /api/logs/ingest/**===post===[admin,user] + - /api/otlp/**===post===[admin,user] + - /api/ingestion/otlp/**===get===[admin,user,guest] + - /api/logs/**===get===[admin,user,guest] + - /api/traces/**===get===[admin,user,guest] # config the resource restful api that need bypass auth protection # rule: api===method diff --git a/hertzbeat-startup/src/main/resources/sureness.yml b/hertzbeat-startup/src/main/resources/sureness.yml index d366fe8a023..b4e8e33d86c 100644 --- a/hertzbeat-startup/src/main/resources/sureness.yml +++ b/hertzbeat-startup/src/main/resources/sureness.yml @@ -71,6 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin] - /api/logs/ingest/**===post===[admin,user] + - /api/otlp/**===post===[admin,user] + - /api/ingestion/otlp/**===get===[admin,user,guest] + - /api/logs/**===get===[admin,user,guest] + - /api/traces/**===get===[admin,user,guest] - /api/account/token===get===[admin] - /api/account/token/**===post===[admin] - /api/account/token/**===delete===[admin] @@ -98,6 +102,9 @@ excludedResource: - /passport/**===get - /status/**===get - /log/**===get + - /observability/**===get + - /metrics/**===get + - /trace/**===get - /**/*.html===get - /**/*.js===get - /**/*.css===get diff --git a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/ContextTest.java b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/ContextTest.java index 902c676c619..19401144d7b 100644 --- a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/ContextTest.java +++ b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/ContextTest.java @@ -43,6 +43,8 @@ import org.apache.hertzbeat.common.queue.impl.InMemoryCommonDataQueue; import org.apache.hertzbeat.common.support.SpringContextHolder; import org.apache.hertzbeat.alert.service.impl.TencentSmsClientImpl; +import org.apache.hertzbeat.log.controller.OtlpSignalController; +import org.apache.hertzbeat.log.controller.ThreeSignalQueryController; import org.apache.hertzbeat.warehouse.WarehouseWorkerPool; import org.apache.hertzbeat.warehouse.controller.MetricsDataController; import org.apache.hertzbeat.warehouse.store.history.tsdb.iotdb.IotDbDataStorage; @@ -105,6 +107,10 @@ void testAutoImport() { assertThrows(NoSuchBeanDefinitionException.class, () -> ctx.getBean(IotDbDataStorage.class)); assertNotNull(ctx.getBean(MetricsDataController.class)); + + // Greptime-only signal controllers must not break the default application context. + assertThrows(NoSuchBeanDefinitionException.class, () -> ctx.getBean(OtlpSignalController.class)); + assertThrows(NoSuchBeanDefinitionException.class, () -> ctx.getBean(ThreeSignalQueryController.class)); } } diff --git a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/constants/WarehouseConstants.java b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/constants/WarehouseConstants.java index db73381be6b..fc0c325e406 100644 --- a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/constants/WarehouseConstants.java +++ b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/constants/WarehouseConstants.java @@ -72,4 +72,10 @@ interface RealTimeName { String LOG_TABLE_NAME = "hertzbeat_logs"; + String GREPTIME_QUERY_REST_TEMPLATE = "greptimeQueryRestTemplate"; + + String GREPTIME_WRITE_REST_TEMPLATE = "greptimeWriteRestTemplate"; + + String GREPTIME_INIT_REST_TEMPLATE = "greptimeInitRestTemplate"; + } 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 1a877c83766..383bbce7855 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 @@ -20,6 +20,8 @@ import lombok.extern.slf4j.Slf4j; import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties; +import org.apache.hertzbeat.warehouse.constants.WarehouseConstants; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; @@ -39,7 +41,9 @@ public class GreptimePromqlQueryExecutor extends PromqlQueryExecutor { private final GreptimeProperties greptimeProperties; - public GreptimePromqlQueryExecutor(GreptimeProperties greptimeProperties, RestTemplate restTemplate) { + public GreptimePromqlQueryExecutor(GreptimeProperties greptimeProperties, + @Qualifier(WarehouseConstants.GREPTIME_QUERY_REST_TEMPLATE) + RestTemplate restTemplate) { super(restTemplate, new HttpPromqlProperties(greptimeProperties.httpEndpoint() + QUERY_PATH, greptimeProperties.username(), greptimeProperties.password())); this.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 index 42bbb2efc99..0afcc8fe20a 100644 --- 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 @@ -23,8 +23,11 @@ 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.common.support.exception.StorageUnavailableException; import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties; import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeSqlQueryContent; +import org.apache.hertzbeat.warehouse.constants.WarehouseConstants; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpEntity; @@ -54,7 +57,9 @@ public class GreptimeSqlQueryExecutor extends SqlQueryExecutor { private final GreptimeProperties greptimeProperties; - public GreptimeSqlQueryExecutor(GreptimeProperties greptimeProperties, RestTemplate restTemplate) { + public GreptimeSqlQueryExecutor(GreptimeProperties greptimeProperties, + @Qualifier(WarehouseConstants.GREPTIME_QUERY_REST_TEMPLATE) + RestTemplate restTemplate) { super(restTemplate, new SqlQueryExecutor.HttpSqlProperties(greptimeProperties.httpEndpoint() + QUERY_PATH, greptimeProperties.username(), greptimeProperties.password())); this.greptimeProperties = greptimeProperties; @@ -88,7 +93,7 @@ public List> execute(String queryString) { HttpMethod.POST, httpEntity, GreptimeSqlQueryContent.class); } catch (Exception e) { log.error("Exception occurred while querying GreptimeDB SQL: {}", e.getMessage(), e); - throw new RuntimeException("Failed to execute GreptimeDB SQL query", e); + throw new StorageUnavailableException("GreptimeDB storage is unavailable", e); } if (responseEntity.getStatusCode().is2xxSuccessful()) { diff --git a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/HistoryDataReader.java b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/HistoryDataReader.java index 567e27177e2..468e6e0557a 100644 --- a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/HistoryDataReader.java +++ b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/HistoryDataReader.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Map; import org.apache.hertzbeat.common.entity.dto.Value; +import org.apache.hertzbeat.common.entity.dto.observability.LogQueryFilter; import org.apache.hertzbeat.common.entity.log.LogEntry; /** @@ -128,4 +129,26 @@ default long countLogsByMultipleConditions(Long startTime, Long endTime, String String severityText, String searchContent) { throw new UnsupportedOperationException("count logs by multiple conditions is not supported"); } -} \ No newline at end of file + + /** Query the transition workbench using OTLP resource semantics. */ + default List queryObservabilityLogs(LogQueryFilter filter, Integer offset, Integer limit) { + return queryLogsByMultipleConditionsWithPagination(filter.start(), filter.end(), filter.traceId(), + filter.spanId(), filter.severityNumber(), filter.severityText(), filter.search(), offset, limit); + } + + /** Count the transition workbench result using OTLP resource semantics. */ + default long countObservabilityLogs(LogQueryFilter filter) { + return countLogsByMultipleConditions(filter.start(), filter.end(), filter.traceId(), filter.spanId(), + filter.severityNumber(), filter.severityText(), filter.search()); + } + + /** Return severity and trace coverage in one storage aggregation. */ + default Map queryLogOverviewAggregate(LogQueryFilter filter) { + throw new UnsupportedOperationException("query log overview aggregate is not supported"); + } + + /** Return log counts grouped by hour in one storage aggregation. */ + default Map queryLogTrendAggregate(LogQueryFilter filter) { + throw new UnsupportedOperationException("query log trend aggregate 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 387698ea1d5..ae953720dea 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 @@ -40,6 +40,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -57,16 +58,19 @@ 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.dto.observability.LogQueryFilter; 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.common.support.exception.StorageUnavailableException; import org.apache.hertzbeat.warehouse.constants.WarehouseConstants; 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.beans.factory.annotation.Qualifier; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -106,7 +110,9 @@ public class GreptimeDbDataStorage extends AbstractHistoryDataStorage { private final GreptimeSqlQueryExecutor greptimeSqlQueryExecutor; - public GreptimeDbDataStorage(GreptimeProperties greptimeProperties, RestTemplate restTemplate, + public GreptimeDbDataStorage(GreptimeProperties greptimeProperties, + @Qualifier(WarehouseConstants.GREPTIME_QUERY_REST_TEMPLATE) + RestTemplate restTemplate, GreptimeSqlQueryExecutor greptimeSqlQueryExecutor) { if (greptimeProperties == null) { log.error("init error, please config Warehouse GreptimeDB props in application.yml"); @@ -514,7 +520,7 @@ public void saveLogData(LogEntry logEntry) { .addField("observed_time_unix_nano", DataType.TimestampNanosecond) .addField("severity_number", DataType.Int32) .addField("severity_text", DataType.String) - .addField("body", DataType.Json) + .addField("body", DataType.String) .addField("trace_id", DataType.String) .addField("span_id", DataType.String) .addField("trace_flags", DataType.Int32) @@ -531,7 +537,7 @@ public void saveLogData(LogEntry logEntry) { logEntry.getObservedTimeUnixNano() != null ? logEntry.getObservedTimeUnixNano() : System.nanoTime(), logEntry.getSeverityNumber(), logEntry.getSeverityText(), - JsonUtil.toJson(logEntry.getBody()), + logBodyAsString(logEntry.getBody()), logEntry.getTraceId(), logEntry.getSpanId(), logEntry.getTraceFlags(), @@ -622,6 +628,151 @@ public long countLogsByMultipleConditions(Long startTime, Long endTime, String t } } + @Override + public List queryObservabilityLogs(LogQueryFilter filter, Integer offset, Integer limit) { + try { + StringBuilder sql = new StringBuilder("SELECT * FROM ").append(LOG_TABLE_NAME); + buildObservabilityWhereConditions(sql, filter); + sql.append(" ORDER BY time_unix_nano DESC"); + if (limit != null && limit > 0) { + sql.append(" LIMIT ").append(Math.min(limit, 200)); + if (offset != null && offset > 0) { + sql.append(" OFFSET ").append(offset); + } + } + return mapRowsToLogEntries(greptimeSqlQueryExecutor.execute(sql.toString())); + } catch (IllegalArgumentException exception) { + throw exception; + } catch (Exception exception) { + throw new StorageUnavailableException("GreptimeDB log storage is unavailable", exception); + } + } + + @Override + public long countObservabilityLogs(LogQueryFilter filter) { + try { + StringBuilder sql = new StringBuilder("SELECT COUNT(*) AS count FROM ").append(LOG_TABLE_NAME); + buildObservabilityWhereConditions(sql, filter); + List> rows = greptimeSqlQueryExecutor.execute(sql.toString()); + return rows.isEmpty() ? 0 : valueAsLong(rows.getFirst(), "count"); + } catch (IllegalArgumentException exception) { + throw exception; + } catch (Exception exception) { + throw new StorageUnavailableException("GreptimeDB log storage is unavailable", exception); + } + } + + @Override + public Map queryLogOverviewAggregate(LogQueryFilter filter) { + try { + StringBuilder sql = new StringBuilder("SELECT COUNT(*) AS total_count, ") + .append("SUM(CASE WHEN severity_number BETWEEN 21 AND 24 THEN 1 ELSE 0 END) AS fatal_count, ") + .append("SUM(CASE WHEN severity_number BETWEEN 17 AND 20 THEN 1 ELSE 0 END) AS error_count, ") + .append("SUM(CASE WHEN severity_number BETWEEN 13 AND 16 THEN 1 ELSE 0 END) AS warn_count, ") + .append("SUM(CASE WHEN severity_number BETWEEN 9 AND 12 THEN 1 ELSE 0 END) AS info_count, ") + .append("SUM(CASE WHEN severity_number BETWEEN 5 AND 8 THEN 1 ELSE 0 END) AS debug_count, ") + .append("SUM(CASE WHEN severity_number BETWEEN 1 AND 4 THEN 1 ELSE 0 END) AS trace_count, ") + .append("SUM(CASE WHEN trace_id IS NOT NULL AND trace_id <> '' THEN 1 ELSE 0 END) AS with_trace, ") + .append("SUM(CASE WHEN span_id IS NOT NULL AND span_id <> '' THEN 1 ELSE 0 END) AS with_span, ") + .append("SUM(CASE WHEN trace_id IS NOT NULL AND trace_id <> '' AND span_id IS NOT NULL ") + .append("AND span_id <> '' THEN 1 ELSE 0 END) AS with_both FROM ") + .append(LOG_TABLE_NAME); + buildObservabilityWhereConditions(sql, filter); + List> rows = greptimeSqlQueryExecutor.execute(sql.toString()); + if (rows.isEmpty()) { + return emptyLogOverview(); + } + Map row = rows.getFirst(); + long total = valueAsLong(row, "total_count"); + Map coverage = new HashMap<>(); + coverage.put("withTrace", valueAsLong(row, "with_trace")); + coverage.put("withoutTrace", total - coverage.get("withTrace")); + coverage.put("withSpan", valueAsLong(row, "with_span")); + coverage.put("withBothTraceAndSpan", valueAsLong(row, "with_both")); + Map overview = new HashMap<>(); + overview.put("totalCount", total); + overview.put("fatalCount", valueAsLong(row, "fatal_count")); + overview.put("errorCount", valueAsLong(row, "error_count")); + overview.put("warnCount", valueAsLong(row, "warn_count")); + overview.put("infoCount", valueAsLong(row, "info_count")); + overview.put("debugCount", valueAsLong(row, "debug_count")); + overview.put("traceCount", valueAsLong(row, "trace_count")); + overview.put("traceCoverage", coverage); + return overview; + } catch (IllegalArgumentException exception) { + throw exception; + } catch (Exception exception) { + throw new StorageUnavailableException("GreptimeDB log storage is unavailable", exception); + } + } + + @Override + public Map queryLogTrendAggregate(LogQueryFilter filter) { + try { + StringBuilder sql = new StringBuilder("SELECT date_bin(INTERVAL '1 hour', time_unix_nano) AS bucket, ") + .append("COUNT(*) AS count FROM ").append(LOG_TABLE_NAME); + buildObservabilityWhereConditions(sql, filter); + sql.append(" GROUP BY bucket ORDER BY bucket ASC"); + Map trend = new LinkedHashMap<>(); + for (Map row : greptimeSqlQueryExecutor.execute(sql.toString())) { + trend.put(String.valueOf(row.get("bucket")), valueAsLong(row, "count")); + } + return trend; + } catch (IllegalArgumentException exception) { + throw exception; + } catch (Exception exception) { + throw new StorageUnavailableException("GreptimeDB log storage is unavailable", exception); + } + } + + private Map emptyLogOverview() { + Map coverage = Map.of("withTrace", 0L, "withoutTrace", 0L, + "withSpan", 0L, "withBothTraceAndSpan", 0L); + Map overview = new HashMap<>(); + for (String key : List.of("totalCount", "fatalCount", "errorCount", "warnCount", "infoCount", + "debugCount", "traceCount")) { + overview.put(key, 0L); + } + overview.put("traceCoverage", coverage); + return overview; + } + + private static long valueAsLong(Map row, String key) { + Long value = castToLong(row.get(key)); + return value == null ? 0 : value; + } + + private void buildObservabilityWhereConditions(StringBuilder sql, LogQueryFilter filter) { + buildWhereConditions(sql, filter.start(), filter.end(), filter.traceId(), filter.spanId(), + filter.severityNumber(), filter.severityText(), filter.search()); + List resourceConditions = new ArrayList<>(); + addJsonCondition(resourceConditions, "service.name", filter.serviceName()); + addJsonCondition(resourceConditions, "service.namespace", filter.serviceNamespace()); + addJsonCondition(resourceConditions, "deployment.environment.name", filter.environment()); + if (StringUtils.hasText(filter.resourceFilter())) { + for (ResourceFilterExpression.Clause clause : ResourceFilterExpression.parse(filter.resourceFilter())) { + String attribute = "json_get_string(resource, '$[\"" + clause.key() + "\"]')"; + switch (clause.operator()) { + case EQUALS -> resourceConditions.add(attribute + " = '" + safeString(clause.value()) + "'"); + case NOT_EQUALS -> resourceConditions.add(attribute + " <> '" + safeString(clause.value()) + "'"); + case EXISTS -> resourceConditions.add(attribute + " IS NOT NULL"); + case NOT_EXISTS -> resourceConditions.add(attribute + " IS NULL"); + default -> throw new IllegalArgumentException("Invalid resource filter expression"); + } + } + } + if (!resourceConditions.isEmpty()) { + sql.append(sql.indexOf(" WHERE ") >= 0 ? " AND " : " WHERE ") + .append(String.join(" AND ", resourceConditions)); + } + } + + private void addJsonCondition(List conditions, String key, String value) { + if (StringUtils.hasText(value)) { + conditions.add("json_get_string(resource, '$[\"" + key + "\"]') = '" + safeString(value) + "'"); + } + } + private static long msToNs(Long ms) { return ms * 1_000_000L; } @@ -725,6 +876,13 @@ private List mapRowsToLogEntries(List> rows) { return list; } + private static String logBodyAsString(Object body) { + if (body == null) { + return null; + } + return body instanceof String value ? value : JsonUtil.toJson(body); + } + private static Object parseJsonMaybe(Object value) { if (value == null) return null; if (value instanceof Map) return value; @@ -819,7 +977,7 @@ private void doSaveLogBatch(List logEntries) { .addField("observed_time_unix_nano", DataType.TimestampNanosecond) .addField("severity_number", DataType.Int32) .addField("severity_text", DataType.String) - .addField("body", DataType.Json) + .addField("body", DataType.String) .addField("trace_id", DataType.String) .addField("span_id", DataType.String) .addField("trace_flags", DataType.Int32) @@ -836,7 +994,7 @@ private void doSaveLogBatch(List logEntries) { logEntry.getObservedTimeUnixNano() != null ? logEntry.getObservedTimeUnixNano() : System.nanoTime(), logEntry.getSeverityNumber(), logEntry.getSeverityText(), - JsonUtil.toJson(logEntry.getBody()), + logBodyAsString(logEntry.getBody()), logEntry.getTraceId(), logEntry.getSpanId(), logEntry.getTraceFlags(), diff --git a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/ResourceFilterExpression.java b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/ResourceFilterExpression.java new file mode 100644 index 00000000000..9ce8a5b7834 --- /dev/null +++ b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/ResourceFilterExpression.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.store.history.tsdb.greptime; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Parses the bounded resource attribute expression accepted by log queries. */ +final class ResourceFilterExpression { + + private static final Pattern CLAUSE = Pattern.compile( + "^(?:resource\\.)?([A-Za-z0-9_.-]+)\\s*(=|!=|(?i:EXISTS)|(?i:NOT\\s+EXISTS))(?:\\s+(.+))?$"); + + private ResourceFilterExpression() { + } + + static List parse(String expression) { + List clauses = new ArrayList<>(); + for (String value : splitClauses(expression)) { + Matcher matcher = CLAUSE.matcher(value.trim()); + if (!matcher.matches()) { + throw invalidExpression(); + } + Operator operator = Operator.from(matcher.group(2)); + String operand = matcher.group(3); + if (operator.requiresValue()) { + if (operand == null || operand.isBlank()) { + throw invalidExpression(); + } + operand = unquote(operand.trim()); + if (operand.isBlank()) { + throw invalidExpression(); + } + } else if (operand != null && !operand.isBlank()) { + throw invalidExpression(); + } + clauses.add(new Clause(matcher.group(1), operator, operand)); + } + if (clauses.isEmpty()) { + throw invalidExpression(); + } + return clauses; + } + + private static List splitClauses(String expression) { + List clauses = new ArrayList<>(); + int start = 0; + char quote = 0; + for (int index = 0; index < expression.length(); index++) { + char current = expression.charAt(index); + if ((current == '\'' || current == '"') && (index == 0 || expression.charAt(index - 1) != '\\')) { + quote = quote == 0 ? current : quote == current ? 0 : quote; + } + if (quote == 0 && index + 3 <= expression.length() + && expression.regionMatches(true, index, "AND", 0, 3) + && isBoundary(expression, index - 1) && isBoundary(expression, index + 3)) { + clauses.add(expression.substring(start, index)); + start = index + 3; + index += 2; + } + } + if (quote != 0) { + throw invalidExpression(); + } + clauses.add(expression.substring(start)); + return clauses; + } + + private static boolean isBoundary(String value, int index) { + return index < 0 || index >= value.length() || Character.isWhitespace(value.charAt(index)); + } + + private static String unquote(String value) { + if (value.length() >= 2 && ((value.startsWith("\"") && value.endsWith("\"")) + || (value.startsWith("'") && value.endsWith("'")))) { + return value.substring(1, value.length() - 1); + } + if (value.indexOf(' ') >= 0) { + throw invalidExpression(); + } + return value; + } + + private static IllegalArgumentException invalidExpression() { + return new IllegalArgumentException("Invalid resource filter expression"); + } + + record Clause(String key, Operator operator, String value) { + } + + enum Operator { + EQUALS, + NOT_EQUALS, + EXISTS, + NOT_EXISTS; + + static Operator from(String value) { + return switch (value.replaceAll("\\s+", " ").toUpperCase(Locale.ROOT)) { + case "=" -> EQUALS; + case "!=" -> NOT_EQUALS; + case "EXISTS" -> EXISTS; + case "NOT EXISTS" -> NOT_EXISTS; + default -> throw invalidExpression(); + }; + } + + boolean requiresValue() { + return this == EQUALS || this == NOT_EQUALS; + } + } +} diff --git a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/influxdb/InfluxdbDataStorage.java b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/influxdb/InfluxdbDataStorage.java index f65d6673aa5..0cade5b80b6 100644 --- a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/influxdb/InfluxdbDataStorage.java +++ b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/influxdb/InfluxdbDataStorage.java @@ -88,13 +88,13 @@ public InfluxdbDataStorage(InfluxdbProperties influxdbProperties) { public void initInfluxDb(InfluxdbProperties influxdbProperties) { var client = new OkHttpClient.Builder() - .readTimeout(NetworkConstants.HttpClientConstants.READ_TIME_OUT, TimeUnit.SECONDS) - .writeTimeout(NetworkConstants.HttpClientConstants.WRITE_TIME_OUT, TimeUnit.SECONDS) - .connectTimeout(NetworkConstants.HttpClientConstants.CONNECT_TIME_OUT, TimeUnit.SECONDS) + .readTimeout(NetworkConstants.HttpClientConstants.READ_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) + .writeTimeout(NetworkConstants.HttpClientConstants.WRITE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) + .connectTimeout(NetworkConstants.HttpClientConstants.CONNECT_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) .connectionPool(new ConnectionPool( NetworkConstants.HttpClientConstants.MAX_IDLE_CONNECTIONS, - NetworkConstants.HttpClientConstants.KEEP_ALIVE_TIMEOUT, - TimeUnit.SECONDS) + NetworkConstants.HttpClientConstants.KEEP_ALIVE_TIMEOUT.toMillis(), + TimeUnit.MILLISECONDS) ).sslSocketFactory(defaultSslSocketFactory(), defaultTrustManager()) .hostnameVerifier(noopHostnameVerifier()) .retryOnConnectionFailure(true); diff --git a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbDataStorage.java b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbDataStorage.java index 801d6352950..93f5b017f24 100644 --- a/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbDataStorage.java +++ b/hertzbeat-warehouse/src/main/java/org/apache/hertzbeat/warehouse/store/history/tsdb/questdb/QuestdbDataStorage.java @@ -102,13 +102,13 @@ public void initQuestDb(QuestdbProperties questdbProperties) { this.queryBaseUrl = "http://" + host + ":" + queryPort + "/exec?query="; this.client = new OkHttpClient.Builder() - .readTimeout(NetworkConstants.HttpClientConstants.READ_TIME_OUT, TimeUnit.SECONDS) - .writeTimeout(NetworkConstants.HttpClientConstants.WRITE_TIME_OUT, TimeUnit.SECONDS) - .connectTimeout(NetworkConstants.HttpClientConstants.CONNECT_TIME_OUT, TimeUnit.SECONDS) + .readTimeout(NetworkConstants.HttpClientConstants.READ_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) + .writeTimeout(NetworkConstants.HttpClientConstants.WRITE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) + .connectTimeout(NetworkConstants.HttpClientConstants.CONNECT_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS) .connectionPool(new ConnectionPool( NetworkConstants.HttpClientConstants.MAX_IDLE_CONNECTIONS, - NetworkConstants.HttpClientConstants.KEEP_ALIVE_TIMEOUT, - TimeUnit.SECONDS) + NetworkConstants.HttpClientConstants.KEEP_ALIVE_TIMEOUT.toMillis(), + TimeUnit.MILLISECONDS) ).sslSocketFactory(defaultSslSocketFactory(), defaultTrustManager()) .hostnameVerifier(noopHostnameVerifier()) .retryOnConnectionFailure(true) @@ -411,4 +411,4 @@ public void destroy() throws Exception { this.client.dispatcher().executorService().shutdown(); } } -} \ No newline at end of file +} diff --git a/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/ResourceFilterExpressionTest.java b/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/ResourceFilterExpressionTest.java new file mode 100644 index 00000000000..a4daaba443b --- /dev/null +++ b/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/ResourceFilterExpressionTest.java @@ -0,0 +1,58 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class ResourceFilterExpressionTest { + + @Test + void shouldParseMultipleResourceClauses() { + var clauses = ResourceFilterExpression.parse( + "resource.cloud.region = \"ap-southeast-1\" AND service.version != dev AND pod.name EXISTS"); + + assertThat(clauses).extracting(ResourceFilterExpression.Clause::key) + .containsExactly("cloud.region", "service.version", "pod.name"); + assertThat(clauses).extracting(ResourceFilterExpression.Clause::operator) + .containsExactly(ResourceFilterExpression.Operator.EQUALS, + ResourceFilterExpression.Operator.NOT_EQUALS, ResourceFilterExpression.Operator.EXISTS); + } + + @Test + void shouldKeepAndInsideQuotedAttributeValue() { + var clauses = ResourceFilterExpression.parse( + "service.version = \"blue AND green\" AND k8s.pod.name NOT EXISTS"); + + assertThat(clauses).hasSize(2); + assertThat(clauses.getFirst().value()).isEqualTo("blue AND green"); + assertThat(clauses.getLast().operator()).isEqualTo(ResourceFilterExpression.Operator.NOT_EXISTS); + } + + @Test + void shouldRejectInvalidResourceExpression() { + assertThatThrownBy(() -> ResourceFilterExpression.parse("cloud.region ~~ prod")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid resource filter expression"); + assertThatThrownBy(() -> ResourceFilterExpression.parse("service.name = \"unterminated")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid resource filter expression"); + } +} diff --git a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml index 282e93928a1..93d75293107 100644 --- a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml +++ b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml @@ -71,6 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin] - /api/logs/ingest/**===post===[admin,user] + - /api/otlp/**===post===[admin,user] + - /api/ingestion/otlp/**===get===[admin,user,guest] + - /api/logs/**===get===[admin,user,guest] + - /api/traces/**===get===[admin,user,guest] # config the resource restful api that need bypass auth protection # rule: api===method @@ -95,6 +99,9 @@ excludedResource: - /passport/**===get - /status/**===get - /log/**===get + - /observability/**===get + - /metrics/**===get + - /trace/**===get - /**/*.html===get - /**/*.js===get - /**/*.css===get diff --git a/script/sureness.yml b/script/sureness.yml index 829bdc73085..23d0f4d1432 100644 --- a/script/sureness.yml +++ b/script/sureness.yml @@ -71,6 +71,10 @@ resourceRole: - /api/chat/**===get===[admin,user] - /api/chat/**===post===[admin] - /api/logs/ingest/**===post===[admin,user] + - /api/otlp/**===post===[admin,user] + - /api/ingestion/otlp/**===get===[admin,user,guest] + - /api/logs/**===get===[admin,user,guest] + - /api/traces/**===get===[admin,user,guest] # config the resource restful api that need bypass auth protection # rule: api===method @@ -95,6 +99,9 @@ excludedResource: - /passport/**===get - /status/**===get - /log/**===get + - /observability/**===get + - /metrics/**===get + - /trace/**===get - /**/*.html===get - /**/*.js===get - /**/*.css===get diff --git a/web-app/angular.json b/web-app/angular.json index 149fdd48230..d7d5b42bd08 100644 --- a/web-app/angular.json +++ b/web-app/angular.json @@ -133,6 +133,11 @@ "tsConfig": "tsconfig.spec.json", "scripts": [], "styles": [], + "stylePreprocessorOptions": { + "includePaths": [ + "node_modules/" + ] + }, "assets": [ "src/assets" ] diff --git a/web-app/src/app/core/interceptor/default.interceptor.ts b/web-app/src/app/core/interceptor/default.interceptor.ts index ecf42b14cd5..aba4d01ad3d 100644 --- a/web-app/src/app/core/interceptor/default.interceptor.ts +++ b/web-app/src/app/core/interceptor/default.interceptor.ts @@ -19,6 +19,7 @@ import { catchError, filter, mergeMap, switchMap, take } from 'rxjs/operators'; import { Message } from '../../pojo/Message'; import { AuthService } from '../../service/auth.service'; import { LocalStorageService } from '../../service/local-storage.service'; +import { SILENT_HTTP_ERROR } from './http-context'; const CODE_MESSAGE: { [key: number]: string } = { 400: 'Request Illegal Content, No Response.', @@ -154,7 +155,7 @@ export class DefaultInterceptor implements HttpInterceptor { res['Accept-Language'] = lang; } let token = this.storageSvc.getAuthorizationToken(); - if (token !== null) { + if (!headers?.has('Authorization') && token !== null) { res['Authorization'] = `Bearer ${token}`; } return res; @@ -176,13 +177,16 @@ export class DefaultInterceptor implements HttpInterceptor { } }), catchError((err: HttpErrorResponse) => { + const silentError = newReq.context.get(SILENT_HTTP_ERROR); // handle failed response and token expired switch (err.status) { case 401: return this.tryRefreshToken(err, newReq, next); case 404: case 500: - this.goTo(`/exception/${err.status}?url=${req.urlWithParams}`); + if (!silentError) { + this.goTo(`/exception/${err.status}?url=${req.urlWithParams}`); + } break; case 400: let resp = new HttpResponse({ @@ -195,7 +199,9 @@ export class DefaultInterceptor implements HttpInterceptor { default: break; } - this.checkStatus(err); + if (!silentError) { + this.checkStatus(err); + } return throwError(err.error); }) ); diff --git a/web-app/src/app/core/interceptor/http-context.ts b/web-app/src/app/core/interceptor/http-context.ts new file mode 100644 index 00000000000..9d0312a3f8b --- /dev/null +++ b/web-app/src/app/core/interceptor/http-context.ts @@ -0,0 +1,22 @@ +/* + * 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 { HttpContextToken } from '@angular/common/http'; + +export const SILENT_HTTP_ERROR = new HttpContextToken(() => false); 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 index d8525db5cde..7503716f561 100644 --- 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 @@ -17,40 +17,124 @@ ~ under the License. --> - - - - -
-
-

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

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

{{ 'observability.integration.title' | i18n }}

+

{{ 'observability.integration.subtitle' | i18n }}

-
-
- -
-

{{ selectedSource.name }}

- + + +
+
+
+ 1 +
+

{{ 'observability.integration.connection' | i18n }}

+
+
+ + + {{ 'observability.integration.protocol' | i18n }} + + + + + + + + + + + {{ 'observability.integration.address' | i18n }} + + + + + + + {{ 'observability.integration.token' | i18n }} + + + + + + + + + + +
+ + service.name + + + + + + environment + + + + +
+ + +

{{ 'observability.integration.detect-hint' | i18n }}

+ +
+
+ + {{ signalLabel(result.signal) }} + {{ result.lastReceived | date : 'yyyy-MM-dd HH:mm:ss' }} + + {{ result.errorKey ? (result.errorKey | i18n) : result.error }} + +
+
+
+ +
+
+ 2 +
+

{{ 'observability.integration.configuration' | i18n }}

+

{{ 'observability.integration.configuration-hint' | i18n }}

+
+
+ + + + + + + + +
+ {{ 'observability.integration.ready-to-copy' | i18n }} +
- - +
{{ snippet(selectedSnippet) }}
+ + +
-
+ 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 index 7ae298020a8..62472a92c23 100644 --- 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 @@ -1,84 +1,220 @@ -@import "~src/styles/theme"; +@import '~src/styles/theme'; -.log-integration-container { +.integration-page { + min-height: 100%; + color: @text-color; +} + +.page-heading { + display: flex; + gap: 24px; + align-items: flex-start; + justify-content: space-between; + padding: 0 0 20px; + border-bottom: 1px solid @border-color-split; + + h1 { + margin: 0 0 6px; + font-weight: 600; + font-size: 24px; + } + + p { + margin: 0; + color: @text-color-secondary; + } +} + +.integration-workspace { + display: grid; + grid-template-columns: minmax(360px, 0.9fr) minmax(440px, 1.1fr); + gap: 0; + margin-top: 20px; + background: @component-background; + border: 1px solid @border-color-split; +} + +.connection-form, +.configuration-output { + padding: 24px; +} + +.connection-form { + border-right: 1px solid @border-color-split; +} + +.section-heading { + display: flex; + gap: 12px; + margin-bottom: 22px; + + h2 { + margin: 0 0 4px; + font-weight: 600; + font-size: 17px; + } + + p { + margin: 0; + color: @text-color-secondary; + } +} + +.step-index { + display: inline-flex; + flex: 0 0 26px; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + color: @primary-color; + font-weight: 600; + background: fade(@primary-color, 10%); + border-radius: 50%; +} + +.field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + + nz-form-item { + min-width: 0; + } +} + +.protocol-options { 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; + width: 100%; + + label { + position: relative; + flex: 1 1 0; + padding: 0 8px; + white-space: nowrap; + text-align: center; + border: 1px solid @border-color-base; + + + label { + margin-left: -1px; } - .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; - } - } + &.ant-radio-button-wrapper-checked { + z-index: 1; + color: @primary-color; + border-color: @primary-color; } } +} + +.token-input { + width: 100%; +} + +.detect-hint { + margin: 8px 0 0; + color: @text-color-secondary; + font-size: 12px; +} + +.probe-results { + margin-top: 18px; + border-top: 1px solid @border-color-split; +} + +.probe-row { + display: grid; + grid-template-columns: 18px 76px 150px minmax(0, 1fr); + gap: 8px; + align-items: center; + min-height: 40px; + color: @success-color; + border-bottom: 1px solid @border-color-split; + + &.probe-error { + color: @error-color; + } - .doc-content { - flex: 1; - padding: 24px; - overflow-y: auto; - background: @common-background-color; + .probe-message { + overflow: hidden; + color: @text-color-secondary; + white-space: nowrap; + text-overflow: ellipsis; + } +} - h2 { - margin-bottom: 24px; - font-size: 20px; - font-weight: 500; - } +.code-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 38px; + padding: 0 10px; + color: @text-color-secondary; + background: @common-tint-gray; + border: 1px solid @border-color-split; + border-bottom: 0; +} + +pre { + min-height: 310px; + margin: 0 0 16px; + padding: 18px; + overflow: auto; + color: @text-color; + font-size: 12px; + line-height: 1.65; + background: #fafafa; + border: 1px solid @border-color-split; + + code { + display: block; + padding: 0; + color: inherit; + background: transparent; + border: 0; } } +:host ::ng-deep .configuration-output .ant-alert-info { + background: @common-tint-gray; + border-color: @border-color-split; +} + [data-theme='dark'] { :host { - .log-integration-container { + .integration-page { + color: @text-color-dark; + } + + .integration-workspace { + background: @common-background-color-dark; + } + + .code-toolbar { + background: @common-tint-gray-dark; + border-color: #434343; + } + + pre { + color: @text-color-dark; 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); - } - } - } + border-color: #434343; } } -} \ No newline at end of file + + :host ::ng-deep .configuration-output .ant-alert-info { + background: @common-tint-gray-dark; + border-color: #434343; + } +} + +@media (max-width: 1080px) { + .integration-workspace { + grid-template-columns: 1fr; + } + + .connection-form { + border-right: 0; + border-bottom: 1px solid @border-color-split; + } +} 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 index be8ff0b2398..2b83885b012 100644 --- 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 @@ -17,25 +17,57 @@ * under the License. */ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { I18NService } from '@core'; +import { NzNotificationService } from 'ng-zorro-antd/notification'; +import { of } from 'rxjs'; +import { AuthService } from '../../../service/auth.service'; +import { ObservabilityService } from '../../../service/observability.service'; import { LogIntegrationComponent } from './log-integration.component'; describe('LogIntegrationComponent', () => { - let component: LogIntegrationComponent; - let fixture: ComponentFixture; + function createComponent() { + const auth = jasmine.createSpyObj('AuthService', ['generateToken']); + const observability = jasmine.createSpyObj('ObservabilityService', ['probe']); + const notifications = jasmine.createSpyObj('NzNotificationService', ['success', 'error', 'warning']); + const router = jasmine.createSpyObj('Router', ['navigateByUrl']); + const i18n = jasmine.createSpyObj('I18NService', ['fanyi']); + i18n.fanyi.and.callFake(key => key); + const component = new LogIntegrationComponent(auth, observability, notifications, router, i18n); + component.endpoint = 'http://localhost:4200'; + return { component, auth, observability }; + } - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [LogIntegrationComponent] - }).compileComponents(); + it('keeps the onboarding form simple and generates all configuration formats', () => { + const { component } = createComponent(); + component.token = 'token-value'; - fixture = TestBed.createComponent(LogIntegrationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + expect(component.formValid).toBeTrue(); + expect(component.snippet('environment')).toContain('OTEL_SERVICE_NAME=my-service'); + expect(component.snippet('collector')).toContain('metrics:'); + expect(component.snippet('collector')).toContain('logs:'); + expect(component.snippet('collector')).toContain('traces:'); + expect(component.snippet('java')).toContain('OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer token-value'); + expect(component.snippet('curl')).toContain('/api/otlp/v1/metrics'); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('generates a managed access token and detects each signal independently', () => { + const { component, auth, observability } = createComponent(); + auth.generateToken.and.returnValue(of({ code: 0, data: { token: 'generated-token' }, msg: '' })); + observability.probe.and.returnValue( + of([ + { signal: 'metrics', status: 'success', lastReceived: 1 }, + { signal: 'logs', status: 'error', error: 'storage unavailable' }, + { signal: 'traces', status: 'success', lastReceived: 1 } + ]) + ); + + component.generateToken(); + component.detect(); + + expect(component.token).toBe('generated-token'); + expect(component.probeResults.length).toBe(3); + expect(component.probeResults[1].error).toBe('storage unavailable'); }); }); 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 index d80408617d8..be9ecefc2e4 100644 --- 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 @@ -18,98 +18,133 @@ */ 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 { Router } 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 { NzNotificationService } from 'ng-zorro-antd/notification'; -import { MarkdownModule } from 'ngx-markdown'; +import { NzRadioModule } from 'ng-zorro-antd/radio'; -interface DataSource { - id: string; - name: string; - icon: string; -} +import { AuthService } from '../../../service/auth.service'; +import { ObservabilityService, OtlpConnectionForm, SignalProbeResult } from '../../../service/observability.service'; -const MARKDOWN_DOC_PATH = './assets/doc/log-integration'; +type OtlpProtocol = 'http-json' | 'http-protobuf' | 'grpc'; @Component({ selector: 'app-log-integration', standalone: true, - imports: [CommonModule, I18nPipe, MarkdownModule, HttpClientModule, NzDividerComponent, SharedModule], + imports: [CommonModule, I18nPipe, SharedModule, NzRadioModule], 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 = ''; + protocol: OtlpProtocol = 'http-json'; + endpoint = ''; + token = ''; + serviceName = 'my-service'; + environment = 'production'; + generatingToken = false; + detecting = false; + probeResults: SignalProbeResult[] = []; + selectedSnippet = 'environment'; constructor( - private http: HttpClient, - @Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService, + private authSvc: AuthService, + private observabilitySvc: ObservabilityService, private notifySvc: NzNotificationService, private router: Router, - private route: ActivatedRoute + @Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService ) {} - 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; + ngOnInit(): void { + this.endpoint = window.location.origin; + } + + get formValid(): boolean { + return Boolean(this.endpoint.trim() && this.token.trim() && this.serviceName.trim() && this.environment.trim()); + } + + generateToken(): void { + this.generatingToken = true; + this.authSvc.generateToken(`otlp-${Date.now()}`, 30 * 24 * 3600).subscribe({ + next: message => { + this.generatingToken = false; + if (message.code === 0 && message.data?.token) { + this.token = message.data.token; + this.notifySvc.success(this.i18nSvc.fanyi('observability.integration.token-created'), ''); } 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]); + this.notifySvc.error(this.i18nSvc.fanyi('observability.integration.token-failed'), message.msg || ''); } - } else { - // When no route params, use the first data source - this.selectedSource = this.dataSources[0]; - this.router.navigate(['/log/integration/', this.selectedSource.id]); + }, + error: () => { + this.generatingToken = false; + this.notifySvc.error(this.i18nSvc.fanyi('observability.integration.token-failed'), ''); } + }); + } - if (this.selectedSource) { - this.loadMarkdownContent(this.selectedSource); + detect(): void { + if (!this.formValid) return; + this.detecting = true; + this.probeResults = []; + this.observabilitySvc.probe(this.connectionForm()).subscribe({ + next: results => { + this.probeResults = results; + this.detecting = false; + }, + error: () => { + this.detecting = false; } }); } - selectSource(source: DataSource) { - this.selectedSource = source; - this.loadMarkdownContent(source); - this.router.navigate(['/log/integration', source.id]); + copy(text: string): void { + navigator.clipboard.writeText(text).then( + () => this.notifySvc.success(this.i18nSvc.fanyi('common.notify.copy-success'), ''), + () => this.notifySvc.warning(this.i18nSvc.fanyi('common.notify.copy-fail'), '') + ); } goToTokenManagement(): void { this.router.navigateByUrl('/setting/settings/token'); } - private loadMarkdownContent(source: DataSource) { - const lang = this.i18nSvc.currentLang; - const path = `${MARKDOWN_DOC_PATH}/${source.id}.${lang}.md`; + signalLabel(signal: SignalProbeResult['signal']): string { + return this.i18nSvc.fanyi(`observability.signal.${signal}`); + } - 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)); - } - }); + snippet(type: string): string { + const endpoint = this.endpoint.replace(/\/+$/, ''); + const httpEndpoint = `${endpoint}/api/otlp`; + const grpcEndpoint = endpoint.replace(/^https?:\/\//, '').replace(/:\d+$/, ':4317'); + const headers = `Authorization=Bearer ${this.token || ''}`; + if (type === 'collector') { + return `exporters:\n otlphttp/hertzbeat:\n endpoint: ${httpEndpoint}\n headers:\n Authorization: "Bearer ${ + this.token || '' + }"\nservice:\n pipelines:\n metrics:\n exporters: [otlphttp/hertzbeat]\n logs:\n exporters: [otlphttp/hertzbeat]\n traces:\n exporters: [otlphttp/hertzbeat]`; + } + if (type === 'java') { + const protocol = this.protocol === 'grpc' ? 'grpc' : this.protocol === 'http-json' ? 'http/json' : 'http/protobuf'; + const target = this.protocol === 'grpc' ? grpcEndpoint : httpEndpoint; + return `OTEL_SERVICE_NAME=${this.serviceName}\nOTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=${this.environment}\nOTEL_EXPORTER_OTLP_PROTOCOL=${protocol}\nOTEL_EXPORTER_OTLP_ENDPOINT=${target}\nOTEL_EXPORTER_OTLP_HEADERS=${headers}`; + } + if (type === 'curl') { + return `curl -X POST '${endpoint}/api/otlp/v1/metrics' \\\n -H 'Authorization: Bearer ${ + this.token || '' + }' \\\n -H 'Content-Type: application/json' \\\n --data-binary @metrics.json`; + } + const protocol = this.protocol === 'grpc' ? 'grpc' : this.protocol === 'http-json' ? 'http/json' : 'http/protobuf'; + const target = this.protocol === 'grpc' ? grpcEndpoint : httpEndpoint; + return `OTEL_SERVICE_NAME=${this.serviceName}\nOTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=${this.environment}\nOTEL_EXPORTER_OTLP_PROTOCOL=${protocol}\nOTEL_EXPORTER_OTLP_ENDPOINT=${target}\nOTEL_EXPORTER_OTLP_HEADERS=${headers}`; + } + + private connectionForm(): OtlpConnectionForm { + return { + endpoint: this.endpoint.trim(), + token: this.token.trim(), + serviceName: this.serviceName.trim(), + environment: this.environment.trim() + }; } } 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 index 3ee454cd9d2..c2a4439eeee 100644 --- 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 @@ -6,7 +6,7 @@ (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 + 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, @@ -14,353 +14,134 @@ See the License for the specific language governing permissions and limitations under the License. --> - - - - - -
- - - - +
+

{{ 'observability.logs.title' | i18n }}

{{ 'observability.logs.subtitle' | i18n }}

+ +
+ + + + + + + + - - - TRACE - DEBUG - INFO - WARN - ERROR - FATAL - - - - - - - - - {{ 'log.manage.column-setting' | i18n }} ({{ getVisibleColumnsCount() }}/{{ getColumnKeys().length }}) - - - -
- - - - - - -
- -
-
-
-
- -
-
-
- - - - - -
-
- - - - - -
-
- - - - - -
-
- - - - - -
-
- - - - - -
-
- - - - - -
-
- -
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
-
- - - - - - - - - {{ '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) }}
-
-
+ +
{{ 'observability.logs.total' | i18n }}{{ overview.totalCount || 0 }}
ERROR{{ overview.errorCount || 0 }}
WARN{{ overview.warnCount || 0 }}
{{ 'observability.logs.with-trace' | i18n }}{{ overview.traceCoverage?.withTrace || 0 }}
+

{{ 'observability.logs.trend' | i18n }}

{{ total }}
+
+

{{ 'observability.logs.results' | i18n }}

{{ total }}
+ + {{ 'observability.column.time' | i18n }}{{ 'observability.logs.severity' | i18n }}{{ 'observability.column.service' | i18n }}{{ 'observability.logs.message' | i18n }}Trace ID + {{ (log.timeUnixNano || 0) / 1000000 | date : 'yyyy-MM-dd HH:mm:ss.SSS' }}{{ log.severityText || log.severityNumber || '-' }}{{ service(log) }}{{ bodyText(log) }}- + + +
{{ detailJson() }}
+
+
-
+ + + +
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 index 71361bddfdc..e37a150eaff 100644 --- 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 @@ -1,82 +1,152 @@ -.manager-card { - .filters-container { - padding: 8px 0; - margin-bottom: 24px; - } +@import '~src/styles/theme'; + +.signal-page { + min-height: 100%; } -.log-body { - max-width: 400px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - cursor: pointer; +.page-heading { + display: flex; + gap: 24px; + align-items: flex-start; + justify-content: space-between; + padding-bottom: 18px; + border-bottom: 1px solid @border-color-split; +} + +.page-heading h1 { + margin: 0 0 5px; + font-weight: 600; + font-size: 24px; +} + +.page-heading p { + margin: 0; + color: @text-color-secondary; +} + +.heading-actions { + display: flex; + gap: 16px; + align-items: center; +} + +.mode-switch { + display: flex; + gap: 0; +} + +.mode-switch button + button { + margin-left: -1px; +} + +.query-toolbar { + display: grid; + grid-template-columns: repeat(4, minmax(150px, 1fr)); + gap: 8px; + align-items: center; + padding: 16px 0; +} + +.query-toolbar app-signal-time-range { + grid-column: 1 / -1; +} + +.resource-expression { + width: 100%; +} + +.overview-strip { + display: grid; + grid-template-columns: repeat(4, 1fr); + margin-bottom: 16px; + border: 1px solid @border-color-split; +} + +.overview-strip div { + display: flex; + flex-direction: column; + gap: 5px; + padding: 10px 16px; + border-right: 1px solid @border-color-split; } -.trace-id, .span-id { - font-family: 'Courier New', monospace; +.overview-strip div:last-child { + border-right: 0; +} + +.overview-strip span { + color: @text-color-secondary; font-size: 12px; - color: #666; } -.attributes-text, .resource-text { - max-width: 180px; +.overview-strip strong { + font-size: 20px; +} + +.chart-region { + min-height: 240px; + border: 1px solid @border-color-split; +} + +.trend-chart { + height: 240px; +} + +.chart-region nz-empty { + display: block; + padding-top: 48px; +} + +.section-title { + display: flex; + gap: 8px; + align-items: center; + margin: 20px 0 10px; +} + +.section-title h2 { + margin: 0; + font-size: 16px; +} + +.section-title span { + color: @text-color-secondary; +} + +.message-cell { + max-width: 580px; overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; - font-size: 12px; - cursor: pointer; + text-overflow: ellipsis; } -.text-muted { - color: #999; - font-style: italic; +.mono { + max-width: 220px; + overflow: hidden; + font-family: SFMono-Regular, Consolas, monospace; + text-overflow: ellipsis; } -nz-statistic { - text-align: center; +.drawer-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-bottom: 14px; } -.column-control-container { - width: 280px; - max-height: 400px; - overflow-y: auto; - .column-control-reset-button { - text-align: center; - } +pre { + max-height: calc(100vh - 150px); + padding: 16px; + overflow: auto; + color: #d7e0ea; + background: #0d1117; } - -.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; - } +@media (max-width: 1400px) { + .query-toolbar { + grid-template-columns: repeat(4, minmax(150px, 1fr)); } - .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; - } + + .query-toolbar > button:last-child { + grid-column: -2 / -1; } } 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 index e7e11e6ca21..8a262b27dfb 100644 --- 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 @@ -17,27 +17,24 @@ * under the License. */ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LogService } from '../../../service/log.service'; import { LogManageComponent } from './log-manage.component'; describe('LogManageComponent', () => { - let component: LogManageComponent; - let fixture: ComponentFixture; + it('switches query and live modes on the canonical log route', () => { + const logs = jasmine.createSpyObj('LogService', ['list', 'overviewStats', 'trendStats']); + const route = { snapshot: { queryParamMap: { get: () => null }, data: {} } } as unknown as ActivatedRoute; + const router = jasmine.createSpyObj('Router', ['navigate']); + const component = new LogManageComponent(logs, route, router); + component.timeRange = [new Date(1_000), new Date(2_000)]; - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [LogManageComponent] - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(LogManageComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); + component.setMode('stream'); - it('should create', () => { - expect(component).toBeTruthy(); + expect(component.mode).toBe('stream'); + expect(router.navigate).toHaveBeenCalledWith(['/log/manage'], { + queryParams: jasmine.objectContaining({ start: 1_000, end: 2_000, view: 'stream' }) + }); }); }); 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 index 496161769bf..0c5f5281a43 100644 --- 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 @@ -18,35 +18,36 @@ */ import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { I18NService } from '@core'; -import { ALAIN_I18N_TOKEN } from '@delon/theme'; +import { ActivatedRoute, Router } from '@angular/router'; +import { I18nPipe } from '@delon/theme'; import { SharedModule } from '@shared'; import { EChartsOption } from 'echarts'; -import { NzAutocompleteModule } from 'ng-zorro-antd/auto-complete'; -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 { NzDrawerModule } from 'ng-zorro-antd/drawer'; 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 { Subject, debounceTime, finalize, forkJoin, switchMap, takeUntil } from 'rxjs'; import { LogEntry } from '../../../pojo/LogEntry'; -import { LogService } from '../../../service/log.service'; +import { LogOverviewStats, LogQueryOptions, LogService } from '../../../service/log.service'; +import { SignalContext } from '../../../service/observability.service'; +import { SignalNavigationComponent } from '../../observability/signal-navigation.component'; +import { moveSignalTimeRangeToNow, readSignalTimeRange, toSignalTimeContext } from '../../observability/signal-query-context'; +import { SignalTimeRangeComponent } from '../../observability/signal-time-range.component'; +import { LogStreamComponent } from '../log-stream/log-stream.component'; + +type LogViewMode = 'query' | 'stream'; + +export function trendBucketMillis(value: string): number { + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return numeric > 10_000_000_000_000 ? numeric / 1_000_000 : numeric; + } + return Date.parse(value); +} @Component({ selector: 'app-log-manage', @@ -54,518 +55,236 @@ import { LogService } from '../../../service/log.service'; imports: [ CommonModule, FormsModule, + I18nPipe, SharedModule, - NzCardModule, + NgxEchartsModule, + NzDrawerModule, + NzEmptyModule, NzTableModule, - NzDatePickerModule, - NzInputModule, - NzButtonModule, NzTagModule, - NzToolTipModule, - NzEmptyModule, - NgxEchartsModule, - NzStatisticModule, - NzSpaceModule, - NzIconModule, - NzDividerModule, - NzCollapseModule, - NzModalModule, - NzCheckboxModule, - NzPopoverModule, - NzListModule, - NzAutocompleteModule + SignalNavigationComponent, + SignalTimeRangeComponent, + LogStreamComponent ], 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 +export class LogManageComponent implements OnInit, OnDestroy { + readonly severityLevels = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL']; + mode: LogViewMode = 'query'; timeRange: Date[] = []; - severityNumber?: number; - severityText?: string; - traceId: string = ''; - spanId: string = ''; - searchContent: string = ''; - - // table with pagination - loading = false; - data: LogEntry[] = []; + serviceName = ''; + serviceNamespace = ''; + environment = ''; + traceId = ''; + spanId = ''; + severityText = ''; + searchContent = ''; + resource = ''; + refreshSeconds = 0; 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') } - }; + total = 0; + logs: LogEntry[] = []; + overview: Partial = {}; + chartOption: EChartsOption = {}; + selected?: LogEntry; + detailVisible = false; + loading = false; + error = ''; + private readonly queryChanges = new Subject(); + private readonly destroyed = new Subject(); - // column control visible - columnControlVisible = false; + constructor(private logsService: LogService, private route: ActivatedRoute, private router: Router) {} ngOnInit(): void { - this.initChartThemes(); - } - - 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)' - } + const params = this.route.snapshot.queryParamMap; + this.timeRange = readSignalTimeRange(params); + this.mode = params.get('view') === 'stream' || this.route.snapshot.data['logMode'] === 'stream' ? 'stream' : 'query'; + this.traceId = params.get('traceId') || ''; + this.spanId = params.get('spanId') || ''; + this.serviceName = params.get('serviceName') || ''; + this.serviceNamespace = params.get('serviceNamespace') || ''; + this.environment = params.get('environment') || ''; + this.severityText = params.get('severityText') || ''; + this.searchContent = params.get('search') || ''; + this.resource = params.get('resource') || ''; + this.refreshSeconds = Math.max(0, Number(params.get('refresh')) || 0); + this.queryChanges + .pipe( + debounceTime(250), + switchMap(() => { + this.loading = true; + this.error = ''; + const options = this.options(); + return forkJoin({ + list: this.logsService.list(options), + overview: this.logsService.overviewStats(options), + trend: this.logsService.trendStats(options) + }).pipe(finalize(() => (this.loading = false))); + }), + takeUntil(this.destroyed) + ) + .subscribe({ + next: result => { + if (result.list.code !== 0 || result.overview.code !== 0 || result.trend.code !== 0) { + this.error = result.list.msg || result.overview.msg || result.trend.msg; + return; } - } - ] - }; + this.logs = result.list.data?.content || []; + this.total = result.list.data?.totalElements || 0; + this.overview = result.overview.data || {}; + this.chartOption = this.trendChart(result.trend.data?.hourlyStats || {}); + this.updateUrl(this.options()); + }, + error: error => (this.error = error?.error?.msg || error?.message || 'Storage unavailable') + }); + if (this.mode === 'query') this.search(); } - onSeverityChartInit(ec: any) { - this.severityInstance = ec; + ngOnDestroy(): void { + this.destroyed.next(); + this.destroyed.complete(); } - 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); + search(): void { + this.queryChanges.next(); } - 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); + refreshToNow(): void { + this.timeRange = moveSignalTimeRangeToNow(this.timeRange); + this.search(); } - 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.searchContent, - 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(this.i18n.fanyi('log.manage.error.unsupported-db')); - } - this.loading = false; - }, - error: () => { - this.loading = false; - this.msg.error(this.i18n.fanyi('common.notify.query-fail')); - } + setMode(mode: LogViewMode): void { + if (this.mode === mode) return; + this.mode = mode; + this.router.navigate(['/log/manage'], { + queryParams: { ...toSignalTimeContext(this.timeRange), ...this.signalFilters(), view: mode === 'stream' ? 'stream' : null } }); + if (mode === 'query') this.search(); } - 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; - const search = this.searchContent || undefined; - - this.logSvc.overviewStats(start, end, traceId, spanId, severity, severityText, search).subscribe({ - next: message => { - if (message.code === 0) { - this.overviewStats = message.data || {}; - this.refreshSeverityChartFromOverview(this.overviewStats); - } - } - }); - - this.logSvc.traceCoverageStats(start, end, traceId, spanId, severity, severityText, search).subscribe({ - next: message => { - if (message.code === 0) { - this.refreshTraceCoverageChart(message.data || {}); - } - } - }); - - this.logSvc.trendStats(start, end, traceId, spanId, severity, severityText, search).subscribe({ - next: message => { - if (message.code === 0) { - this.refreshTrendChart(message.data?.hourlyStats || {}); - } - } - }); + pageChanged(page: number): void { + this.pageIndex = page; + this.search(); } - clearFilters() { - this.timeRange = []; - this.severityNumber = undefined; - this.traceId = ''; - this.spanId = ''; - this.severityText = ''; - this.searchContent = ''; - this.pageIndex = 1; - this.query(); + open(log: LogEntry): void { + this.selected = log; + this.detailVisible = true; } - 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); + openTrace(log: LogEntry): void { + if (!log.traceId) return; + this.router.navigate(['/trace/manage'], { + queryParams: { + start: this.timeRange[0].getTime(), + end: this.timeRange[1].getTime(), + traceId: log.traceId, + serviceName: log.resource?.['service.name'], + serviceNamespace: log.resource?.['service.namespace'], + environment: log.resource?.['deployment.environment.name'] } }); } - 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')); + openMetrics(log: LogEntry): void { + this.router.navigate(['/metrics/manage'], { + queryParams: { + start: this.timeRange[0].getTime(), + end: this.timeRange[1].getTime(), + serviceName: log.resource?.['service.name'], + serviceNamespace: log.resource?.['service.namespace'], + environment: log.resource?.['deployment.environment.name'] } }); } - 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); + navigationContext(): SignalContext & { refresh?: number } { + return { + ...toSignalTimeContext(this.timeRange), + serviceName: this.serviceName, + serviceNamespace: this.serviceNamespace, + environment: this.environment, + traceId: this.traceId, + spanId: this.spanId, + refresh: this.refreshSeconds || undefined + }; } - formatTimestamp(timeUnixNano: number | undefined): string { - if (!timeUnixNano) return ''; - return new Date(timeUnixNano / 1000000).toLocaleString(); + bodyText(log: LogEntry): string { + return typeof log.body === 'string' ? log.body : JSON.stringify(log.body); } - 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')); - }); + service(log: LogEntry): string { + return String(log.resource?.['service.name'] || '-'); } - 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; - } + detailJson(): string { + return JSON.stringify(this.selected, null, 2); } - getVisibleColumnsCount(): number { - return Object.values(this.columnVisibility).filter(col => col.visible).length; + severityColor(value?: number): string { + if (!value) return 'default'; + if (value >= 17) return 'error'; + if (value >= 13) return 'warning'; + if (value >= 9) return 'processing'; + return 'default'; } - getColumnKeys(): string[] { - return Object.keys(this.columnVisibility); + private options(): LogQueryOptions { + return { + ...toSignalTimeContext(this.timeRange), + traceId: this.traceId, + spanId: this.spanId, + severityText: this.severityText, + search: this.searchContent, + serviceName: this.serviceName, + serviceNamespace: this.serviceNamespace, + environment: this.environment, + resource: this.resource, + pageIndex: this.pageIndex - 1, + pageSize: this.pageSize + }; } - getColumnVisibility(): any { - return this.columnVisibility; + private updateUrl(options: LogQueryOptions): void { + const { pageIndex, pageSize, ...context } = options; + this.router.navigate([], { + relativeTo: this.route, + queryParams: { ...context, view: this.mode === 'stream' ? 'stream' : null, refresh: this.refreshSeconds || null }, + replaceUrl: true + }); } - 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')); + private signalFilters(): Pick { + return { + traceId: this.traceId, + spanId: this.spanId, + serviceName: this.serviceName, + serviceNamespace: this.serviceNamespace, + environment: this.environment + }; } - toggleColumnControl(): void { - this.columnControlVisible = !this.columnControlVisible; + private trendChart(hourly: Record): EChartsOption { + const times = Object.keys(hourly).sort(); + return { + animation: false, + tooltip: { trigger: 'axis' }, + grid: { left: 48, right: 20, top: 18, bottom: 36 }, + xAxis: { + type: 'category', + data: times.map(time => { + const millis = trendBucketMillis(time); + return Number.isFinite(millis) + ? new Date(millis).toLocaleString([], { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) + : time; + }) + }, + yAxis: { type: 'value' }, + series: [{ type: 'line', showSymbol: false, areaStyle: { opacity: 0.12 }, data: times.map(time => hourly[time]) }] + }; } } diff --git a/web-app/src/app/routes/log/log-manage/log-trend-time.spec.ts b/web-app/src/app/routes/log/log-manage/log-trend-time.spec.ts new file mode 100644 index 00000000000..2459a80281c --- /dev/null +++ b/web-app/src/app/routes/log/log-manage/log-trend-time.spec.ts @@ -0,0 +1,30 @@ +/* + * 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 { trendBucketMillis } from './log-manage.component'; + +describe('log trend time normalization', () => { + it('converts Greptime nanosecond buckets to epoch milliseconds', () => { + expect(trendBucketMillis('1783699200000000000')).toBe(1783699200000); + }); + + it('keeps epoch milliseconds unchanged', () => { + expect(trendBucketMillis('1783699200000')).toBe(1783699200000); + }); +}); diff --git a/web-app/src/app/routes/log/log-routing.module.ts b/web-app/src/app/routes/log/log-routing.module.ts index 7ba2eb7916e..6175bf8e29a 100644 --- a/web-app/src/app/routes/log/log-routing.module.ts +++ b/web-app/src/app/routes/log/log-routing.module.ts @@ -22,12 +22,11 @@ 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: 'stream', component: LogManageComponent, data: { logMode: 'stream' } }, { path: 'manage', component: LogManageComponent } ]; 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 index 4efa55c9ba4..0916bbe21b6 100644 --- 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 @@ -18,17 +18,18 @@ --> - + -
+
- +
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 index eaa26d12582..f9fff80d397 100644 --- 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 @@ -22,6 +22,17 @@ .log-stream-container { background: @common-background-color; + &.embedded { + .header-card { + margin: 0; + background: transparent; + } + + ::ng-deep .header-card > .ant-card-body { + padding: 16px 0 0; + } + } + .header-card { margin-bottom: 16px; @@ -434,4 +445,3 @@ } } } - 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 index e8d7630ddb7..e5d7c1093e0 100644 --- 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 @@ -28,7 +28,8 @@ import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, - NgZone + NgZone, + Input } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { I18NService } from '@core'; @@ -82,6 +83,7 @@ interface ExtendedLogEntry { changeDetection: ChangeDetectionStrategy.OnPush }) export class LogStreamComponent implements OnInit, OnDestroy, AfterViewInit { + @Input() embedded = false; // SSE connection and state private eventSource!: EventSource; isConnected: boolean = false; @@ -95,10 +97,10 @@ export class LogStreamComponent implements OnInit, OnDestroy, AfterViewInit { // Filter properties filterSeverityNumber: string = ''; - filterSeverityText: string = ''; + @Input() filterSeverityText: string = ''; filterLogContent: string = ''; - filterTraceId: string = ''; - filterSpanId: string = ''; + @Input() filterTraceId: string = ''; + @Input() filterSpanId: string = ''; // UI state showFilters: boolean = true; diff --git a/web-app/src/app/routes/metrics/metrics-manage.component.html b/web-app/src/app/routes/metrics/metrics-manage.component.html new file mode 100644 index 00000000000..55af41e68b9 --- /dev/null +++ b/web-app/src/app/routes/metrics/metrics-manage.component.html @@ -0,0 +1,127 @@ + +
+
+
+

{{ 'observability.metrics.title' | i18n }}

+

{{ 'observability.metrics.subtitle' | i18n }}

+
+
+ + {{ series.length }} {{ 'observability.metrics.series' | i18n }} +
+
+ +
+ + + + {{ name }} + + + +
+ + + +
+
{{ 'observability.metrics.total-series' | i18n }}{{ series.length }}
+
{{ 'observability.metrics.total-samples' | i18n }}{{ summary.sampleCount }}
+
{{ 'observability.metrics.minimum' | i18n }}{{ summary.minimum | number : '1.0-2' }}
+
{{ 'observability.metrics.maximum' | i18n }}{{ summary.maximum | number : '1.0-2' }}
+
+ +
+

{{ 'observability.metrics.trend' | i18n }}

{{ series.length }} +
+ +
+
+ +
+ +
+ +
+ +
+

{{ 'observability.metrics.samples' | i18n }}

+ {{ rows.length }} +
+ + {{ 'observability.column.time' | i18n }}{{ 'observability.column.value' | i18n }}{{ 'observability.column.labels' | i18n }} + + + {{ row.timestamp | date : 'yyyy-MM-dd HH:mm:ss' }}{{ row.value }}{{ labelText(row.labels) }} + + + + + + +
+
{{ label.key }}
{{ label.value }}
+
+
+
+
diff --git a/web-app/src/app/routes/metrics/metrics-manage.component.less b/web-app/src/app/routes/metrics/metrics-manage.component.less new file mode 100644 index 00000000000..277be38e3ac --- /dev/null +++ b/web-app/src/app/routes/metrics/metrics-manage.component.less @@ -0,0 +1,160 @@ +@import '~src/styles/theme'; + +.signal-page { + min-height: 100%; +} + +.page-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding-bottom: 18px; + border-bottom: 1px solid @border-color-split; +} + +.page-heading h1 { + margin: 0 0 5px; + font-weight: 600; + font-size: 24px; +} + +.page-heading p, +.result-count { + margin: 0; + color: @text-color-secondary; +} + +.heading-actions { + display: flex; + gap: 16px; + align-items: center; +} + +.query-toolbar { + display: grid; + grid-template-columns: minmax(400px, 1fr) 140px auto; + gap: 8px; + align-items: center; + padding: 16px 0; +} + +.query-toolbar app-signal-time-range { + grid-column: 1 / -1; +} + +.promql-input { + width: 100%; +} + +.chart-region { + min-height: 240px; + background: @component-background; + border: 1px solid @border-color-split; +} + +.metric-chart { + height: 240px; +} + +.overview-strip { + display: grid; + grid-template-columns: repeat(4, 1fr); + margin-bottom: 0; + border: 1px solid @border-color-split; +} + +.overview-strip div { + display: flex; + flex-direction: column; + gap: 5px; + padding: 10px 16px; + border-right: 1px solid @border-color-split; +} + +.overview-strip div:last-child { + border-right: 0; +} + +.overview-strip span { + color: @text-color-secondary; + font-size: 12px; +} + +.overview-strip strong { + font-size: 20px; +} + +.series-strip { + display: flex; + gap: 6px; + padding: 8px 0; + overflow-x: auto; + border-bottom: 1px solid @border-color-split; +} + +.series-strip button { + flex: 0 0 auto; + max-width: 360px; + overflow: hidden; + font-family: SFMono-Regular, Consolas, monospace; + text-overflow: ellipsis; +} + +.chart-region nz-empty { + display: block; + padding-top: 60px; +} + +.loading-region { + opacity: 0.55; +} + +.section-title { + display: flex; + gap: 8px; + align-items: center; + margin: 18px 0 10px; +} + +.section-title h2 { + margin: 0; + font-size: 16px; +} + +.section-title span { + color: @text-color-secondary; +} + +.mono { + max-width: 640px; + overflow: hidden; + font-family: SFMono-Regular, Consolas, monospace; + white-space: nowrap; + text-overflow: ellipsis; +} + +.detail-list { + display: grid; + grid-template-columns: minmax(120px, auto) 1fr; + margin: 0; +} + +.detail-list dt, +.detail-list dd { + margin: 0; + padding: 9px 0; + border-bottom: 1px solid @border-color-split; +} + +.detail-list dt { + color: @text-color-secondary; +} +@media (max-width: 1400px) { + .query-toolbar { + grid-template-columns: minmax(320px, 1fr) 130px auto; + } + + .query-toolbar > button:last-child { + grid-column: -2 / -1; + } +} diff --git a/web-app/src/app/routes/metrics/metrics-manage.component.spec.ts b/web-app/src/app/routes/metrics/metrics-manage.component.spec.ts new file mode 100644 index 00000000000..304d49fd135 --- /dev/null +++ b/web-app/src/app/routes/metrics/metrics-manage.component.spec.ts @@ -0,0 +1,74 @@ +/* + * 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 { fakeAsync, tick } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { of } from 'rxjs'; + +import { ObservabilityService } from '../../service/observability.service'; +import { MetricsManageComponent } from './metrics-manage.component'; + +describe('MetricsManageComponent', () => { + it('loads inventory, queries the selected metric, and derives the sample summary', fakeAsync(() => { + const observability = jasmine.createSpyObj('ObservabilityService', ['metricInventory', 'queryMetrics']); + observability.metricInventory.and.returnValue(of({ code: 0, data: { metricNames: ['http.requests'] }, msg: '' })); + observability.queryMetrics.and.returnValue( + of({ + code: 0, + data: { + query: 'http.requests', + start: 1_000, + end: 2_000, + step: 15, + series: [ + { + labels: { service: 'checkout' }, + points: [ + { timestamp: 1_000, value: 4 }, + { timestamp: 2_000, value: 9 } + ] + } + ] + }, + msg: '' + }) + ); + const route = { + snapshot: { queryParamMap: convertToParamMap({ start: 1_000, end: 2_000, step: 15 }) } + } as unknown as ActivatedRoute; + const router = jasmine.createSpyObj('Router', ['navigate']); + const component = new MetricsManageComponent(observability, route, router); + + component.ngOnInit(); + tick(250); + + expect(observability.queryMetrics).toHaveBeenCalledWith( + jasmine.objectContaining({ start: 1_000, end: 2_000 }), + 'http.requests', + undefined, + undefined, + undefined, + 15 + ); + expect(component.summary).toEqual({ sampleCount: 2, minimum: 4, maximum: 9, latest: 9 }); + expect(component.rows.length).toBe(2); + expect(router.navigate).toHaveBeenCalledWith([], jasmine.objectContaining({ replaceUrl: true })); + component.ngOnDestroy(); + })); +}); diff --git a/web-app/src/app/routes/metrics/metrics-manage.component.ts b/web-app/src/app/routes/metrics/metrics-manage.component.ts new file mode 100644 index 00000000000..e68ce0b97f9 --- /dev/null +++ b/web-app/src/app/routes/metrics/metrics-manage.component.ts @@ -0,0 +1,224 @@ +/* + * 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, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { I18nPipe } from '@delon/theme'; +import { SharedModule } from '@shared'; +import { EChartsOption } from 'echarts'; +import { NzAutocompleteModule } from 'ng-zorro-antd/auto-complete'; +import { NzDrawerModule } from 'ng-zorro-antd/drawer'; +import { NzEmptyModule } from 'ng-zorro-antd/empty'; +import { NzTableModule } from 'ng-zorro-antd/table'; +import { NgxEchartsModule } from 'ngx-echarts'; +import { Subject, debounceTime, finalize, switchMap, takeUntil } from 'rxjs'; + +import { MetricSeries, ObservabilityService, SignalContext } from '../../service/observability.service'; +import { SignalNavigationComponent } from '../observability/signal-navigation.component'; +import { moveSignalTimeRangeToNow, readSignalTimeRange, toSignalTimeContext } from '../observability/signal-query-context'; +import { SignalTimeRangeComponent } from '../observability/signal-time-range.component'; + +interface MetricTableRow { + timestamp: number; + value: number; + labels: Record; +} + +@Component({ + selector: 'app-metrics-manage', + standalone: true, + imports: [ + CommonModule, + FormsModule, + I18nPipe, + SharedModule, + NgxEchartsModule, + NzTableModule, + NzEmptyModule, + NzDrawerModule, + NzAutocompleteModule, + SignalNavigationComponent, + SignalTimeRangeComponent + ], + templateUrl: './metrics-manage.component.html', + styleUrl: './metrics-manage.component.less' +}) +export class MetricsManageComponent implements OnInit, OnDestroy { + timeRange: Date[] = []; + query = ''; + step = 30; + refreshSeconds = 0; + serviceName = ''; + serviceNamespace = ''; + environment = ''; + operationName = ''; + metricNames: string[] = []; + series: MetricSeries[] = []; + rows: MetricTableRow[] = []; + summary = { sampleCount: 0, minimum: 0, maximum: 0, latest: 0 }; + chartOption: EChartsOption = {}; + loading = false; + error = ''; + detailVisible = false; + selectedSeries?: MetricSeries; + private readonly queryChanges = new Subject(); + private readonly destroyed = new Subject(); + + constructor(private observability: ObservabilityService, private route: ActivatedRoute, private router: Router) {} + + ngOnInit(): void { + const params = this.route.snapshot.queryParamMap; + this.timeRange = readSignalTimeRange(params); + this.serviceName = params.get('serviceName') || ''; + this.serviceNamespace = params.get('serviceNamespace') || ''; + this.environment = params.get('environment') || ''; + this.operationName = params.get('operationName') || ''; + this.query = params.get('query') || ''; + this.step = Math.max(1, Math.min(Number(params.get('step')) || 30, 3600)); + this.refreshSeconds = Math.max(0, Number(params.get('refresh')) || 0); + this.queryChanges + .pipe( + debounceTime(250), + switchMap(() => { + this.loading = true; + this.error = ''; + return this.observability + .queryMetrics(this.context(), this.query || 'up', undefined, undefined, undefined, this.step) + .pipe(finalize(() => (this.loading = false))); + }), + takeUntil(this.destroyed) + ) + .subscribe({ + next: message => { + if (message.code !== 0) { + this.error = message.msg; + return; + } + this.series = message.data?.series || []; + this.rows = this.series.flatMap(series => + series.points.map(point => ({ timestamp: point.timestamp, value: point.value, labels: series.labels })) + ); + const values = this.rows.map(row => row.value); + const latest = this.rows.reduce( + (current, row) => (!current || row.timestamp > current.timestamp ? row : current), + undefined + ); + this.summary = { + sampleCount: this.rows.length, + minimum: values.length ? Math.min(...values) : 0, + maximum: values.length ? Math.max(...values) : 0, + latest: latest?.value || 0 + }; + this.chartOption = this.toChart(this.series); + this.updateUrl(); + }, + error: error => (this.error = error?.error?.msg || error?.message || 'Storage unavailable') + }); + this.loadInventory(); + } + + ngOnDestroy(): void { + this.destroyed.next(); + this.destroyed.complete(); + } + + search(): void { + this.queryChanges.next(); + } + + refreshToNow(): void { + this.timeRange = moveSignalTimeRangeToNow(this.timeRange); + this.search(); + } + + selectMetric(name: string): void { + this.query = name; + this.search(); + } + + showSeries(series: MetricSeries): void { + this.selectedSeries = series; + this.detailVisible = true; + } + + labelText(labels: Record): string { + return Object.entries(labels) + .map(([key, value]) => `${key}=${value}`) + .join(', '); + } + + navigationContext(): SignalContext & { refresh?: number } { + return { ...this.context(), refresh: this.refreshSeconds || undefined }; + } + + private loadInventory(): void { + this.observability + .metricInventory(this.context()) + .pipe(takeUntil(this.destroyed)) + .subscribe({ + next: message => { + this.metricNames = message.data?.metricNames || []; + if (!this.query && this.metricNames.length) this.query = this.metricNames[0]; + this.search(); + }, + error: error => { + this.error = error?.error?.msg || error?.message || 'Storage unavailable'; + this.search(); + } + }); + } + + private context(): SignalContext { + return { + ...toSignalTimeContext(this.timeRange), + serviceName: this.serviceName, + serviceNamespace: this.serviceNamespace, + environment: this.environment, + operationName: this.operationName + }; + } + + private updateUrl(): void { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { ...this.context(), query: this.query || null, step: this.step, refresh: this.refreshSeconds || null }, + replaceUrl: true + }); + } + + private toChart(series: MetricSeries[]): EChartsOption { + return { + animation: false, + tooltip: { trigger: 'axis' }, + grid: { left: 52, right: 24, top: 24, bottom: 44 }, + xAxis: { type: 'time' }, + yAxis: { type: 'value', scale: true }, + dataZoom: [{ type: 'inside' }, { type: 'slider', height: 18, bottom: 8 }], + series: series.map((item, index) => ({ + name: this.labelText(item.labels) || `${this.query} ${index + 1}`, + type: 'line', + showSymbol: false, + sampling: 'lttb', + data: item.points.map(point => [point.timestamp, point.value]) + })) + }; + } +} diff --git a/web-app/src/app/routes/observability-navigation.spec.ts b/web-app/src/app/routes/observability-navigation.spec.ts new file mode 100644 index 00000000000..47bbd765039 --- /dev/null +++ b/web-app/src/app/routes/observability-navigation.spec.ts @@ -0,0 +1,84 @@ +/* + * 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 { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; + +import { LogService } from '../service/log.service'; +import { ObservabilityService } from '../service/observability.service'; +import { LogManageComponent } from './log/log-manage/log-manage.component'; +import { routes } from './routes-routing.module'; +import { TraceManageComponent } from './trace/trace-manage.component'; + +describe('Observability transition navigation', () => { + it('exposes integration, metrics, logs, and traces while keeping the legacy log route', () => { + const children = routes[0].children || []; + expect(children.map(route => route.path)).toContain('observability/integration'); + expect(children.map(route => route.path)).toContain('metrics/manage'); + expect(children.map(route => route.path)).toContain('trace/manage'); + expect(children.map(route => route.path)).toContain('log'); + }); + + it('keeps time and OTLP resource context when moving from a trace to logs and metrics', () => { + const observability = jasmine.createSpyObj('ObservabilityService', [ + 'queryTraces', + 'traceOverview', + 'traceDetail' + ]); + const route = { snapshot: { queryParamMap: convertToParamMap({}) } } as unknown as ActivatedRoute; + const router = jasmine.createSpyObj('Router', ['navigate']); + const component = new TraceManageComponent(observability, route, router); + component.selected = { + summary: { + traceId: 'trace-1', + rootSpanId: 'span-1', + serviceName: 'checkout', + serviceNamespace: 'payments', + rootSpanName: 'POST /checkout', + durationNanos: 2_000_000, + status: 'OK', + startTime: 10_000, + errorSpanCount: 0, + resourceAttributes: { 'deployment.environment.name': 'prod' } + }, + spans: [] + }; + + component.viewLogs(); + component.viewMetrics(); + + expect(router.navigate.calls.argsFor(0)[0]).toEqual(['/log/manage']); + expect(router.navigate.calls.argsFor(0)[1]?.queryParams?.['traceId']).toBe('trace-1'); + expect(router.navigate.calls.argsFor(1)[0]).toEqual(['/metrics/manage']); + expect(router.navigate.calls.argsFor(1)[1]?.queryParams?.['operationName']).toBe('POST /checkout'); + }); + + it('opens the matching trace from a log without an entity route', () => { + const logs = jasmine.createSpyObj('LogService', ['list', 'overviewStats', 'trendStats']); + const route = { snapshot: { queryParamMap: convertToParamMap({}) } } as unknown as ActivatedRoute; + const router = jasmine.createSpyObj('Router', ['navigate']); + const component = new LogManageComponent(logs, route, router); + component.timeRange = [new Date(1_000), new Date(2_000)]; + + component.openTrace({ traceId: 'trace-2', resource: { 'service.name': 'catalog' } }); + + expect(router.navigate).toHaveBeenCalledWith(['/trace/manage'], { + queryParams: jasmine.objectContaining({ traceId: 'trace-2', serviceName: 'catalog', start: 1_000, end: 2_000 }) + }); + }); +}); diff --git a/web-app/src/app/routes/observability/signal-navigation.component.ts b/web-app/src/app/routes/observability/signal-navigation.component.ts new file mode 100644 index 00000000000..c1bd3389fef --- /dev/null +++ b/web-app/src/app/routes/observability/signal-navigation.component.ts @@ -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. + */ + +import { Component, Input } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { I18nPipe } from '@delon/theme'; +import { SharedModule } from '@shared'; + +import { SignalContext } from '../../service/observability.service'; + +type SignalKind = 'metrics' | 'logs' | 'traces'; + +@Component({ + selector: 'app-signal-navigation', + standalone: true, + imports: [I18nPipe, RouterModule, SharedModule], + template: ` + + `, + styles: [ + ` + nav { + display: flex; + } + a + a { + margin-left: -1px; + } + ` + ] +}) +export class SignalNavigationComponent { + @Input() active: SignalKind = 'metrics'; + @Input() context: SignalContext = {}; +} diff --git a/web-app/src/app/routes/observability/signal-query-context.spec.ts b/web-app/src/app/routes/observability/signal-query-context.spec.ts new file mode 100644 index 00000000000..50a66dcec4f --- /dev/null +++ b/web-app/src/app/routes/observability/signal-query-context.spec.ts @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ParamMap } from '@angular/router'; + +import { + createSignalTimePreset, + detectSignalTimePreset, + moveSignalTimeRangeToNow, + readSignalTimeRange, + shiftSignalTimeRange, + toSignalTimeContext +} from './signal-query-context'; + +describe('signal query context', () => { + it('reads and writes the shared start and end contract', () => { + const values = new Map([ + ['start', '1000'], + ['end', '2000'] + ]); + const params = { get: (key: string) => values.get(key) || null } as ParamMap; + + const range = readSignalTimeRange(params); + + expect(range.map(value => value.getTime())).toEqual([1_000, 2_000]); + expect(toSignalTimeContext(range)).toEqual({ start: 1_000, end: 2_000 }); + }); + + it('moves a fixed range to now without changing its duration', () => { + const range = moveSignalTimeRangeToNow([new Date(1_000), new Date(61_000)], 121_000); + + expect(range.map(value => value.getTime())).toEqual([61_000, 121_000]); + }); + + it('creates and detects a relative time preset', () => { + const range = createSignalTimePreset('1h', 7_200_000); + + expect(range.map(value => value.getTime())).toEqual([3_600_000, 7_200_000]); + expect(detectSignalTimePreset(range, 7_200_000)).toBe('1h'); + expect(detectSignalTimePreset(range, 7_300_000)).toBe('custom'); + }); + + it('moves a time window backward and clamps forward movement to now', () => { + const range = [new Date(60_000), new Date(120_000)]; + + expect(shiftSignalTimeRange(range, -1, 300_000).map(value => value.getTime())).toEqual([0, 60_000]); + expect(shiftSignalTimeRange(range, 1, 150_000).map(value => value.getTime())).toEqual([90_000, 150_000]); + }); +}); diff --git a/web-app/src/app/routes/observability/signal-query-context.ts b/web-app/src/app/routes/observability/signal-query-context.ts new file mode 100644 index 00000000000..7c757f72f2d --- /dev/null +++ b/web-app/src/app/routes/observability/signal-query-context.ts @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ParamMap } from '@angular/router'; + +export interface SignalTimeContext { + start?: number; + end?: number; +} + +export type SignalTimePreset = '15m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '7d'; + +export const SIGNAL_TIME_PRESETS: ReadonlyArray<{ value: SignalTimePreset; durationMs: number }> = [ + { value: '15m', durationMs: 15 * 60_000 }, + { value: '30m', durationMs: 30 * 60_000 }, + { value: '1h', durationMs: 60 * 60_000 }, + { value: '3h', durationMs: 3 * 60 * 60_000 }, + { value: '6h', durationMs: 6 * 60 * 60_000 }, + { value: '12h', durationMs: 12 * 60 * 60_000 }, + { value: '24h', durationMs: 24 * 60 * 60_000 }, + { value: '7d', durationMs: 7 * 24 * 60 * 60_000 } +]; + +export function readSignalTimeRange(params: ParamMap, defaultDurationMs = 30 * 60_000): Date[] { + const end = Number(params.get('end')) || Date.now(); + const start = Number(params.get('start')) || end - defaultDurationMs; + return [new Date(start), new Date(end)]; +} + +export function toSignalTimeContext(timeRange?: Date[]): SignalTimeContext { + return { + start: timeRange?.[0]?.getTime(), + end: timeRange?.[1]?.getTime() + }; +} + +export function moveSignalTimeRangeToNow(timeRange: Date[], now = Date.now(), defaultDurationMs = 30 * 60_000): Date[] { + const duration = Math.max(1, (timeRange[1]?.getTime() || now) - (timeRange[0]?.getTime() || now - defaultDurationMs)); + return [new Date(now - duration), new Date(now)]; +} + +export function createSignalTimePreset(preset: SignalTimePreset, now = Date.now()): Date[] { + const duration = SIGNAL_TIME_PRESETS.find(item => item.value === preset)?.durationMs || 30 * 60_000; + return [new Date(now - duration), new Date(now)]; +} + +export function detectSignalTimePreset(timeRange: Date[], now = Date.now(), liveToleranceMs = 60_000): SignalTimePreset | 'custom' { + const duration = (timeRange[1]?.getTime() || 0) - (timeRange[0]?.getTime() || 0); + if (Math.abs(now - (timeRange[1]?.getTime() || 0)) > liveToleranceMs) return 'custom'; + return SIGNAL_TIME_PRESETS.find(item => item.durationMs === duration)?.value || 'custom'; +} + +export function shiftSignalTimeRange(timeRange: Date[], direction: -1 | 1, now = Date.now()): Date[] { + const start = timeRange[0]?.getTime() || now - 30 * 60_000; + const end = timeRange[1]?.getTime() || now; + const duration = Math.max(1, end - start); + if (direction < 0) { + return [new Date(start - duration), new Date(end - duration)]; + } + const nextEnd = Math.min(now, end + duration); + return [new Date(nextEnd - duration), new Date(nextEnd)]; +} diff --git a/web-app/src/app/routes/observability/signal-time-range.component.spec.ts b/web-app/src/app/routes/observability/signal-time-range.component.spec.ts new file mode 100644 index 00000000000..973cd2ff571 --- /dev/null +++ b/web-app/src/app/routes/observability/signal-time-range.component.spec.ts @@ -0,0 +1,47 @@ +/* + * 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 { fakeAsync, tick } from '@angular/core/testing'; + +import { SignalTimeRangeComponent } from './signal-time-range.component'; + +describe('SignalTimeRangeComponent', () => { + it('applies a relative preset without requiring manual dates', () => { + const component = new SignalTimeRangeComponent(); + const ranges: Date[][] = []; + component.valueChange.subscribe(value => ranges.push(value)); + + component.selectPreset('15m'); + + expect(ranges.length).toBe(1); + expect(ranges[0][1].getTime() - ranges[0][0].getTime()).toBe(15 * 60_000); + }); + + it('emits automatic refresh at the configured interval', fakeAsync(() => { + const component = new SignalTimeRangeComponent(); + let refreshes = 0; + component.refresh.subscribe(() => refreshes++); + + component.setRefreshInterval(10); + tick(10_000); + component.ngOnDestroy(); + + expect(refreshes).toBe(1); + })); +}); diff --git a/web-app/src/app/routes/observability/signal-time-range.component.ts b/web-app/src/app/routes/observability/signal-time-range.component.ts new file mode 100644 index 00000000000..06bf68d75c7 --- /dev/null +++ b/web-app/src/app/routes/observability/signal-time-range.component.ts @@ -0,0 +1,173 @@ +/* + * 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 { Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { I18nPipe } from '@delon/theme'; +import { SharedModule } from '@shared'; +import { NzDatePickerModule } from 'ng-zorro-antd/date-picker'; +import { NzSelectModule } from 'ng-zorro-antd/select'; + +import { + createSignalTimePreset, + detectSignalTimePreset, + SIGNAL_TIME_PRESETS, + shiftSignalTimeRange, + SignalTimePreset +} from './signal-query-context'; + +@Component({ + selector: 'app-signal-time-range', + standalone: true, + imports: [FormsModule, I18nPipe, SharedModule, NzDatePickerModule, NzSelectModule], + template: ` + + + + + + + + + + + `, + styles: [ + ` + :host { + display: grid; + grid-template-columns: auto minmax(330px, 1fr) 126px 40px; + gap: 8px; + align-items: center; + min-width: 0; + } + .range-actions { + display: flex; + min-width: 0; + } + .range-actions button { + flex: 0 0 34px; + } + .range-actions button + nz-select, + .range-actions nz-select + button { + margin-left: -1px; + } + .range-actions nz-select { + width: 138px; + } + nz-range-picker { + width: 100%; + min-width: 0; + } + :host > button { + width: 40px; + } + @media (max-width: 900px) { + :host { + grid-template-columns: auto minmax(260px, 1fr) 112px 40px; + } + } + ` + ] +}) +export class SignalTimeRangeComponent implements OnChanges, OnDestroy { + readonly presets = SIGNAL_TIME_PRESETS; + @Input() value: Date[] = []; + @Input() refreshSeconds = 0; + @Output() readonly valueChange = new EventEmitter(); + @Output() readonly refreshSecondsChange = new EventEmitter(); + @Output() readonly apply = new EventEmitter(); + @Output() readonly refresh = new EventEmitter(); + private timerId?: number; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['refreshSeconds']) this.scheduleRefresh(this.refreshSeconds); + } + + ngOnDestroy(): void { + this.clearRefreshTimer(); + } + + get selectedPreset(): SignalTimePreset | 'custom' { + return detectSignalTimePreset(this.value); + } + + get canMoveForward(): boolean { + return Boolean(this.value[1] && this.value[1].getTime() < Date.now() - 30_000); + } + + selectPreset(preset: SignalTimePreset | 'custom'): void { + if (preset === 'custom') return; + this.commit(createSignalTimePreset(preset)); + } + + setAbsoluteRange(value: Date[]): void { + if (!value?.[0] || !value?.[1]) return; + this.commit(value); + } + + shift(direction: -1 | 1): void { + this.commit(shiftSignalTimeRange(this.value, direction)); + } + + refreshNow(): void { + this.refresh.emit(); + } + + setRefreshInterval(seconds: number): void { + this.refreshSecondsChange.emit(seconds); + this.scheduleRefresh(seconds); + } + + private commit(value: Date[]): void { + this.valueChange.emit(value); + this.apply.emit(); + } + + private scheduleRefresh(seconds: number): void { + this.clearRefreshTimer(); + if (seconds > 0) this.timerId = window.setInterval(() => this.refresh.emit(), seconds * 1_000); + } + + private clearRefreshTimer(): void { + if (this.timerId != null) window.clearInterval(this.timerId); + this.timerId = undefined; + } +} diff --git a/web-app/src/app/routes/routes-routing.module.ts b/web-app/src/app/routes/routes-routing.module.ts index b1ff83b0082..084a87a2461 100644 --- a/web-app/src/app/routes/routes-routing.module.ts +++ b/web-app/src/app/routes/routes-routing.module.ts @@ -12,7 +12,7 @@ import { UserLockComponent } from './passport/lock/lock.component'; import { UserLoginComponent } from './passport/login/login.component'; import { StatusPublicComponent } from './status-public/status-public.component'; -const routes: Routes = [ +export const routes: Routes = [ { path: '', component: LayoutBasicComponent, @@ -28,6 +28,21 @@ const routes: Routes = [ data: { titleI18n: 'menu.monitor.center' } }, { path: 'log', loadChildren: () => import('./log/log.module').then(m => m.LogModule) }, + { + path: 'observability/integration', + loadComponent: () => import('./log/log-integration/log-integration.component').then(m => m.LogIntegrationComponent), + data: { titleI18n: 'menu.observability.integration' } + }, + { + path: 'metrics/manage', + loadComponent: () => import('./metrics/metrics-manage.component').then(m => m.MetricsManageComponent), + data: { titleI18n: 'menu.observability.metrics' } + }, + { + path: 'trace/manage', + loadComponent: () => import('./trace/trace-manage.component').then(m => m.TraceManageComponent), + data: { titleI18n: 'menu.observability.traces' } + }, { 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/routes/trace/trace-manage.component.html b/web-app/src/app/routes/trace/trace-manage.component.html new file mode 100644 index 00000000000..ab75188840e --- /dev/null +++ b/web-app/src/app/routes/trace/trace-manage.component.html @@ -0,0 +1,189 @@ + +
+
+

{{ 'observability.traces.title' | i18n }}

{{ 'observability.traces.subtitle' | i18n }}

+ +
+
+ + + + + + + + + + + + +
+ +
+
{{ 'observability.traces.total' | i18n }}{{ overview.totalCount }}
+
{{ 'observability.traces.errors' | i18n }}{{ overview.errorCount }}
+
{{ 'observability.traces.error-rate' | i18n }}{{ overview.errorRate | percent : '1.1-1' }}
+
{{ 'observability.traces.average' | i18n }}{{ overview.averageDurationMillis | number : '1.0-2' }} ms
+
P95{{ overview.p95DurationMillis | number : '1.0-2' }} ms
+
+

{{ 'observability.traces.results' | i18n }}

{{ total }}
+ + {{ 'observability.column.start' | i18n }}{{ 'observability.column.service' | i18n }}{{ 'observability.column.operation' | i18n }}{{ 'observability.column.duration' | i18n }}{{ 'observability.column.status' | i18n }}Trace ID + {{ trace.startTime | date : 'yyyy-MM-dd HH:mm:ss.SSS' }}{{ trace.serviceName }}{{ trace.rootSpanName }}{{ trace.durationNanos / 1000000 | number : '1.0-2' }} ms{{ trace.status }}{{ trace.traceId }} + + + + +
+
+
+ Span +
0 ms{{ waterfallDurationMs() | number : '1.0-2' }} ms
+ {{ 'observability.column.duration' | i18n }} +
+
{{ span.serviceName }}{{ span.spanName }}
{{ span.durationNanos / 1000000 | number : '1.0-2' }} ms
+
+ +
+

{{ 'observability.traces.span-details' | i18n }}

+
+
Span ID
{{ selectedSpan.spanId }}
{{ 'observability.column.status' | i18n }}
{{ spanStatus(selectedSpan) }}{{ selectedSpan.statusMessage }}
+
{{ item.key }}
{{ item.value }}
+
+

{{ 'observability.traces.events' | i18n }}

+
+
+
{{ event.name }}
+
+
{{ attribute.key }}
{{ attribute.value }}
+
+
+
+

{{ 'observability.empty.events' | i18n }}

+
+

{{ 'observability.traces.resource-attributes' | i18n }}

{{ item.key }}
{{ item.value }}
+
+
+
+
diff --git a/web-app/src/app/routes/trace/trace-manage.component.less b/web-app/src/app/routes/trace/trace-manage.component.less new file mode 100644 index 00000000000..6a7eced52ef --- /dev/null +++ b/web-app/src/app/routes/trace/trace-manage.component.less @@ -0,0 +1,271 @@ +@import '~src/styles/theme'; + +.signal-page { + min-height: 100%; +} + +.page-heading { + display: flex; + gap: 24px; + align-items: flex-start; + justify-content: space-between; + padding-bottom: 18px; + border-bottom: 1px solid @border-color-split; +} + +.page-heading h1 { + margin: 0 0 5px; + font-weight: 600; + font-size: 24px; +} + +.page-heading p { + margin: 0; + color: @text-color-secondary; +} + +.query-toolbar { + display: grid; + grid-template-columns: repeat(4, minmax(150px, 1fr)); + gap: 8px; + align-items: center; + padding: 16px 0; +} + +.query-toolbar app-signal-time-range { + grid-column: 1 / -1; +} + +.overview-strip { + display: grid; + grid-template-columns: repeat(5, 1fr); + margin-bottom: 16px; + border: 1px solid @border-color-split; +} + +.overview-strip div { + display: flex; + flex-direction: column; + gap: 5px; + padding: 12px 16px; + border-right: 1px solid @border-color-split; +} + +.overview-strip div:last-child { + border-right: 0; +} + +.overview-strip span { + color: @text-color-secondary; + font-size: 12px; +} + +.overview-strip strong { + font-weight: 600; + font-size: 20px; +} + +.section-title { + display: flex; + gap: 8px; + align-items: center; + margin: 18px 0 10px; +} + +.section-title h2 { + margin: 0; + font-size: 16px; +} + +.section-title span { + color: @text-color-secondary; +} + +.mono { + max-width: 260px; + overflow: hidden; + font-family: SFMono-Regular, Consolas, monospace; + white-space: nowrap; + text-overflow: ellipsis; +} + +.drawer-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + padding-bottom: 14px; + border-bottom: 1px solid @border-color-split; +} + +.waterfall-axis { + display: grid; + grid-template-columns: 210px minmax(220px, 1fr) 82px; + gap: 12px; + align-items: end; + min-height: 34px; + color: @text-color-secondary; + font-size: 11px; + border-bottom: 1px solid @border-color-split; +} + +.time-ruler { + display: flex; + align-items: flex-end; + justify-content: space-between; + height: 24px; + padding-top: 7px; + background-image: linear-gradient(to right, @border-color-split 1px, transparent 1px); + background-repeat: repeat-x; + background-position: left bottom; + background-size: 25% 7px; +} + +.waterfall-row { + display: grid; + grid-template-columns: 210px minmax(220px, 1fr) 82px; + gap: 12px; + align-items: center; + min-height: 46px; + border-bottom: 1px solid @border-color-split; + cursor: pointer; +} + +.waterfall-row:hover, +.selected-row { + background: fade(@primary-color, 5%); +} + +.span-name { + min-width: 0; + border-left: 2px solid fade(@primary-color, 18%); +} + +.span-name strong, +.span-name span { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.span-name span { + color: @text-color-secondary; + font-size: 12px; +} + +.span-track { + position: relative; + height: 18px; + background: fade(@primary-color, 6%); + background-image: linear-gradient(to right, @border-color-split 1px, transparent 1px); + background-size: 25% 100%; +} + +.span-bar { + position: absolute; + top: 2px; + min-width: 3px; + height: 14px; + background: @primary-color; +} + +.error-bar { + background: @error-color; +} + +.attribute-section { + margin-top: 24px; +} + +.attribute-section h3 { + font-size: 14px; +} + +.attribute-section h4 { + margin: 20px 0 8px; + color: @text-color-secondary; + font-weight: 500; + font-size: 12px; + text-transform: uppercase; +} + +.attribute-section dl { + display: grid; + grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.2fr); + column-gap: 24px; +} + +.attribute-section dt, +.attribute-section dd { + margin: 0; + padding: 8px 0; + border-bottom: 1px solid @border-color-split; +} + +.attribute-section dt { + color: @text-color-secondary; + overflow-wrap: anywhere; +} + +.attribute-section dd { + min-width: 0; + overflow-wrap: anywhere; +} + +.status-value { + display: flex; + gap: 8px; + align-items: center; +} + +.event-list { + border-top: 1px solid @border-color-split; +} + +.event-item { + padding: 12px 0; + border-bottom: 1px solid @border-color-split; +} + +.event-item > header { + display: flex; + gap: 16px; + align-items: baseline; + justify-content: space-between; +} + +.event-item time { + color: @text-color-secondary; + font-size: 12px; + white-space: nowrap; +} + +.event-item dl { + margin: 8px 0 0; + padding-left: 12px; + border-left: 2px solid fade(@primary-color, 18%); +} + +.event-item dt, +.event-item dd { + padding: 5px 0; + font-size: 12px; +} + +.inline-empty { + margin: 0; + padding: 10px 0; + color: @text-color-secondary; +} + +.attribute-section pre { + max-height: 180px; + margin: 0; + overflow: auto; + white-space: pre-wrap; +} +@media (max-width: 1300px) { + .query-toolbar { + grid-template-columns: repeat(4, minmax(150px, 1fr)); + } +} diff --git a/web-app/src/app/routes/trace/trace-manage.component.spec.ts b/web-app/src/app/routes/trace/trace-manage.component.spec.ts new file mode 100644 index 00000000000..044a995975f --- /dev/null +++ b/web-app/src/app/routes/trace/trace-manage.component.spec.ts @@ -0,0 +1,126 @@ +/* + * 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 { fakeAsync, tick } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { of } from 'rxjs'; + +import { ObservabilityService, TraceListItem, TraceSpanNode } from '../../service/observability.service'; +import { TraceManageComponent } from './trace-manage.component'; + +describe('TraceManageComponent', () => { + function span(spanId: string, parentSpanId: string, status: string): TraceSpanNode { + return { + traceId: 'trace', + spanId, + parentSpanId, + spanName: spanId, + serviceName: 'service', + status, + spanKind: 'SPAN_KIND_INTERNAL', + statusMessage: '', + durationNanos: 1_000_000, + startTime: 1, + resourceAttributes: {}, + spanAttributes: {}, + spanEvents: [] + }; + } + + function summary(): TraceListItem { + return { + traceId: 'trace', + rootSpanId: 'root', + serviceName: 'service', + serviceNamespace: '', + rootSpanName: 'operation', + durationNanos: 1_000_000, + status: 'STATUS_CODE_OK', + startTime: 1, + errorSpanCount: 0, + resourceAttributes: {} + }; + } + + it('recognizes Greptime status codes and calculates hierarchy depth', () => { + const component = new TraceManageComponent( + jasmine.createSpyObj('ObservabilityService', ['queryTraces', 'traceOverview', 'traceDetail']), + { snapshot: { queryParamMap: convertToParamMap({}) } } as unknown as ActivatedRoute, + jasmine.createSpyObj('Router', ['navigate']) + ); + const root = span('root', '', 'STATUS_CODE_OK'); + const child = span('child', 'root', 'STATUS_CODE_ERROR'); + const leaf = span('leaf', 'child', 'STATUS_CODE_OK'); + component.selected = { summary: summary(), spans: [root, child, leaf] }; + + expect(component.isErrorSpan(child)).toBeTrue(); + expect(component.spanStatus(child)).toBe('ERROR'); + expect(component.spanDepth(leaf)).toBe(2); + expect(component.waterfallDurationMs()).toBe(1); + }); + + it('restores bounded duration and status filters and persists them after querying', fakeAsync(() => { + const observability = jasmine.createSpyObj('ObservabilityService', [ + 'queryTraces', + 'traceOverview', + 'traceDetail' + ]); + observability.queryTraces.and.returnValue( + of({ code: 0, data: { content: [], pageIndex: 0, pageSize: 20, totalElements: 0 }, msg: '' }) + ); + observability.traceOverview.and.returnValue( + of({ code: 0, data: { totalCount: 0, errorCount: 0, errorRate: 0, averageDurationMillis: 0, p95DurationMillis: 0 }, msg: '' }) + ); + const route = { + snapshot: { + queryParamMap: convertToParamMap({ + start: 1_000, + end: 2_000, + environment: 'staging', + errorOnly: 'true', + minDurationMs: '1', + maxDurationMs: '5000', + refresh: '10' + }) + } + } as unknown as ActivatedRoute; + const router = jasmine.createSpyObj('Router', ['navigate']); + const component = new TraceManageComponent(observability, route, router); + + component.ngOnInit(); + tick(250); + + expect(observability.queryTraces).toHaveBeenCalledWith( + jasmine.objectContaining({ start: 1_000, end: 2_000, environment: 'staging' }), + 0, + 20, + true, + 1, + 5_000 + ); + expect(router.navigate).toHaveBeenCalledWith( + [], + jasmine.objectContaining({ + queryParams: jasmine.objectContaining({ errorOnly: true, minDurationMs: 1, maxDurationMs: 5_000, refresh: 10 }), + replaceUrl: true + }) + ); + component.ngOnDestroy(); + })); +}); diff --git a/web-app/src/app/routes/trace/trace-manage.component.ts b/web-app/src/app/routes/trace/trace-manage.component.ts new file mode 100644 index 00000000000..405d2f31d9a --- /dev/null +++ b/web-app/src/app/routes/trace/trace-manage.component.ts @@ -0,0 +1,289 @@ +/* + * 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, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { I18nPipe } from '@delon/theme'; +import { SharedModule } from '@shared'; +import { NzDrawerModule } from 'ng-zorro-antd/drawer'; +import { NzEmptyModule } from 'ng-zorro-antd/empty'; +import { NzTableModule } from 'ng-zorro-antd/table'; +import { NzTagModule } from 'ng-zorro-antd/tag'; +import { Subject, debounceTime, finalize, forkJoin, switchMap, takeUntil } from 'rxjs'; + +import { + ObservabilityService, + SignalContext, + TraceDetail, + TraceListItem, + TraceOverview, + TraceSpanNode +} from '../../service/observability.service'; +import { SignalNavigationComponent } from '../observability/signal-navigation.component'; +import { moveSignalTimeRangeToNow, readSignalTimeRange, toSignalTimeContext } from '../observability/signal-query-context'; +import { SignalTimeRangeComponent } from '../observability/signal-time-range.component'; + +@Component({ + selector: 'app-trace-manage', + standalone: true, + imports: [ + CommonModule, + FormsModule, + I18nPipe, + SharedModule, + NzDrawerModule, + NzEmptyModule, + NzTableModule, + NzTagModule, + SignalNavigationComponent, + SignalTimeRangeComponent + ], + templateUrl: './trace-manage.component.html', + styleUrl: './trace-manage.component.less' +}) +export class TraceManageComponent implements OnInit, OnDestroy { + timeRange: Date[] = []; + serviceName = ''; + serviceNamespace = ''; + environment = ''; + operationName = ''; + traceId = ''; + errorOnly = false; + minDurationMs?: number; + maxDurationMs?: number; + refreshSeconds = 0; + pageIndex = 1; + pageSize = 20; + total = 0; + traces: TraceListItem[] = []; + overview: TraceOverview = { totalCount: 0, errorCount: 0, errorRate: 0, averageDurationMillis: 0, p95DurationMillis: 0 }; + selected?: TraceDetail; + selectedSpan?: TraceSpanNode; + detailVisible = false; + loading = false; + detailLoading = false; + error = ''; + private readonly queryChanges = new Subject(); + private readonly destroyed = new Subject(); + + constructor(private observability: ObservabilityService, private route: ActivatedRoute, private router: Router) {} + + ngOnInit(): void { + const params = this.route.snapshot.queryParamMap; + this.timeRange = readSignalTimeRange(params); + this.traceId = params.get('traceId') || ''; + this.serviceName = params.get('serviceName') || ''; + this.serviceNamespace = params.get('serviceNamespace') || ''; + this.environment = params.get('environment') || ''; + this.operationName = params.get('operationName') || ''; + this.errorOnly = params.get('errorOnly') === 'true'; + this.minDurationMs = this.readOptionalNumber(params.get('minDurationMs')); + this.maxDurationMs = this.readOptionalNumber(params.get('maxDurationMs')); + this.refreshSeconds = Math.max(0, Number(params.get('refresh')) || 0); + this.queryChanges + .pipe( + debounceTime(250), + switchMap(() => { + this.loading = true; + this.error = ''; + return forkJoin({ + list: this.observability.queryTraces( + this.context(), + this.pageIndex - 1, + this.pageSize, + this.errorOnly, + this.minDurationMs, + this.maxDurationMs + ), + overview: this.observability.traceOverview(this.context(), this.errorOnly) + }).pipe(finalize(() => (this.loading = false))); + }), + takeUntil(this.destroyed) + ) + .subscribe({ + next: result => { + if (result.list.code !== 0 || result.overview.code !== 0) { + this.error = result.list.msg || result.overview.msg; + return; + } + this.traces = result.list.data?.content || []; + this.total = result.list.data?.totalElements || 0; + this.overview = result.overview.data || this.overview; + this.updateUrl(); + }, + error: error => (this.error = error?.error?.msg || error?.message || 'Storage unavailable') + }); + this.search(); + } + + ngOnDestroy(): void { + this.destroyed.next(); + this.destroyed.complete(); + } + + search(): void { + this.queryChanges.next(); + } + + refreshToNow(): void { + this.timeRange = moveSignalTimeRangeToNow(this.timeRange); + this.search(); + } + + pageChanged(page: number): void { + this.pageIndex = page; + this.search(); + } + + openTrace(trace: TraceListItem): void { + this.detailVisible = true; + this.detailLoading = true; + this.observability + .traceDetail(trace.traceId) + .pipe( + finalize(() => (this.detailLoading = false)), + takeUntil(this.destroyed) + ) + .subscribe({ + next: message => { + if (message.code === 0) { + this.selected = message.data; + this.selectedSpan = message.data?.spans?.[0]; + } else this.error = message.msg; + }, + error: error => (this.error = error?.error?.msg || error?.message || 'Storage unavailable') + }); + } + + selectSpan(span: TraceSpanNode): void { + this.selectedSpan = span; + } + + viewLogs(): void { + if (!this.selected?.summary) return; + const summary = this.selected.summary; + this.router.navigate(['/log/manage'], { + queryParams: { + ...this.timeContext(summary), + traceId: summary.traceId, + environment: summary.resourceAttributes?.['deployment.environment.name'] + } + }); + } + + viewMetrics(): void { + if (!this.selected?.summary) return; + const summary = this.selected.summary; + this.router.navigate(['/metrics/manage'], { + queryParams: { + ...this.timeContext(summary), + serviceName: summary.serviceName, + serviceNamespace: summary.serviceNamespace, + environment: summary.resourceAttributes?.['deployment.environment.name'], + operationName: summary.rootSpanName + } + }); + } + + navigationContext(): SignalContext & { refresh?: number } { + return { ...this.context(), refresh: this.refreshSeconds || undefined }; + } + + waterfallOffset(span: TraceSpanNode): number { + const spans = this.selected?.spans || []; + const start = Math.min(...spans.map(item => item.startTime)); + const end = Math.max(...spans.map(item => item.startTime + item.durationNanos / 1_000_000)); + return end === start ? 0 : ((span.startTime - start) / (end - start)) * 100; + } + + waterfallWidth(span: TraceSpanNode): number { + const spans = this.selected?.spans || []; + const start = Math.min(...spans.map(item => item.startTime)); + const end = Math.max(...spans.map(item => item.startTime + item.durationNanos / 1_000_000)); + return Math.max(0.7, (span.durationNanos / 1_000_000 / Math.max(1, end - start)) * 100); + } + + waterfallDurationMs(): number { + const spans = this.selected?.spans || []; + if (!spans.length) return 0; + const start = Math.min(...spans.map(item => item.startTime)); + const end = Math.max(...spans.map(item => item.startTime + item.durationNanos / 1_000_000)); + return Math.max(0, end - start); + } + + spanDepth(span: TraceSpanNode): number { + const spans = this.selected?.spans || []; + const byId = new Map(spans.map(item => [item.spanId, item])); + let current = span; + let depth = 0; + const visited = new Set(); + while (current.parentSpanId && byId.has(current.parentSpanId) && !visited.has(current.parentSpanId)) { + visited.add(current.parentSpanId); + current = byId.get(current.parentSpanId)!; + depth++; + } + return Math.min(depth, 8); + } + + isErrorSpan(span?: TraceSpanNode): boolean { + return span?.status === 'ERROR' || span?.status === 'STATUS_CODE_ERROR'; + } + + spanStatus(span: TraceSpanNode): string { + return this.isErrorSpan(span) ? 'ERROR' : 'OK'; + } + + private context(): SignalContext { + return { + ...toSignalTimeContext(this.timeRange), + traceId: this.traceId, + serviceName: this.serviceName, + serviceNamespace: this.serviceNamespace, + environment: this.environment, + operationName: this.operationName + }; + } + + private updateUrl(): void { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { + ...this.context(), + errorOnly: this.errorOnly || null, + minDurationMs: this.minDurationMs ?? null, + maxDurationMs: this.maxDurationMs ?? null, + refresh: this.refreshSeconds || null + }, + replaceUrl: true + }); + } + + private readOptionalNumber(value: string | null): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + + private timeContext(trace: TraceListItem): { start: number; end: number } { + const padding = Math.max(30_000, trace.durationNanos / 1_000_000 + 5_000); + return { start: trace.startTime - padding, end: trace.startTime + padding }; + } +} diff --git a/web-app/src/app/service/log.service.ts b/web-app/src/app/service/log.service.ts index 0f5b70ea7d1..2fc75b7ee3a 100644 --- a/web-app/src/app/service/log.service.ts +++ b/web-app/src/app/service/log.service.ts @@ -25,107 +25,60 @@ 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'; +export interface LogQueryOptions { + start?: number; + end?: number; + traceId?: string; + spanId?: string; + severityNumber?: number; + severityText?: string; + search?: string; + serviceName?: string; + serviceNamespace?: string; + environment?: string; + resource?: string; + pageIndex?: number; + pageSize?: number; +} + +export interface LogOverviewStats { + totalCount: number; + errorCount: number; + warnCount: number; + traceCoverage?: { + withTrace: number; + }; +} @Injectable({ providedIn: 'root' }) export class LogService { constructor(private http: HttpClient) {} - public list( - start?: number, - end?: number, - traceId?: string, - spanId?: string, - severityNumber?: number, - severityText?: string, - search?: 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); - if (search) params = params.set('search', search); - params = params.set('pageIndex', pageIndex); - params = params.set('pageSize', pageSize); - return this.http.get>(logs_list_uri, { params }); + list(options: LogQueryOptions): Observable>> { + return this.http.get>>('/logs/list', { params: this.params(options) }); } - public overviewStats( - start?: number, - end?: number, - traceId?: string, - spanId?: string, - severityNumber?: number, - severityText?: string, - search?: 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); - if (search) params = params.set('search', search); - return this.http.get>(logs_stats_overview_uri, { params }); + overviewStats(options: LogQueryOptions): Observable> { + return this.http.get>('/logs/stats/overview', { params: this.params(options) }); } - public trendStats( - start?: number, - end?: number, - traceId?: string, - spanId?: string, - severityNumber?: number, - severityText?: string, - search?: 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); - if (search) params = params.set('search', search); - return this.http.get>(logs_stats_trend_uri, { params }); + trendStats(options: LogQueryOptions): Observable }>> { + return this.http.get }>>('/logs/stats/trend', { + params: this.params(options) + }); } - public traceCoverageStats( - start?: number, - end?: number, - traceId?: string, - spanId?: string, - severityNumber?: number, - severityText?: string, - search?: string - ): Observable> { + batchDelete(timeUnixNanos: number[]): 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); - if (search) params = params.set('search', search); - return this.http.get>(logs_stats_trace_coverage_uri, { params }); + timeUnixNanos.forEach(value => (params = params.append('timeUnixNanos', value))); + return this.http.delete>('/logs', { params }); } - public batchDelete(timeUnixNanos: number[]): Observable> { - let httpParams = new HttpParams(); - timeUnixNanos.forEach(timeUnixNano => { - httpParams = httpParams.append('timeUnixNanos', timeUnixNano); + private params(options: LogQueryOptions): HttpParams { + let params = new HttpParams(); + Object.entries(options).forEach(([key, value]) => { + if (value != null && value !== '') params = params.set(key, value); }); - const options = { params: httpParams }; - return this.http.delete>(logs_batch_delete_uri, options); + return params; } } diff --git a/web-app/src/app/service/notice-receiver.service.spec.ts b/web-app/src/app/service/notice-receiver.service.spec.ts index b0e1c031894..e7ef04d9128 100644 --- a/web-app/src/app/service/notice-receiver.service.spec.ts +++ b/web-app/src/app/service/notice-receiver.service.spec.ts @@ -19,14 +19,14 @@ import { TestBed } from '@angular/core/testing'; -import { NoticeReceiverMailService } from './notice-receiver.service'; +import { NoticeReceiverService } from './notice-receiver.service'; describe('NoticeReceiverService', () => { - let service: NoticeReceiverMailService; + let service: NoticeReceiverService; beforeEach(() => { TestBed.configureTestingModule({}); - service = TestBed.inject(NoticeReceiverMailService); + service = TestBed.inject(NoticeReceiverService); }); it('should be created', () => { diff --git a/web-app/src/app/service/observability.service.spec.ts b/web-app/src/app/service/observability.service.spec.ts new file mode 100644 index 00000000000..c6b2c307140 --- /dev/null +++ b/web-app/src/app/service/observability.service.spec.ts @@ -0,0 +1,76 @@ +/* + * 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, HttpContext } from '@angular/common/http'; +import { Observable, of } from 'rxjs'; + +import { SILENT_HTTP_ERROR } from '../core/interceptor/http-context'; +import { ObservabilityService, OtlpConnectionForm, SignalProbeResult } from './observability.service'; + +interface ObservabilityServiceInternals { + failedProbe(signal: SignalProbeResult['signal'], error: unknown): SignalProbeResult; + send(config: OtlpConnectionForm, signal: string, payload: unknown): Observable; +} + +interface HttpRequestOptions { + context: HttpContext; +} + +describe('ObservabilityService', () => { + it('maps browser transport failures to a user-facing message key', () => { + const service = new ObservabilityService({} as HttpClient); + const result = (service as unknown as ObservabilityServiceInternals).failedProbe('metrics', new ProgressEvent('error')); + + expect(result.errorKey).toBe('observability.integration.error.network'); + expect(result.error).toBeUndefined(); + }); + + it('maps wrapped HTTP transport failures to the same message key', () => { + const service = new ObservabilityService({} as HttpClient); + const result = (service as unknown as ObservabilityServiceInternals).failedProbe('logs', { + status: 0, + error: new ProgressEvent('error') + }); + + expect(result.errorKey).toBe('observability.integration.error.network'); + }); + + it('preserves a server error message', () => { + const service = new ObservabilityService({} as HttpClient); + const result = (service as unknown as ObservabilityServiceInternals).failedProbe('traces', { + error: { msg: 'Storage unavailable' } + }); + + expect(result.error).toBe('Storage unavailable'); + expect(result.errorKey).toBeUndefined(); + }); + + it('marks probe ingestion requests as locally handled', () => { + const http = jasmine.createSpyObj('HttpClient', ['post']); + http.post.and.returnValue(of({})); + const service = new ObservabilityService(http); + + (service as unknown as ObservabilityServiceInternals) + .send({ endpoint: 'http://localhost:1157', token: 'token', serviceName: 'service', environment: 'test' }, 'metrics', {}) + .subscribe(); + + const options = http.post.calls.mostRecent().args[2] as HttpRequestOptions; + expect(options.context.get(SILENT_HTTP_ERROR)).toBeTrue(); + }); +}); diff --git a/web-app/src/app/service/observability.service.ts b/web-app/src/app/service/observability.service.ts new file mode 100644 index 00000000000..22f32e8579b --- /dev/null +++ b/web-app/src/app/service/observability.service.ts @@ -0,0 +1,378 @@ +/* + * 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, HttpContext, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, catchError, forkJoin, map, of, switchMap, timer } from 'rxjs'; + +import { SILENT_HTTP_ERROR } from '../core/interceptor/http-context'; +import { Message } from '../pojo/Message'; + +export interface OtlpConnectionForm { + endpoint: string; + token: string; + serviceName: string; + environment: string; +} + +export interface SignalProbeResult { + signal: 'metrics' | 'logs' | 'traces'; + status: 'success' | 'error'; + lastReceived?: number; + error?: string; + errorKey?: string; +} + +export interface SignalContext { + start?: number; + end?: number; + serviceName?: string; + serviceNamespace?: string; + environment?: string; + traceId?: string; + spanId?: string; + operationName?: string; +} + +export interface MetricPoint { + timestamp: number; + value: number; +} + +export interface MetricSeries { + labels: Record; + points: MetricPoint[]; +} + +export interface MetricsConsole { + query: string; + start: number; + end: number; + step: number; + series: MetricSeries[]; +} + +export interface TraceListItem { + traceId: string; + rootSpanId: string; + serviceName: string; + serviceNamespace: string; + rootSpanName: string; + durationNanos: number; + status: string; + startTime: number; + errorSpanCount: number; + resourceAttributes: Record; +} + +export interface TraceSpanNode { + traceId: string; + spanId: string; + parentSpanId: string; + spanName: string; + serviceName: string; + status: string; + spanKind: string; + statusMessage: string; + durationNanos: number; + startTime: number; + resourceAttributes: Record; + spanAttributes: Record; + spanEvents: TraceSpanEvent[]; +} + +export interface TraceSpanEvent { + name: string; + time: string; + attributes: Record; +} + +export interface TraceDetail { + summary: TraceListItem; + spans: TraceSpanNode[]; +} + +export interface TraceOverview { + totalCount: number; + errorCount: number; + errorRate: number; + averageDurationMillis: number; + p95DurationMillis: number; +} + +export interface SignalPage { + content: T[]; + pageIndex: number; + pageSize: number; + totalElements: number; +} + +interface LogProbeEntry { + timeUnixNano: number | string; +} + +@Injectable({ providedIn: 'root' }) +export class ObservabilityService { + private readonly silentProbeContext = new HttpContext().set(SILENT_HTTP_ERROR, true); + + constructor(private http: HttpClient) {} + + probe(config: OtlpConnectionForm): Observable { + const now = Date.now(); + const traceId = this.randomHex(32); + const spanId = this.randomHex(16); + return forkJoin([ + this.probeMetrics(config, now), + this.probeLogs(config, now, traceId, spanId), + this.probeTraces(config, now, traceId, spanId) + ]); + } + + metricInventory(context: SignalContext, limit = 100): Observable> { + return this.http.get>('/ingestion/otlp/metrics/inventory', { + params: this.contextParams(context).set('limit', limit) + }); + } + + queryMetrics( + context: SignalContext, + query: string, + filter?: string, + groupBy?: string, + aggregation?: string, + step = 30 + ): Observable> { + let params = this.contextParams(context).set('query', query).set('step', step); + if (filter) params = params.set('filter', filter); + if (groupBy) params = params.set('groupBy', groupBy); + if (aggregation) params = params.set('aggregation', aggregation); + return this.http.get>('/ingestion/otlp/metrics/console', { params }); + } + + queryTraces( + context: SignalContext, + pageIndex: number, + pageSize: number, + errorOnly = false, + minDurationMs?: number, + maxDurationMs?: number + ): Observable>> { + let params = this.contextParams(context).set('pageIndex', pageIndex).set('pageSize', pageSize).set('errorOnly', errorOnly); + if (minDurationMs != null) params = params.set('minDurationMs', minDurationMs); + if (maxDurationMs != null) params = params.set('maxDurationMs', maxDurationMs); + return this.http.get>>('/traces/list', { params }); + } + + traceOverview(context: SignalContext, errorOnly = false): Observable> { + return this.http.get>('/traces/stats/overview', { + params: this.contextParams(context).set('errorOnly', errorOnly) + }); + } + + traceDetail(traceId: string): Observable> { + return this.http.get>(`/traces/${encodeURIComponent(traceId)}`); + } + + private probeMetrics(config: OtlpConnectionForm, now: number): Observable { + const payload = { + resourceMetrics: [ + { + resource: { attributes: this.resourceAttributes(config) }, + scopeMetrics: [ + { + scope: { name: 'hertzbeat-onboarding' }, + metrics: [ + { + name: 'hertzbeat_onboarding_probe', + gauge: { dataPoints: [{ timeUnixNano: this.toNanos(now), asDouble: 1 }] } + } + ] + } + ] + } + ] + }; + return this.send(config, 'metrics', payload).pipe( + switchMap(() => timer(800)), + switchMap(() => { + const params = this.signalParams(config, now).set('query', 'hertzbeat_onboarding_probe').set('step', 1); + return this.http.get>('/ingestion/otlp/metrics/console', { + params, + context: this.silentProbeContext + }); + }), + map(message => { + const values = message.data?.series?.flatMap(series => series.points) || []; + const latest = values.reduce((value, point) => Math.max(value, Number(point.timestamp)), 0); + if (message.code !== 0 || !latest) throw new Error(message.msg || 'Metric probe was not found'); + return { signal: 'metrics', status: 'success', lastReceived: latest } as SignalProbeResult; + }), + catchError(error => of(this.failedProbe('metrics', error))) + ); + } + + private probeLogs(config: OtlpConnectionForm, now: number, traceId: string, spanId: string): Observable { + const payload = { + resourceLogs: [ + { + resource: { attributes: this.resourceAttributes(config) }, + scopeLogs: [ + { + scope: { name: 'hertzbeat-onboarding' }, + logRecords: [ + { + timeUnixNano: this.toNanos(now), + observedTimeUnixNano: this.toNanos(now), + severityNumber: 'SEVERITY_NUMBER_INFO', + severityText: 'INFO', + body: { stringValue: 'HertzBeat OTLP onboarding probe' }, + traceId, + spanId + } + ] + } + ] + } + ] + }; + return this.send(config, 'logs', payload).pipe( + switchMap(() => timer(800)), + switchMap(() => + this.http.get>>('/logs/list', { + params: this.signalParams(config, now).set('traceId', traceId).set('pageSize', 1), + context: this.silentProbeContext + }) + ), + map(message => { + const entry = message.data?.content?.[0]; + if (message.code !== 0 || !entry) throw new Error(message.msg || 'Log probe was not found'); + return { + signal: 'logs', + status: 'success', + lastReceived: Number(entry.timeUnixNano) / 1_000_000 + } as SignalProbeResult; + }), + catchError(error => of(this.failedProbe('logs', error))) + ); + } + + private probeTraces(config: OtlpConnectionForm, now: number, traceId: string, spanId: string): Observable { + const payload = { + resourceSpans: [ + { + resource: { attributes: this.resourceAttributes(config) }, + scopeSpans: [ + { + scope: { name: 'hertzbeat-onboarding' }, + spans: [ + { + traceId, + spanId, + name: 'hertzbeat.onboarding.probe', + kind: 'SPAN_KIND_INTERNAL', + startTimeUnixNano: this.toNanos(now), + endTimeUnixNano: this.toNanos(now + 10), + status: { code: 'STATUS_CODE_OK' } + } + ] + } + ] + } + ] + }; + return this.send(config, 'traces', payload).pipe( + switchMap(() => timer(800)), + switchMap(() => + this.http.get>>('/traces/list', { + params: this.signalParams(config, now).set('traceId', traceId).set('pageSize', 1), + context: this.silentProbeContext + }) + ), + map(message => { + if (message.code !== 0 || !message.data?.content?.length) { + throw new Error(message.msg || 'Trace probe was not found'); + } + return { signal: 'traces', status: 'success', lastReceived: now } as SignalProbeResult; + }), + catchError(error => of(this.failedProbe('traces', error))) + ); + } + + private send(config: OtlpConnectionForm, signal: string, payload: unknown): Observable { + const endpoint = config.endpoint.replace(/\/+$/, ''); + const headers = new HttpHeaders({ Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' }); + return this.http.post(`${endpoint}/api/otlp/v1/${signal}`, payload, { headers, context: this.silentProbeContext }); + } + + private signalParams(config: OtlpConnectionForm, now: number): HttpParams { + return new HttpParams() + .set('start', now - 60_000) + .set('end', now + 60_000) + .set('serviceName', config.serviceName) + .set('environment', config.environment); + } + + contextParams(context: SignalContext): HttpParams { + let params = new HttpParams(); + Object.entries(context).forEach(([key, value]) => { + if (value != null && value !== '') params = params.set(key, value); + }); + return params; + } + + private resourceAttributes(config: OtlpConnectionForm): unknown[] { + return [ + { key: 'service.name', value: { stringValue: config.serviceName } }, + { key: 'deployment.environment.name', value: { stringValue: config.environment } } + ]; + } + + private toNanos(timestamp: number): string { + return (BigInt(timestamp) * 1_000_000n).toString(); + } + + private randomHex(length: number): string { + const bytes = new Uint8Array(length / 2); + crypto.getRandomValues(bytes); + return Array.from(bytes, value => value.toString(16).padStart(2, '0')).join(''); + } + + private failedProbe(signal: SignalProbeResult['signal'], error: unknown): SignalProbeResult { + const failure = this.asRecord(error); + const response = failure?.['error']; + if (error instanceof ProgressEvent || failure?.['status'] === 0 || response instanceof ProgressEvent) { + return { signal, status: 'error', errorKey: 'observability.integration.error.network' }; + } + const responseMessage = this.asRecord(response)?.['msg']; + const responseBody = typeof response === 'string' ? response : undefined; + const message = failure?.['message']; + const detail = + typeof responseMessage === 'string' ? responseMessage : responseBody || (typeof message === 'string' ? message : undefined); + return { + signal, + status: 'error', + error: detail, + errorKey: detail ? undefined : 'observability.integration.error.request' + }; + } + + private asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null ? (value as Record) : undefined; + } +} diff --git a/web-app/src/assets/app-data.json b/web-app/src/assets/app-data.json index c880a953fe2..87b6f3fb3b0 100644 --- a/web-app/src/assets/app-data.json +++ b/web-app/src/assets/app-data.json @@ -137,28 +137,34 @@ ] }, { - "text": "Log", - "i18n": "menu.log", + "text": "Observability", + "i18n": "menu.observability", "group": true, "hideInBreadcrumb": true, "children": [ { "text": "Integration", - "i18n": "menu.log.integration", + "i18n": "menu.observability.integration", "icon": "anticon-api", - "link": "/log/integration/otlp" + "link": "/observability/integration" }, { - "text": "Stream", - "i18n": "menu.log.stream", - "icon": "anticon-file-text", - "link": "/log/stream" + "text": "Metrics", + "i18n": "menu.observability.metrics", + "icon": "anticon-line-chart", + "link": "/metrics/manage" }, { "text": "Manage", - "i18n": "menu.log.manage", + "i18n": "menu.observability.logs", "icon": "anticon-database", "link": "/log/manage" + }, + { + "text": "Traces", + "i18n": "menu.observability.traces", + "icon": "anticon-node-index", + "link": "/trace/manage" } ] }, diff --git a/web-app/src/assets/i18n/en-US.json b/web-app/src/assets/i18n/en-US.json index 211fd3387c9..4fc35366f49 100644 --- a/web-app/src/assets/i18n/en-US.json +++ b/web-app/src/assets/i18n/en-US.json @@ -633,6 +633,113 @@ "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", + "observability.integration.address": "HertzBeat address", + "observability.integration.address-hint": "Use the address your application can reach. Do not add an OTLP path.", + "observability.integration.configuration": "Copy a configuration", + "observability.integration.configuration-hint": "Choose the format used by your application or collector.", + "observability.integration.connection": "Connection details", + "observability.integration.detect": "Detect ingestion", + "observability.integration.detect-hint": "Sends one temporary metric, log, and trace, then verifies each result.", + "observability.integration.error.network": "Cannot reach HertzBeat. Check the address and network access.", + "observability.integration.error.request": "Detection failed. Check the configuration and try again.", + "observability.integration.generate-token": "Generate", + "observability.integration.grpc-note": "OTLP/gRPC listens on port 4317.", + "observability.integration.protocol": "Protocol", + "observability.integration.ready-to-copy": "Generated from the form above", + "observability.integration.subtitle": "Connect metrics, logs, and traces without configuring entities, pipelines, or tables.", + "observability.integration.title": "Connect OpenTelemetry", + "observability.integration.token": "Access token", + "observability.integration.token-created": "A 30-day access token was generated", + "observability.integration.token-failed": "Unable to generate an access token", + "observability.integration.token-placeholder": "Paste or generate an API token", + "observability.signal.logs": "Logs", + "observability.signal.metrics": "Metrics", + "observability.signal.traces": "Traces", + "observability.action.view-logs": "View related logs", + "observability.action.view-metrics": "View related metrics", + "observability.action.view-trace": "View trace", + "observability.column.duration": "Duration", + "observability.column.labels": "Labels", + "observability.column.operation": "Operation", + "observability.column.service": "Service", + "observability.column.start": "Start time", + "observability.column.status": "Status", + "observability.column.time": "Time", + "observability.column.value": "Value", + "observability.empty.events": "No events recorded for this span", + "observability.empty.metrics": "No metric samples matched this query", + "observability.empty.logs": "No logs matched this query", + "observability.empty.spans": "No spans are available for this trace", + "observability.filter.environment": "Environment", + "observability.filter.errors-only": "Errors only", + "observability.filter.status": "Status", + "observability.filter.all-statuses": "All statuses", + "observability.filter.min-duration": "Minimum duration (ms)", + "observability.filter.max-duration": "Maximum duration (ms)", + "observability.filter.operation": "Operation name", + "observability.filter.service": "Service name", + "observability.filter.trace-id": "Trace ID", + "observability.metrics.aggregation": "Aggregation", + "observability.metrics.filter": "label=value", + "observability.metrics.group-by": "Group by label", + "observability.metrics.metric": "Metric name", + "observability.metrics.promql": "PromQL expression (metric names autocomplete)", + "observability.metrics.step": "Step (seconds)", + "observability.metrics.samples": "Samples", + "observability.metrics.total-series": "Total series", + "observability.metrics.total-samples": "Total samples", + "observability.metrics.minimum": "Minimum", + "observability.metrics.maximum": "Maximum", + "observability.metrics.trend": "Trend", + "observability.metrics.series": "series", + "observability.metrics.series-detail": "Series labels", + "observability.metrics.subtitle": "Inspect OTLP metric trends, series, and raw samples.", + "observability.metrics.title": "Metrics", + "observability.navigation.signals": "Switch signal workspace", + "observability.time.toolbar": "Time range controls", + "observability.time.preset": "Relative time range", + "observability.time.custom": "Custom range", + "observability.time.previous": "Previous time window", + "observability.time.next": "Next time window", + "observability.time.auto-refresh": "Auto refresh interval", + "observability.time.refresh-manual": "Manual refresh", + "observability.time.refresh-10s": "Every 10 seconds", + "observability.time.refresh-30s": "Every 30 seconds", + "observability.time.refresh-1m": "Every 1 minute", + "observability.time.refresh-5m": "Every 5 minutes", + "observability.time.last-15m": "Last 15 minutes", + "observability.time.last-30m": "Last 30 minutes", + "observability.time.last-1h": "Last 1 hour", + "observability.time.last-3h": "Last 3 hours", + "observability.time.last-6h": "Last 6 hours", + "observability.time.last-12h": "Last 12 hours", + "observability.time.last-24h": "Last 24 hours", + "observability.time.last-7d": "Last 7 days", + "observability.logs.detail": "Log detail", + "observability.logs.message": "Message", + "observability.logs.mode.query": "Query", + "observability.logs.mode.stream": "Live", + "observability.logs.resource-filter": "Resource expression: cloud.region = prod AND service.version != dev", + "observability.logs.results": "Log results", + "observability.logs.trend": "Trend", + "observability.logs.search": "Search message", + "observability.logs.severity": "Severity", + "observability.logs.subtitle": "Query historical logs or inspect the live stream.", + "observability.logs.title": "Logs", + "observability.logs.total": "Total logs", + "observability.logs.with-trace": "With trace", + "observability.traces.average": "Average duration", + "observability.traces.detail": "Trace detail", + "observability.traces.error-rate": "Error rate", + "observability.traces.errors": "Error traces", + "observability.traces.events": "Events", + "observability.traces.resource-attributes": "Resource attributes", + "observability.traces.subtitle": "Find slow and failed traces, then inspect their span waterfall.", + "observability.traces.span-details": "Span attributes and events", + "observability.traces.title": "Traces", + "observability.traces.total": "Total traces", + "observability.traces.results": "Trace results", + "common.button.query": "Query", "log.manage.batch-delete": "Batch Delete", "log.manage.basic-information": "Basic Information", "log.manage.clear": "Clear", @@ -758,6 +865,11 @@ "menu.log.integration": "Integration", "menu.log.manage": "Log Manage", "menu.log.stream": "Log Stream", + "menu.observability": "Observability", + "menu.observability.integration": "Connect", + "menu.observability.logs": "Logs", + "menu.observability.metrics": "Metrics", + "menu.observability.traces": "Traces", "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 68336fff41e..c86536d9703 100644 --- a/web-app/src/assets/i18n/ja-JP.json +++ b/web-app/src/assets/i18n/ja-JP.json @@ -592,6 +592,113 @@ "log.integration.token.new": "クリックしてトークンを生成", "log.integration.token.notice": "この内容は一度しか表示されません。トークンを大切に保管し、他人に漏らさないでください。", "log.integration.token.title": "アクセス認証トークン", + "observability.integration.address": "HertzBeat address", + "observability.integration.address-hint": "Use the address your application can reach. Do not add an OTLP path.", + "observability.integration.configuration": "Copy a configuration", + "observability.integration.configuration-hint": "Choose the format used by your application or collector.", + "observability.integration.connection": "Connection details", + "observability.integration.detect": "Detect ingestion", + "observability.integration.detect-hint": "Sends one temporary metric, log, and trace, then verifies each result.", + "observability.integration.error.network": "Cannot reach HertzBeat. Check the address and network access.", + "observability.integration.error.request": "Detection failed. Check the configuration and try again.", + "observability.integration.generate-token": "Generate", + "observability.integration.grpc-note": "OTLP/gRPC listens on port 4317.", + "observability.integration.protocol": "Protocol", + "observability.integration.ready-to-copy": "Generated from the form above", + "observability.integration.subtitle": "Connect metrics, logs, and traces without configuring entities, pipelines, or tables.", + "observability.integration.title": "Connect OpenTelemetry", + "observability.integration.token": "Access token", + "observability.integration.token-created": "A 30-day access token was generated", + "observability.integration.token-failed": "Unable to generate an access token", + "observability.integration.token-placeholder": "Paste or generate an API token", + "observability.signal.logs": "Logs", + "observability.signal.metrics": "Metrics", + "observability.signal.traces": "Traces", + "observability.action.view-logs": "View related logs", + "observability.action.view-metrics": "View related metrics", + "observability.action.view-trace": "View trace", + "observability.column.duration": "Duration", + "observability.column.labels": "Labels", + "observability.column.operation": "Operation", + "observability.column.service": "Service", + "observability.column.start": "Start time", + "observability.column.status": "Status", + "observability.column.time": "Time", + "observability.column.value": "Value", + "observability.empty.events": "No events recorded for this span", + "observability.empty.metrics": "No metric samples matched this query", + "observability.empty.logs": "No logs matched this query", + "observability.empty.spans": "No spans are available for this trace", + "observability.filter.environment": "Environment", + "observability.filter.errors-only": "Errors only", + "observability.filter.status": "Status", + "observability.filter.all-statuses": "All statuses", + "observability.filter.min-duration": "Minimum duration (ms)", + "observability.filter.max-duration": "Maximum duration (ms)", + "observability.filter.operation": "Operation name", + "observability.filter.service": "Service name", + "observability.filter.trace-id": "Trace ID", + "observability.metrics.aggregation": "Aggregation", + "observability.metrics.filter": "label=value", + "observability.metrics.group-by": "Group by label", + "observability.metrics.metric": "Metric name", + "observability.metrics.promql": "PromQL expression (metric names autocomplete)", + "observability.metrics.step": "Step (seconds)", + "observability.metrics.samples": "Samples", + "observability.metrics.total-series": "Total series", + "observability.metrics.total-samples": "Total samples", + "observability.metrics.minimum": "Minimum", + "observability.metrics.maximum": "Maximum", + "observability.metrics.trend": "Trend", + "observability.metrics.series": "series", + "observability.metrics.series-detail": "Series labels", + "observability.metrics.subtitle": "Inspect OTLP metric trends, series, and raw samples.", + "observability.metrics.title": "Metrics", + "observability.navigation.signals": "Switch signal workspace", + "observability.time.toolbar": "Time range controls", + "observability.time.preset": "Relative time range", + "observability.time.custom": "Custom range", + "observability.time.previous": "Previous time window", + "observability.time.next": "Next time window", + "observability.time.auto-refresh": "Auto refresh interval", + "observability.time.refresh-manual": "Manual refresh", + "observability.time.refresh-10s": "Every 10 seconds", + "observability.time.refresh-30s": "Every 30 seconds", + "observability.time.refresh-1m": "Every 1 minute", + "observability.time.refresh-5m": "Every 5 minutes", + "observability.time.last-15m": "Last 15 minutes", + "observability.time.last-30m": "Last 30 minutes", + "observability.time.last-1h": "Last 1 hour", + "observability.time.last-3h": "Last 3 hours", + "observability.time.last-6h": "Last 6 hours", + "observability.time.last-12h": "Last 12 hours", + "observability.time.last-24h": "Last 24 hours", + "observability.time.last-7d": "Last 7 days", + "observability.logs.detail": "Log detail", + "observability.logs.message": "Message", + "observability.logs.mode.query": "Query", + "observability.logs.mode.stream": "Live", + "observability.logs.resource-filter": "Resource expression: cloud.region = prod AND service.version != dev", + "observability.logs.results": "Log results", + "observability.logs.trend": "Trend", + "observability.logs.search": "Search message", + "observability.logs.severity": "Severity", + "observability.logs.subtitle": "Query historical logs or inspect the live stream.", + "observability.logs.title": "Logs", + "observability.logs.total": "Total logs", + "observability.logs.with-trace": "With trace", + "observability.traces.average": "Average duration", + "observability.traces.detail": "Trace detail", + "observability.traces.error-rate": "Error rate", + "observability.traces.errors": "Error traces", + "observability.traces.events": "Events", + "observability.traces.resource-attributes": "Resource attributes", + "observability.traces.subtitle": "Find slow and failed traces, then inspect their span waterfall.", + "observability.traces.span-details": "Span attributes and events", + "observability.traces.title": "Traces", + "observability.traces.total": "Total traces", + "observability.traces.results": "Trace results", + "common.button.query": "Query", "log.manage.batch-delete": "一括削除", "log.manage.basic-information": "基本情報", "log.manage.clear": "クリア", @@ -717,6 +824,11 @@ "menu.log.integration": "統合", "menu.log.manage": "ログ管理", "menu.log.stream": "ログストリーム", + "menu.observability": "Observability", + "menu.observability.integration": "Connect", + "menu.observability.logs": "Logs", + "menu.observability.metrics": "Metrics", + "menu.observability.traces": "Traces", "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 b9b9c520dd9..77003bc6416 100644 --- a/web-app/src/assets/i18n/pt-BR.json +++ b/web-app/src/assets/i18n/pt-BR.json @@ -361,6 +361,113 @@ "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", + "observability.integration.address": "HertzBeat address", + "observability.integration.address-hint": "Use the address your application can reach. Do not add an OTLP path.", + "observability.integration.configuration": "Copy a configuration", + "observability.integration.configuration-hint": "Choose the format used by your application or collector.", + "observability.integration.connection": "Connection details", + "observability.integration.detect": "Detect ingestion", + "observability.integration.detect-hint": "Sends one temporary metric, log, and trace, then verifies each result.", + "observability.integration.error.network": "Cannot reach HertzBeat. Check the address and network access.", + "observability.integration.error.request": "Detection failed. Check the configuration and try again.", + "observability.integration.generate-token": "Generate", + "observability.integration.grpc-note": "OTLP/gRPC listens on port 4317.", + "observability.integration.protocol": "Protocol", + "observability.integration.ready-to-copy": "Generated from the form above", + "observability.integration.subtitle": "Connect metrics, logs, and traces without configuring entities, pipelines, or tables.", + "observability.integration.title": "Connect OpenTelemetry", + "observability.integration.token": "Access token", + "observability.integration.token-created": "A 30-day access token was generated", + "observability.integration.token-failed": "Unable to generate an access token", + "observability.integration.token-placeholder": "Paste or generate an API token", + "observability.signal.logs": "Logs", + "observability.signal.metrics": "Metrics", + "observability.signal.traces": "Traces", + "observability.action.view-logs": "View related logs", + "observability.action.view-metrics": "View related metrics", + "observability.action.view-trace": "View trace", + "observability.column.duration": "Duration", + "observability.column.labels": "Labels", + "observability.column.operation": "Operation", + "observability.column.service": "Service", + "observability.column.start": "Start time", + "observability.column.status": "Status", + "observability.column.time": "Time", + "observability.column.value": "Value", + "observability.empty.events": "No events recorded for this span", + "observability.empty.metrics": "No metric samples matched this query", + "observability.empty.logs": "No logs matched this query", + "observability.empty.spans": "No spans are available for this trace", + "observability.filter.environment": "Environment", + "observability.filter.errors-only": "Errors only", + "observability.filter.status": "Status", + "observability.filter.all-statuses": "All statuses", + "observability.filter.min-duration": "Minimum duration (ms)", + "observability.filter.max-duration": "Maximum duration (ms)", + "observability.filter.operation": "Operation name", + "observability.filter.service": "Service name", + "observability.filter.trace-id": "Trace ID", + "observability.metrics.aggregation": "Aggregation", + "observability.metrics.filter": "label=value", + "observability.metrics.group-by": "Group by label", + "observability.metrics.metric": "Metric name", + "observability.metrics.promql": "PromQL expression (metric names autocomplete)", + "observability.metrics.step": "Step (seconds)", + "observability.metrics.samples": "Samples", + "observability.metrics.total-series": "Total series", + "observability.metrics.total-samples": "Total samples", + "observability.metrics.minimum": "Minimum", + "observability.metrics.maximum": "Maximum", + "observability.metrics.trend": "Trend", + "observability.metrics.series": "series", + "observability.metrics.series-detail": "Series labels", + "observability.metrics.subtitle": "Inspect OTLP metric trends, series, and raw samples.", + "observability.metrics.title": "Metrics", + "observability.navigation.signals": "Switch signal workspace", + "observability.time.toolbar": "Time range controls", + "observability.time.preset": "Relative time range", + "observability.time.custom": "Custom range", + "observability.time.previous": "Previous time window", + "observability.time.next": "Next time window", + "observability.time.auto-refresh": "Auto refresh interval", + "observability.time.refresh-manual": "Manual refresh", + "observability.time.refresh-10s": "Every 10 seconds", + "observability.time.refresh-30s": "Every 30 seconds", + "observability.time.refresh-1m": "Every 1 minute", + "observability.time.refresh-5m": "Every 5 minutes", + "observability.time.last-15m": "Last 15 minutes", + "observability.time.last-30m": "Last 30 minutes", + "observability.time.last-1h": "Last 1 hour", + "observability.time.last-3h": "Last 3 hours", + "observability.time.last-6h": "Last 6 hours", + "observability.time.last-12h": "Last 12 hours", + "observability.time.last-24h": "Last 24 hours", + "observability.time.last-7d": "Last 7 days", + "observability.logs.detail": "Log detail", + "observability.logs.message": "Message", + "observability.logs.mode.query": "Query", + "observability.logs.mode.stream": "Live", + "observability.logs.resource-filter": "Resource expression: cloud.region = prod AND service.version != dev", + "observability.logs.results": "Log results", + "observability.logs.trend": "Trend", + "observability.logs.search": "Search message", + "observability.logs.severity": "Severity", + "observability.logs.subtitle": "Query historical logs or inspect the live stream.", + "observability.logs.title": "Logs", + "observability.logs.total": "Total logs", + "observability.logs.with-trace": "With trace", + "observability.traces.average": "Average duration", + "observability.traces.detail": "Trace detail", + "observability.traces.error-rate": "Error rate", + "observability.traces.errors": "Error traces", + "observability.traces.events": "Events", + "observability.traces.resource-attributes": "Resource attributes", + "observability.traces.subtitle": "Find slow and failed traces, then inspect their span waterfall.", + "observability.traces.span-details": "Span attributes and events", + "observability.traces.title": "Traces", + "observability.traces.total": "Total traces", + "observability.traces.results": "Trace results", + "common.button.query": "Query", "log.manage.batch-delete": "Excluir em lote", "log.manage.basic-information": "Informações básicas", "log.manage.clear": "Limpar", @@ -452,6 +559,11 @@ "menu.log.integration": "Integração", "menu.log.manage": "Gerenciamento de Log", "menu.log.stream": "Fluxo de Log", + "menu.observability": "Observability", + "menu.observability.integration": "Connect", + "menu.observability.logs": "Logs", + "menu.observability.metrics": "Metrics", + "menu.observability.traces": "Traces", "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 db4ef2f8562..b7be8345c2a 100644 --- a/web-app/src/assets/i18n/zh-CN.json +++ b/web-app/src/assets/i18n/zh-CN.json @@ -636,6 +636,113 @@ "log.integration.token.new": "点击生成 Token", "log.integration.token.notice": "此内容只会展示一次,请妥善保管您的 Token,不要泄露给他人", "log.integration.token.title": "访问认证 Token", + "observability.integration.address": "HertzBeat 地址", + "observability.integration.address-hint": "填写应用能够访问的 HertzBeat 地址,不需要添加 OTLP 路径。", + "observability.integration.configuration": "复制接入配置", + "observability.integration.configuration-hint": "选择你的应用或 Collector 使用的配置格式。", + "observability.integration.connection": "连接信息", + "observability.integration.detect": "检测接入", + "observability.integration.detect-hint": "发送一条临时指标、日志和链路,并逐项验证查询结果。", + "observability.integration.error.network": "无法连接 HertzBeat,请检查地址和网络访问。", + "observability.integration.error.request": "检测失败,请检查配置后重试。", + "observability.integration.generate-token": "生成", + "observability.integration.grpc-note": "OTLP/gRPC 默认监听 4317 端口。", + "observability.integration.protocol": "协议", + "observability.integration.ready-to-copy": "已根据左侧表单生成", + "observability.integration.subtitle": "无需理解实体、Pipeline 或数据表,即可接入指标、日志和链路。", + "observability.integration.title": "接入 OpenTelemetry", + "observability.integration.token": "访问令牌", + "observability.integration.token-created": "已生成有效期 30 天的访问令牌", + "observability.integration.token-failed": "无法生成访问令牌", + "observability.integration.token-placeholder": "粘贴或生成 API Token", + "observability.signal.logs": "日志", + "observability.signal.metrics": "指标", + "observability.signal.traces": "链路", + "observability.action.view-logs": "查看相关日志", + "observability.action.view-metrics": "查看相关指标", + "observability.action.view-trace": "查看链路", + "observability.column.duration": "耗时", + "observability.column.labels": "标签", + "observability.column.operation": "操作", + "observability.column.service": "服务", + "observability.column.start": "开始时间", + "observability.column.status": "状态", + "observability.column.time": "时间", + "observability.column.value": "数值", + "observability.empty.events": "该 Span 没有记录事件", + "observability.empty.metrics": "当前条件下没有指标样本", + "observability.empty.logs": "当前条件下没有日志", + "observability.empty.spans": "该链路没有可用 Span", + "observability.filter.environment": "环境", + "observability.filter.errors-only": "仅错误", + "observability.filter.status": "状态", + "observability.filter.all-statuses": "全部状态", + "observability.filter.min-duration": "最短耗时(毫秒)", + "observability.filter.max-duration": "最长耗时(毫秒)", + "observability.filter.operation": "操作名称", + "observability.filter.service": "服务名称", + "observability.filter.trace-id": "Trace ID", + "observability.metrics.aggregation": "聚合方式", + "observability.metrics.filter": "标签=值", + "observability.metrics.group-by": "分组标签", + "observability.metrics.metric": "指标名称", + "observability.metrics.promql": "PromQL 表达式(支持指标名补全)", + "observability.metrics.step": "步长(秒)", + "observability.metrics.samples": "样本明细", + "observability.metrics.total-series": "Series 总数", + "observability.metrics.total-samples": "样本总数", + "observability.metrics.minimum": "最小值", + "observability.metrics.maximum": "最大值", + "observability.metrics.trend": "趋势", + "observability.metrics.series": "条 Series", + "observability.metrics.series-detail": "Series 标签", + "observability.metrics.subtitle": "查看 OTLP 指标趋势、Series 和原始样本。", + "observability.metrics.title": "指标", + "observability.navigation.signals": "切换信号工作台", + "observability.time.toolbar": "时间范围控制", + "observability.time.preset": "相对时间范围", + "observability.time.custom": "自定义时间", + "observability.time.previous": "上一个时间窗口", + "observability.time.next": "下一个时间窗口", + "observability.time.auto-refresh": "自动刷新间隔", + "observability.time.refresh-manual": "手动刷新", + "observability.time.refresh-10s": "每 10 秒", + "observability.time.refresh-30s": "每 30 秒", + "observability.time.refresh-1m": "每 1 分钟", + "observability.time.refresh-5m": "每 5 分钟", + "observability.time.last-15m": "最近 15 分钟", + "observability.time.last-30m": "最近 30 分钟", + "observability.time.last-1h": "最近 1 小时", + "observability.time.last-3h": "最近 3 小时", + "observability.time.last-6h": "最近 6 小时", + "observability.time.last-12h": "最近 12 小时", + "observability.time.last-24h": "最近 24 小时", + "observability.time.last-7d": "最近 7 天", + "observability.logs.detail": "日志详情", + "observability.logs.message": "日志内容", + "observability.logs.mode.query": "查询", + "observability.logs.mode.stream": "实时", + "observability.logs.resource-filter": "资源表达式:cloud.region = prod AND service.version != dev", + "observability.logs.results": "日志结果", + "observability.logs.trend": "趋势", + "observability.logs.search": "搜索日志内容", + "observability.logs.severity": "级别", + "observability.logs.subtitle": "查询历史日志或查看实时日志流。", + "observability.logs.title": "日志", + "observability.logs.total": "日志总数", + "observability.logs.with-trace": "含 Trace", + "observability.traces.average": "平均耗时", + "observability.traces.detail": "链路详情", + "observability.traces.error-rate": "错误率", + "observability.traces.errors": "错误链路", + "observability.traces.events": "事件", + "observability.traces.resource-attributes": "资源属性", + "observability.traces.subtitle": "定位慢链路和错误链路,并检查 Span 瀑布图。", + "observability.traces.span-details": "Span 属性与事件", + "observability.traces.title": "链路", + "observability.traces.total": "链路总数", + "observability.traces.results": "链路结果", + "common.button.query": "查询", "log.manage.batch-delete": "批量删除", "log.manage.basic-information": "基本信息", "log.manage.clear": "清空", @@ -748,6 +855,11 @@ "menu.log.integration": "集成接入", "menu.log.manage": "日志管理", "menu.log.stream": "日志流", + "menu.observability": "可观测性", + "menu.observability.integration": "接入", + "menu.observability.logs": "日志", + "menu.observability.metrics": "指标", + "menu.observability.traces": "链路", "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 6cb39791ddb..d53cfa573fc 100644 --- a/web-app/src/assets/i18n/zh-TW.json +++ b/web-app/src/assets/i18n/zh-TW.json @@ -596,6 +596,113 @@ "log.integration.token.new": "點擊生成 Token", "log.integration.token.notice": "此內容只會顯示一次,請妥善保管您的 Token,不要洩漏給他人", "log.integration.token.title": "存取認證 Token", + "observability.integration.address": "HertzBeat 位址", + "observability.integration.address-hint": "填寫應用能夠存取的 HertzBeat 位址,不需要加入 OTLP 路徑。", + "observability.integration.configuration": "複製接入設定", + "observability.integration.configuration-hint": "選擇應用或 Collector 使用的設定格式。", + "observability.integration.connection": "連線資訊", + "observability.integration.detect": "檢測接入", + "observability.integration.detect-hint": "發送一筆臨時指標、日誌和鏈路,並逐項驗證查詢結果。", + "observability.integration.error.network": "無法連線 HertzBeat,請檢查位址和網路存取。", + "observability.integration.error.request": "檢測失敗,請檢查設定後重試。", + "observability.integration.generate-token": "產生", + "observability.integration.grpc-note": "OTLP/gRPC 預設監聽 4317 埠。", + "observability.integration.protocol": "協定", + "observability.integration.ready-to-copy": "已根據左側表單產生", + "observability.integration.subtitle": "無需理解實體、Pipeline 或資料表,即可接入指標、日誌和鏈路。", + "observability.integration.title": "接入 OpenTelemetry", + "observability.integration.token": "存取令牌", + "observability.integration.token-created": "已產生有效期 30 天的存取令牌", + "observability.integration.token-failed": "無法產生存取令牌", + "observability.integration.token-placeholder": "貼上或產生 API Token", + "observability.signal.logs": "日誌", + "observability.signal.metrics": "指標", + "observability.signal.traces": "鏈路", + "observability.action.view-logs": "查看相關日誌", + "observability.action.view-metrics": "查看相關指標", + "observability.action.view-trace": "查看鏈路", + "observability.column.duration": "耗時", + "observability.column.labels": "標籤", + "observability.column.operation": "操作", + "observability.column.service": "服務", + "observability.column.start": "開始時間", + "observability.column.status": "狀態", + "observability.column.time": "時間", + "observability.column.value": "數值", + "observability.empty.events": "該 Span 沒有記錄事件", + "observability.empty.metrics": "目前條件下沒有指標樣本", + "observability.empty.logs": "目前條件下沒有日誌", + "observability.empty.spans": "該鏈路沒有可用 Span", + "observability.filter.environment": "環境", + "observability.filter.errors-only": "僅錯誤", + "observability.filter.status": "狀態", + "observability.filter.all-statuses": "全部狀態", + "observability.filter.min-duration": "最短耗時(毫秒)", + "observability.filter.max-duration": "最長耗時(毫秒)", + "observability.filter.operation": "操作名稱", + "observability.filter.service": "服務名稱", + "observability.filter.trace-id": "Trace ID", + "observability.metrics.aggregation": "聚合方式", + "observability.metrics.filter": "標籤=值", + "observability.metrics.group-by": "分組標籤", + "observability.metrics.metric": "指標名稱", + "observability.metrics.promql": "PromQL 表達式(支援指標名稱補全)", + "observability.metrics.step": "步長(秒)", + "observability.metrics.samples": "樣本明細", + "observability.metrics.total-series": "Series 總數", + "observability.metrics.total-samples": "樣本總數", + "observability.metrics.minimum": "最小值", + "observability.metrics.maximum": "最大值", + "observability.metrics.trend": "趨勢", + "observability.metrics.series": "條 Series", + "observability.metrics.series-detail": "Series 標籤", + "observability.metrics.subtitle": "查看 OTLP 指標趨勢、Series 和原始樣本。", + "observability.metrics.title": "指標", + "observability.navigation.signals": "切換信號工作台", + "observability.time.toolbar": "時間範圍控制", + "observability.time.preset": "相對時間範圍", + "observability.time.custom": "自訂時間", + "observability.time.previous": "上一個時間視窗", + "observability.time.next": "下一個時間視窗", + "observability.time.auto-refresh": "自動重新整理間隔", + "observability.time.refresh-manual": "手動重新整理", + "observability.time.refresh-10s": "每 10 秒", + "observability.time.refresh-30s": "每 30 秒", + "observability.time.refresh-1m": "每 1 分鐘", + "observability.time.refresh-5m": "每 5 分鐘", + "observability.time.last-15m": "最近 15 分鐘", + "observability.time.last-30m": "最近 30 分鐘", + "observability.time.last-1h": "最近 1 小時", + "observability.time.last-3h": "最近 3 小時", + "observability.time.last-6h": "最近 6 小時", + "observability.time.last-12h": "最近 12 小時", + "observability.time.last-24h": "最近 24 小時", + "observability.time.last-7d": "最近 7 天", + "observability.logs.detail": "日誌詳情", + "observability.logs.message": "日誌內容", + "observability.logs.mode.query": "查詢", + "observability.logs.mode.stream": "即時", + "observability.logs.resource-filter": "資源表達式:cloud.region = prod AND service.version != dev", + "observability.logs.results": "日誌結果", + "observability.logs.trend": "趨勢", + "observability.logs.search": "搜尋日誌內容", + "observability.logs.severity": "級別", + "observability.logs.subtitle": "查詢歷史日誌或查看即時日誌流。", + "observability.logs.title": "日誌", + "observability.logs.total": "日誌總數", + "observability.logs.with-trace": "含 Trace", + "observability.traces.average": "平均耗時", + "observability.traces.detail": "鏈路詳情", + "observability.traces.error-rate": "錯誤率", + "observability.traces.errors": "錯誤鏈路", + "observability.traces.events": "事件", + "observability.traces.resource-attributes": "資源屬性", + "observability.traces.subtitle": "定位慢鏈路和錯誤鏈路,並檢查 Span 瀑布圖。", + "observability.traces.span-details": "Span 屬性與事件", + "observability.traces.title": "鏈路", + "observability.traces.total": "鏈路總數", + "observability.traces.results": "鏈路結果", + "common.button.query": "查詢", "log.manage.batch-delete": "批次刪除", "log.manage.basic-information": "基本資訊", "log.manage.clear": "清空", @@ -721,6 +828,11 @@ "menu.log.integration": "整合", "menu.log.manage": "日誌管理", "menu.log.stream": "日誌流", + "menu.observability": "可觀測性", + "menu.observability.integration": "接入", + "menu.observability.logs": "日誌", + "menu.observability.metrics": "指標", + "menu.observability.traces": "鏈路", "menu.main": "主導航", "menu.monitor": "監控", "menu.monitor.bigdata": "大數據監控", diff --git a/web-app/src/styles/theme.less b/web-app/src/styles/theme.less index 46f0c9da57c..f2e3a7be40c 100644 --- a/web-app/src/styles/theme.less +++ b/web-app/src/styles/theme.less @@ -2,8 +2,8 @@ // - `default` Default theme // - `dark` Import the official dark less style file // - `compact` Import the official compact less style file -@import '@delon/theme/theme-default.less'; -@import "ng-zorro-antd/code-editor/style/entry.less"; +@import (less) '../../node_modules/@delon/theme/theme-default.less'; +@import (less) '../../node_modules/ng-zorro-antd/code-editor/style/entry.less'; // ==========The following is the custom theme variable area========== // The theme parameters can be generated at https://ng-alain.github.io/ng-alain/