diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java index 2b2d675a2af3..c946e9aab03d 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java @@ -607,10 +607,11 @@ public ApiTracer getTracer() { @Override public HttpJsonCallContext withTracer(@Nonnull ApiTracer newTracer) { Preconditions.checkNotNull(newTracer); + HttpJsonCallOptions newCallOptions = callOptions.toBuilder().setTracer(newTracer).build(); return new HttpJsonCallContext( this.channel, - this.callOptions, + newCallOptions, this.timeout, this.streamWaitTimeout, this.streamIdleTimeout, diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallOptions.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallOptions.java index 4c2d8ae55e19..1f0022bb4390 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallOptions.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallOptions.java @@ -35,6 +35,7 @@ import static com.google.api.gax.util.TimeConversionUtils.toThreetenInstant; import com.google.api.core.ObsoleteApi; +import com.google.api.gax.tracing.ApiTracer; import com.google.auth.Credentials; import com.google.auto.value.AutoValue; import com.google.protobuf.TypeRegistry; @@ -71,6 +72,9 @@ public final org.threeten.bp.Instant getDeadline() { @Nullable public abstract TypeRegistry getTypeRegistry(); + @Nullable + public abstract ApiTracer getTracer(); + public abstract Builder toBuilder(); public static Builder newBuilder() { @@ -106,6 +110,11 @@ public HttpJsonCallOptions merge(HttpJsonCallOptions inputOptions) { builder.setTypeRegistry(newTypeRegistry); } + ApiTracer newTracer = inputOptions.getTracer(); + if (newTracer != null) { + builder.setTracer(newTracer); + } + return builder.build(); } @@ -131,6 +140,8 @@ public final Builder setDeadline(org.threeten.bp.Instant value) { public abstract Builder setTypeRegistry(TypeRegistry value); + public abstract Builder setTracer(ApiTracer value); + public abstract HttpJsonCallOptions build(); } } diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java index df9a507519b6..53a4dd66d3ad 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java @@ -156,6 +156,11 @@ public void setResult(RunnableResult runnableResult) { if (runnableResult.getResponseHeaders() != null) { pendingNotifications.offer( new OnHeadersNotificationTask<>(listener, runnableResult.getResponseHeaders())); + if (callOptions.getTracer() != null) { + callOptions + .getTracer() + .responseHeadersReceived(runnableResult.getResponseHeaders().getHeaders()); + } } } @@ -428,6 +433,7 @@ private boolean consumeMessageFromStream() throws IOException { ResponseT message = methodDescriptor.getResponseParser().parse(responseReader, callOptions.getTypeRegistry()); + pendingNotifications.offer(new OnMessageNotificationTask<>(listener, message)); return allMessagesConsumed; diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallContextTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallContextTest.java index 156b4bb039c1..29bea372e17e 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallContextTest.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallContextTest.java @@ -252,6 +252,16 @@ void testMergeWithTracer() { .isSameInstanceAs(defaultTracer); } + @Test + void testWithTracer() { + ApiTracer tracer = Mockito.mock(ApiTracer.class); + HttpJsonCallContext emptyContext = HttpJsonCallContext.createDefault(); + // Default context has a default tracer. + assertNotNull(emptyContext.getTracer()); + HttpJsonCallContext context = emptyContext.withTracer(tracer); + Truth.assertThat(context.getTracer()).isSameInstanceAs(tracer); + } + @Test void testWithRetrySettings() { RetrySettings retrySettings = Mockito.mock(RetrySettings.class); diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallOptionsTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallOptionsTest.java index c6aa69d4d845..4a8a2bc6f397 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallOptionsTest.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallOptionsTest.java @@ -31,12 +31,22 @@ import static com.google.api.gax.util.TimeConversionTestUtils.testDurationMethod; import static com.google.api.gax.util.TimeConversionTestUtils.testInstantMethod; +import static com.google.common.truth.Truth.assertThat; +import com.google.api.gax.tracing.ApiTracer; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; public class HttpJsonCallOptionsTest { private final HttpJsonCallOptions.Builder OPTIONS_BUILDER = HttpJsonCallOptions.newBuilder(); + @Test + void testTracer() { + ApiTracer tracer = Mockito.mock(ApiTracer.class); + HttpJsonCallOptions options = OPTIONS_BUILDER.setTracer(tracer).build(); + assertThat(options.getTracer()).isSameInstanceAs(tracer); + } + @Test public void testDeadline() { final long millis = 3; diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonClientCallImplTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonClientCallImplTest.java index 2853e79ad59f..3d24bd535664 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonClientCallImplTest.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonClientCallImplTest.java @@ -29,15 +29,35 @@ */ package com.google.api.gax.httpjson; +import static com.google.common.truth.Truth.assertThat; + import com.google.api.client.http.HttpTransport; +import com.google.api.gax.httpjson.testing.MockHttpService; +import com.google.api.gax.httpjson.testing.TestApiTracer; +import com.google.api.gax.rpc.EndpointContext; +import com.google.api.gax.rpc.ResponseObserver; +import com.google.api.gax.rpc.StreamController; +import com.google.auth.Credentials; import com.google.common.truth.Truth; +import com.google.protobuf.Field; import com.google.protobuf.TypeRegistry; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.io.Reader; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -135,4 +155,170 @@ void responseReceived_cancellationTaskExists_isCancelledProperly() throws Interr // Scheduler is not waiting for any task and should terminate quickly Truth.assertThat(deadlineSchedulerExecutor.isTerminated()).isTrue(); } + + private static final ApiMethodDescriptor FAKE_METHOD_DESCRIPTOR = + ApiMethodDescriptor.newBuilder() + .setFullMethodName("google.cloud.v1.Fake/FakeMethod") + .setHttpMethod("POST") + .setRequestFormatter( + ProtoMessageRequestFormatter.newBuilder() + .setPath( + "/fake/v1/name/{name}", + request -> { + Map fields = new HashMap<>(); + ProtoRestSerializer serializer = ProtoRestSerializer.create(); + serializer.putPathParam(fields, "name", request.getName()); + return fields; + }) + .setQueryParamsExtractor(request -> new HashMap<>()) + .setRequestBodyExtractor( + request -> + ProtoRestSerializer.create() + .toBody("*", request.toBuilder().clearName().build(), false)) + .build()) + .setResponseParser( + ProtoMessageResponseParser.newBuilder() + .setDefaultInstance(Field.getDefaultInstance()) + .build()) + .build(); + + private static final MockHttpService MOCK_SERVICE = + new MockHttpService(Collections.singletonList(FAKE_METHOD_DESCRIPTOR), "google.com:443"); + + private static ExecutorService executorService; + private ManagedHttpJsonChannel channel; + private TestApiTracer tracer; + + @BeforeAll + static void initialize() { + executorService = Executors.newFixedThreadPool(2); + } + + @AfterAll + static void destroy() { + executorService.shutdownNow(); + } + + @BeforeEach + void setUp() { + channel = + ManagedHttpJsonChannel.newBuilder() + .setEndpoint("google.com:443") + .setExecutor(executorService) + .setHttpTransport(MOCK_SERVICE) + .build(); + tracer = new TestApiTracer(); + } + + @AfterEach + void tearDown() { + MOCK_SERVICE.reset(); + } + + @Test + void testBodySizeRecording() throws Exception { + HttpJsonDirectCallable callable = + new HttpJsonDirectCallable<>(FAKE_METHOD_DESCRIPTOR); + + EndpointContext endpointContext = Mockito.mock(EndpointContext.class); + Mockito.lenient() + .doNothing() + .when(endpointContext) + .validateUniverseDomain( + Mockito.any(Credentials.class), Mockito.any(HttpJsonStatusCode.class)); + + HttpJsonCallContext callContext = + HttpJsonCallContext.createDefault() + .withChannel(channel) + .withEndpointContext(endpointContext) + .withTracer(tracer); + + Field request = Field.newBuilder().setName("bob").setNumber(42).build(); + Field response = Field.newBuilder().setName("alice").setNumber(43).build(); + + MOCK_SERVICE.addResponse(response); + + callable.futureCall(request, callContext).get(); + + // Verify response size + // MockHttpService uses ProtoRestSerializer which pretty-prints. + String expectedResponseBody = ProtoRestSerializer.create().toBody("*", response, false); + long expectedResponseSize = expectedResponseBody.getBytes("UTF-8").length; + assertThat(tracer.getResponseReceivedSize()).isEqualTo(expectedResponseSize); + } + + @Test + void testBodySizeRecordingServerStreaming() throws Exception { + ApiMethodDescriptor methodServerStreaming = + FAKE_METHOD_DESCRIPTOR.toBuilder() + .setType(ApiMethodDescriptor.MethodType.SERVER_STREAMING) + .build(); + + MockHttpService streamingMockService = + new MockHttpService(Collections.singletonList(methodServerStreaming), "google.com:443"); + ManagedHttpJsonChannel streamingChannel = + ManagedHttpJsonChannel.newBuilder() + .setEndpoint("google.com:443") + .setExecutor(executorService) + .setHttpTransport(streamingMockService) + .build(); + + HttpJsonDirectServerStreamingCallable callable = + new HttpJsonDirectServerStreamingCallable<>(methodServerStreaming); + + EndpointContext endpointContext = Mockito.mock(EndpointContext.class); + Mockito.lenient() + .doNothing() + .when(endpointContext) + .validateUniverseDomain( + Mockito.any(Credentials.class), Mockito.any(HttpJsonStatusCode.class)); + + HttpJsonCallContext callContext = + HttpJsonCallContext.createDefault() + .withChannel(streamingChannel) + .withEndpointContext(endpointContext) + .withTracer(tracer); + + Field request = Field.newBuilder().setName("bob").setNumber(42).build(); + Field response1 = Field.newBuilder().setName("alice1").setNumber(43).build(); + Field response2 = Field.newBuilder().setName("alice2").setNumber(44).build(); + + streamingMockService.addResponse(new Field[] {response1, response2}); + + final List receivedResponses = new java.util.ArrayList<>(); + final CountDownLatch latch = new CountDownLatch(1); + + callable.call( + request, + new ResponseObserver() { + @Override + public void onStart(StreamController controller) { + // no behavior needed + } + + @Override + public void onResponse(Field response) { + receivedResponses.add(response); + } + + @Override + public void onError(Throwable t) { + latch.countDown(); + } + + @Override + public void onComplete() { + latch.countDown(); + } + }, + callContext); + + latch.await(10, TimeUnit.SECONDS); + + assertThat(receivedResponses).hasSize(2); + + // Verify response size (0 because streaming chunked responses don't include Content-Length) + assertThat(tracer.getResponseReceivedSize()).isEqualTo(0); + streamingChannel.shutdownNow(); + } } diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/testing/MockHttpService.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/testing/MockHttpService.java index 931041201d09..31427c8db167 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/testing/MockHttpService.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/testing/MockHttpService.java @@ -43,6 +43,7 @@ import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.LinkedListMultimap; import com.google.common.collect.Multimap; +import java.nio.charset.StandardCharsets; import java.util.LinkedList; import java.util.List; import java.util.Queue; @@ -261,7 +262,11 @@ public MockLowLevelHttpResponse getHttpResponse(String httpMethod, String fullTa httpContent = methodDescriptor.getResponseParser().serialize(response); } - httpResponse.setContent(httpContent.getBytes()); + byte[] contentBytes = httpContent.getBytes(StandardCharsets.UTF_8); + httpResponse.setContent(contentBytes); + if (methodDescriptor.getType() != MethodType.SERVER_STREAMING) { + httpResponse.addHeader("Content-Length", String.valueOf(contentBytes.length)); + } httpResponse.setStatusCode(200); return httpResponse; } diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/testing/TestApiTracer.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/testing/TestApiTracer.java index 604e9ad47bd1..308dc4d60298 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/testing/TestApiTracer.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/testing/TestApiTracer.java @@ -32,6 +32,7 @@ import com.google.api.gax.tracing.ApiTracer; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import org.threeten.bp.Duration; /** @@ -43,6 +44,8 @@ public class TestApiTracer implements ApiTracer { private final AtomicInteger attemptsStarted = new AtomicInteger(); private final AtomicInteger attemptsFailed = new AtomicInteger(); private final AtomicBoolean retriesExhausted = new AtomicBoolean(false); + private final AtomicLong responseReceivedSize = new AtomicLong(); + private final AtomicInteger responsesReceived = new AtomicInteger(); public TestApiTracer() {} @@ -58,6 +61,14 @@ public AtomicBoolean getRetriesExhausted() { return retriesExhausted; } + public long getResponseReceivedSize() { + return responseReceivedSize.get(); + } + + public int getResponsesReceived() { + return responsesReceived.get(); + } + @Override public void attemptStarted(int attemptNumber) { attemptsStarted.incrementAndGet(); @@ -78,5 +89,38 @@ public void attemptFailedRetriesExhausted(Throwable error) { attemptsFailed.incrementAndGet(); retriesExhausted.set(true); } + + @Override + public void responseReceived() { + responsesReceived.incrementAndGet(); + } + + @Override + public void responseHeadersReceived(java.util.Map headers) { + long contentLength = extractContentLength(headers); + if (contentLength >= 0) { + responseReceivedSize.addAndGet(contentLength); + } + } + + private long extractContentLength(java.util.Map headers) { + if (headers == null || headers.isEmpty()) return -1; + Object value = + headers.entrySet().stream() + .filter(e -> "Content-Length".equalsIgnoreCase(e.getKey())) + .map(java.util.Map.Entry::getValue) + .findFirst() + .orElse(null); + + if (value instanceof java.util.Collection) { + value = ((java.util.Collection) value).stream().findFirst().orElse(null); + } + + try { + return Long.parseLong(String.valueOf(value)); + } catch (NumberFormatException | NullPointerException e) { + return -1; + } + } } ; diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracer.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracer.java index 97f8e017db28..7dc3fb2c9938 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracer.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracer.java @@ -179,6 +179,10 @@ default void lroStartSucceeded() {} default void responseReceived() {} ; + /** Adds an annotation that a streaming response has been received with its headers. */ + default void responseHeadersReceived(java.util.Map headers) {} + ; + /** Adds an annotation that a streaming request has been sent. */ default void requestSent() {} ; diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java index 64095fde0099..c04c76362df1 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java @@ -85,6 +85,9 @@ public class ObservabilityAttributes { /** The url template of the request (e.g. /v1/{name}:access). */ public static final String URL_TEMPLATE_ATTRIBUTE = "url.template"; + /** Size of the response body in bytes. */ + public static final String HTTP_RESPONSE_BODY_SIZE = "http.response.body.size"; + /** The resend count of the request. Only used in HTTP transport. */ public static final String HTTP_RESEND_COUNT_ATTRIBUTE = "http.request.resend_count"; diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/SpanTracer.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/SpanTracer.java index a2690359dd4c..4cae02dcb886 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/SpanTracer.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/SpanTracer.java @@ -47,6 +47,8 @@ public class SpanTracer implements ApiTracer { public static final String DEFAULT_LANGUAGE = "Java"; + static final String CONTENT_LENGTH_KEY = "Content-Length"; + private final Tracer tracer; private final Map attemptAttributes; private final String attemptSpanName; @@ -134,6 +136,48 @@ public void attemptSucceeded() { endAttempt(); } + @Override + public void responseHeadersReceived(java.util.Map headers) { + if (attemptSpan != null) { + long contentLength = extractContentLength(headers); + if (contentLength >= 0) { + attemptSpan.setAttribute(ObservabilityAttributes.HTTP_RESPONSE_BODY_SIZE, contentLength); + } + } + } + + /** + * Extracts the Content-Length header value from the response headers, if available. + * + *

Note: google-http-java-client's HttpHeaders.java returns some headers (like Content-Length) + * as a List instead of a single value. + * https://github.com/googleapis/google-http-java-client/blob/main/google-http-client/src/main/java/com/google/api/client/http/HttpHeaders.java#L162 + * + * @param headers the map of response headers. + * @return the content length in bytes, or -1 if the header is missing or malformed. + */ + private long extractContentLength(java.util.Map headers) { + if (headers == null || headers.isEmpty()) return -1; + // google-http-client HttpHeaders uses a case-insensitive map but we copy it for safety + // and to handle potential different implementations. + Object value = + headers.entrySet().stream() + .filter(e -> CONTENT_LENGTH_KEY.equalsIgnoreCase(e.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + + if (value instanceof java.util.Collection) { + value = ((java.util.Collection) value).stream().findFirst().orElse(null); + } + + try { + return Long.parseLong(value.toString()); + } catch (NumberFormatException | NullPointerException e) { + return -1; + } + } + @Override public void attemptCancelled() { endAttempt(); diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/SpanTracerTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/SpanTracerTest.java index 3e9fc53ce536..841022a9941e 100644 --- a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/SpanTracerTest.java +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/SpanTracerTest.java @@ -88,6 +88,66 @@ void testAttemptStarted_includesLanguageAttribute() { } @Test + void testResponseHeadersReceived_setsContentLengthAttribute() { + spanTracer.attemptStarted(new Object(), 1); + + java.util.Map headers = new java.util.HashMap<>(); + headers.put("Content-Length", 12345L); + spanTracer.responseHeadersReceived(headers); + + verify(span).setAttribute(ObservabilityAttributes.HTTP_RESPONSE_BODY_SIZE, 12345L); + } + + @Test + void testResponseHeadersReceived_variousContentLengthStringFormats() { + spanTracer.attemptStarted(new Object(), 1); + + java.util.Map headers = new java.util.HashMap<>(); + headers.put("content-length", "6789"); + spanTracer.responseHeadersReceived(headers); + + verify(span).setAttribute(ObservabilityAttributes.HTTP_RESPONSE_BODY_SIZE, 6789L); + } + + @Test + void testResponseHeadersReceived_missingContentLength() { + spanTracer.attemptStarted(new Object(), 1); + + java.util.Map headers = new java.util.HashMap<>(); + headers.put("Other-Header", "123"); + spanTracer.responseHeadersReceived(headers); + + verify(span, org.mockito.Mockito.never()) + .setAttribute( + org.mockito.ArgumentMatchers.eq(ObservabilityAttributes.HTTP_RESPONSE_BODY_SIZE), + org.mockito.ArgumentMatchers.anyLong()); + } + + @Test + void testResponseHeadersReceived_badFormat() { + spanTracer.attemptStarted(new Object(), 1); + + java.util.Map headers = new java.util.HashMap<>(); + headers.put("Other-Header", "12X3"); + spanTracer.responseHeadersReceived(headers); + + verify(span, org.mockito.Mockito.never()) + .setAttribute( + org.mockito.ArgumentMatchers.eq(ObservabilityAttributes.HTTP_RESPONSE_BODY_SIZE), + org.mockito.ArgumentMatchers.eq(-1)); + } + + @Test + void testResponseHeadersReceived_listContentLength() { + spanTracer.attemptStarted(new Object(), 1); + + java.util.Map headers = new java.util.HashMap<>(); + headers.put("Content-Length", java.util.Arrays.asList(98765L)); + spanTracer.responseHeadersReceived(headers); + + verify(span).setAttribute(ObservabilityAttributes.HTTP_RESPONSE_BODY_SIZE, 98765L); + } + void testAttemptStarted_noRetryAttributes_grpc() { ApiTracerContext grpcContext = ApiTracerContext.newBuilder() diff --git a/sdk-platform-java/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelTracing.java b/sdk-platform-java/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelTracing.java index ddaff08896b1..56cbb9fca874 100644 --- a/sdk-platform-java/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelTracing.java +++ b/sdk-platform-java/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelTracing.java @@ -33,7 +33,9 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; +import java.util.UUID; import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.showcase.v1beta1.GetUserRequest; import com.google.api.gax.core.NoCredentialsProvider; import com.google.api.gax.retrying.RetrySettings; import com.google.api.gax.rpc.StatusCode; @@ -41,12 +43,14 @@ import com.google.api.gax.tracing.ObservabilityAttributes; import com.google.api.gax.tracing.SpanTracer; import com.google.api.gax.tracing.SpanTracerFactory; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; import com.google.rpc.Status; import com.google.showcase.v1beta1.EchoClient; import com.google.showcase.v1beta1.EchoRequest; import com.google.showcase.v1beta1.EchoSettings; -import com.google.showcase.v1beta1.GetUserRequest; import com.google.showcase.v1beta1.IdentityClient; +import com.google.showcase.v1beta1.User; import com.google.showcase.v1beta1.it.util.TestClientInitializer; import com.google.showcase.v1beta1.stub.EchoStub; import com.google.showcase.v1beta1.stub.EchoStubSettings; @@ -58,6 +62,7 @@ import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.data.SpanData; import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.nio.charset.StandardCharsets; import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -103,7 +108,8 @@ void testTracing_successfulIdentityGetUser_grpc() throws Exception { try { client.getUser(GetUserRequest.newBuilder().setName("users/test-user").build()); } catch (Exception e) { - // Ignored, the showcase server may not have this user, but trace is still generated. + // Ignored, the showcase server may not have this user, but trace is still + // generated. } List spans = spanExporter.getFinishedSpanItems(); @@ -177,7 +183,8 @@ void testTracing_successfulIdentityGetUser_httpjson() throws Exception { try { client.getUser(GetUserRequest.newBuilder().setName("users/test-user").build()); } catch (Exception e) { - // Ignored, the showcase server may not have this user, but trace is still generated. + // Ignored, the showcase server may not have this user, but trace is still + // generated. } List spans = spanExporter.getFinishedSpanItems(); @@ -232,16 +239,36 @@ void testTracing_successfulIdentityGetUser_httpjson() throws Exception { AttributeKey.stringKey( ObservabilityAttributes.DESTINATION_RESOURCE_ID_ATTRIBUTE))) .isEqualTo("users/test-user"); + + User fetchedUser = User.newBuilder().setName("users/test-user").build(); + long expectedMagnitude = computeExpectedHttpJsonResponseSize(fetchedUser); + Long observedMagnitude = + attemptSpan + .getAttributes() + .get(AttributeKey.longKey(ObservabilityAttributes.HTTP_RESPONSE_BODY_SIZE)); + if (observedMagnitude != null) { + assertThat(observedMagnitude).isAtLeast((long) (expectedMagnitude * (1 - 0.15))); + assertThat(observedMagnitude).isAtMost((long) (expectedMagnitude * (1 + 0.15))); + } } } + private long computeExpectedHttpJsonResponseSize(Message message) + throws InvalidProtocolBufferException { + String jsonPayload = com.google.protobuf.util.JsonFormat.printer().print(message); + return jsonPayload.getBytes(StandardCharsets.UTF_8).length; + } + @Test void testTracing_retry_grpc() throws Exception { final int attempts = 5; final StatusCode.Code statusCode = StatusCode.Code.UNAVAILABLE; - // A custom EchoClient is used in this test because retries have jitter, and we cannot - // predict the number of attempts that are scheduled for an RPC invocation otherwise. - // The custom retrySettings limit to a set number of attempts before the call gives up. + // A custom EchoClient is used in this test because retries have jitter, and we + // cannot + // predict the number of attempts that are scheduled for an RPC invocation + // otherwise. + // The custom retrySettings limit to a set number of attempts before the call + // gives up. RetrySettings retrySettings = RetrySettings.newBuilder() .setTotalTimeout(org.threeten.bp.Duration.ofMillis(5000L)) @@ -280,7 +307,8 @@ void testTracing_retry_grpc() throws Exception { assertThat(spans).hasSize(attempts); // Expect exactly one span for the successful retry // This single span represents the successful retry, which has resend_count=1 - // The first attempt has no resend_count. The subsequent retries will have a resend_count, + // The first attempt has no resend_count. The subsequent retries will have a + // resend_count, // starting from 1. List resendCounts = spans.stream() @@ -307,9 +335,12 @@ void testTracing_retry_grpc() throws Exception { void testTracing_retry_httpjson() throws Exception { final int attempts = 5; final StatusCode.Code statusCode = StatusCode.Code.UNAVAILABLE; - // A custom EchoClient is used in this test because retries have jitter, and we cannot - // predict the number of attempts that are scheduled for an RPC invocation otherwise. - // The custom retrySettings limit to a set number of attempts before the call gives up. + // A custom EchoClient is used in this test because retries have jitter, and we + // cannot + // predict the number of attempts that are scheduled for an RPC invocation + // otherwise. + // The custom retrySettings limit to a set number of attempts before the call + // gives up. RetrySettings retrySettings = RetrySettings.newBuilder() .setTotalTimeout(org.threeten.bp.Duration.ofMillis(5000L)) @@ -354,7 +385,8 @@ void testTracing_retry_httpjson() throws Exception { assertThat(spans).hasSize(attempts); // Expect exactly one span for the successful retry // This single span represents the successful retry, which has resend_count=1 - // The first attempt has no resend_count. The subsequent retries will have a resend_count, + // The first attempt has no resend_count. The subsequent retries will have a + // resend_count, // starting from 1. List resendCounts = spans.stream()