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 <[email protected]>
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: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to