This is an automated email from the ASF dual-hosted git repository.

dmsolr pushed a commit to branch feat/gateway-diagnostic-bundles
in repository https://gitbox.apache.org/repos/asf/shenyu.git

commit c30696a691d6ee2de1d0eec1a9095e317f124c07
Author: Luke Haochao Zhuang <[email protected]>
AuthorDate: Wed Oct 21 20:35:57 2026 +0800

    feat: add bounded and redacted gateway diagnostic bundles
    
    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
---
 .../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]);
+    }
+}

Reply via email to