mirror of
https://github.com/dromara/hertzbeat.git
synced 2026-09-17 18:19:02 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a53e1b58e | ||
|
|
2f1fa48bb3 | ||
|
|
2106bbb074 | ||
|
|
30076e6ea6 | ||
|
|
cce7b7ac46 | ||
|
|
2c376356ef | ||
|
|
5d523ddce3 | ||
|
|
1c0c881c58 | ||
|
|
46cce89d7e | ||
|
|
45848ecdb5 | ||
|
|
216e3f9d29 | ||
|
|
9f22a1cdd5 | ||
|
|
a899c7c518 | ||
|
|
a9222b2a78 | ||
|
|
d5cb2e6660 | ||
|
|
1c03f830e5 |
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 |
+12
-4
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+24
@@ -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) {
|
||||
}
|
||||
+29
@@ -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) {
|
||||
}
|
||||
+49
@@ -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) {
|
||||
}
|
||||
+32
@@ -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) {
|
||||
}
|
||||
+47
@@ -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) {
|
||||
}
|
||||
+51
@@ -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) {
|
||||
}
|
||||
+24
@@ -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) {
|
||||
}
|
||||
+26
@@ -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) {
|
||||
}
|
||||
+23
@@ -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) {
|
||||
}
|
||||
+24
@@ -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) {
|
||||
}
|
||||
+28
@@ -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) {
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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";
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+3
-2
@@ -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
@@ -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>
|
||||
|
||||
+102
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -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) { };
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
-82
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+80
@@ -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));
|
||||
}
|
||||
}
|
||||
+64
@@ -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"));
|
||||
}
|
||||
}
|
||||
+136
@@ -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);
|
||||
}
|
||||
+26
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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);
|
||||
}
|
||||
+219
@@ -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;
|
||||
}
|
||||
}
|
||||
+523
@@ -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');
|
||||
+93
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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");
|
||||
}
|
||||
}
|
||||
+107
@@ -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);
|
||||
}
|
||||
}
|
||||
+46
-77
@@ -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
|
||||
|
||||
+81
@@ -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"));
|
||||
}
|
||||
}
|
||||
+59
@@ -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());
|
||||
}
|
||||
}
|
||||
+145
@@ -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));
|
||||
}
|
||||
}
|
||||
+63
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -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);
|
||||
}
|
||||
}
|
||||
+182
@@ -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", "[]"));
|
||||
}
|
||||
}
|
||||
+54
-5
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+65
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+6
@@ -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";
|
||||
|
||||
}
|
||||
|
||||
+5
-1
@@ -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;
|
||||
|
||||
+7
-2
@@ -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()) {
|
||||
|
||||
+24
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+163
-5
@@ -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(),
|
||||
|
||||
+129
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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);
|
||||
|
||||
+6
-6
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+58
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user