This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new c65e342099 [Enhancement] Link Admin instance diagnostics to existing
metrics, logs and trace backends (#7720) (#7878)
c65e342099 is described below
commit c65e342099e1231a1cd9a951dc45373b2dca3a35
Author: Daming <[email protected]>
AuthorDate: Fri Oct 23 11:29:31 2026 +0800
[Enhancement] Link Admin instance diagnostics to existing metrics, logs and
trace backends (#7720) (#7878)
* feat: link admin instance diagnostics to observability backends
- report JVM boot time as the boot identity in gateway instance info so
status reports can be correlated with metrics, logs and traces
- add shenyu.observability url template properties for metrics/logs/trace
backends (Grafana/Prometheus, Kibana/Loki, Jaeger/Tempo, ...)
- add InstanceDiagnosticsService resolving templates with URL-encoded
correlation values and an explicit time window, with distinct states for
not-configured backends, missing legacy correlation data and invalid or
credential-embedding templates
- expose GET /instance/{id}/diagnostics behind the existing instance
permission, no telemetry data is stored in Admin
ref apache/shenyu#7720
* test(admin): pass InstanceDiagnosticsService to InstanceController in
RegistrationProtocolCompatibilityTest
InstanceController now requires InstanceDiagnosticsService as its third
constructor argument. Mock it in the wire-fixture replay test so the
controller can still be built without exercising the diagnostics
endpoint.
---------
Co-authored-by: Luke Haochao Zhuang <[email protected]>
---
.../admin/config/ObservabilityConfiguration.java | 31 ++
.../config/properties/ObservabilityProperties.java | 111 ++++++
.../admin/controller/InstanceController.java | 30 +-
.../admin/model/vo/InstanceDiagnosticsVO.java | 429 +++++++++++++++++++++
.../service/impl/InstanceDiagnosticsService.java | 216 +++++++++++
.../admin/controller/InstanceControllerTest.java | 46 +++
.../RegistrationProtocolCompatibilityTest.java | 6 +-
.../impl/InstanceDiagnosticsServiceTest.java | 238 ++++++++++++
.../apache/shenyu/common/constant/Constants.java | 6 +
.../shenyu/common/utils/SystemInfoUtils.java | 4 +-
.../shenyu/common/utils/SystemInfoUtilsTest.java | 46 +++
11 files changed, 1160 insertions(+), 3 deletions(-)
diff --git
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/ObservabilityConfiguration.java
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/ObservabilityConfiguration.java
new file mode 100644
index 0000000000..8baf22b970
--- /dev/null
+++
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/ObservabilityConfiguration.java
@@ -0,0 +1,31 @@
+/*
+ * 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.shenyu.admin.config;
+
+import org.apache.shenyu.admin.config.properties.ObservabilityProperties;
+import
org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Configuration for instance diagnostics navigation links to existing
+ * observability backends (metrics, logs and traces).
+ */
+@Configuration
+@EnableConfigurationProperties(ObservabilityProperties.class)
+public class ObservabilityConfiguration {
+}
diff --git
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/properties/ObservabilityProperties.java
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/properties/ObservabilityProperties.java
new file mode 100644
index 0000000000..fead99b1d8
--- /dev/null
+++
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/properties/ObservabilityProperties.java
@@ -0,0 +1,111 @@
+/*
+ * 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.shenyu.admin.config.properties;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Operator-configured navigation links from Admin instance diagnostics to
existing
+ * observability backends (Grafana/Prometheus, logging and tracing platforms).
+ *
+ * <p>Each template is a plain http/https URL that may reference the following
+ * placeholders, whose values are URL-encoded before substitution:
+ * {@code {namespace}}, {@code {instanceIp}}, {@code {instancePort}},
+ * {@code {instanceType}}, {@code {bootTime}}, {@code {startTime}}, {@code
{endTime}},
+ * {@code {startTimeIso}} and {@code {endTimeIso}}. Time placeholders are epoch
+ * milliseconds; the {@code Iso} variants are ISO-8601 UTC timestamps.</p>
+ *
+ * <p>Templates must not embed credentials or other sensitive data in the URL;
+ * access control of the destination platform stays with that platform. A blank
+ * template means the corresponding backend is not configured, which is
reported
+ * as a distinct state instead of a broken link.</p>
+ */
+@ConfigurationProperties(prefix = "shenyu.observability")
+public class ObservabilityProperties {
+
+ /**
+ * URL template of the metrics backend, e.g. a Grafana dashboard or
Prometheus query.
+ */
+ private String metricsUrlTemplate;
+
+ /**
+ * URL template of the logging backend, e.g. a Kibana or Loki explore view.
+ */
+ private String logsUrlTemplate;
+
+ /**
+ * URL template of the tracing backend, e.g. a Jaeger or Tempo search view.
+ * Exact request lookups require an actual trace ID from a request/log
record,
+ * so instance diagnostics only links to an instance-scoped search.
+ */
+ private String traceUrlTemplate;
+
+ /**
+ * Gets the metrics URL template.
+ *
+ * @return metricsUrlTemplate
+ */
+ public String getMetricsUrlTemplate() {
+ return metricsUrlTemplate;
+ }
+
+ /**
+ * Sets the metrics URL template.
+ *
+ * @param metricsUrlTemplate metricsUrlTemplate
+ */
+ public void setMetricsUrlTemplate(final String metricsUrlTemplate) {
+ this.metricsUrlTemplate = metricsUrlTemplate;
+ }
+
+ /**
+ * Gets the logs URL template.
+ *
+ * @return logsUrlTemplate
+ */
+ public String getLogsUrlTemplate() {
+ return logsUrlTemplate;
+ }
+
+ /**
+ * Sets the logs URL template.
+ *
+ * @param logsUrlTemplate logsUrlTemplate
+ */
+ public void setLogsUrlTemplate(final String logsUrlTemplate) {
+ this.logsUrlTemplate = logsUrlTemplate;
+ }
+
+ /**
+ * Gets the trace URL template.
+ *
+ * @return traceUrlTemplate
+ */
+ public String getTraceUrlTemplate() {
+ return traceUrlTemplate;
+ }
+
+ /**
+ * Sets the trace URL template.
+ *
+ * @param traceUrlTemplate traceUrlTemplate
+ */
+ public void setTraceUrlTemplate(final String traceUrlTemplate) {
+ this.traceUrlTemplate = traceUrlTemplate;
+ }
+}
diff --git
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/controller/InstanceController.java
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/controller/InstanceController.java
index f7a3cc9ace..3800a03fa8 100644
---
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/controller/InstanceController.java
+++
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/controller/InstanceController.java
@@ -26,10 +26,12 @@ import org.apache.shenyu.admin.model.page.PageParameter;
import org.apache.shenyu.admin.model.query.InstanceQuery;
import org.apache.shenyu.admin.model.query.InstanceQueryCondition;
import org.apache.shenyu.admin.model.result.ShenyuAdminResult;
+import org.apache.shenyu.admin.model.vo.InstanceDiagnosticsVO;
import org.apache.shenyu.admin.model.vo.InstanceInfoVO;
import org.apache.shenyu.admin.service.InstanceInfoService;
import org.apache.shenyu.admin.service.PageService;
import org.apache.shenyu.admin.service.impl.InstanceCheckService;
+import org.apache.shenyu.admin.service.impl.InstanceDiagnosticsService;
import org.apache.shenyu.admin.utils.ShenyuResultMessage;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.util.CollectionUtils;
@@ -51,9 +53,14 @@ public class InstanceController implements
PagedController<InstanceQueryConditio
private final InstanceCheckService instanceCheckService;
- public InstanceController(final InstanceInfoService instanceInfoService,
final InstanceCheckService instanceCheckService) {
+ private final InstanceDiagnosticsService instanceDiagnosticsService;
+
+ public InstanceController(final InstanceInfoService instanceInfoService,
+ final InstanceCheckService instanceCheckService,
+ final InstanceDiagnosticsService
instanceDiagnosticsService) {
this.instanceInfoService = instanceInfoService;
this.instanceCheckService = instanceCheckService;
+ this.instanceDiagnosticsService = instanceDiagnosticsService;
}
/**
@@ -114,6 +121,27 @@ public class InstanceController implements
PagedController<InstanceQueryConditio
return ShenyuAdminResult.success(ShenyuResultMessage.DETAIL_SUCCESS,
instanceInfoVO);
}
+ /**
+ * instance diagnostics: correlation metadata and navigation links to the
+ * operator-configured metrics, logs and trace backends.
+ *
+ * @param id instance id.
+ * @param startTime optional time window start, epoch millis.
+ * @param endTime optional time window end, epoch millis.
+ * @return {@linkplain ShenyuAdminResult}
+ */
+ @GetMapping("/{id}/diagnostics")
+ @RequiresPermissions("system:instance:edit")
+ public ShenyuAdminResult instanceDiagnostics(@PathVariable("id") final
String id,
+ @RequestParam(name =
"startTime", required = false) final Long startTime,
+ @RequestParam(name =
"endTime", required = false) final Long endTime) {
+ InstanceDiagnosticsVO diagnosticsVO =
instanceDiagnosticsService.getInstanceDiagnostics(id, startTime, endTime);
+ if (Objects.isNull(diagnosticsVO)) {
+ return
ShenyuAdminResult.error(ShenyuResultMessage.NOT_FOUND_EXCEPTION);
+ }
+ return ShenyuAdminResult.success(ShenyuResultMessage.QUERY_SUCCESS,
diagnosticsVO);
+ }
+
/**
* receive beat.
*
diff --git
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/model/vo/InstanceDiagnosticsVO.java
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/model/vo/InstanceDiagnosticsVO.java
new file mode 100644
index 0000000000..3245ad3491
--- /dev/null
+++
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/model/vo/InstanceDiagnosticsVO.java
@@ -0,0 +1,429 @@
+/*
+ * 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.shenyu.admin.model.vo;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * Instance diagnostics view: correlation metadata shared by status reports,
+ * metric resources, logs and traces, plus resolved navigation links to the
+ * operator-configured observability backends.
+ */
+public class InstanceDiagnosticsVO implements Serializable {
+
+ private static final long serialVersionUID = 4728175609126640215L;
+
+ /**
+ * instance id.
+ */
+ private String instanceId;
+
+ /**
+ * stable instance identity, ip:port@type#namespace.
+ */
+ private String instanceKey;
+
+ /**
+ * namespace id.
+ */
+ private String namespaceId;
+
+ /**
+ * instance ip.
+ */
+ private String instanceIp;
+
+ /**
+ * instance port.
+ */
+ private String instancePort;
+
+ /**
+ * instance type.
+ */
+ private String instanceType;
+
+ /**
+ * instance state.
+ */
+ private Integer instanceState;
+
+ /**
+ * last heartbeat time, epoch millis.
+ */
+ private Long lastHeartBeatTime;
+
+ /**
+ * boot identity: JVM start time in epoch millis, null for legacy instances
+ * that do not report correlation fields.
+ */
+ private Long bootTime;
+
+ /**
+ * resolved time window start, epoch millis.
+ */
+ private Long startTime;
+
+ /**
+ * resolved time window end, epoch millis.
+ */
+ private Long endTime;
+
+ /**
+ * resolved observability links.
+ */
+ private List<ObservabilityLink> links;
+
+ /**
+ * get instanceId.
+ *
+ * @return instanceId
+ */
+ public String getInstanceId() {
+ return instanceId;
+ }
+
+ /**
+ * set instanceId.
+ *
+ * @param instanceId instanceId
+ */
+ public void setInstanceId(final String instanceId) {
+ this.instanceId = instanceId;
+ }
+
+ /**
+ * get instanceKey.
+ *
+ * @return instanceKey
+ */
+ public String getInstanceKey() {
+ return instanceKey;
+ }
+
+ /**
+ * set instanceKey.
+ *
+ * @param instanceKey instanceKey
+ */
+ public void setInstanceKey(final String instanceKey) {
+ this.instanceKey = instanceKey;
+ }
+
+ /**
+ * get namespaceId.
+ *
+ * @return namespaceId
+ */
+ public String getNamespaceId() {
+ return namespaceId;
+ }
+
+ /**
+ * set namespaceId.
+ *
+ * @param namespaceId namespaceId
+ */
+ public void setNamespaceId(final String namespaceId) {
+ this.namespaceId = namespaceId;
+ }
+
+ /**
+ * get instanceIp.
+ *
+ * @return instanceIp
+ */
+ public String getInstanceIp() {
+ return instanceIp;
+ }
+
+ /**
+ * set instanceIp.
+ *
+ * @param instanceIp instanceIp
+ */
+ public void setInstanceIp(final String instanceIp) {
+ this.instanceIp = instanceIp;
+ }
+
+ /**
+ * get instancePort.
+ *
+ * @return instancePort
+ */
+ public String getInstancePort() {
+ return instancePort;
+ }
+
+ /**
+ * set instancePort.
+ *
+ * @param instancePort instancePort
+ */
+ public void setInstancePort(final String instancePort) {
+ this.instancePort = instancePort;
+ }
+
+ /**
+ * get instanceType.
+ *
+ * @return instanceType
+ */
+ public String getInstanceType() {
+ return instanceType;
+ }
+
+ /**
+ * set instanceType.
+ *
+ * @param instanceType instanceType
+ */
+ public void setInstanceType(final String instanceType) {
+ this.instanceType = instanceType;
+ }
+
+ /**
+ * get instanceState.
+ *
+ * @return instanceState
+ */
+ public Integer getInstanceState() {
+ return instanceState;
+ }
+
+ /**
+ * set instanceState.
+ *
+ * @param instanceState instanceState
+ */
+ public void setInstanceState(final Integer instanceState) {
+ this.instanceState = instanceState;
+ }
+
+ /**
+ * get lastHeartBeatTime.
+ *
+ * @return lastHeartBeatTime
+ */
+ public Long getLastHeartBeatTime() {
+ return lastHeartBeatTime;
+ }
+
+ /**
+ * set lastHeartBeatTime.
+ *
+ * @param lastHeartBeatTime lastHeartBeatTime
+ */
+ public void setLastHeartBeatTime(final Long lastHeartBeatTime) {
+ this.lastHeartBeatTime = lastHeartBeatTime;
+ }
+
+ /**
+ * get bootTime.
+ *
+ * @return bootTime
+ */
+ public Long getBootTime() {
+ return bootTime;
+ }
+
+ /**
+ * set bootTime.
+ *
+ * @param bootTime bootTime
+ */
+ public void setBootTime(final Long bootTime) {
+ this.bootTime = bootTime;
+ }
+
+ /**
+ * get startTime.
+ *
+ * @return startTime
+ */
+ public Long getStartTime() {
+ return startTime;
+ }
+
+ /**
+ * set startTime.
+ *
+ * @param startTime startTime
+ */
+ public void setStartTime(final Long startTime) {
+ this.startTime = startTime;
+ }
+
+ /**
+ * get endTime.
+ *
+ * @return endTime
+ */
+ public Long getEndTime() {
+ return endTime;
+ }
+
+ /**
+ * set endTime.
+ *
+ * @param endTime endTime
+ */
+ public void setEndTime(final Long endTime) {
+ this.endTime = endTime;
+ }
+
+ /**
+ * get links.
+ *
+ * @return links
+ */
+ public List<ObservabilityLink> getLinks() {
+ return links;
+ }
+
+ /**
+ * set links.
+ *
+ * @param links links
+ */
+ public void setLinks(final List<ObservabilityLink> links) {
+ this.links = links;
+ }
+
+ /**
+ * A resolved navigation link to one observability backend.
+ */
+ public static class ObservabilityLink implements Serializable {
+
+ /**
+ * link type: metrics backend.
+ */
+ public static final String TYPE_METRICS = "METRICS";
+
+ /**
+ * link type: logging backend.
+ */
+ public static final String TYPE_LOGS = "LOGS";
+
+ /**
+ * link type: tracing backend.
+ */
+ public static final String TYPE_TRACE = "TRACE";
+
+ /**
+ * state: the link is resolved and ready for navigation.
+ */
+ public static final String STATE_READY = "READY";
+
+ /**
+ * state: no URL template is configured for this backend.
+ */
+ public static final String STATE_NOT_CONFIGURED = "NOT_CONFIGURED";
+
+ /**
+ * state: the instance does not report a correlation field required by
the template.
+ */
+ public static final String STATE_MISSING_INSTANCE_DATA =
"MISSING_INSTANCE_DATA";
+
+ /**
+ * state: the configured template is not a plain http/https URL or
embeds credentials.
+ */
+ public static final String STATE_INVALID_TEMPLATE = "INVALID_TEMPLATE";
+
+ private static final long serialVersionUID = -8154976209143372864L;
+
+ /**
+ * link type, one of METRICS / LOGS / TRACE.
+ */
+ private String type;
+
+ /**
+ * link state, one of the STATE_ constants.
+ */
+ private String state;
+
+ /**
+ * resolved url, only present when state is READY.
+ */
+ private String url;
+
+ public ObservabilityLink() {
+ }
+
+ public ObservabilityLink(final String type, final String state, final
String url) {
+ this.type = type;
+ this.state = state;
+ this.url = url;
+ }
+
+ /**
+ * get type.
+ *
+ * @return type
+ */
+ public String getType() {
+ return type;
+ }
+
+ /**
+ * set type.
+ *
+ * @param type type
+ */
+ public void setType(final String type) {
+ this.type = type;
+ }
+
+ /**
+ * get state.
+ *
+ * @return state
+ */
+ public String getState() {
+ return state;
+ }
+
+ /**
+ * set state.
+ *
+ * @param state state
+ */
+ public void setState(final String state) {
+ this.state = state;
+ }
+
+ /**
+ * get url.
+ *
+ * @return url
+ */
+ public String getUrl() {
+ return url;
+ }
+
+ /**
+ * set url.
+ *
+ * @param url url
+ */
+ public void setUrl(final String url) {
+ this.url = url;
+ }
+ }
+}
diff --git
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/InstanceDiagnosticsService.java
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/InstanceDiagnosticsService.java
new file mode 100644
index 0000000000..6b45a5a031
--- /dev/null
+++
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/InstanceDiagnosticsService.java
@@ -0,0 +1,216 @@
+/*
+ * 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.shenyu.admin.service.impl;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.shenyu.admin.config.properties.ObservabilityProperties;
+import org.apache.shenyu.admin.model.vo.InstanceDiagnosticsVO;
+import
org.apache.shenyu.admin.model.vo.InstanceDiagnosticsVO.ObservabilityLink;
+import org.apache.shenyu.admin.model.vo.InstanceInfoVO;
+import org.apache.shenyu.admin.service.InstanceInfoService;
+import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Builds instance diagnostics: correlation metadata plus navigation links to
the
+ * operator-configured metrics, logging and tracing backends.
+ *
+ * <p>High-volume telemetry stays in the configured backend; this service only
+ * resolves URL templates with URL-encoded instance correlation values and an
+ * explicit time window, so no credentials or sensitive payload data are
embedded
+ * by Admin and no telemetry data is stored in the Admin database. Missing
backend
+ * configuration, missing correlation data on legacy instances and invalid
+ * templates are reported as distinct link states instead of broken links.</p>
+ */
+@Component
+public class InstanceDiagnosticsService {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(InstanceDiagnosticsService.class);
+
+ private static final long DEFAULT_WINDOW_MILLIS = 15 * 60 * 1000L;
+
+ private static final String PLACEHOLDER_NAMESPACE = "{namespace}";
+
+ private static final String PLACEHOLDER_INSTANCE_IP = "{instanceIp}";
+
+ private static final String PLACEHOLDER_INSTANCE_PORT = "{instancePort}";
+
+ private static final String PLACEHOLDER_INSTANCE_TYPE = "{instanceType}";
+
+ private static final String PLACEHOLDER_BOOT_TIME = "{bootTime}";
+
+ private static final String PLACEHOLDER_START_TIME = "{startTime}";
+
+ private static final String PLACEHOLDER_END_TIME = "{endTime}";
+
+ private static final String PLACEHOLDER_START_TIME_ISO = "{startTimeIso}";
+
+ private static final String PLACEHOLDER_END_TIME_ISO = "{endTimeIso}";
+
+ private final InstanceInfoService instanceInfoService;
+
+ private final InstanceCheckService instanceCheckService;
+
+ private final ObservabilityProperties observabilityProperties;
+
+ public InstanceDiagnosticsService(final InstanceInfoService
instanceInfoService,
+ final InstanceCheckService
instanceCheckService,
+ final ObservabilityProperties
observabilityProperties) {
+ this.instanceInfoService = instanceInfoService;
+ this.instanceCheckService = instanceCheckService;
+ this.observabilityProperties = observabilityProperties;
+ }
+
+ /**
+ * Build diagnostics for one instance.
+ *
+ * @param id instance id
+ * @param startTime optional time window start, epoch millis, defaults to
the boot time
+ * when known or the last {@value #DEFAULT_WINDOW_MILLIS}
millis
+ * @param endTime optional time window end, epoch millis, defaults to now
+ * @return the diagnostics view, or null when the instance does not exist
+ */
+ public InstanceDiagnosticsVO getInstanceDiagnostics(final String id, final
Long startTime, final Long endTime) {
+ InstanceInfoVO instanceInfoVO = instanceInfoService.findById(id);
+ if (Objects.isNull(instanceInfoVO)) {
+ return null;
+ }
+ InstanceInfoVO beatInfo =
instanceCheckService.getInstanceHealthBeatInfo(instanceCheckService.getInstanceKey(instanceInfoVO));
+ if (Objects.nonNull(beatInfo)) {
+ instanceInfoVO.setInstanceState(beatInfo.getInstanceState());
+
instanceInfoVO.setLastHeartBeatTime(beatInfo.getLastHeartBeatTime());
+ }
+ Long bootTime = parseBootTime(instanceInfoVO.getInstanceInfo());
+ long resolvedEnd = Objects.nonNull(endTime) && endTime > 0 ? endTime :
System.currentTimeMillis();
+ long resolvedStart;
+ if (Objects.nonNull(startTime) && startTime > 0) {
+ resolvedStart = startTime;
+ } else if (Objects.nonNull(bootTime) && bootTime > resolvedEnd -
DEFAULT_WINDOW_MILLIS) {
+ resolvedStart = bootTime;
+ } else {
+ resolvedStart = resolvedEnd - DEFAULT_WINDOW_MILLIS;
+ }
+ if (resolvedStart > resolvedEnd) {
+ resolvedStart = resolvedEnd;
+ }
+ InstanceDiagnosticsVO diagnosticsVO = new InstanceDiagnosticsVO();
+ diagnosticsVO.setInstanceId(id);
+
diagnosticsVO.setInstanceKey(instanceCheckService.getInstanceKey(instanceInfoVO));
+ diagnosticsVO.setNamespaceId(instanceInfoVO.getNamespaceId());
+ diagnosticsVO.setInstanceIp(instanceInfoVO.getInstanceIp());
+ diagnosticsVO.setInstancePort(instanceInfoVO.getInstancePort());
+ diagnosticsVO.setInstanceType(instanceInfoVO.getInstanceType());
+ diagnosticsVO.setInstanceState(instanceInfoVO.getInstanceState());
+
diagnosticsVO.setLastHeartBeatTime(instanceInfoVO.getLastHeartBeatTime());
+ diagnosticsVO.setBootTime(bootTime);
+ diagnosticsVO.setStartTime(resolvedStart);
+ diagnosticsVO.setEndTime(resolvedEnd);
+ List<ObservabilityLink> links = new ArrayList<>(3);
+ links.add(resolveLink(ObservabilityLink.TYPE_METRICS,
observabilityProperties.getMetricsUrlTemplate(),
+ instanceInfoVO, bootTime, resolvedStart, resolvedEnd));
+ links.add(resolveLink(ObservabilityLink.TYPE_LOGS,
observabilityProperties.getLogsUrlTemplate(),
+ instanceInfoVO, bootTime, resolvedStart, resolvedEnd));
+ links.add(resolveLink(ObservabilityLink.TYPE_TRACE,
observabilityProperties.getTraceUrlTemplate(),
+ instanceInfoVO, bootTime, resolvedStart, resolvedEnd));
+ diagnosticsVO.setLinks(links);
+ return diagnosticsVO;
+ }
+
+ private ObservabilityLink resolveLink(final String type, final String
template, final InstanceInfoVO instanceInfoVO,
+ final Long bootTime, final long
startTime, final long endTime) {
+ if (StringUtils.isBlank(template)) {
+ return new ObservabilityLink(type,
ObservabilityLink.STATE_NOT_CONFIGURED, null);
+ }
+ if (template.contains(PLACEHOLDER_BOOT_TIME) &&
Objects.isNull(bootTime)) {
+ // legacy instances do not report the boot identity, do not invent
one
+ return new ObservabilityLink(type,
ObservabilityLink.STATE_MISSING_INSTANCE_DATA, null);
+ }
+ Map<String, String> values = new LinkedHashMap<>();
+ values.put(PLACEHOLDER_NAMESPACE, instanceInfoVO.getNamespaceId());
+ values.put(PLACEHOLDER_INSTANCE_IP, instanceInfoVO.getInstanceIp());
+ values.put(PLACEHOLDER_INSTANCE_PORT,
instanceInfoVO.getInstancePort());
+ values.put(PLACEHOLDER_INSTANCE_TYPE,
instanceInfoVO.getInstanceType());
+ values.put(PLACEHOLDER_BOOT_TIME, Objects.isNull(bootTime) ? null :
String.valueOf(bootTime));
+ values.put(PLACEHOLDER_START_TIME, String.valueOf(startTime));
+ values.put(PLACEHOLDER_END_TIME, String.valueOf(endTime));
+ values.put(PLACEHOLDER_START_TIME_ISO, formatIso(startTime));
+ values.put(PLACEHOLDER_END_TIME_ISO, formatIso(endTime));
+ String url = template;
+ for (Map.Entry<String, String> entry : values.entrySet()) {
+ url = url.replace(entry.getKey(), encode(entry.getValue()));
+ }
+ // substituted values are already encoded, only template literals may
contain raw spaces
+ url = url.replace(" ", "%20");
+ if (!isSafeNavigationUrl(url)) {
+ LOG.warn("observability {} url template is rejected, it is not a
plain http/https url or embeds credentials", type);
+ return new ObservabilityLink(type,
ObservabilityLink.STATE_INVALID_TEMPLATE, null);
+ }
+ return new ObservabilityLink(type, ObservabilityLink.STATE_READY, url);
+ }
+
+ private static String formatIso(final long epochMillis) {
+ return
DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(epochMillis));
+ }
+
+ private static String encode(final String value) {
+ return StringUtils.isEmpty(value) ? StringUtils.EMPTY :
URLEncoder.encode(value, StandardCharsets.UTF_8);
+ }
+
+ private static boolean isSafeNavigationUrl(final String url) {
+ try {
+ URI uri = new URI(url);
+ String scheme = uri.getScheme();
+ return (Objects.equals("http", scheme) || Objects.equals("https",
scheme)) && Objects.isNull(uri.getUserInfo());
+ } catch (URISyntaxException | IllegalArgumentException ex) {
+ return false;
+ }
+ }
+
+ private Long parseBootTime(final String instanceInfo) {
+ if (StringUtils.isBlank(instanceInfo)) {
+ return null;
+ }
+ try {
+ Map<String, Object> infoMap =
GsonUtils.getInstance().convertToMap(instanceInfo);
+ if (Objects.isNull(infoMap)) {
+ return null;
+ }
+ Object bootTime = infoMap.get(Constants.BOOT_TIME);
+ return bootTime instanceof Number ? ((Number)
bootTime).longValue() : null;
+ } catch (RuntimeException ex) {
+ // unparsable instance info must not break the diagnostics view
+ LOG.debug("failed to parse boot time from instance info: {}",
instanceInfo, ex);
+ return null;
+ }
+ }
+}
diff --git
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/controller/InstanceControllerTest.java
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/controller/InstanceControllerTest.java
index 72584ba51a..9172fb88d4 100644
---
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/controller/InstanceControllerTest.java
+++
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/controller/InstanceControllerTest.java
@@ -21,9 +21,11 @@ import org.apache.shenyu.admin.model.page.CommonPager;
import org.apache.shenyu.admin.model.page.PageParameter;
import org.apache.shenyu.admin.model.query.InstanceQuery;
import org.apache.shenyu.admin.model.vo.InstanceDataVisualVO;
+import org.apache.shenyu.admin.model.vo.InstanceDiagnosticsVO;
import org.apache.shenyu.admin.model.vo.InstanceInfoVO;
import org.apache.shenyu.admin.service.InstanceInfoService;
import org.apache.shenyu.admin.service.impl.InstanceCheckService;
+import org.apache.shenyu.admin.service.impl.InstanceDiagnosticsService;
import org.apache.shenyu.admin.utils.ShenyuResultMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -66,6 +68,9 @@ public final class InstanceControllerTest {
@Mock
private InstanceCheckService instanceCheckService;
+ @Mock
+ private InstanceDiagnosticsService instanceDiagnosticsService;
+
private InstanceInfoVO vo;
@@ -155,6 +160,47 @@ public final class InstanceControllerTest {
.andExpect(jsonPath("$.data.id", is("instance-1")));
}
+ @Test
+ public void testInstanceDiagnostics() throws Exception {
+ InstanceDiagnosticsVO diagnosticsVO = new InstanceDiagnosticsVO();
+ diagnosticsVO.setInstanceId("123");
+ diagnosticsVO.setNamespaceId("ns");
+ diagnosticsVO.setLinks(List.of(new
InstanceDiagnosticsVO.ObservabilityLink(
+ InstanceDiagnosticsVO.ObservabilityLink.TYPE_METRICS,
+ InstanceDiagnosticsVO.ObservabilityLink.STATE_NOT_CONFIGURED,
+ null)));
+ given(this.instanceDiagnosticsService.getInstanceDiagnostics("123",
null, null)).willReturn(diagnosticsVO);
+
this.mockMvc.perform(MockMvcRequestBuilders.get("/instance/{id}/diagnostics",
"123"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.message",
is(ShenyuResultMessage.QUERY_SUCCESS)))
+ .andExpect(jsonPath("$.data.instanceId", is("123")))
+ .andExpect(jsonPath("$.data.links[0].type", is("METRICS")))
+ .andExpect(jsonPath("$.data.links[0].state",
is("NOT_CONFIGURED")));
+ }
+
+ @Test
+ public void testInstanceDiagnosticsWithTimeRange() throws Exception {
+ InstanceDiagnosticsVO diagnosticsVO = new InstanceDiagnosticsVO();
+ diagnosticsVO.setInstanceId("123");
+ diagnosticsVO.setStartTime(1000L);
+ diagnosticsVO.setEndTime(2000L);
+ given(this.instanceDiagnosticsService.getInstanceDiagnostics("123",
1000L, 2000L)).willReturn(diagnosticsVO);
+
this.mockMvc.perform(MockMvcRequestBuilders.get("/instance/{id}/diagnostics",
"123")
+ .param("startTime", "1000")
+ .param("endTime", "2000"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.startTime", is(1000)))
+ .andExpect(jsonPath("$.data.endTime", is(2000)));
+ }
+
+ @Test
+ public void testInstanceDiagnosticsNotFound() throws Exception {
+ given(this.instanceDiagnosticsService.getInstanceDiagnostics("404",
null, null)).willReturn(null);
+
this.mockMvc.perform(MockMvcRequestBuilders.get("/instance/{id}/diagnostics",
"404"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.message",
is(ShenyuResultMessage.NOT_FOUND_EXCEPTION)));
+ }
+
@Test
public void testBeat() throws Exception {
String json =
"{\"instanceIp\":\"127.0.0.1\",\"instancePort\":\"8080\",\"instanceType\":\"grpc\",\"instanceInfo\":\"info\",\"namespaceId\":\"ns\"}";
diff --git
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/controller/RegistrationProtocolCompatibilityTest.java
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/controller/RegistrationProtocolCompatibilityTest.java
index 1788b682d2..4c7fb68d93 100644
---
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/controller/RegistrationProtocolCompatibilityTest.java
+++
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/controller/RegistrationProtocolCompatibilityTest.java
@@ -24,6 +24,7 @@ import
org.apache.shenyu.admin.config.RegistrationProtocolInterceptor;
import org.apache.shenyu.admin.register.ShenyuClientServerRegisterPublisher;
import org.apache.shenyu.admin.service.InstanceInfoService;
import org.apache.shenyu.admin.service.impl.InstanceCheckService;
+import org.apache.shenyu.admin.service.impl.InstanceDiagnosticsService;
import org.apache.shenyu.common.constant.Constants;
import org.apache.shenyu.register.common.dto.InstanceBeatInfoDTO;
import org.apache.shenyu.register.common.dto.MetaDataRegisterDTO;
@@ -78,6 +79,9 @@ class RegistrationProtocolCompatibilityTest {
@InjectMocks
private InstanceCheckService instanceCheckService;
+ @Mock
+ private InstanceDiagnosticsService instanceDiagnosticsService;
+
@InjectMocks
private ShenyuClientHttpRegistryController controller;
@@ -85,7 +89,7 @@ class RegistrationProtocolCompatibilityTest {
@BeforeEach
void setUp() {
- mvc = MockMvcBuilders.standaloneSetup(controller, new
InstanceController(instanceInfoService, instanceCheckService))
+ mvc = MockMvcBuilders.standaloneSetup(controller, new
InstanceController(instanceInfoService, instanceCheckService,
instanceDiagnosticsService))
.addInterceptors(new
RegistrationProtocolInterceptor()).build();
}
diff --git
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/impl/InstanceDiagnosticsServiceTest.java
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/impl/InstanceDiagnosticsServiceTest.java
new file mode 100644
index 0000000000..d228a768e1
--- /dev/null
+++
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/impl/InstanceDiagnosticsServiceTest.java
@@ -0,0 +1,238 @@
+/*
+ * 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.shenyu.admin.service.impl;
+
+import org.apache.shenyu.admin.config.properties.ObservabilityProperties;
+import org.apache.shenyu.admin.model.vo.InstanceDiagnosticsVO;
+import
org.apache.shenyu.admin.model.vo.InstanceDiagnosticsVO.ObservabilityLink;
+import org.apache.shenyu.admin.model.vo.InstanceInfoVO;
+import org.apache.shenyu.admin.service.InstanceInfoService;
+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.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import java.util.List;
+import java.util.Objects;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.not;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public final class InstanceDiagnosticsServiceTest {
+
+ private static final String INSTANCE_ID = "instance-1";
+
+ private static final String INSTANCE_KEY = "10.0.0.1:9195@bootstrap#ns-1";
+
+ private static final long BOOT_TIME = 1_700_000_000_000L;
+
+ @Mock
+ private InstanceInfoService instanceInfoService;
+
+ @Mock
+ private InstanceCheckService instanceCheckService;
+
+ private ObservabilityProperties properties;
+
+ private InstanceDiagnosticsService service;
+
+ @BeforeEach
+ public void setUp() {
+ properties = new ObservabilityProperties();
+ service = new InstanceDiagnosticsService(instanceInfoService,
instanceCheckService, properties);
+
given(instanceCheckService.getInstanceKey(any(InstanceInfoVO.class))).willReturn(INSTANCE_KEY);
+ }
+
+ @Test
+ public void testOneInstanceCorrelatesAcrossMetricsLogsAndTraces() {
+
properties.setMetricsUrlTemplate("http://grafana:3000/d/gw?var-namespace={namespace}&var-instance={instanceIp}:{instancePort}&from={startTime}&to={endTime}");
+
properties.setLogsUrlTemplate("http://kibana:5601/app/logs#/?query=namespace:{namespace}
AND instance:{instanceIp} AND type:{instanceType}&since={startTimeIso}");
+
properties.setTraceUrlTemplate("http://jaeger:16686/search?service={instanceType}&tags={namespace}&start={startTime}000&end={endTime}000&boot={bootTime}");
+
given(instanceInfoService.findById(INSTANCE_ID)).willReturn(buildInstanceInfo(BOOT_TIME));
+
+ InstanceDiagnosticsVO diagnostics =
service.getInstanceDiagnostics(INSTANCE_ID, 1_700_000_100_000L,
1_700_000_200_000L);
+
+ assertNotNull(diagnostics);
+ assertEquals(INSTANCE_ID, diagnostics.getInstanceId());
+ assertEquals(INSTANCE_KEY, diagnostics.getInstanceKey());
+ assertEquals("ns-1", diagnostics.getNamespaceId());
+ assertEquals(BOOT_TIME, diagnostics.getBootTime());
+ assertEquals(1_700_000_100_000L, diagnostics.getStartTime());
+ assertEquals(1_700_000_200_000L, diagnostics.getEndTime());
+ // every link carries the same documented correlation identifiers
+ for (ObservabilityLink link : diagnostics.getLinks()) {
+ assertEquals(ObservabilityLink.STATE_READY, link.getState());
+ assertThat(link.getUrl(), containsString("ns-1"));
+ }
+
assertEquals("http://grafana:3000/d/gw?var-namespace=ns-1&var-instance=10.0.0.1:9195&from=1700000100000&to=1700000200000",
+ linkOf(diagnostics, ObservabilityLink.TYPE_METRICS).getUrl());
+ assertThat(linkOf(diagnostics, ObservabilityLink.TYPE_LOGS).getUrl(),
containsString("instance:10.0.0.1"));
+ assertThat(linkOf(diagnostics, ObservabilityLink.TYPE_LOGS).getUrl(),
containsString("namespace:ns-1"));
+ assertThat(linkOf(diagnostics, ObservabilityLink.TYPE_LOGS).getUrl(),
not(containsString(" ")));
+ assertThat(linkOf(diagnostics, ObservabilityLink.TYPE_TRACE).getUrl(),
containsString("boot=1700000000000"));
+ }
+
+ @Test
+ public void testNamespaceAndTimeRangeAreUrlEncoded() {
+
properties.setMetricsUrlTemplate("http://grafana:3000/d/gw?var-namespace={namespace}&from={startTime}&to={endTime}");
+ InstanceInfoVO instanceInfoVO = buildInstanceInfo(BOOT_TIME);
+ instanceInfoVO.setNamespaceId("ns with space/slash&special=chars");
+
given(instanceInfoService.findById(INSTANCE_ID)).willReturn(instanceInfoVO);
+
+ InstanceDiagnosticsVO diagnostics =
service.getInstanceDiagnostics(INSTANCE_ID, null, null);
+
+ ObservabilityLink metrics = linkOf(diagnostics,
ObservabilityLink.TYPE_METRICS);
+ assertEquals(ObservabilityLink.STATE_READY, metrics.getState());
+ assertThat(metrics.getUrl(),
containsString("var-namespace=ns+with+space%2Fslash%26special%3Dchars"));
+ assertThat(metrics.getUrl(), not(containsString("ns with
space/slash&special=chars")));
+ }
+
+ @Test
+ public void testMissingBackendConfigurationIsReportedAsNotConfigured() {
+
given(instanceInfoService.findById(INSTANCE_ID)).willReturn(buildInstanceInfo(BOOT_TIME));
+
+ InstanceDiagnosticsVO diagnostics =
service.getInstanceDiagnostics(INSTANCE_ID, null, null);
+
+ assertNotNull(diagnostics);
+ for (ObservabilityLink link : diagnostics.getLinks()) {
+ assertEquals(ObservabilityLink.STATE_NOT_CONFIGURED,
link.getState());
+ assertNull(link.getUrl());
+ }
+ }
+
+ @Test
+ public void testLegacyInstanceWithoutCorrelationFields() {
+
properties.setMetricsUrlTemplate("http://grafana:3000/d/gw?var-instance={instanceIp}:{instancePort}&from={startTime}&to={endTime}");
+
properties.setTraceUrlTemplate("http://jaeger:16686/search?boot={bootTime}");
+ // legacy instance info without bootTime
+
given(instanceInfoService.findById(INSTANCE_ID)).willReturn(buildInstanceInfo(null));
+
+ InstanceDiagnosticsVO diagnostics =
service.getInstanceDiagnostics(INSTANCE_ID, null, null);
+
+ assertNotNull(diagnostics);
+ assertNull(diagnostics.getBootTime());
+ // links that do not need the boot identity still work for legacy
instances
+ assertEquals(ObservabilityLink.STATE_READY, linkOf(diagnostics,
ObservabilityLink.TYPE_METRICS).getState());
+ // links that need the missing correlation field are reported
distinctly, not fabricated
+ ObservabilityLink trace = linkOf(diagnostics,
ObservabilityLink.TYPE_TRACE);
+ assertEquals(ObservabilityLink.STATE_MISSING_INSTANCE_DATA,
trace.getState());
+ assertNull(trace.getUrl());
+ }
+
+ @Test
+ public void testUnparsableInstanceInfoDoesNotBreakDiagnostics() {
+
properties.setMetricsUrlTemplate("http://grafana:3000/d/gw?var-instance={instanceIp}");
+ InstanceInfoVO instanceInfoVO = buildInstanceInfo(null);
+ instanceInfoVO.setInstanceInfo("not-a-json");
+
given(instanceInfoService.findById(INSTANCE_ID)).willReturn(instanceInfoVO);
+
+ InstanceDiagnosticsVO diagnostics =
service.getInstanceDiagnostics(INSTANCE_ID, null, null);
+
+ assertNotNull(diagnostics);
+ assertNull(diagnostics.getBootTime());
+ assertEquals(ObservabilityLink.STATE_READY, linkOf(diagnostics,
ObservabilityLink.TYPE_METRICS).getState());
+ }
+
+ @Test
+ public void testCredentialsAndNonHttpTemplatesAreRejected() {
+
properties.setMetricsUrlTemplate("http://user:secret@grafana:3000/d/gw?var-instance={instanceIp}");
+ properties.setLogsUrlTemplate("javascript:alert({instanceIp})");
+
properties.setTraceUrlTemplate("http://jaeger:16686/search?instance={instanceIp");
+
given(instanceInfoService.findById(INSTANCE_ID)).willReturn(buildInstanceInfo(BOOT_TIME));
+
+ InstanceDiagnosticsVO diagnostics =
service.getInstanceDiagnostics(INSTANCE_ID, null, null);
+
+ assertEquals(ObservabilityLink.STATE_INVALID_TEMPLATE,
linkOf(diagnostics, ObservabilityLink.TYPE_METRICS).getState());
+ assertEquals(ObservabilityLink.STATE_INVALID_TEMPLATE,
linkOf(diagnostics, ObservabilityLink.TYPE_LOGS).getState());
+ assertEquals(ObservabilityLink.STATE_INVALID_TEMPLATE,
linkOf(diagnostics, ObservabilityLink.TYPE_TRACE).getState());
+ for (ObservabilityLink link : diagnostics.getLinks()) {
+ assertNull(link.getUrl());
+ }
+ }
+
+ @Test
+ public void testInstanceNotFound() {
+ given(instanceInfoService.findById("missing")).willReturn(null);
+ assertNull(service.getInstanceDiagnostics("missing", null, null));
+ }
+
+ @Test
+ public void testDefaultTimeWindowAndLiveStateMerge() {
+
properties.setMetricsUrlTemplate("http://grafana:3000/d/gw?from={startTime}&to={endTime}");
+
given(instanceInfoService.findById(INSTANCE_ID)).willReturn(buildInstanceInfo(null));
+ InstanceInfoVO beatInfo = new InstanceInfoVO();
+ beatInfo.setInstanceState(1);
+ beatInfo.setLastHeartBeatTime(1_700_000_300_000L);
+
given(instanceCheckService.getInstanceHealthBeatInfo(INSTANCE_KEY)).willReturn(beatInfo);
+
+ long before = System.currentTimeMillis();
+ InstanceDiagnosticsVO diagnostics =
service.getInstanceDiagnostics(INSTANCE_ID, null, null);
+
+ // live state from the heartbeat cache wins over the persisted row
+ assertEquals(1, diagnostics.getInstanceState());
+ assertEquals(1_700_000_300_000L, diagnostics.getLastHeartBeatTime());
+ // default window is the last 15 minutes ending at now
+ assertTrue(diagnostics.getEndTime() >= before);
+ assertEquals(15 * 60 * 1000L, diagnostics.getEndTime() -
diagnostics.getStartTime());
+ assertThat(linkOf(diagnostics,
ObservabilityLink.TYPE_METRICS).getUrl(),
+ containsString("from=" + diagnostics.getStartTime() + "&to=" +
diagnostics.getEndTime()));
+ }
+
+ @Test
+ public void testDefaultWindowStartsAtBootTimeWhenRecent() {
+ long bootTime = System.currentTimeMillis() - 60_000L;
+
given(instanceInfoService.findById(INSTANCE_ID)).willReturn(buildInstanceInfo(bootTime));
+
+ InstanceDiagnosticsVO diagnostics =
service.getInstanceDiagnostics(INSTANCE_ID, null, null);
+
+ assertEquals(bootTime, diagnostics.getStartTime());
+ assertTrue(diagnostics.getEndTime() > diagnostics.getStartTime());
+ }
+
+ private ObservabilityLink linkOf(final InstanceDiagnosticsVO diagnostics,
final String type) {
+ List<ObservabilityLink> links =
diagnostics.getLinks().stream().filter(link ->
type.equals(link.getType())).toList();
+ assertEquals(1, links.size());
+ return links.get(0);
+ }
+
+ private InstanceInfoVO buildInstanceInfo(final Long bootTime) {
+ InstanceInfoVO instanceInfoVO = new InstanceInfoVO();
+ instanceInfoVO.setInstanceIp("10.0.0.1");
+ instanceInfoVO.setInstancePort("9195");
+ instanceInfoVO.setInstanceType("bootstrap");
+ instanceInfoVO.setNamespaceId("ns-1");
+ instanceInfoVO.setInstanceState(0);
+ instanceInfoVO.setLastHeartBeatTime(1_700_000_050_000L);
+ instanceInfoVO.setInstanceInfo(Objects.isNull(bootTime)
+ ? "{\"arch\":\"x86_64\",\"operatingSystem\":\"macOS\"}"
+ :
"{\"arch\":\"x86_64\",\"operatingSystem\":\"macOS\",\"bootTime\":" + bootTime +
"}");
+ return instanceInfoVO;
+ }
+}
diff --git
a/shenyu-common/src/main/java/org/apache/shenyu/common/constant/Constants.java
b/shenyu-common/src/main/java/org/apache/shenyu/common/constant/Constants.java
index f9c024fcd3..b5227a5a7b 100644
---
a/shenyu-common/src/main/java/org/apache/shenyu/common/constant/Constants.java
+++
b/shenyu-common/src/main/java/org/apache/shenyu/common/constant/Constants.java
@@ -1091,6 +1091,12 @@ public interface Constants {
*/
String GB = "GB";
+ /**
+ * The constant bootTime, JVM start time in epoch millis, used as the boot
identity
+ * to correlate instance status reports with metrics, logs and traces.
+ */
+ String BOOT_TIME = "bootTime";
+
/**
* String q.
*/
diff --git
a/shenyu-common/src/main/java/org/apache/shenyu/common/utils/SystemInfoUtils.java
b/shenyu-common/src/main/java/org/apache/shenyu/common/utils/SystemInfoUtils.java
index 828db2e526..d5026664a3 100644
---
a/shenyu-common/src/main/java/org/apache/shenyu/common/utils/SystemInfoUtils.java
+++
b/shenyu-common/src/main/java/org/apache/shenyu/common/utils/SystemInfoUtils.java
@@ -28,6 +28,7 @@ import java.util.Map;
import static org.apache.shenyu.common.constant.Constants.ARCH;
import static org.apache.shenyu.common.constant.Constants.AVAILABLE_PROCESSORS;
+import static org.apache.shenyu.common.constant.Constants.BOOT_TIME;
import static org.apache.shenyu.common.constant.Constants.GB;
import static org.apache.shenyu.common.constant.Constants.OPERATING_SYSTEM;
import static org.apache.shenyu.common.constant.Constants.TOTAL_MEMORY_SIZE_GB;
@@ -64,7 +65,8 @@ public final class SystemInfoUtils {
ARCH, osBean.getArch(),
OPERATING_SYSTEM,
systemInfo.getOperatingSystem().toString(),
AVAILABLE_PROCESSORS, osBean.getAvailableProcessors(),
- TOTAL_MEMORY_SIZE_GB,
bytesToGB(osBean.getTotalMemorySize()) + GB
+ TOTAL_MEMORY_SIZE_GB,
bytesToGB(osBean.getTotalMemorySize()) + GB,
+ BOOT_TIME,
ManagementFactory.getRuntimeMXBean().getStartTime()
);
return GsonUtils.getInstance().toJson(hostInfo);
} catch (Exception e) {
diff --git
a/shenyu-common/src/test/java/org/apache/shenyu/common/utils/SystemInfoUtilsTest.java
b/shenyu-common/src/test/java/org/apache/shenyu/common/utils/SystemInfoUtilsTest.java
new file mode 100644
index 0000000000..3c77c8580a
--- /dev/null
+++
b/shenyu-common/src/test/java/org/apache/shenyu/common/utils/SystemInfoUtilsTest.java
@@ -0,0 +1,46 @@
+/*
+ * 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.shenyu.common.utils;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.management.ManagementFactory;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test cases for SystemInfoUtils.
+ */
+public final class SystemInfoUtilsTest {
+
+ @Test
+ public void testGetSystemInfoContainsBootTime() {
+ String systemInfo = SystemInfoUtils.getSystemInfo();
+ Map<String, Object> infoMap =
GsonUtils.getInstance().convertToMap(systemInfo);
+ assertNotNull(infoMap.get("arch"));
+ assertNotNull(infoMap.get("operatingSystem"));
+ // boot identity used to correlate instance status with metrics, logs
and traces
+ Object bootTime = infoMap.get("bootTime");
+ assertNotNull(bootTime);
+ assertTrue(bootTime instanceof Number);
+ assertEquals(ManagementFactory.getRuntimeMXBean().getStartTime(),
((Number) bootTime).longValue());
+ }
+}