diff --git a/docs/content/dev/extra/opentelemetry.md b/docs/content/dev/extra/opentelemetry.md index a29e1b681..29291fff1 100644 --- a/docs/content/dev/extra/opentelemetry.md +++ b/docs/content/dev/extra/opentelemetry.md @@ -106,6 +106,21 @@ The following attributes are automatically added to spans (defined in `A2AObserv | `gen_ai.agent.a2a.response` | Full response data (only if extraction enabled) | | `error.type` | Error message (on failures) | +### Push Notification Delivery Tracing + +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 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. + +| Attribute | Description | +|-----------|-------------| +| `gen_ai.agent.a2a.operation.name` | `SendPushNotification` | +| `gen_ai.agent.a2a.task_id` | Task identifier | +| `gen_ai.agent.a2a.context_id` | Context identifier (when available) | +| `gen_ai.agent.a2a.push_notification.event_kind` | The type of streaming event delivered (e.g. `TaskStatusUpdateEvent`) | +| `gen_ai.agent.a2a.response` | Full event payload (only if extraction enabled) | +| `error.type` | Error message (on delivery failure) | + ### Request/Response Extraction 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 ### Instrumentation -Adds OpenTelemetry spans to A2A client operations: +Adds OpenTelemetry spans and metrics to A2A client operations: ```xml @@ -133,6 +148,27 @@ Adds OpenTelemetry spans to A2A client operations: ``` +To also enable `gen_ai.client.operation.duration` metrics, pass a `Meter` via the transport config: + +```java +config.setParameters(Map.of( + OpenTelemetryClientTransportWrapper.OTEL_TRACER_KEY, openTelemetry.getTracer("my-service"), + OpenTelemetryClientTransportWrapper.OTEL_METER_KEY, openTelemetry.getMeter("my-service") +)); +``` + +| Instrument | Type | Unit | Description | +|------------|------|------|-------------| +| `gen_ai.client.operation.duration` | Histogram | `s` | Duration of each A2A client operation call | + +Attributes on `gen_ai.client.operation.duration`: + +| Attribute | Value | +|-----------|-------| +| `gen_ai.agent.a2a.operation.name` | A2A method name (e.g. `message/send`) | +| `gen_ai.system` | `a2a` | +| `error.type` | `error` (only on failure) | + ### Context Propagation For trace context propagation across async client boundaries: diff --git a/extras/opentelemetry/common/src/main/java/org/a2aproject/sdk/extras/opentelemetry/A2AObservabilityNames.java b/extras/opentelemetry/common/src/main/java/org/a2aproject/sdk/extras/opentelemetry/A2AObservabilityNames.java index 4bd74e99b..482fdef80 100644 --- a/extras/opentelemetry/common/src/main/java/org/a2aproject/sdk/extras/opentelemetry/A2AObservabilityNames.java +++ b/extras/opentelemetry/common/src/main/java/org/a2aproject/sdk/extras/opentelemetry/A2AObservabilityNames.java @@ -20,4 +20,7 @@ public interface A2AObservabilityNames { String GENAI_RESPONSE = GENAI_PREFIX + ".response"; // gen_ai.output.messages ? String GENAI_ROLE = GENAI_PREFIX + ".role"; String GENAI_TASK_ID = GENAI_PREFIX + ".task_id"; + + // Push notification + String PUSH_NOTIFICATION_EVENT_KIND = GENAI_PREFIX + ".push_notification.event_kind"; } diff --git a/extras/opentelemetry/integration-tests/src/main/java/org/a2aproject/sdk/extras/opentelemetry/it/A2ATestRoutes.java b/extras/opentelemetry/integration-tests/src/main/java/org/a2aproject/sdk/extras/opentelemetry/it/A2ATestRoutes.java index 4c37b5c5d..cb280ee01 100644 --- a/extras/opentelemetry/integration-tests/src/main/java/org/a2aproject/sdk/extras/opentelemetry/it/A2ATestRoutes.java +++ b/extras/opentelemetry/integration-tests/src/main/java/org/a2aproject/sdk/extras/opentelemetry/it/A2ATestRoutes.java @@ -1,7 +1,5 @@ package org.a2aproject.sdk.extras.opentelemetry.it; - - import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE; import com.google.gson.Gson; @@ -12,6 +10,7 @@ import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; import org.a2aproject.sdk.spec.Task; import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.Tracer; @@ -27,6 +26,9 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; /** @@ -40,6 +42,11 @@ public class A2ATestRoutes { private static final String TEXT_PLAIN = "text/plain"; private static final Gson gson = new GsonBuilder().create(); + /** + * Captured webhook requests, each entry is a map of header name to value. + */ + private final List> webhookRequests = new CopyOnWriteArrayList<>(); + @Inject TestUtilsBean testUtilsBean; @Inject @@ -76,6 +83,26 @@ void setupRoutes(@Observes Router router) { .produces(TEXT_PLAIN) .handler(ctx -> getChildQueueCount(ctx.pathParam("taskId"), ctx)); + router.post("/test/pushconfig/:taskId") + .consumes(APPLICATION_JSON) + .handler(BodyHandler.create()) + .blockingHandler(ctx -> savePushNotificationConfig(ctx.pathParam("taskId"), ctx.body().asString(), ctx)); + + router.delete("/test/pushconfig/:taskId/:configId") + .blockingHandler(ctx -> deletePushNotificationConfig( + ctx.pathParam("taskId"), ctx.pathParam("configId"), ctx)); + + router.post("/test/webhook") + .handler(BodyHandler.create()) + .handler(ctx -> captureWebhook(ctx)); + + router.get("/test/webhook/requests") + .produces(APPLICATION_JSON) + .handler(ctx -> getWebhookRequests(ctx)); + + router.delete("/test/webhook/requests") + .handler(ctx -> clearWebhookRequests(ctx)); + router.get("/hello") .produces(TEXT_PLAIN) .handler(ctx -> hello(ctx)); @@ -180,6 +207,48 @@ public void getChildQueueCount(String taskId, RoutingContext rc) { .end(String.valueOf(count)); } + public void savePushNotificationConfig(String taskId, String body, RoutingContext rc) { + try { + TaskPushNotificationConfig config = JsonUtil.fromJson(body, TaskPushNotificationConfig.class); + testUtilsBean.savePushNotificationConfig(taskId, config); + rc.response() + .setStatusCode(200) + .end(); + } catch (Throwable t) { + errorResponse(t, rc); + } + } + + public void deletePushNotificationConfig(String taskId, String configId, RoutingContext rc) { + try { + testUtilsBean.deletePushNotificationConfig(taskId, configId); + rc.response() + .setStatusCode(200) + .end(); + } catch (Throwable t) { + errorResponse(t, rc); + } + } + + private void captureWebhook(RoutingContext rc) { + Map headers = new ConcurrentHashMap<>(); + rc.request().headers().forEach(entry -> headers.put(entry.getKey(), entry.getValue())); + webhookRequests.add(headers); + rc.response().setStatusCode(200).end(); + } + + private void getWebhookRequests(RoutingContext rc) { + rc.response() + .setStatusCode(200) + .putHeader(CONTENT_TYPE, APPLICATION_JSON) + .end(gson.toJson(webhookRequests)); + } + + private void clearWebhookRequests(RoutingContext rc) { + webhookRequests.clear(); + rc.response().setStatusCode(200).end(); + } + public void hello(RoutingContext rc) { Span span = tracer.spanBuilder("hello").startSpan(); try (Scope scope = span.makeCurrent()) { diff --git a/extras/opentelemetry/integration-tests/src/main/java/org/a2aproject/sdk/extras/opentelemetry/it/TestAgentCardProducer.java b/extras/opentelemetry/integration-tests/src/main/java/org/a2aproject/sdk/extras/opentelemetry/it/TestAgentCardProducer.java index a34d076f0..9ea7a3b9c 100644 --- a/extras/opentelemetry/integration-tests/src/main/java/org/a2aproject/sdk/extras/opentelemetry/it/TestAgentCardProducer.java +++ b/extras/opentelemetry/integration-tests/src/main/java/org/a2aproject/sdk/extras/opentelemetry/it/TestAgentCardProducer.java @@ -31,7 +31,7 @@ public AgentCard agentCard() { .documentationUrl("http://example.com/test") .capabilities(AgentCapabilities.builder() .streaming(true) - .pushNotifications(false) + .pushNotifications(true) .build()) .defaultInputModes(Collections.singletonList("text")) .defaultOutputModes(Collections.singletonList("text")) diff --git a/extras/opentelemetry/integration-tests/src/main/java/org/a2aproject/sdk/extras/opentelemetry/it/TestUtilsBean.java b/extras/opentelemetry/integration-tests/src/main/java/org/a2aproject/sdk/extras/opentelemetry/it/TestUtilsBean.java index 52a3ee7bb..4db0b2611 100644 --- a/extras/opentelemetry/integration-tests/src/main/java/org/a2aproject/sdk/extras/opentelemetry/it/TestUtilsBean.java +++ b/extras/opentelemetry/integration-tests/src/main/java/org/a2aproject/sdk/extras/opentelemetry/it/TestUtilsBean.java @@ -1,15 +1,17 @@ package org.a2aproject.sdk.extras.opentelemetry.it; import org.a2aproject.sdk.server.events.QueueManager; +import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore; import org.a2aproject.sdk.server.tasks.TaskStore; import org.a2aproject.sdk.spec.Event; import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; /** * Test utilities for OpenTelemetry integration tests. - * Allows direct manipulation of tasks and queues for testing. + * Allows direct manipulation of tasks, queues, and push notification configs for testing. */ @ApplicationScoped public class TestUtilsBean { @@ -20,6 +22,9 @@ public class TestUtilsBean { @Inject QueueManager queueManager; + @Inject + PushNotificationConfigStore pushNotificationConfigStore; + public void saveTask(Task task) { taskStore.save(task, false); } @@ -43,4 +48,14 @@ public void enqueueEvent(String taskId, Event event) { public int getChildQueueCount(String taskId) { return queueManager.getActiveChildQueueCount(taskId); } + + public void savePushNotificationConfig(String taskId, TaskPushNotificationConfig config) { + pushNotificationConfigStore.setInfo( + TaskPushNotificationConfig.builder(config).taskId(taskId).build(), + null); + } + + public void deletePushNotificationConfig(String taskId, String configId) { + pushNotificationConfigStore.deleteInfo(taskId, configId); + } } diff --git a/extras/opentelemetry/integration-tests/src/main/resources/application.properties b/extras/opentelemetry/integration-tests/src/main/resources/application.properties index 052be4a60..c83af1e35 100644 --- a/extras/opentelemetry/integration-tests/src/main/resources/application.properties +++ b/extras/opentelemetry/integration-tests/src/main/resources/application.properties @@ -29,3 +29,7 @@ quarkus.log.category."io.opentelemetry".level=DEBUG # Authorization is not configured in this test application a2a.authorization.required=false + +# Allow HTTP and localhost for push notification testing +a2a.push-notification.url.allowed-schemes=http,https +a2a.push-notification.url.allow-private-network-targets=true diff --git a/extras/opentelemetry/integration-tests/src/test/java/org/a2aproject/sdk/extras/opentelemetry/it/OpenTelemetryA2ABaseTest.java b/extras/opentelemetry/integration-tests/src/test/java/org/a2aproject/sdk/extras/opentelemetry/it/OpenTelemetryA2ABaseTest.java index cbe09435b..16c74e876 100644 --- a/extras/opentelemetry/integration-tests/src/test/java/org/a2aproject/sdk/extras/opentelemetry/it/OpenTelemetryA2ABaseTest.java +++ b/extras/opentelemetry/integration-tests/src/test/java/org/a2aproject/sdk/extras/opentelemetry/it/OpenTelemetryA2ABaseTest.java @@ -6,17 +6,27 @@ import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.*; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; import org.a2aproject.sdk.A2A; import org.a2aproject.sdk.client.Client; import org.a2aproject.sdk.client.config.ClientConfig; import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; -import org.a2aproject.sdk.spec.*; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.ListTasksParams; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatus; import io.opentelemetry.api.trace.SpanKind; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.lang.reflect.Type; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -49,6 +59,7 @@ void setUp() throws A2AClientException { @BeforeEach void reset() { + given().delete("/test/webhook/requests").then().statusCode(HTTP_OK); await().atMost(5, SECONDS).until(() -> { List> spans = getSpans(); if (spans.isEmpty()) { @@ -235,6 +246,78 @@ void testSpanAttributes() throws Exception { } } + @Test + void testPushNotificationCarriesTraceContext() throws Exception { + String taskId = "push-propagation-task"; + String contextId = "push-propagation-ctx"; + + Task task = Task.builder() + .id(taskId) + .contextId(contextId) + .status(new TaskStatus(TaskState.TASK_STATE_WORKING)) + .history(Collections.emptyList()) + .artifacts(Collections.emptyList()) + .build(); + + saveTaskInTaskStore(task); + reset(); + savePushNotificationConfig(taskId, + TaskPushNotificationConfig.builder() + .id("otel-push-config") + .url("http://localhost:" + serverPort + "/test/webhook") + .build()); + ensureQueueForTask(taskId); + + try { + // Cancel the task to trigger push notification delivery + client.cancelTask(new CancelTaskParams(taskId), null); + + // Wait for the webhook to receive the push notification (queried via HTTP + // so this works in both @QuarkusTest and @QuarkusIntegrationTest modes) + await().atMost(10, SECONDS).until(() -> !fetchWebhookRequests().isEmpty()); + + // Verify traceparent header is present + Map webhookHeaders = fetchWebhookRequests().get(0); + String traceparent = webhookHeaders.entrySet().stream() + .filter(e -> "traceparent".equalsIgnoreCase(e.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + + assertNotNull(traceparent, + "Push notification should carry traceparent header. Received headers: " + webhookHeaders.keySet()); + + // Verify traceparent format: 00-<32-hex-trace-id>-<16-hex-span-id>-<2-hex-flags> + assertTrue(traceparent.matches("00-[a-f0-9]{32}-[a-f0-9]{16}-[a-f0-9]{2}"), + "traceparent should be in W3C format, got: " + traceparent); + + // Wait for the SendPushNotification CLIENT span to be exported + await().atMost(10, SECONDS).until(() -> getSpans().stream() + .anyMatch(span -> "SendPushNotification".equals(span.get("name")) + && SpanKind.CLIENT.name().equals(span.get("kind")))); + + List> spans = getSpans(); + Map pushSpan = spans.stream() + .filter(span -> "SendPushNotification".equals(span.get("name"))) + .filter(span -> SpanKind.CLIENT.name().equals(span.get("kind"))) + .findFirst() + .orElseThrow(() -> new AssertionError( + "No CLIENT span found for SendPushNotification. Spans: " + + spans.stream().map(s -> s.get("name")).toList())); + + assertNotNull(pushSpan.get("spanId")); + assertNotNull(pushSpan.get("traceId")); + + // The traceparent's trace-id should match the SendPushNotification span's trace ID + String traceIdFromHeader = traceparent.split("-")[1]; + assertEquals(traceIdFromHeader, pushSpan.get("traceId"), + "traceparent trace ID should match the SendPushNotification span's trace ID"); + } finally { + deletePushNotificationConfig(taskId, "otel-push-config"); + deleteTaskInTaskStore(taskId); + } + } + protected void saveTaskInTaskStore(Task task) throws Exception { HttpClient httpClient = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -284,6 +367,51 @@ protected void deleteTaskInTaskStore(String taskId) throws Exception { } } + protected void savePushNotificationConfig(String taskId, TaskPushNotificationConfig config) throws Exception { + HttpClient httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/test/pushconfig/" + taskId)) + .POST(HttpRequest.BodyPublishers.ofString(JsonUtil.toJson(config))) + .header("Content-Type", "application/json") + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() != 200) { + throw new RuntimeException(String.format("Saving push config failed! Status: %d, Body: %s", + response.statusCode(), response.body())); + } + } + + protected void deletePushNotificationConfig(String taskId, String configId) throws Exception { + HttpClient httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/test/pushconfig/" + taskId + "/" + configId)) + .DELETE() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() != 200) { + throw new RuntimeException(String.format("Deleting push config failed! Status: %d, Body: %s", + response.statusCode(), response.body())); + } + } + + private static final Type WEBHOOK_LIST_TYPE = new TypeToken>>() {}.getType(); + + /** + * Fetches captured webhook requests via HTTP, so it works across JVM + * boundaries in {@code @QuarkusIntegrationTest} mode. + */ + protected List> fetchWebhookRequests() { + String json = given().get("/test/webhook/requests") + .then().statusCode(HTTP_OK) + .extract().body().asString(); + return new Gson().fromJson(json, WEBHOOK_LIST_TYPE); + } + protected void ensureQueueForTask(String taskId) throws Exception { HttpClient httpClient = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) diff --git a/extras/opentelemetry/integration-tests/src/test/resources/application.properties b/extras/opentelemetry/integration-tests/src/test/resources/application.properties index 932acdd58..f9ec66409 100644 --- a/extras/opentelemetry/integration-tests/src/test/resources/application.properties +++ b/extras/opentelemetry/integration-tests/src/test/resources/application.properties @@ -26,3 +26,7 @@ quarkus.log.category."org.a2aproject.sdk".level=DEBUG # Authorization is not configured in test profiles a2a.authorization.required=false + +# Allow HTTP and localhost for push notification testing +a2a.push-notification.url.allowed-schemes=http,https +a2a.push-notification.url.allow-private-network-targets=true diff --git a/extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryA2AHttpClient.java b/extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryA2AHttpClient.java new file mode 100644 index 000000000..2245724d3 --- /dev/null +++ b/extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryA2AHttpClient.java @@ -0,0 +1,179 @@ +package org.a2aproject.sdk.extras.opentelemetry.server; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapSetter; +import org.a2aproject.sdk.client.http.A2AHttpClient; +import org.a2aproject.sdk.client.http.A2AHttpResponse; +import org.a2aproject.sdk.client.http.ServerSentEvent; +import org.a2aproject.sdk.util.Assert; + +/** + * Delegating {@link A2AHttpClient} that injects W3C trace-context headers + * ({@code traceparent}, {@code tracestate}) into every outbound HTTP request. + * + *

The propagation uses the {@link io.opentelemetry.context.propagation.TextMapPropagator} + * registered with the supplied {@link OpenTelemetry} instance, reading the current + * {@link Context} at call time. This is the same mechanism the client-side + * {@code OpenTelemetryClientPropagatorTransport} uses, applied here to + * server-initiated outbound calls such as push-notification delivery. + * + * @see OpenTelemetryHttpClientProducer + */ +class OpenTelemetryA2AHttpClient implements A2AHttpClient { + + private final A2AHttpClient delegate; + private final OpenTelemetry openTelemetry; + + OpenTelemetryA2AHttpClient(A2AHttpClient delegate, OpenTelemetry openTelemetry) { + this.delegate = Assert.checkNotNullParam("delegate", delegate); + this.openTelemetry = Assert.checkNotNullParam("openTelemetry", openTelemetry); + } + + @Override + public GetBuilder createGet() { + return new PropagatingGetBuilder(delegate.createGet(), openTelemetry); + } + + @Override + public PostBuilder createPost() { + return new PropagatingPostBuilder(delegate.createPost(), openTelemetry); + } + + @Override + public DeleteBuilder createDelete() { + return new PropagatingDeleteBuilder(delegate.createDelete(), openTelemetry); + } + + @SuppressWarnings("rawtypes") + private static final TextMapSetter BUILDER_SETTER = (carrier, key, value) -> { + if (carrier != null) { + carrier.addHeader(key, value); + } + }; + + /** + * Abstract base for propagating builder wrappers. Consolidates the common + * {@link Builder} delegation ({@code url}, {@code addHeaders}, {@code addHeader}) + * and the context injection logic into one place. + * + * @param the concrete propagating builder type (for method chaining) + * @param the delegate builder type + */ + private abstract static class AbstractPropagatingBuilder, D extends Builder> { + + final D delegate; + private final OpenTelemetry openTelemetry; + + AbstractPropagatingBuilder(D delegate, OpenTelemetry openTelemetry) { + this.delegate = delegate; + this.openTelemetry = openTelemetry; + } + + @SuppressWarnings("unchecked") + protected S self() { + return (S) this; + } + + public S url(String s) { + delegate.url(s); + return self(); + } + + public S addHeaders(Map headers) { + delegate.addHeaders(headers); + return self(); + } + + public S addHeader(String name, String value) { + delegate.addHeader(name, value); + return self(); + } + + @SuppressWarnings("rawtypes") + protected void injectContext() { + openTelemetry.getPropagators().getTextMapPropagator() + .inject(Context.current(), (Builder) delegate, BUILDER_SETTER); + } + } + + private static final class PropagatingGetBuilder + extends AbstractPropagatingBuilder + implements GetBuilder { + + PropagatingGetBuilder(GetBuilder delegate, OpenTelemetry openTelemetry) { + super(delegate, openTelemetry); + } + + @Override + public A2AHttpResponse get() throws IOException, InterruptedException { + injectContext(); + return delegate.get(); + } + + @Override + public CompletableFuture getAsyncSSE( + Consumer messageConsumer, + Consumer errorConsumer, + Runnable completeRunnable) throws IOException, InterruptedException { + injectContext(); + return delegate.getAsyncSSE(messageConsumer, errorConsumer, completeRunnable); + } + } + + private static final class PropagatingPostBuilder + extends AbstractPropagatingBuilder + implements PostBuilder { + + PropagatingPostBuilder(PostBuilder delegate, OpenTelemetry openTelemetry) { + super(delegate, openTelemetry); + } + + @Override + public PostBuilder body(String body) { + delegate.body(body); + return this; + } + + @Override + public PostBuilder followRedirects(boolean follow) { + delegate.followRedirects(follow); + return this; + } + + @Override + public A2AHttpResponse post() throws IOException, InterruptedException { + injectContext(); + return delegate.post(); + } + + @Override + public CompletableFuture postAsyncSSE( + Consumer messageConsumer, + Consumer errorConsumer, + Runnable completeRunnable) throws IOException, InterruptedException { + injectContext(); + return delegate.postAsyncSSE(messageConsumer, errorConsumer, completeRunnable); + } + } + + private static final class PropagatingDeleteBuilder + extends AbstractPropagatingBuilder + implements DeleteBuilder { + + PropagatingDeleteBuilder(DeleteBuilder delegate, OpenTelemetry openTelemetry) { + super(delegate, openTelemetry); + } + + @Override + public A2AHttpResponse delete() throws IOException, InterruptedException { + injectContext(); + return delegate.delete(); + } + } +} diff --git a/extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryHttpClientProducer.java b/extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryHttpClientProducer.java new file mode 100644 index 000000000..344f6e918 --- /dev/null +++ b/extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryHttpClientProducer.java @@ -0,0 +1,73 @@ +package org.a2aproject.sdk.extras.opentelemetry.server; + +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.context.Context; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import org.a2aproject.sdk.client.http.A2AHttpClient; +import org.a2aproject.sdk.client.http.A2AHttpClientFactory; +import org.a2aproject.sdk.server.tasks.BasePushNotificationSender; + +/** + * CDI producer that creates an {@link OpenTelemetryA2AHttpClient} wrapping the + * factory-resolved {@link A2AHttpClient} with W3C trace-context propagation. + * + *

When this module is on the classpath and CDI discovers this bean, + * {@link org.a2aproject.sdk.server.tasks.BasePushNotificationSender} will use the + * produced client instead of calling {@link A2AHttpClientFactory#create()} directly, + * ensuring that outbound push-notification HTTP requests carry {@code traceparent} + * and {@code tracestate} headers. + * + *

CDI ambiguity: The {@link A2AHttpClient} bean produced by + * {@link #createPropagatingClient()} has no qualifier. If the application also + * produces an unqualified {@code A2AHttpClient} bean (e.g. a custom Vert.x or + * Android client), CDI will report an {@code AmbiguousResolutionException} at + * startup. In that case, use {@code @Alternative @Priority} on one of the + * producers to resolve the conflict, or inject the custom client with a qualifier. + */ +@ApplicationScoped +public class OpenTelemetryHttpClientProducer { + + private final OpenTelemetry openTelemetry; + + @SuppressWarnings("NullAway") + protected OpenTelemetryHttpClientProducer() { + this.openTelemetry = null; + } + + @Inject + public OpenTelemetryHttpClientProducer(OpenTelemetry openTelemetry) { + this.openTelemetry = openTelemetry; + } + + /** + * Produces an {@link A2AHttpClient} that injects trace-context headers into + * every outbound request. + * + * @return a propagating HTTP client wrapping the default factory client + */ + @Produces + @ApplicationScoped + A2AHttpClient createPropagatingClient() { + return new OpenTelemetryA2AHttpClient(A2AHttpClientFactory.create(), openTelemetry); + } + + /** + * Produces an {@link Executor} that propagates the current OpenTelemetry + * {@link Context} to submitted tasks, ensuring trace context survives + * {@code CompletableFuture.supplyAsync()} boundaries in push notification dispatch. + * + * @return a context-propagating executor wrapping the common ForkJoinPool + */ + @Produces + @ApplicationScoped + @Named(BasePushNotificationSender.DISPATCH_EXECUTOR_NAME) + Executor createContextPropagatingExecutor() { + return Context.taskWrapping(ForkJoinPool.commonPool()); + } +} diff --git a/extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryPushNotificationSenderDecorator.java b/extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryPushNotificationSenderDecorator.java new file mode 100644 index 000000000..c8bc84881 --- /dev/null +++ b/extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryPushNotificationSenderDecorator.java @@ -0,0 +1,149 @@ +package org.a2aproject.sdk.extras.opentelemetry.server; + +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.ERROR_TYPE; +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.EXTRACT_RESPONSE_SYS_PROPERTY; +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.GENAI_CONTEXT_ID; +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.GENAI_OPERATION_NAME; +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.GENAI_RESPONSE; +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.GENAI_TASK_ID; +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.PUSH_NOTIFICATION_EVENT_KIND; + +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 org.a2aproject.sdk.server.tasks.PushNotificationSender; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.jspecify.annotations.Nullable; + +/** + * OpenTelemetry CDI Decorator for {@link PushNotificationSender}. + *

+ * Creates a {@code CLIENT} span named {@code SendPushNotification} for every push + * notification delivery attempt, capturing the task ID, context ID, and event kind + * as span attributes. + *

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

{@code
+ * 
+ *     org.a2aproject.sdk.extras.opentelemetry.server.OpenTelemetryPushNotificationSenderDecorator
+ * 
+ * }
+ */ +@Decorator +@Priority(100) +public abstract class OpenTelemetryPushNotificationSenderDecorator implements PushNotificationSender { + + static final String SEND_PUSH_NOTIFICATION = "SendPushNotification"; + + @Inject + @Delegate + @Any + private PushNotificationSender delegate; + + @Inject + private Tracer tracer; + + /** + * Default constructor for CDI. + */ + public OpenTelemetryPushNotificationSenderDecorator() { + } + + /** + * Constructor for unit testing. + * + * @param delegate the delegate push notification sender + * @param tracer the tracer to use + */ + OpenTelemetryPushNotificationSenderDecorator(PushNotificationSender delegate, Tracer tracer) { + this.delegate = delegate; + this.tracer = tracer; + } + + @Override + public void sendNotification(StreamingEventKind event, @Nullable Task taskSnapshot) { + var spanBuilder = tracer.spanBuilder(SEND_PUSH_NOTIFICATION) + .setSpanKind(SpanKind.CLIENT) + .setAttribute(GENAI_OPERATION_NAME, SEND_PUSH_NOTIFICATION) + .setAttribute(PUSH_NOTIFICATION_EVENT_KIND, event.getClass().getSimpleName()); + + String taskId = extractTaskId(event, taskSnapshot); + if (taskId != null) { + spanBuilder.setAttribute(GENAI_TASK_ID, taskId); + } + String contextId = extractContextId(event, taskSnapshot); + if (contextId != null) { + spanBuilder.setAttribute(GENAI_CONTEXT_ID, contextId); + } + if (extractResponse()) { + spanBuilder.setAttribute(GENAI_RESPONSE, event.toString()); + } + + Span span = spanBuilder.startSpan(); + try (Scope scope = span.makeCurrent()) { + delegate.sendNotification(event, taskSnapshot); + span.setStatus(StatusCode.OK); + } catch (Exception ex) { + span.setAttribute(ERROR_TYPE, ex.getMessage() != null ? ex.getMessage() : ex.getClass().getName()); + span.setStatus(StatusCode.ERROR, ex.getMessage()); + throw ex; + } finally { + span.end(); + } + } + + @Nullable + private static String extractTaskId(StreamingEventKind event, @Nullable Task taskSnapshot) { + if (taskSnapshot != null) { + return taskSnapshot.id(); + } + if (event instanceof Task task) { + return task.id(); + } + if (event instanceof Message message) { + return message.taskId(); + } + if (event instanceof TaskStatusUpdateEvent statusUpdate) { + return statusUpdate.taskId(); + } + if (event instanceof TaskArtifactUpdateEvent artifactUpdate) { + return artifactUpdate.taskId(); + } + return null; + } + + @Nullable + private static String extractContextId(StreamingEventKind event, @Nullable Task taskSnapshot) { + if (taskSnapshot != null) { + return taskSnapshot.contextId(); + } + if (event instanceof Task task) { + return task.contextId(); + } + if (event instanceof Message message) { + return message.contextId(); + } + if (event instanceof TaskStatusUpdateEvent statusUpdate) { + return statusUpdate.contextId(); + } + if (event instanceof TaskArtifactUpdateEvent artifactUpdate) { + return artifactUpdate.contextId(); + } + return null; + } + + private boolean extractResponse() { + return Boolean.getBoolean(EXTRACT_RESPONSE_SYS_PROPERTY); + } +} diff --git a/extras/opentelemetry/server/src/main/resources/META-INF/beans.xml b/extras/opentelemetry/server/src/main/resources/META-INF/beans.xml index e4652bc27..f775403c9 100644 --- a/extras/opentelemetry/server/src/main/resources/META-INF/beans.xml +++ b/extras/opentelemetry/server/src/main/resources/META-INF/beans.xml @@ -5,5 +5,6 @@ bean-discovery-mode="annotated"> org.a2aproject.sdk.extras.opentelemetry.server.OpenTelemetryRequestHandlerDecorator + org.a2aproject.sdk.extras.opentelemetry.server.OpenTelemetryPushNotificationSenderDecorator - \ No newline at end of file + diff --git a/extras/opentelemetry/server/src/test/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryA2AHttpClientTest.java b/extras/opentelemetry/server/src/test/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryA2AHttpClientTest.java new file mode 100644 index 000000000..e4cf86cb5 --- /dev/null +++ b/extras/opentelemetry/server/src/test/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryA2AHttpClientTest.java @@ -0,0 +1,189 @@ +package org.a2aproject.sdk.extras.opentelemetry.server; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.context.propagation.TextMapSetter; +import org.a2aproject.sdk.client.http.A2AHttpClient; +import org.a2aproject.sdk.client.http.A2AHttpResponse; +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.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class OpenTelemetryA2AHttpClientTest { + + @Mock + private A2AHttpClient delegate; + + @Mock + private OpenTelemetry openTelemetry; + + @Mock + private ContextPropagators propagators; + + @Mock + private TextMapPropagator textMapPropagator; + + @Mock + private A2AHttpClient.PostBuilder delegatePostBuilder; + + @Mock + private A2AHttpClient.GetBuilder delegateGetBuilder; + + @Mock + private A2AHttpClient.DeleteBuilder delegateDeleteBuilder; + + @Mock + private A2AHttpResponse response; + + private OpenTelemetryA2AHttpClient client; + + @BeforeEach + void setUp() { + when(openTelemetry.getPropagators()).thenReturn(propagators); + when(propagators.getTextMapPropagator()).thenReturn(textMapPropagator); + + when(delegate.createPost()).thenReturn(delegatePostBuilder); + when(delegate.createGet()).thenReturn(delegateGetBuilder); + when(delegate.createDelete()).thenReturn(delegateDeleteBuilder); + + when(delegatePostBuilder.url(any())).thenReturn(delegatePostBuilder); + when(delegatePostBuilder.body(any())).thenReturn(delegatePostBuilder); + when(delegatePostBuilder.addHeader(any(), any())).thenReturn(delegatePostBuilder); + when(delegatePostBuilder.addHeaders(any())).thenReturn(delegatePostBuilder); + when(delegatePostBuilder.followRedirects(any(boolean.class))).thenReturn(delegatePostBuilder); + + when(delegateGetBuilder.url(any())).thenReturn(delegateGetBuilder); + when(delegateGetBuilder.addHeader(any(), any())).thenReturn(delegateGetBuilder); + when(delegateGetBuilder.addHeaders(any())).thenReturn(delegateGetBuilder); + + when(delegateDeleteBuilder.url(any())).thenReturn(delegateDeleteBuilder); + when(delegateDeleteBuilder.addHeader(any(), any())).thenReturn(delegateDeleteBuilder); + when(delegateDeleteBuilder.addHeaders(any())).thenReturn(delegateDeleteBuilder); + + client = new OpenTelemetryA2AHttpClient(delegate, openTelemetry); + } + + @Test + @SuppressWarnings("unchecked") + void post_injectsContextBeforeDelegating() throws IOException, InterruptedException { + when(delegatePostBuilder.post()).thenReturn(response); + + A2AHttpClient.PostBuilder builder = client.createPost(); + A2AHttpResponse result = builder + .url("http://example.com/push") + .addHeader("Content-Type", "application/json") + .body("{}") + .post(); + + assertSame(response, result); + + InOrder order = inOrder(textMapPropagator, delegatePostBuilder); + order.verify(textMapPropagator).inject(any(Context.class), any(), any(TextMapSetter.class)); + order.verify(delegatePostBuilder).post(); + } + + @Test + @SuppressWarnings("unchecked") + void post_propagatesCurrentSpanContext() throws IOException, InterruptedException { + when(delegatePostBuilder.post()).thenReturn(response); + + Context testContext = Context.current().with(io.opentelemetry.context.ContextKey.named("test"), "value"); + try (Scope ignored = testContext.makeCurrent()) { + client.createPost() + .url("http://example.com/push") + .body("{}") + .post(); + } + + verify(textMapPropagator).inject(any(Context.class), any(), any(TextMapSetter.class)); + } + + @Test + @SuppressWarnings("unchecked") + void get_injectsContextBeforeDelegating() throws IOException, InterruptedException { + when(delegateGetBuilder.get()).thenReturn(response); + + A2AHttpClient.GetBuilder builder = client.createGet(); + A2AHttpResponse result = builder + .url("http://example.com/api") + .get(); + + assertSame(response, result); + + InOrder order = inOrder(textMapPropagator, delegateGetBuilder); + order.verify(textMapPropagator).inject(any(Context.class), any(), any(TextMapSetter.class)); + order.verify(delegateGetBuilder).get(); + } + + @Test + @SuppressWarnings("unchecked") + void delete_injectsContextBeforeDelegating() throws IOException, InterruptedException { + when(delegateDeleteBuilder.delete()).thenReturn(response); + + A2AHttpClient.DeleteBuilder builder = client.createDelete(); + A2AHttpResponse result = builder + .url("http://example.com/resource") + .delete(); + + assertSame(response, result); + + InOrder order = inOrder(textMapPropagator, delegateDeleteBuilder); + order.verify(textMapPropagator).inject(any(Context.class), any(), any(TextMapSetter.class)); + order.verify(delegateDeleteBuilder).delete(); + } + + @Test + void postBuilder_chainsCorrectly() { + A2AHttpClient.PostBuilder builder = client.createPost(); + + A2AHttpClient.PostBuilder chained = builder + .url("http://example.com") + .addHeader("X-Custom", "value") + .body("{}") + .followRedirects(false); + + assertNotNull(chained); + } + + @Test + @SuppressWarnings("unchecked") + void textMapSetter_callsAddHeaderOnBuilder() throws IOException, InterruptedException { + when(delegatePostBuilder.post()).thenReturn(response); + + @SuppressWarnings("rawtypes") + ArgumentCaptor setterCaptor = ArgumentCaptor.forClass(TextMapSetter.class); + + client.createPost() + .url("http://example.com") + .body("{}") + .post(); + + verify(textMapPropagator).inject(any(Context.class), any(), setterCaptor.capture()); + + @SuppressWarnings("rawtypes") + TextMapSetter setter = setterCaptor.getValue(); + setter.set(delegatePostBuilder, "traceparent", "00-abc-def-01"); + + verify(delegatePostBuilder).addHeader("traceparent", "00-abc-def-01"); + } +} diff --git a/extras/opentelemetry/server/src/test/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryPushNotificationSenderDecoratorTest.java b/extras/opentelemetry/server/src/test/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryPushNotificationSenderDecoratorTest.java new file mode 100644 index 000000000..3b2d8b71b --- /dev/null +++ b/extras/opentelemetry/server/src/test/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryPushNotificationSenderDecoratorTest.java @@ -0,0 +1,187 @@ +package org.a2aproject.sdk.extras.opentelemetry.server; + +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.ERROR_TYPE; +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.EXTRACT_RESPONSE_SYS_PROPERTY; +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.GENAI_CONTEXT_ID; +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.GENAI_OPERATION_NAME; +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.GENAI_RESPONSE; +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.GENAI_TASK_ID; +import static org.a2aproject.sdk.extras.opentelemetry.A2AObservabilityNames.PUSH_NOTIFICATION_EVENT_KIND; +import static org.a2aproject.sdk.extras.opentelemetry.server.OpenTelemetryPushNotificationSenderDecorator.SEND_PUSH_NOTIFICATION; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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 java.util.Collections; +import java.util.List; +import org.a2aproject.sdk.server.tasks.PushNotificationSender; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatus; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class OpenTelemetryPushNotificationSenderDecoratorTest { + + @Mock + private PushNotificationSender delegate; + + @Mock + private Tracer tracer; + + @Mock + private SpanBuilder spanBuilder; + + @Mock + private Span span; + + @Mock + private Scope scope; + + private OpenTelemetryPushNotificationSenderDecorator decorator; + + @BeforeEach + void setUp() { + 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.startSpan()).thenReturn(span); + lenient().when(span.makeCurrent()).thenReturn(scope); + + decorator = new TestablePushNotificationSenderDecorator(delegate, tracer); + } + + @Test + void sendNotification_createsSpanWithTaskIdFromSnapshot() { + Task taskSnapshot = Task.builder() + .id("task-1") + .contextId("ctx-1") + .status(new TaskStatus(TaskState.TASK_STATE_WORKING)) + .history(Collections.emptyList()) + .artifacts(Collections.emptyList()) + .build(); + TaskStatusUpdateEvent event = TaskStatusUpdateEvent.builder() + .taskId("task-1") + .contextId("ctx-1") + .status(new TaskStatus(TaskState.TASK_STATE_WORKING)) + .build(); + + decorator.sendNotification(event, taskSnapshot); + + verify(tracer).spanBuilder(SEND_PUSH_NOTIFICATION); + verify(spanBuilder).setSpanKind(SpanKind.CLIENT); + verify(spanBuilder).setAttribute(GENAI_OPERATION_NAME, SEND_PUSH_NOTIFICATION); + verify(spanBuilder).setAttribute(GENAI_TASK_ID, "task-1"); + verify(spanBuilder).setAttribute(GENAI_CONTEXT_ID, "ctx-1"); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + verify(delegate).sendNotification(event, taskSnapshot); + } + + @Test + void sendNotification_createsSpanWithTaskIdFromEvent() { + org.a2aproject.sdk.spec.Artifact artifact = org.a2aproject.sdk.spec.Artifact.builder() + .artifactId("artifact-id") + .parts(List.of(new org.a2aproject.sdk.spec.TextPart("result"))) + .build(); + TaskArtifactUpdateEvent event = new TaskArtifactUpdateEvent( + "task-event-1", artifact, "ctx-event-1", null, null, null); + + decorator.sendNotification(event, null); + + verify(spanBuilder).setAttribute(GENAI_TASK_ID, "task-event-1"); + verify(spanBuilder).setAttribute(GENAI_CONTEXT_ID, "ctx-event-1"); + verify(spanBuilder, never()).setAttribute(eq(GENAI_TASK_ID), eq((String) null)); + verify(span).setStatus(StatusCode.OK); + verify(span).end(); + } + + @Test + void sendNotification_setsErrorStatusOnException() { + TaskStatusUpdateEvent event = TaskStatusUpdateEvent.builder() + .taskId("task-err") + .contextId("ctx-err") + .status(new TaskStatus(TaskState.TASK_STATE_FAILED)) + .build(); + RuntimeException ex = new RuntimeException("webhook unreachable"); + doThrow(ex).when(delegate).sendNotification(any(), any()); + + assertThrows(RuntimeException.class, () -> decorator.sendNotification(event, null)); + + verify(span).setStatus(StatusCode.ERROR, "webhook unreachable"); + verify(span).setAttribute(ERROR_TYPE, "webhook unreachable"); + verify(span).end(); + } + + @Test + void sendNotification_setsEventKindAttribute() { + TaskStatusUpdateEvent event = TaskStatusUpdateEvent.builder() + .taskId("task-kind") + .contextId("ctx-kind") + .status(new TaskStatus(TaskState.TASK_STATE_COMPLETED)) + .build(); + + decorator.sendNotification(event, null); + + verify(spanBuilder).setAttribute(PUSH_NOTIFICATION_EVENT_KIND, "TaskStatusUpdateEvent"); + } + + @Test + void sendNotification_extractsResponseWhenEnabled() { + System.setProperty(EXTRACT_RESPONSE_SYS_PROPERTY, "true"); + try { + TaskStatusUpdateEvent event = TaskStatusUpdateEvent.builder() + .taskId("task-resp") + .contextId("ctx-resp") + .status(new TaskStatus(TaskState.TASK_STATE_WORKING)) + .build(); + + decorator.sendNotification(event, null); + + verify(spanBuilder).setAttribute(eq(GENAI_RESPONSE), anyString()); + } finally { + System.clearProperty(EXTRACT_RESPONSE_SYS_PROPERTY); + } + } + + @Test + void sendNotification_noResponseAttributeWhenDisabled() { + System.clearProperty(EXTRACT_RESPONSE_SYS_PROPERTY); + TaskStatusUpdateEvent event = TaskStatusUpdateEvent.builder() + .taskId("task-noresp") + .contextId("ctx-noresp") + .status(new TaskStatus(TaskState.TASK_STATE_WORKING)) + .build(); + + decorator.sendNotification(event, null); + + verify(spanBuilder, never()).setAttribute(eq(GENAI_RESPONSE), anyString()); + } + + static class TestablePushNotificationSenderDecorator extends OpenTelemetryPushNotificationSenderDecorator { + TestablePushNotificationSenderDecorator(PushNotificationSender delegate, Tracer tracer) { + super(delegate, tracer); + } + } +} diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/tasks/BasePushNotificationSender.java b/server-common/src/main/java/org/a2aproject/sdk/server/tasks/BasePushNotificationSender.java index 9dff9da60..e6671c48c 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/tasks/BasePushNotificationSender.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/tasks/BasePushNotificationSender.java @@ -7,6 +7,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; +import jakarta.inject.Named; import java.io.IOException; import java.util.ArrayList; @@ -15,6 +16,8 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; import org.a2aproject.sdk.client.http.A2AHttpClient; import org.a2aproject.sdk.client.http.A2AHttpClientFactory; @@ -38,6 +41,13 @@ public class BasePushNotificationSender implements PushNotificationSender { private static final Logger LOGGER = LoggerFactory.getLogger(BasePushNotificationSender.class); public static final int DEFAULT_PAGE_SIZE = 100; + /** + * Named qualifier for the optional {@link Executor} used to dispatch push notifications + * asynchronously. When an OpenTelemetry context-propagating executor is produced under + * this name, trace context is automatically carried across the async boundary. + */ + public static final String DISPATCH_EXECUTOR_NAME = "a2a.pushNotificationDispatch"; + // Fields set by constructor injection cannot be final. We need a noargs constructor for // Jakarta compatibility, and it seems that making fields set by constructor injection // final, is not proxyable in all runtimes @@ -45,6 +55,7 @@ public class BasePushNotificationSender implements PushNotificationSender { private PushNotificationConfigStore configStore; private Map formattersByVersion; private PushNotificationUrlValidator urlValidator; + private Executor dispatchExecutor = ForkJoinPool.commonPool(); /** @@ -74,11 +85,14 @@ public BasePushNotificationSender(PushNotificationConfigStore configStore, @Inject public BasePushNotificationSender(PushNotificationConfigStore configStore, Instance formatters, - PushNotificationUrlValidator urlValidator) { - this.httpClient = A2AHttpClientFactory.create(); + PushNotificationUrlValidator urlValidator, + Instance httpClient, + @Named(DISPATCH_EXECUTOR_NAME) Instance dispatchExecutor) { + this.httpClient = httpClient.isResolvable() ? httpClient.get() : A2AHttpClientFactory.create(); this.configStore = configStore; this.formattersByVersion = toFormatterMap(formatters); this.urlValidator = urlValidator; + this.dispatchExecutor = dispatchExecutor.isResolvable() ? dispatchExecutor.get() : ForkJoinPool.commonPool(); } public BasePushNotificationSender(PushNotificationConfigStore configStore, A2AHttpClient httpClient, @@ -171,7 +185,8 @@ private CompletableFuture dispatch(StreamingEventKind event, @Nullable Task taskSnapshot, TaskPushNotificationConfig pushInfo, Map versionsByConfigId) { - return CompletableFuture.supplyAsync(() -> dispatchNotification(event, taskSnapshot, pushInfo, versionsByConfigId)); + return CompletableFuture.supplyAsync( + () -> dispatchNotification(event, taskSnapshot, pushInfo, versionsByConfigId), dispatchExecutor); } private boolean dispatchNotification(StreamingEventKind event,