Skip to content

Commit 49b8dd0

Browse files
committed
feat: Add OpenTelemetry tracing support with comprehensive testing and null safety
Added OpenTelemetry integration for distributed tracing across the A2A SDK: - Added @trace annotation for CDI interceptors to create spans on method invocations - Created SpanInterceptor with robust CDI proxy handling supporting 5 proxy patterns - Added InvocationContext record for capturing method invocation details with: * Null safety annotations (@NonNull/@nullable) * Comprehensive JavaDoc documentation * Specific exception declarations (IllegalAccessException, InvocationTargetException) - Implemented attribute extractors for all transport types: * GrpcAttributeExtractor with null-safe gRPC context key access * JsonRPCAttributeExtractor with method name extraction * RestAttributeExtractor with conditional attribute building * RequestHandlerAttributeExtractor for default request handler - Added ClientTransportWrapper SPI for wrapping transport operations - Created OpenTelemetryClientTransport decorator supporting: * All A2A client methods (sendMessage, getTask, listTasks, etc.) * Streaming operations with event consumer wrapping * Error consumer instrumentation * Helper methods to eliminate code duplication - Updated ClientBuilder to apply transport wrappers via ServiceLoader - Added 21 comprehensive tests for OpenTelemetryClientTransport - Added 12 tests for SpanInterceptor CDI proxy handling - Added 10 tests for GrpcAttributeExtractor null safety - All attribute extractors include defensive null checks preventing NPE - 244 total tests passing in server-common module - Updated HelloWorld example with OpenTelemetry integration: * Added OTEL dependencies and configuration * Configured trace export to console * Added README documentation for running with telemetry * Server now includes @trace annotation on agent method - Added extras/opentelemetry module with dependencies - Updated BOM with OpenTelemetry extras - Added CDI beans.xml and ServiceLoader configuration - Updated .gitignore for IDE and build artifacts Fixes a2aproject#388 Signed-off-by: Emmanuel Hugonnet <[email protected]>
1 parent cf82353 commit 49b8dd0

File tree

44 files changed

+2356
-72
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+2356
-72
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ pom.xml.versionsBackup
66
release.properties
77
.flattened-pom.xml
88

9+
#Claude
10+
CLAUDE.md
11+
912
# Eclipse
1013
.project
1114
.classpath
@@ -20,6 +23,7 @@ bin/
2023

2124
# NetBeans
2225
nb-configuration.xml
26+
nbactions.xml
2327

2428
# Visual Studio Code
2529
.vscode

boms/extras/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
<artifactId>a2a-java-extras-common</artifactId>
3535
<version>${project.version}</version>
3636
</dependency>
37+
<dependency>
38+
<groupId>${project.groupId}</groupId>
39+
<artifactId>a2a-java-sdk-opentelemetry</artifactId>
40+
<version>${project.version}</version>
41+
</dependency>
3742
<dependency>
3843
<groupId>${project.groupId}</groupId>
3944
<artifactId>a2a-java-extras-task-store-database-jpa</artifactId>

boms/extras/src/it/extras-usage-test/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
<groupId>io.github.a2asdk</groupId>
4545
<artifactId>a2a-java-extras-common</artifactId>
4646
</dependency>
47+
<dependency>
48+
<groupId>io.github.a2asdk</groupId>
49+
<artifactId>a2a-java-sdk-opentelemetry</artifactId>
50+
</dependency>
4751
<dependency>
4852
<groupId>io.github.a2asdk</groupId>
4953
<artifactId>a2a-java-extras-task-store-database-jpa</artifactId>

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

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,27 @@
66
import java.util.List;
77
import java.util.Map;
88
import java.util.ServiceLoader;
9+
import java.util.ServiceLoader.Provider;
910
import java.util.function.BiConsumer;
1011
import java.util.function.Consumer;
12+
import java.util.stream.Collectors;
1113

1214
import io.a2a.client.config.ClientConfig;
1315
import io.a2a.client.transport.spi.ClientTransport;
1416
import io.a2a.client.transport.spi.ClientTransportConfig;
1517
import io.a2a.client.transport.spi.ClientTransportConfigBuilder;
1618
import io.a2a.client.transport.spi.ClientTransportProvider;
19+
import io.a2a.client.transport.spi.ClientTransportWrapper;
1720
import io.a2a.spec.A2AClientException;
1821
import io.a2a.spec.AgentCard;
1922
import io.a2a.spec.AgentInterface;
2023
import io.a2a.spec.TransportProtocol;
2124
import org.jspecify.annotations.NonNull;
2225
import org.jspecify.annotations.Nullable;
2326

27+
import org.slf4j.Logger;
28+
import org.slf4j.LoggerFactory;
29+
2430
/**
2531
* Builder for creating instances of {@link Client} to communicate with A2A agents.
2632
* <p>
@@ -96,6 +102,7 @@ public class ClientBuilder {
96102

97103
private static final Map<String, ClientTransportProvider<? extends ClientTransport, ? extends ClientTransportConfig<?>>> transportProviderRegistry = new HashMap<>();
98104
private static final Map<Class<? extends ClientTransport>, String> transportProtocolMapping = new HashMap<>();
105+
private static final Logger LOGGER = LoggerFactory.getLogger(ClientBuilder.class);
99106

100107
static {
101108
ServiceLoader<ClientTransportProvider> loader = ServiceLoader.load(ClientTransportProvider.class);
@@ -108,7 +115,8 @@ public class ClientBuilder {
108115
private final AgentCard agentCard;
109116

110117
private final List<BiConsumer<ClientEvent, AgentCard>> consumers = new ArrayList<>();
111-
private @Nullable Consumer<Throwable> streamErrorHandler;
118+
private @Nullable
119+
Consumer<Throwable> streamErrorHandler;
112120
private ClientConfig clientConfig = new ClientConfig.Builder().build();
113121

114122
private final Map<Class<? extends ClientTransport>, ClientTransportConfig<? extends ClientTransport>> clientTransports = new LinkedHashMap<>();
@@ -318,7 +326,7 @@ private ClientTransport buildClientTransport() throws A2AClientException {
318326
throw new A2AClientException("Missing required TransportConfig for " + agentInterface.protocolBinding());
319327
}
320328

321-
return clientTransportProvider.create(clientTransportConfig, agentCard, agentInterface);
329+
return wrap(clientTransportProvider.create(clientTransportConfig, agentCard, agentInterface), clientTransportConfig);
322330
}
323331

324332
private Map<String, String> getServerPreferredTransports() throws A2AClientException {
@@ -373,10 +381,50 @@ private AgentInterface findBestClientTransport() throws A2AClientException {
373381
if (transportProtocol == null || transportUrl == null) {
374382
throw new A2AClientException("No compatible transport found");
375383
}
376-
if (! transportProviderRegistry.containsKey(transportProtocol)) {
384+
if (!transportProviderRegistry.containsKey(transportProtocol)) {
377385
throw new A2AClientException("No client available for " + transportProtocol);
378386
}
379387

380388
return new AgentInterface(transportProtocol, transportUrl);
381389
}
390+
391+
/**
392+
* Wraps the transport with all available transport wrappers discovered via ServiceLoader.
393+
* Wrappers are applied in priority order (highest priority first).
394+
*
395+
* @param transport the base transport to wrap
396+
* @param clientTransportConfig the transport configuration
397+
* @return the wrapped transport (or original if no wrappers are available/applicable)
398+
*/
399+
private ClientTransport wrap(ClientTransport transport, ClientTransportConfig<? extends ClientTransport> clientTransportConfig) {
400+
ServiceLoader<ClientTransportWrapper> wrapperLoader = ServiceLoader.load(ClientTransportWrapper.class);
401+
402+
// Collect all wrappers and sort by natural order (uses Comparable implementation)
403+
List<ClientTransportWrapper> wrappers = wrapperLoader.stream().map(Provider::get)
404+
.sorted()
405+
.collect(Collectors.toList());
406+
407+
if (wrappers.isEmpty()) {
408+
LOGGER.debug("No client transport wrappers found via ServiceLoader");
409+
return transport;
410+
}
411+
412+
// Apply wrappers in priority order
413+
ClientTransport wrapped = transport;
414+
for (ClientTransportWrapper wrapper : wrappers) {
415+
try {
416+
ClientTransport newWrapped = wrapper.wrap(wrapped, clientTransportConfig);
417+
if (newWrapped != wrapped) {
418+
LOGGER.debug("Applied transport wrapper: {} (priority: {})",
419+
wrapper.getClass().getName(), wrapper.priority());
420+
}
421+
wrapped = newWrapped;
422+
} catch (Exception e) {
423+
LOGGER.warn("Failed to apply transport wrapper {}: {}",
424+
wrapper.getClass().getName(), e.getMessage(), e);
425+
}
426+
}
427+
428+
return wrapped;
429+
}
382430
}

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
@@ -48,7 +48,7 @@
4848
* @see A2AHttpClient
4949
* @see io.a2a.client.http.JdkA2AHttpClient
5050
*/
51-
public class RestTransportConfig extends ClientTransportConfig<RestTransport> {
51+
public class RestTransportConfig extends ClientTransportConfig<RestTransport> {
5252

5353
private final @Nullable A2AHttpClient httpClient;
5454

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package io.a2a.client.transport.spi;
22

33
import java.util.ArrayList;
4+
5+
import java.util.HashMap;
46
import java.util.List;
7+
import java.util.Map;
58

69
import io.a2a.client.transport.spi.interceptors.ClientCallInterceptor;
710

@@ -35,6 +38,7 @@
3538
public abstract class ClientTransportConfig<T extends ClientTransport> {
3639

3740
protected List<ClientCallInterceptor> interceptors = new ArrayList<>();
41+
protected Map<String, ? extends Object > parameters = new HashMap<>();
3842

3943
/**
4044
* Set the list of request/response interceptors.
@@ -63,4 +67,12 @@ public void setInterceptors(List<ClientCallInterceptor> interceptors) {
6367
public List<ClientCallInterceptor> getInterceptors() {
6468
return java.util.Collections.unmodifiableList(interceptors);
6569
}
70+
71+
public void setParameters(Map<String, ? extends Object > parameters) {
72+
this.parameters = new HashMap<>(parameters);
73+
}
74+
75+
public Map<String, ? extends Object > getParameters() {
76+
return parameters;
77+
}
6678
}
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+
}

0 commit comments

Comments
 (0)