You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@hertzbeat.apache.org by zh...@apache.org on 2026/07/11 08:25:12 UTC

(hertzbeat) branch feature/angular-three-signals created (now a9222b2a78)

This is an automated email from the ASF dual-hosted git repository.

zqr10159 pushed a change to branch feature/angular-three-signals
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git


      at a9222b2a78 test(observability): cover transition release contracts

This branch includes the following new commits:

     new 1c03f830e5 feat(observability): add entity-free OTLP signal APIs
     new d5cb2e6660 feat(web): add Angular three-signal workbenches
     new a9222b2a78 test(observability): cover transition release contracts

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@hertzbeat.apache.org
For additional commands, e-mail: notifications-help@hertzbeat.apache.org


(hertzbeat) 01/03: feat(observability): add entity-free OTLP signal APIs

Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

zqr10159 pushed a commit to branch feature/angular-three-signals
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git

commit 1c03f830e59f9d86605b663e20342751328aadce
Author: Logic <zq...@dromara.org>
AuthorDate: Sat Jul 11 16:23:08 2026 +0800

    feat(observability): add entity-free OTLP signal APIs
---
 .../common/constants/NetworkConstants.java         |  16 +-
 .../entity/dto/observability/LogQueryFilter.java   |  24 +
 .../entity/dto/observability/MetricPoint.java      |  17 +
 .../entity/dto/observability/MetricSeries.java     |  20 +
 .../dto/observability/OtlpMetricsConsole.java      |  22 +
 .../dto/observability/OtlpMetricsInventory.java    |  18 +
 .../entity/dto/observability/SignalPage.java       |  22 +
 .../entity/dto/observability/TraceDetail.java      |  14 +
 .../entity/dto/observability/TraceListItem.java    |  16 +
 .../entity/dto/observability/TraceOverview.java    |  13 +
 .../entity/dto/observability/TraceSpanEvent.java   |  14 +
 .../entity/dto/observability/TraceSpanNode.java    |  18 +
 .../common/security/OtlpAccessTokenValidator.java  |  28 ++
 .../exception/StorageUnavailableException.java     |  26 +
 .../src/test/resources/sureness.yml                |   4 +
 hertzbeat-log/pom.xml                              |  19 +-
 .../log/config/GreptimeSignalInitializer.java      | 102 ++++
 .../hertzbeat/log/config/OtlpGrpcServerConfig.java | 201 ++++++++
 .../log/controller/LogQueryController.java         | 147 +++---
 .../log/controller/OtlpSignalController.java       |  78 +++
 .../controller/SignalWorkloadExceptionHandler.java |  64 +++
 .../log/controller/ThreeSignalQueryController.java | 134 ++++++
 .../hertzbeat/log/service/OtlpSignalForwarder.java |  29 ++
 .../log/service/SignalQueryRejectedException.java  |  26 +
 .../hertzbeat/log/service/SignalWorkloadGuard.java |  90 ++++
 .../log/service/ThreeSignalQueryService.java       |  47 ++
 .../service/impl/GreptimeOtlpSignalForwarder.java  | 219 +++++++++
 .../impl/GreptimeThreeSignalQueryService.java      | 523 +++++++++++++++++++++
 .../greptime/pipelines/hertzbeat_otlp_log_v1.yaml  |  56 +++
 .../main/resources/greptime/tables/hzb_traces.sql  |  23 +
 .../log/controller/LogQueryControllerTest.java     | 123 ++---
 .../manager/config/RestTemplateConfig.java         |  59 ++-
 .../impl/ManagerOtlpAccessTokenValidator.java      |  65 +++
 hertzbeat-manager/src/test/resources/sureness.yml  |   4 +
 hertzbeat-startup/src/main/resources/sureness.yml  |   7 +
 .../warehouse/constants/WarehouseConstants.java    |   6 +
 .../warehouse/db/GreptimePromqlQueryExecutor.java  |   6 +-
 .../warehouse/db/GreptimeSqlQueryExecutor.java     |   9 +-
 .../store/history/tsdb/HistoryDataReader.java      |  25 +-
 .../tsdb/greptime/GreptimeDbDataStorage.java       | 168 ++++++-
 .../tsdb/greptime/ResourceFilterExpression.java    | 129 +++++
 .../history/tsdb/influxdb/InfluxdbDataStorage.java |  10 +-
 .../history/tsdb/questdb/QuestdbDataStorage.java   |  12 +-
 .../conf/sureness.yml                              |   7 +
 script/sureness.yml                                |   7 +
 45 files changed, 2478 insertions(+), 189 deletions(-)

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 7c6d15e93b..7f1f364785 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 0000000000..ae36f3e7ae
--- /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 0000000000..bb6f676c55
--- /dev/null
+++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/MetricPoint.java
@@ -0,0 +1,17 @@
+/*
+ * 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.
+ */
+
+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 0000000000..3e7e39266f
--- /dev/null
+++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/MetricSeries.java
@@ -0,0 +1,20 @@
+/*
+ * 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.
+ */
+
+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<String, String> labels, List<MetricPoint> 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 0000000000..01303cd7be
--- /dev/null
+++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/OtlpMetricsConsole.java
@@ -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.
+ */
+
+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<MetricSeries> 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 0000000000..7885775f74
--- /dev/null
+++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/OtlpMetricsInventory.java
@@ -0,0 +1,18 @@
+/*
+ * 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.
+ */
+
+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<String> 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 0000000000..c3255ad1f2
--- /dev/null
+++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/SignalPage.java
@@ -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.
+ */
+
+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 <T> row type
+ */
+public record SignalPage<T>(List<T> 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 0000000000..e7c3ab1c50
--- /dev/null
+++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceDetail.java
@@ -0,0 +1,14 @@
+/*
+ * 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.
+ */
+
+package org.apache.hertzbeat.common.entity.dto.observability;
+
+import java.util.List;
+
+/** Complete trace detail. */
+public record TraceDetail(TraceListItem summary, List<TraceSpanNode> 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 0000000000..617df000b2
--- /dev/null
+++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceListItem.java
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+
+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<String, String> 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 0000000000..f817edc7ec
--- /dev/null
+++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceOverview.java
@@ -0,0 +1,13 @@
+/*
+ * 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.
+ */
+
+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 0000000000..267b79ab14
--- /dev/null
+++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceSpanEvent.java
@@ -0,0 +1,14 @@
+/*
+ * 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.
+ */
+
+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<String, Object> 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 0000000000..4b9645990c
--- /dev/null
+++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/TraceSpanNode.java
@@ -0,0 +1,18 @@
+/*
+ * 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.
+ */
+
+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<String, String> resourceAttributes,
+                            Map<String, String> spanAttributes, List<TraceSpanEvent> 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 0000000000..fbc4900204
--- /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 0000000000..8435410456
--- /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/resources/sureness.yml b/hertzbeat-e2e/hertzbeat-log-e2e/src/test/resources/sureness.yml
index f04c5a2f7d..64228f740d 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 7bc9c65fe3..e3cd19dc10 100644
--- a/hertzbeat-log/pom.xml
+++ b/hertzbeat-log/pom.xml
@@ -30,6 +30,7 @@
 
     <properties>
         <awaitility.version>4.2.0</awaitility.version>
+        <grpc.version>1.56.1</grpc.version>
     </properties>
 
     <dependencies>
@@ -71,6 +72,22 @@
             <groupId>com.google.protobuf</groupId>
             <artifactId>protobuf-java-util</artifactId>
         </dependency>
+        <!-- OTLP gRPC server -->
+        <dependency>
+            <groupId>io.grpc</groupId>
+            <artifactId>grpc-netty-shaded</artifactId>
+            <version>${grpc.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.grpc</groupId>
+            <artifactId>grpc-protobuf</artifactId>
+            <version>${grpc.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.grpc</groupId>
+            <artifactId>grpc-stub</artifactId>
+            <version>${grpc.version}</version>
+        </dependency>
         <!-- awaitility for async testing -->
         <dependency>
             <groupId>org.awaitility</groupId>
@@ -80,4 +97,4 @@
         </dependency>
     </dependencies>
 
-</project>
\ No newline at end of file
+</project>
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 0000000000..d6b2bc2422
--- /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<String, Object> body = new LinkedMultiValueMap<>();
+        body.add("file", new HttpEntity<>(pipeline, partHeaders));
+        String endpoint = StringUtils.trimTrailingCharacter(greptimeProperties.httpEndpoint(), '/')
+                + "/v1/pipelines/hertzbeat_otlp_log_v1";
+        ResponseEntity<String> 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 0000000000..9e6d3bc067
--- /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<OtlpAccessTokenValidator> 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<ExportMetricsServiceResponse> 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<ExportLogsServiceResponse> 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<ExportTraceServiceResponse> 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<String> AUTHORIZATION =
+                Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
+        private final OtlpAccessTokenValidator tokenValidator;
+
+        @Override
+        public <RequestT, ResponseT> ServerCall.Listener<RequestT> interceptCall(
+                ServerCall<RequestT, ResponseT> call, Metadata headers,
+                ServerCallHandler<RequestT, ResponseT> 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<RequestT> 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 5d0bed1f45..07fb225d10 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.Operation;
 import io.swagger.v3.oas.annotations.Parameter;
 import io.swagger.v3.oas.annotations.tags.Tag;
 
-import java.time.Instant;
-import java.time.LocalDateTime;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.stream.Collectors;
 
 import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataReader;
 import org.springframework.data.domain.Page;
@@ -38,7 +33,10 @@ import org.springframework.data.domain.Sort;
 
 import lombok.extern.slf4j.Slf4j;
 import org.apache.hertzbeat.common.entity.dto.Message;
+import org.apache.hertzbeat.common.entity.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 @@ import org.springframework.web.bind.annotation.RestController;
 @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 class LogQueryController {
             @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<LogEntry> 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<LogEntry> result = getPagedLogs(filter, pageIndex, pageSize);
+            return ResponseEntity.ok(Message.success(result));
+        });
     }
 
     @GetMapping("/stats/overview")
@@ -103,29 +113,18 @@ public class LogQueryController {
             @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<LogEntry> logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText, search);
-
-        Map<String, Object> overview = new HashMap<>();
-        overview.put("totalCount", logs.size());
-
-        // Count by severity levels according to OpenTelemetry standard
-        // TRACE: 1-4, DEBUG: 5-8, INFO: 9-12, WARN: 13-16, ERROR: 17-20, FATAL: 21-24
-        long fatalCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 21 && log.getSeverityNumber() <= 24).count();
-        long errorCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 17 && log.getSeverityNumber() <= 20).count();
-        long warnCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 13 && log.getSeverityNumber() <= 16).count();
-        long infoCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 9 && log.getSeverityNumber() <= 12).count();
-        long debugCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 5 && log.getSeverityNumber() <= 8).count();
-        long traceCount = logs.stream().filter(log -> log.getSeverityNumber() != null && log.getSeverityNumber() >= 1 && log.getSeverityNumber() <= 4).count();
-
-        overview.put("fatalCount", fatalCount);
-        overview.put("errorCount", errorCount);
-        overview.put("warnCount", warnCount);
-        overview.put("infoCount", infoCount);
-        overview.put("debugCount", debugCount);
-        overview.put("traceCount", traceCount);
-
-        return ResponseEntity.ok(Message.success(overview));
+            @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<Message<Map<String, Object>>> doOverviewStats(LogQueryFilter filter) {
+        return ResponseEntity.ok(Message.success(historyDataReader.queryLogOverviewAggregate(filter)));
     }
 
     @GetMapping("/stats/trace-coverage")
@@ -145,26 +144,19 @@ public class LogQueryController {
             @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<LogEntry> 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<Message<Map<String, Object>>> doTraceCoverageStats(LogQueryFilter filter) {
         Map<String, Object> result = new HashMap<>();
-
-        // Trace coverage statistics
-        long withTraceId = logs.stream().filter(log -> log.getTraceId() != null && !log.getTraceId().isEmpty()).count();
-        long withSpanId = logs.stream().filter(log -> log.getSpanId() != null && !log.getSpanId().isEmpty()).count();
-        long withBothTraceAndSpan = logs.stream().filter(log ->
-                log.getTraceId() != null && !log.getTraceId().isEmpty()
-                        && log.getSpanId() != null && !log.getSpanId().isEmpty()).count();
-        long withoutTrace = logs.size() - withTraceId;
-
-        Map<String, Long> traceCoverage = new HashMap<>();
-        traceCoverage.put("withTrace", withTraceId);
-        traceCoverage.put("withoutTrace", withoutTrace);
-        traceCoverage.put("withSpan", withSpanId);
-        traceCoverage.put("withBothTraceAndSpan", withBothTraceAndSpan);
-
-        result.put("traceCoverage", traceCoverage);
+        result.put("traceCoverage", historyDataReader.queryLogOverviewAggregate(filter).get("traceCoverage"));
         return ResponseEntity.ok(Message.success(result));
     }
 
@@ -185,49 +177,40 @@ public class LogQueryController {
             @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<LogEntry> logs = getFilteredLogs(start, end, traceId, spanId, severityNumber, severityText, search);
-
-        // Group by hour
-        Map<String, Long> hourlyStats = logs.stream()
-                .filter(log -> log.getTimeUnixNano() != null)
-                .collect(Collectors.groupingBy(
-                        log -> {
-                            long timestampMs = log.getTimeUnixNano() / 1_000_000L;
-                            LocalDateTime dateTime = LocalDateTime.ofInstant(
-                                    Instant.ofEpochMilli(timestampMs),
-                                    ZoneId.systemDefault());
-                            return dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00"));
-                        },
-                        Collectors.counting()));
+            @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<Message<Map<String, Object>>> doTrendStats(LogQueryFilter filter) {
         Map<String, Object> result = new HashMap<>();
-        result.put("hourlyStats", hourlyStats);
+        result.put("hourlyStats", historyDataReader.queryLogTrendAggregate(filter));
         return ResponseEntity.ok(Message.success(result));
     }
 
-    private List<LogEntry> 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<LogEntry> 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<LogEntry> 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<LogEntry> pagedLogs = historyDataReader.queryLogsByMultipleConditionsWithPagination(
-                start, end, traceId, spanId, severityNumber, severityText, search, offset, pageSize);
+        long totalElements = historyDataReader.countObservabilityLogs(filter);
+        List<LogEntry> 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 0000000000..1551d4e44e
--- /dev/null
+++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/OtlpSignalController.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.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.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
+@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<byte[]> metrics(@RequestBody byte[] content, @RequestHeader HttpHeaders headers) {
+        return forward("metrics", content, headers);
+    }
+
+    @PostMapping("/logs")
+    @Operation(summary = "Ingest OTLP logs")
+    public ResponseEntity<byte[]> logs(@RequestBody byte[] content, @RequestHeader HttpHeaders headers) {
+        return forward("logs", content, headers);
+    }
+
+    @PostMapping("/traces")
+    @Operation(summary = "Ingest OTLP traces")
+    public ResponseEntity<byte[]> traces(@RequestBody byte[] content, @RequestHeader HttpHeaders headers) {
+        return forward("traces", content, headers);
+    }
+
+    private ResponseEntity<byte[]> forward(String signal, byte[] content, HttpHeaders headers) {
+        return workloadGuard.execute(Workload.OTLP_WRITE,
+                () -> signalForwarder.forwardHttp(signal, content, headers));
+    }
+
+    @ExceptionHandler(IllegalArgumentException.class)
+    public ResponseEntity<byte[]> 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 0000000000..3cac628fc8
--- /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<Message<Void>> handleInvalidQuery(IllegalArgumentException exception) {
+        return ResponseEntity.badRequest().body(Message.fail(FAIL_CODE, exception.getMessage()));
+    }
+
+    @ExceptionHandler(SignalQueryRejectedException.class)
+    public ResponseEntity<Message<Void>> 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<Message<Void>> 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<Message<Void>> 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 0000000000..25fb38f8de
--- /dev/null
+++ b/hertzbeat-log/src/main/java/org/apache/hertzbeat/log/controller/ThreeSignalQueryController.java
@@ -0,0 +1,134 @@
+/*
+ * 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.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
+@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<Message<OtlpMetricsConsole>> 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<Message<OtlpMetricsInventory>> 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<Message<SignalPage<TraceListItem>>> 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<Message<TraceOverview>> 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<Message<TraceDetail>> 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<Message<?>> 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 0000000000..b2305fafc3
--- /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<byte[]> 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 0000000000..40f43b1cbb
--- /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 0000000000..f60677ad15
--- /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<Workload, Slot> 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<Workload, Limits> limits) {
+        limits.forEach((workload, value) -> slots.put(workload, new Slot(value)));
+    }
+
+    public <T> T execute(Workload workload, Supplier<T> 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 0000000000..5b5409f252
--- /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<TraceListItem> 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 0000000000..766c58f1ec
--- /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<String> 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<byte[]> 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<byte[]> 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<String, Object> map = (Map<String, Object>) rawMap;
+            for (Map.Entry<String, Object> 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 0000000000..dd85d7d2c5
--- /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<String> 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<String> 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<Map> 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<TraceListItem> 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<Map<String, Object>> 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<Map<String, Object>> rows = sqlQueryExecutor.execute(sql);
+        if (rows.isEmpty()) {
+            return new TraceOverview(0, 0, 0, 0, 0);
+        }
+        Map<String, Object> 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<TraceSpanNode> 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<String, String> 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<String, String> 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<MetricSeries> readMetricSeries(Map<?, ?> response) {
+        if (response == null || !(response.get("data") instanceof Map<?, ?> data)
+                || !(data.get("result") instanceof List<?> results)) {
+            return List.of();
+        }
+        List<MetricSeries> series = new ArrayList<>();
+        for (Object result : results) {
+            if (!(result instanceof Map<?, ?> item)) {
+                continue;
+            }
+            Map<String, String> labels = new LinkedHashMap<>();
+            if (item.get("metric") instanceof Map<?, ?> metricLabels) {
+                metricLabels.forEach((key, value) -> labels.put(String.valueOf(key), String.valueOf(value)));
+            }
+            List<MetricPoint> 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<String> 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<String> filters, String column, String value) {
+        if (StringUtils.hasText(value)) {
+            filters.add(column + " = '" + escapeSql(value) + "'");
+        }
+    }
+
+    private void addFlattenedResourceFilter(List<String> filters, String column, String value) {
+        if (StringUtils.hasText(value) && !"all".equalsIgnoreCase(value)) {
+            filters.add("\"" + column + "\" = '" + escapeSql(value) + "'");
+        }
+    }
+
+    private TraceListItem toTraceListItem(Map<String, Object> 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<String, Object> 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<String, String> traceResourceAttributes(Map<String, Object> row) {
+        Map<String, String> 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<String, String> traceSpanAttributes(Map<String, Object> row) {
+        Map<String, String> attributes = new LinkedHashMap<>(asStringMap(row.get("span_attributes")));
+        row.forEach((key, value) -> putFlattenedAttribute(attributes, key, value, "span_attributes."));
+        return attributes;
+    }
+
+    private void putFlattenedAttribute(Map<String, String> target, String key, Object value, String prefix) {
+        if (key.startsWith(prefix) && value != null) {
+            putIfText(target, key.substring(prefix.length()), asString(value));
+        }
+    }
+
+    private List<TraceSpanEvent> asSpanEvents(Object value) {
+        if (!StringUtils.hasText(asString(value)) && !(value instanceof List<?>)) {
+            return List.of();
+        }
+        List<Map<String, Object>> 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<String, Object> toStringKeyMap(Map<?, ?> source) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        source.forEach((key, item) -> result.put(String.valueOf(key), item));
+        return result;
+    }
+
+    private Map<String, Object> asObjectMap(Object value) {
+        if (value instanceof Map<?, ?> map) {
+            return toStringKeyMap(map);
+        }
+        return Collections.emptyMap();
+    }
+
+    private Map<String, String> asStringMap(Object value) {
+        if (value instanceof Map<?, ?> map) {
+            Map<String, String> 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<String, Object> parsed = JsonUtil.fromJson(asString(value), new TypeReference<>() { });
+        if (parsed == null) {
+            return Collections.emptyMap();
+        }
+        Map<String, String> 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<String, String> 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 0000000000..135dc5979b
--- /dev/null
+++ b/hertzbeat-log/src/main/resources/greptime/pipelines/hertzbeat_otlp_log_v1.yaml
@@ -0,0 +1,56 @@
+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 0000000000..c83bbecec4
--- /dev/null
+++ b/hertzbeat-log/src/main/resources/greptime/tables/hzb_traces.sql
@@ -0,0 +1,23 @@
+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/controller/LogQueryControllerTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/LogQueryControllerTest.java
index 8e3da88e40..6aba088ddf 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 @@ class LogQueryControllerTest {
 
         List<LogEntry> 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 @@ class LogQueryControllerTest {
                         .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 @@ class LogQueryControllerTest {
                         .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 @@ class LogQueryControllerTest {
     }
 
     @Test
-    void testOverviewStatsWithMixedSeverityLogs() throws Exception {
-        // Create logs with different severity levels according to OpenTelemetry standard
-        List<LogEntry> 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 @@ class LogQueryControllerTest {
 
     @Test
     void testOverviewStatsWithTimeRange() throws Exception {
-        List<LogEntry> 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 @@ class LogQueryControllerTest {
 
     @Test
     void testTraceCoverageStats() throws Exception {
-        List<LogEntry> 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 @@ class LogQueryControllerTest {
 
     @Test
     void testTrendStats() throws Exception {
-        // Create logs with timestamps that fall into different hours
-        List<LogEntry> 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 @@ class LogQueryControllerTest {
 
     @Test
     void testTrendStatsWithNullTimestamp() throws Exception {
-        List<LogEntry> 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-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 8930dd35cc..79507648ad 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.net.http.HttpClient;
 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 0000000000..ad5aa279e5
--- /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<String> 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/resources/sureness.yml b/hertzbeat-manager/src/test/resources/sureness.yml
index f04c5a2f7d..64228f740d 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 d366fe8a02..b4e8e33d86 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-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 db73381be6..fc0c325e40 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 @@ public interface WarehouseConstants {
 
     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 1a877c8376..383bbce785 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 @@ package org.apache.hertzbeat.warehouse.db;
 
 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 42bbb2efc9..0afcc8fe20 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 lombok.extern.slf4j.Slf4j;
 import org.apache.hertzbeat.common.constants.NetworkConstants;
 import org.apache.hertzbeat.common.constants.SignConstants;
 import org.apache.hertzbeat.common.util.Base64Util;
+import org.apache.hertzbeat.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 class GreptimeSqlQueryExecutor extends SqlQueryExecutor {
                     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 567e27177e..468e6e0557 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 @@ package org.apache.hertzbeat.warehouse.store.history.tsdb;
 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 @@ public interface HistoryDataReader {
                                                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<LogEntry> 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<String, Object> 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<String, Long> 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 387698ea1d..ae953720de 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.time.temporal.TemporalAmount;
 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.CommonConstants;
 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 class GreptimeDbDataStorage extends AbstractHistoryDataStorage {
                     .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 class GreptimeDbDataStorage extends AbstractHistoryDataStorage {
                     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 class GreptimeDbDataStorage extends AbstractHistoryDataStorage {
         }
     }
 
+    @Override
+    public List<LogEntry> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> rows = greptimeSqlQueryExecutor.execute(sql.toString());
+            if (rows.isEmpty()) {
+                return emptyLogOverview();
+            }
+            Map<String, Object> row = rows.getFirst();
+            long total = valueAsLong(row, "total_count");
+            Map<String, Long> 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<String, Object> 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<String, Long> 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<String, Long> trend = new LinkedHashMap<>();
+            for (Map<String, Object> 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<String, Object> emptyLogOverview() {
+        Map<String, Long> coverage = Map.of("withTrace", 0L, "withoutTrace", 0L,
+                "withSpan", 0L, "withBothTraceAndSpan", 0L);
+        Map<String, Object> 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<String, Object> 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<String> 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<String> 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 @@ public class GreptimeDbDataStorage extends AbstractHistoryDataStorage {
         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 @@ public class GreptimeDbDataStorage extends AbstractHistoryDataStorage {
                     .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 @@ public class GreptimeDbDataStorage extends AbstractHistoryDataStorage {
                         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 0000000000..9ce8a5b783
--- /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<Clause> parse(String expression) {
+        List<Clause> 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<String> splitClauses(String expression) {
+        List<String> 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 f65d6673aa..0cade5b80b 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 class InfluxdbDataStorage extends AbstractHistoryDataStorage {
     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 801d635295..93f5b017f2 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 class QuestdbDataStorage extends AbstractHistoryDataStorage {
         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 class QuestdbDataStorage extends AbstractHistoryDataStorage {
             this.client.dispatcher().executorService().shutdown();
         }
     }
-}
\ No newline at end of file
+}
diff --git a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
index 282e93928a..93d7529310 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 829bdc7308..23d0f4d143 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


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@hertzbeat.apache.org
For additional commands, e-mail: notifications-help@hertzbeat.apache.org


(hertzbeat) 02/03: feat(web): add Angular three-signal workbenches

Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

zqr10159 pushed a commit to branch feature/angular-three-signals
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git

commit d5cb2e666041b5e0b87d0a2a1a56fefe19bb484d
Author: Logic <zq...@dromara.org>
AuthorDate: Sat Jul 11 16:23:40 2026 +0800

    feat(web): add Angular three-signal workbenches
---
 web-app/angular.json                               |   5 +
 .../app/core/interceptor/default.interceptor.ts    |  12 +-
 .../interceptor/http-context.ts}                   |  17 +-
 .../log-integration/log-integration.component.html | 150 ++++-
 .../log-integration/log-integration.component.less | 272 ++++++---
 .../log-integration.component.spec.ts              |  56 +-
 .../log-integration/log-integration.component.ts   | 149 +++--
 .../log/log-manage/log-manage.component.html       | 486 ++++-----------
 .../log/log-manage/log-manage.component.less       | 196 ++++--
 .../log/log-manage/log-manage.component.spec.ts    |  29 +-
 .../routes/log/log-manage/log-manage.component.ts  | 679 ++++++---------------
 web-app/src/app/routes/log/log-routing.module.ts   |   3 +-
 .../log/log-stream/log-stream.component.html       |   7 +-
 .../log/log-stream/log-stream.component.less       |  12 +-
 .../routes/log/log-stream/log-stream.component.ts  |  10 +-
 .../routes/metrics/metrics-manage.component.html   | 112 ++++
 .../routes/metrics/metrics-manage.component.less   | 160 +++++
 .../app/routes/metrics/metrics-manage.component.ts | 224 +++++++
 .../observability/signal-navigation.component.ts   |  75 +++
 .../routes/observability/signal-query-context.ts   |  78 +++
 .../observability/signal-time-range.component.ts   | 173 ++++++
 web-app/src/app/routes/routes-routing.module.ts    |  17 +-
 .../app/routes/trace/trace-manage.component.html   | 174 ++++++
 .../app/routes/trace/trace-manage.component.less   | 271 ++++++++
 .../src/app/routes/trace/trace-manage.component.ts | 289 +++++++++
 web-app/src/app/service/log.service.ts             | 127 ++--
 .../app/service/notice-receiver.service.spec.ts    |   6 +-
 web-app/src/app/service/observability.service.ts   | 378 ++++++++++++
 web-app/src/assets/app-data.json                   |  24 +-
 web-app/src/assets/i18n/en-US.json                 | 112 ++++
 web-app/src/assets/i18n/ja-JP.json                 | 112 ++++
 web-app/src/assets/i18n/pt-BR.json                 | 112 ++++
 web-app/src/assets/i18n/zh-CN.json                 | 112 ++++
 web-app/src/assets/i18n/zh-TW.json                 | 112 ++++
 web-app/src/styles/theme.less                      |   4 +-
 35 files changed, 3536 insertions(+), 1219 deletions(-)

diff --git a/web-app/angular.json b/web-app/angular.json
index 149fdd4823..d7d5b42bd0 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 ecf42b14cd..aba4d01ad3 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/service/notice-receiver.service.spec.ts b/web-app/src/app/core/interceptor/http-context.ts
similarity index 66%
copy from web-app/src/app/service/notice-receiver.service.spec.ts
copy to web-app/src/app/core/interceptor/http-context.ts
index b0e1c03189..9d0312a3f8 100644
--- a/web-app/src/app/service/notice-receiver.service.spec.ts
+++ b/web-app/src/app/core/interceptor/http-context.ts
@@ -17,19 +17,6 @@
  * under the License.
  */
 
-import { TestBed } from '@angular/core/testing';
+import { HttpContextToken } from '@angular/common/http';
 
-import { NoticeReceiverMailService } from './notice-receiver.service';
-
-describe('NoticeReceiverService', () => {
-  let service: NoticeReceiverMailService;
-
-  beforeEach(() => {
-    TestBed.configureTestingModule({});
-    service = TestBed.inject(NoticeReceiverMailService);
-  });
-
-  it('should be created', () => {
-    expect(service).toBeTruthy();
-  });
-});
+export const SILENT_HTTP_ERROR = new HttpContextToken<boolean>(() => 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 d8525db5cd..7503716f56 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.
 -->
 
-<app-help-message-show
-  [help_message_content]="'log.help.integration' | i18n"
-  [guild_link]="'log.help.integration.link' | i18n"
-  [module_name]="'menu.log.integration'"
-  [icon_name]="'file-text'"
-></app-help-message-show>
-
-<nz-divider></nz-divider>
-
-<div class="log-integration-container">
-  <div class="data-sources">
-    <h2>{{ 'log.integration.source' | i18n }}</h2>
-    <div class="source-list">
-      <div
-        class="source-item"
-        *ngFor="let source of dataSources"
-        (click)="selectSource(source)"
-        [class.active]="selectedSource?.id === source.id"
-      >
-        <img [src]="source.icon" [alt]="source.name" />
-        <span>{{ source.name }}</span>
-      </div>
+<section class="integration-page">
+  <header class="page-heading">
+    <div>
+      <h1>{{ 'observability.integration.title' | i18n }}</h1>
+      <p>{{ 'observability.integration.subtitle' | i18n }}</p>
     </div>
-  </div>
-  <div class="doc-content">
-    <ng-container *ngIf="selectedSource">
-      <div style="display: flex; justify-content: space-between; align-items: center">
-        <h2>{{ selectedSource.name }}</h2>
-        <button nz-button nzType="default" (click)="goToTokenManagement()" style="margin-right: 16px">
-          <span nz-icon nzType="setting" nzTheme="outline"></span>
-          {{ 'log.integration.token.manage' | i18n }}
+    <button nz-button nzType="primary" (click)="goToTokenManagement()">
+      <span nz-icon nzType="setting"></span>
+      {{ 'log.integration.token.manage' | i18n }}
+    </button>
+  </header>
+
+  <div class="integration-workspace">
+    <form class="connection-form" nz-form nzLayout="vertical">
+      <div class="section-heading">
+        <span class="step-index">1</span>
+        <div>
+          <h2>{{ 'observability.integration.connection' | i18n }}</h2>
+        </div>
+      </div>
+
+      <nz-form-item>
+        <nz-form-label nzRequired>{{ 'observability.integration.protocol' | i18n }}</nz-form-label>
+        <nz-form-control>
+          <nz-radio-group class="protocol-options" [(ngModel)]="protocol" name="protocol">
+            <label nz-radio-button nzValue="http-json">HTTP JSON</label>
+            <label nz-radio-button nzValue="http-protobuf">HTTP Protobuf</label>
+            <label nz-radio-button nzValue="grpc">gRPC</label>
+          </nz-radio-group>
+        </nz-form-control>
+      </nz-form-item>
+
+      <nz-form-item>
+        <nz-form-label nzRequired>{{ 'observability.integration.address' | i18n }}</nz-form-label>
+        <nz-form-control [nzExtra]="'observability.integration.address-hint' | i18n">
+          <input nz-input [(ngModel)]="endpoint" name="endpoint" placeholder="https://hertzbeat.example.com" />
+        </nz-form-control>
+      </nz-form-item>
+
+      <nz-form-item>
+        <nz-form-label nzRequired>{{ 'observability.integration.token' | i18n }}</nz-form-label>
+        <nz-form-control>
+          <nz-input-group class="token-input" [nzAddOnAfter]="tokenAction">
+            <input
+              nz-input
+              type="password"
+              [(ngModel)]="token"
+              name="token"
+              [placeholder]="'observability.integration.token-placeholder' | i18n"
+            />
+          </nz-input-group>
+          <ng-template #tokenAction>
+            <button nz-button nzType="link" type="button" [nzLoading]="generatingToken" (click)="generateToken()">
+              {{ 'observability.integration.generate-token' | i18n }}
+            </button>
+          </ng-template>
+        </nz-form-control>
+      </nz-form-item>
+
+      <div class="field-row">
+        <nz-form-item>
+          <nz-form-label nzRequired>service.name</nz-form-label>
+          <nz-form-control>
+            <input nz-input [(ngModel)]="serviceName" name="serviceName" />
+          </nz-form-control>
+        </nz-form-item>
+        <nz-form-item>
+          <nz-form-label nzRequired>environment</nz-form-label>
+          <nz-form-control>
+            <input nz-input [(ngModel)]="environment" name="environment" />
+          </nz-form-control>
+        </nz-form-item>
+      </div>
+
+      <button nz-button nzType="primary" type="button" [disabled]="!formValid" [nzLoading]="detecting" (click)="detect()">
+        <span nz-icon nzType="thunderbolt"></span>
+        {{ 'observability.integration.detect' | i18n }}
+      </button>
+      <p class="detect-hint">{{ 'observability.integration.detect-hint' | i18n }}</p>
+
+      <div class="probe-results" *ngIf="probeResults.length">
+        <div class="probe-row" *ngFor="let result of probeResults" [class.probe-error]="result.status === 'error'">
+          <span nz-icon [nzType]="result.status === 'success' ? 'check-circle' : 'close-circle'"></span>
+          <strong>{{ signalLabel(result.signal) }}</strong>
+          <span *ngIf="result.lastReceived">{{ result.lastReceived | date : 'yyyy-MM-dd HH:mm:ss' }}</span>
+          <span *ngIf="result.errorKey || result.error" class="probe-message">
+            {{ result.errorKey ? (result.errorKey | i18n) : result.error }}
+          </span>
+        </div>
+      </div>
+    </form>
+
+    <section class="configuration-output">
+      <div class="section-heading">
+        <span class="step-index">2</span>
+        <div>
+          <h2>{{ 'observability.integration.configuration' | i18n }}</h2>
+          <p>{{ 'observability.integration.configuration-hint' | i18n }}</p>
+        </div>
+      </div>
+
+      <nz-tabset>
+        <nz-tab nzTitle="Environment" (nzSelect)="selectedSnippet = 'environment'"></nz-tab>
+        <nz-tab nzTitle="Collector" (nzSelect)="selectedSnippet = 'collector'"></nz-tab>
+        <nz-tab nzTitle="Java Agent" (nzSelect)="selectedSnippet = 'java'"></nz-tab>
+        <nz-tab nzTitle="curl" (nzSelect)="selectedSnippet = 'curl'"></nz-tab>
+      </nz-tabset>
+
+      <div class="code-toolbar">
+        <span>{{ 'observability.integration.ready-to-copy' | i18n }}</span>
+        <button nz-button nzType="text" nzSize="small" (click)="copy(snippet(selectedSnippet))">
+          <span nz-icon nzType="copy"></span>
+          {{ 'common.button.copy' | i18n }}
         </button>
       </div>
-      <markdown [data]="markdownContent"></markdown>
-    </ng-container>
+      <pre><code>{{ snippet(selectedSnippet) }}</code></pre>
+
+      <nz-alert nzType="info" nzShowIcon [nzMessage]="'observability.integration.grpc-note' | i18n"></nz-alert>
+    </section>
   </div>
-</div>
+</section>
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 7ae298020a..62472a92c2 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 be8ff0b239..2b83885b01 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<LogIntegrationComponent>;
+  function createComponent() {
+    const auth = jasmine.createSpyObj<AuthService>('AuthService', ['generateToken']);
+    const observability = jasmine.createSpyObj<ObservabilityService>('ObservabilityService', ['probe']);
+    const notifications = jasmine.createSpyObj<NzNotificationService>('NzNotificationService', ['success', 'error', 'warning']);
+    const router = jasmine.createSpyObj<Router>('Router', ['navigateByUrl']);
+    const i18n = jasmine.createSpyObj<I18NService>('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 d80408617d..be9ecefc2e 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 || '<token>'}`;
+    if (type === 'collector') {
+      return `exporters:\n  otlphttp/hertzbeat:\n    endpoint: ${httpEndpoint}\n    headers:\n      Authorization: "Bearer ${
+        this.token || '<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 || '<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 3ee454cd9d..97ce977ecb 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
@@ -1,366 +1,132 @@
-<!--
-  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.
--->
-<app-help-message-show
-  [help_message_content]="'log.help.manage' | i18n"
-  [guild_link]="'log.help.manage.link' | i18n"
-  [module_name]="'menu.log.manage' | i18n"
-  [icon_name]="'database'"
-></app-help-message-show>
-
-<nz-divider></nz-divider>
-
-<nz-card [nzTitle]="'log.manage.title' | i18n" class="manager-card">
-  <div class="filters-container">
-    <nz-space nzSize="middle" nzWrap>
-      <nz-range-picker *nzSpaceItem [(ngModel)]="timeRange" nzShowTime nzFormat="yyyy-MM-dd HH:mm:ss"></nz-range-picker>
-      <input *nzSpaceItem nz-input [placeholder]="'log.manage.trace-id' | i18n" [(ngModel)]="traceId" style="width: 100px" />
-      <input *nzSpaceItem nz-input [placeholder]="'log.manage.span-id' | i18n" [(ngModel)]="spanId" style="width: 100px" />
+<!-- Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. -->
+<section class="signal-page">
+  <header class="page-heading"
+    ><div
+      ><h1>{{ 'observability.logs.title' | i18n }}</h1
+      ><p>{{ 'observability.logs.subtitle' | i18n }}</p></div
+    ><div class="heading-actions"
+      ><app-signal-navigation active="logs" [context]="navigationContext()"></app-signal-navigation
+      ><div class="mode-switch"
+        ><button nz-button [nzType]="mode === 'query' ? 'primary' : 'default'" (click)="setMode('query')">{{
+          'observability.logs.mode.query' | i18n
+        }}</button
+        ><button nz-button [nzType]="mode === 'stream' ? 'primary' : 'default'" (click)="setMode('stream')">{{
+          'observability.logs.mode.stream' | i18n
+        }}</button></div
+      ></div
+    ></header
+  >
+  <ng-container *ngIf="mode === 'query'; else streamMode">
+    <div class="query-toolbar">
+      <app-signal-time-range
+        [(value)]="timeRange"
+        [(refreshSeconds)]="refreshSeconds"
+        (apply)="search()"
+        (refresh)="refreshToNow()"
+      ></app-signal-time-range>
+      <input nz-input [(ngModel)]="serviceName" [placeholder]="'observability.filter.service' | i18n" />
+      <input nz-input [(ngModel)]="environment" [placeholder]="'observability.filter.environment' | i18n" />
+      <input nz-input [(ngModel)]="traceId" [placeholder]="'observability.filter.trace-id' | i18n" />
+      <nz-select [(ngModel)]="severityText" nzAllowClear [nzPlaceHolder]="'observability.logs.severity' | i18n">
+        <nz-option *ngFor="let level of severityLevels" [nzValue]="level" [nzLabel]="level"></nz-option>
+      </nz-select>
+      <input nz-input [(ngModel)]="searchContent" [placeholder]="'observability.logs.search' | i18n" />
       <input
-        *nzSpaceItem
         nz-input
-        type="number"
-        [placeholder]="'log.manage.severity-number' | i18n"
-        [(ngModel)]="severityNumber"
-        style="width: 120px"
+        class="resource-expression mono"
+        [(ngModel)]="resource"
+        [placeholder]="'observability.logs.resource-filter' | i18n"
+        (keyup.enter)="search()"
       />
-      <input
-        *nzSpaceItem
-        type="text"
-        nz-input
-        [(ngModel)]="severityText"
-        [placeholder]="'log.manage.severity-text' | i18n"
-        [nzAutocomplete]="severityTextAuto"
-        style="width: 120px"
-      />
-      <nz-autocomplete #severityTextAuto>
-        <nz-auto-option nzValue="TRACE">TRACE</nz-auto-option>
-        <nz-auto-option nzValue="DEBUG">DEBUG</nz-auto-option>
-        <nz-auto-option nzValue="INFO">INFO</nz-auto-option>
-        <nz-auto-option nzValue="WARN">WARN</nz-auto-option>
-        <nz-auto-option nzValue="ERROR">ERROR</nz-auto-option>
-        <nz-auto-option nzValue="FATAL">FATAL</nz-auto-option>
-      </nz-autocomplete>
-      <input *nzSpaceItem nz-input [placeholder]="'log.manage.search' | i18n" [(ngModel)]="searchContent" style="width: 200px" />
-      <button *nzSpaceItem nz-button nzType="primary" (click)="query()">
-        <i nz-icon nzType="search"></i> {{ 'log.manage.search' | i18n }}
-      </button>
-      <button *nzSpaceItem nz-button (click)="clearFilters()"> <i nz-icon nzType="clear"></i> {{ 'log.manage.clear' | i18n }} </button>
-      <button *nzSpaceItem nz-button nzType="default" (click)="toggleStatistics()">
-        <i nz-icon [nzType]="showStatistics ? 'up' : 'down'"></i>
-        {{ showStatistics ? ('log.manage.hide-statistics' | i18n) : ('log.manage.show-statistics' | i18n) }}
-      </button>
-      <button *nzSpaceItem nz-button nzType="primary" nzDanger [disabled]="setOfCheckedId.size === 0" (click)="batchDelete()">
-        <i nz-icon nzType="delete"></i>
-        {{ 'log.manage.batch-delete' | i18n }} <span *ngIf="setOfCheckedId.size > 0">({{ setOfCheckedId.size }})</span>
-      </button>
-      <button
-        *nzSpaceItem
-        nz-button
-        nzType="default"
-        nz-popover
-        [nzPopoverTitle]="'log.manage.column-control' | i18n"
-        [(nzPopoverVisible)]="columnControlVisible"
-        nzPopoverTrigger="click"
-        [nzPopoverContent]="columnControlTemplate"
+      <button nz-button nzType="primary" [nzLoading]="loading" (click)="search()"
+        ><span nz-icon nzType="search"></span>{{ 'common.button.query' | i18n }}</button
       >
-        <i nz-icon nzType="setting"></i>
-        {{ 'log.manage.column-setting' | i18n }} ({{ getVisibleColumnsCount() }}/{{ getColumnKeys().length }})
-      </button>
-    </nz-space>
-    <ng-template #columnControlTemplate>
-      <div class="column-control-container">
-        <nz-list nzSize="small">
-          <nz-list-item *ngFor="let item of getColumnKeys()">
-            <label nz-checkbox [ngModel]="getColumnVisibility()[item].visible" (ngModelChange)="toggleColumnVisibility(item)">
-              {{ getColumnVisibility()[item].label }}
-            </label>
-          </nz-list-item>
-        </nz-list>
-        <nz-divider></nz-divider>
-        <div class="column-control-reset-button">
-          <button nz-button nzSize="small" nzType="default" (click)="resetColumns()">
-            <i nz-icon nzType="reload"></i> {{ 'log.manage.reset-all-columns' | i18n }}
-          </button>
-        </div>
-      </div>
-    </ng-template>
-  </div>
-
-  <div *ngIf="showStatistics">
-    <div nz-row [nzGutter]="16" style="margin-bottom: 16px">
-      <div nz-col nzXs="12" nzSm="4">
-        <nz-card>
-          <nz-statistic
-            [nzValue]="overviewStats.totalCount"
-            [nzTitle]="'log.manage.overview.total-logs' | i18n"
-            [nzPrefix]="totalIconTemplate"
-            [nzValueStyle]="{ color: '#1890ff' }"
-          >
-          </nz-statistic>
-          <ng-template #totalIconTemplate><i nz-icon nzType="file-text"></i></ng-template>
-        </nz-card>
-      </div>
-      <div nz-col nzXs="12" nzSm="4">
-        <nz-card>
-          <nz-statistic
-            [nzValue]="overviewStats.fatalCount"
-            [nzTitle]="'log.manage.overview.fatal-logs' | i18n"
-            [nzPrefix]="fatalIconTemplate"
-            [nzValueStyle]="{ color: '#722ed1' }"
-          >
-          </nz-statistic>
-          <ng-template #fatalIconTemplate><i nz-icon nzType="close-circle"></i></ng-template>
-        </nz-card>
-      </div>
-      <div nz-col nzXs="12" nzSm="4">
-        <nz-card>
-          <nz-statistic
-            [nzValue]="overviewStats.errorCount"
-            [nzTitle]="'log.manage.overview.error-logs' | i18n"
-            [nzPrefix]="errorIconTemplate"
-            [nzValueStyle]="{ color: '#ff4d4f' }"
-          >
-          </nz-statistic>
-          <ng-template #errorIconTemplate><i nz-icon nzType="exclamation-circle"></i></ng-template>
-        </nz-card>
-      </div>
-      <div nz-col nzXs="12" nzSm="4">
-        <nz-card>
-          <nz-statistic
-            [nzValue]="overviewStats.warnCount"
-            [nzTitle]="'log.manage.overview.warning-logs' | i18n"
-            [nzPrefix]="warnIconTemplate"
-            [nzValueStyle]="{ color: '#faad14' }"
-          >
-          </nz-statistic>
-          <ng-template #warnIconTemplate><i nz-icon nzType="warning"></i></ng-template>
-        </nz-card>
-      </div>
-      <div nz-col nzXs="12" nzSm="4">
-        <nz-card>
-          <nz-statistic
-            [nzValue]="overviewStats.infoCount"
-            [nzTitle]="'log.manage.overview.info-logs' | i18n"
-            [nzPrefix]="infoIconTemplate"
-            [nzValueStyle]="{ color: '#1890ff' }"
-          >
-          </nz-statistic>
-          <ng-template #infoIconTemplate><i nz-icon nzType="info-circle"></i></ng-template>
-        </nz-card>
-      </div>
-      <div nz-col nzXs="12" nzSm="4">
-        <nz-card>
-          <nz-statistic
-            [nzValue]="overviewStats.debugCount"
-            [nzTitle]="'log.manage.overview.debug-logs' | i18n"
-            [nzPrefix]="debugIconTemplate"
-            [nzValueStyle]="{ color: '#52c41a' }"
-          >
-          </nz-statistic>
-          <ng-template #debugIconTemplate><i nz-icon nzType="bug"></i></ng-template>
-        </nz-card>
-      </div>
-    </div>
-
-    <div nz-row [nzGutter]="16">
-      <div nz-col nzXs="24" nzMd="8">
-        <nz-card [nzTitle]="'log.manage.chart.severity-distribution' | i18n" [nzSize]="'small'">
-          <div echarts [options]="severityOption" (chartInit)="onSeverityChartInit($event)" style="height: 300px"></div>
-        </nz-card>
-      </div>
-      <div nz-col nzXs="24" nzMd="8">
-        <nz-card [nzTitle]="'log.manage.chart.trace-coverage' | i18n" [nzSize]="'small'">
-          <div echarts [options]="traceCoverageOption" (chartInit)="onTraceCoverageChartInit($event)" style="height: 300px"></div>
-        </nz-card>
-      </div>
-      <div nz-col nzXs="24" nzMd="8">
-        <nz-card [nzTitle]="'log.manage.chart.log-trend' | i18n" [nzSize]="'small'">
-          <div echarts [options]="trendOption" (chartInit)="onTrendChartInit($event)" style="height: 300px"></div>
-        </nz-card>
-      </div>
-    </div>
-  </div>
-</nz-card>
-
-<nz-table
-  #fixedTable
-  [nzData]="data"
-  [nzLoading]="loading"
-  nzSize="small"
-  [nzFrontPagination]="false"
-  [nzTotal]="totalElements"
-  [nzPageIndex]="pageIndex"
-  [nzPageSize]="pageSize"
-  nzShowPagination="true"
-  [nzShowSizeChanger]="true"
-  [nzPageSizeOptions]="[10, 20, 50, 100]"
-  (nzQueryParams)="onTablePageChange($event)"
-  [nzScroll]="{ x: '1250px' }"
-  style="margin-top: 16px"
->
-  <thead>
-    <tr>
-      <th nzAlign="center" nzWidth="50px">
-        <label nz-checkbox [(ngModel)]="checked" [nzIndeterminate]="indeterminate" (ngModelChange)="onAllChecked($event)"> </label>
-      </th>
-      <th *ngIf="columnVisibility.time.visible" nzAlign="center" nzWidth="110px">{{ 'log.manage.table.column.time' | i18n }}</th>
-      <th *ngIf="columnVisibility.observedTime.visible" nzAlign="center" nzWidth="110px">{{
-        'log.manage.table.column.observed-time' | i18n
-      }}</th>
-      <th *ngIf="columnVisibility.severity.visible" nzAlign="center" nzWidth="80px">{{ 'log.manage.table.column.severity' | i18n }}</th>
-      <th *ngIf="columnVisibility.body.visible" nzAlign="center" nzWidth="230px">{{ 'log.manage.table.column.body' | i18n }}</th>
-      <th *ngIf="columnVisibility.attributes.visible" nzAlign="center" nzWidth="140px">{{
-        'log.manage.table.column.attributes' | i18n
-      }}</th>
-      <th *ngIf="columnVisibility.resource.visible" nzAlign="center" nzWidth="140px">{{ 'log.manage.table.column.resource' | i18n }}</th>
-      <th *ngIf="columnVisibility.traceId.visible" nzAlign="center" nzWidth="85px">{{ 'log.manage.table.column.trace-id' | i18n }}</th>
-      <th *ngIf="columnVisibility.spanId.visible" nzAlign="center" nzWidth="85px">{{ 'log.manage.table.column.span-id' | i18n }}</th>
-      <th *ngIf="columnVisibility.traceFlags.visible" nzAlign="center" nzWidth="60px">{{
-        'log.manage.table.column.trace-flags' | i18n
-      }}</th>
-      <th *ngIf="columnVisibility.instrumentation.visible" nzAlign="center" nzWidth="100px">{{
-        'log.manage.table.column.instrumentation' | i18n
-      }}</th>
-      <th *ngIf="columnVisibility.droppedCount.visible" nzAlign="center" nzWidth="70px">{{
-        'log.manage.table.column.dropped-count' | i18n
-      }}</th>
-    </tr>
-  </thead>
-  <tbody>
-    <tr *ngFor="let i of fixedTable.data" [title]="'log.manage.table.title' | i18n">
-      <td nzAlign="center">
-        <label
-          nz-checkbox
-          [ngModel]="setOfCheckedId.has(getLogId(i))"
-          (ngModelChange)="onItemChecked(getLogId(i), $event)"
-          (click)="$event.stopPropagation()"
-        >
-        </label>
-      </td>
-      <td *ngIf="columnVisibility.time.visible" nzAlign="center" (click)="showLogDetails(i)">
-        <span [title]="(i.timeUnixNano || 0) / 1000000 | date : 'yyyy-MM-dd HH:mm:ss.SSS'">
-          {{ (i.timeUnixNano || 0) / 1000000 | date : 'yyyy-MM-dd HH:mm:ss' }}
-        </span>
-      </td>
-      <td *ngIf="columnVisibility.observedTime.visible" nzAlign="center" (click)="showLogDetails(i)">
-        <span *ngIf="i.observedTimeUnixNano" [title]="(i.observedTimeUnixNano || 0) / 1000000 | date : 'yyyy-MM-dd HH:mm:ss.SSS'">
-          {{ (i.observedTimeUnixNano || 0) / 1000000 | date : 'yyyy-MM-dd HH:mm:ss' }}
-        </span>
-        <span *ngIf="!i.observedTimeUnixNano" class="text-muted">-</span>
-      </td>
-      <td *ngIf="columnVisibility.severity.visible" nzAlign="center" (click)="showLogDetails(i)">
-        <nz-tag [nzColor]="getSeverityColor(i.severityNumber)">
-          {{ i.severityText || i.severityNumber || '-' }}
-        </nz-tag>
-      </td>
-      <td *ngIf="columnVisibility.body.visible" nzAlign="center" (click)="showLogDetails(i)">
-        <div class="log-body" [title]="i.body | json">
-          {{ getBodyText(i.body) }}
-        </div>
-      </td>
-      <td *ngIf="columnVisibility.attributes.visible" nzAlign="center" (click)="showLogDetails(i)">
-        <div *ngIf="i.attributes && getObjectText(i.attributes); else noAttributes" class="attributes-text" [title]="i.attributes | json">
-          {{ getObjectText(i.attributes) }}
-        </div>
-        <ng-template #noAttributes><span class="text-muted">-</span></ng-template>
-      </td>
-      <td *ngIf="columnVisibility.resource.visible" nzAlign="center" (click)="showLogDetails(i)">
-        <div *ngIf="i.resource && getObjectText(i.resource); else noResource" class="resource-text" [title]="i.resource | json">
-          {{ getObjectText(i.resource) }}
-        </div>
-        <ng-template #noResource><span class="text-muted">-</span></ng-template>
-      </td>
-      <td *ngIf="columnVisibility.traceId.visible" nzAlign="center" (click)="showLogDetails(i)">
-        <span *ngIf="i.traceId" class="trace-id" [title]="i.traceId">{{ i.traceId | slice : 0 : 8 }}...</span>
-        <span *ngIf="!i.traceId" class="text-muted">-</span>
-      </td>
-      <td *ngIf="columnVisibility.spanId.visible" nzAlign="center" (click)="showLogDetails(i)">
-        <span *ngIf="i.spanId" class="span-id" [title]="i.spanId">{{ i.spanId | slice : 0 : 8 }}...</span>
-        <span *ngIf="!i.spanId" class="text-muted">-</span>
-      </td>
-      <td *ngIf="columnVisibility.traceFlags.visible" nzAlign="center" (click)="showLogDetails(i)">
-        <span *ngIf="i.traceFlags !== undefined">{{ i.traceFlags }}</span>
-        <span *ngIf="i.traceFlags === undefined" class="text-muted">-</span>
-      </td>
-      <td *ngIf="columnVisibility.instrumentation.visible" nzAlign="center" (click)="showLogDetails(i)">
-        <div
-          *ngIf="i.instrumentationScope && i.instrumentationScope.name; else noInstrumentation"
-          [title]="getInstrumentationText(i.instrumentationScope)"
-        >
-          {{ i.instrumentationScope.name }}
-          <small *ngIf="i.instrumentationScope.version">({{ i.instrumentationScope.version }})</small>
-        </div>
-        <ng-template #noInstrumentation><span class="text-muted">-</span></ng-template>
-      </td>
-      <td *ngIf="columnVisibility.droppedCount.visible" nzAlign="center" (click)="showLogDetails(i)">
-        <span *ngIf="i.droppedAttributesCount !== undefined">{{ i.droppedAttributesCount }}</span>
-        <span *ngIf="i.droppedAttributesCount === undefined" class="text-muted">-</span>
-      </td>
-    </tr>
-  </tbody>
-</nz-table>
-
-<nz-modal
-  [(nzVisible)]="isModalVisible"
-  [nzTitle]="'log.manage.log-entry-details' | i18n"
-  (nzOnCancel)="handleModalCancel()"
-  [nzFooter]="null"
-  [nzWidth]="800"
->
-  <ng-container *nzModalContent>
-    <div *ngIf="selectedLogEntry" class="log-details-modal">
-      <nz-card [nzTitle]="'log.manage.basic-information' | i18n" [nzSize]="'small'" class="basic-info-card">
-        <div class="basic-info">
-          <div class="info-row">
-            <span class="info-label">{{ 'log.manage.severity-text' | i18n }}:</span>
-            <nz-tag [nzColor]="getSeverityColor(selectedLogEntry.severityNumber)">
-              {{ selectedLogEntry.severityText || selectedLogEntry.severityNumber || '未知' }}
-            </nz-tag>
-          </div>
-          <div class="info-row">
-            <span class="info-label">{{ 'log.manage.timestamp' | i18n }}:</span>
-            <span>{{ formatTimestamp(selectedLogEntry.timeUnixNano) }}</span>
-          </div>
-          <div class="info-row" *ngIf="selectedLogEntry.traceId">
-            <span class="info-label">{{ 'log.manage.trace-id' | i18n }}:</span>
-            <span class="info-value">{{ selectedLogEntry.traceId }}</span>
-          </div>
-          <div class="info-row" *ngIf="selectedLogEntry.spanId">
-            <span class="info-label">{{ 'log.manage.span-id' | i18n }}:</span>
-            <span class="info-value">{{ selectedLogEntry.spanId }}</span>
-          </div>
-        </div>
-      </nz-card>
-
-      <nz-card [nzTitle]="'log.manage.complete-json-data' | i18n" [nzSize]="'small'">
-        <div class="json-content">
-          <button
-            nz-button
-            nzSize="small"
-            nzType="default"
-            (click)="copyToClipboard(getLogEntryJson(selectedLogEntry))"
-            class="copy-button"
-            nz-tooltip="复制到剪贴板"
-          >
-            <i nz-icon nzType="copy"></i>
-          </button>
-          <pre class="pre">{{ getLogEntryJson(selectedLogEntry) }}</pre>
-        </div>
-      </nz-card>
     </div>
+    <nz-alert *ngIf="error" nzType="error" nzShowIcon [nzMessage]="error"></nz-alert>
+    <div class="overview-strip"
+      ><div
+        ><span>{{ 'observability.logs.total' | i18n }}</span
+        ><strong>{{ overview.totalCount || 0 }}</strong></div
+      ><div
+        ><span>ERROR</span><strong>{{ overview.errorCount || 0 }}</strong></div
+      ><div
+        ><span>WARN</span><strong>{{ overview.warnCount || 0 }}</strong></div
+      ><div
+        ><span>{{ 'observability.logs.with-trace' | i18n }}</span
+        ><strong>{{ overview.traceCoverage?.withTrace || 0 }}</strong></div
+      ></div
+    >
+    <div class="section-title"
+      ><h2>{{ 'observability.logs.trend' | i18n }}</h2
+      ><span>{{ total }}</span></div
+    >
+    <section class="chart-region"
+      ><div *ngIf="total; else emptyTrend" echarts [options]="chartOption" class="trend-chart"></div
+      ><ng-template #emptyTrend><nz-empty [nzNotFoundContent]="'observability.empty.logs' | i18n"></nz-empty></ng-template
+    ></section>
+    <div class="section-title"
+      ><h2>{{ 'observability.logs.results' | i18n }}</h2
+      ><span>{{ total }}</span></div
+    >
+    <nz-table
+      #logTable
+      [nzData]="logs"
+      nzSize="small"
+      [nzLoading]="loading"
+      [nzFrontPagination]="false"
+      [nzTotal]="total"
+      [nzPageIndex]="pageIndex"
+      [nzPageSize]="pageSize"
+      (nzPageIndexChange)="pageChanged($event)"
+    >
+      <thead
+        ><tr
+          ><th>{{ 'observability.column.time' | i18n }}</th
+          ><th>{{ 'observability.logs.severity' | i18n }}</th
+          ><th>{{ 'observability.column.service' | i18n }}</th
+          ><th>{{ 'observability.logs.message' | i18n }}</th
+          ><th>Trace ID</th></tr
+        ></thead
+      >
+      <tbody
+        ><tr *ngFor="let log of logTable.data" (click)="open(log)"
+          ><td>{{ (log.timeUnixNano || 0) / 1000000 | date : 'yyyy-MM-dd HH:mm:ss.SSS' }}</td
+          ><td
+            ><nz-tag [nzColor]="severityColor(log.severityNumber)">{{ log.severityText || log.severityNumber || '-' }}</nz-tag></td
+          ><td>{{ service(log) }}</td
+          ><td class="message-cell">{{ bodyText(log) }}</td
+          ><td
+            ><button *ngIf="log.traceId" nz-button nzType="link" class="mono" (click)="$event.stopPropagation(); openTrace(log)">{{
+              log.traceId
+            }}</button
+            ><span *ngIf="!log.traceId">-</span></td
+          ></tr
+        ></tbody
+      >
+    </nz-table>
+    <nz-drawer
+      [nzVisible]="detailVisible"
+      nzPlacement="right"
+      [nzWidth]="620"
+      [nzTitle]="'observability.logs.detail' | i18n"
+      (nzOnClose)="detailVisible = false"
+    >
+      <ng-container *nzDrawerContent
+        ><div class="drawer-actions"
+          ><button nz-button [disabled]="!selected?.traceId" (click)="selected && openTrace(selected)">{{
+            'observability.action.view-trace' | i18n
+          }}</button
+          ><button nz-button (click)="selected && openMetrics(selected)">{{ 'observability.action.view-metrics' | i18n }}</button></div
+        ><pre>{{ detailJson() }}</pre>
+      </ng-container>
+    </nz-drawer>
   </ng-container>
-</nz-modal>
+  <ng-template #streamMode>
+    <app-log-stream
+      [embedded]="true"
+      [filterTraceId]="traceId"
+      [filterSpanId]="spanId"
+      [filterSeverityText]="severityText"
+    ></app-log-stream>
+  </ng-template>
+</section>
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 71361bddfd..e37a150eaf 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 e7e11e6ca2..8a262b27df 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<LogManageComponent>;
+  it('switches query and live modes on the canonical log route', () => {
+    const logs = jasmine.createSpyObj<LogService>('LogService', ['list', 'overviewStats', 'trendStats']);
+    const route = { snapshot: { queryParamMap: { get: () => null }, data: {} } } as unknown as ActivatedRoute;
+    const router = jasmine.createSpyObj<Router>('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 496161769b..0c5f5281a4 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<string>();
-
-  // 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<LogOverviewStats> = {};
+  chartOption: EChartsOption = {};
+  selected?: LogEntry;
+  detailVisible = false;
+  loading = false;
+  error = '';
+  private readonly queryChanges = new Subject<void>();
+  private readonly destroyed = new Subject<void>();
 
-  // 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<string, number>) {
-    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<LogQueryOptions, 'traceId' | 'spanId' | 'serviceName' | 'serviceNamespace' | 'environment'> {
+    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<string, number>): 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-routing.module.ts b/web-app/src/app/routes/log/log-routing.module.ts
index 7ba2eb7916..6175bf8e29 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 4efa55c9ba..0916bbe21b 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 @@
 -->
 
 <app-help-message-show
+  *ngIf="!embedded"
   [help_message_content]="'log.help.stream' | i18n"
   [guild_link]="'log.help.stream.link' | i18n"
   [module_name]="'menu.log.stream'"
   [icon_name]="'file-text'"
 ></app-help-message-show>
 
-<nz-divider></nz-divider>
+<nz-divider *ngIf="!embedded"></nz-divider>
 
-<div class="log-stream-container">
+<div class="log-stream-container" [class.embedded]="embedded">
   <!-- Header with Controls and Filters -->
-  <nz-card [nzTitle]="'log.stream.title' | i18n" class="header-card">
+  <nz-card [nzTitle]="embedded ? undefined : ('log.stream.title' | i18n)" [nzBordered]="!embedded" class="header-card">
     <!-- Main Control Row -->
     <div class="header-content">
       <!-- Connection Status -->
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 eaa26d1258..f9fff80d39 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 e8d7630ddb..e5d7c1093e 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 0000000000..0ddaa72235
--- /dev/null
+++ b/web-app/src/app/routes/metrics/metrics-manage.component.html
@@ -0,0 +1,112 @@
+<!-- Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. -->
+<section class="signal-page">
+  <header class="page-heading">
+    <div>
+      <h1>{{ 'observability.metrics.title' | i18n }}</h1>
+      <p>{{ 'observability.metrics.subtitle' | i18n }}</p>
+    </div>
+    <div class="heading-actions">
+      <app-signal-navigation active="metrics" [context]="navigationContext()"></app-signal-navigation>
+      <span class="result-count">{{ series.length }} {{ 'observability.metrics.series' | i18n }}</span>
+    </div>
+  </header>
+
+  <div class="query-toolbar">
+    <app-signal-time-range
+      [(value)]="timeRange"
+      [(refreshSeconds)]="refreshSeconds"
+      (apply)="search()"
+      (refresh)="refreshToNow()"
+    ></app-signal-time-range>
+    <input
+      nz-input
+      class="promql-input mono"
+      [(ngModel)]="query"
+      [nzAutocomplete]="metricAuto"
+      [placeholder]="'observability.metrics.promql' | i18n"
+      (keyup.enter)="search()"
+    />
+    <nz-autocomplete #metricAuto>
+      <nz-auto-option *ngFor="let name of metricNames" [nzValue]="name">{{ name }}</nz-auto-option>
+    </nz-autocomplete>
+    <input nz-input type="number" min="1" max="3600" [(ngModel)]="step" [placeholder]="'observability.metrics.step' | i18n" />
+    <button nz-button nzType="primary" [nzLoading]="loading" (click)="search()">
+      <span nz-icon nzType="search"></span>{{ 'common.button.query' | i18n }}
+    </button>
+  </div>
+
+  <nz-alert *ngIf="error" nzType="error" nzShowIcon [nzMessage]="error"></nz-alert>
+
+  <div class="overview-strip">
+    <div
+      ><span>{{ 'observability.metrics.total-series' | i18n }}</span
+      ><strong>{{ series.length }}</strong></div
+    >
+    <div
+      ><span>{{ 'observability.metrics.total-samples' | i18n }}</span
+      ><strong>{{ summary.sampleCount }}</strong></div
+    >
+    <div
+      ><span>{{ 'observability.metrics.minimum' | i18n }}</span
+      ><strong>{{ summary.minimum | number : '1.0-2' }}</strong></div
+    >
+    <div
+      ><span>{{ 'observability.metrics.maximum' | i18n }}</span
+      ><strong>{{ summary.maximum | number : '1.0-2' }}</strong></div
+    >
+  </div>
+
+  <div class="section-title">
+    <h2>{{ 'observability.metrics.trend' | i18n }}</h2
+    ><span>{{ series.length }}</span>
+  </div>
+
+  <section class="chart-region" [class.loading-region]="loading">
+    <div *ngIf="series.length; else emptyChart" echarts [options]="chartOption" class="metric-chart"></div>
+    <ng-template #emptyChart><nz-empty [nzNotFoundContent]="'observability.empty.metrics' | i18n"></nz-empty></ng-template>
+  </section>
+
+  <div class="series-strip" *ngIf="series.length">
+    <button nz-button nzType="text" *ngFor="let item of series" (click)="showSeries(item)">
+      {{ labelText(item.labels) || query }}
+    </button>
+  </div>
+
+  <div class="section-title">
+    <h2>{{ 'observability.metrics.samples' | i18n }}</h2>
+    <span>{{ rows.length }}</span>
+  </div>
+  <nz-table #metricTable [nzData]="rows" nzSize="small" [nzPageSize]="20" [nzLoading]="loading">
+    <thead
+      ><tr
+        ><th>{{ 'observability.column.time' | i18n }}</th
+        ><th>{{ 'observability.column.value' | i18n }}</th
+        ><th>{{ 'observability.column.labels' | i18n }}</th></tr
+      ></thead
+    >
+    <tbody>
+      <tr *ngFor="let row of metricTable.data" (click)="showSeries({ labels: row.labels, points: [] })">
+        <td>{{ row.timestamp | date : 'yyyy-MM-dd HH:mm:ss' }}</td
+        ><td>{{ row.value }}</td
+        ><td class="mono">{{ labelText(row.labels) }}</td>
+      </tr>
+    </tbody>
+  </nz-table>
+
+  <nz-drawer
+    [nzVisible]="detailVisible"
+    nzPlacement="right"
+    [nzWidth]="480"
+    [nzTitle]="'observability.metrics.series-detail' | i18n"
+    (nzOnClose)="detailVisible = false"
+  >
+    <ng-container *nzDrawerContent>
+      <dl *ngIf="selectedSeries" class="detail-list">
+        <ng-container *ngFor="let label of selectedSeries.labels | keyvalue"
+          ><dt>{{ label.key }}</dt
+          ><dd>{{ label.value }}</dd></ng-container
+        >
+      </dl>
+    </ng-container>
+  </nz-drawer>
+</section>
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 0000000000..277be38e3a
--- /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.ts b/web-app/src/app/routes/metrics/metrics-manage.component.ts
new file mode 100644
index 0000000000..e68ce0b97f
--- /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<string, string>;
+}
+
+@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<void>();
+  private readonly destroyed = new Subject<void>();
+
+  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<MetricTableRow | undefined>(
+            (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, string>): 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/signal-navigation.component.ts b/web-app/src/app/routes/observability/signal-navigation.component.ts
new file mode 100644
index 0000000000..c1bd3389fe
--- /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: `
+    <nav [attr.aria-label]="'observability.navigation.signals' | i18n">
+      <a
+        nz-button
+        [nzType]="active === 'metrics' ? 'primary' : 'default'"
+        routerLink="/metrics/manage"
+        [queryParams]="context"
+        [attr.aria-current]="active === 'metrics' ? 'page' : null"
+        >{{ 'observability.metrics.title' | i18n }}</a
+      >
+      <a
+        nz-button
+        [nzType]="active === 'logs' ? 'primary' : 'default'"
+        routerLink="/log/manage"
+        [queryParams]="context"
+        [attr.aria-current]="active === 'logs' ? 'page' : null"
+        >{{ 'observability.logs.title' | i18n }}</a
+      >
+      <a
+        nz-button
+        [nzType]="active === 'traces' ? 'primary' : 'default'"
+        routerLink="/trace/manage"
+        [queryParams]="context"
+        [attr.aria-current]="active === 'traces' ? 'page' : null"
+        >{{ 'observability.traces.title' | i18n }}</a
+      >
+    </nav>
+  `,
+  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.ts b/web-app/src/app/routes/observability/signal-query-context.ts
new file mode 100644
index 0000000000..7c757f72f2
--- /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.ts b/web-app/src/app/routes/observability/signal-time-range.component.ts
new file mode 100644
index 0000000000..06bf68d75c
--- /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: `
+    <div class="range-actions" role="toolbar" [attr.aria-label]="'observability.time.toolbar' | i18n">
+      <button nz-button type="button" (click)="shift(-1)" [title]="'observability.time.previous' | i18n">
+        <span nz-icon nzType="left"></span>
+      </button>
+      <nz-select [ngModel]="selectedPreset" (ngModelChange)="selectPreset($event)" [attr.aria-label]="'observability.time.preset' | i18n">
+        <nz-option
+          *ngFor="let preset of presets"
+          [nzValue]="preset.value"
+          [nzLabel]="'observability.time.last-' + preset.value | i18n"
+        ></nz-option>
+        <nz-option *ngIf="selectedPreset === 'custom'" nzValue="custom" [nzLabel]="'observability.time.custom' | i18n"></nz-option>
+      </nz-select>
+      <button nz-button type="button" [disabled]="!canMoveForward" (click)="shift(1)" [title]="'observability.time.next' | i18n">
+        <span nz-icon nzType="right"></span>
+      </button>
+    </div>
+    <nz-range-picker [ngModel]="value" (ngModelChange)="setAbsoluteRange($event)" nzShowTime></nz-range-picker>
+    <nz-select
+      [ngModel]="refreshSeconds"
+      (ngModelChange)="setRefreshInterval($event)"
+      [attr.aria-label]="'observability.time.auto-refresh' | i18n"
+    >
+      <nz-option [nzValue]="0" [nzLabel]="'observability.time.refresh-manual' | i18n"></nz-option>
+      <nz-option [nzValue]="10" [nzLabel]="'observability.time.refresh-10s' | i18n"></nz-option>
+      <nz-option [nzValue]="30" [nzLabel]="'observability.time.refresh-30s' | i18n"></nz-option>
+      <nz-option [nzValue]="60" [nzLabel]="'observability.time.refresh-1m' | i18n"></nz-option>
+      <nz-option [nzValue]="300" [nzLabel]="'observability.time.refresh-5m' | i18n"></nz-option>
+    </nz-select>
+    <button nz-button type="button" (click)="refreshNow()" [title]="'common.refresh' | i18n">
+      <span nz-icon nzType="reload"></span>
+    </button>
+  `,
+  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<Date[]>();
+  @Output() readonly refreshSecondsChange = new EventEmitter<number>();
+  @Output() readonly apply = new EventEmitter<void>();
+  @Output() readonly refresh = new EventEmitter<void>();
+  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 b1ff83b008..084a87a246 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 0000000000..5b6983f0ea
--- /dev/null
+++ b/web-app/src/app/routes/trace/trace-manage.component.html
@@ -0,0 +1,174 @@
+<!-- Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. -->
+<section class="signal-page">
+  <header class="page-heading">
+    <div
+      ><h1>{{ 'observability.traces.title' | i18n }}</h1
+      ><p>{{ 'observability.traces.subtitle' | i18n }}</p></div
+    >
+    <app-signal-navigation active="traces" [context]="navigationContext()"></app-signal-navigation>
+  </header>
+  <div class="query-toolbar">
+    <app-signal-time-range
+      [(value)]="timeRange"
+      [(refreshSeconds)]="refreshSeconds"
+      (apply)="search()"
+      (refresh)="refreshToNow()"
+    ></app-signal-time-range>
+    <input nz-input [(ngModel)]="serviceName" [placeholder]="'observability.filter.service' | i18n" />
+    <input nz-input [(ngModel)]="environment" [placeholder]="'observability.filter.environment' | i18n" />
+    <input nz-input [(ngModel)]="operationName" [placeholder]="'observability.filter.operation' | i18n" />
+    <input nz-input [(ngModel)]="traceId" [placeholder]="'observability.filter.trace-id' | i18n" />
+    <input nz-input type="number" [(ngModel)]="minDurationMs" [placeholder]="'observability.filter.min-duration' | i18n" />
+    <input nz-input type="number" [(ngModel)]="maxDurationMs" [placeholder]="'observability.filter.max-duration' | i18n" />
+    <nz-select [(ngModel)]="errorOnly" [nzPlaceHolder]="'observability.filter.status' | i18n">
+      <nz-option [nzValue]="false" [nzLabel]="'observability.filter.all-statuses' | i18n"></nz-option>
+      <nz-option [nzValue]="true" [nzLabel]="'observability.filter.errors-only' | i18n"></nz-option>
+    </nz-select>
+    <button nz-button nzType="primary" [nzLoading]="loading" (click)="search()"
+      ><span nz-icon nzType="search"></span>{{ 'common.button.query' | i18n }}</button
+    >
+  </div>
+  <nz-alert *ngIf="error" nzType="error" nzShowIcon [nzMessage]="error"></nz-alert>
+  <div class="overview-strip">
+    <div
+      ><span>{{ 'observability.traces.total' | i18n }}</span
+      ><strong>{{ overview.totalCount }}</strong></div
+    >
+    <div
+      ><span>{{ 'observability.traces.errors' | i18n }}</span
+      ><strong>{{ overview.errorCount }}</strong></div
+    >
+    <div
+      ><span>{{ 'observability.traces.error-rate' | i18n }}</span
+      ><strong>{{ overview.errorRate | percent : '1.1-1' }}</strong></div
+    >
+    <div
+      ><span>{{ 'observability.traces.average' | i18n }}</span
+      ><strong>{{ overview.averageDurationMillis | number : '1.0-2' }} ms</strong></div
+    >
+    <div
+      ><span>P95</span><strong>{{ overview.p95DurationMillis | number : '1.0-2' }} ms</strong></div
+    >
+  </div>
+  <div class="section-title"
+    ><h2>{{ 'observability.traces.results' | i18n }}</h2
+    ><span>{{ total }}</span></div
+  >
+  <nz-table
+    #traceTable
+    [nzData]="traces"
+    nzSize="small"
+    [nzLoading]="loading"
+    [nzFrontPagination]="false"
+    [nzTotal]="total"
+    [nzPageIndex]="pageIndex"
+    [nzPageSize]="pageSize"
+    (nzPageIndexChange)="pageChanged($event)"
+  >
+    <thead
+      ><tr
+        ><th>{{ 'observability.column.start' | i18n }}</th
+        ><th>{{ 'observability.column.service' | i18n }}</th
+        ><th>{{ 'observability.column.operation' | i18n }}</th
+        ><th>{{ 'observability.column.duration' | i18n }}</th
+        ><th>{{ 'observability.column.status' | i18n }}</th
+        ><th>Trace ID</th></tr
+      ></thead
+    >
+    <tbody
+      ><tr *ngFor="let trace of traceTable.data" (click)="openTrace(trace)"
+        ><td>{{ trace.startTime | date : 'yyyy-MM-dd HH:mm:ss.SSS' }}</td
+        ><td>{{ trace.serviceName }}</td
+        ><td>{{ trace.rootSpanName }}</td
+        ><td>{{ trace.durationNanos / 1000000 | number : '1.0-2' }} ms</td
+        ><td
+          ><nz-tag [nzColor]="trace.errorSpanCount ? 'error' : 'success'">{{ trace.status }}</nz-tag></td
+        ><td class="mono">{{ trace.traceId }}</td></tr
+      ></tbody
+    >
+  </nz-table>
+  <nz-drawer
+    [nzVisible]="detailVisible"
+    nzPlacement="right"
+    [nzWidth]="760"
+    [nzTitle]="selected?.summary?.rootSpanName || ('observability.traces.detail' | i18n)"
+    (nzOnClose)="detailVisible = false"
+  >
+    <ng-container *nzDrawerContent>
+      <nz-spin [nzSpinning]="detailLoading">
+        <div class="drawer-actions"
+          ><button nz-button (click)="viewLogs()">{{ 'observability.action.view-logs' | i18n }}</button
+          ><button nz-button (click)="viewMetrics()">{{ 'observability.action.view-metrics' | i18n }}</button></div
+        >
+        <div class="waterfall" *ngIf="selected?.spans?.length; else noSpans">
+          <div class="waterfall-axis">
+            <span>Span</span>
+            <div class="time-ruler"
+              ><span>0 ms</span><span>{{ waterfallDurationMs() | number : '1.0-2' }} ms</span></div
+            >
+            <span>{{ 'observability.column.duration' | i18n }}</span>
+          </div>
+          <div
+            class="waterfall-row"
+            *ngFor="let span of selected?.spans"
+            [class.selected-row]="selectedSpan?.spanId === span.spanId"
+            (click)="selectSpan(span)"
+            ><div class="span-name" [style.padding-left.px]="spanDepth(span) * 14"
+              ><strong>{{ span.serviceName }}</strong
+              ><span>{{ span.spanName }}</span></div
+            ><div class="span-track"
+              ><span
+                class="span-bar"
+                [class.error-bar]="isErrorSpan(span)"
+                [style.left.%]="waterfallOffset(span)"
+                [style.width.%]="waterfallWidth(span)"
+              ></span></div
+            ><span>{{ span.durationNanos / 1000000 | number : '1.0-2' }} ms</span></div
+          >
+        </div>
+        <ng-template #noSpans><nz-empty [nzNotFoundContent]="'observability.empty.spans' | i18n"></nz-empty></ng-template>
+        <div class="attribute-section" *ngIf="selectedSpan">
+          <h3>{{ 'observability.traces.span-details' | i18n }}</h3>
+          <dl>
+            <dt>Span ID</dt><dd>{{ selectedSpan.spanId }}</dd> <dt>{{ 'observability.column.status' | i18n }}</dt
+            ><dd class="status-value"
+              ><nz-tag [nzColor]="isErrorSpan(selectedSpan) ? 'error' : 'success'">{{ spanStatus(selectedSpan) }}</nz-tag
+              ><span *ngIf="selectedSpan.statusMessage">{{ selectedSpan.statusMessage }}</span></dd
+            >
+            <ng-container *ngFor="let item of selectedSpan.spanAttributes | keyvalue"
+              ><dt>{{ item.key }}</dt
+              ><dd>{{ item.value }}</dd></ng-container
+            >
+          </dl>
+          <h4>{{ 'observability.traces.events' | i18n }}</h4>
+          <div class="event-list" *ngIf="selectedSpan.spanEvents?.length; else noEvents">
+            <article class="event-item" *ngFor="let event of selectedSpan.spanEvents">
+              <header
+                ><strong>{{ event.name }}</strong
+                ><time>{{ event.time }}</time></header
+              >
+              <dl *ngIf="event.attributes | keyvalue as attributes">
+                <ng-container *ngFor="let attribute of attributes"
+                  ><dt>{{ attribute.key }}</dt
+                  ><dd>{{ attribute.value }}</dd></ng-container
+                >
+              </dl>
+            </article>
+          </div>
+          <ng-template #noEvents
+            ><p class="inline-empty">{{ 'observability.empty.events' | i18n }}</p></ng-template
+          >
+        </div>
+        <div class="attribute-section" *ngIf="selected?.summary"
+          ><h3>{{ 'observability.traces.resource-attributes' | i18n }}</h3
+          ><dl
+            ><ng-container *ngFor="let item of selected?.summary?.resourceAttributes | keyvalue"
+              ><dt>{{ item.key }}</dt
+              ><dd>{{ item.value }}</dd></ng-container
+            ></dl
+          ></div
+        >
+      </nz-spin>
+    </ng-container>
+  </nz-drawer>
+</section>
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 0000000000..6a7eced52e
--- /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.ts b/web-app/src/app/routes/trace/trace-manage.component.ts
new file mode 100644
index 0000000000..405d2f31d9
--- /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<void>();
+  private readonly destroyed = new Subject<void>();
+
+  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<string>();
+    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 0f5b70ea7d..2fc75b7ee3 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<Message<Page<LogEntry>>> {
-    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<Message<any>>(logs_list_uri, { params });
+  list(options: LogQueryOptions): Observable<Message<Page<LogEntry>>> {
+    return this.http.get<Message<Page<LogEntry>>>('/logs/list', { params: this.params(options) });
   }
 
-  public overviewStats(
-    start?: number,
-    end?: number,
-    traceId?: string,
-    spanId?: string,
-    severityNumber?: number,
-    severityText?: string,
-    search?: string
-  ): Observable<Message<any>> {
-    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<Message<any>>(logs_stats_overview_uri, { params });
+  overviewStats(options: LogQueryOptions): Observable<Message<LogOverviewStats>> {
+    return this.http.get<Message<LogOverviewStats>>('/logs/stats/overview', { params: this.params(options) });
   }
 
-  public trendStats(
-    start?: number,
-    end?: number,
-    traceId?: string,
-    spanId?: string,
-    severityNumber?: number,
-    severityText?: string,
-    search?: string
-  ): Observable<Message<any>> {
-    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<Message<any>>(logs_stats_trend_uri, { params });
+  trendStats(options: LogQueryOptions): Observable<Message<{ hourlyStats: Record<string, number> }>> {
+    return this.http.get<Message<{ hourlyStats: Record<string, number> }>>('/logs/stats/trend', {
+      params: this.params(options)
+    });
   }
 
-  public traceCoverageStats(
-    start?: number,
-    end?: number,
-    traceId?: string,
-    spanId?: string,
-    severityNumber?: number,
-    severityText?: string,
-    search?: string
-  ): Observable<Message<any>> {
+  batchDelete(timeUnixNanos: number[]): Observable<Message<string>> {
     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<Message<any>>(logs_stats_trace_coverage_uri, { params });
+    timeUnixNanos.forEach(value => (params = params.append('timeUnixNanos', value)));
+    return this.http.delete<Message<string>>('/logs', { params });
   }
 
-  public batchDelete(timeUnixNanos: number[]): Observable<Message<string>> {
-    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<Message<string>>(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 b0e1c03189..e7ef04d912 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.ts b/web-app/src/app/service/observability.service.ts
new file mode 100644
index 0000000000..22f32e8579
--- /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<string, string>;
+  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<string, string>;
+}
+
+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<string, string>;
+  spanAttributes: Record<string, string>;
+  spanEvents: TraceSpanEvent[];
+}
+
+export interface TraceSpanEvent {
+  name: string;
+  time: string;
+  attributes: Record<string, unknown>;
+}
+
+export interface TraceDetail {
+  summary: TraceListItem;
+  spans: TraceSpanNode[];
+}
+
+export interface TraceOverview {
+  totalCount: number;
+  errorCount: number;
+  errorRate: number;
+  averageDurationMillis: number;
+  p95DurationMillis: number;
+}
+
+export interface SignalPage<T> {
+  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<SignalProbeResult[]> {
+    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<Message<{ metricNames: string[] }>> {
+    return this.http.get<Message<{ metricNames: string[] }>>('/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<Message<MetricsConsole>> {
+    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<Message<MetricsConsole>>('/ingestion/otlp/metrics/console', { params });
+  }
+
+  queryTraces(
+    context: SignalContext,
+    pageIndex: number,
+    pageSize: number,
+    errorOnly = false,
+    minDurationMs?: number,
+    maxDurationMs?: number
+  ): Observable<Message<SignalPage<TraceListItem>>> {
+    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<Message<SignalPage<TraceListItem>>>('/traces/list', { params });
+  }
+
+  traceOverview(context: SignalContext, errorOnly = false): Observable<Message<TraceOverview>> {
+    return this.http.get<Message<TraceOverview>>('/traces/stats/overview', {
+      params: this.contextParams(context).set('errorOnly', errorOnly)
+    });
+  }
+
+  traceDetail(traceId: string): Observable<Message<TraceDetail>> {
+    return this.http.get<Message<TraceDetail>>(`/traces/${encodeURIComponent(traceId)}`);
+  }
+
+  private probeMetrics(config: OtlpConnectionForm, now: number): Observable<SignalProbeResult> {
+    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<Message<MetricsConsole>>('/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<SignalProbeResult> {
+    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<Message<SignalPage<LogProbeEntry>>>('/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<SignalProbeResult> {
+    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<Message<SignalPage<TraceListItem>>>('/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<unknown> {
+    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<string, unknown> | undefined {
+    return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
+  }
+}
diff --git a/web-app/src/assets/app-data.json b/web-app/src/assets/app-data.json
index c880a953fe..87b6f3fb3b 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 211fd3387c..4fc35366f4 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 68336fff41..c86536d970 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 b9b9c520dd..77003bc641 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 db4ef2f856..b7be8345c2 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 6cb39791dd..d53cfa573f 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 46f0c9da57..f2e3a7be40 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/


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@hertzbeat.apache.org
For additional commands, e-mail: notifications-help@hertzbeat.apache.org


(hertzbeat) 03/03: test(observability): cover transition release contracts

Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

zqr10159 pushed a commit to branch feature/angular-three-signals
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git

commit a9222b2a78ea5aa8aa87732de3aa46069bdcbf12
Author: Logic <zq...@dromara.org>
AuthorDate: Sat Jul 11 16:24:17 2026 +0800

    test(observability): cover transition release contracts
---
 .../log/config/GreptimeSignalInitializerTest.java  |  92 +++++++++++
 .../log/config/GreptimeSignalPipelineTest.java     |  37 +++++
 .../log/config/OtlpGrpcServerConfigTest.java       | 107 ++++++++++++
 .../log/controller/OtlpSignalControllerTest.java   |  81 +++++++++
 .../SignalWorkloadExceptionHandlerTest.java        |  59 +++++++
 .../controller/ThreeSignalQueryControllerTest.java | 145 ++++++++++++++++
 .../log/service/SignalWorkloadGuardTest.java       |  63 +++++++
 .../impl/GreptimeOtlpSignalForwarderTest.java      | 126 ++++++++++++++
 .../impl/GreptimeThreeSignalQueryServiceTest.java  | 182 +++++++++++++++++++++
 .../impl/ManagerOtlpAccessTokenValidatorTest.java  |  80 +++++++++
 .../greptime/ResourceFilterExpressionTest.java     |  58 +++++++
 .../routes/log/log-manage/log-trend-time.spec.ts   |  30 ++++
 .../metrics/metrics-manage.component.spec.ts       |  74 +++++++++
 .../app/routes/observability-navigation.spec.ts    |  84 ++++++++++
 .../observability/signal-query-context.spec.ts     |  65 ++++++++
 .../signal-time-range.component.spec.ts            |  47 ++++++
 .../routes/trace/trace-manage.component.spec.ts    | 126 ++++++++++++++
 .../src/app/service/observability.service.spec.ts  |  76 +++++++++
 18 files changed, 1532 insertions(+)

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 0000000000..1054477af7
--- /dev/null
+++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/GreptimeSignalInitializerTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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.startsWith("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 0000000000..1e29119ec4
--- /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 0000000000..41245fe391
--- /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/OtlpSignalControllerTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/OtlpSignalControllerTest.java
new file mode 100644
index 0000000000..a3e48a14e0
--- /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 0000000000..9e3a32fa6d
--- /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<Message<Void>> 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<Message<Void>> 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 0000000000..a98c9fdd2e
--- /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 0000000000..d8c92d7625
--- /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<String> 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 0000000000..07b4cd1815
--- /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<byte[]> response = forwarder.forwardHttp("metrics", "{}".getBytes(StandardCharsets.UTF_8),
+                headers);
+
+        assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
+        ArgumentCaptor<HttpEntity<byte[]>> 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<HttpEntity<byte[]>> 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 0000000000..5a377ac196
--- /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> uri = ArgumentCaptor.forClass(URI.class);
+        ArgumentCaptor<HttpEntity<?>> 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<String> 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<String, Object> 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<String, Object> 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<String, Object> 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/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 0000000000..d65a447305
--- /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-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 0000000000..a4daaba443
--- /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/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 0000000000..2459a80281
--- /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/metrics/metrics-manage.component.spec.ts b/web-app/src/app/routes/metrics/metrics-manage.component.spec.ts
new file mode 100644
index 0000000000..304d49fd13
--- /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>('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>('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/observability-navigation.spec.ts b/web-app/src/app/routes/observability-navigation.spec.ts
new file mode 100644
index 0000000000..47bbd76503
--- /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>('ObservabilityService', [
+      'queryTraces',
+      'traceOverview',
+      'traceDetail'
+    ]);
+    const route = { snapshot: { queryParamMap: convertToParamMap({}) } } as unknown as ActivatedRoute;
+    const router = jasmine.createSpyObj<Router>('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>('LogService', ['list', 'overviewStats', 'trendStats']);
+    const route = { snapshot: { queryParamMap: convertToParamMap({}) } } as unknown as ActivatedRoute;
+    const router = jasmine.createSpyObj<Router>('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-query-context.spec.ts b/web-app/src/app/routes/observability/signal-query-context.spec.ts
new file mode 100644
index 0000000000..50a66dcec4
--- /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-time-range.component.spec.ts b/web-app/src/app/routes/observability/signal-time-range.component.spec.ts
new file mode 100644
index 0000000000..973cd2ff57
--- /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/trace/trace-manage.component.spec.ts b/web-app/src/app/routes/trace/trace-manage.component.spec.ts
new file mode 100644
index 0000000000..044a995975
--- /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>('ObservabilityService', ['queryTraces', 'traceOverview', 'traceDetail']),
+      { snapshot: { queryParamMap: convertToParamMap({}) } } as unknown as ActivatedRoute,
+      jasmine.createSpyObj<Router>('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>('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>('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/service/observability.service.spec.ts b/web-app/src/app/service/observability.service.spec.ts
new file mode 100644
index 0000000000..c6b2c30714
--- /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<unknown>;
+}
+
+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>('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();
+  });
+});


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@hertzbeat.apache.org
For additional commands, e-mail: notifications-help@hertzbeat.apache.org