diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/ErrorTypeUtil.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ErrorTypeUtil.java new file mode 100644 index 0000000000..cbe4e4a580 --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ErrorTypeUtil.java @@ -0,0 +1,313 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.tracing; + +import com.google.api.gax.rpc.ApiException; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableSet; +import java.util.Set; +import javax.annotation.Nullable; + +public class ErrorTypeUtil { + + enum ErrorType { + CLIENT_TIMEOUT, + CLIENT_CONNECTION_ERROR, + CLIENT_REQUEST_ERROR, + CLIENT_REQUEST_BODY_ERROR, + CLIENT_RESPONSE_DECODE_ERROR, + CLIENT_REDIRECT_ERROR, + CLIENT_AUTHENTICATION_ERROR, + CLIENT_UNKNOWN_ERROR, + INTERNAL; + + @Override + public String toString() { + return name(); + } + } + + private static final Set JSON_DECODING_EXCEPTION_CLASS_NAMES = + ImmutableSet.of( + "com.google.gson.JsonSyntaxException", + "com.google.gson.JsonParseException", + "com.fasterxml.jackson.databind.JsonMappingException", + "com.fasterxml.jackson.core.JsonParseException"); + + private static final Set AUTHENTICATION_EXCEPTION_CLASS_NAMES = + ImmutableSet.of("com.google.auth.oauth2.GoogleAuthException"); + + private static final Set CLIENT_TIMEOUT_EXCEPTION_CLASS_NAMES = + ImmutableSet.of( + "java.net.SocketTimeoutException", "com.google.api.gax.rpc.WatchdogTimeoutException"); + private static final Set CLIENT_CONNECTION_EXCEPTIONS = + ImmutableSet.of( + "java.net.ConnectException", + "java.net.UnknownHostException", + "javax.net.ssl.SSLHandshakeException", + "java.nio.channels.UnresolvedAddressException"); + + /** + * Extracts a low-cardinality string representing the specific classification of the error to be + * used in the {@link ObservabilityAttributes#ERROR_TYPE_ATTRIBUTE} attribute. + * + *

This value is determined based on the following priority: + * + *

    + *
  1. {@code google.rpc.ErrorInfo.reason}: If the error response from the service + * includes {@code google.rpc.ErrorInfo} details, the reason field (e.g., + * "RATE_LIMIT_EXCEEDED", "SERVICE_DISABLED") will be used. This offers the most precise + * error cause. + *
  2. Specific Server Error Code: If no {@code ErrorInfo.reason} is available, but a + * server error code was received: + *
      + *
    • For HTTP: The HTTP status code (e.g., "403", "503"). + *
    • For gRPC: The gRPC status code name (e.g., "PERMISSION_DENIED", "UNAVAILABLE"). + *
    + *
  3. Client-Side Network/Operational Errors: For errors occurring within the client + * library or network stack, mapping to specific enum representations from {@link + * ErrorType}: + *
      + *
    • {@code CLIENT_TIMEOUT}: A client-configured timeout was reached. + *
    • {@code CLIENT_CONNECTION_ERROR}: Failure to establish the network connection (DNS, + * TCP, TLS). + *
    • {@code CLIENT_REQUEST_ERROR}: Client-side issue forming or sending the request. + *
    • {@code CLIENT_REQUEST_BODY_ERROR}: Error streaming the request body. + *
    • {@code CLIENT_RESPONSE_DECODE_ERROR}: Client-side error decoding the response body. + *
    • {@code CLIENT_REDIRECT_ERROR}: Problem handling HTTP redirects. + *
    • {@code CLIENT_AUTHENTICATION_ERROR}: Error during credential acquisition or + * application. + *
    • {@code CLIENT_UNKNOWN_ERROR}: For all other errors unknown to the client. + *
    + *
  4. Language-specific error type: The class or struct name of the exception or error + * if available. This must be low-cardinality, meaning it returns the short name of the + * exception class (e.g. {@code "IllegalStateException"}) rather than its message. + *
  5. Internal Fallback: If the error doesn't fit any of the above categories, {@code + * "INTERNAL"} will be used, indicating an unexpected issue within the client library's own + * logic. + *
+ * + * @param error the Throwable from which to extract the error type string. + * @return a low-cardinality string representing the specific error type, or {@code null} if the + * provided error is {@code null}. + */ + public static String extractErrorType(@Nullable Throwable error) { + if (error == null) { + // No information about the error; we default to INTERNAL. + return ErrorType.INTERNAL.toString(); + } + + // 1. & 2. Extract error info reason or server status code + if (error instanceof ApiException) { + String errorType = extractFromApiException((ApiException) error); + if (errorType != null) { + return errorType; + } + } + + // 3. Attempt client side error + String clientError = getClientSideError(error); + if (clientError != null) { + return clientError; + } + + // 4. Language-specific error type fallback + String exceptionName = error.getClass().getSimpleName(); + if (!Strings.isNullOrEmpty(exceptionName)) { + return exceptionName; + } + + // 5. Internal Fallback + return ErrorType.INTERNAL.toString(); + } + + /** + * Extracts the error type from an ApiException. This method prioritizes the ErrorInfo reason, + * then the transport-specific status code (HTTP or gRPC). + * + * @param apiException The ApiException to extract the error type from. + * @return A string representing the error type, or null if no specific type can be determined. + */ + @Nullable + private static String extractFromApiException(ApiException apiException) { + // 1. Check for ErrorInfo.reason + String reason = apiException.getReason(); + if (!Strings.isNullOrEmpty(reason)) { + return reason; + } + + // 2. Specific Server Error Code + if (apiException.getStatusCode() != null) { + Object transportCode = apiException.getStatusCode().getTransportCode(); + if (transportCode instanceof Integer) { + // HTTP Status Code + return String.valueOf(transportCode); + } else if (apiException.getStatusCode().getCode() != null) { + // gRPC Status Code name + return apiException.getStatusCode().getCode().name(); + } + } + return null; + } + + /** + * Determines the client-side error type based on the provided Throwable. This method checks for + * various network and client-specific exceptions. + * + * @param error The Throwable to analyze. + * @return A string representing the client-side error type, or null if not matched. + */ + @Nullable + private static String getClientSideError(Throwable error) { + if (isClientTimeout(error)) { + return ErrorType.CLIENT_TIMEOUT.toString(); + } + if (isClientConnectionError(error)) { + return ErrorType.CLIENT_CONNECTION_ERROR.toString(); + } + if (isClientAuthenticationError(error)) { + return ErrorType.CLIENT_AUTHENTICATION_ERROR.toString(); + } + if (isClientResponseDecodeError(error)) { + return ErrorType.CLIENT_RESPONSE_DECODE_ERROR.toString(); + } + if (isClientRedirectError(error)) { + return ErrorType.CLIENT_REDIRECT_ERROR.toString(); + } + // This covers CLIENT_REQUEST_ERROR for general illegal arguments in client requests. + if (error instanceof IllegalArgumentException) { + return ErrorType.CLIENT_REQUEST_ERROR.toString(); + } + if (isRequestBodyError(error)) { + return ErrorType.CLIENT_REQUEST_BODY_ERROR.toString(); + } + if (isClientUnknownError(error)) { + return ErrorType.CLIENT_UNKNOWN_ERROR.toString(); + } + return null; + } + + /** + * Checks if the given Throwable represents a client-side timeout error. This includes socket + * timeouts and GAX-specific watchdog timeouts. + * + * @param e The Throwable to check. + * @return true if the error is a client timeout, false otherwise. + */ + private static boolean isClientTimeout(Throwable e) { + return hasErrorNameInCauseChain(e, CLIENT_TIMEOUT_EXCEPTION_CLASS_NAMES); + } + + /** + * Checks if the given Throwable represents a client-side connection error. This includes issues + * with establishing connections, unknown hosts, SSL handshakes, and unresolved addresses. + * + * @param e The Throwable to check. + * @return true if the error is a client connection error, false otherwise. + */ + private static boolean isClientConnectionError(Throwable e) { + return hasErrorNameInCauseChain(e, CLIENT_CONNECTION_EXCEPTIONS); + } + + /** + * Checks if the given Throwable represents a client-side response decoding error. This is + * identified by exceptions related to JSON or Gson parsing, either directly or as a cause. + * + * @param e The Throwable to check. + * @return true if the error is a client response decode error, false otherwise. + */ + private static boolean isClientResponseDecodeError(Throwable e) { + return hasErrorNameInCauseChain(e, JSON_DECODING_EXCEPTION_CLASS_NAMES); + } + + /** + * Checks if the given Throwable represents a client-side redirect error. This is identified by + * the presence of "redirect" in the exception message. + * + * @param e The Throwable to check. + * @return true if the error is a client redirect error, false otherwise. + */ + private static boolean isClientRedirectError(Throwable e) { + return e.getMessage() != null && e.getMessage().contains("redirect"); + } + + /** + * Checks if the given Throwable represents a client-side authentication error. This is identified + * by exceptions related to the auth library. + * + * @param e The Throwable to check. + * @return true if the error is a client authentication error, false otherwise. + */ + private static boolean isClientAuthenticationError(Throwable e) { + return hasErrorNameInCauseChain(e, AUTHENTICATION_EXCEPTION_CLASS_NAMES); + } + + /** + * Checks if the given Throwable represents a client-side request body error. This is specifically + * mapped to RestSerializationException from httpjson, which indicates issues during the + * serialization of the request body for REST calls. + * + * @param e The Throwable to check. + * @return true if the error is a client request body error, false otherwise. + */ + private static boolean isRequestBodyError(Throwable e) { + return hasErrorNameInCauseChain(e, ImmutableSet.of("RestSerializationException")); + } + + /** + * Checks if the given Throwable represents an unknown client-side error. This is a general + * fallback for exceptions whose class name contains "unknown", indicating an unclassified + * client-side issue. + * + * @param e The Throwable to check. + * @return true if the error is an unknown client error, false otherwise. + */ + private static boolean isClientUnknownError(Throwable e) { + return e.getClass().getName().toLowerCase().contains("unknown"); + } + + /** + * Recursively checks the throwable and its cause chain for any of the specified error name. + * + * @param t The Throwable to check. + * @param errorClassNames A set of fully qualified class names to check against. + * @return true if an error from the set is found in the cause chain, false otherwise. + */ + private static boolean hasErrorNameInCauseChain(Throwable t, Set errorClassNames) { + Throwable current = t; + while (current != null) { + if (errorClassNames.contains(current.getClass().getName())) { + return true; + } + current = current.getCause(); + } + return false; + } +} 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 b8b4dc2373..07321282ee 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 @@ -73,4 +73,10 @@ public class ObservabilityAttributes { /** The url template of the request (e.g. /v1/{name}:access). */ public static final String URL_TEMPLATE_ATTRIBUTE = "url.template"; + + /** + * The specific error type. Value will be google.rpc.ErrorInfo.reason, a specific Server Error + * Code, Client-Side Network/Operational Error (e.g., CLIENT_TIMEOUT) or internal fallback. + */ + public static final String ERROR_TYPE_ATTRIBUTE = "error.type"; } 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 f2a787fc95..7cc3bf0e41 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,6 +39,15 @@ class ObservabilityUtils { + /** + * Extracts a low-cardinality string representing the specific classification of the error to be + * used in the {@link ObservabilityAttributes#ERROR_TYPE_ATTRIBUTE} attribute. See {@link + * ErrorTypeUtil#extractErrorType} for extended documentation. + */ + static String extractErrorType(@Nullable Throwable error) { + return ErrorTypeUtil.extractErrorType(error); + } + /** Function to extract the status of the error as a string */ static String extractStatus(@Nullable Throwable error) { final String statusString; diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryTraceManager.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryTraceManager.java index 833e56fda4..eb44fef2e2 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryTraceManager.java +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryTraceManager.java @@ -71,6 +71,11 @@ private OtelSpan(io.opentelemetry.api.trace.Span span) { this.span = span; } + @Override + public void addAttribute(String key, String value) { + span.setAttribute(key, value); + } + @Override public void end() { span.end(); 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 c5c28aebe0..f82a461cb0 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 @@ -85,6 +85,34 @@ public void attemptSucceeded() { endAttempt(); } + @Override + public void attemptCancelled() { + endAttempt(); + } + + @Override + public void attemptFailedDuration(Throwable error, java.time.Duration delay) { + recordErrorAndEndAttempt(error); + } + + @Override + public void attemptFailedRetriesExhausted(Throwable error) { + recordErrorAndEndAttempt(error); + } + + @Override + public void attemptPermanentFailure(Throwable error) { + recordErrorAndEndAttempt(error); + } + + private void recordErrorAndEndAttempt(Throwable error) { + if (attemptHandle != null) { + attemptHandle.addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, ObservabilityUtils.extractErrorType(error)); + endAttempt(); + } + } + private void endAttempt() { if (attemptHandle != null) { attemptHandle.end(); diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/TraceManager.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/TraceManager.java index 8572d1ce11..12169ce937 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/tracing/TraceManager.java +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/TraceManager.java @@ -45,6 +45,8 @@ public interface TraceManager { Span createSpan(String name, Map attributes); interface Span { + void addAttribute(String key, String value); + void end(); } } 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 b5e6100fe6..2c2bf3c5c5 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 @@ -35,6 +35,15 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.ErrorDetails; +import com.google.api.gax.rpc.StatusCode; +import com.google.common.collect.ImmutableList; +import com.google.gson.JsonSyntaxException; +import com.google.protobuf.Any; +import com.google.rpc.ErrorInfo; +import java.net.ConnectException; +import java.net.SocketTimeoutException; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -76,4 +85,243 @@ void testAttemptStarted_includesLanguageAttribute() { assertThat(attributesCaptor.getValue()) .containsEntry(SpanTracer.LANGUAGE_ATTRIBUTE, SpanTracer.DEFAULT_LANGUAGE); } + + @Test + void testAttemptFailed_errorInfoReason() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + ErrorInfo errorInfo = ErrorInfo.newBuilder().setReason("RATE_LIMIT_EXCEEDED").build(); + ErrorDetails errorDetails = + ErrorDetails.builder().setRawErrorMessages(ImmutableList.of(Any.pack(errorInfo))).build(); + Throwable cause = new Throwable("message"); + + ApiException apiException = + new ApiException( + cause, + new StatusCode() { + @Override + public Code getCode() { + return Code.UNAVAILABLE; + } + + @Override + public Object getTransportCode() { + return null; + } + }, + true, + errorDetails); + + tracer.attemptFailedRetriesExhausted(apiException); + + verify(attemptHandle) + .addAttribute(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, "RATE_LIMIT_EXCEEDED"); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_specificServerErrorCodeGrpc() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + ApiException apiException = + new ApiException( + "message", + null, + new StatusCode() { + @Override + public Code getCode() { + return Code.PERMISSION_DENIED; + } + + @Override + public Object getTransportCode() { + return "PERMISSION_DENIED"; + } + }, + true); + + tracer.attemptFailedRetriesExhausted(apiException); + + verify(attemptHandle) + .addAttribute(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, "PERMISSION_DENIED"); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_specificServerErrorCodeHttp() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + ApiException apiException = + new ApiException( + "message", + null, + new StatusCode() { + @Override + public Code getCode() { + return Code.PERMISSION_DENIED; + } + + @Override + public Object getTransportCode() { + return 403; + } + }, + true); + + tracer.attemptFailedRetriesExhausted(apiException); + + verify(attemptHandle).addAttribute(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, "403"); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_clientTimeout() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + tracer.attemptFailedRetriesExhausted(new SocketTimeoutException()); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ErrorTypeUtil.ErrorType.CLIENT_TIMEOUT.toString()); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_clientConnectionError() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + tracer.attemptFailedRetriesExhausted(new ConnectException("connection failed")); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ErrorTypeUtil.ErrorType.CLIENT_CONNECTION_ERROR.toString()); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_clientResponseDecodeError() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + tracer.attemptFailedRetriesExhausted(new JsonSyntaxException("bad json")); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ErrorTypeUtil.ErrorType.CLIENT_RESPONSE_DECODE_ERROR.toString()); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_clientRedirectError() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + tracer.attemptFailedRetriesExhausted(new RedirectException("redirect failed")); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ErrorTypeUtil.ErrorType.CLIENT_REDIRECT_ERROR.toString()); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_clientRequestError() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + tracer.attemptFailedRetriesExhausted(new IllegalArgumentException()); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ErrorTypeUtil.ErrorType.CLIENT_REQUEST_ERROR.toString()); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_clientUnknownError() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + tracer.attemptFailedRetriesExhausted(new UnknownClientException()); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ErrorTypeUtil.ErrorType.CLIENT_UNKNOWN_ERROR.toString()); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_languageSpecificFallback() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + tracer.attemptFailedRetriesExhausted(new IllegalStateException("illegal state")); + + verify(attemptHandle) + .addAttribute(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, "IllegalStateException"); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_internalFallback() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + tracer.attemptFailedRetriesExhausted(new Throwable() {}); + + // For an anonymous inner class Throwable, getSimpleName() is empty string, which triggers the + // fallback + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ErrorTypeUtil.ErrorType.INTERNAL.toString()); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_internalFallback_nullError() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + tracer.attemptFailedRetriesExhausted(null); + + // For an anonymous inner class Throwable, getSimpleName() is empty string, which triggers the + // fallback + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ErrorTypeUtil.ErrorType.INTERNAL.toString()); + verify(attemptHandle).end(); + } + + private static class RedirectException extends RuntimeException { + public RedirectException(String message) { + super(message); + } + } + + private static class UnknownClientException extends RuntimeException {} } 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 18594429a0..d2290b4fc4 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 @@ -31,11 +31,15 @@ package com.google.showcase.v1beta1.it; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import com.google.api.gax.rpc.StatusCode.Code; +import com.google.api.gax.rpc.UnavailableException; import com.google.api.gax.tracing.ObservabilityAttributes; import com.google.api.gax.tracing.OpenTelemetryTraceManager; import com.google.api.gax.tracing.SpanTracer; import com.google.api.gax.tracing.SpanTracerFactory; +import com.google.rpc.Status; import com.google.showcase.v1beta1.EchoClient; import com.google.showcase.v1beta1.EchoRequest; import com.google.showcase.v1beta1.it.util.TestClientInitializer; @@ -185,4 +189,68 @@ void testTracing_successfulEcho_httpjson() throws Exception { .isEqualTo(SHOWCASE_ARTIFACT); } } + + @Test + void testTracing_failedEcho_grpc_recordsErrorType() throws Exception { + SpanTracerFactory tracingFactory = + new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); + + try (EchoClient client = + TestClientInitializer.createGrpcEchoClientOpentelemetry(tracingFactory)) { + + EchoRequest echoRequest = + EchoRequest.newBuilder() + .setError(Status.newBuilder().setCode(Code.UNAVAILABLE.ordinal()).build()) + .build(); + + assertThrows(UnavailableException.class, () -> client.echo(echoRequest)); + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).isNotEmpty(); + + SpanData attemptSpan = + spans.stream() + .filter(span -> span.getName().equals("google.showcase.v1beta1.Echo/Echo")) + .findFirst() + .orElseThrow(() -> new AssertionError("Incorrect span name")); + + assertThat( + attemptSpan + .getAttributes() + .get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE))) + .isEqualTo("UNAVAILABLE"); + } + } + + @Test + void testTracing_failedEcho_httpjson_recordsErrorType() throws Exception { + SpanTracerFactory tracingFactory = + new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); + + try (EchoClient client = + TestClientInitializer.createHttpJsonEchoClientOpentelemetry(tracingFactory)) { + + EchoRequest echoRequest = + EchoRequest.newBuilder() + .setError(Status.newBuilder().setCode(Code.UNAVAILABLE.ordinal()).build()) + .build(); + + assertThrows(UnavailableException.class, () -> client.echo(echoRequest)); + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).isNotEmpty(); + + SpanData attemptSpan = + spans.stream() + .filter(span -> span.getName().equals("Echo/Echo/attempt")) + .findFirst() + .orElseThrow(() -> new AssertionError("Attempt span 'Echo/Echo/attempt' not found")); + + assertThat( + attemptSpan + .getAttributes() + .get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE))) + .isEqualTo("503"); // For HTTP/JSON, the transport code 503 is used for UNAVAILABLE + } + } }