diff --git a/.gitignore b/.gitignore index 143f602c6..012a5033e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ release.properties .flattened-pom.xml *.args +#Claude +CLAUDE.md + # Eclipse .project .classpath @@ -21,6 +24,7 @@ bin/ # NetBeans nb-configuration.xml +nbactions.xml # Visual Studio Code .vscode @@ -54,3 +58,4 @@ nbactions.xml .serena/ .bob/ claudedocs +.bob/ diff --git a/boms/extras/pom.xml b/boms/extras/pom.xml index 7cf7a54a8..c353e91af 100644 --- a/boms/extras/pom.xml +++ b/boms/extras/pom.xml @@ -39,6 +39,26 @@ a2a-java-sdk-http-client-vertx ${project.version} + + ${project.groupId} + a2a-java-sdk-opentelemetry-common + ${project.version} + + + ${project.groupId} + a2a-java-sdk-opentelemetry-client + ${project.version} + + + ${project.groupId} + a2a-java-sdk-opentelemetry-client-propagation + ${project.version} + + + ${project.groupId} + a2a-java-sdk-opentelemetry-server + ${project.version} + ${project.groupId} a2a-java-extras-task-store-database-jpa diff --git a/boms/extras/src/it/extras-usage-test/pom.xml b/boms/extras/src/it/extras-usage-test/pom.xml index cd8b897d9..5ec29b3a6 100644 --- a/boms/extras/src/it/extras-usage-test/pom.xml +++ b/boms/extras/src/it/extras-usage-test/pom.xml @@ -48,6 +48,22 @@ io.github.a2asdk a2a-java-sdk-http-client-vertx + + io.github.a2asdk + a2a-java-sdk-opentelemetry-common + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-client + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-client-propagation + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-server + io.github.a2asdk a2a-java-extras-task-store-database-jpa diff --git a/boms/extras/src/it/extras-usage-test/src/main/java/io/a2a/test/ExtrasBomVerifier.java b/boms/extras/src/it/extras-usage-test/src/main/java/io/a2a/test/ExtrasBomVerifier.java index 9f7fd092f..81cd0b855 100644 --- a/boms/extras/src/it/extras-usage-test/src/main/java/io/a2a/test/ExtrasBomVerifier.java +++ b/boms/extras/src/it/extras-usage-test/src/main/java/io/a2a/test/ExtrasBomVerifier.java @@ -17,7 +17,8 @@ public class ExtrasBomVerifier extends DynamicBomVerifier { "tck/", // TCK test suite "tests/", // Integration tests "extras/queue-manager-replicated/tests-multi-instance/", // Test harness applications - "extras/queue-manager-replicated/tests-single-instance/" // Test harness applications + "extras/queue-manager-replicated/tests-single-instance/", // Test harness applications + "extras/opentelemetry/integration-tests/" // Test harness applications // Note: extras/ production modules are NOT in this list - we want to verify those classes load ); diff --git a/client/base/src/main/java/io/a2a/client/ClientBuilder.java b/client/base/src/main/java/io/a2a/client/ClientBuilder.java index c8d2ab6be..81c812c91 100644 --- a/client/base/src/main/java/io/a2a/client/ClientBuilder.java +++ b/client/base/src/main/java/io/a2a/client/ClientBuilder.java @@ -6,14 +6,17 @@ import java.util.List; import java.util.Map; import java.util.ServiceLoader; +import java.util.ServiceLoader.Provider; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; import io.a2a.client.config.ClientConfig; import io.a2a.client.transport.spi.ClientTransport; import io.a2a.client.transport.spi.ClientTransportConfig; import io.a2a.client.transport.spi.ClientTransportConfigBuilder; import io.a2a.client.transport.spi.ClientTransportProvider; +import io.a2a.client.transport.spi.ClientTransportWrapper; import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; import io.a2a.spec.AgentInterface; @@ -21,6 +24,9 @@ import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + /** * Builder for creating instances of {@link Client} to communicate with A2A agents. *

@@ -96,6 +102,7 @@ public class ClientBuilder { private static final Map>> transportProviderRegistry = new HashMap<>(); private static final Map, String> transportProtocolMapping = new HashMap<>(); + private static final Logger LOGGER = LoggerFactory.getLogger(ClientBuilder.class); static { ServiceLoader loader = ServiceLoader.load(ClientTransportProvider.class); @@ -318,7 +325,7 @@ private ClientTransport buildClientTransport() throws A2AClientException { throw new A2AClientException("Missing required TransportConfig for " + agentInterface.protocolBinding()); } - return clientTransportProvider.create(clientTransportConfig, agentCard, agentInterface); + return wrap(clientTransportProvider.create(clientTransportConfig, agentCard, agentInterface), clientTransportConfig); } private Map getServerPreferredTransports() throws A2AClientException { @@ -373,10 +380,55 @@ private AgentInterface findBestClientTransport() throws A2AClientException { if (transportProtocol == null || transportUrl == null) { throw new A2AClientException("No compatible transport found"); } - if (! transportProviderRegistry.containsKey(transportProtocol)) { + if (!transportProviderRegistry.containsKey(transportProtocol)) { throw new A2AClientException("No client available for " + transportProtocol); } return new AgentInterface(transportProtocol, transportUrl); } + + /** + * Wraps the transport with all available transport wrappers discovered via ServiceLoader. + * Wrappers are applied in reverse priority order (lowest priority first) to build a stack + * where the highest priority wrapper is the outermost layer. + * + * @param transport the base transport to wrap + * @param clientTransportConfig the transport configuration + * @return the wrapped transport (or original if no wrappers are available/applicable) + */ + private ClientTransport wrap(ClientTransport transport, ClientTransportConfig clientTransportConfig) { + ServiceLoader wrapperLoader = ServiceLoader.load(ClientTransportWrapper.class); + + // Collect all wrappers, sort by priority, then reverse for stack application + List wrappers = wrapperLoader.stream().map(Provider::get) + .sorted() + .collect(Collectors.toList()); + + if (wrappers.isEmpty()) { + LOGGER.debug("No client transport wrappers found via ServiceLoader"); + return transport; + } + LOGGER.debug(wrappers.size() + " client transport wrappers found via ServiceLoader"); + + // Reverse to apply lowest priority first (building stack with highest priority outermost) + java.util.Collections.reverse(wrappers); + + // Apply wrappers to build stack + ClientTransport wrapped = transport; + for (ClientTransportWrapper wrapper : wrappers) { + try { + ClientTransport newWrapped = wrapper.wrap(wrapped, clientTransportConfig); + if (newWrapped != wrapped) { + LOGGER.debug("Applied transport wrapper: {} (priority: {})", + wrapper.getClass().getName(), wrapper.priority()); + } + wrapped = newWrapped; + } catch (Exception e) { + LOGGER.warn("Failed to apply transport wrapper {}: {}", + wrapper.getClass().getName(), e.getMessage(), e); + } + } + + return wrapped; + } } diff --git a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfig.java b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfig.java index 241541a32..e78c67271 100644 --- a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfig.java +++ b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfig.java @@ -48,7 +48,7 @@ * @see A2AHttpClient * @see io.a2a.client.http.JdkA2AHttpClient */ -public class RestTransportConfig extends ClientTransportConfig { +public class RestTransportConfig extends ClientTransportConfig { private final @Nullable A2AHttpClient httpClient; diff --git a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java index 9c05fac59..f93954b40 100644 --- a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java @@ -1,9 +1,13 @@ package io.a2a.client.transport.spi; import java.util.ArrayList; + +import java.util.HashMap; import java.util.List; +import java.util.Map; import io.a2a.client.transport.spi.interceptors.ClientCallInterceptor; +import java.util.Collections; /** * Base configuration class for A2A client transport protocols. @@ -34,7 +38,8 @@ */ public abstract class ClientTransportConfig { - protected List interceptors = new ArrayList<>(); + protected List interceptors = Collections.emptyList(); + protected Map parameters = Collections.emptyMap(); /** * Set the list of request/response interceptors. @@ -63,4 +68,28 @@ public void setInterceptors(List interceptors) { public List getInterceptors() { return java.util.Collections.unmodifiableList(interceptors); } + + /** + * Set the Map of config parameters. + * The provided map is copied to prevent external modifications from affecting + * this configuration. + * + * @param parameters the map of parameters to use (will be copied) + */ + public void setParameters(Map parameters) { + this.parameters = new HashMap<>(parameters); + } + + + /** + * Get the list of configured parameters. + *

+ * Returns an unmodifiable view of the parameters map. Attempting to modify + * the returned map will throw {@link UnsupportedOperationException}. + * + * @return an unmodifiable map of configured parameters (never null, but may be empty) + */ + public Map getParameters() { + return java.util.Collections.unmodifiableMap(parameters); + } } diff --git a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportWrapper.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportWrapper.java new file mode 100644 index 000000000..25dba33b9 --- /dev/null +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportWrapper.java @@ -0,0 +1,81 @@ +package io.a2a.client.transport.spi; + +/** + * Service provider interface for wrapping client transports with additional functionality. + * Implementations can add cross-cutting concerns like tracing, metrics, logging, etc. + * + *

Wrappers are discovered via Java's ServiceLoader mechanism. To register a wrapper, + * create a file {@code META-INF/services/io.a2a.client.transport.spi.ClientTransportWrapper} + * containing the fully qualified class name of your implementation. + * + *

Wrappers are sorted by priority in descending order (highest priority first). + * This interface implements {@link Comparable} to enable natural sorting. + * + *

Example implementation: + *

{@code
+ * public class TracingWrapper implements ClientTransportWrapper {
+ *     @Override
+ *     public ClientTransport wrap(ClientTransport transport, ClientTransportConfig config) {
+ *         if (config.getParameters().containsKey("tracer")) {
+ *             return new TracingTransport(transport, (Tracer) config.getParameters().get("tracer"));
+ *         }
+ *         return transport;
+ *     }
+ *
+ *     @Override
+ *     public int priority() {
+ *         return 100; // Higher priority = wraps earlier (outermost)
+ *     }
+ * }
+ * }
+ */ +public interface ClientTransportWrapper extends Comparable { + + /** + * Wraps the given transport with additional functionality. + * + *

Implementations should check the configuration to determine if they should + * actually wrap the transport. If the wrapper is not applicable (e.g., required + * configuration is missing), return the original transport unchanged. + * + * @param transport the transport to wrap + * @param config the transport configuration, may contain wrapper-specific parameters + * @return the wrapped transport, or the original if wrapping is not applicable + */ + ClientTransport wrap(ClientTransport transport, ClientTransportConfig config); + + /** + * Returns the priority of this wrapper. Higher priority wrappers are applied first + * (wrap the transport earlier, resulting in being the outermost wrapper). + * + *

Default priority is 0. Suggested ranges: + *

+ * + * @return the priority value, higher values = higher priority + */ + default int priority() { + return 0; + } + + /** + * Compares this wrapper with another based on priority. + * Returns a negative integer, zero, or a positive integer as this wrapper + * has higher priority than, equal to, or lower priority than the specified wrapper. + * + *

Note: This comparison is reversed (higher priority comes first) to enable + * natural sorting in descending priority order. + * + * @param other the wrapper to compare to + * @return negative if this has higher priority, positive if lower, zero if equal + */ + @Override + default int compareTo(ClientTransportWrapper other) { + // Reverse comparison: higher priority should come first + return Integer.compare(other.priority(), this.priority()); + } +} diff --git a/examples/helloworld/client/README.md b/examples/helloworld/client/README.md index ac01c890f..cd5043f11 100644 --- a/examples/helloworld/client/README.md +++ b/examples/helloworld/client/README.md @@ -41,9 +41,13 @@ The Python A2A server is part of the [a2a-samples](https://github.com/google-a2a The server will start running on `http://localhost:9999`. -## Run the Java A2A Client with JBang +### Using the Java Server Instead -The Java client can be run using JBang, which allows you to run Java source files directly without any manual compilation. +Alternatively, you can use the Java server example instead of the Python server. The Java server supports multiple transport protocols (JSONRPC, GRPC, and HTTP+JSON). See the [server README](../server/README.md) for details on starting the Java server with different transport protocols. + +## Run the Java A2A Client + +The Java client can be run using either Maven or JBang. ### Build the A2A Java SDK @@ -54,38 +58,204 @@ cd /path/to/a2a-java mvn clean install ``` -### Using the JBang script +### Option 1: Using Maven (Recommended) -A JBang script is provided in the example directory to make running the client easy: +Run the client using Maven's exec plugin: -1. Make sure you have JBang installed. If not, follow the [JBang installation guide](https://www.jbang.dev/documentation/guide/latest/installation.html). +```bash +cd examples/helloworld/client +mvn exec:java +``` -2. Navigate to the example directory: - ```bash - cd examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/ - ``` +#### Transport Protocol Selection + +The client supports multiple transport protocols. You can select which protocol to use via the `quarkus.agentcard.protocol` property: + +**Using JSONRPC (default)**: +```bash +mvn exec:java +``` + +**Using GRPC**: +```bash +mvn exec:java -Dquarkus.agentcard.protocol=GRPC +``` + +**Using HTTP+JSON**: +```bash +mvn exec:java -Dquarkus.agentcard.protocol=HTTP+JSON +``` + +Available protocols: +- `JSONRPC` - Uses JSON-RPC for communication (default) +- `GRPC` - Uses gRPC for communication +- `HTTP+JSON` - Uses HTTP with JSON payloads + +**Note**: The protocol you select on the client must match the protocol configured on the server. + +#### Enabling OpenTelemetry + +To enable OpenTelemetry with Maven: +```bash +mvn exec:java -Dopentelemetry=true +``` + +You can combine protocol selection with OpenTelemetry: +```bash +mvn exec:java -Dquarkus.agentcard.protocol=HTTP+JSON -Dopentelemetry=true +``` + +### Option 2: Using JBang + +A JBang script is provided for running the client without Maven: + +1. Make sure you have JBang installed. If not, follow the [JBang installation guide](https://www.jbang.dev/documentation/guide/latest/installation.html). 3. Run the client using the JBang script: ```bash - jbang HelloWorldRunner.java + jbang examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java ``` -This script automatically handles the dependencies and sources for you. +#### Transport Protocol Selection with JBang + +Select the transport protocol using the same `-Dquarkus.agentcard.protocol` property: + +**Using JSONRPC (default)**: +```bash +jbang examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java +``` + +**Using GRPC**: +```bash +jbang examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java -Dquarkus.agentcard.protocol=GRPC +``` + +**Using HTTP+JSON**: +```bash +jbang examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java -Dquarkus.agentcard.protocol=HTTP+JSON +``` + +#### Enabling OpenTelemetry with JBang + +To enable OpenTelemetry with JBang: +```bash +jbang examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java -Dopentelemetry=true +``` + +You can combine protocol selection with OpenTelemetry: +```bash +jbang examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java -Dquarkus.agentcard.protocol=GRPC -Dopentelemetry=true +``` ## What the Example Does The Java client (`HelloWorldClient.java`) performs the following actions: 1. Fetches the server's public agent card -2. Fetches the server's extended agent card +2. Fetches the server's extended agent card 3. Creates a client using the extended agent card that connects to the Python server at `http://localhost:9999`. 4. Sends a regular message asking "how much is 10 USD in INR?". 5. Prints the server's response. 6. Sends the same message as a streaming request. 7. Prints each chunk of the server's streaming response as it arrives. +## Enable OpenTelemetry (Optional) + +The client includes support for distributed tracing with OpenTelemetry. To enable it: + +### Prerequisites + +**IMPORTANT**: The client expects an OpenTelemetry collector to be ready and accepting traces. You have two options: + +#### Option 1: Use the Java Server Example (Recommended) + +Instead of the Python server, use the Java server example which has built-in OpenTelemetry support: + +1. **Start the Java server with OpenTelemetry enabled**: + ```bash + mvn quarkus:dev -Popentelemetry -pl examples/helloworld/server/ -Dquarkus.agentcard.protocol=HTTP+JSON + ``` + This will: + - Start the server at `http://localhost:9999` + - Launch Grafana at `http://localhost:3001` + - Start OTLP collectors on ports 5317 (gRPC) and 5318 (HTTP) + +2. **Run the client with OpenTelemetry**: + + Using Maven (from `examples/helloworld/client`): + ```bash + mvn exec:java -Dopentelemetry=true + ``` + + With specific protocol: + ```bash + mvn exec:java -Dquarkus.agentcard.protocol=HTTP+JSON -Dopentelemetry=true + ``` + + Or using JBang: + ```bash + jbang examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java -Dopentelemetry=true + ``` + + With specific protocol: + ```bash + jbang examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java -Dquarkus.agentcard.protocol=HTTP+JSON -Dopentelemetry=true + ``` + +3. **View traces in Grafana**: + - Open `http://localhost:3001` (credentials: admin/admin) + - Go to "Explore" → select "Tempo" data source + - View distributed traces showing the full request flow from client to server + +#### Option 2: Use External OpenTelemetry Collector + +If you want to use the Python server with OpenTelemetry: + +1. **Start an OpenTelemetry collector** on port 5317 (e.g., using Docker): + ```bash + docker run -p 5317:4317 otel/opentelemetry-collector + ``` + +2. **Run the Python server** + +3. **Run the client with OpenTelemetry**: + ```bash + mvn exec:java -Dopentelemetry=true + ``` + + Or with JBang: + ```bash + jbang examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java -Dopentelemetry=true + ``` + + With specific protocol: + ```bash + mvn exec:java -Dquarkus.agentcard.protocol=HTTP+JSON -Dopentelemetry=true + ``` + +### What Gets Traced + +When OpenTelemetry is enabled, the client traces: +- Agent card fetching (public and extended) +- Message sending (blocking and streaming) +- Task operations (get, cancel, list) +- Push notification configuration operations +- Connection and transport layer operations + +Client traces are automatically linked with server traces (when using the Java server), providing end-to-end visibility of the entire A2A protocol flow. + +### Configuration + +The client is configured to send traces to `http://localhost:5317` (OTLP gRPC endpoint). To use a different endpoint, modify the `initOpenTelemetry()` method in `HelloWorldClient.java`: + +```java +OtlpGrpcSpanExporter.builder() + .setEndpoint("http://your-collector:4317") + .build() +``` + ## Notes - Make sure the Python server is running before starting the Java client. - The client will wait for 10 seconds to collect streaming responses before exiting. -- You can modify the message text or server URL in the `HelloWorldClient.java` file if needed. \ No newline at end of file +- You can modify the message text or server URL in the `HelloWorldClient.java` file if needed. diff --git a/examples/helloworld/client/pom.xml b/examples/helloworld/client/pom.xml index a9e0cf0d4..7f18c0ba8 100644 --- a/examples/helloworld/client/pom.xml +++ b/examples/helloworld/client/pom.xml @@ -12,7 +12,7 @@ a2a-java-sdk-examples-client - Java SDK A2A Examples + Java SDK A2A Examples - HelloWorld Client Examples for the Java SDK for the Agent2Agent Protocol (A2A) @@ -27,7 +27,34 @@ io.github.a2asdk a2a-java-sdk-jsonrpc-common - ${project.version} + + + io.github.a2asdk + a2a-java-sdk-client-transport-grpc + + + io.github.a2asdk + a2a-java-sdk-client-transport-rest + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-client + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-client-propagation + + + io.opentelemetry + opentelemetry-sdk + + + io.opentelemetry + opentelemetry-exporter-otlp + + + io.opentelemetry + opentelemetry-exporter-logging @@ -61,6 +88,20 @@ + + org.codehaus.mojo + exec-maven-plugin + 3.6.2 + + io.a2a.examples.helloworld.HelloWorldClient + + + opentelemetry + ${opentelemetry} + + + + \ No newline at end of file diff --git a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java index 5b8a6d196..4368fb6ea 100644 --- a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java +++ b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java @@ -1,5 +1,8 @@ package io.a2a.examples.helloworld; +import static io.a2a.extras.opentelemetry.client.OpenTelemetryClientTransportWrapper.OTEL_TRACER_KEY; +import static io.a2a.extras.opentelemetry.client.propagation.OpenTelemetryClientPropagatorTransportWapper.OTEL_OPEN_TELEMETRY_KEY; + import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -11,16 +14,32 @@ import io.a2a.A2A; import io.a2a.client.Client; +import io.a2a.client.ClientBuilder; import io.a2a.client.ClientEvent; import io.a2a.client.MessageEvent; import io.a2a.client.http.A2ACardResolver; +import io.a2a.client.transport.grpc.GrpcTransport; +import io.a2a.client.transport.grpc.GrpcTransportConfigBuilder; import io.a2a.client.transport.jsonrpc.JSONRPCTransport; import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig; +import io.a2a.client.transport.rest.RestTransport; +import io.a2a.client.transport.rest.RestTransportConfig; +import io.a2a.client.transport.spi.ClientTransportConfig; import io.a2a.jsonrpc.common.json.JsonUtil; import io.a2a.spec.AgentCard; import io.a2a.spec.Message; import io.a2a.spec.Part; import io.a2a.spec.TextPart; +import io.grpc.Channel; +import io.grpc.ManagedChannelBuilder; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import java.util.function.Function; /** * A simple example of using the A2A Java SDK to communicate with an A2A server. @@ -32,13 +51,13 @@ public class HelloWorldClient { private static final String MESSAGE_TEXT = "how much is 10 USD in INR?"; public static void main(String[] args) { + OpenTelemetrySdk openTelemetrySdk = null; try { - AgentCard finalAgentCard = null; AgentCard publicAgentCard = new A2ACardResolver("http://localhost:9999").getAgentCard(); System.out.println("Successfully fetched public agent card:"); System.out.println(JsonUtil.toJson(publicAgentCard)); System.out.println("Using public agent card for client initialization (default)."); - finalAgentCard = publicAgentCard; + AgentCard finalAgentCard = publicAgentCard; if (publicAgentCard.capabilities().extendedAgentCard()) { System.out.println("Public card supports authenticated extended card. Attempting to fetch from: " + SERVER_URL + "/ExtendedAgentCard"); @@ -81,20 +100,23 @@ public static void main(String[] args) { messageResponse.completeExceptionally(error); }; - Client client = Client + if (Boolean.getBoolean("opentelemetry")) { + openTelemetrySdk = initOpenTelemetry(); + } + + ClientBuilder clientBuilder = Client .builder(finalAgentCard) .addConsumers(consumers) - .streamingErrorHandler(streamingErrorHandler) - .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfig()) - .build(); + .streamingErrorHandler(streamingErrorHandler); + configureTransport(clientBuilder, openTelemetrySdk); + Client client = clientBuilder.build(); Message message = A2A.toUserMessage(MESSAGE_TEXT); // the message ID will be automatically generated for you - - System.out.println("Sending message: " + MESSAGE_TEXT); - client.sendMessage(message); - System.out.println("Message sent successfully. Responses will be handled by the configured consumers."); - try { + System.out.println("Sending message: " + MESSAGE_TEXT); + client.sendMessage(message); + System.out.println("Message sent successfully. Responses will be handled by the configured consumers."); + String responseText = messageResponse.get(); System.out.println("Response: " + responseText); } catch (Exception e) { @@ -103,7 +125,69 @@ public static void main(String[] args) { } catch (Exception e) { System.err.println("An error occurred: " + e.getMessage()); e.printStackTrace(); + } finally { + // Ensure OpenTelemetry SDK is properly shut down to export all pending spans + if (openTelemetrySdk != null) { + System.out.println("Shutting down OpenTelemetry SDK..."); + openTelemetrySdk.close(); + System.out.println("OpenTelemetry SDK shutdown complete."); + } } } -} \ No newline at end of file + static OpenTelemetrySdk initOpenTelemetry() { + SdkTracerProvider sdkTracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(BatchSpanProcessor.builder( + OtlpGrpcSpanExporter.builder() + .setEndpoint("http://localhost:5317") + .build() + ).build()) + .setResource(Resource.getDefault().toBuilder() + .put("service.version", "1.0") + .put("service.name", "helloworld-client") + .build()) + .build(); + + return OpenTelemetrySdk.builder() + .setTracerProvider(sdkTracerProvider) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build(); + } + private static void configureTransport(ClientBuilder clientBuilder, OpenTelemetrySdk openTelemetrySdk) { + ClientTransportConfig transportConfig; + switch(System.getProperty("quarkus.agentcard.protocol", "JSONRPC")) { + case "GRPC": + Function channelFactory = url -> { + // Extract "localhost:9999" from "http://localhost:9999" + String target = url.replaceAll("^https?://", ""); + return ManagedChannelBuilder.forTarget(target) + .usePlaintext() // No TLS + .build(); + }; + transportConfig = new GrpcTransportConfigBuilder().channelFactory(channelFactory).build(); + updateTransportConfig(transportConfig, openTelemetrySdk); + clientBuilder.withTransport(GrpcTransport.class, transportConfig); + break; + case "HTTP+JSON": + transportConfig = new RestTransportConfig(); + updateTransportConfig(transportConfig, openTelemetrySdk); + clientBuilder.withTransport(RestTransport.class, transportConfig); + break; + case "JSONRPC": + default: + transportConfig = new JSONRPCTransportConfig(); + updateTransportConfig(transportConfig, openTelemetrySdk); + clientBuilder.withTransport(JSONRPCTransport.class, transportConfig); + break; + } + } + + private static void updateTransportConfig(ClientTransportConfig transportConfig, OpenTelemetrySdk openTelemetrySdk) { + if (openTelemetrySdk != null) { + Map parameters = new HashMap<>(transportConfig.getParameters()); + parameters.put(OTEL_TRACER_KEY, openTelemetrySdk.getTracer("helloworld-client")); + parameters.put(OTEL_OPEN_TELEMETRY_KEY, openTelemetrySdk); + transportConfig.setParameters(parameters); + } + } +} diff --git a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java index 138f7e4f7..0cd652283 100644 --- a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java +++ b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java @@ -1,6 +1,16 @@ + ///usr/bin/env jbang "$0" "$@" ; exit $? + //DEPS io.github.a2asdk:a2a-java-sdk-client:1.0.0.Alpha2-SNAPSHOT //DEPS io.github.a2asdk:a2a-java-sdk-client-transport-jsonrpc:1.0.0.Alpha2-SNAPSHOT +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-grpc:1.0.0.Alpha2-SNAPSHOT +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-rest:1.0.0.Alpha2-SNAPSHOT +//DEPS io.github.a2asdk:a2a-java-sdk-opentelemetry-client:1.0.0.Alpha2-SNAPSHOT +//DEPS io.github.a2asdk:a2a-java-sdk-opentelemetry-client-propagation:1.0.0.Alpha2-SNAPSHOT +//DEPS io.opentelemetry:opentelemetry-sdk:1.55.0 +//DEPS io.opentelemetry:opentelemetry-exporter-otlp:1.55.0 +//DEPS io.opentelemetry:opentelemetry-exporter-logging:1.55.0 +//DEPS io.grpc:grpc-netty:1.77.0 //SOURCES HelloWorldClient.java /** @@ -17,7 +27,16 @@ * The script will communicate with the A2A server at http://localhost:9999 */ public class HelloWorldRunner { + public static void main(String[] args) { + for (String arg : args) { + if (arg != null && arg.startsWith("-D")) { + int index = arg.indexOf('='); + if (index > 0) { + System.setProperty(arg.substring(2, index), arg.substring(index + 1)); + } + } + } io.a2a.examples.helloworld.HelloWorldClient.main(args); } -} +} diff --git a/examples/helloworld/pom.xml b/examples/helloworld/pom.xml index 589e9c30d..e48e2fdad 100644 --- a/examples/helloworld/pom.xml +++ b/examples/helloworld/pom.xml @@ -29,30 +29,10 @@ io.github.a2asdk a2a-java-sdk-client - ${project.version} - - - - io.quarkus - quarkus-maven-plugin - true - - - - build - generate-code - generate-code-tests - - - - - - - client server diff --git a/examples/helloworld/server/README.md b/examples/helloworld/server/README.md index 5573dce09..66f39f7ec 100644 --- a/examples/helloworld/server/README.md +++ b/examples/helloworld/server/README.md @@ -18,6 +18,35 @@ cd examples/helloworld/server mvn quarkus:dev ``` +### Transport Protocol Selection + +The server supports multiple transport protocols. You can select which protocol to use via the `quarkus.agentcard.protocol` property: + +**Using JSONRPC (default)**: +```bash +mvn quarkus:dev +``` + +**Using GRPC**: +```bash +mvn quarkus:dev -Dquarkus.agentcard.protocol=GRPC +``` + +**Using HTTP+JSON**: +```bash +mvn quarkus:dev -Dquarkus.agentcard.protocol=HTTP+JSON +``` + +You can also change the default protocol by editing `src/main/resources/application.properties` and setting: +```properties +quarkus.agentcard.protocol=HTTP+JSON +``` + +Available protocols: +- `JSONRPC` - Uses JSON-RPC for communication (default) +- `GRPC` - Uses gRPC for communication +- `HTTP+JSON` - Uses HTTP with JSON payloads + ## Setup and Run the Python A2A Client The Python A2A client is part of the [a2a-samples](https://github.com/google-a2a/a2a-samples) project. To set it up and run it: @@ -61,6 +90,36 @@ The Python A2A client (`test_client.py`) performs the following actions: 6. Sends the same message as a streaming request. 7. Prints each chunk of the server's streaming response as it arrives. +## Enable OpenTelemetry (Optional) + +The server includes support for distributed tracing with OpenTelemetry. To enable it: + +1. **Run with the OpenTelemetry profile**: + ```bash + mvn quarkus:dev -Popentelemetry + ``` + +2. **Access Grafana dashboard**: + - Quarkus Dev Services will automatically start a Grafana observability stack + - Open Grafana at `http://localhost:3001` (default credentials: admin/admin) + - View traces in the "Explore" section using the Tempo data source + +3. **What gets traced**: + - All A2A protocol operations (send message, get task, cancel task, etc.) + - Streaming message responses + - Task lifecycle events + - Custom operations in your `AgentExecutor` implementation (using `@Trace` annotation) + +4. **Configuration**: + - OpenTelemetry settings are in `application.properties` + - OTLP exporters run on ports 5317 (gRPC) and 5318 (HTTP) + - To use a custom OTLP endpoint, uncomment and modify: + ```properties + quarkus.otel.exporter.otlp.endpoint=http://localhost:4317 + ``` + +For more information, see the [OpenTelemetry extras module documentation](../../../extras/opentelemetry/README.md). + ## Notes - Make sure the Java server is running before starting the Python client. diff --git a/examples/helloworld/server/pom.xml b/examples/helloworld/server/pom.xml index 699adf4d4..aea025993 100644 --- a/examples/helloworld/server/pom.xml +++ b/examples/helloworld/server/pom.xml @@ -12,7 +12,7 @@ a2a-java-sdk-examples-server - Java SDK A2A Examples + Java SDK A2A Examples - HelloWorld Server Examples for the Java SDK for the Agent2Agent Protocol (A2A) @@ -29,6 +29,14 @@ quarkus-resteasy provided + + io.github.a2asdk + a2a-java-sdk-reference-grpc + + + io.github.a2asdk + a2a-java-sdk-reference-rest + jakarta.enterprise jakarta.enterprise.cdi-api @@ -55,7 +63,31 @@ + + --add-opens=java.base/java.lang=ALL-UNNAMED + + + + + opentelemetry + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-server + + + io.quarkus + quarkus-opentelemetry + + + io.quarkus + quarkus-observability-devservices-lgtm + provided + + + + \ No newline at end of file diff --git a/examples/helloworld/server/src/main/java/io/a2a/examples/helloworld/AgentCardProducer.java b/examples/helloworld/server/src/main/java/io/a2a/examples/helloworld/AgentCardProducer.java index 165b1574e..36e982217 100644 --- a/examples/helloworld/server/src/main/java/io/a2a/examples/helloworld/AgentCardProducer.java +++ b/examples/helloworld/server/src/main/java/io/a2a/examples/helloworld/AgentCardProducer.java @@ -13,20 +13,24 @@ import io.a2a.spec.AgentCard; import io.a2a.spec.AgentInterface; import io.a2a.spec.AgentSkill; +import org.eclipse.microprofile.config.inject.ConfigProperty; @ApplicationScoped public class AgentCardProducer { + @ConfigProperty(name = "quarkus.agentcard.protocol", defaultValue="JSONRPC") + String protocol; + @Produces @PublicAgentCard public AgentCard agentCard() { // NOTE: Transport validation will automatically check that transports specified // in this AgentCard match those available on the classpath when handlers are initialized + return AgentCard.builder() .name("Hello World Agent") .description("Just a hello world agent") - .supportedInterfaces(Collections.singletonList( - new AgentInterface("jsonrpc", "http://localhost:9999"))) + .supportedInterfaces(Collections.singletonList(getAgentInterface())) .version("1.0.0") .documentationUrl("http://example.com/docs") .capabilities(AgentCapabilities.builder() @@ -45,5 +49,16 @@ public AgentCard agentCard() { .protocolVersions(CURRENT_PROTOCOL_VERSION) .build(); } -} + private AgentInterface getAgentInterface() { + switch(protocol) { + case "GRPC": + return new AgentInterface("GRPC", "localhost:9000"); + case "HTTP+JSON": + return new AgentInterface("HTTP+JSON", "http://localhost:9999"); + case "JSONRPC": + default: + return new AgentInterface("JSONRPC", "http://localhost:9999"); + } + } +} diff --git a/examples/helloworld/server/src/main/resources/application.properties b/examples/helloworld/server/src/main/resources/application.properties index a2452b339..eb8aee9ba 100644 --- a/examples/helloworld/server/src/main/resources/application.properties +++ b/examples/helloworld/server/src/main/resources/application.properties @@ -1 +1,12 @@ -%dev.quarkus.http.port=9999 \ No newline at end of file +%dev.quarkus.http.port=9999 + +# Protocol can be JSONRPC, GRPC, or HTTP+JSON +quarkus.agentcard.protocol=JSONRPC + +# OpenTelemetry configuration +quarkus.otel.sdk.disabled=false +quarkus.observability.lgtm.grafana-port=3001 +quarkus.observability.lgtm.otel-grpc-port=5317 +quarkus.observability.lgtm.otel-http-port=5318 +#quarkus.otel.exporter.otlp.endpoint=http://localhost:4317 +#quarkus.log.console.format=%d{HH:mm:ss} %-5p traceId=%X{traceId}, parentId=%X{parentId}, spanId=%X{spanId}, sampled=%X{sampled} [%c{2.}] (%t) %s%e%n \ No newline at end of file diff --git a/extras/opentelemetry/client-propagation/pom.xml b/extras/opentelemetry/client-propagation/pom.xml new file mode 100644 index 000000000..c3eac38f2 --- /dev/null +++ b/extras/opentelemetry/client-propagation/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-parent + 1.0.0.Alpha2-SNAPSHOT + + + a2a-java-sdk-opentelemetry-client-propagation + + A2A Java SDK :: Extras :: Opentelemetry :: Client Propagation + OpenTelemetry client propagation support for A2A Java SDK + + + + ${project.groupId} + a2a-java-sdk-client-transport-spi + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + + diff --git a/extras/opentelemetry/client-propagation/src/main/java/io/a2a/extras/opentelemetry/client/propagation/OpenTelemetryClientPropagatorTransport.java b/extras/opentelemetry/client-propagation/src/main/java/io/a2a/extras/opentelemetry/client/propagation/OpenTelemetryClientPropagatorTransport.java new file mode 100644 index 000000000..1fb6628f3 --- /dev/null +++ b/extras/opentelemetry/client-propagation/src/main/java/io/a2a/extras/opentelemetry/client/propagation/OpenTelemetryClientPropagatorTransport.java @@ -0,0 +1,123 @@ +package io.a2a.extras.opentelemetry.client.propagation; + +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.client.transport.spi.interceptors.ClientCallContext; +import io.a2a.jsonrpc.common.wrappers.ListTasksResult; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.AgentCard; +import io.a2a.spec.DeleteTaskPushNotificationConfigParams; +import io.a2a.spec.EventKind; +import io.a2a.spec.GetTaskPushNotificationConfigParams; +import io.a2a.spec.ListTaskPushNotificationConfigParams; +import io.a2a.spec.ListTaskPushNotificationConfigResult; +import io.a2a.spec.ListTasksParams; +import io.a2a.spec.MessageSendParams; +import io.a2a.spec.StreamingEventKind; +import io.a2a.spec.Task; +import io.a2a.spec.TaskIdParams; +import io.a2a.spec.TaskPushNotificationConfig; +import io.a2a.spec.TaskQueryParams; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapSetter; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import org.jspecify.annotations.Nullable; + +public class OpenTelemetryClientPropagatorTransport implements ClientTransport { + + private final OpenTelemetry openTelemetry; + private final ClientTransport delegate; + + private static final TextMapSetter> MAP_SETTER = new TextMapSetter>() { + @Override + public void set(@Nullable Map carrier, String key, String value) { + if (carrier != null) { + carrier.put(key, value); + } + } + }; + + public OpenTelemetryClientPropagatorTransport(ClientTransport delegate, OpenTelemetry openTelemetry) { + this.delegate = delegate; + this.openTelemetry = openTelemetry; + } + + private ClientCallContext propagateContext(@Nullable ClientCallContext context) { + ClientCallContext clientContext; + if (context == null) { + clientContext = new ClientCallContext(Map.of(), new HashMap<>()); + } else { + clientContext = new ClientCallContext(context.getState(), new HashMap<>(context.getHeaders())); + } + openTelemetry.getPropagators().getTextMapPropagator().inject(Context.current(), clientContext.getHeaders(), MAP_SETTER); + return clientContext; + } + + @Override + public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallContext context) throws A2AClientException { + return delegate.sendMessage(request, propagateContext(context)); + } + + @Override + public void sendMessageStreaming(MessageSendParams request, Consumer eventConsumer, + Consumer errorConsumer, @Nullable ClientCallContext context) throws A2AClientException { + delegate.sendMessageStreaming(request, eventConsumer, errorConsumer, propagateContext(context)); + } + + @Override + public Task getTask(TaskQueryParams request, @Nullable ClientCallContext context) throws A2AClientException { + return delegate.getTask(request, propagateContext(context)); + } + + @Override + public Task cancelTask(TaskIdParams request, @Nullable ClientCallContext context) throws A2AClientException { + return delegate.cancelTask(request, propagateContext(context)); + } + + @Override + public ListTasksResult listTasks(ListTasksParams request, @Nullable ClientCallContext context) throws A2AClientException { + return delegate.listTasks(request, propagateContext(context)); + } + + @Override + public TaskPushNotificationConfig createTaskPushNotificationConfiguration(TaskPushNotificationConfig request, + @Nullable ClientCallContext context) throws A2AClientException { + return delegate.createTaskPushNotificationConfiguration(request, propagateContext(context)); + } + + @Override + public TaskPushNotificationConfig getTaskPushNotificationConfiguration(GetTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) throws A2AClientException { + return delegate.getTaskPushNotificationConfiguration(request, propagateContext(context)); + } + + @Override + public ListTaskPushNotificationConfigResult listTaskPushNotificationConfigurations(ListTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) throws A2AClientException { + return delegate.listTaskPushNotificationConfigurations(request, propagateContext(context)); + } + + @Override + public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) throws A2AClientException { + delegate.deleteTaskPushNotificationConfigurations(request, propagateContext(context)); + } + + @Override + public void resubscribe(TaskIdParams request, Consumer eventConsumer, + Consumer errorConsumer, @Nullable ClientCallContext context) throws A2AClientException { + delegate.resubscribe(request, eventConsumer, errorConsumer, propagateContext(context)); + } + + @Override + public AgentCard getExtendedAgentCard(@Nullable ClientCallContext context) throws A2AClientException { + return delegate.getExtendedAgentCard(propagateContext(context)); + } + + @Override + public void close() { + delegate.close(); + } +} diff --git a/extras/opentelemetry/client-propagation/src/main/java/io/a2a/extras/opentelemetry/client/propagation/OpenTelemetryClientPropagatorTransportWapper.java b/extras/opentelemetry/client-propagation/src/main/java/io/a2a/extras/opentelemetry/client/propagation/OpenTelemetryClientPropagatorTransportWapper.java new file mode 100644 index 000000000..572e03104 --- /dev/null +++ b/extras/opentelemetry/client-propagation/src/main/java/io/a2a/extras/opentelemetry/client/propagation/OpenTelemetryClientPropagatorTransportWapper.java @@ -0,0 +1,49 @@ +package io.a2a.extras.opentelemetry.client.propagation; + +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.client.transport.spi.ClientTransportConfig; +import io.a2a.client.transport.spi.ClientTransportWrapper; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Tracer; + +/** + * OpenTelemetry client transport wrapper that adds opentelemtry propagation to A2A client calls. + * + *

This wrapper is automatically discovered via Java's ServiceLoader mechanism. + * To enable tracing, add a {@link Tracer} instance to the transport configuration: + *

{@code
+ * ClientTransportConfig config = new JSONRPCTransportConfig();
+ * config.setParameters(Map.of(
+ *     OpenTelemetryClientTransportFactory.OTEL_TRACER_KEY,
+ *     openTelemetry.getTracer("my-service"),
+ *     OpenTelemetryClientTransportFactory.OTEL_OPEN_TELEMETRY_KEY,
+ *     openTelemetry
+ * ));
+ * }
+ */ +public class OpenTelemetryClientPropagatorTransportWapper implements ClientTransportWrapper { + + /** + * Configuration key for the OpenTelemetry Tracer instance. + * Value must be of type {@link Tracer}. + */ + public static final String OTEL_TRACER_KEY = "io.a2a.extras.opentelemetry.Tracer"; + public static final String OTEL_OPEN_TELEMETRY_KEY = "io.a2a.extras.opentelemetry.OpenTelemetry"; + + @Override + public ClientTransport wrap(ClientTransport transport, ClientTransportConfig config) { + Object openTelemetryObj = config.getParameters().get(OTEL_OPEN_TELEMETRY_KEY); + if (openTelemetryObj != null && openTelemetryObj instanceof OpenTelemetry openTelemetry) { + return new OpenTelemetryClientPropagatorTransport(transport, openTelemetry); + } + // No tracer configured, return unwrapped transport + return transport; + } + + @Override + public int priority() { + // Observability/tracing should be in the middle priority range + // so it can observe other wrappers but doesn't interfere with security + return 500; + } +} diff --git a/extras/opentelemetry/client-propagation/src/main/java/io/a2a/extras/opentelemetry/client/propagation/package-info.java b/extras/opentelemetry/client-propagation/src/main/java/io/a2a/extras/opentelemetry/client/propagation/package-info.java new file mode 100644 index 000000000..0ca927df9 --- /dev/null +++ b/extras/opentelemetry/client-propagation/src/main/java/io/a2a/extras/opentelemetry/client/propagation/package-info.java @@ -0,0 +1,5 @@ +@NullMarked +package io.a2a.extras.opentelemetry.client.propagation; + +import org.jspecify.annotations.NullMarked; + diff --git a/extras/opentelemetry/client-propagation/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportWrapper b/extras/opentelemetry/client-propagation/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportWrapper new file mode 100644 index 000000000..66627c2b2 --- /dev/null +++ b/extras/opentelemetry/client-propagation/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportWrapper @@ -0,0 +1 @@ +io.a2a.extras.opentelemetry.client.propagation.OpenTelemetryClientPropagatorTransportWapper diff --git a/extras/opentelemetry/client/pom.xml b/extras/opentelemetry/client/pom.xml new file mode 100644 index 000000000..daf97421c --- /dev/null +++ b/extras/opentelemetry/client/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-parent + 1.0.0.Alpha2-SNAPSHOT + + + a2a-java-sdk-opentelemetry-client + + A2A Java SDK :: Extras :: Opentelemetry :: Client + OpenTelemetry client support for A2A Java SDK + + + + ${project.groupId} + a2a-java-sdk-opentelemetry-common + + + ${project.groupId} + a2a-java-sdk-client-transport-spi + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + + diff --git a/extras/opentelemetry/client/src/main/java/io/a2a/extras/opentelemetry/client/OpenTelemetryClientTransport.java b/extras/opentelemetry/client/src/main/java/io/a2a/extras/opentelemetry/client/OpenTelemetryClientTransport.java new file mode 100644 index 000000000..cdd4d9f31 --- /dev/null +++ b/extras/opentelemetry/client/src/main/java/io/a2a/extras/opentelemetry/client/OpenTelemetryClientTransport.java @@ -0,0 +1,491 @@ +package io.a2a.extras.opentelemetry.client; + +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.EXTRACT_REQUEST_SYS_PROPERTY; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.EXTRACT_RESPONSE_SYS_PROPERTY; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_CONFIG_ID; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_CONTEXT_ID; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_EXTENSIONS; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_MESSAGE_ID; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_OPERATION_NAME; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_PARTS_NUMBER; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_REQUEST; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_RESPONSE; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_ROLE; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_TASK_ID; + +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.client.transport.spi.interceptors.ClientCallContext; +import io.a2a.jsonrpc.common.wrappers.ListTasksResult; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.A2AMethods; +import io.a2a.spec.AgentCard; +import io.a2a.spec.DeleteTaskPushNotificationConfigParams; +import io.a2a.spec.EventKind; +import io.a2a.spec.GetTaskPushNotificationConfigParams; +import io.a2a.spec.ListTaskPushNotificationConfigParams; +import io.a2a.spec.ListTaskPushNotificationConfigResult; +import io.a2a.spec.ListTasksParams; +import io.a2a.spec.MessageSendParams; +import io.a2a.spec.StreamingEventKind; +import io.a2a.spec.Task; +import io.a2a.spec.TaskIdParams; +import io.a2a.spec.TaskPushNotificationConfig; +import io.a2a.spec.TaskQueryParams; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.jspecify.annotations.Nullable; + +public class OpenTelemetryClientTransport implements ClientTransport { + + private final Tracer tracer; + private final ClientTransport delegate; + + public OpenTelemetryClientTransport(ClientTransport delegate, Tracer tracer) { + this.delegate = delegate; + this.tracer = tracer; + } + + private boolean extractRequest() { + return Boolean.getBoolean(EXTRACT_REQUEST_SYS_PROPERTY); + } + + private boolean extractResponse() { + return Boolean.getBoolean(EXTRACT_RESPONSE_SYS_PROPERTY); + } + + @Override + public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallContext context) throws A2AClientException { + ClientCallContext clientContext = createContext(context); + SpanBuilder spanBuilder = tracer.spanBuilder(A2AMethods.SEND_MESSAGE_METHOD).setSpanKind(SpanKind.CLIENT); + spanBuilder.setAttribute(GENAI_OPERATION_NAME, A2AMethods.SEND_MESSAGE_METHOD); + if (request.message() != null) { + if (request.message().taskId() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, request.message().taskId()); + } + if (request.message().contextId() != null) { + spanBuilder.setAttribute(GENAI_CONTEXT_ID, request.message().contextId()); + } + if (request.message().messageId() != null) { + spanBuilder.setAttribute(GENAI_MESSAGE_ID, request.message().messageId()); + } + if (request.message().role() != null) { + spanBuilder.setAttribute(GENAI_ROLE, request.message().role().asString()); + } + if (request.message().extensions() != null && !request.message().extensions().isEmpty()) { + spanBuilder.setAttribute(GENAI_EXTENSIONS, String.join(",", request.message().extensions())); + } + spanBuilder.setAttribute(GENAI_PARTS_NUMBER, request.message().parts().size()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, request.toString()); + } + Span span = spanBuilder.startSpan(); + try (Scope scope = span.makeCurrent()) { + EventKind result = delegate.sendMessage(request, clientContext); + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + if (result != null) { + span.setStatus(StatusCode.OK); + } + return result; + } catch (Exception ex) { + span.setStatus(StatusCode.ERROR, ex.getMessage()); + throw ex; + } finally { + span.end(); + } + } + + @Override + public void sendMessageStreaming(MessageSendParams request, Consumer eventConsumer, + Consumer errorConsumer, @Nullable ClientCallContext context) throws A2AClientException { + ClientCallContext clientContext = createContext(context); + SpanBuilder spanBuilder = tracer.spanBuilder(A2AMethods.SEND_STREAMING_MESSAGE_METHOD).setSpanKind(SpanKind.CLIENT); + spanBuilder.setAttribute(GENAI_OPERATION_NAME, A2AMethods.SEND_STREAMING_MESSAGE_METHOD); + if (request.message() != null) { + if (request.message().taskId() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, request.message().taskId()); + } + if (request.message().contextId() != null) { + spanBuilder.setAttribute(GENAI_CONTEXT_ID, request.message().contextId()); + } + if (request.message().messageId() != null) { + spanBuilder.setAttribute(GENAI_MESSAGE_ID, request.message().messageId()); + } + if (request.message().role() != null) { + spanBuilder.setAttribute(GENAI_ROLE, request.message().role().asString()); + } + if (request.message().extensions() != null && !request.message().extensions().isEmpty()) { + spanBuilder.setAttribute(GENAI_EXTENSIONS, String.join(",", request.message().extensions())); + } + spanBuilder.setAttribute(GENAI_PARTS_NUMBER, request.message().parts().size()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, request.toString()); + } + Span span = spanBuilder.startSpan(); + try (Scope scope = span.makeCurrent()) { + delegate.sendMessageStreaming( + request, + new OpenTelemetryEventConsumer(A2AMethods.SEND_STREAMING_MESSAGE_METHOD + "-event", eventConsumer, tracer, span.getSpanContext()), + new OpenTelemetryErrorConsumer(A2AMethods.SEND_STREAMING_MESSAGE_METHOD + "-error", errorConsumer, tracer, span.getSpanContext()), + clientContext + ); + span.setStatus(StatusCode.OK); + } catch (Exception ex) { + span.setStatus(StatusCode.ERROR, ex.getMessage()); + throw ex; + } finally { + span.end(); + } + } + + @Override + public Task getTask(TaskQueryParams request, @Nullable ClientCallContext context) throws A2AClientException { + ClientCallContext clientContext = createContext(context); + SpanBuilder spanBuilder = tracer.spanBuilder(A2AMethods.GET_TASK_METHOD).setSpanKind(SpanKind.CLIENT); + spanBuilder.setAttribute(GENAI_OPERATION_NAME, A2AMethods.GET_TASK_METHOD); + if (request.id() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, request.id()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, request.toString()); + } + Span span = spanBuilder.startSpan(); + try (Scope scope = span.makeCurrent()) { + Task result = delegate.getTask(request, clientContext); + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + if (result != null) { + span.setStatus(StatusCode.OK); + } + return result; + } catch (Exception ex) { + span.setStatus(StatusCode.ERROR, ex.getMessage()); + throw ex; + } finally { + span.end(); + } + } + + @Override + public Task cancelTask(TaskIdParams request, @Nullable ClientCallContext context) throws A2AClientException { + ClientCallContext clientContext = createContext(context); + SpanBuilder spanBuilder = tracer.spanBuilder(A2AMethods.CANCEL_TASK_METHOD).setSpanKind(SpanKind.CLIENT); + spanBuilder.setAttribute(GENAI_OPERATION_NAME, A2AMethods.CANCEL_TASK_METHOD); + if (request.id() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, request.id()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, request.toString()); + } + Span span = spanBuilder.startSpan(); + try (Scope scope = span.makeCurrent()) { + Task result = delegate.cancelTask(request, clientContext); + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + if (result != null) { + span.setStatus(StatusCode.OK); + } + return result; + } catch (Exception ex) { + span.setStatus(StatusCode.ERROR, ex.getMessage()); + throw ex; + } finally { + span.end(); + } + } + + @Override + public ListTasksResult listTasks(ListTasksParams request, @Nullable ClientCallContext context) throws A2AClientException { + ClientCallContext clientContext = createContext(context); + SpanBuilder spanBuilder = tracer.spanBuilder(A2AMethods.LIST_TASK_METHOD).setSpanKind(SpanKind.CLIENT); + spanBuilder.setAttribute(GENAI_OPERATION_NAME, A2AMethods.LIST_TASK_METHOD); + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, request.toString()); + } + if (request.contextId() != null) { + spanBuilder.setAttribute(GENAI_CONTEXT_ID, request.contextId()); + } + Span span = spanBuilder.startSpan(); + try (Scope scope = span.makeCurrent()) { + ListTasksResult result = delegate.listTasks(request, clientContext); + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + if (result != null) { + span.setStatus(StatusCode.OK); + } + return result; + } catch (Exception ex) { + span.setStatus(StatusCode.ERROR, ex.getMessage()); + throw ex; + } finally { + span.end(); + } + } + + @Override + public TaskPushNotificationConfig createTaskPushNotificationConfiguration(TaskPushNotificationConfig request, + @Nullable ClientCallContext context) throws A2AClientException { + ClientCallContext clientContext = createContext(context); + SpanBuilder spanBuilder = tracer.spanBuilder(A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD).setSpanKind(SpanKind.CLIENT); + spanBuilder.setAttribute(GENAI_OPERATION_NAME, A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + if (request.taskId() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, request.taskId()); + } + if (request.pushNotificationConfig() != null && request.pushNotificationConfig().id() != null) { + spanBuilder.setAttribute(GENAI_CONFIG_ID, request.pushNotificationConfig().id()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, request.toString()); + } + Span span = spanBuilder.startSpan(); + try (Scope scope = span.makeCurrent()) { + TaskPushNotificationConfig result = delegate.createTaskPushNotificationConfiguration(request, clientContext); + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + if (result != null) { + span.setStatus(StatusCode.OK); + } + return result; + } catch (Exception ex) { + span.setStatus(StatusCode.ERROR, ex.getMessage()); + throw ex; + } finally { + span.end(); + } + } + + @Override + public TaskPushNotificationConfig getTaskPushNotificationConfiguration(GetTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) throws A2AClientException { + ClientCallContext clientContext = createContext(context); + SpanBuilder spanBuilder = tracer.spanBuilder(A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD).setSpanKind(SpanKind.CLIENT); + spanBuilder.setAttribute(GENAI_OPERATION_NAME, A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + if (request.id() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, request.id()); + } + if (request.pushNotificationConfigId() != null) { + spanBuilder.setAttribute(GENAI_CONFIG_ID, request.pushNotificationConfigId()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, request.toString()); + } + Span span = spanBuilder.startSpan(); + try (Scope scope = span.makeCurrent()) { + TaskPushNotificationConfig result = delegate.getTaskPushNotificationConfiguration(request, clientContext); + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + if (result != null) { + span.setStatus(StatusCode.OK); + } + return result; + } catch (Exception ex) { + span.setStatus(StatusCode.ERROR, ex.getMessage()); + throw ex; + } finally { + span.end(); + } + } + + @Override + public ListTaskPushNotificationConfigResult listTaskPushNotificationConfigurations(ListTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) throws A2AClientException { + ClientCallContext clientContext = createContext(context); + SpanBuilder spanBuilder = tracer.spanBuilder(A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD).setSpanKind(SpanKind.CLIENT); + spanBuilder.setAttribute(GENAI_OPERATION_NAME, A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, request.toString()); + } + if (request.id() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, request.id()); + } + Span span = spanBuilder.startSpan(); + try (Scope scope = span.makeCurrent()) { + ListTaskPushNotificationConfigResult result = delegate.listTaskPushNotificationConfigurations(request, clientContext); + if (result != null && extractResponse()) { + String responseValue = result.configs().stream() + .map(TaskPushNotificationConfig::toString) + .collect(Collectors.joining(",")); + span.setAttribute(GENAI_RESPONSE, responseValue); + } + if (result != null) { + span.setStatus(StatusCode.OK); + } + return result; + } catch (Exception ex) { + span.setStatus(StatusCode.ERROR, ex.getMessage()); + throw ex; + } finally { + span.end(); + } + } + + @Override + public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) throws A2AClientException { + ClientCallContext clientContext = createContext(context); + SpanBuilder spanBuilder = tracer.spanBuilder(A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD).setSpanKind(SpanKind.CLIENT); + spanBuilder.setAttribute(GENAI_OPERATION_NAME, A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, request.toString()); + } + if (request.id() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, request.id()); + } + if (request.pushNotificationConfigId() != null) { + spanBuilder.setAttribute(GENAI_CONFIG_ID, request.pushNotificationConfigId()); + } + Span span = spanBuilder.startSpan(); + try (Scope scope = span.makeCurrent()) { + delegate.deleteTaskPushNotificationConfigurations(request, clientContext); + span.setStatus(StatusCode.OK); + } catch (Exception ex) { + span.setStatus(StatusCode.ERROR, ex.getMessage()); + throw ex; + } finally { + span.end(); + } + } + + @Override + public void resubscribe(TaskIdParams request, Consumer eventConsumer, + Consumer errorConsumer, @Nullable ClientCallContext context) throws A2AClientException { + ClientCallContext clientContext = createContext(context); + SpanBuilder spanBuilder = tracer.spanBuilder(A2AMethods.SUBSCRIBE_TO_TASK_METHOD).setSpanKind(SpanKind.CLIENT); + spanBuilder.setAttribute(GENAI_OPERATION_NAME, A2AMethods.SUBSCRIBE_TO_TASK_METHOD); + if (request.id() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, request.id()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, request.toString()); + } + Span span = spanBuilder.startSpan(); + try (Scope scope = span.makeCurrent()) { + delegate.resubscribe( + request, + new OpenTelemetryEventConsumer(A2AMethods.SUBSCRIBE_TO_TASK_METHOD + "-event", eventConsumer, tracer, span.getSpanContext()), + new OpenTelemetryErrorConsumer(A2AMethods.SUBSCRIBE_TO_TASK_METHOD + "-error", errorConsumer, tracer, span.getSpanContext()), + clientContext + ); + span.setStatus(StatusCode.OK); + } catch (Exception ex) { + span.setStatus(StatusCode.ERROR, ex.getMessage()); + throw ex; + } finally { + span.end(); + } + } + + @Override + public AgentCard getExtendedAgentCard(@Nullable ClientCallContext context) throws A2AClientException { + ClientCallContext clientContext = createContext(context); + SpanBuilder spanBuilder = tracer.spanBuilder(A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD).setSpanKind(SpanKind.CLIENT); + spanBuilder.setAttribute(GENAI_OPERATION_NAME, A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD); + Span span = spanBuilder.startSpan(); + try (Scope scope = span.makeCurrent()) { + AgentCard result = delegate.getExtendedAgentCard(clientContext); + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + if (result != null) { + span.setStatus(StatusCode.OK); + } + return result; + } catch (Exception ex) { + span.setStatus(StatusCode.ERROR, ex.getMessage()); + throw ex; + } finally { + span.end(); + } + } + + private ClientCallContext createContext(@Nullable ClientCallContext context) { + if (context == null) { + return new ClientCallContext(Map.of(), new HashMap<>()); + } + return new ClientCallContext(context.getState(), new HashMap<>(context.getHeaders())); + } + + @Override + public void close() { + delegate.close(); + } + + private static class OpenTelemetryEventConsumer implements Consumer { + + private final Consumer delegate; + private final Tracer tracer; + private final SpanContext context; + private final String name; + + public OpenTelemetryEventConsumer(String name, Consumer delegate, Tracer tracer, SpanContext context) { + this.delegate = delegate; + this.tracer = tracer; + this.context = context; + this.name = name; + } + + @Override + public void accept(StreamingEventKind t) { + SpanBuilder spanBuilder = tracer.spanBuilder(name) + .setSpanKind(SpanKind.CLIENT); + spanBuilder.setAttribute("gen_ai.agent.a2a.streaming-event", t.toString()); + spanBuilder.addLink(context); + Span span = spanBuilder.startSpan(); + try { + delegate.accept(t); + span.setStatus(StatusCode.OK); + } finally { + span.end(); + } + } + } + + private static class OpenTelemetryErrorConsumer implements Consumer { + + private final Consumer delegate; + private final Tracer tracer; + private final SpanContext context; + private final String name; + + public OpenTelemetryErrorConsumer(String name, Consumer delegate, Tracer tracer, SpanContext context) { + this.delegate = delegate; + this.tracer = tracer; + this.context = context; + this.name = name; + } + + @Override + public void accept(Throwable t) { + if (t == null) { + return; + } + SpanBuilder spanBuilder = tracer.spanBuilder(name) + .setSpanKind(SpanKind.CLIENT); + spanBuilder.addLink(context); + Span span = spanBuilder.startSpan(); + try { + span.setStatus(StatusCode.ERROR, t.getMessage()); + delegate.accept(t); + } finally { + span.end(); + } + } + } +} diff --git a/extras/opentelemetry/client/src/main/java/io/a2a/extras/opentelemetry/client/OpenTelemetryClientTransportWrapper.java b/extras/opentelemetry/client/src/main/java/io/a2a/extras/opentelemetry/client/OpenTelemetryClientTransportWrapper.java new file mode 100644 index 000000000..2f61bf280 --- /dev/null +++ b/extras/opentelemetry/client/src/main/java/io/a2a/extras/opentelemetry/client/OpenTelemetryClientTransportWrapper.java @@ -0,0 +1,47 @@ +package io.a2a.extras.opentelemetry.client; + +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.client.transport.spi.ClientTransportConfig; +import io.a2a.client.transport.spi.ClientTransportWrapper; +import io.opentelemetry.api.trace.Tracer; + +/** + * OpenTelemetry client transport wrapper that adds distributed tracing to A2A client calls. + * + *

This wrapper is automatically discovered via Java's ServiceLoader mechanism. + * To enable tracing, add a {@link Tracer} instance to the transport configuration: + *

{@code
+ * ClientTransportConfig config = new JSONRPCTransportConfig();
+ * config.setParameters(Map.of(
+ *     OpenTelemetryClientTransportFactory.OTEL_TRACER_KEY,
+ *     openTelemetry.getTracer("my-service"),
+ *     OpenTelemetryClientTransportFactory.OTEL_OPEN_TELEMETRY_KEY,
+ *     openTelemetry
+ * ));
+ * }
+ */ +public class OpenTelemetryClientTransportWrapper implements ClientTransportWrapper { + + /** + * Configuration key for the OpenTelemetry Tracer instance. + * Value must be of type {@link Tracer}. + */ + public static final String OTEL_TRACER_KEY = "io.a2a.extras.opentelemetry.Tracer"; + + @Override + public ClientTransport wrap(ClientTransport transport, ClientTransportConfig config) { + Object tracerObj = config.getParameters().get(OTEL_TRACER_KEY); + if (tracerObj != null && tracerObj instanceof Tracer tracer) { + return new OpenTelemetryClientTransport(transport, tracer); + } + // No tracer configured, return unwrapped transport + return transport; + } + + @Override + public int priority() { + // Observability/tracing should be in the middle priority range + // so it can observe other wrappers but doesn't interfere with security + return 600; + } +} diff --git a/extras/opentelemetry/client/src/main/java/io/a2a/extras/opentelemetry/client/package-info.java b/extras/opentelemetry/client/src/main/java/io/a2a/extras/opentelemetry/client/package-info.java new file mode 100644 index 000000000..0a7d54541 --- /dev/null +++ b/extras/opentelemetry/client/src/main/java/io/a2a/extras/opentelemetry/client/package-info.java @@ -0,0 +1,5 @@ +@NullMarked +package io.a2a.extras.opentelemetry.client; + +import org.jspecify.annotations.NullMarked; + diff --git a/extras/opentelemetry/client/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportWrapper b/extras/opentelemetry/client/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportWrapper new file mode 100644 index 000000000..801f7dd3a --- /dev/null +++ b/extras/opentelemetry/client/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportWrapper @@ -0,0 +1 @@ +io.a2a.extras.opentelemetry.client.OpenTelemetryClientTransportWrapper diff --git a/extras/opentelemetry/client/src/test/java/io/a2a/extras/opentelemetry/client/OpenTelemetryClientTransportTest.java b/extras/opentelemetry/client/src/test/java/io/a2a/extras/opentelemetry/client/OpenTelemetryClientTransportTest.java new file mode 100644 index 000000000..865353824 --- /dev/null +++ b/extras/opentelemetry/client/src/test/java/io/a2a/extras/opentelemetry/client/OpenTelemetryClientTransportTest.java @@ -0,0 +1,489 @@ +package io.a2a.extras.opentelemetry.client; + +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.EXTRACT_REQUEST_SYS_PROPERTY; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.EXTRACT_RESPONSE_SYS_PROPERTY; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_REQUEST; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_RESPONSE; + +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.client.transport.spi.interceptors.ClientCallContext; +import io.a2a.jsonrpc.common.wrappers.ListTasksResult; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.AgentCard; +import io.a2a.spec.DeleteTaskPushNotificationConfigParams; +import io.a2a.spec.EventKind; +import io.a2a.spec.GetTaskPushNotificationConfigParams; +import io.a2a.spec.ListTaskPushNotificationConfigParams; +import io.a2a.spec.ListTaskPushNotificationConfigResult; +import io.a2a.spec.ListTasksParams; +import io.a2a.spec.Message; +import io.a2a.spec.MessageSendParams; +import io.a2a.spec.StreamingEventKind; +import io.a2a.spec.TextPart; +import io.a2a.spec.Task; +import io.a2a.spec.TaskIdParams; +import io.a2a.spec.TaskPushNotificationConfig; +import io.a2a.spec.TaskQueryParams; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.List; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import io.a2a.spec.A2AMethods; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class OpenTelemetryClientTransportTest { + + @Mock + private ClientTransport delegate; + + @Mock + private Tracer tracer; + + @Mock + private SpanBuilder spanBuilder; + + @Mock + private Span span; + + @Mock + private Scope scope; + + @Mock + private SpanContext spanContext; + + @Mock + private ClientCallContext context; + + private OpenTelemetryClientTransport transport; + + @BeforeEach + void setUp() { + System.setProperty(EXTRACT_REQUEST_SYS_PROPERTY, "true"); + System.setProperty(EXTRACT_RESPONSE_SYS_PROPERTY, "true"); + when(tracer.spanBuilder(anyString())).thenReturn(spanBuilder); + when(spanBuilder.setSpanKind(any(SpanKind.class))).thenReturn(spanBuilder); + when(spanBuilder.setAttribute(anyString(), anyString())).thenReturn(spanBuilder); + when(spanBuilder.setAttribute(anyString(), anyLong())).thenReturn(spanBuilder); + when(spanBuilder.addLink(any(SpanContext.class))).thenReturn(spanBuilder); + when(spanBuilder.startSpan()).thenReturn(span); + when(span.makeCurrent()).thenReturn(scope); + when(span.getSpanContext()).thenReturn(spanContext); + when(context.getHeaders()).thenReturn(new java.util.HashMap<>()); + + transport = new OpenTelemetryClientTransport(delegate, tracer); + } + + @Test + void testSendMessage_Success() throws A2AClientException { + MessageSendParams request = mock(MessageSendParams.class); + EventKind expectedResult = mock(EventKind.class); + when(request.toString()).thenReturn("request-string"); + when(expectedResult.toString()).thenReturn("response-string"); + when(delegate.sendMessage(eq(request), any(ClientCallContext.class))).thenReturn(expectedResult); + + EventKind result = transport.sendMessage(request, context); + + assertEquals(expectedResult, result); + verify(tracer).spanBuilder(A2AMethods.SEND_MESSAGE_METHOD); + verify(spanBuilder).setSpanKind(SpanKind.CLIENT); + verify(spanBuilder).setAttribute(GENAI_REQUEST, "request-string"); + verify(span).setAttribute(GENAI_RESPONSE, "response-string"); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + verify(scope).close(); + } + + @Test + void testSendMessage_NullResponse() throws A2AClientException { + MessageSendParams request = mock(MessageSendParams.class); + when(request.toString()).thenReturn("request-string"); + when(delegate.sendMessage(eq(request), any(ClientCallContext.class))).thenReturn(null); + + EventKind result = transport.sendMessage(request, context); + + assertNull(result); + verify(spanBuilder).setAttribute(GENAI_REQUEST, "request-string"); + verify(spanBuilder, never()).setAttribute(eq(GENAI_RESPONSE), anyString()); + verify(span, never()).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void testSendMessage_ThrowsException() throws A2AClientException { + MessageSendParams request = mock(MessageSendParams.class); + when(request.toString()).thenReturn("request-string"); + A2AClientException expectedException = new A2AClientException("Test error"); + when(delegate.sendMessage(eq(request), any(ClientCallContext.class))).thenThrow(expectedException); + + A2AClientException exception = assertThrows(A2AClientException.class, + () -> transport.sendMessage(request, context)); + + assertEquals(expectedException, exception); + verify(span).setStatus(StatusCode.ERROR, "Test error"); + verify(span).end(); + verify(scope).close(); + } + + @Test + void testSendMessageStreaming() throws A2AClientException { + MessageSendParams request = mock(MessageSendParams.class); + when(request.toString()).thenReturn("request-string"); + Consumer eventConsumer = mock(Consumer.class); + Consumer errorConsumer = mock(Consumer.class); + + transport.sendMessageStreaming(request, eventConsumer, errorConsumer, context); + + verify(tracer).spanBuilder(A2AMethods.SEND_STREAMING_MESSAGE_METHOD); + verify(spanBuilder).setSpanKind(SpanKind.CLIENT); + verify(spanBuilder).setAttribute(GENAI_REQUEST, "request-string"); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + + ArgumentCaptor> eventConsumerCaptor = ArgumentCaptor.forClass(Consumer.class); + ArgumentCaptor> errorConsumerCaptor = ArgumentCaptor.forClass(Consumer.class); + verify(delegate).sendMessageStreaming(eq(request), eventConsumerCaptor.capture(), + errorConsumerCaptor.capture(), any(ClientCallContext.class)); + + assertNotNull(eventConsumerCaptor.getValue()); + assertNotNull(errorConsumerCaptor.getValue()); + } + + @Test + void testGetTask_Success() throws A2AClientException { + TaskQueryParams request = mock(TaskQueryParams.class); + Task expectedResult = mock(Task.class); + when(request.toString()).thenReturn("request-string"); + when(expectedResult.toString()).thenReturn("response-string"); + when(delegate.getTask(eq(request), any(ClientCallContext.class))).thenReturn(expectedResult); + + Task result = transport.getTask(request, context); + + assertEquals(expectedResult, result); + verify(tracer).spanBuilder(A2AMethods.GET_TASK_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, "request-string"); + verify(span).setAttribute(GENAI_RESPONSE, "response-string"); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void testCancelTask_Success() throws A2AClientException { + TaskIdParams request = mock(TaskIdParams.class); + Task expectedResult = mock(Task.class); + when(request.toString()).thenReturn("request-string"); + when(expectedResult.toString()).thenReturn("response-string"); + when(delegate.cancelTask(eq(request), any(ClientCallContext.class))).thenReturn(expectedResult); + + Task result = transport.cancelTask(request, context); + + assertEquals(expectedResult, result); + verify(tracer).spanBuilder(A2AMethods.CANCEL_TASK_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, "request-string"); + verify(span).setAttribute(GENAI_RESPONSE, "response-string"); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void testListTasks_Success() throws A2AClientException { + ListTasksParams request = mock(ListTasksParams.class); + ListTasksResult expectedResult = mock(ListTasksResult.class); + when(request.toString()).thenReturn("request-string"); + when(expectedResult.toString()).thenReturn("response-string"); + when(delegate.listTasks(eq(request), any(ClientCallContext.class))).thenReturn(expectedResult); + + ListTasksResult result = transport.listTasks(request, context); + + assertEquals(expectedResult, result); + verify(tracer).spanBuilder(A2AMethods.LIST_TASK_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, "request-string"); + verify(span).setAttribute(GENAI_RESPONSE, "response-string"); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void testCreateTaskPushNotificationConfiguration_Success() throws A2AClientException { + TaskPushNotificationConfig request = mock(TaskPushNotificationConfig.class); + TaskPushNotificationConfig expectedResult = mock(TaskPushNotificationConfig.class); + when(request.toString()).thenReturn("request-string"); + when(expectedResult.toString()).thenReturn("response-string"); + when(delegate.createTaskPushNotificationConfiguration(eq(request), any(ClientCallContext.class))).thenReturn(expectedResult); + + TaskPushNotificationConfig result = transport.createTaskPushNotificationConfiguration(request, context); + + assertEquals(expectedResult, result); + verify(tracer).spanBuilder(A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, "request-string"); + verify(span).setAttribute(GENAI_RESPONSE, "response-string"); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void testGetTaskPushNotificationConfiguration_Success() throws A2AClientException { + GetTaskPushNotificationConfigParams request = mock(GetTaskPushNotificationConfigParams.class); + TaskPushNotificationConfig expectedResult = mock(TaskPushNotificationConfig.class); + when(request.toString()).thenReturn("request-string"); + when(expectedResult.toString()).thenReturn("response-string"); + when(delegate.getTaskPushNotificationConfiguration(eq(request), any(ClientCallContext.class))).thenReturn(expectedResult); + + TaskPushNotificationConfig result = transport.getTaskPushNotificationConfiguration(request, context); + + assertEquals(expectedResult, result); + verify(tracer).spanBuilder(A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, "request-string"); + verify(span).setAttribute(GENAI_RESPONSE, "response-string"); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void testListTaskPushNotificationConfigurations_Success() throws A2AClientException { + ListTaskPushNotificationConfigParams request = mock(ListTaskPushNotificationConfigParams.class); + TaskPushNotificationConfig config1 = mock(TaskPushNotificationConfig.class); + TaskPushNotificationConfig config2 = mock(TaskPushNotificationConfig.class); + when(config1.toString()).thenReturn("config1"); + when(config2.toString()).thenReturn("config2"); + ListTaskPushNotificationConfigResult expectedResult = new ListTaskPushNotificationConfigResult(List.of(config1, config2)); + when(request.toString()).thenReturn("request-string"); + when(delegate.listTaskPushNotificationConfigurations(eq(request), any(ClientCallContext.class))).thenReturn(expectedResult); + + ListTaskPushNotificationConfigResult result = transport.listTaskPushNotificationConfigurations(request, context); + + assertEquals(expectedResult, result); + verify(tracer).spanBuilder(A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + verify(spanBuilder).setSpanKind(SpanKind.CLIENT); + verify(spanBuilder).setAttribute(GENAI_REQUEST, "request-string"); + verify(span).setAttribute(GENAI_RESPONSE, "config1,config2"); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void testDeleteTaskPushNotificationConfigurations_Success() throws A2AClientException { + DeleteTaskPushNotificationConfigParams request = mock(DeleteTaskPushNotificationConfigParams.class); + when(request.toString()).thenReturn("request-string"); + doNothing().when(delegate).deleteTaskPushNotificationConfigurations(eq(request), any(ClientCallContext.class)); + + transport.deleteTaskPushNotificationConfigurations(request, context); + + verify(tracer).spanBuilder(A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, "request-string"); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + verify(delegate).deleteTaskPushNotificationConfigurations(eq(request), any(ClientCallContext.class)); + } + + @Test + void testResubscribe() throws A2AClientException { + TaskIdParams request = mock(TaskIdParams.class); + when(request.toString()).thenReturn("request-string"); + Consumer eventConsumer = mock(Consumer.class); + Consumer errorConsumer = mock(Consumer.class); + + transport.resubscribe(request, eventConsumer, errorConsumer, context); + + verify(tracer).spanBuilder(A2AMethods.SUBSCRIBE_TO_TASK_METHOD); + verify(spanBuilder).setSpanKind(SpanKind.CLIENT); + verify(spanBuilder).setAttribute(GENAI_REQUEST, "request-string"); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + + ArgumentCaptor> eventConsumerCaptor = ArgumentCaptor.forClass(Consumer.class); + ArgumentCaptor> errorConsumerCaptor = ArgumentCaptor.forClass(Consumer.class); + verify(delegate).resubscribe(eq(request), eventConsumerCaptor.capture(), + errorConsumerCaptor.capture(), any(ClientCallContext.class)); + + assertNotNull(eventConsumerCaptor.getValue()); + assertNotNull(errorConsumerCaptor.getValue()); + } + + @Test + void testGetAgentCard_Success() throws A2AClientException { + AgentCard expectedResult = mock(AgentCard.class); + when(expectedResult.toString()).thenReturn("response-string"); + when(delegate.getExtendedAgentCard(any(ClientCallContext.class))).thenReturn(expectedResult); + + AgentCard result = transport.getExtendedAgentCard(context); + + assertEquals(expectedResult, result); + verify(tracer).spanBuilder(A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD); + verify(spanBuilder).setSpanKind(SpanKind.CLIENT); + verify(span).setAttribute(GENAI_RESPONSE, "response-string"); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void testGetAgentCard_NullResponse() throws A2AClientException { + when(delegate.getExtendedAgentCard(any(ClientCallContext.class))).thenReturn(null); + + AgentCard result = transport.getExtendedAgentCard(context); + + assertNull(result); + verify(tracer).spanBuilder(A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD); + verify(spanBuilder).setSpanKind(SpanKind.CLIENT); + verify(span, never()).setAttribute(eq(GENAI_RESPONSE), anyString()); + verify(span, never()).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void testClose() { + transport.close(); + verify(delegate).close(); + } + + @Test + void testEventConsumer_ThroughSendMessageStreaming() throws A2AClientException { + MessageSendParams request = mock(MessageSendParams.class); + when(request.toString()).thenReturn("request-string"); + + SpanBuilder eventSpanBuilder = mock(SpanBuilder.class); + Span eventSpan = mock(Span.class); + when(tracer.spanBuilder(A2AMethods.SEND_STREAMING_MESSAGE_METHOD + "-event")).thenReturn(eventSpanBuilder); + when(eventSpanBuilder.setSpanKind(any(SpanKind.class))).thenReturn(eventSpanBuilder); + when(eventSpanBuilder.setAttribute(anyString(), anyString())).thenReturn(eventSpanBuilder); + when(eventSpanBuilder.addLink(any(SpanContext.class))).thenReturn(eventSpanBuilder); + when(eventSpanBuilder.startSpan()).thenReturn(eventSpan); + + ArgumentCaptor> eventConsumerCaptor = ArgumentCaptor.forClass(Consumer.class); + Consumer originalConsumer = mock(Consumer.class); + + transport.sendMessageStreaming(request, originalConsumer, mock(Consumer.class), context); + + verify(delegate).sendMessageStreaming(eq(request), eventConsumerCaptor.capture(), any(Consumer.class), any(ClientCallContext.class)); + + Message event = Message.builder() + .messageId("test-id") + .taskId("task-id") + .role(Message.Role.USER) + .parts(List.of(new TextPart("test content"))) + .build(); + + eventConsumerCaptor.getValue().accept(event); + + verify(tracer).spanBuilder(A2AMethods.SEND_STREAMING_MESSAGE_METHOD + "-event"); + verify(eventSpan).setStatus(StatusCode.OK); + verify(eventSpan).end(); + verify(originalConsumer).accept(event); + } + + @Test + void testErrorConsumer_ThroughSendMessageStreaming() throws A2AClientException { + MessageSendParams request = mock(MessageSendParams.class); + when(request.toString()).thenReturn("request-string"); + + SpanBuilder errorSpanBuilder = mock(SpanBuilder.class); + Span errorSpan = mock(Span.class); + when(tracer.spanBuilder(A2AMethods.SEND_STREAMING_MESSAGE_METHOD + "-error")).thenReturn(errorSpanBuilder); + when(errorSpanBuilder.setSpanKind(any(SpanKind.class))).thenReturn(errorSpanBuilder); + when(errorSpanBuilder.addLink(any(SpanContext.class))).thenReturn(errorSpanBuilder); + when(errorSpanBuilder.startSpan()).thenReturn(errorSpan); + + ArgumentCaptor> errorConsumerCaptor = ArgumentCaptor.forClass(Consumer.class); + Consumer originalConsumer = mock(Consumer.class); + + transport.sendMessageStreaming(request, mock(Consumer.class), originalConsumer, context); + + verify(delegate).sendMessageStreaming(eq(request), any(Consumer.class), errorConsumerCaptor.capture(), any(ClientCallContext.class)); + + Throwable error = new RuntimeException("Test error"); + + errorConsumerCaptor.getValue().accept(error); + + verify(tracer).spanBuilder(A2AMethods.SEND_STREAMING_MESSAGE_METHOD + "-error"); + verify(errorSpan).setStatus(StatusCode.ERROR, "Test error"); + verify(errorSpan).end(); + verify(originalConsumer).accept(error); + } + + @Test + void testErrorConsumer_WithNullThrowable() throws A2AClientException { + MessageSendParams request = mock(MessageSendParams.class); + when(request.toString()).thenReturn("request-string"); + + ArgumentCaptor> errorConsumerCaptor = ArgumentCaptor.forClass(Consumer.class); + Consumer originalConsumer = mock(Consumer.class); + + transport.sendMessageStreaming(request, mock(Consumer.class), originalConsumer, context); + + verify(delegate).sendMessageStreaming(eq(request), any(Consumer.class), errorConsumerCaptor.capture(), any(ClientCallContext.class)); + + errorConsumerCaptor.getValue().accept(null); + + verify(originalConsumer, never()).accept(any()); + } + + @Test + void testDeleteTaskPushNotificationConfigurations_ThrowsException() throws A2AClientException { + DeleteTaskPushNotificationConfigParams request = mock(DeleteTaskPushNotificationConfigParams.class); + when(request.toString()).thenReturn("request-string"); + A2AClientException expectedException = new A2AClientException("Delete failed"); + doThrow(expectedException).when(delegate).deleteTaskPushNotificationConfigurations(eq(request), any(ClientCallContext.class)); + + A2AClientException exception = assertThrows(A2AClientException.class, + () -> transport.deleteTaskPushNotificationConfigurations(request, context)); + + assertEquals(expectedException, exception); + verify(span).setStatus(StatusCode.ERROR, "Delete failed"); + verify(span).end(); + } + + @Test + void testResubscribe_ThrowsException() throws A2AClientException { + TaskIdParams request = mock(TaskIdParams.class); + when(request.toString()).thenReturn("request-string"); + Consumer eventConsumer = mock(Consumer.class); + Consumer errorConsumer = mock(Consumer.class); + A2AClientException expectedException = new A2AClientException("Resubscribe failed"); + doThrow(expectedException).when(delegate).resubscribe(any(TaskIdParams.class), any(Consumer.class), + any(Consumer.class), any(ClientCallContext.class)); + + A2AClientException exception = assertThrows(A2AClientException.class, + () -> transport.resubscribe(request, eventConsumer, errorConsumer, context)); + + assertEquals(expectedException, exception); + verify(span).setStatus(StatusCode.ERROR, "Resubscribe failed"); + verify(span).end(); + } + + @Test + void testSendMessageStreaming_ThrowsException() throws A2AClientException { + MessageSendParams request = mock(MessageSendParams.class); + when(request.toString()).thenReturn("request-string"); + Consumer eventConsumer = mock(Consumer.class); + Consumer errorConsumer = mock(Consumer.class); + A2AClientException expectedException = new A2AClientException("Streaming failed"); + doThrow(expectedException).when(delegate).sendMessageStreaming(any(MessageSendParams.class), any(Consumer.class), + any(Consumer.class), any(ClientCallContext.class)); + + A2AClientException exception = assertThrows(A2AClientException.class, + () -> transport.sendMessageStreaming(request, eventConsumer, errorConsumer, context)); + + assertEquals(expectedException, exception); + verify(span).setStatus(StatusCode.ERROR, "Streaming failed"); + verify(span).end(); + } +} diff --git a/extras/opentelemetry/common/pom.xml b/extras/opentelemetry/common/pom.xml new file mode 100644 index 000000000..d21655202 --- /dev/null +++ b/extras/opentelemetry/common/pom.xml @@ -0,0 +1,18 @@ + + + 4.0.0 + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-parent + 1.0.0.Alpha2-SNAPSHOT + + + a2a-java-sdk-opentelemetry-common + + A2A Java SDK :: Extras :: Opentelemetry :: Common + Common OpenTelemetry utilities for A2A Java SDK + + diff --git a/extras/opentelemetry/common/src/main/java/io/a2a/extras/opentelemetry/A2AObservabilityNames.java b/extras/opentelemetry/common/src/main/java/io/a2a/extras/opentelemetry/A2AObservabilityNames.java new file mode 100644 index 000000000..c2821d8a7 --- /dev/null +++ b/extras/opentelemetry/common/src/main/java/io/a2a/extras/opentelemetry/A2AObservabilityNames.java @@ -0,0 +1,23 @@ +package io.a2a.extras.opentelemetry; + +public interface A2AObservabilityNames { + + String EXTRACT_REQUEST_SYS_PROPERTY = "io.a2a.server.extract.request"; + String EXTRACT_RESPONSE_SYS_PROPERTY = "io.a2a.server.extract.response"; + + String ERROR_TYPE = "error.type"; + + String GENAI_PREFIX = "gen_ai.agent.a2a"; + String GENAI_CONFIG_ID = GENAI_PREFIX + ".config_id"; + String GENAI_CONTEXT_ID = GENAI_PREFIX + ".context_id"; //gen_ai.conversation.id + String GENAI_EXTENSIONS = GENAI_PREFIX + ".extensions"; + String GENAI_MESSAGE_ID = GENAI_PREFIX + ".message_id"; + String GENAI_OPERATION_NAME = GENAI_PREFIX + ".operation.name"; //gen_ai.agent.operation.name ? + String GENAI_PARTS_NUMBER = GENAI_PREFIX + ".parts.number"; + String GENAI_PROTOCOL = GENAI_PREFIX + ".protocol"; + String GENAI_STATUS = GENAI_PREFIX + ".status"; + String GENAI_REQUEST = GENAI_PREFIX + ".request"; //gen_ai.input.messages ? + String GENAI_RESPONSE = GENAI_PREFIX + ".response"; // gen_ai.output.messages ? + String GENAI_ROLE = GENAI_PREFIX + ".role"; + String GENAI_TASK_ID = GENAI_PREFIX + ".task_id"; +} diff --git a/extras/opentelemetry/common/src/main/java/io/a2a/extras/opentelemetry/package-info.java b/extras/opentelemetry/common/src/main/java/io/a2a/extras/opentelemetry/package-info.java new file mode 100644 index 000000000..8e7125631 --- /dev/null +++ b/extras/opentelemetry/common/src/main/java/io/a2a/extras/opentelemetry/package-info.java @@ -0,0 +1,8 @@ +/** + * Common OpenTelemetry utilities and shared components for A2A Java SDK. + *

+ * This package contains common utilities, constants, and helper classes + * used across both client and server OpenTelemetry integrations. + */ +@org.jspecify.annotations.NullMarked +package io.a2a.extras.opentelemetry; diff --git a/extras/opentelemetry/integration-tests/README.md b/extras/opentelemetry/integration-tests/README.md new file mode 100644 index 000000000..a66fe8f6c --- /dev/null +++ b/extras/opentelemetry/integration-tests/README.md @@ -0,0 +1,160 @@ +# OpenTelemetry Integration Tests (Quarkus-based) + +## Overview + +This module provides **Quarkus-based integration tests** for OpenTelemetry tracing in the A2A Java SDK, similar to the approach used in the [Quarkus OpenTelemetry quickstart](https://github.com/quarkusio/quarkus/tree/main/integration-tests/opentelemetry-quickstart). + +Unlike the previous mock-based tests, these are **real integration tests** that: +- Start an actual Quarkus application +- Expose a REST API with A2A agent endpoints +- Make real HTTP requests +- Validate that OpenTelemetry spans are created correctly + +## Architecture + +### Components + +1. **SimpleAgent** - A basic A2A agent implementation for testing + - Implements all RequestHandler methods + - Stores tasks in memory + - Provides simple echo responses for messages + +2. **AgentResource** - JAX-RS REST resource + - Exposes HTTP endpoints (`/a2a/tasks`, `/a2a/messages`, etc.) + - Delegates to the RequestHandler + - Creates ServerCallContext for each request + +3. **InstrumentedRequestHandler** - CDI Alternative + - Wraps SimpleAgent with OpenTelemetry decorator + - Delegates to the OpenTelemetry decorator for span creation + - Ensures spans are created for each operation + +4. **OpenTelemetryProducer** - CDI bean producer + - Creates the Tracer from Quarkus OpenTelemetry + - Produces the OpenTelemetryRequestHandlerDecorator (CDI decorator) + - Integrates with Quarkus OpenTelemetry extension + +## Test Strategy + +### Functional Tests (`OpenTelemetryIntegrationTest`) +- Use `@QuarkusTest` annotation +- Make real HTTP requests using REST Assured +- Verify HTTP responses are correct +- Ensure the application behaves correctly end-to-end + +### Tracing Tests (`OpenTelemetryTracingTest`) +- Use `InMemorySpanExporter` to capture spans +- Verify that HTTP requests create OpenTelemetry spans +- Validate span names, kinds (CLIENT/SERVER), and status codes +- Check that spans are properly ended + +## Current Status + +### ✅ Completed +- Project structure and POM configuration +- Quarkus dependencies and plugins (including opentelemetry-sdk-testing) +- SimpleAgentExecutor following reference module pattern +- TestAgentCardProducer with proper JSONRPC interface +- InMemorySpanExporter producer for span validation +- OpenTelemetryIntegrationTest using Client API +- Tests compile and run (service loader issues resolved) + +### 🔨 In Progress / Known Issues + +1. **Test Execution Timeouts** + - Tests are timing out during message send operations + - Error: "Timeout waiting for consumption to complete for task test-task-1" + - Likely a configuration issue between client (non-streaming) and server (streaming capable) + - Need to investigate JSONRPC transport configuration + +2. **Span Validation** + - InMemorySpanExporter is configured but needs verification + - Some tests are not finding expected spans + - May need to configure span processor to route to InMemorySpanExporter + +## Running the Tests + +### Prerequisites +```bash +# Build all A2A SDK modules first +mvn clean install -DskipTests +``` + +### Run Integration Tests +```bash +# From the integration-tests directory +mvn clean verify + +# Or from the root +mvn verify -pl extras/opentelemetry/integration-tests -am +``` + +### Run Specific Test +```bash +mvn test -Dtest=OpenTelemetryIntegrationTest +``` + +## Configuration + +### Application Properties +- `src/main/resources/application.properties` - Runtime configuration +- `src/test/resources/application.properties` - Test-specific configuration + +Key settings: +```properties +# OpenTelemetry +quarkus.otel.sdk.disabled=false +quarkus.otel.traces.enabled=true +quarkus.otel.service.name=a2a-opentelemetry-integration-test + +# Test mode: use in-memory exporter +quarkus.otel.traces.exporter=none +``` + +### beans.xml +Located at `src/main/resources/META-INF/beans.xml`: +- Enables CDI bean discovery +- Configures alternatives (InstrumentedRequestHandler) + +## Next Steps + +To complete this integration test module: + +1. **Resolve CDI ambiguity** + - Add `@Named` or custom qualifier to SimpleAgent + - Or exclude DefaultRequestHandler from test classpath + - Or use `@Alternative` more effectively + +2. **Configure span exporter** + - Properly wire InMemorySpanExporter into Quarkus OpenTelemetry + - May need custom OTel SDK configuration + +3. **Add more test scenarios** + - Test error handling and error spans + - Test streaming operations + - Test context propagation across services + - Test span attributes and metadata + +4. **Performance testing** (optional) + - Measure overhead of OpenTelemetry instrumentation + - Verify spans don't impact performance significantly + +## Comparison with Quarkus Quickstart + +This implementation follows the same patterns as the Quarkus OpenTelemetry quickstart: +- ✅ Uses `@QuarkusTest` for integration tests +- ✅ Uses REST Assured for HTTP testing +- ✅ Integrates with Quarkus OpenTelemetry extension +- ✅ Uses in-memory span exporter for validation +- ✅ Tests actual HTTP requests, not mocks + +Unlike the quickstart which is a standalone app, this module: +- Tests A2A SDK-specific functionality +- Validates OpenTelemetry CDI decorator integration +- Focuses on A2A protocol operations (tasks, messages, etc.) + +## References + +- [Quarkus OpenTelemetry Guide](https://quarkus.io/guides/opentelemetry) +- [Quarkus OpenTelemetry Quickstart](https://github.com/quarkusio/quarkus/tree/main/integration-tests/opentelemetry-quickstart) +- [OpenTelemetry Java Documentation](https://opentelemetry.io/docs/languages/java/) diff --git a/extras/opentelemetry/integration-tests/pom.xml b/extras/opentelemetry/integration-tests/pom.xml new file mode 100644 index 000000000..6d2b4734a --- /dev/null +++ b/extras/opentelemetry/integration-tests/pom.xml @@ -0,0 +1,136 @@ + + + 4.0.0 + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-parent + 1.0.0.Alpha2-SNAPSHOT + + + a2a-java-sdk-opentelemetry-integration-tests + + A2A Java SDK :: Extras :: OpenTelemetry :: Integration Tests + Quarkus-based integration tests for OpenTelemetry support in A2A Java SDK + + + + + ${project.groupId} + a2a-java-sdk-client + + + ${project.groupId} + a2a-java-sdk-reference-jsonrpc + + + ${project.groupId} + a2a-java-sdk-opentelemetry-server + + + ${project.groupId} + a2a-java-sdk-opentelemetry-client + + + + + io.quarkus + quarkus-opentelemetry + + + + + io.quarkus + quarkus-reactive-routes + + + + + jakarta.enterprise + jakarta.enterprise.cdi-api + provided + + + + + com.google.code.gson + gson + + + + + io.quarkus + quarkus-junit5 + test + + + io.opentelemetry + opentelemetry-sdk-testing + + + io.rest-assured + rest-assured + test + + + org.awaitility + awaitility + test + + + + + + + io.quarkus + quarkus-maven-plugin + true + + + + build + generate-code + generate-code-tests + + + + + --add-opens=java.base/java.lang=ALL-UNNAMED + + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.jboss.logmanager.LogManager + ${maven.home} + + --add-opens java.base/java.lang=ALL-UNNAMED + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + + org.jboss.logmanager.LogManager + ${maven.home} + + --add-opens java.base/java.lang=ALL-UNNAMED + + + + + + diff --git a/extras/opentelemetry/integration-tests/src/main/java/io/a2a/extras/opentelemetry/it/A2ATestRoutes.java b/extras/opentelemetry/integration-tests/src/main/java/io/a2a/extras/opentelemetry/it/A2ATestRoutes.java new file mode 100644 index 000000000..f6f84f6e1 --- /dev/null +++ b/extras/opentelemetry/integration-tests/src/main/java/io/a2a/extras/opentelemetry/it/A2ATestRoutes.java @@ -0,0 +1,228 @@ +package io.a2a.extras.opentelemetry.it; + + + +import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.a2a.jsonrpc.common.json.JsonUtil; +import io.a2a.spec.Task; +import io.a2a.spec.TaskArtifactUpdateEvent; +import io.a2a.spec.TaskStatusUpdateEvent; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.quarkus.vertx.web.Body; +import io.quarkus.vertx.web.Param; +import io.quarkus.vertx.web.Route; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Test routes for OpenTelemetry integration testing. + * Exposes test utilities via REST endpoints. + */ +@Singleton +public class A2ATestRoutes { + + private static final String APPLICATION_JSON = "application/json"; + private static final String TEXT_PLAIN = "text/plain"; + private static final Gson gson = new GsonBuilder().create(); + + @Inject + TestUtilsBean testUtilsBean; + @Inject + InMemorySpanExporter inMemorySpanExporter; + + @Inject + Tracer tracer; + + @Route(path = "/test/task", methods = {Route.HttpMethod.POST}, consumes = {APPLICATION_JSON}, type = Route.HandlerType.BLOCKING) + public void saveTask(@Body String body, RoutingContext rc) { + try { + Task task = JsonUtil.fromJson(body, Task.class); + testUtilsBean.saveTask(task); + rc.response() + .setStatusCode(200) + .end(); + } catch (Throwable t) { + errorResponse(t, rc); + } + } + + @Route(path = "/test/task/:taskId", methods = {Route.HttpMethod.GET}, produces = {APPLICATION_JSON}, type = Route.HandlerType.BLOCKING) + public void getTask(@Param String taskId, RoutingContext rc) { + try { + Task task = testUtilsBean.getTask(taskId); + if (task == null) { + rc.response() + .setStatusCode(404) + .end(); + return; + } + rc.response() + .setStatusCode(200) + .putHeader(CONTENT_TYPE, APPLICATION_JSON) + .end(JsonUtil.toJson(task)); + + } catch (Throwable t) { + errorResponse(t, rc); + } + } + + @Route(path = "/test/task/:taskId", methods = {Route.HttpMethod.DELETE}, type = Route.HandlerType.BLOCKING) + public void deleteTask(@Param String taskId, RoutingContext rc) { + try { + Task task = testUtilsBean.getTask(taskId); + if (task == null) { + rc.response() + .setStatusCode(404) + .end(); + return; + } + testUtilsBean.deleteTask(taskId); + rc.response() + .setStatusCode(200) + .end(); + } catch (Throwable t) { + errorResponse(t, rc); + } + } + + @Route(path = "/test/queue/ensure/:taskId", methods = {Route.HttpMethod.POST}) + public void ensureTaskQueue(@Param String taskId, RoutingContext rc) { + try { + testUtilsBean.ensureQueue(taskId); + rc.response() + .setStatusCode(200) + .end(); + } catch (Throwable t) { + errorResponse(t, rc); + } + } + + @Route(path = "/test/queue/enqueueTaskStatusUpdateEvent/:taskId", methods = {Route.HttpMethod.POST}) + public void enqueueTaskStatusUpdateEvent(@Param String taskId, @Body String body, RoutingContext rc) { + try { + TaskStatusUpdateEvent event = JsonUtil.fromJson(body, TaskStatusUpdateEvent.class); + testUtilsBean.enqueueEvent(taskId, event); + rc.response() + .setStatusCode(200) + .end(); + } catch (Throwable t) { + errorResponse(t, rc); + } + } + + @Route(path = "/test/queue/enqueueTaskArtifactUpdateEvent/:taskId", methods = {Route.HttpMethod.POST}) + public void enqueueTaskArtifactUpdateEvent(@Param String taskId, @Body String body, RoutingContext rc) { + try { + TaskArtifactUpdateEvent event = JsonUtil.fromJson(body, TaskArtifactUpdateEvent.class); + testUtilsBean.enqueueEvent(taskId, event); + rc.response() + .setStatusCode(200) + .end(); + } catch (Throwable t) { + errorResponse(t, rc); + } + } + + @Route(path = "/test/queue/childCount/:taskId", methods = {Route.HttpMethod.GET}, produces = {TEXT_PLAIN}) + public void getChildQueueCount(@Param String taskId, RoutingContext rc) { + int count = testUtilsBean.getChildQueueCount(taskId); + rc.response() + .setStatusCode(200) + .end(String.valueOf(count)); + } + + @Route(path = "/hello", methods = {Route.HttpMethod.GET}, produces = {TEXT_PLAIN}) + public void hello(RoutingContext rc) { + Span span = tracer.spanBuilder("hello").startSpan(); + try (Scope scope = span.makeCurrent()) { + rc.response() + .setStatusCode(200) + .putHeader(CONTENT_TYPE, TEXT_PLAIN) + .end("Hello from Quarkus REST"); + } finally { + span.end(); + } + } + + @Route(path = "/export", methods = {Route.HttpMethod.GET}, produces = {APPLICATION_JSON}) + public void exportSpans(@Param String taskId, RoutingContext rc) { + List spans = inMemorySpanExporter.getFinishedSpanItems() + .stream() + .filter(sd -> !sd.getName().contains("export") && !sd.getName().contains("reset")) + .collect(Collectors.toList()); + String json = gson.toJson(serialize(spans)); + rc.response() + .setStatusCode(200) + .putHeader(CONTENT_TYPE, APPLICATION_JSON) + .end(json); + } + + private JsonElement serialize(List spanDatas) { + JsonArray spans = new JsonArray(spanDatas.size()); + for (SpanData spanData : spanDatas) { + JsonObject jsonObject = new JsonObject(); + + jsonObject.addProperty("spanId", spanData.getSpanId()); + jsonObject.addProperty("traceId", spanData.getTraceId()); + jsonObject.addProperty("name", spanData.getName()); + jsonObject.addProperty("kind", spanData.getKind().name()); + jsonObject.addProperty("ended", spanData.hasEnded()); + + jsonObject.addProperty("parentSpanId", spanData.getParentSpanContext().getSpanId()); + jsonObject.addProperty("parent_spanId", spanData.getParentSpanContext().getSpanId()); + jsonObject.addProperty("parent_traceId", spanData.getParentSpanContext().getTraceId()); + jsonObject.addProperty("parent_remote", spanData.getParentSpanContext().isRemote()); + jsonObject.addProperty("parent_valid", spanData.getParentSpanContext().isValid()); + + spanData.getAttributes().forEach((k, v) -> { + jsonObject.addProperty("attr_" + k.getKey(), v.toString()); + }); + + spanData.getResource().getAttributes().forEach((k, v) -> { + jsonObject.addProperty("resource_" + k.getKey(), v.toString()); + }); + spans.add(jsonObject); + } + + return spans; + } + + @Route(path = "/reset", methods = {Route.HttpMethod.GET}, produces = {TEXT_PLAIN}) + public void reset(@Param String taskId, RoutingContext rc) { + inMemorySpanExporter.reset(); + rc.response().setStatusCode(200).end(); + } + + private void errorResponse(Throwable t, RoutingContext rc) { + t.printStackTrace(); + rc.response() + .setStatusCode(500) + .putHeader(CONTENT_TYPE, TEXT_PLAIN) + .end(); + } + + @ApplicationScoped + static class InMemorySpanExporterProducer { + + @Produces + @Singleton + InMemorySpanExporter inMemorySpanExporter() { + return InMemorySpanExporter.create(); + } + } +} diff --git a/extras/opentelemetry/integration-tests/src/main/java/io/a2a/extras/opentelemetry/it/SimpleAgentExecutor.java b/extras/opentelemetry/integration-tests/src/main/java/io/a2a/extras/opentelemetry/it/SimpleAgentExecutor.java new file mode 100644 index 000000000..9e706f676 --- /dev/null +++ b/extras/opentelemetry/integration-tests/src/main/java/io/a2a/extras/opentelemetry/it/SimpleAgentExecutor.java @@ -0,0 +1,48 @@ +package io.a2a.extras.opentelemetry.it; + +import io.a2a.A2A; +import io.a2a.server.agentexecution.AgentExecutor; +import io.a2a.server.agentexecution.RequestContext; +import io.a2a.server.events.EventQueue; +import io.a2a.server.tasks.TaskUpdater; +import io.a2a.spec.A2AError; +import io.a2a.spec.TextPart; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Simple AgentExecutor for integration testing. + * Echoes back the user's message and completes the task immediately. + */ +@ApplicationScoped +public class SimpleAgentExecutor implements AgentExecutor { + + @Override + public void execute(RequestContext context, EventQueue eventQueue) throws A2AError { + TaskUpdater updater = new TaskUpdater(context, eventQueue); + + // If task doesn't exist, create it + if (context.getTask() == null) { + updater.submit(); + } + + // Get the user's message + String userText = context.getMessage().parts().stream() + .filter(part -> part instanceof TextPart) + .map(part -> ((TextPart) part).text()) + .findFirst() + .orElse(""); + + // Echo it back + String response = "Echo: " + userText; + eventQueue.enqueueEvent(A2A.toAgentMessage(response)); + + // Complete the task immediately + updater.complete(); + } + + @Override + public void cancel(RequestContext context, EventQueue eventQueue) throws A2AError { + TaskUpdater updater = new TaskUpdater(context, eventQueue); + updater.cancel(); + } +} diff --git a/extras/opentelemetry/integration-tests/src/main/java/io/a2a/extras/opentelemetry/it/TestAgentCardProducer.java b/extras/opentelemetry/integration-tests/src/main/java/io/a2a/extras/opentelemetry/it/TestAgentCardProducer.java new file mode 100644 index 000000000..b07873f14 --- /dev/null +++ b/extras/opentelemetry/integration-tests/src/main/java/io/a2a/extras/opentelemetry/it/TestAgentCardProducer.java @@ -0,0 +1,49 @@ +package io.a2a.extras.opentelemetry.it; + +import io.a2a.server.PublicAgentCard; +import io.a2a.spec.AgentCapabilities; +import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentInterface; +import io.a2a.spec.AgentSkill; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; + +import java.util.Collections; +import java.util.List; + +import static io.a2a.spec.AgentCard.CURRENT_PROTOCOL_VERSION; + +/** + * Produces the AgentCard for integration testing. + */ +@ApplicationScoped +public class TestAgentCardProducer { + + @Produces + @PublicAgentCard + public AgentCard agentCard() { + return AgentCard.builder() + .name("OpenTelemetry Test Agent") + .description("Test agent for OpenTelemetry integration tests") + .supportedInterfaces(Collections.singletonList( + new AgentInterface("JSONRPC", "http://localhost:8081") + )) + .version("1.0.0-TEST") + .documentationUrl("http://example.com/test") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(Collections.singletonList("text")) + .defaultOutputModes(Collections.singletonList("text")) + .skills(Collections.singletonList(AgentSkill.builder() + .id("echo") + .name("Echo") + .description("Echoes back the user's message") + .tags(Collections.singletonList("test")) + .examples(List.of("hello", "test message")) + .build())) + .protocolVersions(CURRENT_PROTOCOL_VERSION) + .build(); + } +} diff --git a/extras/opentelemetry/integration-tests/src/main/java/io/a2a/extras/opentelemetry/it/TestUtilsBean.java b/extras/opentelemetry/integration-tests/src/main/java/io/a2a/extras/opentelemetry/it/TestUtilsBean.java new file mode 100644 index 000000000..594063171 --- /dev/null +++ b/extras/opentelemetry/integration-tests/src/main/java/io/a2a/extras/opentelemetry/it/TestUtilsBean.java @@ -0,0 +1,46 @@ +package io.a2a.extras.opentelemetry.it; + +import io.a2a.server.events.QueueManager; +import io.a2a.server.tasks.TaskStore; +import io.a2a.spec.Event; +import io.a2a.spec.Task; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +/** + * Test utilities for OpenTelemetry integration tests. + * Allows direct manipulation of tasks and queues for testing. + */ +@ApplicationScoped +public class TestUtilsBean { + + @Inject + TaskStore taskStore; + + @Inject + QueueManager queueManager; + + public void saveTask(Task task) { + taskStore.save(task, false); + } + + public Task getTask(String taskId) { + return taskStore.get(taskId); + } + + public void deleteTask(String taskId) { + taskStore.delete(taskId); + } + + public void ensureQueue(String taskId) { + queueManager.createOrTap(taskId); + } + + public void enqueueEvent(String taskId, Event event) { + queueManager.get(taskId).enqueueEvent(event); + } + + public int getChildQueueCount(String taskId) { + return queueManager.getActiveChildQueueCount(taskId); + } +} diff --git a/extras/opentelemetry/integration-tests/src/main/resources/META-INF/beans.xml b/extras/opentelemetry/integration-tests/src/main/resources/META-INF/beans.xml new file mode 100644 index 000000000..5badbc80d --- /dev/null +++ b/extras/opentelemetry/integration-tests/src/main/resources/META-INF/beans.xml @@ -0,0 +1,9 @@ + + + + io.a2a.extras.opentelemetry.it.InstrumentedRequestHandler + + diff --git a/extras/opentelemetry/integration-tests/src/main/resources/application.properties b/extras/opentelemetry/integration-tests/src/main/resources/application.properties new file mode 100644 index 000000000..ca0d2c4fd --- /dev/null +++ b/extras/opentelemetry/integration-tests/src/main/resources/application.properties @@ -0,0 +1,28 @@ +# Quarkus configuration +quarkus.application.name=a2a-otel-test + +# HTTP configuration +quarkus.http.port=8081 +quarkus.http.test-port=8081 + +# OpenTelemetry configuration +quarkus.otel.sdk.disabled=false +quarkus.otel.traces.enabled=true +quarkus.otel.metrics.enabled=false +quarkus.otel.logs.enabled=false + +quarkus.otel.instrument.vertx-http=false + +quarkus.otel.bsp.schedule.delay=0 +quarkus.otel.bsp.export.timeout=5s + +# Service name +quarkus.otel.service.name=a2a-opentelemetry-integration-test + +# Propagators +quarkus.otel.propagators=tracecontext + +# Logging +quarkus.log.level=INFO +quarkus.log.category."io.a2a".level=DEBUG +quarkus.log.category."io.opentelemetry".level=DEBUG diff --git a/extras/opentelemetry/integration-tests/src/test/java/io/a2a/extras/opentelemetry/it/BaseTest.java b/extras/opentelemetry/integration-tests/src/test/java/io/a2a/extras/opentelemetry/it/BaseTest.java new file mode 100644 index 000000000..69304e10e --- /dev/null +++ b/extras/opentelemetry/integration-tests/src/test/java/io/a2a/extras/opentelemetry/it/BaseTest.java @@ -0,0 +1,20 @@ +package io.a2a.extras.opentelemetry.it; + +import static io.restassured.RestAssured.get; + +import java.util.List; +import java.util.Map; + +import io.restassured.common.mapper.TypeRef; + +public class BaseTest { + + protected List> getSpans() { + return get("/export").body().as(new TypeRef<>() { + }); + } + + protected void buildGlobalTelemetryInstance() { + // Do nothing in JVM mode + } +} \ No newline at end of file diff --git a/extras/opentelemetry/integration-tests/src/test/java/io/a2a/extras/opentelemetry/it/OpenTelemetryIT.java b/extras/opentelemetry/integration-tests/src/test/java/io/a2a/extras/opentelemetry/it/OpenTelemetryIT.java new file mode 100644 index 000000000..0262615ee --- /dev/null +++ b/extras/opentelemetry/integration-tests/src/test/java/io/a2a/extras/opentelemetry/it/OpenTelemetryIT.java @@ -0,0 +1,325 @@ +package io.a2a.extras.opentelemetry.it; + +import static io.quarkus.vertx.web.ReactiveRoutes.APPLICATION_JSON; + +import io.a2a.client.Client; +import io.a2a.client.config.ClientConfig; +import io.a2a.client.transport.jsonrpc.JSONRPCTransport; +import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import io.a2a.spec.*; +import io.opentelemetry.api.trace.SpanKind; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +import io.a2a.A2A; +import io.a2a.jsonrpc.common.json.JsonUtil; +import io.quarkus.test.junit.QuarkusIntegrationTest; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * Integration test for OpenTelemetry tracing in A2A SDK. + *

+ * This test uses the Client API and TestUtilsBean to validate that + * OpenTelemetry spans are created correctly for A2A operations. + */ +@QuarkusIntegrationTest +class OpenTelemetryIT extends OpenTelemetryTest { + + private Client client; + private int serverPort = 8081; + + @BeforeEach + void setUp() throws A2AClientException { + // Create client with non-streaming mode for simplicity + ClientConfig clientConfig = new ClientConfig.Builder() + .setStreaming(false) + .build(); + + client = Client.builder(A2A.getAgentCard("http://localhost:8081")) + .clientConfig(clientConfig) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder()) + .build(); + } + + @Test + void testGetTaskCreatesSpans() throws Exception { + // Arrange: Create a task directly in the task store + String taskId = "span-test-task-1"; + String contextId = "span-test-ctx-1"; + + Task task = Task.builder() + .id(taskId) + .contextId(contextId) + .status(new TaskStatus(TaskState.COMPLETED)) + .history(Collections.emptyList()) + .artifacts(Collections.emptyList()) + .build(); + + saveTaskInTaskStore(task); + + // Reset spans to only capture the getTask operation + reset(); + + try { + // Act: Get the task via Client API + Task retrievedTask = client.getTask(new TaskQueryParams(taskId), null); + + // Assert: Verify task was retrieved + assertNotNull(retrievedTask); + assertEquals(taskId, retrievedTask.id()); + + // Wait briefly for spans to be exported + Thread.sleep(5000); + + // Assert: Verify spans were created + List> spans = getSpans(); + System.out.println("We have created spans " + spans); + assertFalse(spans.isEmpty(), "Should have created spans for getTask operation"); + + // Verify we have server spans + long serverSpanCount = spans.stream() + .filter(span -> SpanKind.valueOf((span.get("kind").toString())) == SpanKind.SERVER) + .count(); + assertTrue(serverSpanCount > 0, "Should have at least one SERVER span"); + + // Verify A2A attributes on the SERVER span + Map serverSpan = spans.stream() + .filter(span -> SpanKind.valueOf(span.get("kind").toString()) == SpanKind.SERVER) + .filter(span -> "GetTask".equals(span.get("name"))) + .findFirst() + .orElseThrow(() -> new AssertionError("No SERVER span found for GetTask")); + + assertEquals("GetTask", serverSpan.get("attr_gen_ai.agent.a2a.operation.name"), + "Operation name attribute should be set"); + assertEquals(taskId, serverSpan.get("attr_gen_ai.agent.a2a.task_id"), + "Task ID attribute should be set"); + + } finally { + // Cleanup + deleteTaskInTaskStore(taskId); + } + } + + @Test + void testListTasksCreatesSpans() throws Exception { + // Reset spans + reset(); + + // Act: List tasks + ListTasksParams params = new ListTasksParams( + null, null, null, null, null, null, null, "" + ); + + io.a2a.jsonrpc.common.wrappers.ListTasksResult result = client.listTasks(params, null); + + // Assert: Verify result + assertNotNull(result); + + // Wait briefly for spans to be exported + Thread.sleep(5000); + + // Assert: Verify spans + List> spans = getSpans(); + System.out.println("We have created spans " + spans); + assertFalse(spans.isEmpty(), "Should have created spans for listTasks"); + + // Verify A2A attributes on the SERVER span + Map serverSpan = spans.stream() + .filter(span -> SpanKind.valueOf(span.get("kind").toString()) == SpanKind.SERVER) + .filter(span -> "ListTasks".equals(span.get("name"))) + .findFirst() + .orElseThrow(() -> new AssertionError("No SERVER span found for ListTasks")); + + assertEquals("ListTasks", serverSpan.get("attr_gen_ai.agent.a2a.operation.name"), + "Operation name attribute should be set"); + } + + @Test + void testCancelTaskCreatesSpans() throws Exception { + // Arrange: Create a task in WORKING state + String taskId = "cancel-test-task-1"; + String contextId = "cancel-test-ctx-1"; + + Task task = Task.builder() + .id(taskId) + .contextId(contextId) + .status(new TaskStatus(TaskState.WORKING)) + .history(Collections.emptyList()) + .artifacts(Collections.emptyList()) + .build(); + + saveTaskInTaskStore(task); + ensureQueueForTask(taskId); + + // Reset spans to only capture the cancel operation + reset(); + + try { + // Act: Cancel the task + Task cancelledTask = client.cancelTask(new TaskIdParams(taskId), null); + + // Assert: Verify task was cancelled + assertNotNull(cancelledTask); + assertEquals(TaskState.CANCELED, cancelledTask.status().state()); + + // Wait briefly for spans to be exported + Thread.sleep(5000); + + // Assert: Verify spans + List> spans = getSpans(); + System.out.println("We have created spans " + spans); + assertFalse(spans.isEmpty(), "Should have created spans for cancelTask"); + + // Verify A2A attributes on the SERVER span + Map serverSpan = spans.stream() + .filter(span -> SpanKind.valueOf(span.get("kind").toString()) == SpanKind.SERVER) + .filter(span -> "CancelTask".equals(span.get("name"))) + .findFirst() + .orElseThrow(() -> new AssertionError("No SERVER span found for CancelTask")); + + assertEquals("CancelTask", serverSpan.get("attr_gen_ai.agent.a2a.operation.name"), + "Operation name attribute should be set"); + assertEquals(taskId, serverSpan.get("attr_gen_ai.agent.a2a.task_id"), + "Task ID attribute should be set"); + + } finally { + // Cleanup + deleteTaskInTaskStore(taskId); + } + } + + @Test + void testSpanAttributes() throws Exception { + // Arrange: Create a task + String taskId = "attr-test-task-1"; + String contextId = "attr-test-ctx-1"; + + Task task = Task.builder() + .id(taskId) + .contextId(contextId) + .status(new TaskStatus(TaskState.COMPLETED)) + .history(Collections.emptyList()) + .artifacts(Collections.emptyList()) + .build(); + + saveTaskInTaskStore(task); + reset(); + + try { + // Act: Perform operation + client.getTask(new TaskQueryParams(taskId), null); + + // Wait for spans + Thread.sleep(5000); + + // Assert: Verify span details + List> spans = getSpans(); + System.out.println("We have created spans " + spans); + assertFalse(spans.isEmpty()); + + // Find the SERVER span for the getTask operation + Map serverSpan = spans.stream() + .filter(span -> SpanKind.valueOf(span.get("kind").toString()) == SpanKind.SERVER) + .filter(span -> "GetTask".equals(span.get("name"))) + .findFirst() + .orElseThrow(() -> new AssertionError("No SERVER span found for GetTask")); + + // Verify basic span properties + assertNotNull(serverSpan.get("spanId"), "Span should have a span ID"); + assertNotNull(serverSpan.get("traceId"), "Span should have a trace ID"); + assertEquals("GetTask", serverSpan.get("name"), "Span name should be GetTask"); + assertEquals("SERVER", serverSpan.get("kind"), "Span kind should be SERVER"); + assertEquals(Boolean.TRUE, serverSpan.get("ended"), "Span should be ended"); + + // Verify A2A-specific attributes + assertEquals("GetTask", serverSpan.get("attr_gen_ai.agent.a2a.operation.name"), + "Operation name attribute should be set"); + assertEquals(taskId, serverSpan.get("attr_gen_ai.agent.a2a.task_id"), + "Task ID attribute should be set"); + + // Verify resource attributes - service name should be present + assertNotNull(serverSpan.get("resource_service.name"), + "Service name resource attribute should be set"); + System.out.println("Service name: " + serverSpan.get("resource_service.name")); + + // Verify parent span context exists + assertNotNull(serverSpan.get("parentSpanId"), "Span should have parent span ID field"); + + } finally { + deleteTaskInTaskStore(taskId); + } + } + + protected void saveTaskInTaskStore(Task task) throws Exception { + HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/test/task")) + .POST(HttpRequest.BodyPublishers.ofString(JsonUtil.toJson(task))) + .header("Content-Type", APPLICATION_JSON) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() != 200) { + throw new RuntimeException(String.format("Saving task failed! Status: %d, Body: %s", response.statusCode(), response.body())); + } + } + protected Task getTaskFromTaskStore(String taskId) throws Exception { + HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/test/task/" + taskId)) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() == 404) { + return null; + } + if (response.statusCode() != 200) { + throw new RuntimeException(String.format("Getting task failed! Status: %d, Body: %s", response.statusCode(), response.body())); + } + return JsonUtil.fromJson(response.body(), Task.class); + } + + protected void deleteTaskInTaskStore(String taskId) throws Exception { + HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(("http://localhost:" + serverPort + "/test/task/" + taskId))) + .DELETE() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() != 200) { + throw new RuntimeException(response.statusCode() + ": Deleting task failed!" + response.body()); + } + } + + protected void ensureQueueForTask(String taskId) throws Exception { + HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/test/queue/ensure/" + taskId)) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() != 200) { + throw new RuntimeException(String.format("Ensuring queue failed! Status: %d, Body: %s", response.statusCode(), response.body())); + } + } + +} diff --git a/extras/opentelemetry/integration-tests/src/test/java/io/a2a/extras/opentelemetry/it/OpenTelemetryTest.java b/extras/opentelemetry/integration-tests/src/test/java/io/a2a/extras/opentelemetry/it/OpenTelemetryTest.java new file mode 100644 index 000000000..50011fa81 --- /dev/null +++ b/extras/opentelemetry/integration-tests/src/test/java/io/a2a/extras/opentelemetry/it/OpenTelemetryTest.java @@ -0,0 +1,42 @@ +package io.a2a.extras.opentelemetry.it; + +import static io.restassured.RestAssured.given; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.CoreMatchers.is; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +public class OpenTelemetryTest extends BaseTest { + + @BeforeEach + void reset() { + await().atMost(5, SECONDS).until(() -> { + List> spans = getSpans(); + if (spans.isEmpty()) { + return true; + } else { + given().get("/reset").then().statusCode(HTTP_OK); + return false; + } + }); + } + + @Test + void buildTimeEnabled() { + given() + .when().get("/hello") + .then() + .statusCode(200) + .body(is("Hello from Quarkus REST")); + await().atMost(5, SECONDS).until(() -> getSpans().size() == 1); + } +} \ No newline at end of file diff --git a/extras/opentelemetry/integration-tests/src/test/resources/application.properties b/extras/opentelemetry/integration-tests/src/test/resources/application.properties new file mode 100644 index 000000000..1e459ea8e --- /dev/null +++ b/extras/opentelemetry/integration-tests/src/test/resources/application.properties @@ -0,0 +1,25 @@ +# Test configuration +quarkus.application.name=a2a-otel-test + +# HTTP configuration +quarkus.http.test-port=8081 + +# OpenTelemetry configuration for testing +quarkus.otel.sdk.disabled=false +quarkus.otel.traces.enabled=true +quarkus.otel.metrics.enabled=false +quarkus.otel.logs.enabled=false +quarkus.otel.instrument.vertx-http=false + +quarkus.otel.bsp.schedule.delay=0 +quarkus.otel.bsp.export.timeout=5s + +# Service name +quarkus.otel.service.name=a2a-opentelemetry-integration-test + +# Propagators +quarkus.otel.propagators=tracecontext + +# Logging +quarkus.log.level=INFO +quarkus.log.category."io.a2a".level=DEBUG diff --git a/extras/opentelemetry/pom.xml b/extras/opentelemetry/pom.xml new file mode 100644 index 000000000..23e7867de --- /dev/null +++ b/extras/opentelemetry/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + + io.github.a2asdk + a2a-java-sdk-parent + 1.0.0.Alpha2-SNAPSHOT + ../../pom.xml + + + a2a-java-sdk-opentelemetry-parent + pom + + A2A Java SDK :: Extras :: Opentelemetry :: Parent + Java SDK for the Agent2Agent Protocol (A2A) - Extras - Opentelemetry + + + 2.0.1 + + + + org.eclipse.microprofile.telemetry + microprofile-telemetry-api + ${version.org.eclipse.microprofile.telemetry} + pom + provided + + + + + common + client-propagation + client + server + integration-tests + + + \ No newline at end of file diff --git a/extras/opentelemetry/server/pom.xml b/extras/opentelemetry/server/pom.xml new file mode 100644 index 000000000..cef51f092 --- /dev/null +++ b/extras/opentelemetry/server/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-parent + 1.0.0.Alpha2-SNAPSHOT + + + a2a-java-sdk-opentelemetry-server + + A2A Java SDK :: Extras :: Opentelemetry :: Server + OpenTelemetry server support for A2A Java SDK + + + + ${project.groupId} + a2a-java-sdk-opentelemetry-common + + + ${project.groupId} + a2a-java-sdk-server-common + + + ${project.groupId} + a2a-java-sdk-jsonrpc-common + + + ${project.groupId} + a2a-java-sdk-spec-grpc + + + org.eclipse.microprofile.telemetry + microprofile-telemetry-api + ${version.org.eclipse.microprofile.telemetry} + pom + provided + + + jakarta.enterprise + jakarta.enterprise.cdi-api + + + jakarta.inject + jakarta.inject-api + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + + diff --git a/extras/opentelemetry/server/src/main/java/io/a2a/extras/opentelemetry/OpenTelemetryRequestHandlerDecorator.java b/extras/opentelemetry/server/src/main/java/io/a2a/extras/opentelemetry/OpenTelemetryRequestHandlerDecorator.java new file mode 100644 index 000000000..e5aa9c964 --- /dev/null +++ b/extras/opentelemetry/server/src/main/java/io/a2a/extras/opentelemetry/OpenTelemetryRequestHandlerDecorator.java @@ -0,0 +1,465 @@ +package io.a2a.extras.opentelemetry; + +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.ERROR_TYPE; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.EXTRACT_REQUEST_SYS_PROPERTY; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.EXTRACT_RESPONSE_SYS_PROPERTY; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_CONFIG_ID; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_CONTEXT_ID; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_EXTENSIONS; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_MESSAGE_ID; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_OPERATION_NAME; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_PARTS_NUMBER; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_REQUEST; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_RESPONSE; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_ROLE; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_TASK_ID; + +import io.a2a.jsonrpc.common.wrappers.ListTasksResult; +import io.a2a.server.ServerCallContext; +import io.a2a.server.requesthandlers.RequestHandler; +import io.a2a.spec.A2AError; +import io.a2a.spec.A2AMethods; +import io.a2a.spec.DeleteTaskPushNotificationConfigParams; +import io.a2a.spec.EventKind; +import io.a2a.spec.GetTaskPushNotificationConfigParams; +import io.a2a.spec.ListTaskPushNotificationConfigParams; +import io.a2a.spec.ListTaskPushNotificationConfigResult; +import io.a2a.spec.ListTasksParams; +import io.a2a.spec.MessageSendParams; +import io.a2a.spec.StreamingEventKind; +import io.a2a.spec.Task; +import io.a2a.spec.TaskIdParams; +import io.a2a.spec.TaskPushNotificationConfig; +import io.a2a.spec.TaskQueryParams; +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 io.opentelemetry.context.Scope; +import jakarta.annotation.Priority; +import jakarta.decorator.Decorator; +import jakarta.decorator.Delegate; +import jakarta.enterprise.inject.Any; +import jakarta.inject.Inject; +import java.util.concurrent.Flow; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * OpenTelemetry CDI Decorator for {@link RequestHandler}. + *

+ * This decorator adds distributed tracing to A2A server request handlers. + * It creates spans for each request handler method invocation, capturing: + *

    + *
  • Request parameters as span attributes
  • + *
  • Response data as span attributes
  • + *
  • Errors and exceptions with proper status codes
  • + *
  • Timing information for performance monitoring
  • + *
+ *

+ * To enable this decorator, add it to your beans.xml: + *

{@code
+ * 
+ *     io.a2a.extras.opentelemetry.OpenTelemetryRequestHandlerDecorator
+ * 
+ * }
+ */ +@Decorator +@Priority(100) +public abstract class OpenTelemetryRequestHandlerDecorator implements RequestHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(OpenTelemetryRequestHandlerDecorator.class); + + @Inject + @Delegate + @Any + private RequestHandler delegate; + + @Inject + private Tracer tracer; + + /** + * Default constructor for CDI. + */ + public OpenTelemetryRequestHandlerDecorator() { + } + + /** + * Constructor for testing. + * + * @param delegate the delegate request handler + * @param tracer the tracer to use + */ + public OpenTelemetryRequestHandlerDecorator(RequestHandler delegate, Tracer tracer) { + this.delegate = delegate; + this.tracer = tracer; + } + + @Override + public Task onGetTask(TaskQueryParams params, ServerCallContext context) throws A2AError { + var spanBuilder = tracer.spanBuilder(A2AMethods.GET_TASK_METHOD) + .setSpanKind(SpanKind.SERVER) + .setAttribute(GENAI_OPERATION_NAME, A2AMethods.GET_TASK_METHOD); + + if (params.id() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, params.id()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, params.toString()); + } + + Span span = spanBuilder.startSpan(); + + try (Scope scope = span.makeCurrent()) { + Task result = delegate.onGetTask(params, context); + + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + + span.setStatus(StatusCode.OK); + return result; + } catch (A2AError error) { + span.setAttribute(ERROR_TYPE, error.getMessage()); + span.setStatus(StatusCode.ERROR, error.getMessage()); + throw error; + } finally { + span.end(); + } + } + + @Override + public ListTasksResult onListTasks(ListTasksParams params, ServerCallContext context) throws A2AError { + var spanBuilder = tracer.spanBuilder(A2AMethods.LIST_TASK_METHOD) + .setSpanKind(SpanKind.SERVER) + .setAttribute(GENAI_OPERATION_NAME, A2AMethods.LIST_TASK_METHOD); + + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, params.toString()); + } + if (params.contextId() != null) { + spanBuilder.setAttribute(GENAI_CONTEXT_ID, params.contextId()); + } + Span span = spanBuilder.startSpan(); + + try (Scope scope = span.makeCurrent()) { + ListTasksResult result = delegate.onListTasks(params, context); + + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + + span.setStatus(StatusCode.OK); + return result; + } catch (A2AError error) { + span.setAttribute(ERROR_TYPE, error.getMessage()); + span.setStatus(StatusCode.ERROR, error.getMessage()); + throw error; + } finally { + span.end(); + } + } + + @Override + public Task onCancelTask(TaskIdParams params, ServerCallContext context) throws A2AError { + var spanBuilder = tracer.spanBuilder(A2AMethods.CANCEL_TASK_METHOD) + .setSpanKind(SpanKind.SERVER) + .setAttribute(GENAI_OPERATION_NAME, A2AMethods.CANCEL_TASK_METHOD); + + if (params.id() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, params.id()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, params.toString()); + } + + Span span = spanBuilder.startSpan(); + + try (Scope scope = span.makeCurrent()) { + Task result = delegate.onCancelTask(params, context); + + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + + span.setStatus(StatusCode.OK); + return result; + } catch (A2AError error) { + span.setAttribute(ERROR_TYPE, error.getMessage()); + span.setStatus(StatusCode.ERROR, error.getMessage()); + throw error; + } finally { + span.end(); + } + } + + @Override + public EventKind onMessageSend(MessageSendParams params, ServerCallContext context) throws A2AError { + var spanBuilder = tracer.spanBuilder(A2AMethods.SEND_MESSAGE_METHOD) + .setSpanKind(SpanKind.SERVER) + .setAttribute(GENAI_OPERATION_NAME, A2AMethods.SEND_MESSAGE_METHOD); + + if (params.message() != null) { + if (params.message().taskId() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, params.message().taskId()); + } + if (params.message().contextId() != null) { + spanBuilder.setAttribute(GENAI_CONTEXT_ID, params.message().contextId()); + } + if (params.message().messageId() != null) { + spanBuilder.setAttribute(GENAI_MESSAGE_ID, params.message().messageId()); + } + if (params.message().role() != null) { + spanBuilder.setAttribute(GENAI_ROLE, params.message().role().asString()); + } + if (params.message().extensions() != null && !params.message().extensions().isEmpty()) { + spanBuilder.setAttribute(GENAI_EXTENSIONS, String.join(",", params.message().extensions())); + } + spanBuilder.setAttribute(GENAI_PARTS_NUMBER, params.message().parts().size()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, params.toString()); + } + + Span span = spanBuilder.startSpan(); + + try (Scope scope = span.makeCurrent()) { + EventKind result = delegate.onMessageSend(params, context); + + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + + span.setStatus(StatusCode.OK); + return result; + } catch (A2AError error) { + span.setAttribute(ERROR_TYPE, error.getMessage()); + span.setStatus(StatusCode.ERROR, error.getMessage()); + throw error; + } finally { + span.end(); + } + } + + @Override + public Flow.Publisher onMessageSendStream(MessageSendParams params, ServerCallContext context) throws A2AError { + var spanBuilder = tracer.spanBuilder(A2AMethods.SEND_STREAMING_MESSAGE_METHOD) + .setSpanKind(SpanKind.SERVER) + .setAttribute(GENAI_OPERATION_NAME, A2AMethods.SEND_STREAMING_MESSAGE_METHOD); + + if (params.message() != null) { + if (params.message().taskId() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, params.message().taskId()); + } + if (params.message().contextId() != null) { + spanBuilder.setAttribute(GENAI_CONTEXT_ID, params.message().contextId()); + } + if (params.message().messageId() != null) { + spanBuilder.setAttribute(GENAI_MESSAGE_ID, params.message().messageId()); + } + if (params.message().role() != null) { + spanBuilder.setAttribute(GENAI_ROLE, params.message().role().asString()); + } + if (params.message().extensions() != null && !params.message().extensions().isEmpty()) { + spanBuilder.setAttribute(GENAI_EXTENSIONS, String.join(",", params.message().extensions())); + } + spanBuilder.setAttribute(GENAI_PARTS_NUMBER, params.message().parts().size()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, params.toString()); + } + + Span span = spanBuilder.startSpan(); + + try (Scope scope = span.makeCurrent()) { + Flow.Publisher result = delegate.onMessageSendStream(params, context); + + if (extractResponse()) { + span.setAttribute(GENAI_RESPONSE, "Stream publisher created"); + } + + span.setStatus(StatusCode.OK); + return result; + } catch (A2AError error) { + span.setAttribute(ERROR_TYPE, error.getMessage()); + span.setStatus(StatusCode.ERROR, error.getMessage()); + throw error; + } finally { + span.end(); + } + } + + @Override + public TaskPushNotificationConfig onCreateTaskPushNotificationConfig(TaskPushNotificationConfig params, ServerCallContext context) throws A2AError { + var spanBuilder = tracer.spanBuilder(A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD) + .setSpanKind(SpanKind.SERVER) + .setAttribute(GENAI_OPERATION_NAME, A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + + if (params.taskId() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, params.taskId()); + } + if (params.pushNotificationConfig() != null && params.pushNotificationConfig().id() != null) { + spanBuilder.setAttribute(GENAI_CONFIG_ID, params.pushNotificationConfig().id()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, params.toString()); + } + + Span span = spanBuilder.startSpan(); + + try (Scope scope = span.makeCurrent()) { + TaskPushNotificationConfig result = delegate.onCreateTaskPushNotificationConfig(params, context); + + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + + span.setStatus(StatusCode.OK); + return result; + } catch (A2AError error) { + span.setAttribute(ERROR_TYPE, error.getMessage()); + span.setStatus(StatusCode.ERROR, error.getMessage()); + throw error; + } finally { + span.end(); + } + } + + @Override + public TaskPushNotificationConfig onGetTaskPushNotificationConfig(GetTaskPushNotificationConfigParams params, ServerCallContext context) throws A2AError { + var spanBuilder = tracer.spanBuilder(A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD) + .setSpanKind(SpanKind.SERVER) + .setAttribute(GENAI_OPERATION_NAME, A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + + if (params.id() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, params.id()); + } + if (params.pushNotificationConfigId() != null) { + spanBuilder.setAttribute(GENAI_CONFIG_ID, params.pushNotificationConfigId()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, params.toString()); + } + + Span span = spanBuilder.startSpan(); + + try (Scope scope = span.makeCurrent()) { + TaskPushNotificationConfig result = delegate.onGetTaskPushNotificationConfig(params, context); + + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + + span.setStatus(StatusCode.OK); + return result; + } catch (A2AError error) { + span.setAttribute(ERROR_TYPE, error.getMessage()); + span.setStatus(StatusCode.ERROR, error.getMessage()); + throw error; + } finally { + span.end(); + } + } + + @Override + public Flow.Publisher onResubscribeToTask(TaskIdParams params, ServerCallContext context) throws A2AError { + var spanBuilder = tracer.spanBuilder(A2AMethods.SUBSCRIBE_TO_TASK_METHOD) + .setSpanKind(SpanKind.SERVER) + .setAttribute(GENAI_OPERATION_NAME, A2AMethods.SUBSCRIBE_TO_TASK_METHOD); + + if (params.id() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, params.id()); + } + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, params.toString()); + } + + Span span = spanBuilder.startSpan(); + + try (Scope scope = span.makeCurrent()) { + Flow.Publisher result = delegate.onResubscribeToTask(params, context); + + if (extractResponse()) { + span.setAttribute(GENAI_RESPONSE, "Stream publisher created"); + } + + span.setStatus(StatusCode.OK); + return result; + } catch (A2AError error) { + span.setAttribute(ERROR_TYPE, error.getMessage()); + span.setStatus(StatusCode.ERROR, error.getMessage()); + throw error; + } finally { + span.end(); + } + } + + @Override + public ListTaskPushNotificationConfigResult onListTaskPushNotificationConfig(ListTaskPushNotificationConfigParams params, ServerCallContext context) throws A2AError { + var spanBuilder = tracer.spanBuilder(A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD) + .setSpanKind(SpanKind.SERVER) + .setAttribute(GENAI_OPERATION_NAME, A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, params.toString()); + } + if (params.id() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, params.id()); + } + + Span span = spanBuilder.startSpan(); + + try (Scope scope = span.makeCurrent()) { + ListTaskPushNotificationConfigResult result = delegate.onListTaskPushNotificationConfig(params, context); + + if (result != null && extractResponse()) { + span.setAttribute(GENAI_RESPONSE, result.toString()); + } + + span.setStatus(StatusCode.OK); + return result; + } catch (A2AError error) { + span.setAttribute(ERROR_TYPE, error.getMessage()); + span.setStatus(StatusCode.ERROR, error.getMessage()); + throw error; + } finally { + span.end(); + } + } + + @Override + public void onDeleteTaskPushNotificationConfig(DeleteTaskPushNotificationConfigParams params, ServerCallContext context) throws A2AError { + var spanBuilder = tracer.spanBuilder(A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD) + .setSpanKind(SpanKind.SERVER) + .setAttribute(GENAI_OPERATION_NAME, A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + + if (extractRequest()) { + spanBuilder.setAttribute(GENAI_REQUEST, params.toString()); + } + if (params.id() != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, params.id()); + } + + Span span = spanBuilder.startSpan(); + + try (Scope scope = span.makeCurrent()) { + delegate.onDeleteTaskPushNotificationConfig(params, context); + + span.setStatus(StatusCode.OK); + } catch (A2AError error) { + span.setAttribute(ERROR_TYPE, error.getMessage()); + span.setStatus(StatusCode.ERROR, error.getMessage()); + throw error; + } finally { + span.end(); + } + } + + private boolean extractRequest() { + return Boolean.getBoolean(EXTRACT_REQUEST_SYS_PROPERTY); + } + + private boolean extractResponse() { + return Boolean.getBoolean(EXTRACT_RESPONSE_SYS_PROPERTY); + } +} diff --git a/extras/opentelemetry/server/src/main/java/io/a2a/extras/opentelemetry/package-info.java b/extras/opentelemetry/server/src/main/java/io/a2a/extras/opentelemetry/package-info.java new file mode 100644 index 000000000..fdd7057d5 --- /dev/null +++ b/extras/opentelemetry/server/src/main/java/io/a2a/extras/opentelemetry/package-info.java @@ -0,0 +1,5 @@ +@NullMarked +package io.a2a.extras.opentelemetry; + +import org.jspecify.annotations.NullMarked; + diff --git a/extras/opentelemetry/server/src/main/resources/META-INF/beans.xml b/extras/opentelemetry/server/src/main/resources/META-INF/beans.xml new file mode 100644 index 000000000..dcc4135c8 --- /dev/null +++ b/extras/opentelemetry/server/src/main/resources/META-INF/beans.xml @@ -0,0 +1,9 @@ + + + + io.a2a.extras.opentelemetry.OpenTelemetryRequestHandlerDecorator + + \ No newline at end of file diff --git a/extras/opentelemetry/server/src/test/java/io/a2a/extras/opentelemetry/OpenTelemetryRequestHandlerDecoratorTest.java b/extras/opentelemetry/server/src/test/java/io/a2a/extras/opentelemetry/OpenTelemetryRequestHandlerDecoratorTest.java new file mode 100644 index 000000000..a575b6fb1 --- /dev/null +++ b/extras/opentelemetry/server/src/test/java/io/a2a/extras/opentelemetry/OpenTelemetryRequestHandlerDecoratorTest.java @@ -0,0 +1,497 @@ +package io.a2a.extras.opentelemetry; + +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.ERROR_TYPE; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.EXTRACT_REQUEST_SYS_PROPERTY; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.EXTRACT_RESPONSE_SYS_PROPERTY; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_REQUEST; +import static io.a2a.extras.opentelemetry.A2AObservabilityNames.GENAI_RESPONSE; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import io.a2a.jsonrpc.common.wrappers.ListTasksResult; +import io.a2a.server.ServerCallContext; +import io.a2a.server.requesthandlers.RequestHandler; +import io.a2a.spec.*; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Flow; + +@ExtendWith(MockitoExtension.class) +class OpenTelemetryRequestHandlerDecoratorTest { + + @Mock + private Tracer tracer; + + @Mock + private Span span; + + @Mock + private SpanBuilder spanBuilder; + + @Mock + private Scope scope; + + @Mock + private ServerCallContext context; + + @Mock + private RequestHandler delegate; + + private TestableOpenTelemetryRequestHandlerDecorator decorator; + + @BeforeEach + void setUp() { + // Set system properties for extracting request/response + System.setProperty(EXTRACT_REQUEST_SYS_PROPERTY, "true"); + System.setProperty(EXTRACT_RESPONSE_SYS_PROPERTY, "true"); + + // Set up the mock chain + lenient().when(tracer.spanBuilder(anyString())).thenReturn(spanBuilder); + lenient().when(spanBuilder.setSpanKind(any(SpanKind.class))).thenReturn(spanBuilder); + lenient().when(spanBuilder.setAttribute(anyString(), anyString())).thenReturn(spanBuilder); + lenient().when(spanBuilder.setAttribute(anyString(), anyLong())).thenReturn(spanBuilder); + lenient().when(spanBuilder.startSpan()).thenReturn(span); + lenient().when(span.makeCurrent()).thenReturn(scope); + lenient().when(span.setAttribute(anyString(), anyString())).thenReturn(span); + lenient().when(span.setStatus(any(StatusCode.class))).thenReturn(span); + lenient().when(span.setStatus(any(StatusCode.class), anyString())).thenReturn(span); + + // Create decorator with mocked dependencies + decorator = new TestableOpenTelemetryRequestHandlerDecorator(delegate, tracer); + } + + /** + * Concrete test implementation of the abstract decorator for testing purposes. + */ + static class TestableOpenTelemetryRequestHandlerDecorator extends OpenTelemetryRequestHandlerDecorator { + public TestableOpenTelemetryRequestHandlerDecorator(RequestHandler delegate, Tracer tracer) { + super(delegate, tracer); + } + } + + @Nested + class GetTaskTests { + @Test + void onGetTask_createsSpanAndDelegatesToHandler() throws A2AError { + TaskQueryParams params = new TaskQueryParams("task-123", null); + Task result = Task.builder() + .id("task-123") + .contextId("ctx-1") + .status(new TaskStatus(TaskState.COMPLETED)) + .history(Collections.emptyList()) + .artifacts(Collections.emptyList()) + .build(); + when(delegate.onGetTask(params, context)).thenReturn(result); + + Task actualResult = decorator.onGetTask(params, context); + + assertEquals(result, actualResult); + verify(tracer).spanBuilder(A2AMethods.GET_TASK_METHOD); + verify(spanBuilder).setSpanKind(SpanKind.SERVER); + verify(spanBuilder).setAttribute(GENAI_REQUEST, params.toString()); + verify(spanBuilder).startSpan(); + verify(span).makeCurrent(); + verify(span).setAttribute(GENAI_RESPONSE, result.toString()); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + verify(delegate).onGetTask(params, context); + } + + @Test + void onGetTask_withError_setsErrorStatusAndRethrows() throws A2AError { + TaskQueryParams params = new TaskQueryParams("task-123", null); + A2AError error = new TaskNotFoundError(); + when(delegate.onGetTask(params, context)).thenThrow(error); + + assertThrows(TaskNotFoundError.class, () -> decorator.onGetTask(params, context)); + + verify(span).setAttribute(ERROR_TYPE, error.getMessage()); + verify(span).setStatus(StatusCode.ERROR, error.getMessage()); + verify(span).end(); + } + } + + @Nested + class ListTasksTests { + @Test + void onListTasks_createsSpanAndDelegatesToHandler() throws A2AError { + ListTasksParams params = new ListTasksParams(null, null, null, null, null, null, null, "test-tenant"); + ListTasksResult result = new ListTasksResult(Collections.emptyList(), 0, 0, null); + when(delegate.onListTasks(params, context)).thenReturn(result); + + ListTasksResult actualResult = decorator.onListTasks(params, context); + + assertEquals(result, actualResult); + verify(tracer).spanBuilder(A2AMethods.LIST_TASK_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, params.toString()); + verify(span).setAttribute(GENAI_RESPONSE, result.toString()); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void onListTasks_withError_setsErrorStatus() throws A2AError { + ListTasksParams params = new ListTasksParams(null, null, null, null, null, null, null, "test-tenant"); + A2AError error = new InvalidRequestError("Invalid parameters"); + when(delegate.onListTasks(params, context)).thenThrow(error); + + assertThrows(InvalidRequestError.class, () -> decorator.onListTasks(params, context)); + + verify(span).setAttribute(ERROR_TYPE, error.getMessage()); + verify(span).setStatus(StatusCode.ERROR, error.getMessage()); + verify(span).end(); + } + } + + @Nested + class CancelTaskTests { + @Test + void onCancelTask_createsSpanAndDelegatesToHandler() throws A2AError { + TaskIdParams params = new TaskIdParams("task-123"); + Task result = Task.builder() + .id("task-123") + .contextId("ctx-1") + .status(new TaskStatus(TaskState.CANCELED)) + .history(Collections.emptyList()) + .artifacts(Collections.emptyList()) + .build(); + when(delegate.onCancelTask(params, context)).thenReturn(result); + + Task actualResult = decorator.onCancelTask(params, context); + + assertEquals(result, actualResult); + verify(tracer).spanBuilder(A2AMethods.CANCEL_TASK_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, params.toString()); + verify(span).setAttribute(GENAI_RESPONSE, result.toString()); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void onCancelTask_withError_setsErrorStatus() throws A2AError { + TaskIdParams params = new TaskIdParams("task-123"); + A2AError error = new TaskNotFoundError(); + when(delegate.onCancelTask(params, context)).thenThrow(error); + + assertThrows(TaskNotFoundError.class, () -> decorator.onCancelTask(params, context)); + + verify(span).setAttribute(ERROR_TYPE, error.getMessage()); + verify(span).setStatus(StatusCode.ERROR, error.getMessage()); + verify(span).end(); + } + } + + @Nested + class MessageSendTests { + @Test + void onMessageSend_createsSpanAndDelegatesToHandler() throws A2AError { + Message message = Message.builder() + .role(Message.Role.USER) + .parts(List.of(new TextPart("test message"))) + .messageId("msg-123") + .contextId("ctx-1") + .taskId("task-123") + .build(); + MessageSendParams params = new MessageSendParams(message, null, null, ""); + EventKind result = Task.builder() + .id("task-123") + .contextId("ctx-1") + .status(new TaskStatus(TaskState.COMPLETED)) + .history(Collections.emptyList()) + .artifacts(Collections.emptyList()) + .build(); + when(delegate.onMessageSend(params, context)).thenReturn(result); + + EventKind actualResult = decorator.onMessageSend(params, context); + + assertEquals(result, actualResult); + verify(tracer).spanBuilder(A2AMethods.SEND_MESSAGE_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, params.toString()); + verify(span).setAttribute(GENAI_RESPONSE, result.toString()); + verify(span).setStatus(StatusCode.OK); + } + + @Test + void onMessageSend_withError_setsErrorStatus() throws A2AError { + Message message = Message.builder() + .role(Message.Role.USER) + .parts(List.of(new TextPart("test message"))) + .messageId("msg-123") + .contextId("ctx-1") + .taskId("task-123") + .build(); + MessageSendParams params = new MessageSendParams(message, null, null, ""); + A2AError error = new InvalidRequestError("Invalid message"); + when(delegate.onMessageSend(params, context)).thenThrow(error); + + assertThrows(InvalidRequestError.class, () -> decorator.onMessageSend(params, context)); + + verify(span).setAttribute(ERROR_TYPE, error.getMessage()); + verify(span).setStatus(StatusCode.ERROR, error.getMessage()); + verify(span).end(); + } + } + + @Nested + class MessageSendStreamTests { + @Test + void onMessageSendStream_createsSpanWithSpecialMessage() throws A2AError { + Message message = Message.builder() + .role(Message.Role.USER) + .parts(List.of(new TextPart("test message"))) + .messageId("msg-123") + .contextId("ctx-1") + .taskId("task-123") + .build(); + MessageSendParams params = new MessageSendParams(message, null, null, ""); + Flow.Publisher publisher = mock(Flow.Publisher.class); + when(delegate.onMessageSendStream(params, context)).thenReturn(publisher); + + Flow.Publisher actualResult = decorator.onMessageSendStream(params, context); + + assertEquals(publisher, actualResult); + verify(tracer).spanBuilder(A2AMethods.SEND_STREAMING_MESSAGE_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, params.toString()); + verify(span).setAttribute(GENAI_RESPONSE, "Stream publisher created"); + verify(span).setStatus(StatusCode.OK); + } + + @Test + void onMessageSendStream_withError_setsErrorStatus() throws A2AError { + Message message = Message.builder() + .role(Message.Role.USER) + .parts(List.of(new TextPart("test message"))) + .messageId("msg-123") + .contextId("ctx-1") + .taskId("task-123") + .build(); + MessageSendParams params = new MessageSendParams(message, null, null, ""); + A2AError error = new InvalidRequestError("Stream error"); + when(delegate.onMessageSendStream(params, context)).thenThrow(error); + + assertThrows(InvalidRequestError.class, () -> decorator.onMessageSendStream(params, context)); + + verify(span).setAttribute(ERROR_TYPE, error.getMessage()); + verify(span).setStatus(StatusCode.ERROR, error.getMessage()); + verify(span).end(); + } + } + + @Nested + class SetTaskPushNotificationConfigTests { + @Test + void onSetTaskPushNotificationConfig_createsSpanAndDelegatesToHandler() throws A2AError { + PushNotificationConfig config = new PushNotificationConfig("http://example.com", null, null, "config-1"); + TaskPushNotificationConfig params = new TaskPushNotificationConfig("task-123", config, null); + TaskPushNotificationConfig result = new TaskPushNotificationConfig("task-123", config, null); + when(delegate.onCreateTaskPushNotificationConfig(params, context)).thenReturn(result); + + TaskPushNotificationConfig actualResult = decorator.onCreateTaskPushNotificationConfig(params, context); + + assertEquals(result, actualResult); + verify(tracer).spanBuilder(A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, params.toString()); + verify(span).setAttribute(GENAI_RESPONSE, result.toString()); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void onSetTaskPushNotificationConfig_withError_setsErrorStatus() throws A2AError { + PushNotificationConfig config = new PushNotificationConfig("http://example.com", null, null, "config-1"); + TaskPushNotificationConfig params = new TaskPushNotificationConfig("task-123", config, null); + A2AError error = new InvalidRequestError("Invalid config"); + when(delegate.onCreateTaskPushNotificationConfig(params, context)).thenThrow(error); + + assertThrows(InvalidRequestError.class, () -> decorator.onCreateTaskPushNotificationConfig(params, context)); + + verify(span).setAttribute(ERROR_TYPE, error.getMessage()); + verify(span).setStatus(StatusCode.ERROR, error.getMessage()); + verify(span).end(); + } + } + + @Nested + class GetTaskPushNotificationConfigTests { + @Test + void onGetTaskPushNotificationConfig_createsSpanAndDelegatesToHandler() throws A2AError { + GetTaskPushNotificationConfigParams params = new GetTaskPushNotificationConfigParams("task-123", null); + PushNotificationConfig config = new PushNotificationConfig("http://example.com", null, null, "config-1"); + TaskPushNotificationConfig result = new TaskPushNotificationConfig("task-123", config, null); + when(delegate.onGetTaskPushNotificationConfig(params, context)).thenReturn(result); + + TaskPushNotificationConfig actualResult = decorator.onGetTaskPushNotificationConfig(params, context); + + assertEquals(result, actualResult); + verify(tracer).spanBuilder(A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, params.toString()); + verify(span).setAttribute(GENAI_RESPONSE, result.toString()); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void onGetTaskPushNotificationConfig_withError_setsErrorStatus() throws A2AError { + GetTaskPushNotificationConfigParams params = new GetTaskPushNotificationConfigParams("task-123", null); + A2AError error = new TaskNotFoundError(); + when(delegate.onGetTaskPushNotificationConfig(params, context)).thenThrow(error); + + assertThrows(TaskNotFoundError.class, () -> decorator.onGetTaskPushNotificationConfig(params, context)); + + verify(span).setAttribute(ERROR_TYPE, error.getMessage()); + verify(span).setStatus(StatusCode.ERROR, error.getMessage()); + verify(span).end(); + } + } + + @Nested + class ResubscribeToTaskTests { + @Test + void onResubscribeToTask_createsSpanWithSpecialMessage() throws A2AError { + TaskIdParams params = new TaskIdParams("task-123"); + Flow.Publisher publisher = mock(Flow.Publisher.class); + when(delegate.onResubscribeToTask(params, context)).thenReturn(publisher); + + Flow.Publisher actualResult = decorator.onResubscribeToTask(params, context); + + assertEquals(publisher, actualResult); + verify(tracer).spanBuilder(A2AMethods.SUBSCRIBE_TO_TASK_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, params.toString()); + verify(span).setAttribute(GENAI_RESPONSE, "Stream publisher created"); + verify(span).setStatus(StatusCode.OK); + } + + @Test + void onResubscribeToTask_withError_setsErrorStatus() throws A2AError { + TaskIdParams params = new TaskIdParams("task-123"); + A2AError error = new TaskNotFoundError(); + when(delegate.onResubscribeToTask(params, context)).thenThrow(error); + + assertThrows(TaskNotFoundError.class, () -> decorator.onResubscribeToTask(params, context)); + + verify(span).setAttribute(ERROR_TYPE, error.getMessage()); + verify(span).setStatus(StatusCode.ERROR, error.getMessage()); + verify(span).end(); + } + } + + @Nested + class ListTaskPushNotificationConfigTests { + @Test + void onListTaskPushNotificationConfig_createsSpanAndDelegatesToHandler() throws A2AError { + ListTaskPushNotificationConfigParams params = new ListTaskPushNotificationConfigParams("task-123"); + ListTaskPushNotificationConfigResult result = new ListTaskPushNotificationConfigResult(Collections.emptyList(), null); + when(delegate.onListTaskPushNotificationConfig(params, context)).thenReturn(result); + + ListTaskPushNotificationConfigResult actualResult = decorator.onListTaskPushNotificationConfig(params, context); + + assertEquals(result, actualResult); + verify(tracer).spanBuilder(A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, params.toString()); + verify(span).setAttribute(GENAI_RESPONSE, result.toString()); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void onListTaskPushNotificationConfig_withError_setsErrorStatus() throws A2AError { + ListTaskPushNotificationConfigParams params = new ListTaskPushNotificationConfigParams("task-123"); + A2AError error = new InvalidRequestError("Invalid request"); + when(delegate.onListTaskPushNotificationConfig(params, context)).thenThrow(error); + + assertThrows(InvalidRequestError.class, () -> decorator.onListTaskPushNotificationConfig(params, context)); + + verify(span).setAttribute(ERROR_TYPE, error.getMessage()); + verify(span).setStatus(StatusCode.ERROR, error.getMessage()); + verify(span).end(); + } + } + + @Nested + class DeleteTaskPushNotificationConfigTests { + @Test + void onDeleteTaskPushNotificationConfig_createsSpanAndDelegatesToHandler() throws A2AError { + DeleteTaskPushNotificationConfigParams params = new DeleteTaskPushNotificationConfigParams("task-123", "config-123"); + doNothing().when(delegate).onDeleteTaskPushNotificationConfig(params, context); + + decorator.onDeleteTaskPushNotificationConfig(params, context); + + verify(tracer).spanBuilder(A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + verify(spanBuilder).setAttribute(GENAI_REQUEST, params.toString()); + verify(span).setStatus(StatusCode.OK); + verify(span, never()).setAttribute(eq(GENAI_RESPONSE), anyString()); + verify(span).end(); + } + + @Test + void onDeleteTaskPushNotificationConfig_withError_setsErrorStatus() throws A2AError { + DeleteTaskPushNotificationConfigParams params = new DeleteTaskPushNotificationConfigParams("task-123", "config-123"); + A2AError error = new TaskNotFoundError(); + doThrow(error).when(delegate).onDeleteTaskPushNotificationConfig(params, context); + + assertThrows(TaskNotFoundError.class, () -> decorator.onDeleteTaskPushNotificationConfig(params, context)); + + verify(span).setAttribute(ERROR_TYPE, error.getMessage()); + verify(span).setStatus(StatusCode.ERROR, error.getMessage()); + verify(span).end(); + } + } + + @Nested + class SpanLifecycleTests { + @Test + void allMethods_createAndEndSpans() throws A2AError { + TaskQueryParams params = new TaskQueryParams("task-123", null); + Task result = Task.builder() + .id("task-123") + .contextId("ctx-1") + .status(new TaskStatus(TaskState.COMPLETED)) + .history(Collections.emptyList()) + .artifacts(Collections.emptyList()) + .build(); + when(delegate.onGetTask(params, context)).thenReturn(result); + + decorator.onGetTask(params, context); + + verify(span, times(1)).makeCurrent(); + verify(span, times(1)).end(); + } + + @Test + void spanAttributes_setCorrectly() throws A2AError { + TaskQueryParams params = new TaskQueryParams("task-123", null); + Task result = Task.builder() + .id("task-123") + .contextId("ctx-1") + .status(new TaskStatus(TaskState.COMPLETED)) + .history(Collections.emptyList()) + .artifacts(Collections.emptyList()) + .build(); + when(delegate.onGetTask(params, context)).thenReturn(result); + + decorator.onGetTask(params, context); + + verify(tracer).spanBuilder(A2AMethods.GET_TASK_METHOD); + verify(spanBuilder).setSpanKind(SpanKind.SERVER); + verify(spanBuilder).setAttribute(GENAI_REQUEST, params.toString()); + verify(span).setAttribute(GENAI_RESPONSE, result.toString()); + verify(span).setStatus(StatusCode.OK); + } + } +} diff --git a/pom.xml b/pom.xml index 8f3545dba..dcf90ba44 100644 --- a/pom.xml +++ b/pom.xml @@ -189,6 +189,36 @@ a2a-java-sdk-reference-rest ${project.version}
+ + io.github.a2asdk + a2a-java-sdk-opentelemetry + ${project.version} + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-spring + ${project.version} + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-common + ${project.version} + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-client + ${project.version} + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-server + ${project.version} + + + io.github.a2asdk + a2a-java-sdk-opentelemetry-client-propagation + ${project.version} + io.grpc grpc-bom @@ -512,6 +542,7 @@ examples/helloworld examples/cloud-deployment/server extras/common + extras/opentelemetry extras/task-store-database-jpa extras/push-notification-config-store-database-jpa extras/queue-manager-replicated diff --git a/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/A2AExtensionsInterceptor.java b/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/A2AExtensionsInterceptor.java index 98e40585b..3b0421b44 100644 --- a/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/A2AExtensionsInterceptor.java +++ b/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/A2AExtensionsInterceptor.java @@ -14,7 +14,7 @@ /** * gRPC server interceptor that captures request metadata and context information, * providing equivalent functionality to Python's grpc.aio.ServicerContext. - * + * * This interceptor: * - Extracts A2A extension headers from incoming requests * - Captures ServerCall and Metadata for rich context access @@ -24,7 +24,6 @@ @ApplicationScoped public class A2AExtensionsInterceptor implements ServerInterceptor { - @Override public ServerCall.Listener interceptCall( ServerCall serverCall, @@ -43,12 +42,14 @@ public ServerCall.Listener interceptCall( // Create enhanced context with rich information (equivalent to Python's ServicerContext) Context context = Context.current() - // Store complete metadata for full header access - .withValue(GrpcContextKeys.METADATA_KEY, metadata) - // Store method name (equivalent to Python's context.method()) - .withValue(GrpcContextKeys.METHOD_NAME_KEY, serverCall.getMethodDescriptor().getFullMethodName()) - // Store peer information for client connection details - .withValue(GrpcContextKeys.PEER_INFO_KEY, getPeerInfo(serverCall)); + // Store complete metadata for full header access + .withValue(GrpcContextKeys.METADATA_KEY, metadata) + // Store Grpc method name + .withValue(GrpcContextKeys.GRPC_METHOD_NAME_KEY, serverCall.getMethodDescriptor().getFullMethodName()) + // Store method name (equivalent to Python's context.method()) + .withValue(GrpcContextKeys.METHOD_NAME_KEY, GrpcContextKeys.METHOD_MAPPING.get(serverCall.getMethodDescriptor().getBareMethodName())) + // Store peer information for client connection details + .withValue(GrpcContextKeys.PEER_INFO_KEY, getPeerInfo(serverCall)); // Store A2A version if present if (version != null) { @@ -66,7 +67,7 @@ public ServerCall.Listener interceptCall( /** * Safely extracts peer information from the ServerCall. - * + * * @param serverCall the gRPC ServerCall * @return peer information string, or "unknown" if not available */ diff --git a/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java b/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java index a41f2d361..b49115fa4 100644 --- a/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java +++ b/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java @@ -50,6 +50,7 @@ import org.jspecify.annotations.Nullable; import static io.a2a.spec.A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; +import static io.a2a.spec.A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD; import static io.a2a.spec.A2AMethods.GET_TASK_METHOD; import static io.a2a.spec.A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; import static io.a2a.spec.A2AMethods.LIST_TASK_METHOD; @@ -88,7 +89,7 @@ public void sendMessage(@Body String body, RoutingContext rc) { ServerCallContext context = createCallContext(rc, SEND_MESSAGE_METHOD); HTTPRestResponse response = null; try { - response = jsonRestHandler.sendMessage(body, extractTenant(rc), context); + response = jsonRestHandler.sendMessage(context, extractTenant(rc), body); } catch (Throwable t) { response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage())); } finally { @@ -102,7 +103,7 @@ public void sendMessageStreaming(@Body String body, RoutingContext rc) { HTTPRestStreamingResponse streamingResponse = null; HTTPRestResponse error = null; try { - HTTPRestResponse response = jsonRestHandler.sendStreamingMessage(body, extractTenant(rc), context); + HTTPRestResponse response = jsonRestHandler.sendStreamingMessage(context, extractTenant(rc), body); if (response instanceof HTTPRestStreamingResponse hTTPRestStreamingResponse) { streamingResponse = hTTPRestStreamingResponse; } else { @@ -158,8 +159,8 @@ public void listTasks(RoutingContext rc) { includeArtifacts = Boolean.valueOf(includeArtifactsStr); } - response = jsonRestHandler.listTasks(contextId, statusStr, pageSize, pageToken, - historyLength, lastUpdatedAfter, includeArtifacts, extractTenant(rc), context); + response = jsonRestHandler.listTasks(context, extractTenant(rc), contextId, statusStr, pageSize, pageToken, + historyLength, lastUpdatedAfter, includeArtifacts); } catch (NumberFormatException e) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError("Invalid number format in parameters")); } catch (IllegalArgumentException e) { @@ -184,7 +185,7 @@ public void getTask(RoutingContext rc) { if (rc.request().params().contains(HISTORY_LENGTH_PARAM)) { historyLength = Integer.valueOf(rc.request().params().get(HISTORY_LENGTH_PARAM)); } - response = jsonRestHandler.getTask(taskId, historyLength, extractTenant(rc), context); + response = jsonRestHandler.getTask(context, extractTenant(rc), taskId, historyLength); } } catch (NumberFormatException e) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad historyLength")); @@ -204,7 +205,7 @@ public void cancelTask(RoutingContext rc) { if (taskId == null || taskId.isEmpty()) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad task id")); } else { - response = jsonRestHandler.cancelTask(taskId, extractTenant(rc), context); + response = jsonRestHandler.cancelTask(context, extractTenant(rc), taskId); } } catch (Throwable t) { if (t instanceof A2AError error) { @@ -238,7 +239,7 @@ public void subscribeToTask(RoutingContext rc) { if (taskId == null || taskId.isEmpty()) { error = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad task id")); } else { - HTTPRestResponse response = jsonRestHandler.subscribeToTask(taskId, extractTenant(rc), context); + HTTPRestResponse response = jsonRestHandler.subscribeToTask(context, extractTenant(rc), taskId); if (response instanceof HTTPRestStreamingResponse hTTPRestStreamingResponse) { streamingResponse = hTTPRestStreamingResponse; } else { @@ -271,7 +272,7 @@ public void CreateTaskPushNotificationConfiguration(@Body String body, RoutingCo if (taskId == null || taskId.isEmpty()) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad task id")); } else { - response = jsonRestHandler.CreateTaskPushNotificationConfiguration(taskId, body, extractTenant(rc), context); + response = jsonRestHandler.createTaskPushNotificationConfiguration(context, extractTenant(rc), body, taskId); } } catch (Throwable t) { response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage())); @@ -290,7 +291,7 @@ public void getTaskPushNotificationConfiguration(RoutingContext rc) { if (taskId == null || taskId.isEmpty()) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad task id")); } else { - response = jsonRestHandler.getTaskPushNotificationConfiguration(taskId, configId, extractTenant(rc), context); + response = jsonRestHandler.getTaskPushNotificationConfiguration(context, extractTenant(rc), taskId, configId); } } catch (Throwable t) { response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage())); @@ -309,7 +310,7 @@ public void getTaskPushNotificationConfigurationWithoutId(RoutingContext rc) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad task id")); } else { // Call get with null configId - trailing slash distinguishes this from list - response = jsonRestHandler.getTaskPushNotificationConfiguration(taskId, null, extractTenant(rc), context); + response = jsonRestHandler.getTaskPushNotificationConfiguration(context, extractTenant(rc), taskId, null); } } catch (Throwable t) { response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage())); @@ -335,7 +336,7 @@ public void listTaskPushNotificationConfigurations(RoutingContext rc) { if (rc.request().params().contains(PAGE_TOKEN_PARAM)) { pageToken = Utils.defaultIfNull(rc.request().params().get(PAGE_TOKEN_PARAM), ""); } - response = jsonRestHandler.listTaskPushNotificationConfigurations(taskId, pageSize, pageToken, extractTenant(rc), context); + response = jsonRestHandler.listTaskPushNotificationConfigurations(context, extractTenant(rc), taskId, pageSize, pageToken); } } catch (NumberFormatException e) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad " + PAGE_SIZE_PARAM)); @@ -358,7 +359,7 @@ public void deleteTaskPushNotificationConfiguration(RoutingContext rc) { } else if (configId == null || configId.isEmpty()) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad config id")); } else { - response = jsonRestHandler.deleteTaskPushNotificationConfiguration(taskId, configId, extractTenant(rc), context); + response = jsonRestHandler.deleteTaskPushNotificationConfiguration(context, extractTenant(rc), taskId, configId); } } catch (Throwable t) { response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage())); @@ -397,7 +398,7 @@ public void getAgentCard(RoutingContext rc) { @Route(regex = "^\\/(?[^\\/]*\\/?)extendedAgentCard$", order = 1, methods = Route.HttpMethod.GET, produces = APPLICATION_JSON) public void getExtendedAgentCard(RoutingContext rc) { - HTTPRestResponse response = jsonRestHandler.getExtendedAgentCard(extractTenant(rc)); + HTTPRestResponse response = jsonRestHandler.getExtendedAgentCard(createCallContext(rc, GET_EXTENDED_AGENT_CARD_METHOD), extractTenant(rc)); sendResponse(rc, response); } diff --git a/reference/rest/src/test/java/io/a2a/server/rest/quarkus/A2AServerRoutesTest.java b/reference/rest/src/test/java/io/a2a/server/rest/quarkus/A2AServerRoutesTest.java index 9ef8f7c47..e6da67400 100644 --- a/reference/rest/src/test/java/io/a2a/server/rest/quarkus/A2AServerRoutesTest.java +++ b/reference/rest/src/test/java/io/a2a/server/rest/quarkus/A2AServerRoutesTest.java @@ -94,7 +94,7 @@ public void testSendMessage_MethodNameSetInContext() { when(mockHttpResponse.getStatusCode()).thenReturn(200); when(mockHttpResponse.getContentType()).thenReturn("application/json"); when(mockHttpResponse.getBody()).thenReturn("{}"); - when(mockRestHandler.sendMessage(anyString(), anyString(), any(ServerCallContext.class))).thenReturn(mockHttpResponse); + when(mockRestHandler.sendMessage(any(ServerCallContext.class), anyString(), anyString())).thenReturn(mockHttpResponse); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class); @@ -102,7 +102,7 @@ public void testSendMessage_MethodNameSetInContext() { routes.sendMessage("{}", mockRoutingContext); // Assert - verify(mockRestHandler).sendMessage(eq("{}"), anyString(), contextCaptor.capture()); + verify(mockRestHandler).sendMessage(contextCaptor.capture(), anyString(), eq("{}")); ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(SEND_MESSAGE_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); @@ -115,7 +115,7 @@ public void testSendMessageStreaming_MethodNameSetInContext() { when(mockHttpResponse.getStatusCode()).thenReturn(200); when(mockHttpResponse.getContentType()).thenReturn("application/json"); when(mockHttpResponse.getBody()).thenReturn("{}"); - when(mockRestHandler.sendStreamingMessage(anyString(), anyString(), any(ServerCallContext.class))) + when(mockRestHandler.sendStreamingMessage(any(ServerCallContext.class), anyString(), anyString())) .thenReturn(mockHttpResponse); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class); @@ -124,7 +124,7 @@ public void testSendMessageStreaming_MethodNameSetInContext() { routes.sendMessageStreaming("{}", mockRoutingContext); // Assert - verify(mockRestHandler).sendStreamingMessage(eq("{}"), anyString(), contextCaptor.capture()); + verify(mockRestHandler).sendStreamingMessage(contextCaptor.capture(), anyString(), eq("{}")); ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(SEND_STREAMING_MESSAGE_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); @@ -138,7 +138,7 @@ public void testGetTask_MethodNameSetInContext() { when(mockHttpResponse.getStatusCode()).thenReturn(200); when(mockHttpResponse.getContentType()).thenReturn("application/json"); when(mockHttpResponse.getBody()).thenReturn("{test:value}"); - when(mockRestHandler.getTask(anyString(), any(), anyString(), any(ServerCallContext.class))).thenReturn(mockHttpResponse); + when(mockRestHandler.getTask(any(ServerCallContext.class), anyString(), anyString(), any())).thenReturn(mockHttpResponse); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class); @@ -146,7 +146,7 @@ public void testGetTask_MethodNameSetInContext() { routes.getTask(mockRoutingContext); // Assert - verify(mockRestHandler).getTask(eq("task123"), any(), anyString(), contextCaptor.capture()); + verify(mockRestHandler).getTask(contextCaptor.capture(), anyString(), eq("task123"), any()); ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(GET_TASK_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); @@ -160,7 +160,7 @@ public void testCancelTask_MethodNameSetInContext() { when(mockHttpResponse.getStatusCode()).thenReturn(200); when(mockHttpResponse.getContentType()).thenReturn("application/json"); when(mockHttpResponse.getBody()).thenReturn("{}"); - when(mockRestHandler.cancelTask(anyString(), anyString(), any(ServerCallContext.class))).thenReturn(mockHttpResponse); + when(mockRestHandler.cancelTask(any(ServerCallContext.class), anyString(), anyString())).thenReturn(mockHttpResponse); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class); @@ -168,7 +168,7 @@ public void testCancelTask_MethodNameSetInContext() { routes.cancelTask(mockRoutingContext); // Assert - verify(mockRestHandler).cancelTask(eq("task123"), anyString(), contextCaptor.capture()); + verify(mockRestHandler).cancelTask(contextCaptor.capture(), anyString(), eq("task123")); ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(CANCEL_TASK_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); @@ -182,7 +182,7 @@ public void testResubscribeTask_MethodNameSetInContext() { when(mockHttpResponse.getStatusCode()).thenReturn(200); when(mockHttpResponse.getContentType()).thenReturn("application/json"); when(mockHttpResponse.getBody()).thenReturn("{}"); - when(mockRestHandler.subscribeToTask(anyString(), anyString(), any(ServerCallContext.class))) + when(mockRestHandler.subscribeToTask(any(ServerCallContext.class), anyString(), anyString())) .thenReturn(mockHttpResponse); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class); @@ -191,7 +191,7 @@ public void testResubscribeTask_MethodNameSetInContext() { routes.subscribeToTask(mockRoutingContext); // Assert - verify(mockRestHandler).subscribeToTask(eq("task123"), anyString(), contextCaptor.capture()); + verify(mockRestHandler).subscribeToTask(contextCaptor.capture(), anyString(), eq("task123")); ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(SUBSCRIBE_TO_TASK_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); @@ -205,8 +205,7 @@ public void testCreateTaskPushNotificationConfiguration_MethodNameSetInContext() when(mockHttpResponse.getStatusCode()).thenReturn(200); when(mockHttpResponse.getContentType()).thenReturn("application/json"); when(mockHttpResponse.getBody()).thenReturn("{}"); - when(mockRestHandler.CreateTaskPushNotificationConfiguration(anyString(), anyString(), anyString(), - any(ServerCallContext.class))).thenReturn(mockHttpResponse); + when(mockRestHandler.createTaskPushNotificationConfiguration(any(ServerCallContext.class), anyString(), anyString(), anyString())).thenReturn(mockHttpResponse); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class); @@ -214,7 +213,7 @@ public void testCreateTaskPushNotificationConfiguration_MethodNameSetInContext() routes.CreateTaskPushNotificationConfiguration("{}", mockRoutingContext); // Assert - verify(mockRestHandler).CreateTaskPushNotificationConfiguration(eq("task123"), eq("{}"), anyString(), contextCaptor.capture()); + verify(mockRestHandler).createTaskPushNotificationConfiguration(contextCaptor.capture(), anyString(), eq("{}"), eq("task123")); ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); @@ -229,8 +228,7 @@ public void testGetTaskPushNotificationConfiguration_MethodNameSetInContext() { when(mockHttpResponse.getStatusCode()).thenReturn(200); when(mockHttpResponse.getContentType()).thenReturn("application/json"); when(mockHttpResponse.getBody()).thenReturn("{}"); - when(mockRestHandler.getTaskPushNotificationConfiguration(anyString(), anyString(), anyString(), - any(ServerCallContext.class))).thenReturn(mockHttpResponse); + when(mockRestHandler.getTaskPushNotificationConfiguration(any(ServerCallContext.class), anyString(), anyString(), anyString())).thenReturn(mockHttpResponse); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class); @@ -238,8 +236,8 @@ public void testGetTaskPushNotificationConfiguration_MethodNameSetInContext() { routes.getTaskPushNotificationConfiguration(mockRoutingContext); // Assert - verify(mockRestHandler).getTaskPushNotificationConfiguration(eq("task123"), eq("config456"), anyString(), - contextCaptor.capture()); + verify(mockRestHandler).getTaskPushNotificationConfiguration(contextCaptor.capture(), anyString(), eq("task123"), + eq("config456")); ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); @@ -253,7 +251,7 @@ public void testListTaskPushNotificationConfigurations_MethodNameSetInContext() when(mockHttpResponse.getStatusCode()).thenReturn(200); when(mockHttpResponse.getContentType()).thenReturn("application/json"); when(mockHttpResponse.getBody()).thenReturn("{}"); - when(mockRestHandler.listTaskPushNotificationConfigurations(anyString(), anyInt(), anyString(), anyString(), any(ServerCallContext.class))) + when(mockRestHandler.listTaskPushNotificationConfigurations(any(ServerCallContext.class), anyString(), anyString(), anyInt(), anyString())) .thenReturn(mockHttpResponse); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class); @@ -262,7 +260,7 @@ public void testListTaskPushNotificationConfigurations_MethodNameSetInContext() routes.listTaskPushNotificationConfigurations(mockRoutingContext); // Assert - verify(mockRestHandler).listTaskPushNotificationConfigurations(eq("task123"), anyInt(), anyString(), anyString(), contextCaptor.capture()); + verify(mockRestHandler).listTaskPushNotificationConfigurations(contextCaptor.capture(), anyString(), eq("task123"), anyInt(), anyString()); ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); @@ -277,8 +275,7 @@ public void testDeleteTaskPushNotificationConfiguration_MethodNameSetInContext() when(mockHttpResponse.getStatusCode()).thenReturn(200); when(mockHttpResponse.getContentType()).thenReturn("application/json"); when(mockHttpResponse.getBody()).thenReturn("{}"); - when(mockRestHandler.deleteTaskPushNotificationConfiguration(anyString(), anyString(), anyString(), - any(ServerCallContext.class))).thenReturn(mockHttpResponse); + when(mockRestHandler.deleteTaskPushNotificationConfiguration(any(ServerCallContext.class), anyString(), anyString(), anyString())).thenReturn(mockHttpResponse); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class); @@ -286,8 +283,8 @@ public void testDeleteTaskPushNotificationConfiguration_MethodNameSetInContext() routes.deleteTaskPushNotificationConfiguration(mockRoutingContext); // Assert - verify(mockRestHandler).deleteTaskPushNotificationConfiguration(eq("task123"), eq("config456"), anyString(), - contextCaptor.capture()); + verify(mockRestHandler).deleteTaskPushNotificationConfiguration(contextCaptor.capture(), anyString(), eq("task123"), + eq("config456")); ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); diff --git a/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java b/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java index e1a16eb3c..27fb786a5 100644 --- a/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java +++ b/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java @@ -6,6 +6,7 @@ import static java.util.concurrent.TimeUnit.SECONDS; import java.time.Instant; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -68,6 +69,7 @@ import io.a2a.spec.TaskState; import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.UnsupportedOperationError; +import java.util.Collections; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; diff --git a/spec/src/main/java/io/a2a/spec/A2AMethods.java b/spec/src/main/java/io/a2a/spec/A2AMethods.java index e270a7d91..1731077fe 100644 --- a/spec/src/main/java/io/a2a/spec/A2AMethods.java +++ b/spec/src/main/java/io/a2a/spec/A2AMethods.java @@ -27,5 +27,6 @@ public interface A2AMethods { String SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD = "CreateTaskPushNotificationConfig"; /** Method name for subscribing to task events. */ String SUBSCRIBE_TO_TASK_METHOD = "SubscribeToTask"; + } diff --git a/spec/src/main/java/io/a2a/spec/Message.java b/spec/src/main/java/io/a2a/spec/Message.java index 4383b1e02..978af4c40 100644 --- a/spec/src/main/java/io/a2a/spec/Message.java +++ b/spec/src/main/java/io/a2a/spec/Message.java @@ -94,6 +94,11 @@ public static Builder builder(Message message) { return new Builder(message); } + @Override + public String toString() { + return "Message{" + "role=" + role + ", parts=" + parts + ", messageId=" + messageId + ", contextId=" + contextId + ", taskId=" + taskId + ", metadata=" + metadata + ", referenceTaskIds=" + referenceTaskIds + ", extensions=" + extensions + '}'; + } + /** * Defines the role of the message sender in the conversation. *

diff --git a/spec/src/main/java/io/a2a/spec/Task.java b/spec/src/main/java/io/a2a/spec/Task.java index 24336dcd9..b51db72ce 100644 --- a/spec/src/main/java/io/a2a/spec/Task.java +++ b/spec/src/main/java/io/a2a/spec/Task.java @@ -103,6 +103,11 @@ public static Builder builder(Task task) { return new Builder(task); } + @Override + public String toString() { + return "Task{" + "id=" + id + ", contextId=" + contextId + ", status=" + status + ", artifacts=" + artifacts + ", history=" + history + ", metadata=" + metadata + '}'; + } + /** * Builder for constructing immutable {@link Task} instances. *

diff --git a/transport/grpc/src/main/java/io/a2a/transport/grpc/context/GrpcContextKeys.java b/transport/grpc/src/main/java/io/a2a/transport/grpc/context/GrpcContextKeys.java index a025236f0..a1392f20a 100644 --- a/transport/grpc/src/main/java/io/a2a/transport/grpc/context/GrpcContextKeys.java +++ b/transport/grpc/src/main/java/io/a2a/transport/grpc/context/GrpcContextKeys.java @@ -1,16 +1,20 @@ package io.a2a.transport.grpc.context; + +import java.util.Map; + +import io.a2a.spec.A2AMethods; import io.grpc.Context; /** * Shared gRPC context keys for A2A protocol data. - * + * * These keys provide access to gRPC context information similar to * Python's grpc.aio.ServicerContext, enabling rich context access * in service method implementations. */ public final class GrpcContextKeys { - + /** * Context key for storing the X-A2A-Version header value. * Set by server interceptors and accessed by service handlers. @@ -24,21 +28,28 @@ public final class GrpcContextKeys { */ public static final Context.Key EXTENSIONS_HEADER_KEY = Context.key("x-a2a-extensions"); - + /** * Context key for storing the complete gRPC Metadata object. * Provides access to all request headers and metadata. */ public static final Context.Key METADATA_KEY = Context.key("grpc-metadata"); - + /** * Context key for storing the method name being called. * Equivalent to Python's context.method() functionality. */ - public static final Context.Key METHOD_NAME_KEY = + public static final Context.Key GRPC_METHOD_NAME_KEY = Context.key("grpc-method-name"); + /** + * Context key for storing the method name being called. + * Equivalent to Python's context.method() functionality. + */ + public static final Context.Key METHOD_NAME_KEY = + Context.key("method"); + /** * Context key for storing the peer information. * Provides access to client connection details. @@ -46,6 +57,18 @@ public final class GrpcContextKeys { public static final Context.Key PEER_INFO_KEY = Context.key("grpc-peer-info"); + public static final Map METHOD_MAPPING = Map.of( + "SendMessage", A2AMethods.SEND_MESSAGE_METHOD, + "SendStreamingMessage", A2AMethods.SEND_STREAMING_MESSAGE_METHOD, + "GetTask", A2AMethods.GET_TASK_METHOD, + "ListTask", A2AMethods.LIST_TASK_METHOD, + "CancelTask", A2AMethods.CANCEL_TASK_METHOD, + "SubscribeToTask", A2AMethods.SUBSCRIBE_TO_TASK_METHOD, + "CreateTaskPushNotification", A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + "GetTaskPushNotification", A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + "ListTaskPushNotification", A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + "DeleteTaskPushNotification", A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); + private GrpcContextKeys() { // Utility class } diff --git a/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java b/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java index 376d9b11d..06f58e96e 100644 --- a/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java +++ b/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java @@ -57,15 +57,17 @@ import io.a2a.spec.VersionNotSupportedError; import io.a2a.transport.grpc.context.GrpcContextKeys; import io.grpc.Context; +import io.grpc.Metadata; import io.grpc.Status; import io.grpc.stub.StreamObserver; +import org.jspecify.annotations.Nullable; @Vetoed public abstract class GrpcHandler extends A2AServiceGrpc.A2AServiceImplBase { // Hook so testing can wait until streaming subscriptions are established. // Without this we get intermittent failures - private static volatile Runnable streamingSubscribedRunnable; + private static volatile @Nullable Runnable streamingSubscribedRunnable; private final AtomicBoolean initialised = new AtomicBoolean(false); @@ -279,12 +281,14 @@ private void convertToStreamResponse(Flow.Publisher publishe ServerCallContext context) { CompletableFuture.runAsync(() -> { publisher.subscribe(new Flow.Subscriber() { - private Flow.Subscription subscription; + private Flow.@Nullable Subscription subscription; @Override public void onSubscribe(Flow.Subscription subscription) { this.subscription = subscription; - subscription.request(1); + if (this.subscription != null) { + this.subscription.request(1); + } // Detect gRPC client disconnect and call EventConsumer.cancel() directly // This stops the polling loop without relying on subscription cancellation propagation @@ -312,14 +316,18 @@ public void onNext(StreamingEventKind event) { if (response.hasStatusUpdate() && response.getStatusUpdate().getFinal()) { responseObserver.onCompleted(); } else { - subscription.request(1); + if (this.subscription != null) { + this.subscription.request(1); + } } } @Override public void onError(Throwable throwable) { // Cancel upstream to stop EventConsumer when error occurs - subscription.cancel(); + if (this.subscription != null) { + subscription.cancel(); + } if (throwable instanceof A2AError jsonrpcError) { handleError(responseObserver, jsonrpcError); } else { @@ -403,8 +411,12 @@ private ServerCallContext createCallContext(StreamObserver responseObserv if (grpcMetadata != null) { state.put("grpc_metadata", grpcMetadata); } - - String methodName = GrpcContextKeys.METHOD_NAME_KEY.get(currentContext); + Map headers= new HashMap<>(); + for(String key : grpcMetadata.keys()) { + headers.put(key, grpcMetadata.get(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER))); + } + state.put("headers", headers); + String methodName = GrpcContextKeys.GRPC_METHOD_NAME_KEY.get(currentContext); if (methodName != null) { state.put("grpc_method_name", methodName); } @@ -573,7 +585,7 @@ public static void setStreamingSubscribedRunnable(Runnable runnable) { * * @return the version header value, or null if not available */ - private String getVersionFromContext() { + private @Nullable String getVersionFromContext() { try { return GrpcContextKeys.VERSION_HEADER_KEY.get(); } catch (Exception e) { @@ -589,7 +601,7 @@ private String getVersionFromContext() { * * @return the extensions header value, or null if not available */ - private String getExtensionsFromContext() { + private @Nullable String getExtensionsFromContext() { try { return GrpcContextKeys.EXTENSIONS_HEADER_KEY.get(); } catch (Exception e) { @@ -609,7 +621,7 @@ private String getExtensionsFromContext() { * @param key the context key to retrieve * @return the context value, or null if not available */ - private static T getFromContext(Context.Key key) { + private static @Nullable T getFromContext(Context.Key key) { try { return key.get(); } catch (Exception e) { @@ -624,7 +636,7 @@ private static T getFromContext(Context.Key key) { * * @return the gRPC Metadata object, or null if not available */ - protected static io.grpc.Metadata getCurrentMetadata() { + protected static io.grpc.@Nullable Metadata getCurrentMetadata() { return getFromContext(GrpcContextKeys.METADATA_KEY); } @@ -634,8 +646,8 @@ protected static io.grpc.Metadata getCurrentMetadata() { * * @return the method name, or null if not available */ - protected static String getCurrentMethodName() { - return getFromContext(GrpcContextKeys.METHOD_NAME_KEY); + protected static @Nullable String getCurrentMethodName() { + return getFromContext(GrpcContextKeys.GRPC_METHOD_NAME_KEY); } /** @@ -644,7 +656,7 @@ protected static String getCurrentMethodName() { * * @return the peer information, or null if not available */ - protected static String getCurrentPeerInfo() { + protected static @Nullable String getCurrentPeerInfo() { return getFromContext(GrpcContextKeys.PEER_INFO_KEY); } } diff --git a/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/package-info.java b/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/package-info.java new file mode 100644 index 000000000..0cc667b2d --- /dev/null +++ b/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/package-info.java @@ -0,0 +1,9 @@ +/* + * Copyright The WildFly Authors + * SPDX-License-Identifier: Apache-2.0 + */ +@NullMarked +package io.a2a.transport.grpc.handler; + +import org.jspecify.annotations.NullMarked; + diff --git a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java index a592bd55d..eeb7b9c93 100644 --- a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java +++ b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java @@ -110,7 +110,6 @@ public SendMessageResponse onMessageSend(SendMessageRequest request, ServerCallC } } - public Flow.Publisher onMessageSendStream( SendStreamingMessageRequest request, ServerCallContext context) { if (!agentCard.capabilities().streaming()) { diff --git a/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java b/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java index f4b594f6b..cf28a7d7c 100644 --- a/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java +++ b/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java @@ -108,7 +108,8 @@ public RestHandler(AgentCard agentCard, RequestHandler requestHandler, Executor this.executor = executor; } - public HTTPRestResponse sendMessage(String body, String tenant, ServerCallContext context) { + public HTTPRestResponse sendMessage(ServerCallContext context, String tenant, String body) { + try { A2AVersionValidator.validateProtocolVersion(agentCard, context); A2AExtensions.validateRequiredExtensions(agentCard, context); @@ -124,7 +125,7 @@ public HTTPRestResponse sendMessage(String body, String tenant, ServerCallContex } } - public HTTPRestResponse sendStreamingMessage(String body, String tenant, ServerCallContext context) { + public HTTPRestResponse sendStreamingMessage(ServerCallContext context, String tenant, String body) { try { if (!agentCard.capabilities().streaming()) { return createErrorResponse(new InvalidRequestError("Streaming is not supported by the agent")); @@ -143,7 +144,7 @@ public HTTPRestResponse sendStreamingMessage(String body, String tenant, ServerC } } - public HTTPRestResponse cancelTask(String taskId, String tenant, ServerCallContext context) { + public HTTPRestResponse cancelTask(ServerCallContext context, String tenant, String taskId) { try { if (taskId == null || taskId.isEmpty()) { throw new InvalidParamsError(); @@ -161,7 +162,7 @@ public HTTPRestResponse cancelTask(String taskId, String tenant, ServerCallConte } } - public HTTPRestResponse CreateTaskPushNotificationConfiguration(String taskId, String body, String tenant, ServerCallContext context) { + public HTTPRestResponse createTaskPushNotificationConfiguration(ServerCallContext context, String tenant, String body, String taskId) { try { if (!agentCard.capabilities().pushNotifications()) { throw new PushNotificationNotSupportedError(); @@ -178,7 +179,7 @@ public HTTPRestResponse CreateTaskPushNotificationConfiguration(String taskId, S } } - public HTTPRestResponse subscribeToTask(String taskId, String tenant, ServerCallContext context) { + public HTTPRestResponse subscribeToTask(ServerCallContext context, String tenant, String taskId) { try { if (!agentCard.capabilities().streaming()) { return createErrorResponse(new InvalidRequestError("Streaming is not supported by the agent")); @@ -193,7 +194,7 @@ public HTTPRestResponse subscribeToTask(String taskId, String tenant, ServerCall } } - public HTTPRestResponse getTask(String taskId, @Nullable Integer historyLength, String tenant, ServerCallContext context) { + public HTTPRestResponse getTask(ServerCallContext context, String tenant, String taskId, @Nullable Integer historyLength) { try { TaskQueryParams params = new TaskQueryParams(taskId, historyLength, tenant); Task task = requestHandler.onGetTask(params, context); @@ -208,11 +209,11 @@ public HTTPRestResponse getTask(String taskId, @Nullable Integer historyLength, } } - public HTTPRestResponse listTasks(@Nullable String contextId, @Nullable String status, + public HTTPRestResponse listTasks(ServerCallContext context, String tenant, + @Nullable String contextId, @Nullable String status, @Nullable Integer pageSize, @Nullable String pageToken, @Nullable Integer historyLength, @Nullable String statusTimestampAfter, - @Nullable Boolean includeArtifacts, String tenant, - ServerCallContext context) { + @Nullable Boolean includeArtifacts) { try { // Build params ListTasksParams.Builder paramsBuilder = ListTasksParams.builder(); @@ -303,7 +304,7 @@ public HTTPRestResponse listTasks(@Nullable String contextId, @Nullable String s } } - public HTTPRestResponse getTaskPushNotificationConfiguration(String taskId, @Nullable String configId, String tenant, ServerCallContext context) { + public HTTPRestResponse getTaskPushNotificationConfiguration(ServerCallContext context, String tenant, String taskId, @Nullable String configId) { try { if (!agentCard.capabilities().pushNotifications()) { throw new PushNotificationNotSupportedError(); @@ -318,7 +319,7 @@ public HTTPRestResponse getTaskPushNotificationConfiguration(String taskId, @Nul } } - public HTTPRestResponse listTaskPushNotificationConfigurations(String taskId, int pageSize, String pageToken, String tenant, ServerCallContext context) { + public HTTPRestResponse listTaskPushNotificationConfigurations(ServerCallContext context, String tenant, String taskId, int pageSize, String pageToken) { try { if (!agentCard.capabilities().pushNotifications()) { throw new PushNotificationNotSupportedError(); @@ -333,7 +334,7 @@ public HTTPRestResponse listTaskPushNotificationConfigurations(String taskId, in } } - public HTTPRestResponse deleteTaskPushNotificationConfiguration(String taskId, String configId, String tenant, ServerCallContext context) { + public HTTPRestResponse deleteTaskPushNotificationConfiguration(ServerCallContext context, String tenant, String taskId, String configId) { try { if (!agentCard.capabilities().pushNotifications()) { throw new PushNotificationNotSupportedError(); @@ -491,7 +492,7 @@ private int mapErrorToHttpStatus(A2AError error) { return 500; } - public HTTPRestResponse getExtendedAgentCard(String tenant) { + public HTTPRestResponse getExtendedAgentCard(ServerCallContext context, String tenant) { try { if (!agentCard.capabilities().extendedAgentCard() || extendedAgentCard == null || !extendedAgentCard.isResolvable()) { throw new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null); diff --git a/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java b/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java index 48eaae912..595902509 100644 --- a/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java +++ b/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java @@ -34,13 +34,13 @@ public void testGetTaskSuccess() { RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); - RestHandler.HTTPRestResponse response = handler.getTask(MINIMAL_TASK.id(), 0, "", callContext); + RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", MINIMAL_TASK.id(), 0); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); Assertions.assertTrue(response.getBody().contains(MINIMAL_TASK.id())); - response = handler.getTask(MINIMAL_TASK.id(),2 , "",callContext); + response = handler.getTask(callContext, "", MINIMAL_TASK.id(), 2); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -51,7 +51,7 @@ public void testGetTaskSuccess() { public void testGetTaskNotFound() { RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); - RestHandler.HTTPRestResponse response = handler.getTask("nonexistent", 0, "",callContext); + RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", "nonexistent", 0); Assertions.assertEquals(404, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -63,8 +63,8 @@ public void testListTasksStatusWireString() { RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); - RestHandler.HTTPRestResponse response = handler.listTasks(null, "submitted", null, null, - null, null, null, "", callContext); + RestHandler.HTTPRestResponse response = handler.listTasks(callContext, "", null, "submitted", null, null, + null, null, null); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -75,8 +75,8 @@ public void testListTasksStatusWireString() { public void testListTasksInvalidStatus() { RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); - RestHandler.HTTPRestResponse response = handler.listTasks(null, "not-a-status", null, null, - null, null, null, "", callContext); + RestHandler.HTTPRestResponse response = handler.listTasks(callContext, "", null, "not-a-status", null, null, + null, null, null); Assertions.assertEquals(422, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -108,7 +108,7 @@ public void testSendMessage() throws InvalidProtocolBufferException { } }"""; - RestHandler.HTTPRestResponse response = handler.sendMessage(requestBody, "", callContext); + RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", requestBody); Assertions.assertEquals(200, response.getStatusCode(), response.toString()); Assertions.assertEquals("application/json", response.getContentType()); Assertions.assertNotNull(response.getBody()); @@ -119,7 +119,7 @@ public void testSendMessageInvalidBody() { RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); String invalidBody = "invalid json"; - RestHandler.HTTPRestResponse response = handler.sendMessage(invalidBody, "", callContext); + RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", invalidBody); Assertions.assertEquals(400, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -143,7 +143,7 @@ public void testSendMessageWrongValueBody() { } } }"""; - RestHandler.HTTPRestResponse response = handler.sendMessage(requestBody, "", callContext); + RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", requestBody); Assertions.assertEquals(422, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -154,7 +154,7 @@ public void testSendMessageWrongValueBody() { public void testSendMessageEmptyBody() { RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); - RestHandler.HTTPRestResponse response = handler.sendMessage("", "", callContext); + RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", ""); Assertions.assertEquals(400, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -175,7 +175,7 @@ public void testCancelTaskSuccess() { taskUpdater.cancel(); }; - RestHandler.HTTPRestResponse response = handler.cancelTask(MINIMAL_TASK.id(), "", callContext); + RestHandler.HTTPRestResponse response = handler.cancelTask(callContext, "", MINIMAL_TASK.id()); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -186,7 +186,7 @@ public void testCancelTaskSuccess() { public void testCancelTaskNotFound() { RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); - RestHandler.HTTPRestResponse response = handler.cancelTask("nonexistent", "", callContext); + RestHandler.HTTPRestResponse response = handler.cancelTask(callContext, "", "nonexistent"); Assertions.assertEquals(404, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -216,7 +216,7 @@ public void testSendStreamingMessageSuccess() { } }"""; - RestHandler.HTTPRestResponse response = handler.sendStreamingMessage(requestBody, "", callContext); + RestHandler.HTTPRestResponse response = handler.sendStreamingMessage(callContext, "", requestBody); Assertions.assertEquals(200, response.getStatusCode(), response.toString()); Assertions.assertInstanceOf(RestHandler.HTTPRestStreamingResponse.class, response); RestHandler.HTTPRestStreamingResponse streamingResponse = (RestHandler.HTTPRestStreamingResponse) response; @@ -239,7 +239,7 @@ public void testSendStreamingMessageNotSupported() { } """; - RestHandler.HTTPRestResponse response = handler.sendStreamingMessage(requestBody, "", callContext); + RestHandler.HTTPRestResponse response = handler.sendStreamingMessage(callContext, "", requestBody); Assertions.assertEquals(400, response.getStatusCode()); Assertions.assertTrue(response.getBody().contains("InvalidRequestError")); @@ -265,7 +265,7 @@ public void testPushNotificationConfigSuccess() { } }""".formatted(MINIMAL_TASK.id(), MINIMAL_TASK.id()); - RestHandler.HTTPRestResponse response = handler.CreateTaskPushNotificationConfiguration( MINIMAL_TASK.id(), requestBody, "", callContext); + RestHandler.HTTPRestResponse response = handler.createTaskPushNotificationConfiguration(callContext, "", requestBody, MINIMAL_TASK.id()); Assertions.assertEquals(201, response.getStatusCode(), response.toString()); Assertions.assertEquals("application/json", response.getContentType()); @@ -286,7 +286,7 @@ public void testPushNotificationConfigNotSupported() { } """.formatted(MINIMAL_TASK.id()); - RestHandler.HTTPRestResponse response = handler.CreateTaskPushNotificationConfiguration(MINIMAL_TASK.id(), requestBody, "", callContext); + RestHandler.HTTPRestResponse response = handler.createTaskPushNotificationConfiguration(callContext, "", requestBody, MINIMAL_TASK.id()); Assertions.assertEquals(501, response.getStatusCode()); Assertions.assertTrue(response.getBody().contains("PushNotificationNotSupportedError")); @@ -312,11 +312,11 @@ public void testGetPushNotificationConfig() { } } }""".formatted(MINIMAL_TASK.id(), MINIMAL_TASK.id()); - RestHandler.HTTPRestResponse response = handler.CreateTaskPushNotificationConfiguration(MINIMAL_TASK.id(), createRequestBody, "", callContext); + RestHandler.HTTPRestResponse response = handler.createTaskPushNotificationConfiguration(callContext, "", createRequestBody, MINIMAL_TASK.id()); Assertions.assertEquals(201, response.getStatusCode(), response.toString()); Assertions.assertEquals("application/json", response.getContentType()); // Now get it - response = handler.getTaskPushNotificationConfiguration(MINIMAL_TASK.id(), "default-config-id", "", callContext); + response = handler.getTaskPushNotificationConfiguration(callContext, "", MINIMAL_TASK.id(), "default-config-id"); Assertions.assertEquals(200, response.getStatusCode(), response.toString()); Assertions.assertEquals("application/json", response.getContentType()); } @@ -325,7 +325,7 @@ public void testGetPushNotificationConfig() { public void testDeletePushNotificationConfig() { RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); - RestHandler.HTTPRestResponse response = handler.deleteTaskPushNotificationConfiguration(MINIMAL_TASK.id(), "default-config-id", "", callContext); + RestHandler.HTTPRestResponse response = handler.deleteTaskPushNotificationConfiguration(callContext, "", MINIMAL_TASK.id(), "default-config-id"); Assertions.assertEquals(204, response.getStatusCode()); } @@ -334,7 +334,7 @@ public void testListPushNotificationConfigs() { RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); - RestHandler.HTTPRestResponse response = handler.listTaskPushNotificationConfigurations(MINIMAL_TASK.id(), 0, "", "", callContext); + RestHandler.HTTPRestResponse response = handler.listTaskPushNotificationConfigurations(callContext, "", MINIMAL_TASK.id(), 0, ""); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -346,11 +346,11 @@ public void testHttpStatusCodeMapping() { RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); // Test 400 for invalid request - RestHandler.HTTPRestResponse response = handler.sendMessage("", "", callContext); + RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", ""); Assertions.assertEquals(400, response.getStatusCode()); // Test 404 for not found - response = handler.getTask("nonexistent", 0, "", callContext); + response = handler.getTask(callContext, "", "nonexistent", 0); Assertions.assertEquals(404, response.getStatusCode()); } @@ -390,7 +390,7 @@ public void testStreamingDoesNotBlockMainThread() throws Exception { }"""; // Start streaming - RestHandler.HTTPRestResponse response = handler.sendStreamingMessage(requestBody, "", callContext); + RestHandler.HTTPRestResponse response = handler.sendStreamingMessage(callContext, "", requestBody); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertInstanceOf(RestHandler.HTTPRestStreamingResponse.class, response); @@ -479,7 +479,7 @@ public void testExtensionSupportRequiredErrorOnSendMessage() { } }"""; - RestHandler.HTTPRestResponse response = handler.sendMessage(requestBody, "", callContext); + RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", requestBody); Assertions.assertEquals(400, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -528,7 +528,7 @@ public void testExtensionSupportRequiredErrorOnSendStreamingMessage() { } }"""; - RestHandler.HTTPRestResponse response = handler.sendStreamingMessage(requestBody, "", callContext); + RestHandler.HTTPRestResponse response = handler.sendStreamingMessage(callContext, "", requestBody); // Streaming responses embed errors in the stream with status 200 Assertions.assertEquals(200, response.getStatusCode()); @@ -630,7 +630,7 @@ public void testRequiredExtensionProvidedSuccess() { } }"""; - RestHandler.HTTPRestResponse response = handler.sendMessage(requestBody, "", contextWithExtension); + RestHandler.HTTPRestResponse response = handler.sendMessage(contextWithExtension, "", requestBody); // Should succeed without error Assertions.assertEquals(200, response.getStatusCode()); @@ -681,7 +681,7 @@ public void testVersionNotSupportedErrorOnSendMessage() { } }"""; - RestHandler.HTTPRestResponse response = handler.sendMessage(requestBody, "", contextWithVersion); + RestHandler.HTTPRestResponse response = handler.sendMessage(contextWithVersion, "", requestBody); Assertions.assertEquals(501, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -731,7 +731,7 @@ public void testVersionNotSupportedErrorOnSendStreamingMessage() { } }"""; - RestHandler.HTTPRestResponse response = handler.sendStreamingMessage(requestBody, "", contextWithVersion); + RestHandler.HTTPRestResponse response = handler.sendStreamingMessage(contextWithVersion, "", requestBody); // Streaming responses embed errors in the stream with status 200 Assertions.assertEquals(200, response.getStatusCode()); @@ -824,7 +824,7 @@ public void testCompatibleVersionSuccess() { } }"""; - RestHandler.HTTPRestResponse response = handler.sendMessage(requestBody, "", contextWithVersion); + RestHandler.HTTPRestResponse response = handler.sendMessage(contextWithVersion, "", requestBody); // Should succeed without error Assertions.assertEquals(200, response.getStatusCode()); @@ -872,7 +872,7 @@ public void testNoVersionDefaultsToCurrentVersionSuccess() { } }"""; - RestHandler.HTTPRestResponse response = handler.sendMessage(requestBody, "", callContext); + RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", requestBody); // Should succeed without error (defaults to 1.0) Assertions.assertEquals(200, response.getStatusCode()); @@ -885,8 +885,8 @@ public void testListTasksNegativeTimestampReturns422() { RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); // Negative timestamp should return 422 (Invalid params) - RestHandler.HTTPRestResponse response = handler.listTasks(null, null, null, null, - null, "-1", null, "", callContext); + RestHandler.HTTPRestResponse response = handler.listTasks(callContext, "", null, null, null, null, + null, "-1", null); Assertions.assertEquals(422, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -900,8 +900,8 @@ public void testListTasksUnixMillisecondsTimestamp() { // Unix milliseconds timestamp should be accepted String timestampMillis = String.valueOf(System.currentTimeMillis() - 10000); - RestHandler.HTTPRestResponse response = handler.listTasks(null, null, null, null, - null, timestampMillis, null, "", callContext); + RestHandler.HTTPRestResponse response = handler.listTasks(callContext, "", null, null, null, null, + null, timestampMillis, null); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -914,8 +914,8 @@ public void testListTasksProtobufEnumStatus() { taskStore.save(MINIMAL_TASK, false); // Protobuf enum format (TASK_STATE_SUBMITTED) should be accepted - RestHandler.HTTPRestResponse response = handler.listTasks(null, "TASK_STATE_SUBMITTED", null, null, - null, null, null, "", callContext); + RestHandler.HTTPRestResponse response = handler.listTasks(callContext, "", null, "TASK_STATE_SUBMITTED", null, null, + null, null, null); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -928,8 +928,8 @@ public void testListTasksEnumConstantStatus() { taskStore.save(MINIMAL_TASK, false); // Enum constant format (SUBMITTED) should be accepted - RestHandler.HTTPRestResponse response = handler.listTasks(null, "SUBMITTED", null, null, - null, null, null, "", callContext); + RestHandler.HTTPRestResponse response = handler.listTasks(callContext, "", null, "SUBMITTED", null, null, + null, null, null); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -941,8 +941,8 @@ public void testListTasksEmptyResultIncludesAllFields() { RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); // Query for a context that doesn't exist - should return empty result with all fields - RestHandler.HTTPRestResponse response = handler.listTasks("nonexistent-context-id", null, null, null, - null, null, null, "", callContext); + RestHandler.HTTPRestResponse response = handler.listTasks(callContext, "", "nonexistent-context-id", null, null, null, + null, null, null); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType());