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

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new b4e115367f feat(agent-gateway): trace logical MCP tool calls with 
application telemetry (#7622)
b4e115367f is described below

commit b4e115367fd57adc8aba332d655b1009932ba394
Author: BobSong <[email protected]>
AuthorDate: Thu Oct 15 11:21:05 2026 +0800

    feat(agent-gateway): trace logical MCP tool calls with application 
telemetry (#7622)
    
    Co-authored-by: BobSong-dev <[email protected]>
---
 .../shenyu-plugin-agent-gateway/README.md          |  46 ++++
 .../gateway/protocol/AgentMcpCallObservation.java  |  73 +++++-
 .../agent/gateway/protocol/AgentMcpCallTracer.java |  55 +++++
 .../gateway/protocol/AgentMcpHttpHandler.java      |  15 +-
 .../gateway/protocol/AgentMcpCallTracerTest.java   | 185 ++++++++++++++++
 .../pom.xml                                        |  15 ++
 .../gateway/AgentGatewayPluginConfiguration.java   |  38 +++-
 .../gateway/OpenTelemetryAgentMcpCallTracer.java   |  77 +++++++
 .../AgentGatewayPluginConfigurationTest.java       |  52 +++++
 .../OpenTelemetryAgentMcpCallTracerTest.java       | 244 +++++++++++++++++++++
 .../gateway/OpenTelemetryAgentMcpOtlpTest.java     |  86 ++++++++
 11 files changed, 870 insertions(+), 16 deletions(-)

diff --git a/shenyu-plugin/shenyu-plugin-agent-gateway/README.md 
b/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
index f49bc9222f..762b1951d3 100644
--- a/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
+++ b/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
@@ -508,3 +508,49 @@ It is not arbitrary free-text redaction or 
prompt-injection prevention. Operator
 must enumerate the fields to remove and use compatible tools; unsupported 
results
 fail closed rather than being described as safely redacted. The original schema
 is never rewritten or weakened to make a deletion pass.
+
+## Optional OpenTelemetry call spans
+
+The Agent Gateway starter can trace logical MCP tool calls when
+`shenyu.plugins.agent.gateway.tracing.enabled=true`. Tracing is disabled by
+default. Add the OpenTelemetry API/SDK to the gateway application and supply an
+unambiguous `OpenTelemetry` Spring bean. The starter uses only the API; it does
+not create, register globally, configure or close an SDK/exporter. Explicitly
+enabling tracing without a usable bridge fails startup. A custom
+`AgentMcpCallTracer` bean is an explicit alternative instrumentation opt-in.
+
+Each authenticated, parsed `tools/call` creates one INTERNAL span named
+`shenyu.agent_gateway.tools.call`. It ends once after response writing 
completes,
+fails or is cancelled, not when the provider merely computes a result. 
Discovery,
+listing, invalid requests and rejection before parsing do not create logical
+call spans. Existing Metrics observations remain independent.
+
+The only span attribute added by this bridge is `shenyu.mcp.outcome`, with the
+existing bounded values `success`, `tool_error`, `rejected`, `server_error`,
+`timeout` and `cancelled`. Business/server errors and timeouts set ERROR 
status;
+other outcomes retain UNSET. No exception event, message or stack trace is
+recorded. It does not collect tool names, RPC/request ids, identities, 
parameters,
+results, credential headers or endpoint URLs. Application SDK resource 
attributes,
+processors, sampling and other instrumentations remain the operator's 
responsibility.
+
+To link a call to a trusted application tracing parent, explicitly provide an
+`io.opentelemetry.context.Context` in Reactor Context keyed by that class:
+
+```java
+return chain.filter(exchange).contextWrite(context ->
+        context.put(io.opentelemetry.context.Context.class, trustedParent));
+```
+
+The bridge copies only the parent span identity, not baggage or arbitrary 
context
+data. It puts the child span context under the same key for tool execution, so
+`Mono.deferContextual` can access it across scheduler changes. It never reads a
+thread-local/global parent, installs a long-lived Scope, extracts caller 
tracing
+headers or injects outbound HTTP headers. Without an explicit Reactor parent,
+the call gets a root span; automatic Java-agent/Micrometer parent integration 
is
+not implied. No SDK-wide Reactor hooks are installed.
+
+For OTLP export, configure the application-owned SDK's exporter/processor, 
trusted
+collector endpoint, TLS, credentials and shutdown/flush policy in the 
application.
+Merely setting the gateway tracing flag or supplying a no-op API does not 
export
+telemetry. There is no separate gateway retry, Token accounting or new metrics
+exporter in this feature. Errors in the observation bridge do not fail tool 
calls.
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallObservation.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallObservation.java
index ab997a904f..4247687b44 100644
--- 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallObservation.java
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallObservation.java
@@ -23,11 +23,14 @@ import 
org.apache.shenyu.common.metrics.AgentMcpCallObserver.Outcome;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import reactor.core.publisher.SignalType;
+import reactor.util.context.Context;
+import reactor.util.context.ContextView;
 
 import java.util.Objects;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
 
 /**
  * One subscription's bounded observation state, independent of RPC ids and 
sessions.
@@ -38,7 +41,15 @@ final class AgentMcpCallObservation {
 
     private final AgentMcpCallObserver observer;
 
-    private final AtomicBoolean recorded = new AtomicBoolean();
+    private final AgentMcpCallTracer tracer;
+
+    private final ContextView parentContext;
+
+    private volatile AgentMcpCallTracer.Trace trace;
+
+    private final AtomicReference<Outcome> completed = new AtomicReference<>();
+
+    private final AtomicBoolean traceRecorded = new AtomicBoolean();
 
     private volatile long started;
 
@@ -47,16 +58,45 @@ final class AgentMcpCallObservation {
     private volatile Outcome outcome = Outcome.SERVER_ERROR;
 
     AgentMcpCallObservation(final AgentMcpCallObserver observer) {
+        this(observer, null, Context.empty());
+    }
+
+    AgentMcpCallObservation(final AgentMcpCallObserver observer, final 
AgentMcpCallTracer tracer, final ContextView parentContext) {
         this.observer = observer;
+        this.tracer = tracer;
+        this.parentContext = parentContext;
     }
 
     void parsed(final AgentMcpRequest request) {
-        if (Objects.nonNull(observer) && 
"tools/call".equals(request.getMethod())) {
+        if (Objects.nonNull(completed.get())) {
+            return;
+        }
+        if ((Objects.nonNull(observer) || Objects.nonNull(tracer)) && 
"tools/call".equals(request.getMethod())) {
             started = System.nanoTime();
             call = true;
+            if (Objects.nonNull(tracer)) {
+                try {
+                    trace = tracer.start(parentContext);
+                    // Cancellation may finish the request while the 
application creates its span.
+                    finishTrace();
+                } catch (RuntimeException error) {
+                    LOG.debug("MCP call tracing callback failed");
+                }
+            }
         }
     }
 
+    Context traceContext(final Context context) {
+        if (Objects.nonNull(trace)) {
+            try {
+                return Objects.requireNonNull(trace.attach(context), "trace 
context");
+            } catch (RuntimeException error) {
+                LOG.debug("MCP call tracing callback failed");
+            }
+        }
+        return context;
+    }
+
     void response(final ObjectNode response) {
         outcome = response.path("result").path("isError").asBoolean(false) ? 
Outcome.TOOL_ERROR : Outcome.SUCCESS;
     }
@@ -72,16 +112,31 @@ final class AgentMcpCallObservation {
     }
 
     void finish(final SignalType signal) {
-        if (!call || !recorded.compareAndSet(false, true)) {
+        Outcome terminal = signal == SignalType.CANCEL ? Outcome.CANCELLED : 
outcome;
+        if (!completed.compareAndSet(null, terminal) || !call) {
             return;
         }
-        Outcome terminal = signal == SignalType.CANCEL ? Outcome.CANCELLED : 
outcome;
         long millis = TimeUnit.NANOSECONDS.toMillis(Math.max(0, 
System.nanoTime() - started));
-        try {
-            observer.record(terminal, millis);
-        } catch (RuntimeException error) {
-            // Observability must not fail a call or log potentially sensitive 
callback errors.
-            LOG.debug("MCP call statistics callback failed");
+        if (Objects.nonNull(observer)) {
+            try {
+                observer.record(terminal, millis);
+            } catch (RuntimeException error) {
+                // Observability must not fail a call or log potentially 
sensitive callback errors.
+                LOG.debug("MCP call statistics callback failed");
+            }
+        }
+        finishTrace();
+    }
+
+    private void finishTrace() {
+        AgentMcpCallTracer.Trace current = trace;
+        Outcome terminal = completed.get();
+        if (Objects.nonNull(current) && Objects.nonNull(terminal) && 
traceRecorded.compareAndSet(false, true)) {
+            try {
+                current.finish(terminal);
+            } catch (RuntimeException error) {
+                LOG.debug("MCP call tracing callback failed");
+            }
         }
     }
 }
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallTracer.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallTracer.java
new file mode 100644
index 0000000000..2dcf81fdc3
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallTracer.java
@@ -0,0 +1,55 @@
+/*
+ * 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.plugin.agent.gateway.protocol;
+
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver.Outcome;
+import reactor.util.context.Context;
+import reactor.util.context.ContextView;
+
+/**
+ * Optional tracing bridge; each parsed tools/call owns one trace until its 
response terminates.
+ */
+@FunctionalInterface
+public interface AgentMcpCallTracer {
+
+    /**
+     * Start one subscription's trace without receiving request data or 
identifiers.
+     * @param parentContext subscription-local context
+     * @return exclusively owned trace
+     */
+    Trace start(ContextView parentContext);
+
+    /**
+     * A request-local trace, independent of RPC ids and shared remote clients.
+     */
+    interface Trace {
+
+        /**
+         * Attach explicit execution context without installing a thread-local 
scope.
+         * @param context downstream context
+         * @return execution context
+         */
+        Context attach(Context context);
+
+        /**
+         * Finish after execution and response writing, including cancellation.
+         * @param outcome bounded logical terminal outcome
+         */
+        void finish(Outcome outcome);
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpHttpHandler.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpHttpHandler.java
index aa95fe9b95..2ba90c9bdd 100644
--- 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpHttpHandler.java
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpHttpHandler.java
@@ -59,13 +59,20 @@ public final class AgentMcpHttpHandler {
 
     private final AgentMcpSecurityResolver securityResolver;
 
+    private final AgentMcpCallTracer tracer;
+
     private final AgentMcpRequestParser parser = new AgentMcpRequestParser();
 
     private final ObjectMapper mapper = new ObjectMapper();
 
     public AgentMcpHttpHandler(final AgentMcpDispatcher dispatcher, final 
AgentMcpSecurityResolver securityResolver) {
+        this(dispatcher, securityResolver, null);
+    }
+
+    public AgentMcpHttpHandler(final AgentMcpDispatcher dispatcher, final 
AgentMcpSecurityResolver securityResolver, final AgentMcpCallTracer tracer) {
         this.dispatcher = Objects.requireNonNull(dispatcher, "dispatcher");
         this.securityResolver = Objects.requireNonNull(securityResolver, 
"securityResolver");
+        this.tracer = tracer;
     }
 
     /**
@@ -79,10 +86,11 @@ public final class AgentMcpHttpHandler {
      */
     public Mono<Void> handle(final ServerWebExchange exchange, final 
AgentGatewayMcpConfig config,
                              final AgentTrafficContext traffic, final long 
configurationVersion) {
-        return Mono.defer(() -> {
+        return Mono.deferContextual(parentContext -> {
             final Instant deadline = 
Instant.now().plusMillis(config.getTimeoutMs());
             Object callback = 
exchange.getAttribute(Constants.METRICS_AGENT_MCP_CALL);
-            final AgentMcpCallObservation observation = new 
AgentMcpCallObservation(callback instanceof AgentMcpCallObserver observer ? 
observer : null);
+            final AgentMcpCallObservation observation = new 
AgentMcpCallObservation(callback instanceof AgentMcpCallObserver observer ? 
observer : null,
+                    tracer, parentContext);
             AtomicReference<JsonNode> rpcId = new AtomicReference<>();
             return Mono.fromRunnable(() -> preflight(exchange, config))
                     .then(Mono.defer(() -> securityResolver.resolve(exchange))
@@ -92,7 +100,8 @@ public final class AgentMcpHttpHandler {
                                 rpcId.set(request.getId());
                                 observation.parsed(request);
                                 return dispatcher.dispatch(request, () -> new 
AgentMcpExecutionContext(traffic.getRequestId(), identity.getSubject(),
-                                        traffic.getRuleId(), 
configurationVersion, config.getAllowedTools(), identity.getToolGrants(), 
deadline, config.getResultFilters()));
+                                        traffic.getRuleId(), 
configurationVersion, config.getAllowedTools(), identity.getToolGrants(), 
deadline, config.getResultFilters()))
+                                        
.contextWrite(observation::traceContext);
                             }))
                     .flatMap(response -> {
                         observation.response(response);
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallTracerTest.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallTracerTest.java
new file mode 100644
index 0000000000..137cc52872
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallTracerTest.java
@@ -0,0 +1,185 @@
+/*
+ * 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.plugin.agent.gateway.protocol;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver;
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver.Outcome;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import reactor.core.publisher.SignalType;
+import reactor.util.context.Context;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class AgentMcpCallTracerTest {
+
+    @Test
+    void parsingAfterTerminationCannotStartANewTrace() {
+        AtomicInteger starts = new AtomicInteger();
+        AtomicInteger metrics = new AtomicInteger();
+        AgentMcpCallObservation observation = new 
AgentMcpCallObservation((outcome, millis) -> metrics.incrementAndGet(), ignored 
-> {
+            starts.incrementAndGet();
+            return mock(AgentMcpCallTracer.Trace.class);
+        }, Context.empty());
+        observation.finish(SignalType.CANCEL);
+        observation.parsed(request("tools/call"));
+        observation.finish(SignalType.ON_COMPLETE);
+        assertEquals(0, starts.get());
+        assertEquals(0, metrics.get());
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void aSpanCreatedAfterRequestTerminationIsStillFinishedOnce(final boolean 
cancel) throws Exception {
+        CountDownLatch entered = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        var executor = Executors.newSingleThreadExecutor();
+        AgentMcpCallTracer.Trace trace = mock(AgentMcpCallTracer.Trace.class);
+        AtomicInteger metrics = new AtomicInteger();
+        AgentMcpCallTracer tracer = ignored -> {
+            entered.countDown();
+            try {
+                if (!release.await(5, TimeUnit.SECONDS)) {
+                    throw new IllegalStateException("Fixture release timed 
out");
+                }
+            } catch (InterruptedException error) {
+                Thread.currentThread().interrupt();
+                throw new IllegalStateException("Fixture interrupted", error);
+            }
+            return trace;
+        };
+        AgentMcpCallObservation observation = new 
AgentMcpCallObservation((outcome, millis) -> metrics.incrementAndGet(), tracer, 
Context.empty());
+        try {
+            final var parsed = executor.submit(() -> 
observation.parsed(request("tools/call")));
+            assertTrue(entered.await(5, TimeUnit.SECONDS));
+            observation.failure(new TimeoutException("private-timeout"));
+            observation.finish(cancel ? SignalType.CANCEL : 
SignalType.ON_COMPLETE);
+            release.countDown();
+            parsed.get(5, TimeUnit.SECONDS);
+            observation.finish(SignalType.ON_ERROR);
+            verify(trace).finish(cancel ? Outcome.CANCELLED : Outcome.TIMEOUT);
+            assertEquals(1, metrics.get());
+        } finally {
+            release.countDown();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void tracingWithoutMetricsStillEndsExactlyOnce() {
+        AgentMcpCallTracer.Trace trace = mock(AgentMcpCallTracer.Trace.class);
+        AgentMcpCallObservation observation = new 
AgentMcpCallObservation(null, ignored -> trace, Context.empty());
+        observation.parsed(request("tools/call"));
+        observation.finish(SignalType.CANCEL);
+        observation.finish(SignalType.ON_COMPLETE);
+        verify(trace).finish(Outcome.CANCELLED);
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {0, 1, 2})
+    void tracingFailuresDoNotPreventMetricsOrChangeContext(final int stage) {
+        AtomicInteger metrics = new AtomicInteger();
+        AgentMcpCallTracer.Trace trace = new AgentMcpCallTracer.Trace() {
+            @Override
+            public Context attach(final Context context) {
+                if (stage == 1) {
+                    throw new IllegalStateException("private-attach-error");
+                }
+                return context;
+            }
+
+            @Override
+            public void finish(final Outcome outcome) {
+                if (stage == 2) {
+                    throw new IllegalStateException("private-finish-error");
+                }
+            }
+        };
+        AgentMcpCallTracer tracer = ignored -> {
+            if (stage == 0) {
+                throw new IllegalStateException("private-start-error");
+            }
+            return trace;
+        };
+        AgentMcpCallObservation observation = new 
AgentMcpCallObservation((outcome, millis) -> metrics.incrementAndGet(), tracer, 
Context.empty());
+        observation.parsed(request("tools/call"));
+        Context context = Context.of("private-identity", "private-value");
+        assertSame(context, observation.traceContext(context));
+        observation.finish(SignalType.ON_COMPLETE);
+        assertEquals(1, metrics.get());
+    }
+
+    @Test
+    void metricsFailureDoesNotPreventTraceCompletion() {
+        AgentMcpCallTracer.Trace trace = mock(AgentMcpCallTracer.Trace.class);
+        AgentMcpCallObserver observer = (outcome, millis) -> {
+            throw new IllegalStateException("private-observer-error");
+        };
+        AgentMcpCallObservation observation = new 
AgentMcpCallObservation(observer, ignored -> trace, Context.empty());
+        observation.parsed(request("tools/call"));
+        observation.finish(SignalType.ON_COMPLETE);
+        verify(trace).finish(Outcome.SERVER_ERROR);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"tools/list", "server/discover"})
+    void discoveryDoesNotStartACallTrace(final String method) {
+        AtomicInteger starts = new AtomicInteger();
+        AgentMcpCallObservation observation = new 
AgentMcpCallObservation(null, ignored -> {
+            starts.incrementAndGet();
+            return mock(AgentMcpCallTracer.Trace.class);
+        }, Context.empty());
+        observation.parsed(request(method));
+        observation.finish(SignalType.ON_COMPLETE);
+        assertEquals(0, starts.get());
+    }
+
+    @Test
+    void invalidTraceContextCannotReplaceExecutionContextWithNull() {
+        AgentMcpCallTracer.Trace trace = mock(AgentMcpCallTracer.Trace.class);
+        when(trace.attach(any())).thenReturn(null);
+        AgentMcpCallObservation observation = new 
AgentMcpCallObservation(null, ignored -> trace, Context.empty());
+        observation.parsed(request("tools/call"));
+        Context context = Context.empty();
+        assertSame(context, observation.traceContext(context));
+        observation.finish(SignalType.CANCEL);
+        verify(trace).finish(Outcome.CANCELLED);
+    }
+
+    private AgentMcpRequest request(final String method) {
+        ObjectNode params = new ObjectMapper().createObjectNode();
+        
params.putObject("_meta").putObject("io.modelcontextprotocol/clientCapabilities");
+        return new AgentMcpRequest(TextNode.valueOf("same-id"), method, 
params);
+    }
+}
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/pom.xml
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/pom.xml
index 1ba6f72faa..3ce2ba86b8 100644
--- 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/pom.xml
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/pom.xml
@@ -26,6 +26,21 @@
     <artifactId>shenyu-spring-boot-starter-plugin-agent-gateway</artifactId>
 
     <dependencies>
+        <dependency>
+            <groupId>io.opentelemetry</groupId>
+            <artifactId>opentelemetry-api</artifactId>
+            <optional>true</optional>
+        </dependency>
+        <dependency>
+            <groupId>io.opentelemetry</groupId>
+            <artifactId>opentelemetry-sdk-testing</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>io.opentelemetry</groupId>
+            <artifactId>opentelemetry-exporter-otlp</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>org.apache.shenyu</groupId>
             <artifactId>shenyu-plugin-agent-gateway</artifactId>
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfiguration.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfiguration.java
index 0475fb0273..4ab469170e 100644
--- 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfiguration.java
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfiguration.java
@@ -17,6 +17,8 @@
 
 package org.apache.shenyu.springboot.starter.plugin.agent.gateway;
 
+import io.opentelemetry.api.OpenTelemetry;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpCallTracer;
 import org.apache.shenyu.plugin.agent.gateway.AgentGatewayPlugin;
 import org.apache.shenyu.plugin.agent.gateway.AgentGatewayConstants;
 import 
org.apache.shenyu.plugin.agent.gateway.handler.AgentGatewayPluginDataHandler;
@@ -29,6 +31,7 @@ import 
org.apache.shenyu.plugin.agent.gateway.tool.AgentToolRegistry;
 import org.apache.shenyu.plugin.api.ShenyuPlugin;
 import org.apache.shenyu.plugin.base.handler.PluginDataHandler;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.beans.factory.ObjectProvider;
@@ -56,19 +59,26 @@ public class AgentGatewayPluginConfiguration {
      * @param providers local providers
      * @param resolvers trusted identity adapters
      * @param catalogs optional, unambiguous managed remote catalog
+     * @param tracers optional tracing bridge
+     * @param environment deployment-controlled tracing opt-in
      * @return the plugin
      */
     @Bean
     public ShenyuPlugin agentGatewayPlugin(final 
ObjectProvider<AgentToolProvider> providers, final 
ObjectProvider<AgentMcpSecurityResolver> resolvers,
-                                         final 
ObjectProvider<AgentMcpRemoteCatalog> catalogs) {
-        return createPlugin(providers, resolvers, catalogs.getIfAvailable());
+                                         final 
ObjectProvider<AgentMcpRemoteCatalog> catalogs, final 
ObjectProvider<AgentMcpCallTracer> tracers,
+                                         final Environment environment) {
+        AgentMcpCallTracer tracer = tracers.getIfAvailable();
+        if 
(environment.getProperty("shenyu.plugins.agent.gateway.tracing.enabled", 
Boolean.class, false) && java.util.Objects.isNull(tracer)) {
+            throw new IllegalStateException("Enabled MCP tracing requires an 
application tracing bridge");
+        }
+        return createPlugin(providers, resolvers, catalogs.getIfAvailable(), 
tracer);
     }
 
     private ShenyuPlugin createPlugin(final ObjectProvider<AgentToolProvider> 
providers, final ObjectProvider<AgentMcpSecurityResolver> resolvers,
-                                     final AgentMcpRemoteCatalog catalog) {
+                                     final AgentMcpRemoteCatalog catalog, 
final AgentMcpCallTracer tracer) {
         AgentToolRegistry registry = new 
AgentToolRegistry(providers.orderedStream().toList());
         AgentMcpSecurityResolver resolver = resolvers.getIfAvailable(() -> 
exchange -> Mono.empty());
-        return new AgentGatewayPlugin(new AgentMcpHttpHandler(new 
AgentMcpDispatcher(registry, "shenyu-agent-gateway", 
AgentGatewayConstants.MCP_SERVER_VERSION, catalog), resolver));
+        return new AgentGatewayPlugin(new AgentMcpHttpHandler(new 
AgentMcpDispatcher(registry, "shenyu-agent-gateway", 
AgentGatewayConstants.MCP_SERVER_VERSION, catalog), resolver, tracer));
     }
 
     /**
@@ -101,4 +111,24 @@ public class AgentGatewayPluginConfiguration {
                 
Path.of(environment.getRequiredProperty("shenyu.plugins.agent.gateway.aggregation.credentials-directory"))));
         return new ManagedRemoteMcpCatalog(allowed, 
tools.orderedStream().map(AgentToolProvider::getName).collect(Collectors.toUnmodifiableSet()),
 resolver);
     }
+
+    /**
+     * Optional API bridge; SDK, exporters and their lifecycle belong to the 
application.
+     */
+    @Configuration(proxyBeanMethods = false)
+    @ConditionalOnClass(OpenTelemetry.class)
+    @ConditionalOnProperty(value = 
"shenyu.plugins.agent.gateway.tracing.enabled", havingValue = "true")
+    static class TracingConfiguration {
+
+        /**
+         * Use the explicitly supplied application telemetry, never a second 
SDK or global singleton.
+         * @param telemetry application-owned telemetry
+         * @return request-local tracing bridge
+         */
+        @Bean
+        @ConditionalOnMissingBean(AgentMcpCallTracer.class)
+        AgentMcpCallTracer agentGatewayCallTracer(final OpenTelemetry 
telemetry) {
+            return new OpenTelemetryAgentMcpCallTracer(telemetry);
+        }
+    }
 }
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/OpenTelemetryAgentMcpCallTracer.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/OpenTelemetryAgentMcpCallTracer.java
new file mode 100644
index 0000000000..ccc5cc37c3
--- /dev/null
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/OpenTelemetryAgentMcpCallTracer.java
@@ -0,0 +1,77 @@
+/*
+ * 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.springboot.starter.plugin.agent.gateway;
+
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanKind;
+import io.opentelemetry.api.trace.StatusCode;
+import io.opentelemetry.api.trace.Tracer;
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver.Outcome;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpCallTracer;
+import reactor.util.context.Context;
+import reactor.util.context.ContextView;
+
+import java.util.Locale;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Bounded MCP spans using explicitly propagated Reactor context, without 
thread-local scopes.
+ */
+final class OpenTelemetryAgentMcpCallTracer implements AgentMcpCallTracer {
+
+    private final Tracer tracer;
+
+    OpenTelemetryAgentMcpCallTracer(final OpenTelemetry telemetry) {
+        tracer = telemetry.getTracer("org.apache.shenyu.agent.gateway");
+    }
+
+    @Override
+    public Trace start(final ContextView parentContext) {
+        Object value = 
parentContext.getOrDefault(io.opentelemetry.context.Context.class, null);
+        io.opentelemetry.context.Context parent = 
io.opentelemetry.context.Context.root();
+        if (value instanceof io.opentelemetry.context.Context context) {
+            // Keep only the parent span identity, never baggage or unrelated 
application data.
+            parent = 
parent.with(Span.wrap(Span.fromContext(context).getSpanContext()));
+        }
+        Span span = 
tracer.spanBuilder("shenyu.agent_gateway.tools.call").setSpanKind(SpanKind.INTERNAL).setParent(parent).startSpan();
+        io.opentelemetry.context.Context execution = 
io.opentelemetry.context.Context.root().with(span);
+        AtomicBoolean ended = new AtomicBoolean();
+        return new Trace() {
+            @Override
+            public Context attach(final Context context) {
+                return context.put(io.opentelemetry.context.Context.class, 
execution);
+            }
+
+            @Override
+            public void finish(final Outcome outcome) {
+                if (!ended.compareAndSet(false, true)) {
+                    return;
+                }
+                try {
+                    span.setAttribute("shenyu.mcp.outcome", 
outcome.name().toLowerCase(Locale.ROOT));
+                    if (outcome == Outcome.TOOL_ERROR || outcome == 
Outcome.SERVER_ERROR || outcome == Outcome.TIMEOUT) {
+                        span.setStatus(StatusCode.ERROR);
+                    }
+                } finally {
+                    span.end();
+                }
+            }
+        };
+    }
+}
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfigurationTest.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfigurationTest.java
index 0a667ffef5..1385178896 100644
--- 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfigurationTest.java
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfigurationTest.java
@@ -20,6 +20,9 @@ package 
org.apache.shenyu.springboot.starter.plugin.agent.gateway;
 import org.apache.shenyu.plugin.agent.gateway.AgentGatewayPlugin;
 import org.apache.shenyu.plugin.agent.gateway.AgentTrafficContext;
 import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpHttpHandler;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpCallTracer;
+import io.opentelemetry.api.OpenTelemetry;
+import org.springframework.boot.test.context.FilteredClassLoader;
 import org.apache.shenyu.plugin.agent.gateway.security.AgentMcpIdentity;
 import 
org.apache.shenyu.plugin.agent.gateway.security.AgentMcpSecurityResolver;
 import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolProvider;
@@ -59,6 +62,55 @@ class AgentGatewayPluginConfigurationTest {
         });
     }
 
+    @Test
+    void tracingIsOffByDefaultEvenWithApplicationTelemetry() {
+        
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true")
+                .withBean(OpenTelemetry.class, OpenTelemetry::noop)
+                .run(context -> 
assertFalse(context.containsBean("agentGatewayCallTracer")));
+    }
+
+    @Test
+    void enabledTracingUsesApplicationTelemetry() {
+        
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true", 
"shenyu.plugins.agent.gateway.tracing.enabled=true")
+                .withBean(OpenTelemetry.class, OpenTelemetry::noop)
+                .run(context -> {
+                    assertTrue(context.getBean(AgentMcpCallTracer.class) 
instanceof OpenTelemetryAgentMcpCallTracer);
+                    AgentMcpHttpHandler handler = (AgentMcpHttpHandler) 
ReflectionTestUtils.getField(context.getBean(AgentGatewayPlugin.class), 
"mcpHandler");
+                    assertEquals(context.getBean(AgentMcpCallTracer.class), 
ReflectionTestUtils.getField(handler, "tracer"));
+                });
+    }
+
+    @Test
+    void enabledTracingRequiresApplicationTelemetry() {
+        
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true", 
"shenyu.plugins.agent.gateway.tracing.enabled=true")
+                .run(context -> assertNotNull(context.getStartupFailure()));
+    }
+
+    @Test
+    void optionalTelemetryApiIsNotRequiredWhenDisabled() {
+        contextRunner.withClassLoader(new 
FilteredClassLoader("io.opentelemetry"))
+                
.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true")
+                .run(context -> {
+                    assertNotNull(context.getBean(AgentGatewayPlugin.class));
+                    
assertFalse(context.containsBean("agentGatewayCallTracer"));
+                });
+    }
+
+    @Test
+    void explicitlyEnabledTracingCannotSilentlyDisappearWithoutApi() {
+        contextRunner.withClassLoader(new 
FilteredClassLoader("io.opentelemetry"))
+                
.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true", 
"shenyu.plugins.agent.gateway.tracing.enabled=true")
+                .run(context -> assertNotNull(context.getStartupFailure()));
+    }
+
+    @Test
+    void customTracingBridgeDoesNotRequireAnotherSdk() {
+        AgentMcpCallTracer tracer = ignored -> 
mock(AgentMcpCallTracer.Trace.class);
+        
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true", 
"shenyu.plugins.agent.gateway.tracing.enabled=true")
+                .withBean(AgentMcpCallTracer.class, () -> tracer)
+                .run(context -> assertEquals(tracer, 
context.getBean(AgentMcpCallTracer.class)));
+    }
+
     @Test
     void shouldAssemblePluginWhenEnabled() {
         
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true")
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/OpenTelemetryAgentMcpCallTracerTest.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/OpenTelemetryAgentMcpCallTracerTest.java
new file mode 100644
index 0000000000..99e15b9811
--- /dev/null
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/OpenTelemetryAgentMcpCallTracerTest.java
@@ -0,0 +1,244 @@
+/*
+ * 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.springboot.starter.plugin.agent.gateway;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import io.opentelemetry.api.baggage.Baggage;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanKind;
+import io.opentelemetry.api.trace.StatusCode;
+import io.opentelemetry.context.Context;
+import io.opentelemetry.context.Scope;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import io.opentelemetry.sdk.trace.data.SpanData;
+import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
+import org.apache.shenyu.common.dto.AgentGatewayMcpConfig;
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver.Outcome;
+import org.apache.shenyu.plugin.agent.gateway.AgentTrafficContext;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpCallTracer;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpDispatcher;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpHttpHandler;
+import org.apache.shenyu.plugin.agent.gateway.security.AgentMcpIdentity;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolExecutionException;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolProvider;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolRegistry;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+import reactor.test.StepVerifier;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.awaitility.Awaitility.await;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class OpenTelemetryAgentMcpCallTracerTest {
+
+    @ParameterizedTest
+    @CsvSource({"json,SUCCESS", "sse,SUCCESS", "json,TOOL_ERROR", 
"sse,TOOL_ERROR", "json,SERVER_ERROR", "sse,SERVER_ERROR", "json,REJECTED", 
"sse,REJECTED"})
+    void exportsOnlyBoundedTerminalData(final String mode, final Outcome 
expected) {
+        try (Fixture fixture = new Fixture()) {
+            Mono<JsonObject> action = switch (expected) {
+                case TOOL_ERROR -> Mono.error(new 
AgentToolExecutionException("private-business-error"));
+                case SERVER_ERROR -> Mono.error(new 
IllegalStateException("private-server-error"));
+                default -> 
Mono.just(JsonParser.parseString("{\"private-result\":\"secret\"}").getAsJsonObject());
+            };
+            MockServerWebExchange exchange = exchange("tools/call");
+            StepVerifier.create(fixture.handler(action, expected == 
Outcome.REJECTED ? Set.of() : Set.of("read"))
+                    .handle(exchange, config(mode, 30000), traffic(), 
1)).verifyComplete();
+            SpanData span = fixture.single(expected);
+            assertEquals(SpanKind.INTERNAL, span.getKind());
+            assertEquals(expected == Outcome.TOOL_ERROR || expected == 
Outcome.SERVER_ERROR ? StatusCode.ERROR : StatusCode.UNSET, 
span.getStatus().getStatusCode());
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void deadlineEndsSpanOnceAndCancelsItsExecution(final String mode) {
+        try (Fixture fixture = new Fixture()) {
+            AtomicBoolean cancelled = new AtomicBoolean();
+            MockServerWebExchange exchange = exchange("tools/call");
+            AgentMcpHttpHandler handler = 
fixture.handler(Mono.<JsonObject>never().doOnCancel(() -> cancelled.set(true)), 
Set.of("read"));
+            StepVerifier.withVirtualTime(() -> handler.handle(exchange, 
config(mode, 100), traffic(), 1))
+                    .thenAwait(Duration.ofMillis(101)).verifyComplete();
+            assertEquals(504, exchange.getResponse().getStatusCode().value());
+            assertTrue(cancelled.get());
+            assertEquals(StatusCode.ERROR, 
fixture.single(Outcome.TIMEOUT).getStatus().getStatusCode());
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void responseWritingMustTerminateBeforeSpanEnds(final String mode) {
+        try (Fixture fixture = new Fixture()) {
+            MockServerWebExchange exchange = exchange("tools/call");
+            exchange.getResponse().setWriteHandler(buffers -> 
buffers.doOnNext(DataBufferUtils::release).then(Mono.never()));
+            StepVerifier.create(fixture.handler(Mono.just(new JsonObject()), 
Set.of("read")).handle(exchange, config(mode, 30000), traffic(), 1))
+                    .then(() -> 
assertTrue(fixture.exporter.getFinishedSpanItems().isEmpty())).thenCancel().verify();
+            fixture.single(Outcome.CANCELLED);
+        }
+    }
+
+    @Test
+    void reactorParentAndChildStayPrivateAcrossThirtyTwoSameIdCalls() {
+        try (Fixture fixture = new Fixture()) {
+            List<String> parents = new CopyOnWriteArrayList<>();
+            Map<String, String> children = new ConcurrentHashMap<>();
+            Mono<JsonObject> action = Mono.deferContextual(context -> {
+                Context execution = context.get(Context.class);
+                assertTrue(Baggage.fromContext(execution).isEmpty());
+                
children.put(Span.fromContext(execution).getSpanContext().getSpanId(), 
context.get("expected-parent"));
+                return Mono.just(new JsonObject());
+            }).subscribeOn(Schedulers.parallel());
+            AgentMcpHttpHandler handler = fixture.handler(action, 
Set.of("read"));
+            StepVerifier.create(Flux.range(0, 32).flatMap(index -> {
+                Span parent = 
fixture.sdk.getTracer("test-parent").spanBuilder("parent").startSpan();
+                parents.add(parent.getSpanContext().getSpanId());
+                Context context = Baggage.builder().put("private-subject", 
"secret").build().storeInContext(Context.root().with(parent));
+                return handler.handle(exchange("tools/call"), config("json", 
30000), traffic(), index)
+                        .contextWrite(value -> value.put(Context.class, 
context).put("expected-parent", parent.getSpanContext().getSpanId()))
+                        .doFinally(signal -> parent.end());
+            }, 32)).verifyComplete();
+            await().atMost(Duration.ofSeconds(3)).until(() -> 
fixture.exporter.getFinishedSpanItems().size() == 64);
+            List<SpanData> calls = 
fixture.exporter.getFinishedSpanItems().stream().filter(span -> 
"shenyu.agent_gateway.tools.call".equals(span.getName())).toList();
+            assertEquals(32, calls.size());
+            assertEquals(32, children.size());
+            assertEquals(Set.copyOf(parents), 
calls.stream().map(SpanData::getParentSpanId).collect(java.util.stream.Collectors.toSet()));
+            assertEquals(children.keySet(), 
calls.stream().map(SpanData::getSpanId).collect(java.util.stream.Collectors.toSet()));
+            calls.forEach(span -> assertEquals(children.get(span.getSpanId()), 
span.getParentSpanId()));
+            assertFalse(Span.current().getSpanContext().isValid());
+        }
+    }
+
+    @Test
+    void doesNotInheritThreadLocalParentOrEndIt() {
+        try (Fixture fixture = new Fixture()) {
+            Span unrelated = 
fixture.sdk.getTracer("unrelated").spanBuilder("unrelated").startSpan();
+            try (Scope ignored = unrelated.makeCurrent()) {
+                AgentMcpCallTracer.Trace trace = 
fixture.tracer.start(reactor.util.context.Context.empty());
+                trace.finish(Outcome.SUCCESS);
+                trace.finish(Outcome.TIMEOUT);
+                
assertFalse(fixture.single(Outcome.SUCCESS).getParentSpanContext().isValid());
+                assertEquals(unrelated.getSpanContext(), 
Span.current().getSpanContext());
+            } finally {
+                unrelated.end();
+            }
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"tools/list", "server/discover"})
+    void discoveryDoesNotExportLogicalCallSpans(final String method) {
+        try (Fixture fixture = new Fixture()) {
+            StepVerifier.create(fixture.handler(Mono.just(new JsonObject()), 
Set.of("read"))
+                    .handle(exchange(method), config("json", 30000), 
traffic(), 1)).verifyComplete();
+            assertTrue(fixture.exporter.getFinishedSpanItems().isEmpty());
+        }
+    }
+
+    @Test
+    void cancellingBeforeParsingDoesNotCreateASpan() {
+        try (Fixture fixture = new Fixture()) {
+            StepVerifier.create(fixture.handler(Mono.never(), Set.of("read"))
+                    .handle(exchange("tools/call"), config("json", 30000), 
traffic(), 1)).thenCancel().verify();
+            assertTrue(fixture.exporter.getFinishedSpanItems().isEmpty());
+        }
+    }
+
+    private static MockServerWebExchange exchange(final String method) {
+        MockServerHttpRequest.BodyBuilder builder = 
MockServerHttpRequest.post("/agent").header("Content-Type", "application/json")
+                .header("Accept", "application/json, 
text/event-stream").header("MCP-Protocol-Version", 
"2026-07-28").header("Mcp-Method", method);
+        if ("tools/call".equals(method)) {
+            builder.header("Mcp-Name", "read");
+        }
+        return 
MockServerWebExchange.from(builder.body("{\"jsonrpc\":\"2.0\",\"id\":\"same-id\",\"method\":\""
 + method + "\","
+                + 
"\"params\":{\"name\":\"read\",\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\","
+                + "\"io.modelcontextprotocol/clientCapabilities\":{}}}}"));
+    }
+
+    private static AgentGatewayMcpConfig config(final String mode, final int 
timeout) {
+        return 
AgentGatewayMcpConfig.parse(JsonParser.parseString("{\"allowedTools\":[\"read\"],\"responseMode\":\""
 + mode
+                + "\",\"timeoutMs\":" + timeout + "}").getAsJsonObject(), 
true);
+    }
+
+    private static AgentTrafficContext traffic() {
+        return new AgentTrafficContext("private-id", "mcp", 
"private-selector", "private-rule");
+    }
+
+    private static final class Fixture implements AutoCloseable {
+
+        private final InMemorySpanExporter exporter = 
InMemorySpanExporter.create();
+
+        private final SdkTracerProvider provider = 
SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(exporter)).build();
+
+        private final OpenTelemetrySdk sdk = 
OpenTelemetrySdk.builder().setTracerProvider(provider).build();
+
+        private final OpenTelemetryAgentMcpCallTracer tracer = new 
OpenTelemetryAgentMcpCallTracer(sdk);
+
+        private AgentMcpHttpHandler handler(final Mono<JsonObject> action, 
final Set<String> grants) {
+            AgentToolProvider tool = mock(AgentToolProvider.class);
+            when(tool.getName()).thenReturn("read");
+            when(tool.getDescription()).thenReturn("private-description");
+            
when(tool.getInputSchema()).thenReturn(JsonParser.parseString("{\"type\":\"object\"}").getAsJsonObject());
+            when(tool.getRequiredClientCapabilities()).thenReturn(new 
JsonObject());
+            when(tool.invoke(any())).thenReturn(action);
+            return new AgentMcpHttpHandler(new AgentMcpDispatcher(new 
AgentToolRegistry(List.of(tool)), "test", "1"),
+                    ignored -> Mono.just(new 
AgentMcpIdentity("private-subject", grants)), tracer);
+        }
+
+        private SpanData single(final Outcome outcome) {
+            List<SpanData> spans = exporter.getFinishedSpanItems();
+            assertEquals(1, spans.size());
+            SpanData span = spans.get(0);
+            assertEquals("shenyu.agent_gateway.tools.call", span.getName());
+            assertEquals(1, span.getAttributes().size());
+            assertEquals(outcome.name().toLowerCase(java.util.Locale.ROOT), 
span.getAttributes().get(AttributeKey.stringKey("shenyu.mcp.outcome")));
+            assertTrue(span.getEvents().isEmpty());
+            assertTrue(span.getLinks().isEmpty());
+            assertTrue(span.getEndEpochNanos() >= span.getStartEpochNanos());
+            return span;
+        }
+
+        @Override
+        public void close() {
+            sdk.close();
+        }
+    }
+}
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/OpenTelemetryAgentMcpOtlpTest.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/OpenTelemetryAgentMcpOtlpTest.java
new file mode 100644
index 0000000000..0dfca132cc
--- /dev/null
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/OpenTelemetryAgentMcpOtlpTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.springboot.starter.plugin.agent.gateway;
+
+import com.sun.net.httpserver.HttpServer;
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.resources.Resource;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver.Outcome;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpCallTracer;
+import org.junit.jupiter.api.Test;
+import reactor.util.context.Context;
+
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class OpenTelemetryAgentMcpOtlpTest {
+
+    @Test
+    void applicationOwnedSdkExportsOnceOverRealOtlpHttp() throws Exception {
+        AtomicReference<byte[]> payload = new AtomicReference<>();
+        AtomicReference<String> contentType = new AtomicReference<>();
+        AtomicReference<String> method = new AtomicReference<>();
+        AtomicInteger requests = new AtomicInteger();
+        HttpServer receiver = HttpServer.create(new 
InetSocketAddress("127.0.0.1", 0), 0);
+        receiver.createContext("/v1/traces", exchange -> {
+            requests.incrementAndGet();
+            method.set(exchange.getRequestMethod());
+            
contentType.set(exchange.getRequestHeaders().getFirst("Content-Type"));
+            payload.set(exchange.getRequestBody().readAllBytes());
+            exchange.getResponseHeaders().set("Content-Type", 
"application/x-protobuf");
+            exchange.sendResponseHeaders(200, -1);
+            exchange.close();
+        });
+        receiver.start();
+        try {
+            OtlpHttpSpanExporter exporter = 
OtlpHttpSpanExporter.builder().setTimeout(Duration.ofSeconds(3))
+                    .setEndpoint("http://127.0.0.1:"; + 
receiver.getAddress().getPort() + "/v1/traces").build();
+            try (SdkTracerProvider provider = 
SdkTracerProvider.builder().setResource(Resource.empty())
+                    
.addSpanProcessor(SimpleSpanProcessor.create(exporter)).build()) {
+                OpenTelemetry telemetry = 
OpenTelemetrySdk.builder().setTracerProvider(provider).build();
+                AgentMcpCallTracer.Trace trace = new 
OpenTelemetryAgentMcpCallTracer(telemetry).start(Context.of("private-subject", 
"secret-value"));
+                trace.finish(Outcome.SUCCESS);
+                trace.finish(Outcome.SERVER_ERROR);
+                assertTrue(provider.forceFlush().join(5, 
TimeUnit.SECONDS).isSuccess());
+                assertEquals(1, requests.get());
+                assertEquals("POST", method.get());
+                assertEquals("application/x-protobuf", contentType.get());
+                String wire = new String(payload.get(), 
StandardCharsets.UTF_8);
+                assertTrue(wire.contains("shenyu.agent_gateway.tools.call"));
+                assertTrue(wire.contains("shenyu.mcp.outcome"));
+                assertTrue(wire.contains("success"));
+                assertFalse(wire.contains("private-subject"));
+                assertFalse(wire.contains("secret-value"));
+            }
+        } finally {
+            receiver.stop(0);
+        }
+    }
+}

Reply via email to