This is an automated email from the ASF dual-hosted git repository. dmsolr pushed a commit to branch issue-7720-instance-observability-links in repository https://gitbox.apache.org/repos/asf/shenyu.git
commit 48fc1b6b1a0ad74ab6ea332d45c12a152114e1a2 Author: Luke Haochao Zhuang <[email protected]> AuthorDate: Tue Oct 20 16:35:04 2026 +0800 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 --- .../admin/config/ObservabilityConfiguration.java | 31 ++ .../config/properties/ObservabilityProperties.java | 111 ++++++ .../admin/controller/InstanceController.java | 32 +- .../admin/model/vo/InstanceDiagnosticsVO.java | 429 +++++++++++++++++++++ .../service/impl/InstanceDiagnosticsService.java | 216 +++++++++++ .../admin/controller/InstanceControllerTest.java | 46 +++ .../impl/InstanceDiagnosticsServiceTest.java | 238 ++++++++++++ .../apache/shenyu/common/constant/Constants.java | 6 + .../shenyu/common/utils/SystemInfoUtils.java | 4 +- .../shenyu/common/utils/SystemInfoUtilsTest.java | 46 +++ 10 files changed, 1157 insertions(+), 2 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 ff515f3997..aa20713ea1 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; @@ -39,6 +41,8 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import java.util.Objects; + /** * this is instance controller. */ @@ -49,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; } /** @@ -106,6 +115,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 f73df32148..5b67ae8385 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; @@ -65,6 +67,9 @@ public final class InstanceControllerTest { @Mock private InstanceCheckService instanceCheckService; + @Mock + private InstanceDiagnosticsService instanceDiagnosticsService; + private InstanceInfoVO vo; @@ -112,6 +117,47 @@ public final class InstanceControllerTest { .andExpect(jsonPath("$.data.instanceIp", is(vo.getInstanceIp()))); } + @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/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 1f58a4916a..802dd9e771 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 @@ -1079,6 +1079,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()); + } +}
