Compare commits

...
Author SHA1 Message Date
Logic 6a53e1b58e fix(e2e): pin Vector image for log tests 2026-07-15 23:20:34 +08:00
aias00 2f1fa48bb3 Merge branch 'master' into feature/angular-three-signals 2026-07-15 07:16:35 -07:00
Tomsun28andgithub-actions[bot] 2106bbb074 Update hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/OtlpMetricsInventory.java
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Tomsun28 <tomsun28@outlook.com>
2026-07-13 22:46:33 +08:00
Tomsun28andgithub-actions[bot] 30076e6ea6 Update hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/SignalPage.java
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Tomsun28 <tomsun28@outlook.com>
2026-07-13 22:46:24 +08:00
Tomsun28andgithub-actions[bot] cce7b7ac46 Update hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/MetricSeries.java
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Tomsun28 <tomsun28@outlook.com>
2026-07-13 22:46:14 +08:00
Duansg 2c376356ef Merge branch 'master' into feature/angular-three-signals 2026-07-13 14:13:42 +08:00
Logic 5d523ddce3 docs(pr): add Angular observability screenshots 2026-07-12 08:01:47 +08:00
Logic 1c0c881c58 test(log): isolate OTLP gRPC from storage e2e 2026-07-11 22:11:37 +08:00
Logic 46cce89d7e test(observability): allow licensed Greptime schema 2026-07-11 21:53:36 +08:00
Logic 45848ecdb5 Merge remote-tracking branch 'apache/feature/angular-three-signals' into feature/angular-three-signals 2026-07-11 21:49:21 +08:00
Logic 216e3f9d29 fix(ci): gate Greptime signal controllers 2026-07-11 21:47:22 +08:00
Duansgandgithub-actions[bot] 9f22a1cdd5 Update hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/observability/MetricPoint.java
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Duansg <siguoduan@gmail.com>
2026-07-11 20:57:30 +08:00
Duansg a899c7c518 Merge branch 'master' into feature/angular-three-signals 2026-07-11 20:55:46 +08:00
Logic a9222b2a78 test(observability): cover transition release contracts 2026-07-11 16:24:17 +08:00
Logic d5cb2e6660 feat(web): add Angular three-signal workbenches 2026-07-11 16:23:40 +08:00
Logic 1c03f830e5 feat(observability): add entity-free OTLP signal APIs 2026-07-11 16:23:08 +08:00
107 changed files with 7817 additions and 1397 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

@@ -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);
}
}
@@ -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) {
}
@@ -0,0 +1,29 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.hertzbeat.common.entity.dto.observability;
/**
* One OTLP metric sample.
*
* @param timestamp epoch milliseconds
* @param value numeric sample value
*/
public record MetricPoint(long timestamp, double value) {
}
@@ -0,0 +1,49 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.entity.dto.observability;
import java.util.List;
import java.util.Map;
/**
* OTLP metric time series.
*
* @param labels series labels
* @param points ordered samples
*/
public record MetricSeries(Map<String, String> labels, List<MetricPoint> points) {
}
@@ -0,0 +1,32 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.entity.dto.observability;
import java.util.List;
/**
* OTLP metrics query result.
*
* @param query effective PromQL expression
* @param start query start in epoch milliseconds
* @param end query end in epoch milliseconds
* @param stepSeconds query resolution
* @param series returned time series
*/
public record OtlpMetricsConsole(String query, long start, long end, int stepSeconds, List<MetricSeries> series) {
}
@@ -0,0 +1,47 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.entity.dto.observability;
import java.util.List;
/**
* Discoverable OTLP metric names.
*
* @param metricNames sorted metric names
*/
public record OtlpMetricsInventory(List<String> metricNames) {
}
@@ -0,0 +1,51 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.entity.dto.observability;
import java.util.List;
/**
* Stable pagination payload shared by the three signal workbenches.
*
* @param content current page content
* @param pageIndex zero-based page index
* @param pageSize requested page size
* @param totalElements total matching records
* @param <T> row type
*/
public record SignalPage<T>(List<T> content, int pageIndex, int pageSize, long totalElements) {
}
@@ -0,0 +1,24 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.entity.dto.observability;
import java.util.List;
/** Complete trace detail. */
public record TraceDetail(TraceListItem summary, List<TraceSpanNode> spans) {
}
@@ -0,0 +1,26 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.entity.dto.observability;
import java.util.Map;
/** Trace summary row. */
public record TraceListItem(String traceId, String rootSpanId, String serviceName, String serviceNamespace,
String rootSpanName, long durationNanos, String status, long startTime,
int errorSpanCount, Map<String, String> resourceAttributes) {
}
@@ -0,0 +1,23 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.entity.dto.observability;
/** Trace aggregate statistics. */
public record TraceOverview(long totalCount, long errorCount, double errorRate, double averageDurationMillis,
double p95DurationMillis) {
}
@@ -0,0 +1,24 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.entity.dto.observability;
import java.util.Map;
/** Event emitted while a trace span is active. */
public record TraceSpanEvent(String name, String time, Map<String, Object> attributes) {
}
@@ -0,0 +1,28 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.entity.dto.observability;
import java.util.List;
import java.util.Map;
/** Trace span used by the Angular waterfall. */
public record TraceSpanNode(String traceId, String spanId, String parentSpanId, String spanName,
String serviceName, String status, String spanKind, String statusMessage,
long durationNanos, long startTime, Map<String, String> resourceAttributes,
Map<String, String> spanAttributes, List<TraceSpanEvent> spanEvents) {
}
@@ -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);
}
@@ -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);
}
}
@@ -63,13 +63,14 @@ import static org.mockito.Mockito.doAnswer;
@SpringBootTest(classes = org.apache.hertzbeat.startup.HertzBeatApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = {
"warehouse.store.duckdb.enabled=false",
"warehouse.store.greptime.enabled=true"
"warehouse.store.greptime.enabled=true",
"hertzbeat.otlp.grpc.enabled=false"
})
@Slf4j
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class LogPeriodicAlertE2eTest {
private static final String VECTOR_IMAGE = "timberio/vector:latest-alpine";
private static final String VECTOR_IMAGE = "timberio/vector:0.56.0-alpine";
private static final int VECTOR_PORT = 8686;
private static final String VECTOR_CONFIG_PATH = "/etc/vector/vector.yml";
private static final String ENV_HERTZBEAT_PORT = "HERTZBEAT_PORT";
@@ -60,7 +60,7 @@ import static org.mockito.Mockito.doAnswer;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class LogRealTimeAlertE2eTest {
private static final String VECTOR_IMAGE = "timberio/vector:latest-alpine";
private static final String VECTOR_IMAGE = "timberio/vector:0.56.0-alpine";
private static final int VECTOR_PORT = 8686;
private static final String VECTOR_CONFIG_PATH = "/etc/vector/vector.yml";
private static final String ENV_HERTZBEAT_PORT = "HERTZBEAT_PORT";
@@ -49,7 +49,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class LogIngestionE2eTest {
private static final String VECTOR_IMAGE = "timberio/vector:latest-alpine";
private static final String VECTOR_IMAGE = "timberio/vector:0.56.0-alpine";
private static final int VECTOR_PORT = 8686;
private static final String VECTOR_CONFIG_PATH = "/etc/vector/vector.yml";
private static final String ENV_HERTZBEAT_PORT = "HERTZBEAT_PORT";
@@ -52,13 +52,14 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringBootTest(classes = org.apache.hertzbeat.startup.HertzBeatApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = {
"warehouse.store.duckdb.enabled=false",
"warehouse.store.greptime.enabled=true"
"warehouse.store.greptime.enabled=true",
"hertzbeat.otlp.grpc.enabled=false"
})
@Slf4j
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class GreptimeLogStorageE2eTest {
private static final String VECTOR_IMAGE = "timberio/vector:latest-alpine";
private static final String VECTOR_IMAGE = "timberio/vector:0.56.0-alpine";
private static final int VECTOR_PORT = 8686;
private static final String VECTOR_CONFIG_PATH = "/etc/vector/vector.yml";
private static final String ENV_HERTZBEAT_PORT = "HERTZBEAT_PORT";
@@ -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
+18 -1
View File
@@ -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>
</project>
@@ -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());
}
}
}
@@ -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) { };
}
}
}
@@ -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 final HistoryDataReader historyDataReader;
private static final int MAX_PAGE_SIZE = 200;
public LogQueryController(HistoryDataReader historyDataReader) {
private final HistoryDataReader historyDataReader;
private final SignalWorkloadGuard workloadGuard;
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);
@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)));
}
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));
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);
}
}
}
@@ -0,0 +1,80 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.nio.charset.StandardCharsets;
import org.apache.hertzbeat.log.service.OtlpSignalForwarder;
import org.apache.hertzbeat.log.service.SignalWorkloadGuard;
import org.apache.hertzbeat.log.service.SignalWorkloadGuard.Workload;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Standard OTLP/HTTP gateway for all three signals. */
@RestController
@ConditionalOnProperty(prefix = "warehouse.store.greptime", name = "enabled", havingValue = "true")
@RequestMapping("/api/otlp/v1")
@Tag(name = "OTLP Signal Controller")
public class OtlpSignalController {
private final OtlpSignalForwarder signalForwarder;
private final SignalWorkloadGuard workloadGuard;
public OtlpSignalController(OtlpSignalForwarder signalForwarder, SignalWorkloadGuard workloadGuard) {
this.signalForwarder = signalForwarder;
this.workloadGuard = workloadGuard;
}
@PostMapping("/metrics")
@Operation(summary = "Ingest OTLP metrics")
public ResponseEntity<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));
}
}
@@ -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"));
}
}
@@ -0,0 +1,136 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.apache.hertzbeat.common.entity.dto.observability.OtlpMetricsConsole;
import org.apache.hertzbeat.common.entity.dto.observability.OtlpMetricsInventory;
import org.apache.hertzbeat.common.entity.dto.observability.SignalPage;
import org.apache.hertzbeat.common.entity.dto.observability.TraceDetail;
import org.apache.hertzbeat.common.entity.dto.observability.TraceListItem;
import org.apache.hertzbeat.common.entity.dto.observability.TraceOverview;
import org.apache.hertzbeat.log.service.ThreeSignalQueryService;
import org.apache.hertzbeat.log.service.SignalWorkloadGuard;
import org.apache.hertzbeat.log.service.SignalWorkloadGuard.Workload;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** Public entity-free query APIs for OTLP metrics and traces. */
@RestController
@ConditionalOnProperty(prefix = "warehouse.store.greptime", name = "enabled", havingValue = "true")
@Tag(name = "Three Signal Query Controller")
public class ThreeSignalQueryController {
private final ThreeSignalQueryService queryService;
private final SignalWorkloadGuard workloadGuard;
public ThreeSignalQueryController(ThreeSignalQueryService queryService, SignalWorkloadGuard workloadGuard) {
this.queryService = queryService;
this.workloadGuard = workloadGuard;
}
@GetMapping(path = "/api/ingestion/otlp/metrics/console", produces = "application/json")
@Operation(summary = "Query OTLP metrics")
public ResponseEntity<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())));
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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));
}
}
}
@@ -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);
}
@@ -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;
}
}
@@ -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");
}
}
}
@@ -0,0 +1,71 @@
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
version: 2
processors:
- vrl:
source: |
.time_unix_nano = .Timestamp
.observed_time_unix_nano = .ObservedTimestamp
.trace_id = .TraceId
.span_id = .SpanId
.trace_flags = .TraceFlags
.severity_text = .SeverityText
.severity_number = .SeverityNumber
.body = .Body
.attributes = .LogAttributes
.resource = .ResourceAttributes
.instrumentation_scope = .InstrumentationScope
.dropped_attributes_count = .DroppedAttributesCount
.
- select:
fields:
- time_unix_nano
- observed_time_unix_nano
- trace_id
- span_id
- trace_flags
- severity_text
- severity_number
- body
- attributes
- resource
- instrumentation_scope
- dropped_attributes_count
transform:
- field: time_unix_nano
type: epoch, ns
index: timestamp
- field: observed_time_unix_nano
type: epoch, ns
- fields:
- trace_id
- span_id
type: string
index: skipping
- fields:
- attributes
- resource
- instrumentation_scope
type: json
- field: body
type: string
- field: severity_text
type: string
- fields:
- severity_number
- trace_flags
- dropped_attributes_count
type: int32
@@ -0,0 +1,38 @@
-- Licensed to the Apache Software Foundation (ASF) under one or more
-- contributor license agreements. See the NOTICE file distributed with
-- this work for additional information regarding copyright ownership.
-- The ASF licenses this file to You under the Apache License, Version 2.0
-- (the "License"); you may not use this file except in compliance with
-- the License. You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
CREATE TABLE IF NOT EXISTS hzb_traces (
"timestamp" TIMESTAMP(9) TIME INDEX,
"timestamp_end" TIMESTAMP(9) NULL,
"duration_nano" BIGINT UNSIGNED NULL,
"trace_id" STRING NULL SKIPPING INDEX WITH(granularity = '10240', type = 'BLOOM'),
"span_id" STRING NULL SKIPPING INDEX WITH(granularity = '10240', type = 'BLOOM'),
"parent_span_id" STRING NULL SKIPPING INDEX WITH(granularity = '10240', type = 'BLOOM'),
"span_kind" STRING NULL,
"span_name" STRING NULL,
"span_status_code" STRING NULL,
"span_status_message" STRING NULL,
"trace_state" STRING NULL,
"scope_name" STRING NULL,
"scope_version" STRING NULL,
"service_name" STRING NULL SKIPPING INDEX WITH(granularity = '10240', type = 'BLOOM'),
"resource_attributes.service.namespace" STRING NULL,
"resource_attributes.deployment.environment.name" STRING NULL,
"resource_attributes" JSON NULL,
"span_attributes" JSON NULL,
"span_events" JSON NULL,
"span_links" JSON NULL,
PRIMARY KEY("service_name")
) WITH (append_mode = true, table_data_model = 'greptime_trace_v1');
@@ -0,0 +1,93 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.config;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.mockito.InOrder;
import org.apache.hertzbeat.warehouse.db.GreptimeSqlQueryExecutor;
import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
@ExtendWith(MockitoExtension.class)
class GreptimeSignalInitializerTest {
@Mock
private GreptimeSqlQueryExecutor sqlQueryExecutor;
@Mock
private RestTemplate restTemplate;
private GreptimeSignalInitializer initializer;
@BeforeEach
void setUp() {
initializer = new GreptimeSignalInitializer(
new GreptimeProperties(true, "127.0.0.1:4001", "http://127.0.0.1:4000",
"public", "greptime", "secret"),
sqlQueryExecutor,
restTemplate);
}
@Test
void shouldPrepareTraceSchemaPipelineAndReadiness() {
when(sqlQueryExecutor.execute(anyString())).thenAnswer(invocation ->
invocation.getArgument(0, String.class).startsWith("SELECT 1")
? List.of(Map.of("ready", 1)) : List.of());
when(restTemplate.exchange(anyString(), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class)))
.thenReturn(ResponseEntity.ok("ok"));
initializer.initialize();
InOrder sqlOrder = org.mockito.Mockito.inOrder(sqlQueryExecutor);
sqlOrder.verify(sqlQueryExecutor).execute(org.mockito.ArgumentMatchers.contains(
"CREATE TABLE IF NOT EXISTS hzb_traces"));
sqlOrder.verify(sqlQueryExecutor).execute(org.mockito.ArgumentMatchers.startsWith(
"ALTER TABLE hzb_traces ADD COLUMN IF NOT EXISTS \"resource_attributes.service.namespace\""));
sqlOrder.verify(sqlQueryExecutor).execute(org.mockito.ArgumentMatchers.startsWith(
"ALTER TABLE hzb_traces ADD COLUMN IF NOT EXISTS \"resource_attributes.deployment.environment.name\""));
sqlOrder.verify(sqlQueryExecutor).execute("SELECT 1 AS ready");
verify(restTemplate).exchange(eq("http://127.0.0.1:4000/v1/pipelines/hertzbeat_otlp_log_v1"),
eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class));
}
@Test
void shouldFailStartupWhenGreptimeIsUnavailable() {
when(sqlQueryExecutor.execute(anyString())).thenThrow(new IllegalStateException("offline"));
assertThatThrownBy(initializer::initialize)
.isInstanceOf(IllegalStateException.class)
.hasMessage("GreptimeDB is required for the three-signal release but initialization failed")
.hasCauseInstanceOf(IllegalStateException.class);
}
}
@@ -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");
}
}
@@ -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);
}
}
@@ -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
@@ -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"));
}
}
@@ -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());
}
}
@@ -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));
}
}
@@ -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();
}
}
}
@@ -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);
}
}
@@ -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", "[]"));
}
}
@@ -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;
}
@@ -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";
}
}
}
@@ -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);
}
}
@@ -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
@@ -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
@@ -43,6 +43,8 @@ import org.apache.hertzbeat.common.config.CommonProperties;
import org.apache.hertzbeat.common.queue.impl.InMemoryCommonDataQueue;
import org.apache.hertzbeat.common.support.SpringContextHolder;
import org.apache.hertzbeat.alert.service.impl.TencentSmsClientImpl;
import org.apache.hertzbeat.log.controller.OtlpSignalController;
import org.apache.hertzbeat.log.controller.ThreeSignalQueryController;
import org.apache.hertzbeat.warehouse.WarehouseWorkerPool;
import org.apache.hertzbeat.warehouse.controller.MetricsDataController;
import org.apache.hertzbeat.warehouse.store.history.tsdb.iotdb.IotDbDataStorage;
@@ -105,6 +107,10 @@ class ContextTest extends AbstractSpringIntegrationTest {
assertThrows(NoSuchBeanDefinitionException.class, () -> ctx.getBean(IotDbDataStorage.class));
assertNotNull(ctx.getBean(MetricsDataController.class));
// Greptime-only signal controllers must not break the default application context.
assertThrows(NoSuchBeanDefinitionException.class, () -> ctx.getBean(OtlpSignalController.class));
assertThrows(NoSuchBeanDefinitionException.class, () -> ctx.getBean(ThreeSignalQueryController.class));
}
}
@@ -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";
}
@@ -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;
@@ -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()) {
@@ -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");
}
}
/** 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");
}
}
@@ -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(),
@@ -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;
}
}
}
@@ -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);
@@ -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();
}
}
}
}
@@ -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");
}
}
@@ -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
+7
View File
@@ -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
+5
View File
@@ -133,6 +133,11 @@
"tsConfig": "tsconfig.spec.json",
"scripts": [],
"styles": [],
"stylePreprocessorOptions": {
"includePaths": [
"node_modules/"
]
},
"assets": [
"src/assets"
]
@@ -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);
})
);
@@ -0,0 +1,22 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { HttpContextToken } from '@angular/common/http';
export const SILENT_HTTP_ERROR = new HttpContextToken<boolean>(() => false);
@@ -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>
@@ -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;
height: 100%;
background: @common-background-color;
border-radius: 4px;
gap: 24px;
align-items: flex-start;
justify-content: space-between;
padding: 0 0 20px;
border-bottom: 1px solid @border-color-split;
.data-sources {
width: 240px;
border-right: 1px solid #f0f0f0;
padding: 16px;
background: @common-background-color;
h2 {
margin-bottom: 16px;
font-size: 16px;
font-weight: 500;
}
.source-list {
.source-item {
display: flex;
align-items: center;
padding: 12px;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s;
&:hover {
background: rgba(0, 0, 0, 0.05);
}
&.active {
background: rgba(0, 0, 0, 0.1);
}
img {
width: 24px;
height: 24px;
margin-right: 8px;
}
}
}
h1 {
margin: 0 0 6px;
font-weight: 600;
font-size: 24px;
}
.doc-content {
flex: 1;
padding: 24px;
overflow-y: auto;
background: @common-background-color;
p {
margin: 0;
color: @text-color-secondary;
}
}
h2 {
margin-bottom: 24px;
font-size: 20px;
font-weight: 500;
.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;
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;
}
&.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;
}
.probe-message {
overflow: hidden;
color: @text-color-secondary;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.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 {
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);
}
.integration-page {
color: @text-color-dark;
}
&.active {
background: rgba(255, 255, 255, 0.6);
}
}
}
.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;
border-color: #434343;
}
}
}
: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;
}
}
@@ -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');
});
});
@@ -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;
} else {
// If no matching source found, use the first one as default
this.selectedSource = this.dataSources[0];
this.router.navigate(['/log/integration/', this.selectedSource.id]);
}
} else {
// When no route params, use the first data source
this.selectedSource = this.dataSources[0];
this.router.navigate(['/log/integration/', this.selectedSource.id]);
}
ngOnInit(): void {
this.endpoint = window.location.origin;
}
if (this.selectedSource) {
this.loadMarkdownContent(this.selectedSource);
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 {
this.notifySvc.error(this.i18nSvc.fanyi('observability.integration.token-failed'), message.msg || '');
}
},
error: () => {
this.generatingToken = false;
this.notifySvc.error(this.i18nSvc.fanyi('observability.integration.token-failed'), '');
}
});
}
selectSource(source: DataSource) {
this.selectedSource = source;
this.loadMarkdownContent(source);
this.router.navigate(['/log/integration', source.id]);
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;
}
});
}
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()
};
}
}
@@ -6,7 +6,7 @@
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
@@ -14,353 +14,134 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<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" />
<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>
@@ -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;
}
.trace-id, .span-id {
font-family: 'Courier New', monospace;
.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;
}
.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;
}
}
@@ -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();
});
component.setMode('stream');
beforeEach(() => {
fixture = TestBed.createComponent(LogManageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
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' })
});
});
});
@@ -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,
NzTableModule,
NzDatePickerModule,
NzInputModule,
NzButtonModule,
NzTagModule,
NzToolTipModule,
NzEmptyModule,
NgxEchartsModule,
NzStatisticModule,
NzSpaceModule,
NzIconModule,
NzDividerModule,
NzCollapseModule,
NzModalModule,
NzCheckboxModule,
NzPopoverModule,
NzListModule,
NzAutocompleteModule
NzDrawerModule,
NzEmptyModule,
NzTableModule,
NzTagModule,
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;
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>();
// 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') }
};
// 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];
}
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.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)'
}
}
}
]
};
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;
}
onTrendChartInit(ec: any) {
this.trendInstance = ec;
}
onTraceCoverageChartInit(ec: any) {
this.traceCoverageInstance = ec;
ngOnDestroy(): void {
this.destroyed.next();
this.destroyed.complete();
}
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);
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();
}
query() {
this.loading = true;
const start = this.timeRange?.[0]?.getTime();
const end = this.timeRange?.[1]?.getTime();
pageChanged(page: number): void {
this.pageIndex = page;
this.search();
}
const obs = this.logSvc.list(
start,
end,
this.traceId,
this.spanId,
this.severityNumber,
this.severityText,
this.searchContent,
this.pageIndex - 1,
this.pageSize
);
open(log: LogEntry): void {
this.selected = log;
this.detailVisible = true;
}
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'));
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']
}
});
}
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 || {});
}
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']
}
});
}
clearFilters() {
this.timeRange = [];
this.severityNumber = undefined;
this.traceId = '';
this.spanId = '';
this.severityText = '';
this.searchContent = '';
this.pageIndex = 1;
this.query();
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
};
}
toggleStatistics() {
this.showStatistics = !this.showStatistics;
bodyText(log: LogEntry): string {
return typeof log.body === 'string' ? log.body : JSON.stringify(log.body);
}
// Batch selection methods
updateCheckedSet(id: string, checked: boolean): void {
if (checked) {
this.setOfCheckedId.add(id);
} else {
this.setOfCheckedId.delete(id);
}
service(log: LogEntry): string {
return String(log.resource?.['service.name'] || '-');
}
onItemChecked(id: string, checked: boolean): void {
this.updateCheckedSet(id, checked);
this.refreshCheckedStatus();
detailJson(): string {
return JSON.stringify(this.selected, null, 2);
}
onAllChecked(checked: boolean): void {
this.data.forEach(item => this.updateCheckedSet(this.getLogId(item), checked));
this.refreshCheckedStatus();
}
refreshCheckedStatus(): void {
this.checked = this.data.every(item => this.setOfCheckedId.has(this.getLogId(item)));
this.indeterminate = this.data.some(item => this.setOfCheckedId.has(this.getLogId(item))) && !this.checked;
}
getLogId(item: LogEntry): string {
return `${item.timeUnixNano}_${item.traceId || 'no-trace'}`;
}
batchDelete(): void {
if (this.setOfCheckedId.size === 0) {
this.msg.warning(this.i18n.fanyi('common.notify.no-select-delete'));
return;
}
const selectedItems = this.data.filter(item => this.setOfCheckedId.has(this.getLogId(item)));
this.modal.confirm({
nzTitle: this.i18n.fanyi('common.confirm.delete-batch'),
nzOkText: this.i18n.fanyi('common.button.delete'),
nzOkDanger: true,
nzCancelText: this.i18n.fanyi('common.button.cancel'),
nzOnOk: () => {
this.performBatchDelete(selectedItems);
}
});
}
performBatchDelete(selectedItems: LogEntry[]): void {
const timeUnixNanos = selectedItems.filter(item => item.timeUnixNano != null).map(item => item.timeUnixNano!);
if (timeUnixNanos.length === 0) {
this.msg.warning(this.i18n.fanyi('common.notify.no-select-delete'));
return;
}
this.logSvc.batchDelete(timeUnixNanos).subscribe({
next: message => {
if (message.code === 0) {
this.msg.success(this.i18n.fanyi('common.notify.delete-success'));
this.setOfCheckedId.clear();
this.refreshCheckedStatus();
this.query();
} else {
this.msg.error(message.msg || this.i18n.fanyi('common.notify.delete-fail'));
}
},
error: () => {
this.msg.error(this.i18n.fanyi('common.notify.delete-fail'));
}
});
}
onTablePageChange(params: { pageIndex: number; pageSize: number; sort: any; filter: any }) {
this.pageIndex = params.pageIndex;
this.pageSize = params.pageSize;
this.query();
}
getSeverityColor(severityNumber?: number): string {
if (!severityNumber) return 'default';
if (severityNumber >= 21 && severityNumber <= 24) return 'purple'; // FATAL
if (severityNumber >= 17 && severityNumber <= 20) return 'red'; // ERROR
if (severityNumber >= 13 && severityNumber <= 16) return 'orange'; // WARN
if (severityNumber >= 9 && severityNumber <= 12) return 'blue'; // INFO
if (severityNumber >= 5 && severityNumber <= 8) return 'green'; // DEBUG
if (severityNumber >= 1 && severityNumber <= 4) return 'default'; // TRACE
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';
}
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);
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
};
}
getObjectText(obj: any): string {
if (!obj) return '';
if (typeof obj === 'object') {
const keys = Object.keys(obj);
if (keys.length === 0) return '';
if (keys.length === 1) {
return `${keys[0]}: ${obj[keys[0]]}`;
}
return `${keys[0]}: ${obj[keys[0]]} (+${keys.length - 1} more)`;
}
return String(obj);
}
getInstrumentationText(scope: any): string {
if (!scope) return '';
const parts = [];
if (scope.name) parts.push(`Name: ${scope.name}`);
if (scope.version) parts.push(`Version: ${scope.version}`);
if (scope.attributes && Object.keys(scope.attributes).length > 0) {
parts.push(`Attributes: ${Object.keys(scope.attributes).length} items`);
}
return parts.join('\n');
}
// Modal methods
showLogDetails(logEntry: LogEntry): void {
this.selectedLogEntry = logEntry;
this.isModalVisible = true;
}
handleModalCancel(): void {
this.isModalVisible = false;
this.selectedLogEntry = null;
}
getLogEntryJson(logEntry: LogEntry): string {
return JSON.stringify(logEntry, null, 2);
}
formatTimestamp(timeUnixNano: number | undefined): string {
if (!timeUnixNano) return '';
return new Date(timeUnixNano / 1000000).toLocaleString();
}
copyToClipboard(text: string): void {
navigator.clipboard
.writeText(text)
.then(() => {
this.msg.success(this.i18n.fanyi('common.notify.copy-success'));
})
.catch(err => {
console.error('Failed to copy: ', err);
this.msg.error(this.i18n.fanyi('common.notify.copy-fail'));
});
}
toggleColumnVisibility(column: string): void {
if (this.columnVisibility[column as keyof typeof this.columnVisibility]) {
this.columnVisibility[column as keyof typeof this.columnVisibility].visible =
!this.columnVisibility[column as keyof typeof this.columnVisibility].visible;
}
}
getVisibleColumnsCount(): number {
return Object.values(this.columnVisibility).filter(col => col.visible).length;
}
getColumnKeys(): string[] {
return Object.keys(this.columnVisibility);
}
getColumnVisibility(): any {
return this.columnVisibility;
}
resetColumns(): void {
Object.keys(this.columnVisibility).forEach(key => {
this.columnVisibility[key as keyof typeof this.columnVisibility].visible = true;
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
});
this.msg.success(this.i18n.fanyi('common.notify.operate-success'));
}
toggleColumnControl(): void {
this.columnControlVisible = !this.columnControlVisible;
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
};
}
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]) }]
};
}
}
@@ -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);
});
});
@@ -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 }
];
@@ -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 -->
@@ -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 @@
}
}
}
@@ -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;
@@ -0,0 +1,127 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<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>
@@ -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;
}
}
@@ -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();
}));
});
@@ -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])
}))
};
}
}
@@ -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 })
});
});
});
@@ -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 = {};
}
@@ -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]);
});
});
@@ -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)];
}
@@ -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);
}));
});
@@ -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;
}
}
@@ -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) }
]
@@ -0,0 +1,189 @@
<!--
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.
-->
<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>
@@ -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));
}
}
@@ -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();
}));
});
@@ -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 };
}
}
+45 -92
View File
@@ -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 });
}
public traceCoverageStats(
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_trace_coverage_uri, { params });
}
public batchDelete(timeUnixNanos: number[]): Observable<Message<string>> {
let httpParams = new HttpParams();
timeUnixNanos.forEach(timeUnixNano => {
httpParams = httpParams.append('timeUnixNanos', timeUnixNano);
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)
});
const options = { params: httpParams };
return this.http.delete<Message<string>>(logs_batch_delete_uri, options);
}
batchDelete(timeUnixNanos: number[]): Observable<Message<string>> {
let params = new HttpParams();
timeUnixNanos.forEach(value => (params = params.append('timeUnixNanos', value)));
return this.http.delete<Message<string>>('/logs', { params });
}
private params(options: LogQueryOptions): HttpParams {
let params = new HttpParams();
Object.entries(options).forEach(([key, value]) => {
if (value != null && value !== '') params = params.set(key, value);
});
return params;
}
}
@@ -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', () => {
@@ -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();
});
});
@@ -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;
}
}

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