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());
+    }
+}

Reply via email to