Skip to content

Commit 6a0262d

Browse files
committed
feat(opentelemetry): add push notification tracing and trace-context propagation
PushNotificationSender.sendNotification() changed from void to boolean to let decorators observe delivery outcome. Signed-off-by: Emmanuel Hugonnet <ehugonne@redhat.com>
1 parent 05f5d9b commit 6a0262d

36 files changed

Lines changed: 1355 additions & 79 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public abstract class AbstractA2ARequestHandlerTest_v0_3 {
8585
.parts(new TextPart_v0_3("test message"))
8686
.build();
8787

88-
private static final PushNotificationSender NOOP_PUSHNOTIFICATION_SENDER = (event, snapshot) -> {};
88+
private static final PushNotificationSender NOOP_PUSHNOTIFICATION_SENDER = (event, snapshot) -> true;
8989

9090
// V1.0 backend infrastructure
9191
protected AgentExecutor agentExecutor;

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,21 @@ The following attributes are automatically added to spans (defined in `A2AObserv
106106
| `gen_ai.agent.a2a.response` | Full response data (only if extraction enabled) |
107107
| `error.type` | Error message (on failures) |
108108

109+
### Push Notification Delivery Tracing
110+
111+
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.
112+
113+
The module also provides **W3C trace-context propagation** for outbound push notification HTTP requests. When the OpenTelemetry server module is on the classpath, the `OpenTelemetryHttpClientProducer` CDI bean wraps the HTTP client used by `BasePushNotificationSender` with `OpenTelemetryA2AHttpClient`, which injects `traceparent` and `tracestate` headers into every push notification delivery. It also produces a context-propagating `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.
114+
115+
| Attribute | Description |
116+
|-----------|-------------|
117+
| `gen_ai.agent.a2a.operation.name` | `SendPushNotification` |
118+
| `gen_ai.agent.a2a.task_id` | Task identifier |
119+
| `gen_ai.agent.a2a.context_id` | Context identifier (when available) |
120+
| `gen_ai.agent.a2a.push_notification.event_kind` | The type of streaming event delivered (e.g. `TaskStatusUpdateEvent`) |
121+
| `gen_ai.agent.a2a.response` | Full event payload (only if extraction enabled) |
122+
| `error.type` | `delivery_failed` when one or more deliveries fail, or the exception message when an exception is thrown |
123+
109124
### Request/Response Extraction
110125

111126
Enable request and response data extraction in spans using JVM system properties:
@@ -124,7 +139,7 @@ Enable request and response data extraction in spans using JVM system properties
124139

125140
### Instrumentation
126141

127-
Adds OpenTelemetry spans to A2A client operations:
142+
Adds OpenTelemetry spans and metrics to A2A client operations:
128143

129144
```xml
130145
<dependency>
@@ -133,6 +148,27 @@ Adds OpenTelemetry spans to A2A client operations:
133148
</dependency>
134149
```
135150

151+
To also enable `gen_ai.client.operation.duration` metrics, pass a `Meter` via the transport config:
152+
153+
```java
154+
config.setParameters(Map.of(
155+
OpenTelemetryClientTransportWrapper.OTEL_TRACER_KEY, openTelemetry.getTracer("my-service"),
156+
OpenTelemetryClientTransportWrapper.OTEL_METER_KEY, openTelemetry.getMeter("my-service")
157+
));
158+
```
159+
160+
| Instrument | Type | Unit | Description |
161+
|------------|------|------|-------------|
162+
| `gen_ai.client.operation.duration` | Histogram | `s` | Duration of each A2A client operation call |
163+
164+
Attributes on `gen_ai.client.operation.duration`:
165+
166+
| Attribute | Value |
167+
|-----------|-------|
168+
| `gen_ai.agent.a2a.operation.name` | A2A method name (e.g. `message/send`) |
169+
| `gen_ai.system` | `a2a` |
170+
| `error.type` | `error` (only on failure) |
171+
136172
### Context Propagation
137173

138174
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
@@ -20,4 +20,8 @@ public interface A2AObservabilityNames {
2020
String GENAI_RESPONSE = GENAI_PREFIX + ".response"; // gen_ai.output.messages ?
2121
String GENAI_ROLE = GENAI_PREFIX + ".role";
2222
String GENAI_TASK_ID = GENAI_PREFIX + ".task_id";
23+
24+
// Push notification
25+
String PUSH_NOTIFICATION_EVENT_KIND = GENAI_PREFIX + ".push_notification.event_kind";
26+
String PUSH_NOTIFICATION_DELIVERY_FAILED = "delivery_failed";
2327
}

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

Lines changed: 72 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.trace.Span;
1717
import io.opentelemetry.api.trace.Tracer;
@@ -26,7 +26,10 @@
2626
import jakarta.enterprise.inject.Produces;
2727
import jakarta.inject.Inject;
2828
import jakarta.inject.Singleton;
29+
import java.util.HashMap;
2930
import java.util.List;
31+
import java.util.Map;
32+
import java.util.concurrent.CopyOnWriteArrayList;
3033
import java.util.stream.Collectors;
3134

3235
/**
@@ -40,6 +43,11 @@ public class A2ATestRoutes {
4043
private static final String TEXT_PLAIN = "text/plain";
4144
private static final Gson gson = new GsonBuilder().create();
4245

46+
/**
47+
* Captured webhook requests, each entry is a map of header name to value.
48+
*/
49+
private final List<Map<String, String>> webhookRequests = new CopyOnWriteArrayList<>();
50+
4351
@Inject
4452
TestUtilsBean testUtilsBean;
4553
@Inject
@@ -76,6 +84,26 @@ void setupRoutes(@Observes Router router) {
7684
.produces(TEXT_PLAIN)
7785
.handler(ctx -> getChildQueueCount(ctx.pathParam("taskId"), ctx));
7886

87+
router.post("/test/pushconfig/:taskId")
88+
.consumes(APPLICATION_JSON)
89+
.handler(BodyHandler.create())
90+
.blockingHandler(ctx -> savePushNotificationConfig(ctx.pathParam("taskId"), ctx.body().asString(), ctx));
91+
92+
router.delete("/test/pushconfig/:taskId/:configId")
93+
.blockingHandler(ctx -> deletePushNotificationConfig(
94+
ctx.pathParam("taskId"), ctx.pathParam("configId"), ctx));
95+
96+
router.post("/test/webhook")
97+
.handler(BodyHandler.create())
98+
.handler(ctx -> captureWebhook(ctx));
99+
100+
router.get("/test/webhook/requests")
101+
.produces(APPLICATION_JSON)
102+
.handler(ctx -> getWebhookRequests(ctx));
103+
104+
router.delete("/test/webhook/requests")
105+
.handler(ctx -> clearWebhookRequests(ctx));
106+
79107
router.get("/hello")
80108
.produces(TEXT_PLAIN)
81109
.handler(ctx -> hello(ctx));
@@ -180,6 +208,48 @@ public void getChildQueueCount(String taskId, RoutingContext rc) {
180208
.end(String.valueOf(count));
181209
}
182210

211+
public void savePushNotificationConfig(String taskId, String body, RoutingContext rc) {
212+
try {
213+
TaskPushNotificationConfig config = JsonUtil.fromJson(body, TaskPushNotificationConfig.class);
214+
testUtilsBean.savePushNotificationConfig(taskId, config);
215+
rc.response()
216+
.setStatusCode(200)
217+
.end();
218+
} catch (Throwable t) {
219+
errorResponse(t, rc);
220+
}
221+
}
222+
223+
public void deletePushNotificationConfig(String taskId, String configId, RoutingContext rc) {
224+
try {
225+
testUtilsBean.deletePushNotificationConfig(taskId, configId);
226+
rc.response()
227+
.setStatusCode(200)
228+
.end();
229+
} catch (Throwable t) {
230+
errorResponse(t, rc);
231+
}
232+
}
233+
234+
private void captureWebhook(RoutingContext rc) {
235+
Map<String, String> headers = new HashMap<>();
236+
rc.request().headers().forEach(entry -> headers.put(entry.getKey(), entry.getValue()));
237+
webhookRequests.add(headers);
238+
rc.response().setStatusCode(HTTP_OK).end();
239+
}
240+
241+
private void getWebhookRequests(RoutingContext rc) {
242+
rc.response()
243+
.setStatusCode(HTTP_OK)
244+
.putHeader(CONTENT_TYPE, APPLICATION_JSON)
245+
.end(gson.toJson(webhookRequests));
246+
}
247+
248+
private void clearWebhookRequests(RoutingContext rc) {
249+
webhookRequests.clear();
250+
rc.response().setStatusCode(HTTP_OK).end();
251+
}
252+
183253
public void hello(RoutingContext rc) {
184254
Span span = tracer.spanBuilder("hello").startSpan();
185255
try (Scope scope = span.makeCurrent()) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public AgentCard agentCard() {
3131
.documentationUrl("http://example.com/test")
3232
.capabilities(AgentCapabilities.builder()
3333
.streaming(true)
34-
.pushNotifications(false)
34+
.pushNotifications(true)
3535
.build())
3636
.defaultInputModes(Collections.singletonList("text"))
3737
.defaultOutputModes(Collections.singletonList("text"))

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
package org.a2aproject.sdk.extras.opentelemetry.it;
22

33
import org.a2aproject.sdk.server.events.QueueManager;
4+
import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore;
45
import org.a2aproject.sdk.server.tasks.TaskStore;
56
import org.a2aproject.sdk.spec.Event;
67
import org.a2aproject.sdk.spec.Task;
8+
import org.a2aproject.sdk.spec.TaskPushNotificationConfig;
79
import jakarta.enterprise.context.ApplicationScoped;
810
import jakarta.inject.Inject;
911

1012
/**
1113
* Test utilities for OpenTelemetry integration tests.
12-
* Allows direct manipulation of tasks and queues for testing.
14+
* Allows direct manipulation of tasks, queues, and push notification configs for testing.
1315
*/
1416
@ApplicationScoped
1517
public class TestUtilsBean {
@@ -20,6 +22,9 @@ public class TestUtilsBean {
2022
@Inject
2123
QueueManager queueManager;
2224

25+
@Inject
26+
PushNotificationConfigStore pushNotificationConfigStore;
27+
2328
public void saveTask(Task task) {
2429
taskStore.save(task, false);
2530
}
@@ -43,4 +48,14 @@ public void enqueueEvent(String taskId, Event event) {
4348
public int getChildQueueCount(String taskId) {
4449
return queueManager.getActiveChildQueueCount(taskId);
4550
}
51+
52+
public void savePushNotificationConfig(String taskId, TaskPushNotificationConfig config) {
53+
pushNotificationConfigStore.setInfo(
54+
TaskPushNotificationConfig.builder(config).taskId(taskId).build(),
55+
null);
56+
}
57+
58+
public void deletePushNotificationConfig(String taskId, String configId) {
59+
pushNotificationConfigStore.deleteInfo(taskId, configId);
60+
}
4661
}

‎extras/opentelemetry/integration-tests/src/main/resources/application.properties‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ quarkus.log.category."io.opentelemetry".level=DEBUG
2929

3030
# Authorization is not configured in this test application
3131
a2a.authorization.required=false
32+
33+
# Allow HTTP and localhost for push notification testing
34+
a2a.push-notification.url.allowed-schemes=http,https
35+
a2a.push-notification.url.allow-private-network-targets=true

0 commit comments

Comments
 (0)