Skip to content

Commit ec67db0

Browse files
committed
feat(opentelemetry): add streaming span lifecycle and operation metrics
- Per-event child spans and terminal closing span for streaming methods, linked to the initial method span - Duration histograms: gen_ai.client.operation.duration (client) and gen_ai.agent.a2a.streaming.duration (server) Signed-off-by: Emmanuel Hugonnet <ehugonne@redhat.com>
1 parent 05f5d9b commit ec67db0

30 files changed

Lines changed: 1844 additions & 145 deletions

File tree

‎client/transport/grpc/src/main/java/org/a2aproject/sdk/client/transport/grpc/EventStreamObserver.java‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@
99
import org.a2aproject.sdk.grpc.StreamResponse;
1010
import org.a2aproject.sdk.spec.StreamingEventKind;
1111
import io.grpc.stub.StreamObserver;
12+
import org.jspecify.annotations.Nullable;
1213

1314
public class EventStreamObserver implements StreamObserver<StreamResponse> {
1415

1516
private static final Logger LOGGER = Logger.getLogger(EventStreamObserver.class.getName());
1617
private final Consumer<StreamingEventKind> eventHandler;
17-
private final Consumer<Throwable> errorHandler;
18+
@Nullable private final Consumer<Throwable> errorHandler;
1819

19-
public EventStreamObserver(Consumer<StreamingEventKind> eventHandler, Consumer<Throwable> errorHandler) {
20+
public EventStreamObserver(Consumer<StreamingEventKind> eventHandler, @Nullable Consumer<Throwable> errorHandler) {
2021
this.eventHandler = eventHandler;
2122
this.errorHandler = errorHandler;
2223
}
@@ -39,7 +40,9 @@ public void onNext(StreamResponse response) {
3940
break;
4041
default:
4142
LOGGER.warning("Invalid stream response " + response.getPayloadCase());
42-
errorHandler.accept(new IllegalStateException("Invalid stream response from server: " + response.getPayloadCase()));
43+
if (errorHandler != null) {
44+
errorHandler.accept(new IllegalStateException("Invalid stream response from server: " + response.getPayloadCase()));
45+
}
4346
return;
4447
}
4548
eventHandler.accept(event);
@@ -59,6 +62,8 @@ public void onError(Throwable t) {
5962

6063
@Override
6164
public void onCompleted() {
62-
// done
65+
if (errorHandler != null) {
66+
errorHandler.accept(null);
67+
}
6368
}
6469
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package org.a2aproject.sdk.client.transport.grpc;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNull;
5+
6+
import java.util.ArrayList;
7+
import java.util.List;
8+
import java.util.function.Consumer;
9+
import org.a2aproject.sdk.spec.StreamingEventKind;
10+
import org.junit.jupiter.api.Test;
11+
12+
class EventStreamObserverTest {
13+
14+
@Test
15+
void onCompleted_callsErrorHandlerWithNull() {
16+
List<StreamingEventKind> events = new ArrayList<>();
17+
List<Throwable> errors = new ArrayList<>();
18+
19+
Consumer<StreamingEventKind> eventHandler = events::add;
20+
Consumer<Throwable> errorHandler = errors::add;
21+
EventStreamObserver observer = new EventStreamObserver(eventHandler, errorHandler);
22+
23+
observer.onCompleted();
24+
25+
assertEquals(1, errors.size(), "errorHandler should have been called once");
26+
assertNull(errors.get(0), "errorHandler should be called with null to signal clean completion");
27+
assertEquals(0, events.size(), "eventHandler should not be called");
28+
}
29+
30+
@Test
31+
void onCompleted_withNullErrorHandler_doesNotThrow() {
32+
Consumer<StreamingEventKind> eventHandler = e -> {};
33+
EventStreamObserver observer = new EventStreamObserver(eventHandler, null);
34+
35+
observer.onCompleted();
36+
}
37+
}

‎client/transport/spi/src/main/java/org/a2aproject/sdk/client/transport/spi/ClientTransport.java‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ EventKind sendMessage(MessageSendParams request, @Nullable ClientCallContext con
4343
*
4444
* @param request the message send parameters
4545
* @param eventConsumer consumer that will receive streaming events as they arrive
46-
* @param errorConsumer consumer that will be called if an error occurs during streaming
46+
* @param errorConsumer consumer called when the stream ends; called with {@code null} to signal
47+
* clean completion, or with a non-null {@link Throwable} if an error occurs
4748
* @param context optional client call context for the request (may be {@code null})
4849
* @throws A2AClientException if setting up the streaming connection fails
4950
*/
@@ -131,7 +132,8 @@ void deleteTaskPushNotificationConfigurations(
131132
*
132133
* @param request the task ID parameters specifying which task to subscribe to
133134
* @param eventConsumer consumer that will receive streaming events as they arrive
134-
* @param errorConsumer consumer that will be called if an error occurs during streaming
135+
* @param errorConsumer consumer called when the stream ends; called with {@code null} to signal
136+
* clean completion, or with a non-null {@link Throwable} if an error occurs
135137
* @param context optional client call context for the request (may be {@code null})
136138
* @throws A2AClientException if subscribing to the task fails for any reason
137139
*/

‎compat-0.3/client/transport/grpc/src/main/java/org/a2aproject/sdk/compat03/client/transport/grpc/EventStreamObserver_v0_3.java‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@
88
import java.util.function.Consumer;
99
import java.util.logging.Logger;
1010

11+
import org.jspecify.annotations.Nullable;
1112
import static org.a2aproject.sdk.compat03.grpc.utils.ProtoUtils_v0_3.FromProto;
1213

1314
public class EventStreamObserver_v0_3 implements StreamObserver<StreamResponse> {
1415

1516
private static final Logger LOGGER = Logger.getLogger(EventStreamObserver_v0_3.class.getName());
1617
private final Consumer<StreamingEventKind_v0_3> eventHandler;
17-
private final Consumer<Throwable> errorHandler;
18+
@Nullable private final Consumer<Throwable> errorHandler;
1819

19-
public EventStreamObserver_v0_3(Consumer<StreamingEventKind_v0_3> eventHandler, Consumer<Throwable> errorHandler) {
20+
public EventStreamObserver_v0_3(Consumer<StreamingEventKind_v0_3> eventHandler, @Nullable Consumer<Throwable> errorHandler) {
2021
this.eventHandler = eventHandler;
2122
this.errorHandler = errorHandler;
2223
}
@@ -39,7 +40,9 @@ public void onNext(StreamResponse response) {
3940
break;
4041
default:
4142
LOGGER.warning("Invalid stream response " + response.getPayloadCase());
42-
errorHandler.accept(new IllegalStateException("Invalid stream response from server: " + response.getPayloadCase()));
43+
if (errorHandler != null) {
44+
errorHandler.accept(new IllegalStateException("Invalid stream response from server: " + response.getPayloadCase()));
45+
}
4346
return;
4447
}
4548
eventHandler.accept(event);
@@ -59,6 +62,8 @@ public void onError(Throwable t) {
5962

6063
@Override
6164
public void onCompleted() {
62-
// done
65+
if (errorHandler != null) {
66+
errorHandler.accept(null);
67+
}
6368
}
6469
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package org.a2aproject.sdk.compat03.client.transport.grpc;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNull;
5+
6+
import java.util.ArrayList;
7+
import java.util.List;
8+
import java.util.function.Consumer;
9+
import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3;
10+
import org.junit.jupiter.api.Test;
11+
12+
class EventStreamObserver_v0_3_Test {
13+
14+
@Test
15+
void onCompleted_callsErrorHandlerWithNull() {
16+
List<StreamingEventKind_v0_3> events = new ArrayList<>();
17+
List<Throwable> errors = new ArrayList<>();
18+
19+
Consumer<StreamingEventKind_v0_3> eventHandler = events::add;
20+
Consumer<Throwable> errorHandler = errors::add;
21+
EventStreamObserver_v0_3 observer = new EventStreamObserver_v0_3(eventHandler, errorHandler);
22+
23+
observer.onCompleted();
24+
25+
assertEquals(1, errors.size(), "errorHandler should have been called once");
26+
assertNull(errors.get(0), "errorHandler should be called with null to signal clean completion");
27+
assertEquals(0, events.size(), "eventHandler should not be called");
28+
}
29+
30+
@Test
31+
void onCompleted_withNullErrorHandler_doesNotThrow() {
32+
Consumer<StreamingEventKind_v0_3> eventHandler = e -> {};
33+
EventStreamObserver_v0_3 observer = new EventStreamObserver_v0_3(eventHandler, null);
34+
35+
observer.onCompleted();
36+
}
37+
}

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Adds distributed tracing and context propagation to A2A servers and clients usin
1414
- **Context Propagation**: OpenTelemetry trace context propagation across async operations
1515
- **Request/Response Logging**: Optional extraction of request and response data into spans
1616
- **Error Tracking**: Automatic error status and error type attributes on failures
17+
- **Metrics**: `gen_ai.client.operation.duration` histogram on the client side; `gen_ai.agent.a2a.streaming.duration` histogram for full streaming lifecycle on the server side
1718

1819
## Modules
1920

@@ -65,6 +66,76 @@ Async agent execution (maintains trace context)
6566
Response (with trace headers)
6667
```
6768

69+
### Streaming Span Lifecycle
70+
71+
Streaming methods (`message/stream` and `tasks/resubscribe`) follow a two-span model to correctly represent the full duration of a streaming response.
72+
73+
**Span 1 — method span** is created when the request arrives and ended as soon as the publisher is returned to the transport layer. It captures request attributes and the initial outcome (success or synchronous error).
74+
75+
**Per-event child spans** (`<method>-event`) are created for each streaming event published via `onNext()`. Each child span carries a [link](https://opentelemetry.io/docs/concepts/signals/traces/#span-links) back to Span 1 and is made the current span during delegate execution, so any nested telemetry is correctly parented.
76+
77+
**Span 2 — end span** (`<method>-end`) is created when the stream terminates — either on completion or error. It also carries a link to Span 1 for correlation.
78+
79+
<pre class="mermaid">
80+
sequenceDiagram
81+
participant C as Client
82+
participant D as OTel Decorator
83+
participant H as RequestHandler
84+
participant W as WrappedPublisher
85+
86+
C->>D: onMessageSendStream(params)
87+
D->>D: create Span₁ (SERVER)
88+
D->>H: onMessageSendStream(params)
89+
H-->>D: publisher
90+
D->>D: capture Span₁.context
91+
D->>D: Span₁.setStatus(OK) · Span₁.end()
92+
D-->>C: WrappedPublisher
93+
94+
note over D: Span₁ closed immediately
95+
96+
loop for each streaming event
97+
W->>W: create child span (link→Span₁)
98+
W-->>C: onNext(event)
99+
W->>W: child span.end()
100+
end
101+
102+
alt stream completes normally
103+
W->>W: create Span₂ (SERVER, link→Span₁)
104+
W->>W: Span₂.setStatus(OK) · Span₂.end()
105+
W-->>C: onComplete()
106+
else stream errors
107+
W->>W: create Span₂ (SERVER, link→Span₁)
108+
W->>W: Span₂.setStatus(ERROR) · Span₂.end()
109+
W-->>C: onError(throwable)
110+
end
111+
</pre>
112+
113+
Each per-event child span carries:
114+
115+
| Attribute | Value |
116+
|-----------|-------|
117+
| `gen_ai.agent.a2a.streaming-event` | Event kind identifier (`"message"`, `"task"`, `"statusUpdate"`, `"artifactUpdate"`) |
118+
| `gen_ai.agent.a2a.task.status.state` | Task status state (for `TaskStatusUpdateEvent` and `Task` events) |
119+
| `gen_ai.agent.a2a.task_id` | Task identifier (when available from the event) |
120+
121+
### Metrics
122+
123+
The server decorator records the following histogram instruments:
124+
125+
| Instrument | Type | Unit | Description |
126+
|------------|------|------|-------------|
127+
| `gen_ai.agent.a2a.streaming.duration` | Histogram | `s` | Duration from stream initiation to last event, for `message/stream` and `tasks/resubscribe` |
128+
129+
Attributes on `gen_ai.agent.a2a.streaming.duration`:
130+
131+
| Attribute | Value |
132+
|-----------|-------|
133+
| `gen_ai.agent.a2a.operation.name` | A2A method name (`message/stream` or `tasks/resubscribe`) |
134+
| `gen_ai.system` | `a2a` |
135+
| `error.type` | `error` (only on stream failure) |
136+
137+
Meter injection uses CDI `Instance<Meter>`, so the histogram is silently skipped if no `Meter` bean is available in the container.
138+
68139
### Context-Aware Async Executor
69140

70141
> **Note:** The `AsyncManagedExecutorProducer` is provided by the **Quarkus reference server** ([`reference/common`](https://github.com/a2aproject/a2a-java/blob/main/reference/common/src/main/java/org/a2aproject/sdk/server/common/quarkus/AsyncManagedExecutorProducer.java)), not the OpenTelemetry module. It is documented here because it enables context propagation (including trace context) across async boundaries.
@@ -133,6 +204,30 @@ Adds OpenTelemetry spans to A2A client operations:
133204
</dependency>
134205
```
135206

207+
### Metrics
208+
209+
The client transport records the following histogram:
210+
211+
| Instrument | Type | Unit | Description |
212+
|------------|------|------|-------------|
213+
| `gen_ai.client.operation.duration` | Histogram | `s` | Duration of A2A client operations from invocation to completion or error |
214+
215+
Attributes on `gen_ai.client.operation.duration`:
216+
217+
| Attribute | Value |
218+
|-----------|-------|
219+
| `gen_ai.agent.a2a.operation.name` | A2A method name (e.g., `message/send`, `tasks/get`) |
220+
| `gen_ai.system` | `a2a` |
221+
| `error.type` | `error` (only on failure) |
222+
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+
136231
### Context Propagation
137232

138233
For trace context propagation across async client boundaries:

‎examples/helloworld/client/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,11 @@ The Java client (`HelloWorldClient.java`) performs the following actions:
161161

162162
## Enable OpenTelemetry (Optional)
163163

164-
The client includes support for distributed tracing with OpenTelemetry. To enable it:
164+
The client includes support for distributed tracing and metrics with OpenTelemetry. To enable it:
165165

166166
### Prerequisites
167167

168-
**IMPORTANT**: The client expects an OpenTelemetry collector to be ready and accepting traces. You have two options:
168+
**IMPORTANT**: The client expects an OpenTelemetry collector to be ready and accepting traces and metrics. You have two options:
169169

170170
#### Option 1: Use the Java Server Example (Recommended)
171171

‎examples/helloworld/client/src/main/java/org/a2aproject/sdk/examples/helloworld/client/HelloWorldClient.java‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.a2aproject.sdk.examples.helloworld.client;
22

3+
import static org.a2aproject.sdk.extras.opentelemetry.client.OpenTelemetryClientTransportWrapper.OTEL_METER_KEY;
34
import static org.a2aproject.sdk.extras.opentelemetry.client.OpenTelemetryClientTransportWrapper.OTEL_TRACER_KEY;
45
import static org.a2aproject.sdk.extras.opentelemetry.client.propagation.OpenTelemetryClientPropagatorTransportWrapper.OTEL_OPEN_TELEMETRY_KEY;
56

@@ -34,8 +35,11 @@
3435
import io.grpc.ManagedChannelBuilder;
3536
import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator;
3637
import io.opentelemetry.context.propagation.ContextPropagators;
38+
import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter;
3739
import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
3840
import io.opentelemetry.sdk.OpenTelemetrySdk;
41+
import io.opentelemetry.sdk.metrics.SdkMeterProvider;
42+
import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader;
3943
import io.opentelemetry.sdk.resources.Resource;
4044
import io.opentelemetry.sdk.trace.SdkTracerProvider;
4145
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;
@@ -136,20 +140,32 @@ public static void main(String[] args) {
136140
}
137141

138142
static OpenTelemetrySdk initOpenTelemetry() {
143+
Resource resource = Resource.getDefault().toBuilder()
144+
.put("service.version", "1.0")
145+
.put("service.name", "helloworld-client")
146+
.build();
147+
139148
SdkTracerProvider sdkTracerProvider = SdkTracerProvider.builder()
140149
.addSpanProcessor(BatchSpanProcessor.builder(
141150
OtlpGrpcSpanExporter.builder()
142151
.setEndpoint("http://localhost:5317")
143152
.build()
144153
).build())
145-
.setResource(Resource.getDefault().toBuilder()
146-
.put("service.version", "1.0")
147-
.put("service.name", "helloworld-client")
148-
.build())
154+
.setResource(resource)
155+
.build();
156+
157+
SdkMeterProvider sdkMeterProvider = SdkMeterProvider.builder()
158+
.registerMetricReader(PeriodicMetricReader.builder(
159+
OtlpGrpcMetricExporter.builder()
160+
.setEndpoint("http://localhost:5317")
161+
.build()
162+
).build())
163+
.setResource(resource)
149164
.build();
150165

151166
return OpenTelemetrySdk.builder()
152167
.setTracerProvider(sdkTracerProvider)
168+
.setMeterProvider(sdkMeterProvider)
153169
.setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance()))
154170
.build();
155171
}
@@ -186,6 +202,7 @@ private static void updateTransportConfig(ClientTransportConfig transportConfig,
186202
if (openTelemetrySdk != null) {
187203
Map<String, Object> parameters = new HashMap<>(transportConfig.getParameters());
188204
parameters.put(OTEL_TRACER_KEY, openTelemetrySdk.getTracer("helloworld-client"));
205+
parameters.put(OTEL_METER_KEY, openTelemetrySdk.getMeter("helloworld-client"));
189206
parameters.put(OTEL_OPEN_TELEMETRY_KEY, openTelemetrySdk);
190207
transportConfig.setParameters(parameters);
191208
}

‎examples/helloworld/server/src/main/resources/application.properties‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ quarkus.agentcard.protocol=JSONRPC
55

66
# OpenTelemetry configuration
77
quarkus.otel.sdk.disabled=false
8+
quarkus.otel.metrics.enabled=true
89
quarkus.observability.lgtm.grafana-port=3001
910
quarkus.observability.lgtm.otel-grpc-port=5317
1011
quarkus.observability.lgtm.otel-http-port=5318

‎extras/opentelemetry/client/pom.xml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
<groupId>org.slf4j</groupId>
2929
<artifactId>slf4j-api</artifactId>
3030
</dependency>
31+
<dependency>
32+
<groupId>${project.groupId}</groupId>
33+
<artifactId>a2a-java-sdk-opentelemetry-common</artifactId>
34+
<classifier>tests</classifier>
35+
<scope>test</scope>
36+
</dependency>
3137
<dependency>
3238
<groupId>org.junit.jupiter</groupId>
3339
<artifactId>junit-jupiter</artifactId>

0 commit comments

Comments
 (0)