Skip to content

Commit 6675310

Browse files
authored
feat(gax): Implement trace context extraction and injection with integration test (#12625)
This PR implements native trace context propagation for Google Cloud Java clients, enabling App Centric Observability by linking client-side telemetry with server-side resources. It introduces a new injectTraceContext method to the ApiTracer interface and implements it in SpanTracer using the OpenTelemetry W3CTraceContextPropagator. The extracted trace context is then dynamically injected into the outgoing request metadata natively within GrpcClientCalls and HttpJsonClientCalls for unary requests. CompositeTracer has also been updated to delegate this new method to its child tracers. By pushing this logic down into the ApiTracer and core call factories, we ensure that trace headers (e.g., traceparent) are automatically propagated whenever tracing is enabled, without requiring users to configure separate transport-specific interceptors or rely on external OpenTelemetry instrumentation agents. Finally, this PR includes an end-to-end integration test (ITOtelTracePropagation) in the java-showcase module, which verifies the successful round-trip of W3C trace headers against the gapic-showcase server.
1 parent c08d553 commit 6675310

File tree

11 files changed

+409
-7
lines changed

11 files changed

+409
-7
lines changed

sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcClientCalls.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,19 @@ public static <RequestT, ResponseT> ClientCall<RequestT, ResponseT> newCall(
9090
channel = ((ChannelPool) channel).getChannel(grpcContext.getChannelAffinity());
9191
}
9292

93-
if (!grpcContext.getExtraHeaders().isEmpty()) {
94-
ClientInterceptor interceptor =
95-
MetadataUtils.newAttachHeadersInterceptor(grpcContext.getMetadata());
93+
java.util.Map<String, String> traceContext = new java.util.HashMap<>();
94+
grpcContext.getTracer().injectTraceContext(traceContext);
95+
96+
if (!grpcContext.getExtraHeaders().isEmpty() || !traceContext.isEmpty()) {
97+
Metadata metadata = new Metadata();
98+
metadata.merge(grpcContext.getMetadata());
99+
for (java.util.Map.Entry<String, String> entry : traceContext.entrySet()) {
100+
Metadata.Key<String> key =
101+
Metadata.Key.of(entry.getKey(), Metadata.ASCII_STRING_MARSHALLER);
102+
metadata.removeAll(key);
103+
metadata.put(key, entry.getValue());
104+
}
105+
ClientInterceptor interceptor = MetadataUtils.newAttachHeadersInterceptor(metadata);
96106
channel = ClientInterceptors.intercept(channel, interceptor);
97107
}
98108

sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcClientCallsTest.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,4 +340,48 @@ void testUniverseDomainNotReady_shouldRetry() throws IOException {
340340
Truth.assertThat(exception.isRetryable()).isTrue();
341341
Mockito.verify(mockChannel, Mockito.never()).newCall(descriptor, callOptions);
342342
}
343+
344+
@Test
345+
void testTraceContextHeaders() throws IOException {
346+
Metadata emptyHeaders = new Metadata();
347+
348+
MethodDescriptor<Color, Money> descriptor = FakeServiceGrpc.METHOD_RECOGNIZE;
349+
350+
@SuppressWarnings("unchecked")
351+
ClientCall<Color, Money> mockClientCall = Mockito.mock(ClientCall.class);
352+
353+
@SuppressWarnings("unchecked")
354+
ClientCall.Listener<Money> mockListener = Mockito.mock(ClientCall.Listener.class);
355+
356+
Channel mockChannel = Mockito.mock(ManagedChannel.class);
357+
com.google.api.gax.tracing.ApiTracer mockTracer =
358+
Mockito.mock(com.google.api.gax.tracing.ApiTracer.class);
359+
360+
Mockito.doAnswer(
361+
invocation -> {
362+
java.util.Map<String, String> carrier = invocation.getArgument(0);
363+
carrier.put("traceparent", "00-00000000000000000000000000000001-0000000000000002-01");
364+
return null;
365+
})
366+
.when(mockTracer)
367+
.injectTraceContext(Mockito.anyMap());
368+
369+
Mockito.doAnswer(
370+
invocation -> {
371+
Metadata clientCallHeaders = (Metadata) invocation.getArguments()[1];
372+
Metadata.Key<String> traceparentKey =
373+
Metadata.Key.of("traceparent", Metadata.ASCII_STRING_MARSHALLER);
374+
assertThat(clientCallHeaders.getAll(traceparentKey))
375+
.containsExactly("00-00000000000000000000000000000001-0000000000000002-01");
376+
return null;
377+
})
378+
.when(mockClientCall)
379+
.start(Mockito.<ClientCall.Listener<Money>>any(), Mockito.<Metadata>any());
380+
381+
Mockito.when(mockChannel.newCall(Mockito.eq(descriptor), Mockito.<CallOptions>any()))
382+
.thenReturn(mockClientCall);
383+
384+
GrpcCallContext context = defaultCallContext.withChannel(mockChannel).withTracer(mockTracer);
385+
GrpcClientCalls.newCall(descriptor, context).start(mockListener, emptyHeaders);
386+
}
343387
}

sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCalls.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,25 @@ public static <RequestT, ResponseT> HttpJsonClientCall<RequestT, ResponseT> newC
8080
return httpJsonContext.getChannel().newCall(methodDescriptor, httpJsonContext.getCallOptions());
8181
}
8282

83+
static HttpJsonMetadata getMetadataWithTraceContext(HttpJsonCallContext context) {
84+
java.util.Map<String, String> traceHeaders = new java.util.HashMap<>();
85+
context.getTracer().injectTraceContext(traceHeaders);
86+
87+
java.util.Map<String, java.util.List<String>> finalHeaders =
88+
new java.util.HashMap<>(context.getExtraHeaders());
89+
for (java.util.Map.Entry<String, String> entry : traceHeaders.entrySet()) {
90+
finalHeaders.put(entry.getKey(), java.util.Collections.singletonList(entry.getValue()));
91+
}
92+
return HttpJsonMetadata.newBuilder().build().withHeaders(finalHeaders);
93+
}
94+
8395
static <RequestT, ResponseT> ApiFuture<ResponseT> futureUnaryCall(
8496
HttpJsonClientCall<RequestT, ResponseT> clientCall,
8597
RequestT request,
8698
HttpJsonCallContext context) {
8799
// Start the call
88100
HttpJsonFuture<ResponseT> future = new HttpJsonFuture<>(clientCall);
89-
clientCall.start(
90-
new FutureListener<>(future),
91-
HttpJsonMetadata.newBuilder().build().withHeaders(context.getExtraHeaders()));
101+
clientCall.start(new FutureListener<>(future), getMetadataWithTraceContext(context));
92102

93103
// Send the request
94104
try {

sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonClientCallsTest.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,35 @@ void testUniverseDomainNotReady_shouldRetry() throws IOException {
139139
.isEqualTo(HttpJsonStatusCode.Code.UNAUTHENTICATED);
140140
Mockito.verify(mockChannel, Mockito.never()).newCall(descriptor, callOptions);
141141
}
142+
143+
@Test
144+
void testGetMetadataWithTraceContext() {
145+
com.google.api.gax.tracing.ApiTracer mockTracer =
146+
Mockito.mock(com.google.api.gax.tracing.ApiTracer.class);
147+
Mockito.doAnswer(
148+
invocation -> {
149+
java.util.Map<String, String> carrier = invocation.getArgument(0);
150+
carrier.put("traceparent", "00-00000000000000000000000000000001-0000000000000002-01");
151+
return null;
152+
})
153+
.when(mockTracer)
154+
.injectTraceContext(Mockito.anyMap());
155+
156+
java.util.Map<String, java.util.List<String>> extraHeaders = new java.util.HashMap<>();
157+
extraHeaders.put("existing-header", java.util.Collections.singletonList("existing-value"));
158+
159+
HttpJsonCallContext context =
160+
(HttpJsonCallContext)
161+
HttpJsonCallContext.createDefault()
162+
.withTracer(mockTracer)
163+
.withExtraHeaders(extraHeaders);
164+
165+
HttpJsonMetadata metadata = HttpJsonClientCalls.getMetadataWithTraceContext(context);
166+
167+
assertThat(metadata.getHeaders()).containsKey("existing-header");
168+
assertThat(metadata.getHeaders().get("existing-header").toString()).contains("existing-value");
169+
assertThat(metadata.getHeaders()).containsKey("traceparent");
170+
assertThat(metadata.getHeaders().get("traceparent").toString())
171+
.contains("00-00000000000000000000000000000001-0000000000000002-01");
172+
}
142173
}

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracer.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,9 @@ default void requestSent() {}
196196
default void batchRequestSent(long elementCount, long requestSize) {}
197197
;
198198

199+
/** Extract the trace context from the tracer and add it to the given headers map. */
200+
default void injectTraceContext(java.util.Map<String, String> carrier) {}
201+
199202
/**
200203
* Annotates the attempt with the full resolved HTTP URL. Only relevant for HTTP transport.
201204
*

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/CompositeTracer.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,11 @@ public void batchRequestSent(long elementCount, long requestSize) {
213213
child.batchRequestSent(elementCount, requestSize);
214214
}
215215
}
216+
217+
@Override
218+
public void injectTraceContext(java.util.Map<String, String> carrier) {
219+
for (ApiTracer child : children) {
220+
child.injectTraceContext(carrier);
221+
}
222+
}
216223
}

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/SpanTracer.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,23 @@ public class SpanTracer implements ApiTracer {
5454
private final ApiTracerContext apiTracerContext;
5555
private Span attemptSpan;
5656

57+
@Override
58+
public void injectTraceContext(java.util.Map<String, String> carrier) {
59+
if (attemptSpan != null) {
60+
io.opentelemetry.context.Context context =
61+
io.opentelemetry.context.Context.current().with(attemptSpan);
62+
io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator.getInstance()
63+
.inject(
64+
context,
65+
carrier,
66+
(c, k, v) -> {
67+
if (c != null) {
68+
c.put(k, v);
69+
}
70+
});
71+
}
72+
}
73+
5774
/**
5875
* Creates a new instance of {@code SpanTracer}.
5976
*

sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/CompositeTracerTest.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,4 +270,13 @@ void testBatchRequestSent() {
270270
verify(child1).batchRequestSent(10L, 100L);
271271
verify(child2).batchRequestSent(10L, 100L);
272272
}
273+
274+
@Test
275+
void testInjectTraceContext() {
276+
java.util.Map<String, String> carrier = new java.util.HashMap<>();
277+
compositeTracer.injectTraceContext(carrier);
278+
InOrder inOrder = inOrder(child1, child2);
279+
inOrder.verify(child1).injectTraceContext(carrier);
280+
inOrder.verify(child2).injectTraceContext(carrier);
281+
}
273282
}

sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/SpanTracerTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,4 +602,27 @@ public RedirectException(String message) {
602602
}
603603

604604
private static class UnknownClientException extends RuntimeException {}
605+
606+
@Test
607+
void testInjectTraceContext_addsHeaders() {
608+
io.opentelemetry.api.trace.SpanContext mockSpanContext =
609+
io.opentelemetry.api.trace.SpanContext.create(
610+
"00000000000000000000000000000001",
611+
"0000000000000002",
612+
io.opentelemetry.api.trace.TraceFlags.getSampled(),
613+
io.opentelemetry.api.trace.TraceState.getDefault());
614+
io.opentelemetry.api.trace.Span realSpan =
615+
io.opentelemetry.api.trace.Span.wrap(mockSpanContext);
616+
when(spanBuilder.startSpan()).thenReturn(realSpan);
617+
618+
spanTracer = new SpanTracer(tracer, ApiTracerContext.empty(), ATTEMPT_SPAN_NAME);
619+
spanTracer.attemptStarted(new Object(), 1);
620+
621+
java.util.Map<String, String> carrier = new java.util.HashMap<>();
622+
spanTracer.injectTraceContext(carrier);
623+
624+
assertThat(carrier).containsKey("traceparent");
625+
assertThat(carrier.get("traceparent")).contains("00000000000000000000000000000001");
626+
assertThat(carrier.get("traceparent")).contains("0000000000000002");
627+
}
605628
}

sdk-platform-java/java-showcase/gapic-showcase/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
</parent>
1818

1919
<properties>
20-
<gapic-showcase.version>0.36.2</gapic-showcase.version>
20+
<gapic-showcase.version>0.39.0</gapic-showcase.version>
2121
<!-- This is the last version supporting slf4j 1.x, do not upgrade -->
2222
<slf4j1-logback.version>1.2.13</slf4j1-logback.version>
2323
<slf4j2-logback.version>1.5.25</slf4j2-logback.version>

0 commit comments

Comments
 (0)