diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java index 64095fde00..9016f7e1d9 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java +++ b/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"; + /** The HTTP status code of the request (e.g., 200, 404). */ + public static final String HTTP_RESPONSE_STATUS_ATTRIBUTE = "http.response.status_code"; + /** 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/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityUtils.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityUtils.java index 2487964370..452d362f03 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityUtils.java +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityUtils.java @@ -39,10 +39,20 @@ class ObservabilityUtils { - /** Function to extract the status of the error as a string */ + /** Function to extract the status of the error as a string (defaults to gRPC canonical codes). */ static String extractStatus(@Nullable Throwable error) { - final String statusString; + return (String) extractStatus(error, ApiTracerContext.Transport.GRPC); + } + + static Object extractStatus(@Nullable Throwable error, ApiTracerContext.Transport transport) { + if (transport == ApiTracerContext.Transport.HTTP) { + return extractHttpStatus(error); + } + return extractGrpcStatus(error); + } + private static String extractGrpcStatus(@Nullable Throwable error) { + final String statusString; if (error == null) { return StatusCode.Code.OK.toString(); } else if (error instanceof CancellationException) { @@ -52,10 +62,41 @@ static String extractStatus(@Nullable Throwable error) { } else { statusString = StatusCode.Code.UNKNOWN.toString(); } - return statusString; } + private static Long extractHttpStatus(@Nullable Throwable error) { + if (error == null) { + return 200L; + } else if (error instanceof ApiException) { + Object transportCode = ((ApiException) error).getStatusCode().getTransportCode(); + if (transportCode instanceof Integer) { + return ((Integer) transportCode).longValue(); + } else { + return (long) ((ApiException) error).getStatusCode().getCode().getHttpStatusCode(); + } + } else { + StatusCode.Code code = StatusCode.Code.UNKNOWN; + if (error instanceof CancellationException) { + code = StatusCode.Code.CANCELLED; + } + return (long) code.getHttpStatusCode(); + } + } + + static void populateStatusAttributes( + Map attributes, + @Nullable Throwable error, + ApiTracerContext.Transport transport) { + if (transport == ApiTracerContext.Transport.GRPC) { + attributes.put( + ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE, extractStatus(error, transport)); + } else if (transport == ApiTracerContext.Transport.HTTP) { + attributes.put( + ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE, extractStatus(error, transport)); + } + } + static Attributes toOtelAttributes(Map attributes) { AttributesBuilder attributesBuilder = Attributes.builder(); if (attributes == null) { @@ -69,6 +110,8 @@ static Attributes toOtelAttributes(Map attributes) { attributesBuilder.put(k, (Long) v); } else if (v instanceof Integer) { attributesBuilder.put(k, (long) (Integer) v); + } else if (v instanceof Long) { + attributesBuilder.put(k, (Long) v); } }); return attributesBuilder.build(); diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/SpanTracer.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/SpanTracer.java index a2690359dd..dd7946786d 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/tracing/SpanTracer.java +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/SpanTracer.java @@ -38,6 +38,7 @@ import io.opentelemetry.api.trace.Tracer; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.CancellationException; /** An implementation of {@link ApiTracer} that uses OpenTelemetry to record traces. */ @BetaApi @@ -131,31 +132,44 @@ public void attemptStarted(Object request, int attemptNumber) { @Override public void attemptSucceeded() { - endAttempt(); + endAttempt(null); } @Override public void attemptCancelled() { - endAttempt(); + endAttempt(new CancellationException()); } @Override - public void attemptFailedDuration(Throwable error, java.time.Duration delay) { - endAttempt(); + public void attemptFailedRetriesExhausted(Throwable error) { + endAttempt(error); } @Override - public void attemptFailedRetriesExhausted(Throwable error) { - endAttempt(); + public void attemptPermanentFailure(Throwable error) { + endAttempt(error); } @Override - public void attemptPermanentFailure(Throwable error) { - endAttempt(); + public void attemptFailedDuration(Throwable error, java.time.Duration delay) { + endAttempt(error); } - private void endAttempt() { + @Override + public void attemptFailed(Throwable error, org.threeten.bp.Duration delay) { + endAttempt(error); + } + + private void endAttempt(Throwable error) { if (attemptSpan != null) { + Map endAttributes = new HashMap<>(); + ObservabilityUtils.populateStatusAttributes( + endAttributes, error, this.apiTracerContext.transport()); + + if (!endAttributes.isEmpty()) { + attemptSpan.setAllAttributes(ObservabilityUtils.toOtelAttributes(endAttributes)); + } + attemptSpan.end(); attemptSpan = null; } diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/ObservabilityUtilsTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/ObservabilityUtilsTest.java index 0af3be4746..8fad41052e 100644 --- a/gax-java/gax/src/test/java/com/google/api/gax/tracing/ObservabilityUtilsTest.java +++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/ObservabilityUtilsTest.java @@ -114,6 +114,106 @@ void testToOtelAttributes_shouldMapIntAttributes() { .isEqualTo((long) attribute2Value); } + @Test + void testPopulateStatusAttributes_grpc_success() { + Map attributes = new java.util.HashMap<>(); + ObservabilityUtils.populateStatusAttributes(attributes, null, ApiTracerContext.Transport.GRPC); + assertThat(attributes) + .containsEntry(ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE, "OK"); + } + + @Test + void testPopulateStatusAttributes_grpc_apiException() { + Map attributes = new java.util.HashMap<>(); + ApiException error = + new ApiException("fake_error", null, new FakeStatusCode(StatusCode.Code.NOT_FOUND), false); + ObservabilityUtils.populateStatusAttributes(attributes, error, ApiTracerContext.Transport.GRPC); + assertThat(attributes) + .containsEntry(ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE, "NOT_FOUND"); + } + + @Test + void testPopulateStatusAttributes_grpc_cancellationException() { + Map attributes = new java.util.HashMap<>(); + Throwable error = new java.util.concurrent.CancellationException(); + ObservabilityUtils.populateStatusAttributes(attributes, error, ApiTracerContext.Transport.GRPC); + assertThat(attributes) + .containsEntry(ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE, "CANCELLED"); + } + + @Test + void testPopulateStatusAttributes_http_success() { + Map attributes = new java.util.HashMap<>(); + ObservabilityUtils.populateStatusAttributes(attributes, null, ApiTracerContext.Transport.HTTP); + assertThat(attributes) + .containsEntry( + ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE, + (long) StatusCode.Code.OK.getHttpStatusCode()); + } + + @Test + void testPopulateStatusAttributes_http_apiExceptionWithIntegerTransportCode() { + Map attributes = new java.util.HashMap<>(); + ApiException error = + new ApiException( + "fake_error", + null, + new com.google.api.gax.rpc.StatusCode() { + @Override + public Code getCode() { + return Code.NOT_FOUND; + } + + @Override + public Object getTransportCode() { + return StatusCode.Code.NOT_FOUND.getHttpStatusCode(); + } + }, + false); + ObservabilityUtils.populateStatusAttributes(attributes, error, ApiTracerContext.Transport.HTTP); + assertThat(attributes) + .containsEntry( + ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE, + (long) StatusCode.Code.NOT_FOUND.getHttpStatusCode()); + } + + @Test + void testPopulateStatusAttributes_http_apiExceptionWithNonIntegerTransportCode() { + Map attributes = new java.util.HashMap<>(); + ApiException error = + new ApiException( + "fake_error", + null, + new com.google.api.gax.rpc.StatusCode() { + @Override + public Code getCode() { + return Code.NOT_FOUND; + } + + @Override + public Object getTransportCode() { + return "Not Found"; + } + }, + false); + ObservabilityUtils.populateStatusAttributes(attributes, error, ApiTracerContext.Transport.HTTP); + assertThat(attributes) + .containsEntry( + ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE, + (long) StatusCode.Code.NOT_FOUND.getHttpStatusCode()); + } + + @Test + void testPopulateStatusAttributes_http_cancellationException() { + Map attributes = new java.util.HashMap<>(); + Throwable error = new java.util.concurrent.CancellationException(); + ObservabilityUtils.populateStatusAttributes(attributes, error, ApiTracerContext.Transport.HTTP); + assertThat(attributes) + .containsEntry( + ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE, + (long) StatusCode.Code.CANCELLED.getHttpStatusCode()); + } + @Test void testToOtelAttributes_shouldReturnEmptyAttributes_nullInput() { assertThat(ObservabilityUtils.toOtelAttributes(null)).isEqualTo(Attributes.empty()); diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/SpanTracerTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/SpanTracerTest.java index 3e9fc53ce5..9816d6f604 100644 --- a/gax-java/gax/src/test/java/com/google/api/gax/tracing/SpanTracerTest.java +++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/SpanTracerTest.java @@ -87,6 +87,52 @@ void testAttemptStarted_includesLanguageAttribute() { SpanTracer.DEFAULT_LANGUAGE); } + @Test + void testAttemptSucceeded_grpc() { + ApiTracerContext context = + ApiTracerContext.newBuilder() + .setLibraryMetadata(com.google.api.gax.rpc.LibraryMetadata.empty()) + .setTransport(ApiTracerContext.Transport.GRPC) + .build(); + spanTracer = new SpanTracer(tracer, context, ATTEMPT_SPAN_NAME); + + spanTracer.attemptStarted(new Object(), 1); + spanTracer.attemptSucceeded(); + + ArgumentCaptor attrsCaptor = ArgumentCaptor.forClass(Attributes.class); + verify(span).setAllAttributes(attrsCaptor.capture()); + verify(span).end(); + + assertThat(attrsCaptor.getValue().asMap()) + .containsEntry( + io.opentelemetry.api.common.AttributeKey.stringKey( + ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE), + "OK"); + } + + @Test + void testAttemptSucceeded_http() { + ApiTracerContext context = + ApiTracerContext.newBuilder() + .setLibraryMetadata(com.google.api.gax.rpc.LibraryMetadata.empty()) + .setTransport(ApiTracerContext.Transport.HTTP) + .build(); + spanTracer = new SpanTracer(tracer, context, ATTEMPT_SPAN_NAME); + + spanTracer.attemptStarted(new Object(), 1); + spanTracer.attemptSucceeded(); + + ArgumentCaptor attrsCaptor = ArgumentCaptor.forClass(Attributes.class); + verify(span).setAllAttributes(attrsCaptor.capture()); + verify(span).end(); + + assertThat(attrsCaptor.getValue().asMap()) + .containsEntry( + io.opentelemetry.api.common.AttributeKey.longKey( + ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE), + 200L); + } + @Test void testAttemptStarted_noRetryAttributes_grpc() { ApiTracerContext grpcContext = @@ -110,6 +156,46 @@ void testAttemptStarted_noRetryAttributes_grpc() { ObservabilityAttributes.HTTP_RESEND_COUNT_ATTRIBUTE)); } + @Test + void testAttemptFailed_grpc() { + ApiTracerContext context = + ApiTracerContext.newBuilder() + .setLibraryMetadata(com.google.api.gax.rpc.LibraryMetadata.empty()) + .setTransport(ApiTracerContext.Transport.GRPC) + .build(); + spanTracer = new SpanTracer(tracer, context, ATTEMPT_SPAN_NAME); + + com.google.api.gax.rpc.ApiException exception = + new com.google.api.gax.rpc.ApiException( + "error", + null, + new com.google.api.gax.rpc.StatusCode() { + @Override + public Code getCode() { + return Code.NOT_FOUND; + } + + @Override + public Object getTransportCode() { + return null; + } + }, + false); + + spanTracer.attemptStarted(new Object(), 1); + spanTracer.attemptFailedRetriesExhausted(exception); + + ArgumentCaptor attrsCaptor = ArgumentCaptor.forClass(Attributes.class); + verify(span).setAllAttributes(attrsCaptor.capture()); + verify(span).end(); + + assertThat(attrsCaptor.getValue().asMap()) + .containsEntry( + io.opentelemetry.api.common.AttributeKey.stringKey( + ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE), + "NOT_FOUND"); + } + @Test void testAttemptStarted_retryAttributes_grpc() { ApiTracerContext grpcContext = @@ -136,6 +222,46 @@ void testAttemptStarted_retryAttributes_grpc() { ObservabilityAttributes.HTTP_RESEND_COUNT_ATTRIBUTE)); } + @Test + void testAttemptFailed_http() { + ApiTracerContext context = + ApiTracerContext.newBuilder() + .setLibraryMetadata(com.google.api.gax.rpc.LibraryMetadata.empty()) + .setTransport(ApiTracerContext.Transport.HTTP) + .build(); + spanTracer = new SpanTracer(tracer, context, ATTEMPT_SPAN_NAME); + + com.google.api.gax.rpc.ApiException exception = + new com.google.api.gax.rpc.ApiException( + "error", + null, + new com.google.api.gax.rpc.StatusCode() { + @Override + public Code getCode() { + return Code.NOT_FOUND; + } + + @Override + public Object getTransportCode() { + return 404; + } + }, + false); + + spanTracer.attemptStarted(new Object(), 1); + spanTracer.attemptFailedRetriesExhausted(exception); + + ArgumentCaptor attrsCaptor = ArgumentCaptor.forClass(Attributes.class); + verify(span).setAllAttributes(attrsCaptor.capture()); + verify(span).end(); + + assertThat(attrsCaptor.getValue().asMap()) + .containsEntry( + io.opentelemetry.api.common.AttributeKey.longKey( + ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE), + 404L); + } + @Test void testAttemptStarted_noRetryAttributes_http() { ApiTracerContext httpContext = diff --git a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelTracing.java b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelTracing.java index ddaff08896..2cc3076003 100644 --- a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelTracing.java +++ b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelTracing.java @@ -42,11 +42,13 @@ import com.google.api.gax.tracing.SpanTracer; import com.google.api.gax.tracing.SpanTracerFactory; import com.google.rpc.Status; +import com.google.showcase.v1beta1.CreateUserRequest; 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; @@ -95,13 +97,14 @@ void tearDown() { @Test void testTracing_successfulIdentityGetUser_grpc() throws Exception { + final String username = "users/test-user"; SpanTracerFactory tracingFactory = new SpanTracerFactory(openTelemetrySdk); try (IdentityClient client = TestClientInitializer.createGrpcIdentityClientOpentelemetry(tracingFactory)) { try { - client.getUser(GetUserRequest.newBuilder().setName("users/test-user").build()); + client.getUser(GetUserRequest.newBuilder().setName(username).build()); } catch (Exception e) { // Ignored, the showcase server may not have this user, but trace is still generated. } @@ -157,6 +160,13 @@ void testTracing_successfulIdentityGetUser_grpc() throws Exception { .get(AttributeKey.stringKey(ObservabilityAttributes.VERSION_ATTRIBUTE))) .isEqualTo("0.0.0-SNAPSHOT"); // {x-version-update-end} + assertThat( + attemptSpan + .getAttributes() + .get( + AttributeKey.stringKey( + ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE))) + .isEqualTo("OK"); assertThat( attemptSpan .getAttributes() @@ -169,12 +179,15 @@ void testTracing_successfulIdentityGetUser_grpc() throws Exception { @Test void testTracing_successfulIdentityGetUser_httpjson() throws Exception { + final String username = "users/test-user"; SpanTracerFactory tracingFactory = new SpanTracerFactory(openTelemetrySdk); try (IdentityClient client = TestClientInitializer.createHttpJsonIdentityClientOpentelemetry(tracingFactory)) { try { + client.createUser( + CreateUserRequest.newBuilder().setUser(User.newBuilder().setName(username)).build()); 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. @@ -225,6 +238,12 @@ void testTracing_successfulIdentityGetUser_httpjson() throws Exception { .getAttributes() .get(AttributeKey.stringKey(ObservabilityAttributes.HTTP_URL_TEMPLATE_ATTRIBUTE))) .isEqualTo("v1beta1/{name=users/*}"); + assertThat( + attemptSpan + .getAttributes() + .get( + AttributeKey.longKey(ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE))) + .isEqualTo(200L); assertThat( attemptSpan .getAttributes()