Skip to content

Commit 893ce8b

Browse files
committed
feat: Add telemetry support.
* Adding an annotation that can be used by CDI interceptor to create spans on current exchanges. * Adding support for client wrappers to be able to add client side spans. * Updating the helloworld example to use opentelemetry. Fixing issue a2aproject#388 Signed-off-by: Emmanuel Hugonnet <[email protected]>
1 parent 4c71032 commit 893ce8b

File tree

32 files changed

+1130
-64
lines changed

32 files changed

+1130
-64
lines changed

client/base/src/main/java/io/a2a/client/ClientBuilder.java

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,32 @@
55
import io.a2a.client.transport.spi.ClientTransportConfig;
66
import io.a2a.client.transport.spi.ClientTransportConfigBuilder;
77
import io.a2a.client.transport.spi.ClientTransportProvider;
8+
import io.a2a.client.transport.spi.ClientTransportWrapper;
89
import io.a2a.spec.A2AClientException;
910
import io.a2a.spec.AgentCard;
1011
import io.a2a.spec.AgentInterface;
1112
import io.a2a.spec.TransportProtocol;
13+
import org.jspecify.annotations.NonNull;
14+
import org.jspecify.annotations.Nullable;
15+
import org.slf4j.Logger;
16+
import org.slf4j.LoggerFactory;
1217

1318
import java.util.ArrayList;
1419
import java.util.HashMap;
1520
import java.util.LinkedHashMap;
1621
import java.util.List;
1722
import java.util.Map;
1823
import java.util.ServiceLoader;
24+
import java.util.ServiceLoader.Provider;
1925
import java.util.function.BiConsumer;
2026
import java.util.function.Consumer;
21-
import org.jspecify.annotations.NonNull;
22-
import org.jspecify.annotations.Nullable;
27+
import java.util.stream.Collectors;
2328

2429
public class ClientBuilder {
2530

2631
private static final Map<String, ClientTransportProvider<? extends ClientTransport, ? extends ClientTransportConfig<?>>> transportProviderRegistry = new HashMap<>();
2732
private static final Map<Class<? extends ClientTransport>, String> transportProtocolMapping = new HashMap<>();
33+
private static final Logger LOGGER = LoggerFactory.getLogger(ClientBuilder.class);
2834

2935
static {
3036
ServiceLoader<ClientTransportProvider> loader = ServiceLoader.load(ClientTransportProvider.class);
@@ -37,7 +43,8 @@ public class ClientBuilder {
3743
private final AgentCard agentCard;
3844

3945
private final List<BiConsumer<ClientEvent, AgentCard>> consumers = new ArrayList<>();
40-
private @Nullable Consumer<Throwable> streamErrorHandler;
46+
private @Nullable
47+
Consumer<Throwable> streamErrorHandler;
4148
private ClientConfig clientConfig = new ClientConfig.Builder().build();
4249

4350
private final Map<Class<? extends ClientTransport>, ClientTransportConfig<? extends ClientTransport>> clientTransports = new LinkedHashMap<>();
@@ -105,7 +112,7 @@ private ClientTransport buildClientTransport() throws A2AClientException {
105112
throw new A2AClientException("Missing required TransportConfig for " + agentInterface.transport());
106113
}
107114

108-
return clientTransportProvider.create(clientTransportConfig, agentCard, agentInterface.url());
115+
return wrap(clientTransportProvider.create(clientTransportConfig, agentCard, agentInterface.url()), clientTransportConfig);
109116
}
110117

111118
private Map<String, String> getServerPreferredTransports() {
@@ -160,10 +167,50 @@ private AgentInterface findBestClientTransport() throws A2AClientException {
160167
if (transportProtocol == null || transportUrl == null) {
161168
throw new A2AClientException("No compatible transport found");
162169
}
163-
if (! transportProviderRegistry.containsKey(transportProtocol)) {
170+
if (!transportProviderRegistry.containsKey(transportProtocol)) {
164171
throw new A2AClientException("No client available for " + transportProtocol);
165172
}
166173

167174
return new AgentInterface(transportProtocol, transportUrl);
168175
}
176+
177+
/**
178+
* Wraps the transport with all available transport wrappers discovered via ServiceLoader.
179+
* Wrappers are applied in priority order (highest priority first).
180+
*
181+
* @param transport the base transport to wrap
182+
* @param clientTransportConfig the transport configuration
183+
* @return the wrapped transport (or original if no wrappers are available/applicable)
184+
*/
185+
private ClientTransport wrap(ClientTransport transport, ClientTransportConfig<? extends ClientTransport> clientTransportConfig) {
186+
ServiceLoader<ClientTransportWrapper> wrapperLoader = ServiceLoader.load(ClientTransportWrapper.class);
187+
188+
// Collect all wrappers and sort by natural order (uses Comparable implementation)
189+
List<ClientTransportWrapper> wrappers = wrapperLoader.stream().map(Provider::get)
190+
.sorted()
191+
.collect(Collectors.toList());
192+
193+
if (wrappers.isEmpty()) {
194+
LOGGER.debug("No client transport wrappers found via ServiceLoader");
195+
return transport;
196+
}
197+
198+
// Apply wrappers in priority order
199+
ClientTransport wrapped = transport;
200+
for (ClientTransportWrapper wrapper : wrappers) {
201+
try {
202+
ClientTransport newWrapped = wrapper.wrap(wrapped, clientTransportConfig);
203+
if (newWrapped != wrapped) {
204+
LOGGER.debug("Applied transport wrapper: {} (priority: {})",
205+
wrapper.getClass().getName(), wrapper.priority());
206+
}
207+
wrapped = newWrapped;
208+
} catch (Exception e) {
209+
LOGGER.warn("Failed to apply transport wrapper {}: {}",
210+
wrapper.getClass().getName(), e.getMessage(), e);
211+
}
212+
}
213+
214+
return wrapped;
215+
}
169216
}

client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import io.a2a.client.transport.spi.ClientTransportConfig;
55
import org.jspecify.annotations.Nullable;
66

7-
public class RestTransportConfig extends ClientTransportConfig<RestTransport> {
7+
public class RestTransportConfig extends ClientTransportConfig<RestTransport> {
88

99
private final @Nullable A2AHttpClient httpClient;
1010

client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22

33
import io.a2a.client.transport.spi.interceptors.ClientCallInterceptor;
44
import java.util.ArrayList;
5+
import java.util.HashMap;
56

67
import java.util.List;
8+
import java.util.Map;
79

810
/**
911
* Configuration for an A2A client transport.
1012
*/
1113
public abstract class ClientTransportConfig<T extends ClientTransport> {
1214

1315
protected List<ClientCallInterceptor> interceptors = new ArrayList<>();
16+
protected Map<String, ? extends Object > parameters = new HashMap<>();
1417

1518
public void setInterceptors(List<ClientCallInterceptor> interceptors) {
1619
this.interceptors = new ArrayList<>(interceptors);
@@ -19,4 +22,12 @@ public void setInterceptors(List<ClientCallInterceptor> interceptors) {
1922
public List<ClientCallInterceptor> getInterceptors() {
2023
return interceptors;
2124
}
25+
26+
public void setParameters(Map<String, ? extends Object > parameters) {
27+
this.parameters = new HashMap<>(parameters);
28+
}
29+
30+
public Map<String, ? extends Object > getParameters() {
31+
return parameters;
32+
}
2233
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package io.a2a.client.transport.spi;
2+
3+
/**
4+
* Service provider interface for wrapping client transports with additional functionality.
5+
* Implementations can add cross-cutting concerns like tracing, metrics, logging, etc.
6+
*
7+
* <p>Wrappers are discovered via Java's ServiceLoader mechanism. To register a wrapper,
8+
* create a file {@code META-INF/services/io.a2a.client.transport.spi.ClientTransportWrapper}
9+
* containing the fully qualified class name of your implementation.
10+
*
11+
* <p>Wrappers are sorted by priority in descending order (highest priority first).
12+
* This interface implements {@link Comparable} to enable natural sorting.
13+
*
14+
* <p>Example implementation:
15+
* <pre>{@code
16+
* public class TracingWrapper implements ClientTransportWrapper {
17+
* @Override
18+
* public ClientTransport wrap(ClientTransport transport, ClientTransportConfig<?> config) {
19+
* if (config.getParameters().containsKey("tracer")) {
20+
* return new TracingTransport(transport, (Tracer) config.getParameters().get("tracer"));
21+
* }
22+
* return transport;
23+
* }
24+
*
25+
* @Override
26+
* public int priority() {
27+
* return 100; // Higher priority = wraps earlier (outermost)
28+
* }
29+
* }
30+
* }</pre>
31+
*/
32+
public interface ClientTransportWrapper extends Comparable<ClientTransportWrapper> {
33+
34+
/**
35+
* Wraps the given transport with additional functionality.
36+
*
37+
* <p>Implementations should check the configuration to determine if they should
38+
* actually wrap the transport. If the wrapper is not applicable (e.g., required
39+
* configuration is missing), return the original transport unchanged.
40+
*
41+
* @param transport the transport to wrap
42+
* @param config the transport configuration, may contain wrapper-specific parameters
43+
* @return the wrapped transport, or the original if wrapping is not applicable
44+
*/
45+
ClientTransport wrap(ClientTransport transport, ClientTransportConfig<?> config);
46+
47+
/**
48+
* Returns the priority of this wrapper. Higher priority wrappers are applied first
49+
* (wrap the transport earlier, resulting in being the outermost wrapper).
50+
*
51+
* <p>Default priority is 0. Suggested ranges:
52+
* <ul>
53+
* <li>1000+ : Critical infrastructure (security, authentication)
54+
* <li>500-999: Observability (tracing, metrics, logging)
55+
* <li>100-499: Enhancement (caching, retry logic)
56+
* <li>0-99: Optional features
57+
* </ul>
58+
*
59+
* @return the priority value, higher values = higher priority
60+
*/
61+
default int priority() {
62+
return 0;
63+
}
64+
65+
/**
66+
* Compares this wrapper with another based on priority.
67+
* Returns a negative integer, zero, or a positive integer as this wrapper
68+
* has higher priority than, equal to, or lower priority than the specified wrapper.
69+
*
70+
* <p>Note: This comparison is reversed (higher priority comes first) to enable
71+
* natural sorting in descending priority order.
72+
*
73+
* @param other the wrapper to compare to
74+
* @return negative if this has higher priority, positive if lower, zero if equal
75+
*/
76+
@Override
77+
default int compareTo(ClientTransportWrapper other) {
78+
// Reverse comparison: higher priority should come first
79+
return Integer.compare(other.priority(), this.priority());
80+
}
81+
}

examples/helloworld/client/pom.xml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@
2020
<groupId>io.github.a2asdk</groupId>
2121
<artifactId>a2a-java-sdk-client</artifactId>
2222
</dependency>
23+
<dependency>
24+
<groupId>io.github.a2asdk</groupId>
25+
<artifactId>a2a-java-sdk-opentelemetry</artifactId>
26+
</dependency>
27+
<dependency>
28+
<groupId>io.opentelemetry</groupId>
29+
<artifactId>opentelemetry-sdk</artifactId>
30+
</dependency>
31+
<dependency>
32+
<groupId>io.opentelemetry</groupId>
33+
<artifactId>opentelemetry-exporter-otlp</artifactId>
34+
</dependency>
35+
<dependency>
36+
<groupId>io.opentelemetry</groupId>
37+
<artifactId>opentelemetry-exporter-logging</artifactId>
38+
</dependency>
2339
</dependencies>
2440

2541
<build>

examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import io.a2a.A2A;
1313

1414
import io.a2a.client.Client;
15-
import io.a2a.client.ClientBuilder;
1615
import io.a2a.client.ClientEvent;
1716
import io.a2a.client.MessageEvent;
1817
import io.a2a.client.http.A2ACardResolver;
@@ -22,6 +21,12 @@
2221
import io.a2a.spec.Message;
2322
import io.a2a.spec.Part;
2423
import io.a2a.spec.TextPart;
24+
import io.opentelemetry.api.OpenTelemetry;
25+
import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
26+
import io.opentelemetry.sdk.OpenTelemetrySdk;
27+
import io.opentelemetry.sdk.resources.Resource;
28+
import io.opentelemetry.sdk.trace.SdkTracerProvider;
29+
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
2530

2631
/**
2732
* A simple example of using the A2A Java SDK to communicate with an A2A server.
@@ -34,7 +39,13 @@ public class HelloWorldClient {
3439
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
3540

3641
public static void main(String[] args) {
42+
3743
try {
44+
String endpoint = "http://localhost:5317";
45+
if(args != null && args.length > 0) {
46+
endpoint = args[0];
47+
}
48+
OpenTelemetry openTelemetrySdk = initOpenTelemetry(endpoint);
3849
AgentCard finalAgentCard = null;
3950
AgentCard publicAgentCard = new A2ACardResolver("http://localhost:9999").getAgentCard();
4051
System.out.println("Successfully fetched public agent card:");
@@ -82,16 +93,18 @@ public static void main(String[] args) {
8293
error.printStackTrace();
8394
messageResponse.completeExceptionally(error);
8495
};
85-
96+
JSONRPCTransportConfig transportConfig = new JSONRPCTransportConfig();
97+
transportConfig.setParameters(Map.of("io.a2a.extras.opentelemetry.Tracer",
98+
openTelemetrySdk.getTracer("helloworld-client")));
8699
Client client = Client
87100
.builder(finalAgentCard)
88101
.addConsumers(consumers)
89102
.streamingErrorHandler(streamingErrorHandler)
90-
.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfig())
103+
.withTransport(JSONRPCTransport.class, transportConfig)
91104
.build();
92105

93106
Message message = A2A.toUserMessage(MESSAGE_TEXT); // the message ID will be automatically generated for you
94-
107+
95108
System.out.println("Sending message: " + MESSAGE_TEXT);
96109
client.sendMessage(message);
97110
System.out.println("Message sent successfully. Responses will be handled by the configured consumers.");
@@ -108,4 +121,17 @@ public static void main(String[] args) {
108121
}
109122
}
110123

111-
}
124+
static OpenTelemetry initOpenTelemetry(String endpoint) {
125+
SdkTracerProvider sdkTracerProvider = SdkTracerProvider.builder()
126+
.addSpanProcessor(SimpleSpanProcessor.create(OtlpGrpcSpanExporter.builder().setEndpoint(endpoint).build()))
127+
.setResource(Resource.getDefault().toBuilder().put("service.version", "1.0").put("service.name", "helloworld-client").build())
128+
.build();
129+
130+
OpenTelemetrySdk openTelemetrySdk = OpenTelemetrySdk.builder()
131+
.setTracerProvider(sdkTracerProvider)
132+
.build();
133+
134+
Runtime.getRuntime().addShutdownHook(new Thread(sdkTracerProvider::close));
135+
return openTelemetrySdk;
136+
}
137+
}

examples/helloworld/pom.xml

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,25 +34,6 @@
3434
</dependencies>
3535
</dependencyManagement>
3636

37-
<build>
38-
<plugins>
39-
<plugin>
40-
<groupId>io.quarkus</groupId>
41-
<artifactId>quarkus-maven-plugin</artifactId>
42-
<extensions>true</extensions>
43-
<executions>
44-
<execution>
45-
<goals>
46-
<goal>build</goal>
47-
<goal>generate-code</goal>
48-
<goal>generate-code-tests</goal>
49-
</goals>
50-
</execution>
51-
</executions>
52-
</plugin>
53-
</plugins>
54-
</build>
55-
5637
<modules>
5738
<module>client</module>
5839
<module>server</module>

examples/helloworld/server/pom.xml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@
2929
<artifactId>quarkus-resteasy-jackson</artifactId>
3030
<scope>provided</scope>
3131
</dependency>
32+
<dependency>
33+
<groupId>io.quarkus</groupId>
34+
<artifactId>quarkus-opentelemetry</artifactId>
35+
</dependency>
36+
37+
<dependency>
38+
<groupId>io.github.a2asdk</groupId>
39+
<artifactId>a2a-java-sdk-opentelemetry</artifactId>
40+
</dependency>
3241
<dependency>
3342
<groupId>jakarta.enterprise</groupId>
3443
<artifactId>jakarta.enterprise.cdi-api</artifactId>
@@ -38,6 +47,11 @@
3847
<groupId>jakarta.ws.rs</groupId>
3948
<artifactId>jakarta.ws.rs-api</artifactId>
4049
</dependency>
50+
<dependency>
51+
<groupId>io.quarkus</groupId>
52+
<artifactId>quarkus-observability-devservices-lgtm</artifactId>
53+
<scope>provided</scope>
54+
</dependency>
4155
</dependencies>
4256

4357
<build>
@@ -55,6 +69,9 @@
5569
</goals>
5670
</execution>
5771
</executions>
72+
<configuration>
73+
<jvmArgs>--add-opens=java.base/java.lang=ALL-UNNAMED</jvmArgs>
74+
</configuration>
5875
</plugin>
5976
</plugins>
6077
</build>

0 commit comments

Comments
 (0)