This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new 0488d00f06 feat: add bounded and redacted gateway diagnostic bundles
(#7961)
0488d00f06 is described below
commit 0488d00f0601e267818a343479a60db4835c1184
Author: Daming <[email protected]>
AuthorDate: Fri Oct 23 11:22:32 2026 +0800
feat: add bounded and redacted gateway diagnostic bundles (#7961)
Introduce an opt-in diagnostics subsystem in shenyu-web that exports a
bounded, redacted ZIP bundle of gateway runtime state for troubleshooting.
- DiagnosticProperties: shenyu.diagnostics.* config (disabled by default),
total/section byte budgets, section/total timeouts, max concurrent jobs
- SectionCollector framework with RuntimeInfoCollector,
PluginInventoryCollector
and EffectiveConfigDigestCollector
- DiagnosticRedactor: secret-key detection, JSON/text redaction, URL
stripping
and SHA-256 digests; effective config is emitted as digests only, never
raw
- DiagnosticBundleService: per-section isolation, timeout, budget
enforcement
and concurrency limiting with an overload fallback bundle
- DiagnosticBundleWriter + DiagnosticManifest: path-traversal-safe ZIP
archive
with a manifest of section statuses and sizes
- ShenyuDiagnosticsEndpoint: /actuator/diagnostics/bundle, guarded by
@ConditionalOnProperty(shenyu.diagnostics.enabled=true)
- Unit tests covering redaction, writer, service budget/timeout/concurrency,
a seeded-secret leak fixture, and the endpoint
Co-authored-by: Luke Haochao Zhuang <[email protected]>
---
.../shenyu/web/diagnostics/DiagnosticBudget.java | 92 +++++++++
.../web/diagnostics/DiagnosticBundleService.java | 211 +++++++++++++++++++
.../web/diagnostics/DiagnosticBundleWriter.java | 129 ++++++++++++
.../shenyu/web/diagnostics/DiagnosticManifest.java | 105 ++++++++++
.../web/diagnostics/DiagnosticProperties.java | 155 ++++++++++++++
.../shenyu/web/diagnostics/DiagnosticRedactor.java | 196 ++++++++++++++++++
.../shenyu/web/diagnostics/DiagnosticSection.java | 172 ++++++++++++++++
.../web/diagnostics/DiagnosticsConfiguration.java | 79 +++++++
.../EffectiveConfigDigestCollector.java | 139 +++++++++++++
.../web/diagnostics/PluginInventoryCollector.java | 98 +++++++++
.../web/diagnostics/RuntimeInfoCollector.java | 106 ++++++++++
.../shenyu/web/diagnostics/SectionCollector.java | 45 ++++
.../shenyu/web/diagnostics/SectionStatus.java | 59 ++++++
.../web/diagnostics/ShenyuDiagnosticsEndpoint.java | 75 +++++++
.../diagnostics/DiagnosticBundleServiceTest.java | 226 +++++++++++++++++++++
.../diagnostics/DiagnosticBundleWriterTest.java | 120 +++++++++++
.../web/diagnostics/DiagnosticRedactorTest.java | 94 +++++++++
.../EffectiveConfigDigestCollectorTest.java | 91 +++++++++
.../diagnostics/ShenyuDiagnosticsEndpointTest.java | 58 ++++++
19 files changed, 2250 insertions(+)
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBudget.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBudget.java
new file mode 100644
index 0000000000..1dddaec444
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBudget.java
@@ -0,0 +1,92 @@
+/*
+ * 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.web.diagnostics;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * An independent time and size budget handed to a single collector.
+ *
+ * <p>Collectors are expected to poll {@link #shouldStop()} between bounded
work units and
+ * to respect {@link #maxBytes()} so a slow or greedy section cannot consume
the whole
+ * bundle allowance or delay other sections. Cancellation is cooperative: the
orchestrator
+ * sets it when the section deadline passes or the caller goes away.</p>
+ */
+public final class DiagnosticBudget {
+
+ private final long deadlineNanos;
+
+ private final int maxBytes;
+
+ private final AtomicBoolean cancelled = new AtomicBoolean(false);
+
+ /**
+ * Instantiates a new budget.
+ *
+ * @param timeoutMs the wall-clock budget in milliseconds
+ * @param maxBytes the maximum content size in bytes
+ */
+ public DiagnosticBudget(final long timeoutMs, final int maxBytes) {
+ this.deadlineNanos = System.nanoTime() +
TimeUnit.MILLISECONDS.toNanos(timeoutMs);
+ this.maxBytes = maxBytes;
+ }
+
+ /**
+ * Whether the time budget is exhausted.
+ *
+ * @return true if expired
+ */
+ public boolean expired() {
+ return System.nanoTime() - deadlineNanos >= 0;
+ }
+
+ /**
+ * Whether the orchestrator asked this collector to stop.
+ *
+ * @return true if cancelled
+ */
+ public boolean isCancelled() {
+ return cancelled.get();
+ }
+
+ /**
+ * Request cooperative cancellation.
+ */
+ public void cancel() {
+ cancelled.set(true);
+ }
+
+ /**
+ * Whether the collector should stop, either because it was cancelled or
ran out of time.
+ *
+ * @return true if the collector should stop producing content
+ */
+ public boolean shouldStop() {
+ return isCancelled() || expired();
+ }
+
+ /**
+ * Get the maximum content size in bytes.
+ *
+ * @return the max bytes
+ */
+ public int maxBytes() {
+ return maxBytes;
+ }
+}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleService.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleService.java
new file mode 100644
index 0000000000..29a3bc5d7e
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleService.java
@@ -0,0 +1,211 @@
+/*
+ * 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.web.diagnostics;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.lang.management.ManagementFactory;
+import java.lang.management.RuntimeMXBean;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Orchestrates bounded, partial-tolerant diagnostic collection and archive
assembly.
+ *
+ * <p>Each collector runs on a worker thread with its own time and size
budget. A collector that
+ * times out, is cancelled or throws becomes a {@link SectionStatus#FAILED}
section instead of
+ * failing the whole bundle, and exhausting the total budget marks the
remaining sections
+ * {@link SectionStatus#SKIPPED}. Simultaneous bundle jobs are limited; when
the limit is reached
+ * the request degrades to an overload bundle that says so honestly rather
than queueing behind
+ * gateway traffic.</p>
+ */
+public final class DiagnosticBundleService {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(DiagnosticBundleService.class);
+
+ private static final String UNKNOWN = "unknown";
+
+ private final List<SectionCollector> collectors;
+
+ private final DiagnosticProperties properties;
+
+ private final DiagnosticBundleWriter writer;
+
+ private final Semaphore jobs;
+
+ /**
+ * Instantiates a new bundle service.
+ *
+ * @param collectors the section collectors
+ * @param properties the diagnostic bounds
+ * @param writer the archive writer
+ */
+ public DiagnosticBundleService(final List<SectionCollector> collectors,
final DiagnosticProperties properties,
+ final DiagnosticBundleWriter writer) {
+ this.collectors = List.copyOf(collectors);
+ this.properties = properties;
+ this.writer = writer;
+ this.jobs = new Semaphore(Math.max(1,
properties.getMaxConcurrentJobs()));
+ }
+
+ /**
+ * Collect all sections and assemble the archive.
+ *
+ * @return the bundle archive bytes
+ */
+ public byte[] bundle() {
+ if (!jobs.tryAcquire()) {
+ return writeOverloadBundle();
+ }
+ try {
+ return writer.write(collect());
+ } catch (IOException ex) {
+ throw new UncheckedIOException("failed to assemble diagnostic
bundle", ex);
+ } finally {
+ jobs.release();
+ }
+ }
+
+ /**
+ * Collect all sections into a manifest without archiving.
+ *
+ * @return the manifest carrying every section outcome
+ */
+ public DiagnosticManifest collect() {
+ long generatedAt = System.currentTimeMillis();
+ long deadlineNanos = System.nanoTime() +
TimeUnit.MILLISECONDS.toNanos(properties.getTotalTimeoutMs());
+ List<DiagnosticSection> sections = new ArrayList<>(collectors.size());
+ ExecutorService executor = Executors.newSingleThreadExecutor(runnable
-> {
+ Thread thread = new Thread(runnable, "shenyu-diagnostics");
+ thread.setDaemon(true);
+ return thread;
+ });
+ try {
+ for (SectionCollector collector : collectors) {
+ sections.add(runCollector(executor, collector, deadlineNanos));
+ }
+ } finally {
+ executor.shutdownNow();
+ }
+ return new DiagnosticManifest(generatedAt, instanceIdentity(),
softwareVersions(), sections);
+ }
+
+ private DiagnosticSection runCollector(final ExecutorService executor,
final SectionCollector collector, final long deadlineNanos) {
+ String name = collector.name();
+ long observedAt = System.currentTimeMillis();
+ if (System.nanoTime() - deadlineNanos >= 0) {
+ return DiagnosticSection.of(name, SectionStatus.SKIPPED,
observedAt, 0L, "total budget exhausted");
+ }
+ DiagnosticBudget budget = new
DiagnosticBudget(properties.getSectionTimeoutMs(),
properties.getMaxSectionBytes());
+ long startNanos = System.nanoTime();
+ Future<DiagnosticSection> future = executor.submit(() ->
collector.collect(budget));
+ try {
+ return capSection(future.get(properties.getSectionTimeoutMs(),
TimeUnit.MILLISECONDS), startNanos);
+ } catch (TimeoutException ex) {
+ budget.cancel();
+ future.cancel(true);
+ return DiagnosticSection.of(name, SectionStatus.FAILED,
observedAt, elapsedMs(startNanos), "section timeout");
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ budget.cancel();
+ future.cancel(true);
+ return DiagnosticSection.of(name, SectionStatus.FAILED,
observedAt, elapsedMs(startNanos), "interrupted");
+ } catch (ExecutionException ex) {
+ return DiagnosticSection.of(name, SectionStatus.FAILED,
observedAt, elapsedMs(startNanos), failureReason(ex.getCause()));
+ }
+ }
+
+ private DiagnosticSection capSection(final DiagnosticSection section,
final long startNanos) {
+ long durationMs = elapsedMs(startNanos);
+ if (Objects.isNull(section)) {
+ return DiagnosticSection.of(UNKNOWN, SectionStatus.FAILED,
System.currentTimeMillis(), durationMs, "collector returned null");
+ }
+ byte[] content = section.getContent();
+ if (content.length > properties.getMaxSectionBytes()) {
+ byte[] capped = Arrays.copyOf(content,
properties.getMaxSectionBytes());
+ return DiagnosticSection.truncated(section.getName(),
section.getObservedAtEpochMs(), durationMs, capped, "section size cap");
+ }
+ return section;
+ }
+
+ private byte[] writeOverloadBundle() {
+ long now = System.currentTimeMillis();
+ List<DiagnosticSection> sections = new ArrayList<>(collectors.size());
+ for (SectionCollector collector : collectors) {
+ sections.add(DiagnosticSection.of(collector.name(),
SectionStatus.SKIPPED, now, 0L, "concurrent collection limit reached"));
+ }
+ DiagnosticManifest manifest = new DiagnosticManifest(now,
instanceIdentity(), softwareVersions(), sections);
+ try {
+ return writer.write(manifest);
+ } catch (IOException ex) {
+ throw new UncheckedIOException("failed to assemble overload
diagnostic bundle", ex);
+ }
+ }
+
+ private Map<String, Object> instanceIdentity() {
+ Map<String, Object> instance = new LinkedHashMap<>();
+ RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
+ instance.put("bootTimeEpochMs", runtime.getStartTime());
+ instance.put("uptimeMs", runtime.getUptime());
+ instance.put("hostAddress", hostAddress());
+ return instance;
+ }
+
+ private Map<String, String> softwareVersions() {
+ Map<String, String> versions = new LinkedHashMap<>();
+ String shenyuVersion =
DiagnosticBundleService.class.getPackage().getImplementationVersion();
+ versions.put("shenyu", Objects.isNull(shenyuVersion) ? UNKNOWN :
shenyuVersion);
+ versions.put("java", System.getProperty("java.version", UNKNOWN));
+ versions.put("osName", System.getProperty("os.name", UNKNOWN));
+ versions.put("osArch", System.getProperty("os.arch", UNKNOWN));
+ return versions;
+ }
+
+ private String hostAddress() {
+ try {
+ return InetAddress.getLocalHost().getHostAddress();
+ } catch (UnknownHostException ex) {
+ LOG.debug("unable to resolve local host address for diagnostic
manifest", ex);
+ return UNKNOWN;
+ }
+ }
+
+ private long elapsedMs(final long startNanos) {
+ return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);
+ }
+
+ private String failureReason(final Throwable cause) {
+ return Objects.isNull(cause) ? "collector failure" :
cause.getClass().getSimpleName();
+ }
+}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleWriter.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleWriter.java
new file mode 100644
index 0000000000..4f1db59e4c
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleWriter.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.web.diagnostics;
+
+import org.apache.shenyu.common.utils.GsonUtils;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+/**
+ * Packages a manifest and its sections into an in-memory ZIP archive.
+ *
+ * <p>Entry names are sanitised so a hostile section name cannot introduce
path traversal or an
+ * absolute path, and both per-entry and total sizes are hard-capped so a
bundle can never grow
+ * without bound. The archive is built entirely in memory and returned as a
byte array; no
+ * temporary file touches the disk on this path.</p>
+ */
+public final class DiagnosticBundleWriter {
+
+ private static final String MANIFEST_ENTRY = "manifest.json";
+
+ private static final String SECTION_PREFIX = "sections/";
+
+ private static final String SECTION_SUFFIX = ".json";
+
+ private static final int MAX_NAME_LENGTH = 64;
+
+ private final DiagnosticProperties properties;
+
+ /**
+ * Instantiates a new writer.
+ *
+ * @param properties the diagnostic bounds
+ */
+ public DiagnosticBundleWriter(final DiagnosticProperties properties) {
+ this.properties = properties;
+ }
+
+ /**
+ * Write the bundle archive.
+ *
+ * @param manifest the manifest, which also carries the sections
+ * @return the archive bytes
+ * @throws IOException if the in-memory archive cannot be produced
+ */
+ public byte[] write(final DiagnosticManifest manifest) throws IOException {
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ Set<String> used = new HashSet<>();
+ try (ZipOutputStream zip = new ZipOutputStream(buffer,
StandardCharsets.UTF_8)) {
+ putEntry(zip, used, MANIFEST_ENTRY,
GsonUtils.getGson().toJson(manifest.toMap()).getBytes(StandardCharsets.UTF_8));
+ List<DiagnosticSection> sections = manifest.getSections();
+ int remaining = properties.getMaxTotalBytes();
+ for (DiagnosticSection section : sections) {
+ byte[] content = cap(section.getContent(),
properties.getMaxSectionBytes());
+ if (content.length > remaining) {
+ content = Arrays.copyOf(content, Math.max(0, remaining));
+ }
+ remaining -= content.length;
+ putEntry(zip, used, SECTION_PREFIX +
safeEntryName(section.getName()) + SECTION_SUFFIX, content);
+ }
+ }
+ return buffer.toByteArray();
+ }
+
+ /**
+ * Sanitise a section name into a single, relative, traversal-safe archive
entry component.
+ *
+ * @param name the raw section name
+ * @return a safe entry component
+ */
+ public static String safeEntryName(final String name) {
+ if (Objects.isNull(name)) {
+ return "section";
+ }
+ String cleaned = name.replaceAll("[^A-Za-z0-9._-]", "_");
+ cleaned = cleaned.replaceAll("\\.{2,}", ".");
+ while (cleaned.startsWith(".")) {
+ cleaned = cleaned.substring(1);
+ }
+ if (cleaned.length() > MAX_NAME_LENGTH) {
+ cleaned = cleaned.substring(0, MAX_NAME_LENGTH);
+ }
+ if (cleaned.isEmpty() || ".".equals(cleaned) || "..".equals(cleaned)) {
+ return "section";
+ }
+ return cleaned;
+ }
+
+ private void putEntry(final ZipOutputStream zip, final Set<String> used,
final String name, final byte[] content) throws IOException {
+ String unique = name;
+ int suffix = 1;
+ while (!used.add(unique)) {
+ unique = name + "-" + suffix++;
+ }
+ zip.putNextEntry(new ZipEntry(unique));
+ zip.write(content);
+ zip.closeEntry();
+ }
+
+ private byte[] cap(final byte[] content, final int maxBytes) {
+ if (content.length <= maxBytes) {
+ return content;
+ }
+ return Arrays.copyOf(content, maxBytes);
+ }
+}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticManifest.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticManifest.java
new file mode 100644
index 0000000000..056641c2ef
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticManifest.java
@@ -0,0 +1,105 @@
+/*
+ * 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.web.diagnostics;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * The bundle manifest describing provenance and the per-section outcome.
+ *
+ * <p>The manifest is explicit that a bundle is not an atomic cross-component
snapshot: every
+ * section records its own observation time and status, and the manifest
records the source
+ * instance boot identity and software versions so a consumer can reason about
skew.</p>
+ */
+public final class DiagnosticManifest {
+
+ /**
+ * The bundle format version, bumped on any structural change to the
archive layout.
+ */
+ public static final int FORMAT_VERSION = 1;
+
+ private final long generatedAtEpochMs;
+
+ private final Map<String, Object> instance;
+
+ private final Map<String, String> softwareVersions;
+
+ private final List<DiagnosticSection> sections;
+
+ /**
+ * Instantiates a new manifest.
+ *
+ * @param generatedAtEpochMs the bundle generation timestamp in epoch
milliseconds
+ * @param instance the source instance identity
+ * @param softwareVersions the observed software versions
+ * @param sections the collected sections
+ */
+ public DiagnosticManifest(final long generatedAtEpochMs, final Map<String,
Object> instance,
+ final Map<String, String> softwareVersions,
final List<DiagnosticSection> sections) {
+ this.generatedAtEpochMs = generatedAtEpochMs;
+ this.instance = new LinkedHashMap<>(instance);
+ this.softwareVersions = new LinkedHashMap<>(softwareVersions);
+ this.sections = new ArrayList<>(sections);
+ }
+
+ /**
+ * Render the manifest as an ordered map suitable for JSON serialisation.
+ *
+ * @return the manifest map
+ */
+ public Map<String, Object> toMap() {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("formatVersion", FORMAT_VERSION);
+ map.put("generatedAtEpochMs", generatedAtEpochMs);
+ map.put("instance", instance);
+ map.put("softwareVersions", softwareVersions);
+ map.put("atomicSnapshot", Boolean.FALSE);
+ List<Map<String, Object>> summaries = new ArrayList<>(sections.size());
+ for (DiagnosticSection section : sections) {
+ summaries.add(summarize(section));
+ }
+ map.put("sections", summaries);
+ return map;
+ }
+
+ /**
+ * Get the collected sections.
+ *
+ * @return the sections
+ */
+ public List<DiagnosticSection> getSections() {
+ return new ArrayList<>(sections);
+ }
+
+ private Map<String, Object> summarize(final DiagnosticSection section) {
+ Map<String, Object> summary = new LinkedHashMap<>();
+ summary.put("name", section.getName());
+ summary.put("status", section.getStatus().name());
+ summary.put("observedAtEpochMs", section.getObservedAtEpochMs());
+ summary.put("durationMs", section.getDurationMs());
+ summary.put("sizeBytes", section.getSizeBytes());
+ if (Objects.nonNull(section.getReason())) {
+ summary.put("reason", section.getReason());
+ }
+ return summary;
+ }
+}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticProperties.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticProperties.java
new file mode 100644
index 0000000000..d5be4e263a
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticProperties.java
@@ -0,0 +1,155 @@
+/*
+ * 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.web.diagnostics;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Configuration for the opt-in gateway diagnostic bundle facility.
+ *
+ * <p>The feature is disabled by default. Every bound is a hard ceiling that
keeps a
+ * support bundle small and cheap: an oversized or slow collection degrades to
a partial
+ * bundle rather than growing without limit or blocking gateway traffic.</p>
+ */
+@ConfigurationProperties(prefix = "shenyu.diagnostics")
+public class DiagnosticProperties {
+
+ private static final int DEFAULT_MAX_TOTAL_BYTES = 4 * 1024 * 1024;
+
+ private static final int DEFAULT_MAX_SECTION_BYTES = 512 * 1024;
+
+ private boolean enabled;
+
+ private int maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES;
+
+ private int maxSectionBytes = DEFAULT_MAX_SECTION_BYTES;
+
+ private long sectionTimeoutMs = 2000L;
+
+ private long totalTimeoutMs = 8000L;
+
+ private int maxConcurrentJobs = 2;
+
+ /**
+ * Whether the diagnostic bundle endpoint is exposed. Disabled by default.
+ *
+ * @return true if enabled
+ */
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ /**
+ * Set whether the diagnostic bundle endpoint is exposed.
+ *
+ * @param enabled the enabled flag
+ */
+ public void setEnabled(final boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ /**
+ * Get the maximum total uncompressed bundle size in bytes.
+ *
+ * @return the max total bytes
+ */
+ public int getMaxTotalBytes() {
+ return maxTotalBytes;
+ }
+
+ /**
+ * Set the maximum total uncompressed bundle size in bytes.
+ *
+ * @param maxTotalBytes the max total bytes
+ */
+ public void setMaxTotalBytes(final int maxTotalBytes) {
+ this.maxTotalBytes = maxTotalBytes;
+ }
+
+ /**
+ * Get the maximum size of a single section in bytes.
+ *
+ * @return the max section bytes
+ */
+ public int getMaxSectionBytes() {
+ return maxSectionBytes;
+ }
+
+ /**
+ * Set the maximum size of a single section in bytes.
+ *
+ * @param maxSectionBytes the max section bytes
+ */
+ public void setMaxSectionBytes(final int maxSectionBytes) {
+ this.maxSectionBytes = maxSectionBytes;
+ }
+
+ /**
+ * Get the per-section collection timeout in milliseconds.
+ *
+ * @return the section timeout ms
+ */
+ public long getSectionTimeoutMs() {
+ return sectionTimeoutMs;
+ }
+
+ /**
+ * Set the per-section collection timeout in milliseconds.
+ *
+ * @param sectionTimeoutMs the section timeout ms
+ */
+ public void setSectionTimeoutMs(final long sectionTimeoutMs) {
+ this.sectionTimeoutMs = sectionTimeoutMs;
+ }
+
+ /**
+ * Get the total collection timeout across all sections in milliseconds.
+ *
+ * @return the total timeout ms
+ */
+ public long getTotalTimeoutMs() {
+ return totalTimeoutMs;
+ }
+
+ /**
+ * Set the total collection timeout across all sections in milliseconds.
+ *
+ * @param totalTimeoutMs the total timeout ms
+ */
+ public void setTotalTimeoutMs(final long totalTimeoutMs) {
+ this.totalTimeoutMs = totalTimeoutMs;
+ }
+
+ /**
+ * Get the maximum number of simultaneous bundle collection jobs.
+ *
+ * @return the max concurrent jobs
+ */
+ public int getMaxConcurrentJobs() {
+ return maxConcurrentJobs;
+ }
+
+ /**
+ * Set the maximum number of simultaneous bundle collection jobs.
+ *
+ * @param maxConcurrentJobs the max concurrent jobs
+ */
+ public void setMaxConcurrentJobs(final int maxConcurrentJobs) {
+ this.maxConcurrentJobs = maxConcurrentJobs;
+ }
+}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticRedactor.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticRedactor.java
new file mode 100644
index 0000000000..8a603c1b30
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticRedactor.java
@@ -0,0 +1,196 @@
+/*
+ * 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.web.diagnostics;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.shenyu.common.utils.GsonUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * The shared allowlist and redaction policy applied before anything enters a
bundle.
+ *
+ * <p>Redaction is deliberately conservative: it is safer to hide a non-secret
value than to
+ * leak a credential. Secret material is recognised by key name and by a small
set of value
+ * shapes (bearer tokens, JWTs and {@code key=value} assignments). Free-form
text and JSON are
+ * both supported; unparseable JSON falls back to text scrubbing so a
malformed payload can
+ * never bypass the policy. Where a value must be correlatable but not
revealed, use
+ * {@link #digest(String)} to publish a stable SHA-256 fingerprint instead of
the raw value.</p>
+ */
+public final class DiagnosticRedactor {
+
+ /**
+ * The replacement for any redacted value.
+ */
+ public static final String REDACTED = "******";
+
+ private static final String SHA_256 = "SHA-256";
+
+ private static final Set<String> SECRET_KEY_TOKENS = Set.of(
+ "password", "passwd", "pwd", "secret", "token", "apikey",
"accesskey",
+ "secretkey", "privatekey", "credential", "authorization", "cookie",
+ "bearer", "signature", "jwt", "sessionid");
+
+ private static final Pattern SECRET_ASSIGN = Pattern.compile(
+
"(?i)(\\b(?:password|passwd|pwd|secret|token|api[-_]?key|access[-_]?key|secret[-_]?key|"
+ +
"private[-_]?key|credential|authorization|cookie|session[-_]?id)\\b\\s*[:=]\\s*)(\"?)([^\",}\\s]+)");
+
+ private static final Pattern BEARER =
Pattern.compile("(?i)(bearer\\s+)([A-Za-z0-9._~+/=-]+)");
+
+ private static final Pattern JWT =
Pattern.compile("\\beyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+");
+
+ /**
+ * Whether a configuration key names secret material.
+ *
+ * @param key the key to test
+ * @return true if the key must have its value redacted
+ */
+ public boolean isSecretKey(final String key) {
+ if (Objects.isNull(key)) {
+ return false;
+ }
+ String normalized = key.toLowerCase().replaceAll("[^a-z0-9]", "");
+ return SECRET_KEY_TOKENS.stream().anyMatch(normalized::contains);
+ }
+
+ /**
+ * Redact a JSON document, hiding the value of every secret-named member
at any depth.
+ *
+ * <p>Unparseable input is scrubbed as free text so a malformed payload
cannot bypass the
+ * policy.</p>
+ *
+ * @param json the JSON document
+ * @return the redacted JSON, or redacted text when the input is not valid
JSON
+ */
+ public String redactJson(final String json) {
+ if (Objects.isNull(json) || json.isEmpty()) {
+ return json;
+ }
+ try {
+ JsonElement root = JsonParser.parseString(json);
+ JsonElement redacted = redactElement(root);
+ return GsonUtils.getGson().toJson(redacted);
+ } catch (RuntimeException ex) {
+ return redactText(json);
+ }
+ }
+
+ /**
+ * Scrub secret value shapes from free-form text such as thread dumps or
error messages.
+ *
+ * @param text the text to scrub
+ * @return the scrubbed text
+ */
+ public String redactText(final String text) {
+ if (Objects.isNull(text) || text.isEmpty()) {
+ return text;
+ }
+ String result = BEARER.matcher(text).replaceAll("$1" + REDACTED);
+ result = SECRET_ASSIGN.matcher(result).replaceAll("$1$2" + REDACTED);
+ return JWT.matcher(result).replaceAll(REDACTED);
+ }
+
+ /**
+ * Reduce a URL to its non-sensitive parts by dropping credentials and the
query string.
+ *
+ * @param url the url to strip
+ * @return the stripped url
+ */
+ public String stripUrl(final String url) {
+ if (Objects.isNull(url) || url.isEmpty()) {
+ return url;
+ }
+ String result = url.replaceAll("(?i)(://)[^/@?#]+@", "$1" + REDACTED +
"@");
+ int query = result.indexOf('?');
+ if (query >= 0) {
+ result = result.substring(0, query) + "?" + REDACTED;
+ }
+ int fragment = result.indexOf('#');
+ if (fragment >= 0) {
+ result = result.substring(0, fragment);
+ }
+ return result;
+ }
+
+ /**
+ * Produce a stable, non-reversible SHA-256 fingerprint of a value.
+ *
+ * <p>Use this to keep configuration correlatable across bundles without
ever publishing
+ * the raw value. A {@code null} or empty value yields an empty
fingerprint so callers do
+ * not mistake "no value" for a real digest.</p>
+ *
+ * @param value the value to fingerprint
+ * @return the lowercase hex digest, or an empty string for null/empty
input
+ */
+ public String digest(final String value) {
+ if (Objects.isNull(value) || value.isEmpty()) {
+ return "";
+ }
+ try {
+ MessageDigest messageDigest = MessageDigest.getInstance(SHA_256);
+ byte[] hash =
messageDigest.digest(value.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(hash);
+ } catch (NoSuchAlgorithmException ex) {
+ return REDACTED;
+ }
+ }
+
+ private JsonElement redactElement(final JsonElement element) {
+ if (element.isJsonObject()) {
+ return redactObject(element.getAsJsonObject());
+ }
+ if (element.isJsonArray()) {
+ return redactArray(element.getAsJsonArray());
+ }
+ return element;
+ }
+
+ private JsonElement redactObject(final JsonObject source) {
+ JsonObject target = new JsonObject();
+ for (Map.Entry<String, JsonElement> entry : source.entrySet()) {
+ if (isSecretKey(entry.getKey())) {
+ target.addProperty(entry.getKey(), REDACTED);
+ } else {
+ target.add(entry.getKey(), redactElement(entry.getValue()));
+ }
+ }
+ return target;
+ }
+
+ private JsonElement redactArray(final JsonArray source) {
+ List<JsonElement> items = new ArrayList<>(source.size());
+ for (JsonElement item : source) {
+ items.add(redactElement(item));
+ }
+ JsonArray target = new JsonArray();
+ items.forEach(target::add);
+ return target;
+ }
+}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticSection.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticSection.java
new file mode 100644
index 0000000000..c6d86579ca
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticSection.java
@@ -0,0 +1,172 @@
+/*
+ * 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.web.diagnostics;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+/**
+ * An immutable, already-redacted diagnostic section.
+ *
+ * <p>Content is stored as bytes so the writer never has to re-encode it. The
byte
+ * array is defensively copied on the way in and out; callers cannot mutate
the state
+ * of a published section.</p>
+ */
+public final class DiagnosticSection {
+
+ private final String name;
+
+ private final SectionStatus status;
+
+ private final long observedAtEpochMs;
+
+ private final long durationMs;
+
+ private final byte[] content;
+
+ private final String reason;
+
+ private DiagnosticSection(final String name, final SectionStatus status,
final long observedAtEpochMs,
+ final long durationMs, final byte[] content,
final String reason) {
+ this.name = name;
+ this.status = status;
+ this.observedAtEpochMs = observedAtEpochMs;
+ this.durationMs = durationMs;
+ this.content = Arrays.copyOf(content, content.length);
+ this.reason = reason;
+ }
+
+ /**
+ * Create a complete section.
+ *
+ * @param name the section name
+ * @param observedAtEpochMs the observation timestamp in epoch milliseconds
+ * @param durationMs the collection duration in milliseconds
+ * @param content the already-redacted UTF-8 content
+ * @return the section
+ */
+ public static DiagnosticSection ok(final String name, final long
observedAtEpochMs, final long durationMs, final byte[] content) {
+ return new DiagnosticSection(name, SectionStatus.OK,
observedAtEpochMs, durationMs, content, null);
+ }
+
+ /**
+ * Create a complete section from a UTF-8 string.
+ *
+ * @param name the section name
+ * @param observedAtEpochMs the observation timestamp in epoch milliseconds
+ * @param durationMs the collection duration in milliseconds
+ * @param content the already-redacted content
+ * @return the section
+ */
+ public static DiagnosticSection ok(final String name, final long
observedAtEpochMs, final long durationMs, final String content) {
+ return ok(name, observedAtEpochMs, durationMs,
content.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Create a section that carries an explicit status and reason with no
content.
+ *
+ * @param name the section name
+ * @param status the section status
+ * @param observedAtEpochMs the observation timestamp in epoch milliseconds
+ * @param durationMs the collection duration in milliseconds
+ * @param reason a short, non-secret reason
+ * @return the section
+ */
+ public static DiagnosticSection of(final String name, final SectionStatus
status, final long observedAtEpochMs,
+ final long durationMs, final String
reason) {
+ return new DiagnosticSection(name, status, observedAtEpochMs,
durationMs, new byte[0], reason);
+ }
+
+ /**
+ * Create a section whose content was cut to satisfy the size budget.
+ *
+ * @param name the section name
+ * @param observedAtEpochMs the observation timestamp in epoch milliseconds
+ * @param durationMs the collection duration in milliseconds
+ * @param content the truncated, already-redacted content
+ * @param reason a short, non-secret reason
+ * @return the section
+ */
+ public static DiagnosticSection truncated(final String name, final long
observedAtEpochMs, final long durationMs,
+ final byte[] content, final
String reason) {
+ return new DiagnosticSection(name, SectionStatus.TRUNCATED,
observedAtEpochMs, durationMs, content, reason);
+ }
+
+ /**
+ * Get the section name.
+ *
+ * @return the name
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Get the section status.
+ *
+ * @return the status
+ */
+ public SectionStatus getStatus() {
+ return status;
+ }
+
+ /**
+ * Get the observation timestamp in epoch milliseconds.
+ *
+ * @return the observed at epoch ms
+ */
+ public long getObservedAtEpochMs() {
+ return observedAtEpochMs;
+ }
+
+ /**
+ * Get the collection duration in milliseconds.
+ *
+ * @return the duration ms
+ */
+ public long getDurationMs() {
+ return durationMs;
+ }
+
+ /**
+ * Get a copy of the section content.
+ *
+ * @return the content bytes
+ */
+ public byte[] getContent() {
+ return Arrays.copyOf(content, content.length);
+ }
+
+ /**
+ * Get the content size in bytes.
+ *
+ * @return the size bytes
+ */
+ public int getSizeBytes() {
+ return content.length;
+ }
+
+ /**
+ * Get the non-secret reason, may be {@code null} for {@link
SectionStatus#OK}.
+ *
+ * @return the reason
+ */
+ public String getReason() {
+ return reason;
+ }
+}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticsConfiguration.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticsConfiguration.java
new file mode 100644
index 0000000000..e7f2755733
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticsConfiguration.java
@@ -0,0 +1,79 @@
+/*
+ * 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.web.diagnostics;
+
+import org.apache.shenyu.web.handler.ShenyuWebHandler;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import
org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.List;
+
+/**
+ * Wires the opt-in gateway diagnostic bundle facility.
+ *
+ * <p>The whole configuration is gated on {@code
shenyu.diagnostics.enabled=true}; a default
+ * deployment creates none of these beans and the endpoint is not registered.
Collector order is
+ * fixed here so the manifest lists sections deterministically.</p>
+ */
+@Configuration
+@ConditionalOnProperty(name = "shenyu.diagnostics.enabled", havingValue =
"true")
+@EnableConfigurationProperties(DiagnosticProperties.class)
+public class DiagnosticsConfiguration {
+
+ /**
+ * The shared redaction policy.
+ *
+ * @return the redactor
+ */
+ @Bean
+ public DiagnosticRedactor diagnosticRedactor() {
+ return new DiagnosticRedactor();
+ }
+
+ /**
+ * The archive writer.
+ *
+ * @param properties the diagnostic bounds
+ * @return the writer
+ */
+ @Bean
+ public DiagnosticBundleWriter diagnosticBundleWriter(final
DiagnosticProperties properties) {
+ return new DiagnosticBundleWriter(properties);
+ }
+
+ /**
+ * The bundle service composing the fixed collector set.
+ *
+ * @param properties the diagnostic bounds
+ * @param redactor the shared redaction policy
+ * @param writer the archive writer
+ * @param webHandler the gateway web handler
+ * @return the bundle service
+ */
+ @Bean
+ public DiagnosticBundleService diagnosticBundleService(final
DiagnosticProperties properties, final DiagnosticRedactor redactor,
+ final
DiagnosticBundleWriter writer, final ShenyuWebHandler webHandler) {
+ List<SectionCollector> collectors = List.of(
+ new RuntimeInfoCollector(),
+ new PluginInventoryCollector(webHandler),
+ new EffectiveConfigDigestCollector(redactor));
+ return new DiagnosticBundleService(collectors, properties, writer);
+ }
+}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/EffectiveConfigDigestCollector.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/EffectiveConfigDigestCollector.java
new file mode 100644
index 0000000000..9c44b65fe2
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/EffectiveConfigDigestCollector.java
@@ -0,0 +1,139 @@
+/*
+ * 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.web.diagnostics;
+
+import org.apache.shenyu.common.dto.PluginData;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.plugin.base.cache.BaseDataCache;
+import org.apache.shenyu.plugin.base.cache.MetaDataCache;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Summarises the effective plugin, selector and rule configuration as
redacted digests.
+ *
+ * <p>Selector and rule handles routinely carry credentials, upstream secrets
and tokens. This
+ * collector never publishes a raw handle or plugin config: it emits a stable
SHA-256 digest so
+ * two bundles can be compared for "did the config change" without ever
revealing the value, plus
+ * non-secret structural counters. The rendered document is passed through the
shared redaction
+ * policy again as defense in depth.</p>
+ */
+public final class EffectiveConfigDigestCollector implements SectionCollector {
+
+ private static final String NAME = "config-digest";
+
+ private final DiagnosticRedactor redactor;
+
+ /**
+ * Instantiates a new effective config digest collector.
+ *
+ * @param redactor the shared redaction policy
+ */
+ public EffectiveConfigDigestCollector(final DiagnosticRedactor redactor) {
+ this.redactor = redactor;
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public DiagnosticSection collect(final DiagnosticBudget budget) {
+ final long observedAt = System.currentTimeMillis();
+ final long startNanos = System.nanoTime();
+ if (budget.shouldStop()) {
+ return DiagnosticSection.of(NAME, SectionStatus.SKIPPED,
observedAt, 0L, "budget exhausted before start");
+ }
+ BaseDataCache base = BaseDataCache.getInstance();
+ Map<String, Object> root = new LinkedHashMap<>();
+ root.put("plugins", summarizePlugins(base.getPluginMap(), budget));
+ root.put("selectors", summarizeSelectors(base.getSelectorMap(),
budget));
+ root.put("rules", summarizeRules(base.getRuleMap(), budget));
+ root.put("metadataCount",
MetaDataCache.getInstance().getMetaDataMap().size());
+ String redacted =
redactor.redactJson(GsonUtils.getInstance().toJson(root));
+ return DiagnosticSection.ok(NAME, observedAt, elapsedMs(startNanos),
redacted);
+ }
+
+ private List<Map<String, Object>> summarizePlugins(final
ConcurrentMap<String, PluginData> plugins, final DiagnosticBudget budget) {
+ List<Map<String, Object>> list = new ArrayList<>(plugins.size());
+ for (Map.Entry<String, PluginData> entry : plugins.entrySet()) {
+ if (budget.shouldStop()) {
+ break;
+ }
+ PluginData data = entry.getValue();
+ Map<String, Object> item = new LinkedHashMap<>();
+ item.put("name", entry.getKey());
+ item.put("role", data.getRole());
+ item.put("configDigest", redactor.digest(data.getConfig()));
+ list.add(item);
+ }
+ return list;
+ }
+
+ private List<Map<String, Object>> summarizeSelectors(final
ConcurrentMap<String, List<SelectorData>> selectors, final DiagnosticBudget
budget) {
+ List<Map<String, Object>> list = new ArrayList<>();
+ for (Map.Entry<String, List<SelectorData>> entry :
selectors.entrySet()) {
+ for (SelectorData data : entry.getValue()) {
+ if (budget.shouldStop()) {
+ return list;
+ }
+ Map<String, Object> item = new LinkedHashMap<>();
+ item.put("pluginName", entry.getKey());
+ item.put("handleDigest", redactor.digest(data.getHandle()));
+ item.put("conditionCount", size(data.getConditionList()));
+ list.add(item);
+ }
+ }
+ return list;
+ }
+
+ private List<Map<String, Object>> summarizeRules(final
ConcurrentMap<String, List<RuleData>> rules, final DiagnosticBudget budget) {
+ List<Map<String, Object>> list = new ArrayList<>();
+ for (Map.Entry<String, List<RuleData>> entry : rules.entrySet()) {
+ for (RuleData data : entry.getValue()) {
+ if (budget.shouldStop()) {
+ return list;
+ }
+ Map<String, Object> item = new LinkedHashMap<>();
+ item.put("selectorId", entry.getKey());
+ item.put("pluginName", data.getPluginName());
+ item.put("handleDigest", redactor.digest(data.getHandle()));
+ item.put("conditionCount", size(data.getConditionDataList()));
+ list.add(item);
+ }
+ }
+ return list;
+ }
+
+ private int size(final List<?> list) {
+ return Objects.isNull(list) ? 0 : list.size();
+ }
+
+ private long elapsedMs(final long startNanos) {
+ return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);
+ }
+}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/PluginInventoryCollector.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/PluginInventoryCollector.java
new file mode 100644
index 0000000000..489b62130a
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/PluginInventoryCollector.java
@@ -0,0 +1,98 @@
+/*
+ * 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.web.diagnostics;
+
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.plugin.api.ShenyuPlugin;
+import org.apache.shenyu.web.handler.ShenyuWebHandler;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Collects the ordered inventory of plugins actually composed into this
gateway.
+ *
+ * <p>Only the plugin identity and order are published; no plugin
configuration or rule handle is
+ * included here. Configuration is summarised separately as redacted digests by
+ * {@link EffectiveConfigDigestCollector}.</p>
+ */
+public final class PluginInventoryCollector implements SectionCollector {
+
+ private static final String NAME = "plugins";
+
+ private final ShenyuWebHandler webHandler;
+
+ /**
+ * Instantiates a new plugin inventory collector.
+ *
+ * @param webHandler the gateway web handler exposing the live plugin chain
+ */
+ public PluginInventoryCollector(final ShenyuWebHandler webHandler) {
+ this.webHandler = webHandler;
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public DiagnosticSection collect(final DiagnosticBudget budget) {
+ final long observedAt = System.currentTimeMillis();
+ final long startNanos = System.nanoTime();
+ if (budget.shouldStop()) {
+ return DiagnosticSection.of(NAME, SectionStatus.SKIPPED,
observedAt, 0L, "budget exhausted before start");
+ }
+ List<ShenyuPlugin> plugins = webHandler.getPlugins();
+ List<Map<String, Object>> inventory = new ArrayList<>(plugins.size());
+ boolean complete = true;
+ for (ShenyuPlugin plugin : plugins) {
+ if (budget.shouldStop()) {
+ complete = false;
+ break;
+ }
+ inventory.add(describe(plugin));
+ }
+ Map<String, Object> root = new LinkedHashMap<>();
+ root.put("count", plugins.size());
+ root.put("collected", inventory.size());
+ root.put("plugins", inventory);
+ String json = GsonUtils.getInstance().toJson(root);
+ if (!complete) {
+ return DiagnosticSection.truncated(NAME, observedAt,
elapsedMs(startNanos), json.getBytes(StandardCharsets.UTF_8), "budget exhausted
during inventory");
+ }
+ return DiagnosticSection.ok(NAME, observedAt, elapsedMs(startNanos),
json);
+ }
+
+ private Map<String, Object> describe(final ShenyuPlugin plugin) {
+ Map<String, Object> entry = new LinkedHashMap<>();
+ String named = plugin.named();
+ entry.put("name", named.isEmpty() ? plugin.getClass().getSimpleName()
: named);
+ entry.put("class", plugin.getClass().getName());
+ entry.put("order", plugin.getOrder());
+ return entry;
+ }
+
+ private long elapsedMs(final long startNanos) {
+ return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);
+ }
+}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/RuntimeInfoCollector.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/RuntimeInfoCollector.java
new file mode 100644
index 0000000000..3580a596a4
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/RuntimeInfoCollector.java
@@ -0,0 +1,106 @@
+/*
+ * 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.web.diagnostics;
+
+import org.apache.shenyu.common.utils.GsonUtils;
+
+import java.lang.management.ManagementFactory;
+import java.lang.management.MemoryUsage;
+import java.lang.management.OperatingSystemMXBean;
+import java.lang.management.RuntimeMXBean;
+import java.lang.management.ThreadMXBean;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Collects bounded JVM, OS, memory and thread counters for the local gateway.
+ *
+ * <p>Only aggregate counters are published. No heap dump, thread dump or
environment variable
+ * is collected, matching the issue's explicit exclusion of high-volume or
secret-bearing data
+ * from the default bundle.</p>
+ */
+public final class RuntimeInfoCollector implements SectionCollector {
+
+ private static final String NAME = "runtime";
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public DiagnosticSection collect(final DiagnosticBudget budget) {
+ final long observedAt = System.currentTimeMillis();
+ final long startNanos = System.nanoTime();
+ if (budget.shouldStop()) {
+ return DiagnosticSection.of(NAME, SectionStatus.SKIPPED,
observedAt, 0L, "budget exhausted before start");
+ }
+ Map<String, Object> runtime = new LinkedHashMap<>();
+ runtime.put("jvm", jvm());
+ runtime.put("os", os());
+ runtime.put("memory", memory());
+ runtime.put("threads", threads());
+ String json = GsonUtils.getInstance().toJson(runtime);
+ return DiagnosticSection.ok(NAME, observedAt, elapsedMs(startNanos),
json);
+ }
+
+ private Map<String, Object> jvm() {
+ RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
+ Map<String, Object> jvm = new LinkedHashMap<>();
+ jvm.put("vmName", runtime.getVmName());
+ jvm.put("vmVersion", runtime.getVmVersion());
+ jvm.put("specVersion", runtime.getSpecVersion());
+ jvm.put("bootTimeEpochMs", runtime.getStartTime());
+ jvm.put("uptimeMs", runtime.getUptime());
+ return jvm;
+ }
+
+ private Map<String, Object> os() {
+ OperatingSystemMXBean os =
ManagementFactory.getOperatingSystemMXBean();
+ Map<String, Object> info = new LinkedHashMap<>();
+ info.put("name", os.getName());
+ info.put("arch", os.getArch());
+ info.put("availableProcessors", os.getAvailableProcessors());
+ return info;
+ }
+
+ private Map<String, Object> memory() {
+ MemoryUsage heap =
ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
+ MemoryUsage nonHeap =
ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage();
+ Map<String, Object> memory = new LinkedHashMap<>();
+ memory.put("heapUsedBytes", heap.getUsed());
+ memory.put("heapCommittedBytes", heap.getCommitted());
+ memory.put("heapMaxBytes", heap.getMax());
+ memory.put("nonHeapUsedBytes", nonHeap.getUsed());
+ return memory;
+ }
+
+ private Map<String, Object> threads() {
+ ThreadMXBean threads = ManagementFactory.getThreadMXBean();
+ Map<String, Object> info = new LinkedHashMap<>();
+ info.put("count", threads.getThreadCount());
+ info.put("peak", threads.getPeakThreadCount());
+ info.put("daemon", threads.getDaemonThreadCount());
+ return info;
+ }
+
+ private long elapsedMs(final long startNanos) {
+ return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);
+ }
+}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/SectionCollector.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/SectionCollector.java
new file mode 100644
index 0000000000..6fb4cbb353
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/SectionCollector.java
@@ -0,0 +1,45 @@
+/*
+ * 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.web.diagnostics;
+
+/**
+ * A single, independently-budgeted contributor to a diagnostic bundle.
+ *
+ * <p>Implementations must return already-redacted content and must never
throw for an
+ * expected unsupported or empty condition; they should return a section with
the matching
+ * {@link SectionStatus} instead. Unexpected failures are captured by the
orchestrator and
+ * turned into a {@link SectionStatus#FAILED} section so one bad collector
cannot sink the
+ * whole bundle.</p>
+ */
+public interface SectionCollector {
+
+ /**
+ * The stable, filesystem-safe section name used as the archive entry key.
+ *
+ * @return the section name
+ */
+ String name();
+
+ /**
+ * Collect this section within the supplied budget.
+ *
+ * @param budget the independent time and size budget
+ * @return the redacted section, never {@code null}
+ */
+ DiagnosticSection collect(DiagnosticBudget budget);
+}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/SectionStatus.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/SectionStatus.java
new file mode 100644
index 0000000000..bbffd3b2d9
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/SectionStatus.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.web.diagnostics;
+
+/**
+ * Outcome of a single diagnostic section collection.
+ *
+ * <p>A bundle is deliberately partial-tolerant: every section carries an
explicit
+ * status so a consumer can tell a real observation from a failed, stale,
truncated
+ * or unsupported one instead of silently treating missing data as empty.</p>
+ */
+public enum SectionStatus {
+
+ /**
+ * The section was collected within its budget and is complete.
+ */
+ OK,
+
+ /**
+ * The section was collected but its content exceeded the size budget and
was cut.
+ */
+ TRUNCATED,
+
+ /**
+ * The collector threw, timed out or was cancelled; no trustworthy content
is present.
+ */
+ FAILED,
+
+ /**
+ * The observation exists but is older than the collection window.
+ */
+ STALE,
+
+ /**
+ * This gateway build or configuration does not support the section.
+ */
+ UNSUPPORTED,
+
+ /**
+ * The section was not attempted, for example because the total budget was
exhausted
+ * or the concurrent collection limit was reached.
+ */
+ SKIPPED
+}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/ShenyuDiagnosticsEndpoint.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/ShenyuDiagnosticsEndpoint.java
new file mode 100644
index 0000000000..a770fdb2af
--- /dev/null
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/ShenyuDiagnosticsEndpoint.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.web.diagnostics;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+/**
+ * Gateway management endpoint that exports a bounded, redacted diagnostic
bundle.
+ *
+ * <p>The endpoint is registered only when {@code shenyu.diagnostics.enabled}
is {@code true}, so a
+ * default deployment does not expose it. Collection runs on the
bounded-elastic scheduler and is
+ * assembled in memory; the download filename is generated from a timestamp so
no caller-supplied
+ * value can influence it. Exposing this path behind the deployment's own
authentication is the
+ * operator's responsibility, exactly like the other gateway actuator
endpoints.</p>
+ */
+@RestController
+@RequestMapping("/actuator/diagnostics")
+@ConditionalOnProperty(name = "shenyu.diagnostics.enabled", havingValue =
"true")
+public class ShenyuDiagnosticsEndpoint {
+
+ private final DiagnosticBundleService bundleService;
+
+ /**
+ * Instantiates a new diagnostics endpoint.
+ *
+ * @param bundleService the bundle service
+ */
+ public ShenyuDiagnosticsEndpoint(final DiagnosticBundleService
bundleService) {
+ this.bundleService = bundleService;
+ }
+
+ /**
+ * Export the diagnostic bundle as a ZIP archive download.
+ *
+ * @return the archive bytes wrapped in an attachment response
+ */
+ @GetMapping(value = "/bundle", produces =
MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ public Mono<ResponseEntity<byte[]>> bundle() {
+ return Mono.fromCallable(bundleService::bundle)
+ .subscribeOn(Schedulers.boundedElastic())
+ .map(this::toAttachment);
+ }
+
+ private ResponseEntity<byte[]> toAttachment(final byte[] body) {
+ String filename = "shenyu-diagnostics-" + System.currentTimeMillis() +
".zip";
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;
filename=\"" + filename + "\"")
+ .contentType(MediaType.APPLICATION_OCTET_STREAM)
+ .contentLength(body.length)
+ .body(body);
+ }
+}
diff --git
a/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleServiceTest.java
b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleServiceTest.java
new file mode 100644
index 0000000000..20c3920a5e
--- /dev/null
+++
b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleServiceTest.java
@@ -0,0 +1,226 @@
+/*
+ * 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.web.diagnostics;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test cases for {@link DiagnosticBundleService}.
+ */
+public final class DiagnosticBundleServiceTest {
+
+ @Test
+ public void failingCollectorIsIsolatedAndDoesNotLeakItsMessage() {
+ DiagnosticProperties properties = new DiagnosticProperties();
+ DiagnosticBundleService service = service(properties, new
FixedCollector("runtime", "{}"), new ThrowingCollector("bad"));
+ List<DiagnosticSection> sections = service.collect().getSections();
+ assertEquals(SectionStatus.OK, sections.get(0).getStatus());
+ assertEquals(SectionStatus.FAILED, sections.get(1).getStatus());
+ assertEquals("IllegalStateException", sections.get(1).getReason());
+ assertFalse(sections.get(1).getReason().contains("boom-secret"));
+ }
+
+ @Test
+ public void slowCollectorTimesOutIndependently() {
+ DiagnosticProperties properties = new DiagnosticProperties();
+ properties.setSectionTimeoutMs(50L);
+ DiagnosticBundleService service = service(properties, new
FixedCollector("runtime", "{}"), new SleepingCollector("slow", 5000L));
+ List<DiagnosticSection> sections = service.collect().getSections();
+ assertEquals(SectionStatus.OK, sections.get(0).getStatus());
+ assertEquals(SectionStatus.FAILED, sections.get(1).getStatus());
+ assertEquals("section timeout", sections.get(1).getReason());
+ }
+
+ @Test
+ public void exhaustedTotalBudgetSkipsEverySection() {
+ DiagnosticProperties properties = new DiagnosticProperties();
+ properties.setTotalTimeoutMs(0L);
+ DiagnosticBundleService service = service(properties, new
FixedCollector("runtime", "{}"), new FixedCollector("plugins", "[]"));
+ List<DiagnosticSection> sections = service.collect().getSections();
+ assertEquals(2, sections.size());
+ assertEquals(SectionStatus.SKIPPED, sections.get(0).getStatus());
+ assertEquals(SectionStatus.SKIPPED, sections.get(1).getStatus());
+ assertEquals("total budget exhausted", sections.get(0).getReason());
+ }
+
+ @Test
+ public void oversizedSectionIsTruncated() {
+ DiagnosticProperties properties = new DiagnosticProperties();
+ properties.setMaxSectionBytes(8);
+ DiagnosticBundleService service = service(properties, new
FixedCollector("runtime", "x".repeat(200)));
+ DiagnosticSection section = service.collect().getSections().get(0);
+ assertEquals(SectionStatus.TRUNCATED, section.getStatus());
+ assertTrue(section.getSizeBytes() <= 8);
+ }
+
+ @Test
+ public void healthyBundleProducesArchive() throws IOException {
+ DiagnosticProperties properties = new DiagnosticProperties();
+ DiagnosticBundleService service = service(properties, new
FixedCollector("runtime", "{}"), new FixedCollector("plugins", "[]"));
+ byte[] archive = service.bundle();
+ assertTrue(archive.length > 0);
+ assertTrue(readEntry(archive,
"manifest.json").contains("formatVersion"));
+ }
+
+ @Test
+ public void concurrentLimitDegradesToOverloadBundle() throws Exception {
+ DiagnosticProperties properties = new DiagnosticProperties();
+ properties.setMaxConcurrentJobs(1);
+ properties.setSectionTimeoutMs(10000L);
+ BlockingCollector blocking = new BlockingCollector("runtime");
+ DiagnosticBundleService service = service(properties, blocking);
+ AtomicReference<byte[]> first = new AtomicReference<>();
+ Thread worker = new Thread(() -> first.set(service.bundle()));
+ worker.start();
+ assertTrue(blocking.started.await(5L, TimeUnit.SECONDS));
+ byte[] overload = service.bundle();
+ assertTrue(readEntry(overload, "manifest.json").contains("concurrent
collection limit reached"));
+ blocking.release.countDown();
+ worker.join(TimeUnit.SECONDS.toMillis(10L));
+ assertTrue(Objects.nonNull(first.get()) && first.get().length > 0);
+ }
+
+ private DiagnosticBundleService service(final DiagnosticProperties
properties, final SectionCollector... collectors) {
+ return new DiagnosticBundleService(List.of(collectors), properties,
new DiagnosticBundleWriter(properties));
+ }
+
+ private String readEntry(final byte[] archive, final String target) throws
IOException {
+ try (ZipInputStream zip = new ZipInputStream(new
ByteArrayInputStream(archive), StandardCharsets.UTF_8)) {
+ ZipEntry entry = zip.getNextEntry();
+ while (Objects.nonNull(entry)) {
+ if (entry.getName().equals(target)) {
+ return new String(zip.readAllBytes(),
StandardCharsets.UTF_8);
+ }
+ entry = zip.getNextEntry();
+ }
+ }
+ return "";
+ }
+
+ private static final class FixedCollector implements SectionCollector {
+
+ private final String name;
+
+ private final String content;
+
+ FixedCollector(final String name, final String content) {
+ this.name = name;
+ this.content = content;
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public DiagnosticSection collect(final DiagnosticBudget budget) {
+ return DiagnosticSection.ok(name, System.currentTimeMillis(), 0L,
content);
+ }
+ }
+
+ private static final class ThrowingCollector implements SectionCollector {
+
+ private final String name;
+
+ ThrowingCollector(final String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public DiagnosticSection collect(final DiagnosticBudget budget) {
+ throw new IllegalStateException("boom-secret");
+ }
+ }
+
+ private static final class SleepingCollector implements SectionCollector {
+
+ private final String name;
+
+ private final long sleepMs;
+
+ SleepingCollector(final String name, final long sleepMs) {
+ this.name = name;
+ this.sleepMs = sleepMs;
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public DiagnosticSection collect(final DiagnosticBudget budget) {
+ try {
+ Thread.sleep(sleepMs);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ }
+ return DiagnosticSection.ok(name, System.currentTimeMillis(), 0L,
"{}");
+ }
+ }
+
+ private static final class BlockingCollector implements SectionCollector {
+
+ private final CountDownLatch started = new CountDownLatch(1);
+
+ private final CountDownLatch release = new CountDownLatch(1);
+
+ private final String name;
+
+ BlockingCollector(final String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public DiagnosticSection collect(final DiagnosticBudget budget) {
+ started.countDown();
+ try {
+ release.await(10L, TimeUnit.SECONDS);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ }
+ return DiagnosticSection.ok(name, System.currentTimeMillis(), 0L,
"{}");
+ }
+ }
+}
diff --git
a/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleWriterTest.java
b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleWriterTest.java
new file mode 100644
index 0000000000..52796accf9
--- /dev/null
+++
b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleWriterTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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.web.diagnostics;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test cases for {@link DiagnosticBundleWriter}.
+ */
+public final class DiagnosticBundleWriterTest {
+
+ @Test
+ public void archiveContainsManifestAndSections() throws IOException {
+ DiagnosticBundleWriter writer = new DiagnosticBundleWriter(new
DiagnosticProperties());
+ DiagnosticManifest manifest = manifest(
+ DiagnosticSection.ok("runtime", 1L, 1L, "{\"a\":1}"),
+ DiagnosticSection.ok("plugins", 1L, 1L, "[]"));
+ Map<String, String> entries = read(writer.write(manifest));
+ assertTrue(entries.containsKey("manifest.json"));
+ assertTrue(entries.containsKey("sections/runtime.json"));
+ assertTrue(entries.containsKey("sections/plugins.json"));
+ assertTrue(entries.get("manifest.json").contains("formatVersion"));
+ }
+
+ @Test
+ public void entryNamesAreTraversalSafe() throws IOException {
+ DiagnosticBundleWriter writer = new DiagnosticBundleWriter(new
DiagnosticProperties());
+ DiagnosticManifest manifest = manifest(
+ DiagnosticSection.ok("../../etc/passwd", 1L, 1L, "x"),
+ DiagnosticSection.ok("/absolute/win\\path", 1L, 1L, "y"));
+ Map<String, String> entries = read(writer.write(manifest));
+ for (String name : entries.keySet()) {
+ if (name.startsWith("sections/")) {
+ String component = name.substring("sections/".length(),
name.length() - ".json".length());
+ assertFalse(component.contains(".."));
+ assertFalse(component.contains("/"));
+ assertFalse(component.contains("\\"));
+ assertFalse(component.isEmpty());
+ }
+ }
+ assertEquals(3, entries.size());
+ }
+
+ @Test
+ public void safeEntryNameNeutralisesHostileNames() {
+ assertEquals("section", DiagnosticBundleWriter.safeEntryName(".."));
+ assertEquals("section", DiagnosticBundleWriter.safeEntryName(""));
+ assertEquals("section", DiagnosticBundleWriter.safeEntryName(null));
+
assertFalse(DiagnosticBundleWriter.safeEntryName("../../x").contains(".."));
+ assertEquals("a_b", DiagnosticBundleWriter.safeEntryName("a/b"));
+ }
+
+ @Test
+ public void perEntrySizeIsCapped() throws IOException {
+ DiagnosticProperties properties = new DiagnosticProperties();
+ properties.setMaxSectionBytes(16);
+ DiagnosticBundleWriter writer = new DiagnosticBundleWriter(properties);
+ String big = "x".repeat(200);
+ DiagnosticManifest manifest = manifest(DiagnosticSection.ok("runtime",
1L, 1L, big));
+ Map<String, String> entries = read(writer.write(manifest));
+ assertTrue(entries.get("sections/runtime.json").length() <= 16);
+ }
+
+ @Test
+ public void duplicateSectionNamesProduceUniqueEntries() throws IOException
{
+ DiagnosticBundleWriter writer = new DiagnosticBundleWriter(new
DiagnosticProperties());
+ DiagnosticManifest manifest = manifest(
+ DiagnosticSection.ok("dup", 1L, 1L, "a"),
+ DiagnosticSection.ok("dup", 1L, 1L, "b"));
+ Map<String, String> entries = read(writer.write(manifest));
+ assertTrue(entries.containsKey("sections/dup.json"));
+ assertEquals(3, entries.size());
+ }
+
+ private DiagnosticManifest manifest(final DiagnosticSection... sections) {
+ return new DiagnosticManifest(System.currentTimeMillis(),
Map.of("hostAddress", "127.0.0.1"),
+ Map.of("shenyu", "test"), List.of(sections));
+ }
+
+ private Map<String, String> read(final byte[] archive) throws IOException {
+ Map<String, String> entries = new LinkedHashMap<>();
+ try (ZipInputStream zip = new ZipInputStream(new
ByteArrayInputStream(archive), StandardCharsets.UTF_8)) {
+ ZipEntry entry = zip.getNextEntry();
+ while (Objects.nonNull(entry)) {
+ entries.put(entry.getName(), new String(zip.readAllBytes(),
StandardCharsets.UTF_8));
+ entry = zip.getNextEntry();
+ }
+ }
+ return entries;
+ }
+}
diff --git
a/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticRedactorTest.java
b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticRedactorTest.java
new file mode 100644
index 0000000000..addbdec501
--- /dev/null
+++
b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticRedactorTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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.web.diagnostics;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test cases for {@link DiagnosticRedactor}.
+ */
+public final class DiagnosticRedactorTest {
+
+ private final DiagnosticRedactor redactor = new DiagnosticRedactor();
+
+ @Test
+ public void secretKeyDetection() {
+ assertTrue(redactor.isSecretKey("password"));
+ assertTrue(redactor.isSecretKey("api-key"));
+ assertTrue(redactor.isSecretKey("API_KEY"));
+ assertTrue(redactor.isSecretKey("authorization"));
+ assertTrue(redactor.isSecretKey("clientSecret"));
+ assertFalse(redactor.isSecretKey("author"));
+ assertFalse(redactor.isSecretKey("name"));
+ assertFalse(redactor.isSecretKey("sortKey"));
+ assertFalse(redactor.isSecretKey(null));
+ }
+
+ @Test
+ public void redactJsonHidesSecretValuesAtAnyDepth() {
+ String json = "{\"username\":\"admin\",\"password\":\"hunter2\","
+ + "\"nested\":{\"apiKey\":\"sk-123\",\"safe\":\"v\"},"
+ + "\"list\":[{\"token\":\"t1\"},{\"ok\":\"yes\"}]}";
+ String result = redactor.redactJson(json);
+ assertTrue(result.contains("admin"));
+ assertTrue(result.contains("\"safe\":\"v\""));
+ assertTrue(result.contains("\"ok\":\"yes\""));
+ assertFalse(result.contains("hunter2"));
+ assertFalse(result.contains("sk-123"));
+ assertFalse(result.contains("t1"));
+ assertTrue(result.contains(DiagnosticRedactor.REDACTED));
+ }
+
+ @Test
+ public void malformedJsonFallsBackToTextScrubbing() {
+ String result = redactor.redactJson("{not-json password=abc123");
+ assertFalse(result.contains("abc123"));
+ }
+
+ @Test
+ public void redactTextScrubsBearerJwtAndAssignments() {
+ String text = "Authorization: Bearer abc.def.ghi password=secret123
eyJhbGciOi.eyJzdWI.SflKxw";
+ String result = redactor.redactText(text);
+ assertFalse(result.contains("secret123"));
+ assertFalse(result.contains("abc.def.ghi"));
+ assertFalse(result.contains("eyJhbGciOi"));
+ }
+
+ @Test
+ public void stripUrlRemovesCredentialsQueryAndFragment() {
+ String result =
redactor.stripUrl("https://user:pass@host:8080/path?token=abc&x=1#frag");
+ assertFalse(result.contains("pass"));
+ assertFalse(result.contains("token=abc"));
+ assertFalse(result.contains("frag"));
+ assertTrue(result.contains("host:8080/path"));
+ assertTrue(result.contains(DiagnosticRedactor.REDACTED));
+ }
+
+ @Test
+ public void digestIsStableAndEmptyForBlankInput() {
+ assertEquals(64, redactor.digest("abc").length());
+ assertEquals(redactor.digest("abc"), redactor.digest("abc"));
+ assertFalse(redactor.digest("abc").equals(redactor.digest("abd")));
+ assertEquals("", redactor.digest(null));
+ assertEquals("", redactor.digest(""));
+ }
+}
diff --git
a/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/EffectiveConfigDigestCollectorTest.java
b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/EffectiveConfigDigestCollectorTest.java
new file mode 100644
index 0000000000..d764bc8734
--- /dev/null
+++
b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/EffectiveConfigDigestCollectorTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.web.diagnostics;
+
+import org.apache.shenyu.common.dto.PluginData;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.plugin.base.cache.BaseDataCache;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test cases for {@link EffectiveConfigDigestCollector}, focused on the
seeded-secret leak fixture.
+ */
+public final class EffectiveConfigDigestCollectorTest {
+
+ private static final String SELECTOR_HANDLE =
"[{\"password\":\"hunter2\",\"upstream\":\"10.0.0.1:8080\"}]";
+
+ private static final String RULE_HANDLE =
"{\"apiKey\":\"sk-secret\",\"path\":\"/order\"}";
+
+ private static final String PLUGIN_CONFIG =
"{\"clientSecret\":\"cfg-secret\"}";
+
+ private final DiagnosticRedactor redactor = new DiagnosticRedactor();
+
+ private final EffectiveConfigDigestCollector collector = new
EffectiveConfigDigestCollector(redactor);
+
+ @BeforeEach
+ public void setUp() {
+ cleanCaches();
+ BaseDataCache base = BaseDataCache.getInstance();
+
base.cachePluginData(PluginData.builder().id("p1").name("divide").config(PLUGIN_CONFIG).role("1").enabled(true).build());
+
base.cacheSelectData(SelectorData.builder().id("s1").name("sel").pluginName("divide").handle(SELECTOR_HANDLE).enabled(true).build());
+
base.cacheRuleData(RuleData.builder().id("r1").name("rule").pluginName("divide").selectorId("s1").handle(RULE_HANDLE).build());
+ }
+
+ @AfterEach
+ public void tearDown() {
+ cleanCaches();
+ }
+
+ @Test
+ public void seededSecretsNeverAppearInTheSection() {
+ DiagnosticSection section = collector.collect(new
DiagnosticBudget(2000L, 524288));
+ String content = new String(section.getContent(),
StandardCharsets.UTF_8);
+ assertEquals(SectionStatus.OK, section.getStatus());
+ assertFalse(content.contains("hunter2"));
+ assertFalse(content.contains("sk-secret"));
+ assertFalse(content.contains("cfg-secret"));
+ assertFalse(content.contains("password"));
+ assertFalse(content.contains("apiKey"));
+ }
+
+ @Test
+ public void structuralIdentityAndDigestsArePreserved() {
+ DiagnosticSection section = collector.collect(new
DiagnosticBudget(2000L, 524288));
+ String content = new String(section.getContent(),
StandardCharsets.UTF_8);
+ assertTrue(content.contains("divide"));
+ assertTrue(content.contains(redactor.digest(SELECTOR_HANDLE)));
+ assertTrue(content.contains(redactor.digest(RULE_HANDLE)));
+ assertTrue(content.contains(redactor.digest(PLUGIN_CONFIG)));
+ }
+
+ private void cleanCaches() {
+ BaseDataCache base = BaseDataCache.getInstance();
+ base.cleanPluginData();
+ base.cleanSelectorData();
+ base.cleanRuleData();
+ }
+}
diff --git
a/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/ShenyuDiagnosticsEndpointTest.java
b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/ShenyuDiagnosticsEndpointTest.java
new file mode 100644
index 0000000000..1cc36d6b04
--- /dev/null
+++
b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/ShenyuDiagnosticsEndpointTest.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.web.diagnostics;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+
+import java.time.Duration;
+import java.util.List;
+
+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 {@link ShenyuDiagnosticsEndpoint}.
+ */
+public final class ShenyuDiagnosticsEndpointTest {
+
+ @Test
+ public void bundleReturnsAZipAttachment() {
+ DiagnosticProperties properties = new DiagnosticProperties();
+ DiagnosticBundleService service = new DiagnosticBundleService(
+ List.of(new RuntimeInfoCollector()), properties, new
DiagnosticBundleWriter(properties));
+ ShenyuDiagnosticsEndpoint endpoint = new
ShenyuDiagnosticsEndpoint(service);
+
+ ResponseEntity<byte[]> response =
endpoint.bundle().block(Duration.ofSeconds(10));
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.OK, response.getStatusCode());
+ assertEquals(MediaType.APPLICATION_OCTET_STREAM,
response.getHeaders().getContentType());
+ String disposition =
response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
+ assertTrue(disposition.startsWith("attachment;
filename=\"shenyu-diagnostics-"));
+ byte[] body = response.getBody();
+ assertNotNull(body);
+ assertTrue(body.length > 0);
+ assertEquals((byte) 'P', body[0]);
+ assertEquals((byte) 'K', body[1]);
+ }
+}