Skip to content

Commit 479eaf8

Browse files
authored
feat(opentelemetry): add push notification tracing and trace-context propagation (#1229)
feat!(opentelemetry): add push notification tracing and trace-context propagation BREAKING: PushNotificationSender.sendNotification() now returns boolean. - OTel decorator creates CLIENT spans for push delivery with error tracking - W3C traceparent/tracestate headers injected into outbound push notification requests - @Instrumented CDI qualifier transparently wraps application HTTP client and dispatch executor - Three-tier CDI resolution: @Instrumented → @Named/unqualified → built-in default Signed-off-by: Emmanuel Hugonnet <ehugonne@redhat.com>
1 parent c1c9434 commit 479eaf8

40 files changed

Lines changed: 2050 additions & 122 deletions

File tree

‎compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/AbstractA2ARequestHandlerTest_v0_3.java‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
import org.a2aproject.sdk.server.tasks.InMemoryPushNotificationConfigStore;
3131
import org.a2aproject.sdk.server.tasks.InMemoryTaskStore;
3232
import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore;
33-
import org.a2aproject.sdk.server.tasks.PushNotificationSender;
3433
import org.a2aproject.sdk.server.tasks.PushNotificationUrlValidator;
3534
import org.a2aproject.sdk.server.tasks.TaskStore;
3635
import org.a2aproject.sdk.spec.A2AError;
@@ -85,8 +84,6 @@ public abstract class AbstractA2ARequestHandlerTest_v0_3 {
8584
.parts(new TextPart_v0_3("test message"))
8685
.build();
8786

88-
private static final PushNotificationSender NOOP_PUSHNOTIFICATION_SENDER = (event, snapshot) -> {};
89-
9087
// V1.0 backend infrastructure
9188
protected AgentExecutor agentExecutor;
9289
protected TaskStore taskStore;

‎docs/content/dev/extra/http-clients.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,3 +266,9 @@ public class MyHttpClientProducer {
266266
| 0 | JDK | Always (fallback) |
267267

268268
If the CDI container is active but no `A2AHttpClient` bean is registered, this provider is skipped and the next available provider is used.
269+
270+
### Compatibility with OpenTelemetry
271+
272+
When the [OpenTelemetry server module](opentelemetry.md) is on the classpath alongside a custom `A2AHttpClient` CDI bean, the OpenTelemetry module wraps your bean with trace-context propagation rather than replacing it. Your custom configuration (mTLS, proxy, authentication, timeouts, etc.) is preserved for push notification delivery while `traceparent` and `tracestate` headers are injected automatically.
273+
274+
This works through the `@Instrumented` CDI qualifier (defined in `server-common`): the OpenTelemetry module produces an `@Instrumented A2AHttpClient` that delegates to your unqualified bean. `BasePushNotificationSender` prefers the `@Instrumented` variant when available, so both your custom configuration and the trace-context headers are present on outbound requests. No changes to your producer code are required.

‎docs/content/dev/extra/opentelemetry.md‎

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,70 @@ The following attributes are automatically added to spans (defined in `A2AObserv
177177
| `gen_ai.agent.a2a.response` | Full response data (only if extraction enabled) |
178178
| `error.type` | Error message (on failures) |
179179

180+
### Push Notification Delivery Tracing
181+
182+
When the server delivers a push notification to a client webhook, `OpenTelemetryPushNotificationSenderDecorator` creates a `CLIENT` span named `SendPushNotification`. This span is independent of the original request span but carries the `gen_ai.agent.a2a.task_id` attribute, allowing correlation by task ID in your observability tool. The span status reflects the actual delivery outcome: `OK` when all notifications succeed, `ERROR` when one or more deliveries fail (with `error.type` set to `delivery_failed`) or when an exception is thrown.
183+
184+
The module also provides **W3C trace-context propagation** for outbound push notification HTTP requests via the `@Instrumented` CDI qualifier and `OpenTelemetryHttpClientProducer`.
185+
186+
#### How it works
187+
188+
The `OpenTelemetryHttpClientProducer` CDI bean produces an `@Instrumented A2AHttpClient` — a wrapper that injects `traceparent` and `tracestate` headers into every outbound HTTP request. `BasePushNotificationSender` resolves the HTTP client in this order:
189+
190+
1. An `@Instrumented A2AHttpClient` bean (if available — e.g. the one produced by this module)
191+
2. An unqualified `A2AHttpClient` bean (if the application provides one)
192+
3. `A2AHttpClientFactory.create()` as last resort
193+
194+
The `@Instrumented` qualifier (defined in `server-common`) marks HTTP client beans that have been wrapped with observability instrumentation. It ensures the instrumented client does not compete with the application's own `A2AHttpClient` bean in default CDI resolution — the two coexist, and the instrumented variant wraps the application's bean as its delegate.
195+
196+
#### Custom HTTP client compatibility
197+
198+
If your application provides a custom `A2AHttpClient` CDI bean (e.g. for mTLS, proxy, or custom timeouts), the OpenTelemetry wrapper **preserves that configuration** by delegating to your bean rather than replacing it. No additional annotations or configuration are needed — just produce your bean as usual:
199+
200+
```java
201+
@Produces
202+
@ApplicationScoped
203+
public A2AHttpClient myHttpClient() {
204+
// your custom client (mTLS, proxy, auth, etc.)
205+
}
206+
```
207+
208+
The OpenTelemetry module automatically wraps it with trace-context injection.
209+
210+
#### Context-propagating executor
211+
212+
The producer also creates an `@Instrumented Executor` (via `Context.taskWrapping()`) that ensures the OTel trace context survives the async boundary when push notifications are dispatched to multiple recipients in parallel. This allows the receiving webhook to join the distributed trace.
213+
214+
`BasePushNotificationSender` resolves the dispatch executor in this order:
215+
216+
1. An `@Instrumented Executor` bean (if available — e.g. the one produced by this module)
217+
2. A `@Named("a2a.pushNotificationDispatch") Executor` bean (if the application provides one)
218+
3. `ForkJoinPool.commonPool()` as last resort
219+
220+
#### Custom dispatch executor compatibility
221+
222+
If your application provides a custom `@Named("a2a.pushNotificationDispatch") Executor` CDI bean (e.g. for custom thread pool sizing), the OpenTelemetry module **preserves that configuration** by wrapping it with `Context.taskWrapping()` rather than replacing it. Just produce your bean as usual:
223+
224+
```java
225+
@Produces
226+
@ApplicationScoped
227+
@Named("a2a.pushNotificationDispatch")
228+
public Executor myDispatchExecutor() {
229+
// your custom thread pool
230+
}
231+
```
232+
233+
The OpenTelemetry module automatically wraps it with context propagation.
234+
235+
| Attribute | Description |
236+
|-----------|-------------|
237+
| `gen_ai.agent.a2a.operation.name` | `SendPushNotification` |
238+
| `gen_ai.agent.a2a.task_id` | Task identifier |
239+
| `gen_ai.agent.a2a.context_id` | Context identifier (when available) |
240+
| `gen_ai.agent.a2a.push_notification.event_kind` | The type of streaming event delivered (e.g. `TaskStatusUpdateEvent`) |
241+
| `gen_ai.agent.a2a.response` | Full event payload (only if extraction enabled) |
242+
| `error.type` | `delivery_failed` when one or more deliveries fail, or the exception message when an exception is thrown |
243+
180244
### Request/Response Extraction
181245

182246
Enable request and response data extraction in spans using JVM system properties:
@@ -195,7 +259,7 @@ Enable request and response data extraction in spans using JVM system properties
195259

196260
### Instrumentation
197261

198-
Adds OpenTelemetry spans to A2A client operations:
262+
Adds OpenTelemetry spans and metrics to A2A client operations:
199263

200264
```xml
201265
<dependency>
@@ -206,11 +270,20 @@ Adds OpenTelemetry spans to A2A client operations:
206270

207271
### Metrics
208272

209-
The client transport records the following histogram:
273+
The client transport wrapper is auto-discovered via ServiceLoader — just adding the dependency enables tracing. To also enable `gen_ai.client.operation.duration` metrics, pass a `Meter` via the transport config:
274+
275+
```java
276+
config.setParameters(Map.of(
277+
OpenTelemetryClientTransportWrapper.OTEL_TRACER_KEY, openTelemetry.getTracer("my-service"),
278+
OpenTelemetryClientTransportWrapper.OTEL_METER_KEY, openTelemetry.getMeter("my-service")
279+
));
280+
```
281+
282+
If no `Meter` is provided, the histogram is silently skipped.
210283

211284
| Instrument | Type | Unit | Description |
212285
|------------|------|------|-------------|
213-
| `gen_ai.client.operation.duration` | Histogram | `s` | Duration of A2A client operations from invocation to completion or error |
286+
| `gen_ai.client.operation.duration` | Histogram | `s` | Duration of each A2A client operation call |
214287

215288
Attributes on `gen_ai.client.operation.duration`:
216289

@@ -220,14 +293,6 @@ Attributes on `gen_ai.client.operation.duration`:
220293
| `gen_ai.system` | `a2a` |
221294
| `error.type` | `error` (only on failure) |
222295

223-
Metrics require passing a `Meter` to the constructor:
224-
225-
```java
226-
OpenTelemetryClientTransport transport = new OpenTelemetryClientTransport(delegate, tracer, meter);
227-
```
228-
229-
If `meter` is `null`, the histogram is silently skipped.
230-
231296
### Context Propagation
232297

233298
For trace context propagation across async client boundaries:

‎extras/http-client-cdi/src/test/java/org/a2aproject/sdk/client/http/cdi/CdiA2AHttpClientFactoryTest.java‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ public void testCreateDoesNotLogWarningWhenCdiSucceeds() {
8181
assertFalse(records.stream().anyMatch(r -> r.getLevel() == Level.WARNING),
8282
"No warning should be logged when CDI provider succeeds");
8383
}
84+
85+
@Test
86+
public void testCreateExcludingCdiSkipsTheCdiBeanAndFallsBack() {
87+
// Regression guard for the recursive CDI self-delegation bug: a CDI producer that
88+
// produces an A2AHttpClient must call createExcluding("cdi") so the factory does not
89+
// re-enter the container and return the proxy being constructed as its own delegate.
90+
A2AHttpClient client = A2AHttpClientFactory.createExcluding("cdi");
91+
assertNotNull(client);
92+
assertFalse(client instanceof DummyA2AHttpClient,
93+
"createExcluding('cdi') must not return the CDI-registered bean");
94+
}
8495
}
8596

8697
@Nested

‎extras/opentelemetry/common/src/main/java/org/a2aproject/sdk/extras/opentelemetry/A2AObservabilityNames.java‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,8 @@ public interface A2AObservabilityNames {
4242
String GENAI_STREAMING_DURATION = GENAI_PREFIX + ".streaming.duration";
4343
String GENAI_SYSTEM = "gen_ai.system";
4444
String GENAI_SYSTEM_VALUE = "a2a";
45+
46+
// Push notification
47+
String PUSH_NOTIFICATION_EVENT_KIND = GENAI_PREFIX + ".push_notification.event_kind";
48+
String PUSH_NOTIFICATION_DELIVERY_FAILED = "delivery_failed";
4549
}

‎extras/opentelemetry/integration-tests/src/main/java/org/a2aproject/sdk/extras/opentelemetry/it/A2ATestRoutes.java‎

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
package org.a2aproject.sdk.extras.opentelemetry.it;
22

3-
4-
53
import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE;
4+
import static java.net.HttpURLConnection.HTTP_OK;
65

76
import com.google.gson.Gson;
87
import com.google.gson.GsonBuilder;
@@ -12,6 +11,7 @@
1211
import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil;
1312
import org.a2aproject.sdk.spec.Task;
1413
import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent;
14+
import org.a2aproject.sdk.spec.TaskPushNotificationConfig;
1515
import org.a2aproject.sdk.spec.TaskStatusUpdateEvent;
1616
import io.opentelemetry.api.OpenTelemetry;
1717
import io.opentelemetry.api.trace.Span;
@@ -32,7 +32,10 @@
3232
import jakarta.enterprise.inject.Produces;
3333
import jakarta.inject.Inject;
3434
import jakarta.inject.Singleton;
35+
import java.util.HashMap;
3536
import java.util.List;
37+
import java.util.Map;
38+
import java.util.concurrent.CopyOnWriteArrayList;
3639

3740
/**
3841
* Test routes for OpenTelemetry integration testing.
@@ -45,6 +48,11 @@ public class A2ATestRoutes {
4548
private static final String TEXT_PLAIN = "text/plain";
4649
private static final Gson gson = new GsonBuilder().create();
4750

51+
/**
52+
* Captured webhook requests, each entry is a map of header name to value.
53+
*/
54+
private final List<Map<String, String>> webhookRequests = new CopyOnWriteArrayList<>();
55+
4856
@Inject
4957
TestUtilsBean testUtilsBean;
5058
@Inject
@@ -53,6 +61,8 @@ public class A2ATestRoutes {
5361
InMemoryMetricExporter inMemoryMetricExporter;
5462
@Inject
5563
OpenTelemetry openTelemetry;
64+
@Inject
65+
CustomDispatchExecutorProducer customDispatchExecutorProducer;
5666

5767
@Inject
5868
Tracer tracer;
@@ -85,6 +95,37 @@ void setupRoutes(@Observes Router router) {
8595
.produces(TEXT_PLAIN)
8696
.handler(ctx -> getChildQueueCount(ctx.pathParam("taskId"), ctx));
8797

98+
router.post("/test/pushconfig/:taskId")
99+
.consumes(APPLICATION_JSON)
100+
.handler(BodyHandler.create())
101+
.blockingHandler(ctx -> savePushNotificationConfig(ctx.pathParam("taskId"), ctx.body().asString(), ctx));
102+
103+
router.delete("/test/pushconfig/:taskId/:configId")
104+
.blockingHandler(ctx -> deletePushNotificationConfig(
105+
ctx.pathParam("taskId"), ctx.pathParam("configId"), ctx));
106+
107+
router.post("/test/webhook")
108+
.handler(BodyHandler.create())
109+
.handler(ctx -> captureWebhook(ctx));
110+
111+
router.get("/test/webhook/requests")
112+
.produces(APPLICATION_JSON)
113+
.handler(ctx -> getWebhookRequests(ctx));
114+
115+
router.delete("/test/webhook/requests")
116+
.handler(ctx -> clearWebhookRequests(ctx));
117+
118+
router.get("/test/dispatch-executor/count")
119+
.produces(TEXT_PLAIN)
120+
.handler(ctx -> ctx.response().setStatusCode(HTTP_OK)
121+
.end(String.valueOf(customDispatchExecutorProducer.getExecutionCount())));
122+
123+
router.delete("/test/dispatch-executor/count")
124+
.handler(ctx -> {
125+
customDispatchExecutorProducer.resetExecutionCount();
126+
ctx.response().setStatusCode(HTTP_OK).end();
127+
});
128+
88129
router.get("/hello")
89130
.produces(TEXT_PLAIN)
90131
.handler(ctx -> hello(ctx));
@@ -197,6 +238,48 @@ public void getChildQueueCount(String taskId, RoutingContext rc) {
197238
.end(String.valueOf(count));
198239
}
199240

241+
public void savePushNotificationConfig(String taskId, String body, RoutingContext rc) {
242+
try {
243+
TaskPushNotificationConfig config = JsonUtil.fromJson(body, TaskPushNotificationConfig.class);
244+
testUtilsBean.savePushNotificationConfig(taskId, config);
245+
rc.response()
246+
.setStatusCode(200)
247+
.end();
248+
} catch (Throwable t) {
249+
errorResponse(t, rc);
250+
}
251+
}
252+
253+
public void deletePushNotificationConfig(String taskId, String configId, RoutingContext rc) {
254+
try {
255+
testUtilsBean.deletePushNotificationConfig(taskId, configId);
256+
rc.response()
257+
.setStatusCode(200)
258+
.end();
259+
} catch (Throwable t) {
260+
errorResponse(t, rc);
261+
}
262+
}
263+
264+
private void captureWebhook(RoutingContext rc) {
265+
Map<String, String> headers = new HashMap<>();
266+
rc.request().headers().forEach(entry -> headers.put(entry.getKey(), entry.getValue()));
267+
webhookRequests.add(headers);
268+
rc.response().setStatusCode(HTTP_OK).end();
269+
}
270+
271+
private void getWebhookRequests(RoutingContext rc) {
272+
rc.response()
273+
.setStatusCode(HTTP_OK)
274+
.putHeader(CONTENT_TYPE, APPLICATION_JSON)
275+
.end(gson.toJson(webhookRequests));
276+
}
277+
278+
private void clearWebhookRequests(RoutingContext rc) {
279+
webhookRequests.clear();
280+
rc.response().setStatusCode(HTTP_OK).end();
281+
}
282+
200283
public void hello(RoutingContext rc) {
201284
Span span = tracer.spanBuilder("hello").startSpan();
202285
try (Scope scope = span.makeCurrent()) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package org.a2aproject.sdk.extras.opentelemetry.it;
2+
3+
import java.util.concurrent.Executor;
4+
import java.util.concurrent.ExecutorService;
5+
import java.util.concurrent.Executors;
6+
import java.util.concurrent.ThreadFactory;
7+
import java.util.concurrent.atomic.AtomicInteger;
8+
9+
import jakarta.annotation.PreDestroy;
10+
import jakarta.enterprise.context.ApplicationScoped;
11+
import jakarta.enterprise.inject.Produces;
12+
import jakarta.inject.Named;
13+
import org.a2aproject.sdk.server.tasks.BasePushNotificationSender;
14+
15+
/**
16+
* Produces a custom {@link Named @Named} dispatch {@link Executor} CDI bean
17+
* that uses a distinctive thread name prefix. Used by integration tests to
18+
* verify that the OpenTelemetry module wraps the application's executor with
19+
* context propagation instead of replacing it.
20+
*
21+
* <p>An {@link AtomicInteger} counter tracks how many tasks have been
22+
* submitted, allowing tests to confirm the custom executor was actually used.
23+
*/
24+
@ApplicationScoped
25+
public class CustomDispatchExecutorProducer {
26+
27+
static final String CUSTOM_THREAD_PREFIX = "custom-dispatch-";
28+
29+
private final AtomicInteger executionCount = new AtomicInteger();
30+
private final ExecutorService pool;
31+
32+
public CustomDispatchExecutorProducer() {
33+
AtomicInteger threadCounter = new AtomicInteger();
34+
ThreadFactory threadFactory = r -> {
35+
Thread t = new Thread(r, CUSTOM_THREAD_PREFIX + threadCounter.getAndIncrement());
36+
t.setDaemon(true);
37+
return t;
38+
};
39+
this.pool = Executors.newFixedThreadPool(2, threadFactory);
40+
}
41+
42+
@Produces
43+
@ApplicationScoped
44+
@Named(BasePushNotificationSender.DISPATCH_EXECUTOR_NAME)
45+
Executor customDispatchExecutor() {
46+
return command -> {
47+
executionCount.incrementAndGet();
48+
pool.execute(command);
49+
};
50+
}
51+
52+
/**
53+
* Returns the number of tasks submitted through this executor.
54+
*/
55+
public int getExecutionCount() {
56+
return executionCount.get();
57+
}
58+
59+
/**
60+
* Resets the execution counter.
61+
*/
62+
public void resetExecutionCount() {
63+
executionCount.set(0);
64+
}
65+
66+
@PreDestroy
67+
void shutdown() {
68+
pool.shutdownNow();
69+
}
70+
}

0 commit comments

Comments
 (0)