From ee42bf19a793489958ad3e251864b13944218af8 Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Mon, 16 Mar 2026 16:35:14 -0400 Subject: [PATCH 01/16] impl(o11y): introduce error.type --- .../gax/tracing/ObservabilityAttributes.java | 6 + .../api/gax/tracing/ObservabilityUtils.java | 99 +++++++ .../tracing/OpenTelemetryTraceManager.java | 5 + .../google/api/gax/tracing/SpanTracer.java | 31 ++ .../google/api/gax/tracing/TraceManager.java | 2 + .../api/gax/tracing/SpanTracerTest.java | 264 ++++++++++++++++++ .../showcase/v1beta1/it/ITOtelTracing.java | 68 +++++ 7 files changed, 475 insertions(+) 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..8d0547bfa8 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,105 @@ class ObservabilityUtils { + 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(); + } + } + + /** + * Function to extract the {@link ObservabilityAttributes#ERROR_TYPE_ATTRIBUTE } attribute from a + * Throwable + */ + static String extractErrorType(@Nullable Throwable error) { + if (error == null) { + return null; + } + + if (error instanceof ApiException) { + ApiException apiException = (ApiException) error; + + // 1. Check for ErrorInfo.reason + String reason = apiException.getReason(); + if (reason != null && !reason.isEmpty()) { + 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(); + } + } + } + + // 3. Client-Side Network/Operational Errors + String exceptionName = error.getClass().getSimpleName(); + + if (error instanceof java.util.concurrent.TimeoutException + || error instanceof java.net.SocketTimeoutException + || exceptionName.equals("WatchdogTimeoutException")) { + return ErrorType.CLIENT_TIMEOUT.toString(); + } + + if (error instanceof java.net.ConnectException + || error instanceof java.net.UnknownHostException + || error instanceof java.nio.channels.UnresolvedAddressException + || exceptionName.equals("ConnectException")) { + return ErrorType.CLIENT_CONNECTION_ERROR.toString(); + } + + if (exceptionName.contains("CredentialsException") + || exceptionName.contains("AuthenticationException")) { + return ErrorType.CLIENT_AUTHENTICATION_ERROR.toString(); + } + + if (exceptionName.contains("ProtocolBufferParsingException") + || exceptionName.contains("DecodeException")) { + return ErrorType.CLIENT_RESPONSE_DECODE_ERROR.toString(); + } + + if (exceptionName.contains("RedirectException")) { + return ErrorType.CLIENT_REDIRECT_ERROR.toString(); + } + + if (exceptionName.contains("RequestBodyException")) { + return ErrorType.CLIENT_REQUEST_BODY_ERROR.toString(); + } + + if (exceptionName.contains("RequestException")) { + return ErrorType.CLIENT_REQUEST_ERROR.toString(); + } + + if (exceptionName.contains("UnknownClientException")) { + return ErrorType.CLIENT_UNKNOWN_ERROR.toString(); + } + + // 4. Language-specific error type fallback + if (exceptionName != null && !exceptionName.isEmpty()) { + return exceptionName; + } + + // 5. Internal Fallback + return ErrorType.INTERNAL.toString(); + } + /** 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..122e2a66f3 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,37 @@ 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) { + if (error != 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..9439327dbc 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,7 +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.protobuf.Any; +import com.google.rpc.ErrorInfo; +import java.net.ConnectException; import java.util.Map; +import java.util.concurrent.TimeoutException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -76,4 +84,260 @@ 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 TimeoutException("timed out")); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ObservabilityUtils.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, + ObservabilityUtils.ErrorType.CLIENT_CONNECTION_ERROR.toString()); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_clientAuthenticationError() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + tracer.attemptFailedRetriesExhausted(new CredentialsException()); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ObservabilityUtils.ErrorType.CLIENT_AUTHENTICATION_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 DecodeException()); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ObservabilityUtils.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()); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ObservabilityUtils.ErrorType.CLIENT_REDIRECT_ERROR.toString()); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_clientRequestBodyError() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + tracer.attemptFailedRetriesExhausted(new RequestBodyException()); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ObservabilityUtils.ErrorType.CLIENT_REQUEST_BODY_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 RequestException()); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, + ObservabilityUtils.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, + ObservabilityUtils.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, + ObservabilityUtils.ErrorType.INTERNAL.toString()); + verify(attemptHandle).end(); + } + + private static class CredentialsException extends RuntimeException {} + + private static class DecodeException extends RuntimeException {} + + private static class RedirectException extends RuntimeException {} + + private static class RequestBodyException extends RuntimeException {} + + private static class RequestException extends RuntimeException {} + + 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 + } + } } From 4269ccdd7fea903c72c6009d324f581b29d12876 Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Mon, 16 Mar 2026 16:51:27 -0400 Subject: [PATCH 02/16] docs: add javadoc for error type logic --- .../api/gax/tracing/ObservabilityUtils.java | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) 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 8d0547bfa8..5bef1fda05 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 @@ -57,8 +57,49 @@ public String toString() { } /** - * Function to extract the {@link ObservabilityAttributes#ERROR_TYPE_ATTRIBUTE } attribute from a - * Throwable + * 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}: Other unclassified client-side network or protocol + * errors. + *
    + *
  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}. */ static String extractErrorType(@Nullable Throwable error) { if (error == null) { From e63af7f7a30a33d5315a282c9ce528d40be0fed4 Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Tue, 17 Mar 2026 13:27:55 -0400 Subject: [PATCH 03/16] fix: reduce code complexity --- .../api/gax/tracing/ObservabilityUtils.java | 152 +++++++++++++----- 1 file changed, 108 insertions(+), 44 deletions(-) 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 5bef1fda05..ef0c9462b3 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 @@ -31,8 +31,12 @@ import com.google.api.gax.rpc.ApiException; import com.google.api.gax.rpc.StatusCode; +import com.google.common.base.Strings; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.concurrent.CancellationException; import javax.annotation.Nullable; @@ -56,6 +60,38 @@ public String toString() { } } + private static final List> CLIENT_TIMEOUT_CLASSES = + Arrays.asList( + java.util.concurrent.TimeoutException.class, java.net.SocketTimeoutException.class); + private static final List CLIENT_TIMEOUT_NAMES = + Collections.singletonList("WatchdogTimeoutException"); + + private static final List> CLIENT_CONNECTION_ERROR_CLASSES = + Arrays.asList( + java.net.ConnectException.class, + java.net.UnknownHostException.class, + java.nio.channels.UnresolvedAddressException.class); + private static final List CLIENT_CONNECTION_ERROR_NAMES = + Collections.singletonList("ConnectException"); + + private static final List CLIENT_AUTH_ERROR_SUBSTRINGS = + Arrays.asList("CredentialsException", "AuthenticationException"); + + private static final List CLIENT_RESPONSE_DECODE_ERROR_SUBSTRINGS = + Arrays.asList("ProtocolBufferParsingException", "DecodeException"); + + private static final List CLIENT_REDIRECT_ERROR_SUBSTRINGS = + Collections.singletonList("RedirectException"); + + private static final List CLIENT_REQUEST_BODY_ERROR_SUBSTRINGS = + Collections.singletonList("RequestBodyException"); + + private static final List CLIENT_REQUEST_ERROR_SUBSTRINGS = + Collections.singletonList("RequestException"); + + private static final List CLIENT_UNKNOWN_ERROR_SUBSTRINGS = + Collections.singletonList("UnknownClientException"); + /** * Extracts a low-cardinality string representing the specific classification of the error to be * used in the {@link ObservabilityAttributes#ERROR_TYPE_ATTRIBUTE} attribute. @@ -107,76 +143,104 @@ static String extractErrorType(@Nullable Throwable error) { } if (error instanceof ApiException) { - ApiException apiException = (ApiException) error; - - // 1. Check for ErrorInfo.reason - String reason = apiException.getReason(); - if (reason != null && !reason.isEmpty()) { - return reason; + String errorType = extractFromApiException((ApiException) error); + if (errorType != null) { + return errorType; } + } - // 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(); - } - } + String clientError = getClientSideError(error); + if (clientError != null) { + return clientError; } - // 3. Client-Side Network/Operational Errors + // 4. Language-specific error type fallback String exceptionName = error.getClass().getSimpleName(); + if (exceptionName != null && !exceptionName.isEmpty()) { + return exceptionName; + } - if (error instanceof java.util.concurrent.TimeoutException - || error instanceof java.net.SocketTimeoutException - || exceptionName.equals("WatchdogTimeoutException")) { - return ErrorType.CLIENT_TIMEOUT.toString(); + // 5. Internal Fallback + return ErrorType.INTERNAL.toString(); + } + + @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; + } - if (error instanceof java.net.ConnectException - || error instanceof java.net.UnknownHostException - || error instanceof java.nio.channels.UnresolvedAddressException - || exceptionName.equals("ConnectException")) { + @Nullable + private static String getClientSideError(Throwable error) { + if (isInstanceof(error, CLIENT_TIMEOUT_CLASSES)) { + return ErrorType.CLIENT_TIMEOUT.toString(); + } + if (isInstanceof(error, CLIENT_CONNECTION_ERROR_CLASSES)) { return ErrorType.CLIENT_CONNECTION_ERROR.toString(); } - if (exceptionName.contains("CredentialsException") - || exceptionName.contains("AuthenticationException")) { + String exceptionName = error.getClass().getSimpleName(); + + if (CLIENT_TIMEOUT_NAMES.contains(exceptionName)) { + return ErrorType.CLIENT_TIMEOUT.toString(); + } + if (CLIENT_CONNECTION_ERROR_NAMES.contains(exceptionName)) { + return ErrorType.CLIENT_CONNECTION_ERROR.toString(); + } + if (nameContains(exceptionName, CLIENT_AUTH_ERROR_SUBSTRINGS)) { return ErrorType.CLIENT_AUTHENTICATION_ERROR.toString(); } - - if (exceptionName.contains("ProtocolBufferParsingException") - || exceptionName.contains("DecodeException")) { + if (nameContains(exceptionName, CLIENT_RESPONSE_DECODE_ERROR_SUBSTRINGS)) { return ErrorType.CLIENT_RESPONSE_DECODE_ERROR.toString(); } - - if (exceptionName.contains("RedirectException")) { + if (nameContains(exceptionName, CLIENT_REDIRECT_ERROR_SUBSTRINGS)) { return ErrorType.CLIENT_REDIRECT_ERROR.toString(); } - - if (exceptionName.contains("RequestBodyException")) { + if (nameContains(exceptionName, CLIENT_REQUEST_BODY_ERROR_SUBSTRINGS)) { return ErrorType.CLIENT_REQUEST_BODY_ERROR.toString(); } - - if (exceptionName.contains("RequestException")) { + if (nameContains(exceptionName, CLIENT_REQUEST_ERROR_SUBSTRINGS)) { return ErrorType.CLIENT_REQUEST_ERROR.toString(); } - - if (exceptionName.contains("UnknownClientException")) { + if (nameContains(exceptionName, CLIENT_UNKNOWN_ERROR_SUBSTRINGS)) { return ErrorType.CLIENT_UNKNOWN_ERROR.toString(); } - // 4. Language-specific error type fallback - if (exceptionName != null && !exceptionName.isEmpty()) { - return exceptionName; + return null; + } + + private static boolean isInstanceof(Throwable error, List> classes) { + for (Class clazz : classes) { + if (clazz.isInstance(error)) { + return true; + } } + return false; + } - // 5. Internal Fallback - return ErrorType.INTERNAL.toString(); + private static boolean nameContains(String name, List substrings) { + for (String sub : substrings) { + if (name.contains(sub)) { + return true; + } + } + return false; } /** Function to extract the status of the error as a string */ From b215900265e6db0eee9c92f41a5b70b35031d584 Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Tue, 17 Mar 2026 13:29:05 -0400 Subject: [PATCH 04/16] chore: add comments --- .../java/com/google/api/gax/tracing/ObservabilityUtils.java | 2 ++ 1 file changed, 2 insertions(+) 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 ef0c9462b3..6e5bafd653 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 @@ -142,6 +142,7 @@ static String extractErrorType(@Nullable Throwable error) { return null; } + // 1. & 2. Extract error info reason or server status code if (error instanceof ApiException) { String errorType = extractFromApiException((ApiException) error); if (errorType != null) { @@ -149,6 +150,7 @@ static String extractErrorType(@Nullable Throwable error) { } } + // 3. Attempt client side error String clientError = getClientSideError(error); if (clientError != null) { return clientError; From bfcad8b2d65514eefab217dd6e63926f2a80b637 Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Tue, 17 Mar 2026 14:39:25 -0400 Subject: [PATCH 05/16] refactor: put error type logic in new class --- .../google/api/gax/tracing/ErrorTypeUtil.java | 211 ++++++++++++++++++ .../api/gax/tracing/ObservabilityUtils.java | 161 +------------ .../api/gax/tracing/SpanTracerTest.java | 43 ++-- 3 files changed, 235 insertions(+), 180 deletions(-) create mode 100644 gax-java/gax/src/main/java/com/google/api/gax/tracing/ErrorTypeUtil.java 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..1e5c11ee7a --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ErrorTypeUtil.java @@ -0,0 +1,211 @@ +/* + * 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.api.gax.rpc.WatchdogTimeoutException; +import com.google.common.base.Strings; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.nio.channels.UnresolvedAddressException; +import javax.annotation.Nullable; +import javax.net.ssl.SSLHandshakeException; + +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(); + } + } + + /** + * 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}: Other unclassified client-side network or protocol + * errors. + *
    + *
  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) { + return null; + } + + // 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(); + } + + @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; + } + + @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(); + } + if (error instanceof IllegalArgumentException) { // This covers CLIENT_REQUEST_ERROR + return ErrorType.CLIENT_REQUEST_ERROR.toString(); + } + if (error.getClass().getSimpleName().contains("RequestBodyException")) { + return ErrorType.CLIENT_REQUEST_BODY_ERROR.toString(); + } + if (error.getClass().getSimpleName().contains("UnknownClientException")) { + return ErrorType.CLIENT_UNKNOWN_ERROR.toString(); + } + + return null; + } + + private static boolean isClientTimeout(Throwable e) { + return e instanceof SocketTimeoutException || e instanceof WatchdogTimeoutException; + } + + private static boolean isClientConnectionError(Throwable e) { + return e instanceof ConnectException + || e instanceof UnknownHostException + || e instanceof SSLHandshakeException + || e instanceof UnresolvedAddressException; + } + + private static boolean isClientResponseDecodeError(Throwable e) { + return e.getClass().getName().contains("Json") + || e.getClass().getName().contains("Gson") + || (e.getCause() != null && e.getCause().getClass().getName().contains("Gson")); + } + + private static boolean isClientRedirectError(Throwable e) { + return e.getMessage() != null && e.getMessage().contains("redirect"); + } + + private static boolean isClientAuthenticationError(Throwable e) { + return e.getClass().getName().contains("GoogleAuthException"); + } +} 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 6e5bafd653..ca5a58e496 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 @@ -31,67 +31,14 @@ import com.google.api.gax.rpc.ApiException; import com.google.api.gax.rpc.StatusCode; -import com.google.common.base.Strings; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; import java.util.Map; import java.util.concurrent.CancellationException; import javax.annotation.Nullable; class ObservabilityUtils { - 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 List> CLIENT_TIMEOUT_CLASSES = - Arrays.asList( - java.util.concurrent.TimeoutException.class, java.net.SocketTimeoutException.class); - private static final List CLIENT_TIMEOUT_NAMES = - Collections.singletonList("WatchdogTimeoutException"); - - private static final List> CLIENT_CONNECTION_ERROR_CLASSES = - Arrays.asList( - java.net.ConnectException.class, - java.net.UnknownHostException.class, - java.nio.channels.UnresolvedAddressException.class); - private static final List CLIENT_CONNECTION_ERROR_NAMES = - Collections.singletonList("ConnectException"); - - private static final List CLIENT_AUTH_ERROR_SUBSTRINGS = - Arrays.asList("CredentialsException", "AuthenticationException"); - - private static final List CLIENT_RESPONSE_DECODE_ERROR_SUBSTRINGS = - Arrays.asList("ProtocolBufferParsingException", "DecodeException"); - - private static final List CLIENT_REDIRECT_ERROR_SUBSTRINGS = - Collections.singletonList("RedirectException"); - - private static final List CLIENT_REQUEST_BODY_ERROR_SUBSTRINGS = - Collections.singletonList("RequestBodyException"); - - private static final List CLIENT_REQUEST_ERROR_SUBSTRINGS = - Collections.singletonList("RequestException"); - - private static final List CLIENT_UNKNOWN_ERROR_SUBSTRINGS = - Collections.singletonList("UnknownClientException"); - /** * Extracts a low-cardinality string representing the specific classification of the error to be * used in the {@link ObservabilityAttributes#ERROR_TYPE_ATTRIBUTE} attribute. @@ -111,7 +58,7 @@ public String toString() { * *
  • Client-Side Network/Operational Errors: For errors occurring within the client * library or network stack, mapping to specific enum representations from {@link - * ErrorType}: + * ErrorTypeUtil.ErrorType}: *
      *
    • {@code CLIENT_TIMEOUT}: A client-configured timeout was reached. *
    • {@code CLIENT_CONNECTION_ERROR}: Failure to establish the network connection (DNS, @@ -138,111 +85,7 @@ public String toString() { * provided error is {@code null}. */ static String extractErrorType(@Nullable Throwable error) { - if (error == null) { - return null; - } - - // 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 (exceptionName != null && !exceptionName.isEmpty()) { - return exceptionName; - } - - // 5. Internal Fallback - return ErrorType.INTERNAL.toString(); - } - - @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; - } - - @Nullable - private static String getClientSideError(Throwable error) { - if (isInstanceof(error, CLIENT_TIMEOUT_CLASSES)) { - return ErrorType.CLIENT_TIMEOUT.toString(); - } - if (isInstanceof(error, CLIENT_CONNECTION_ERROR_CLASSES)) { - return ErrorType.CLIENT_CONNECTION_ERROR.toString(); - } - - String exceptionName = error.getClass().getSimpleName(); - - if (CLIENT_TIMEOUT_NAMES.contains(exceptionName)) { - return ErrorType.CLIENT_TIMEOUT.toString(); - } - if (CLIENT_CONNECTION_ERROR_NAMES.contains(exceptionName)) { - return ErrorType.CLIENT_CONNECTION_ERROR.toString(); - } - if (nameContains(exceptionName, CLIENT_AUTH_ERROR_SUBSTRINGS)) { - return ErrorType.CLIENT_AUTHENTICATION_ERROR.toString(); - } - if (nameContains(exceptionName, CLIENT_RESPONSE_DECODE_ERROR_SUBSTRINGS)) { - return ErrorType.CLIENT_RESPONSE_DECODE_ERROR.toString(); - } - if (nameContains(exceptionName, CLIENT_REDIRECT_ERROR_SUBSTRINGS)) { - return ErrorType.CLIENT_REDIRECT_ERROR.toString(); - } - if (nameContains(exceptionName, CLIENT_REQUEST_BODY_ERROR_SUBSTRINGS)) { - return ErrorType.CLIENT_REQUEST_BODY_ERROR.toString(); - } - if (nameContains(exceptionName, CLIENT_REQUEST_ERROR_SUBSTRINGS)) { - return ErrorType.CLIENT_REQUEST_ERROR.toString(); - } - if (nameContains(exceptionName, CLIENT_UNKNOWN_ERROR_SUBSTRINGS)) { - return ErrorType.CLIENT_UNKNOWN_ERROR.toString(); - } - - return null; - } - - private static boolean isInstanceof(Throwable error, List> classes) { - for (Class clazz : classes) { - if (clazz.isInstance(error)) { - return true; - } - } - return false; - } - - private static boolean nameContains(String name, List substrings) { - for (String sub : substrings) { - if (name.contains(sub)) { - return true; - } - } - return false; + return ErrorTypeUtil.extractErrorType(error); } /** Function to extract the status of the error as a string */ 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 9439327dbc..e987ecf34d 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 @@ -39,11 +39,12 @@ 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 java.util.concurrent.TimeoutException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -185,12 +186,12 @@ void testAttemptFailed_clientTimeout() { tracer.attemptStarted(new Object(), 1); - tracer.attemptFailedRetriesExhausted(new TimeoutException("timed out")); + tracer.attemptFailedRetriesExhausted(new SocketTimeoutException()); verify(attemptHandle) .addAttribute( ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ObservabilityUtils.ErrorType.CLIENT_TIMEOUT.toString()); + ErrorTypeUtil.ErrorType.CLIENT_TIMEOUT.toString()); verify(attemptHandle).end(); } @@ -205,7 +206,7 @@ void testAttemptFailed_clientConnectionError() { verify(attemptHandle) .addAttribute( ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ObservabilityUtils.ErrorType.CLIENT_CONNECTION_ERROR.toString()); + ErrorTypeUtil.ErrorType.CLIENT_CONNECTION_ERROR.toString()); verify(attemptHandle).end(); } @@ -215,12 +216,12 @@ void testAttemptFailed_clientAuthenticationError() { tracer.attemptStarted(new Object(), 1); - tracer.attemptFailedRetriesExhausted(new CredentialsException()); + tracer.attemptFailedRetriesExhausted(new TestGoogleAuthException()); verify(attemptHandle) .addAttribute( ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ObservabilityUtils.ErrorType.CLIENT_AUTHENTICATION_ERROR.toString()); + ErrorTypeUtil.ErrorType.CLIENT_AUTHENTICATION_ERROR.toString()); verify(attemptHandle).end(); } @@ -230,12 +231,12 @@ void testAttemptFailed_clientResponseDecodeError() { tracer.attemptStarted(new Object(), 1); - tracer.attemptFailedRetriesExhausted(new DecodeException()); + tracer.attemptFailedRetriesExhausted(new JsonSyntaxException("bad json")); verify(attemptHandle) .addAttribute( ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ObservabilityUtils.ErrorType.CLIENT_RESPONSE_DECODE_ERROR.toString()); + ErrorTypeUtil.ErrorType.CLIENT_RESPONSE_DECODE_ERROR.toString()); verify(attemptHandle).end(); } @@ -245,12 +246,12 @@ void testAttemptFailed_clientRedirectError() { tracer.attemptStarted(new Object(), 1); - tracer.attemptFailedRetriesExhausted(new RedirectException()); + tracer.attemptFailedRetriesExhausted(new RedirectException("redirect failed")); verify(attemptHandle) .addAttribute( ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ObservabilityUtils.ErrorType.CLIENT_REDIRECT_ERROR.toString()); + ErrorTypeUtil.ErrorType.CLIENT_REDIRECT_ERROR.toString()); verify(attemptHandle).end(); } @@ -265,7 +266,7 @@ void testAttemptFailed_clientRequestBodyError() { verify(attemptHandle) .addAttribute( ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ObservabilityUtils.ErrorType.CLIENT_REQUEST_BODY_ERROR.toString()); + ErrorTypeUtil.ErrorType.CLIENT_REQUEST_BODY_ERROR.toString()); verify(attemptHandle).end(); } @@ -275,12 +276,12 @@ void testAttemptFailed_clientRequestError() { tracer.attemptStarted(new Object(), 1); - tracer.attemptFailedRetriesExhausted(new RequestException()); + tracer.attemptFailedRetriesExhausted(new IllegalArgumentException()); verify(attemptHandle) .addAttribute( ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ObservabilityUtils.ErrorType.CLIENT_REQUEST_ERROR.toString()); + ErrorTypeUtil.ErrorType.CLIENT_REQUEST_ERROR.toString()); verify(attemptHandle).end(); } @@ -295,7 +296,7 @@ void testAttemptFailed_clientUnknownError() { verify(attemptHandle) .addAttribute( ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ObservabilityUtils.ErrorType.CLIENT_UNKNOWN_ERROR.toString()); + ErrorTypeUtil.ErrorType.CLIENT_UNKNOWN_ERROR.toString()); verify(attemptHandle).end(); } @@ -325,19 +326,19 @@ void testAttemptFailed_internalFallback() { verify(attemptHandle) .addAttribute( ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ObservabilityUtils.ErrorType.INTERNAL.toString()); + ErrorTypeUtil.ErrorType.INTERNAL.toString()); verify(attemptHandle).end(); } - private static class CredentialsException extends RuntimeException {} + private static class TestGoogleAuthException extends RuntimeException {} - private static class DecodeException extends RuntimeException {} - - private static class RedirectException extends RuntimeException {} + private static class RedirectException extends RuntimeException { + public RedirectException(String message) { + super(message); + } + } private static class RequestBodyException extends RuntimeException {} - private static class RequestException extends RuntimeException {} - private static class UnknownClientException extends RuntimeException {} } From d3cd69d600020178be22733a74584aaeab53403f Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Tue, 17 Mar 2026 14:46:59 -0400 Subject: [PATCH 06/16] fix: handle request body and unknown error --- .../com/google/api/gax/tracing/ErrorTypeUtil.java | 15 ++++++++++----- .../google/api/gax/tracing/SpanTracerTest.java | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) 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 index 1e5c11ee7a..c930cb355e 100644 --- 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 @@ -88,8 +88,6 @@ public String toString() { *
    • {@code CLIENT_REDIRECT_ERROR}: Problem handling HTTP redirects. *
    • {@code CLIENT_AUTHENTICATION_ERROR}: Error during credential acquisition or * application. - *
    • {@code CLIENT_UNKNOWN_ERROR}: Other unclassified client-side network or protocol - * errors. *
    *
  • 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 @@ -174,13 +172,12 @@ private static String getClientSideError(Throwable error) { if (error instanceof IllegalArgumentException) { // This covers CLIENT_REQUEST_ERROR return ErrorType.CLIENT_REQUEST_ERROR.toString(); } - if (error.getClass().getSimpleName().contains("RequestBodyException")) { + if (isRequestBodyError(error)) { return ErrorType.CLIENT_REQUEST_BODY_ERROR.toString(); } - if (error.getClass().getSimpleName().contains("UnknownClientException")) { + if (isClientUnknownError(error)) { return ErrorType.CLIENT_UNKNOWN_ERROR.toString(); } - return null; } @@ -208,4 +205,12 @@ private static boolean isClientRedirectError(Throwable e) { private static boolean isClientAuthenticationError(Throwable e) { return e.getClass().getName().contains("GoogleAuthException"); } + + private static boolean isRequestBodyError(Throwable e) { + return e.getClass().getName().contains("RestSerializationException"); + } + + private static boolean isClientUnknownError(Throwable e) { + return e.getClass().getName().toLowerCase().contains("unknown"); + } } 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 e987ecf34d..69014be353 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 @@ -261,7 +261,7 @@ void testAttemptFailed_clientRequestBodyError() { tracer.attemptStarted(new Object(), 1); - tracer.attemptFailedRetriesExhausted(new RequestBodyException()); + tracer.attemptFailedRetriesExhausted(new TestRestSerializationException()); verify(attemptHandle) .addAttribute( @@ -338,7 +338,7 @@ public RedirectException(String message) { } } - private static class RequestBodyException extends RuntimeException {} + private static class TestRestSerializationException extends RuntimeException {} private static class UnknownClientException extends RuntimeException {} } From f38a66d30550fc3c208d975aff5c1b8dcebdd783 Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Tue, 17 Mar 2026 14:55:23 -0400 Subject: [PATCH 07/16] fix: improve handling for INTERNAL --- .../google/api/gax/tracing/ErrorTypeUtil.java | 4 +- .../api/gax/tracing/ObservabilityUtils.java | 44 +------------------ .../google/api/gax/tracing/SpanTracer.java | 7 +-- .../api/gax/tracing/SpanTracerTest.java | 17 +++++++ 4 files changed, 24 insertions(+), 48 deletions(-) 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 index c930cb355e..6e3691ef7c 100644 --- 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 @@ -88,6 +88,7 @@ public String toString() { *
  • {@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. * *
  • 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 @@ -103,7 +104,8 @@ public String toString() { */ public static String extractErrorType(@Nullable Throwable error) { if (error == null) { - return null; + // No information about the error; we default to INTERNAL. + return ErrorType.INTERNAL.toString(); } // 1. & 2. Extract error info reason or server status code 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 ca5a58e496..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 @@ -41,48 +41,8 @@ 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. - * - *

    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 - * ErrorTypeUtil.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}: Other unclassified client-side network or protocol - * errors. - *
      - *
    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}. + * 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); 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 122e2a66f3..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 @@ -107,11 +107,8 @@ public void attemptPermanentFailure(Throwable error) { private void recordErrorAndEndAttempt(Throwable error) { if (attemptHandle != null) { - if (error != null) { - attemptHandle.addAttribute( - ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ObservabilityUtils.extractErrorType(error)); - } + attemptHandle.addAttribute( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, ObservabilityUtils.extractErrorType(error)); endAttempt(); } } 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 69014be353..8f9e6dd08d 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 @@ -330,6 +330,23 @@ void testAttemptFailed_internalFallback() { 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 TestGoogleAuthException extends RuntimeException {} private static class RedirectException extends RuntimeException { From efbe7bad4106da052f5fa3534c2166267fc3be03 Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Tue, 17 Mar 2026 15:06:09 -0400 Subject: [PATCH 08/16] docs: add javadoc for private methods --- .../google/api/gax/tracing/ErrorTypeUtil.java | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) 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 index 6e3691ef7c..16e3fdc7dd 100644 --- 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 @@ -132,6 +132,13 @@ public static String extractErrorType(@Nullable Throwable error) { 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 @@ -154,6 +161,13 @@ private static String extractFromApiException(ApiException apiException) { 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)) { @@ -171,7 +185,8 @@ private static String getClientSideError(Throwable error) { if (isClientRedirectError(error)) { return ErrorType.CLIENT_REDIRECT_ERROR.toString(); } - if (error instanceof IllegalArgumentException) { // This covers CLIENT_REQUEST_ERROR + // 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)) { @@ -183,10 +198,24 @@ private static String getClientSideError(Throwable error) { 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 e instanceof SocketTimeoutException || e instanceof WatchdogTimeoutException; } + /** + * 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 e instanceof ConnectException || e instanceof UnknownHostException @@ -194,24 +223,61 @@ private static boolean isClientConnectionError(Throwable e) { || e instanceof UnresolvedAddressException; } + /** + * 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 e.getClass().getName().contains("Json") || e.getClass().getName().contains("Gson") || (e.getCause() != null && e.getCause().getClass().getName().contains("Gson")); } + /** + * 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 e.getClass().getName().contains("GoogleAuthException"); } + /** + * 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 e.getClass().getName().contains("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"); } From 75fe9caf1cac95c1996af9e14f480f5068549f13 Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Tue, 17 Mar 2026 15:32:09 -0400 Subject: [PATCH 09/16] fix: use recursive exception logic --- .../google/api/gax/tracing/ErrorTypeUtil.java | 61 ++++++++++++++----- .../api/gax/tracing/SpanTracerTest.java | 34 ----------- 2 files changed, 45 insertions(+), 50 deletions(-) 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 index 16e3fdc7dd..cbe4e4a580 100644 --- 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 @@ -30,14 +30,10 @@ package com.google.api.gax.tracing; import com.google.api.gax.rpc.ApiException; -import com.google.api.gax.rpc.WatchdogTimeoutException; import com.google.common.base.Strings; -import java.net.ConnectException; -import java.net.SocketTimeoutException; -import java.net.UnknownHostException; -import java.nio.channels.UnresolvedAddressException; +import com.google.common.collect.ImmutableSet; +import java.util.Set; import javax.annotation.Nullable; -import javax.net.ssl.SSLHandshakeException; public class ErrorTypeUtil { @@ -58,6 +54,26 @@ public String toString() { } } + 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. @@ -206,7 +222,7 @@ private static String getClientSideError(Throwable error) { * @return true if the error is a client timeout, false otherwise. */ private static boolean isClientTimeout(Throwable e) { - return e instanceof SocketTimeoutException || e instanceof WatchdogTimeoutException; + return hasErrorNameInCauseChain(e, CLIENT_TIMEOUT_EXCEPTION_CLASS_NAMES); } /** @@ -217,10 +233,7 @@ private static boolean isClientTimeout(Throwable e) { * @return true if the error is a client connection error, false otherwise. */ private static boolean isClientConnectionError(Throwable e) { - return e instanceof ConnectException - || e instanceof UnknownHostException - || e instanceof SSLHandshakeException - || e instanceof UnresolvedAddressException; + return hasErrorNameInCauseChain(e, CLIENT_CONNECTION_EXCEPTIONS); } /** @@ -231,9 +244,7 @@ private static boolean isClientConnectionError(Throwable e) { * @return true if the error is a client response decode error, false otherwise. */ private static boolean isClientResponseDecodeError(Throwable e) { - return e.getClass().getName().contains("Json") - || e.getClass().getName().contains("Gson") - || (e.getCause() != null && e.getCause().getClass().getName().contains("Gson")); + return hasErrorNameInCauseChain(e, JSON_DECODING_EXCEPTION_CLASS_NAMES); } /** @@ -255,7 +266,7 @@ private static boolean isClientRedirectError(Throwable e) { * @return true if the error is a client authentication error, false otherwise. */ private static boolean isClientAuthenticationError(Throwable e) { - return e.getClass().getName().contains("GoogleAuthException"); + return hasErrorNameInCauseChain(e, AUTHENTICATION_EXCEPTION_CLASS_NAMES); } /** @@ -267,7 +278,7 @@ private static boolean isClientAuthenticationError(Throwable e) { * @return true if the error is a client request body error, false otherwise. */ private static boolean isRequestBodyError(Throwable e) { - return e.getClass().getName().contains("RestSerializationException"); + return hasErrorNameInCauseChain(e, ImmutableSet.of("RestSerializationException")); } /** @@ -281,4 +292,22 @@ private static boolean isRequestBodyError(Throwable e) { 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/test/java/com/google/api/gax/tracing/SpanTracerTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/SpanTracerTest.java index 8f9e6dd08d..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 @@ -210,21 +210,6 @@ void testAttemptFailed_clientConnectionError() { verify(attemptHandle).end(); } - @Test - void testAttemptFailed_clientAuthenticationError() { - when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); - - tracer.attemptStarted(new Object(), 1); - - tracer.attemptFailedRetriesExhausted(new TestGoogleAuthException()); - - verify(attemptHandle) - .addAttribute( - ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ErrorTypeUtil.ErrorType.CLIENT_AUTHENTICATION_ERROR.toString()); - verify(attemptHandle).end(); - } - @Test void testAttemptFailed_clientResponseDecodeError() { when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); @@ -255,21 +240,6 @@ void testAttemptFailed_clientRedirectError() { verify(attemptHandle).end(); } - @Test - void testAttemptFailed_clientRequestBodyError() { - when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); - - tracer.attemptStarted(new Object(), 1); - - tracer.attemptFailedRetriesExhausted(new TestRestSerializationException()); - - verify(attemptHandle) - .addAttribute( - ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ErrorTypeUtil.ErrorType.CLIENT_REQUEST_BODY_ERROR.toString()); - verify(attemptHandle).end(); - } - @Test void testAttemptFailed_clientRequestError() { when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); @@ -347,15 +317,11 @@ void testAttemptFailed_internalFallback_nullError() { verify(attemptHandle).end(); } - private static class TestGoogleAuthException extends RuntimeException {} - private static class RedirectException extends RuntimeException { public RedirectException(String message) { super(message); } } - private static class TestRestSerializationException extends RuntimeException {} - private static class UnknownClientException extends RuntimeException {} } From f746253cffc342ad94c1116fad9d3f96417ffa42 Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Wed, 18 Mar 2026 16:18:12 -0400 Subject: [PATCH 10/16] fix: remove unused exceptions --- .../main/java/com/google/api/gax/tracing/ErrorTypeUtil.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 index cbe4e4a580..9858e820fb 100644 --- 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 @@ -55,11 +55,7 @@ public String toString() { } 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"); + ImmutableSet.of("com.google.gson.JsonSyntaxException", "com.google.gson.JsonParseException"); private static final Set AUTHENTICATION_EXCEPTION_CLASS_NAMES = ImmutableSet.of("com.google.auth.oauth2.GoogleAuthException"); From 9b8c0804bbe4c70f8886104a3a1738098c16400e Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Thu, 19 Mar 2026 13:24:25 -0400 Subject: [PATCH 11/16] test: emulate server behavior --- .../google/api/gax/tracing/ErrorTypeUtil.java | 22 ++- .../api/gax/tracing/ErrorTypeUtilTest.java | 185 ++++++++++++++++++ 2 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 gax-java/gax/src/test/java/com/google/api/gax/tracing/ErrorTypeUtilTest.java 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 index 9858e820fb..dcdca0f951 100644 --- 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 @@ -55,20 +55,31 @@ public String toString() { } private static final Set JSON_DECODING_EXCEPTION_CLASS_NAMES = - ImmutableSet.of("com.google.gson.JsonSyntaxException", "com.google.gson.JsonParseException"); + ImmutableSet.of( + "com.google.gson.JsonSyntaxException", + "com.google.gson.JsonParseException", + "com.fasterxml.jackson.core.JsonParseException", + "com.fasterxml.jackson.databind.exc.MismatchedInputException"); private static final Set AUTHENTICATION_EXCEPTION_CLASS_NAMES = - ImmutableSet.of("com.google.auth.oauth2.GoogleAuthException"); + ImmutableSet.of( + "com.google.auth.oauth2.GoogleAuthException", "java.security.GeneralSecurityException"); private static final Set CLIENT_TIMEOUT_EXCEPTION_CLASS_NAMES = ImmutableSet.of( - "java.net.SocketTimeoutException", "com.google.api.gax.rpc.WatchdogTimeoutException"); + "java.net.SocketTimeoutException", + "com.google.api.gax.rpc.WatchdogTimeoutException", + "io.netty.handler.timeout.ReadTimeoutException", + "io.netty.handler.timeout.WriteTimeoutException"); + private static final Set CLIENT_CONNECTION_EXCEPTIONS = ImmutableSet.of( "java.net.ConnectException", "java.net.UnknownHostException", "javax.net.ssl.SSLHandshakeException", - "java.nio.channels.UnresolvedAddressException"); + "java.nio.channels.UnresolvedAddressException", + "java.net.NoRouteToHostException", + "java.net.BindException"); /** * Extracts a low-cardinality string representing the specific classification of the error to be @@ -274,7 +285,8 @@ private static boolean isClientAuthenticationError(Throwable e) { * @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")); + return hasErrorNameInCauseChain( + e, ImmutableSet.of("com.google.api.gax.httpjson.RestSerializationException")); } /** diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/ErrorTypeUtilTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/ErrorTypeUtilTest.java new file mode 100644 index 0000000000..d3d728de17 --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/ErrorTypeUtilTest.java @@ -0,0 +1,185 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; + +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.StatusCode; +import com.google.api.gax.rpc.testing.FakeStatusCode; +import java.net.BindException; +import java.net.ConnectException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLSocketFactory; +import org.junit.jupiter.api.Test; + +class ErrorTypeUtilTest { + + @Test + void testExtractErrorType_null() { + assertThat(ErrorTypeUtil.extractErrorType(null)) + .isEqualTo(ErrorTypeUtil.ErrorType.INTERNAL.toString()); + } + + @Test + void testExtractErrorType_apiException_noReason() { + ApiException exception = + new ApiException( + "fake_error", null, new FakeStatusCode(StatusCode.Code.INVALID_ARGUMENT), false); + assertThat(ErrorTypeUtil.extractErrorType(exception)) + .isEqualTo(StatusCode.Code.INVALID_ARGUMENT.toString()); + } + + @Test + void testExtractErrorType_realSocketTimeoutException() throws Exception { + try (ServerSocket serverSocket = new ServerSocket(0)) { + int port = serverSocket.getLocalPort(); + try (Socket clientSocket = new Socket()) { + clientSocket.connect(new InetSocketAddress("localhost", port), 1000); + clientSocket.setSoTimeout(10); // 10ms read timeout + clientSocket.getInputStream().read(); + org.junit.jupiter.api.Assertions.fail("Expected SocketTimeoutException"); + } catch (Exception e) { + assertThat(e).isInstanceOf(SocketTimeoutException.class); + assertThat(ErrorTypeUtil.extractErrorType(e)) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_TIMEOUT.toString()); + } + } + } + + @Test + void testExtractErrorType_realConnectException() { + try { + try (ServerSocket tempServer = new ServerSocket(0)) { + int freePort = tempServer.getLocalPort(); + tempServer.close(); + new Socket("localhost", freePort); + } + org.junit.jupiter.api.Assertions.fail("Expected ConnectException"); + } catch (Exception e) { + assertThat(e).isInstanceOf(ConnectException.class); + assertThat(ErrorTypeUtil.extractErrorType(e)) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_CONNECTION_ERROR.toString()); + } + } + + @Test + void testExtractErrorType_realUnknownHostException() { + try { + new Socket("this.host.does.not.exist.invalid", 80); + org.junit.jupiter.api.Assertions.fail("Expected UnknownHostException"); + } catch (Exception e) { + assertThat(e).isInstanceOf(UnknownHostException.class); + assertThat(ErrorTypeUtil.extractErrorType(e)) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_CONNECTION_ERROR.toString()); + } + } + + @Test + void testExtractErrorType_realSSLHandshakeException() throws Exception { + // Emulating a reliable SSLHandshakeException (vs a generic SSLException) requires + // complex keystore setups which are brittle. We instantiate it directly here. + assertThat(ErrorTypeUtil.extractErrorType(new SSLHandshakeException("Cert path building failed"))) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_CONNECTION_ERROR.toString()); + } + + @Test + void testExtractErrorType_realBindException() throws Exception { + try (ServerSocket serverSocket1 = new ServerSocket(0)) { + int port = serverSocket1.getLocalPort(); + try (ServerSocket serverSocket2 = new ServerSocket(port)) { + org.junit.jupiter.api.Assertions.fail("Expected BindException"); + } catch (Exception e) { + assertThat(e).isInstanceOf(BindException.class); + assertThat(ErrorTypeUtil.extractErrorType(e)) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_CONNECTION_ERROR.toString()); + } + } + } + + @Test + void testExtractErrorType_clientRequestError() { + assertThat(ErrorTypeUtil.extractErrorType(new IllegalArgumentException())) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_REQUEST_ERROR.toString()); + } + + @Test + void testExtractErrorType_fallbackToSimpleName() { + assertThat(ErrorTypeUtil.extractErrorType(new NullPointerException())) + .isEqualTo("NullPointerException"); + assertThat(ErrorTypeUtil.extractErrorType(new IllegalStateException())) + .isEqualTo("IllegalStateException"); + } + + @Test + void testExtractErrorType_clientResponseDecodeError() { + assertThat(ErrorTypeUtil.extractErrorType(new com.google.gson.JsonSyntaxException("fail"))) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_RESPONSE_DECODE_ERROR.toString()); + } + + @Test + void testExtractErrorType_clientRequestBodyError() { + assertThat(ErrorTypeUtil.extractErrorType(new com.google.api.gax.httpjson.RestSerializationException("fail", null))) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_REQUEST_BODY_ERROR.toString()); + } + + @Test + void testExtractErrorType_nettyTimeouts() { + assertThat(ErrorTypeUtil.extractErrorType(new io.netty.handler.timeout.ReadTimeoutException())) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_TIMEOUT.toString()); + assertThat(ErrorTypeUtil.extractErrorType(new io.netty.handler.timeout.WriteTimeoutException())) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_TIMEOUT.toString()); + } + + @Test + void testExtractErrorType_jacksonDecodingError() { + assertThat(ErrorTypeUtil.extractErrorType(new com.fasterxml.jackson.core.JsonParseException("fail"))) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_RESPONSE_DECODE_ERROR.toString()); + } + + @Test + void testExtractErrorType_otherNetworkErrors() { + assertThat(ErrorTypeUtil.extractErrorType(new java.net.NoRouteToHostException())) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_CONNECTION_ERROR.toString()); + assertThat(ErrorTypeUtil.extractErrorType(new java.net.BindException())) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_CONNECTION_ERROR.toString()); + } + + @Test + void testExtractErrorType_unknownException() { + assertThat(ErrorTypeUtil.extractErrorType(new Exception("Unknown stuff"))) + .isEqualTo("Exception"); + } +} From 56986fb741960df4ecc4045a45ab84a9878c536b Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Thu, 19 Mar 2026 16:07:38 -0400 Subject: [PATCH 12/16] test: add ITs for ErrorType --- .../com/google/api/gax/tracing/ApiTracer.java | 10 + .../google/api/gax/tracing/ErrorTypeUtil.java | 154 +++---- .../{tracing => rpc}/ErrorTypeUtilTest.java | 60 ++- .../api/gax/tracing/SpanTracerTest.java | 16 - .../showcase/v1beta1/it/ITOtelErrorType.java | 420 ++++++++++++++++++ .../showcase/v1beta1/it/ITOtelTracing.java | 68 --- 6 files changed, 518 insertions(+), 210 deletions(-) rename gax-java/gax/src/test/java/com/google/api/gax/{tracing => rpc}/ErrorTypeUtilTest.java (82%) create mode 100644 java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracer.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracer.java index 97f8e017db..003242f3b4 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracer.java +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracer.java @@ -183,6 +183,16 @@ default void responseReceived() {} default void requestSent() {} ; + /** + * Adds an annotation that a streaming request has been sent. + * + * @param requestSize the size of the request in bytes. + */ + default void requestSent(long requestSize) { + requestSent(); + } + ; + /** * Adds an annotation that a batch of writes has been flushed. * 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 index dcdca0f951..e0fc885292 100644 --- 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 @@ -30,14 +30,25 @@ package com.google.api.gax.tracing; import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.DeadlineExceededException; +import com.google.api.gax.rpc.WatchdogTimeoutException; import com.google.common.base.Strings; import com.google.common.collect.ImmutableSet; +import java.io.FileNotFoundException; +import java.net.BindException; +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.nio.channels.UnresolvedAddressException; +import java.security.GeneralSecurityException; import java.util.Set; import javax.annotation.Nullable; +import javax.net.ssl.SSLHandshakeException; public class ErrorTypeUtil { - enum ErrorType { + public enum ErrorType { CLIENT_TIMEOUT, CLIENT_CONNECTION_ERROR, CLIENT_REQUEST_ERROR, @@ -54,32 +65,23 @@ public String toString() { } } - private static final Set JSON_DECODING_EXCEPTION_CLASS_NAMES = - ImmutableSet.of( - "com.google.gson.JsonSyntaxException", - "com.google.gson.JsonParseException", - "com.fasterxml.jackson.core.JsonParseException", - "com.fasterxml.jackson.databind.exc.MismatchedInputException"); - - private static final Set AUTHENTICATION_EXCEPTION_CLASS_NAMES = - ImmutableSet.of( - "com.google.auth.oauth2.GoogleAuthException", "java.security.GeneralSecurityException"); + private static final Set> AUTHENTICATION_EXCEPTION_CLASSES = + ImmutableSet.of(GeneralSecurityException.class, FileNotFoundException.class); - private static final Set CLIENT_TIMEOUT_EXCEPTION_CLASS_NAMES = + private static final Set> CLIENT_TIMEOUT_EXCEPTION_CLASSES = ImmutableSet.of( - "java.net.SocketTimeoutException", - "com.google.api.gax.rpc.WatchdogTimeoutException", - "io.netty.handler.timeout.ReadTimeoutException", - "io.netty.handler.timeout.WriteTimeoutException"); + SocketTimeoutException.class, + WatchdogTimeoutException.class, + DeadlineExceededException.class); - private static final Set CLIENT_CONNECTION_EXCEPTIONS = + private static final Set> CLIENT_CONNECTION_EXCEPTIONS = ImmutableSet.of( - "java.net.ConnectException", - "java.net.UnknownHostException", - "javax.net.ssl.SSLHandshakeException", - "java.nio.channels.UnresolvedAddressException", - "java.net.NoRouteToHostException", - "java.net.BindException"); + ConnectException.class, + UnknownHostException.class, + SSLHandshakeException.class, + UnresolvedAddressException.class, + NoRouteToHostException.class, + BindException.class); /** * Extracts a low-cardinality string representing the specific classification of the error to be @@ -92,26 +94,15 @@ public String toString() { * 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. - *
  • 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"). - *
    *
  • Client-Side Network/Operational Errors: For errors occurring within the client * library or network stack, mapping to specific enum representations from {@link - * ErrorType}: + * ErrorType}. This includes checking the cause chain for diagnostic markers (e.g., {@code + * ConnectException} or {@code SocketTimeoutException}). + *
  • Specific Server Error Code: If no {@code ErrorInfo.reason} is available and it is + * not a client-side failure, but a server error code was received: *
      - *
    • {@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. + *
    • For HTTP: The HTTP status code (e.g., "403", "503"). + *
    • For gRPC: The gRPC status code name (e.g., "PERMISSION_DENIED", "UNAVAILABLE"). *
    *
  • 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 @@ -131,20 +122,28 @@ public static String extractErrorType(@Nullable Throwable error) { return ErrorType.INTERNAL.toString(); } - // 1. & 2. Extract error info reason or server status code + // 1. Extract error info reason (most specific server-side info) if (error instanceof ApiException) { - String errorType = extractFromApiException((ApiException) error); - if (errorType != null) { - return errorType; + String reason = ((ApiException) error).getReason(); + if (!Strings.isNullOrEmpty(reason)) { + return reason; } } - // 3. Attempt client side error + // 2. Attempt client side error (includes checking cause chains) String clientError = getClientSideError(error); if (clientError != null) { return clientError; } + // 3. Extract server status code if available + if (error instanceof ApiException) { + String errorCode = extractServerErrorCode((ApiException) error); + if (errorCode != null) { + return errorCode; + } + } + // 4. Language-specific error type fallback String exceptionName = error.getClass().getSimpleName(); if (!Strings.isNullOrEmpty(exceptionName)) { @@ -156,21 +155,13 @@ public static String extractErrorType(@Nullable Throwable error) { } /** - * Extracts the error type from an ApiException. This method prioritizes the ErrorInfo reason, - * then the transport-specific status code (HTTP or gRPC). + * Extracts the server error code from an ApiException. * - * @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. + * @param apiException The ApiException to extract the error code from. + * @return A string representing the error code, or null if no specific code 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 + private static String extractServerErrorCode(ApiException apiException) { if (apiException.getStatusCode() != null) { Object transportCode = apiException.getStatusCode().getTransportCode(); if (transportCode instanceof Integer) { @@ -202,9 +193,6 @@ private static String getClientSideError(Throwable error) { 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(); } @@ -212,9 +200,6 @@ private static String getClientSideError(Throwable error) { 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(); } @@ -229,7 +214,7 @@ private static String getClientSideError(Throwable error) { * @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); + return hasErrorClassInCauseChain(e, CLIENT_TIMEOUT_EXCEPTION_CLASSES); } /** @@ -240,18 +225,7 @@ private static boolean isClientTimeout(Throwable e) { * @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); + return hasErrorClassInCauseChain(e, CLIENT_CONNECTION_EXCEPTIONS); } /** @@ -273,20 +247,7 @@ private static boolean isClientRedirectError(Throwable e) { * @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("com.google.api.gax.httpjson.RestSerializationException")); + return hasErrorClassInCauseChain(e, AUTHENTICATION_EXCEPTION_CLASSES); } /** @@ -302,17 +263,20 @@ private static boolean isClientUnknownError(Throwable e) { } /** - * Recursively checks the throwable and its cause chain for any of the specified error name. + * Recursively checks the throwable and its cause chain for any of the specified error classes. * * @param t The Throwable to check. - * @param errorClassNames A set of fully qualified class names to check against. + * @param errorClasses A set of class objects 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) { + private static boolean hasErrorClassInCauseChain( + Throwable t, Set> errorClasses) { Throwable current = t; while (current != null) { - if (errorClassNames.contains(current.getClass().getName())) { - return true; + for (Class errorClass : errorClasses) { + if (errorClass.isInstance(current)) { + return true; + } } current = current.getCause(); } diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/ErrorTypeUtilTest.java b/gax-java/gax/src/test/java/com/google/api/gax/rpc/ErrorTypeUtilTest.java similarity index 82% rename from gax-java/gax/src/test/java/com/google/api/gax/tracing/ErrorTypeUtilTest.java rename to gax-java/gax/src/test/java/com/google/api/gax/rpc/ErrorTypeUtilTest.java index d3d728de17..1076faedb0 100644 --- a/gax-java/gax/src/test/java/com/google/api/gax/tracing/ErrorTypeUtilTest.java +++ b/gax-java/gax/src/test/java/com/google/api/gax/rpc/ErrorTypeUtilTest.java @@ -27,22 +27,24 @@ * (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; +package com.google.api.gax.rpc; import static com.google.common.truth.Truth.assertThat; -import com.google.api.gax.rpc.ApiException; -import com.google.api.gax.rpc.StatusCode; import com.google.api.gax.rpc.testing.FakeStatusCode; +import com.google.api.gax.tracing.ErrorTypeUtil; +import java.io.FileNotFoundException; +import java.io.IOException; import java.net.BindException; import java.net.ConnectException; import java.net.InetSocketAddress; +import java.net.NoRouteToHostException; import java.net.ServerSocket; import java.net.Socket; import java.net.SocketTimeoutException; import java.net.UnknownHostException; +import java.security.GeneralSecurityException; import javax.net.ssl.SSLHandshakeException; -import javax.net.ssl.SSLSocketFactory; import org.junit.jupiter.api.Test; class ErrorTypeUtilTest { @@ -129,6 +131,22 @@ void testExtractErrorType_realBindException() throws Exception { } } + @Test + void testExtractErrorType_clientTimeout_others() { + assertThat(ErrorTypeUtil.extractErrorType(new WatchdogTimeoutException("timeout", false))) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_TIMEOUT.toString()); + assertThat(ErrorTypeUtil.extractErrorType(new DeadlineExceededException("timeout", null, new FakeStatusCode(StatusCode.Code.DEADLINE_EXCEEDED), false))) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_TIMEOUT.toString()); + } + + @Test + void testExtractErrorType_clientAuthenticationError() { + assertThat(ErrorTypeUtil.extractErrorType(new GeneralSecurityException("auth fail"))) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_AUTHENTICATION_ERROR.toString()); + assertThat(ErrorTypeUtil.extractErrorType(new FileNotFoundException("key not found"))) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_AUTHENTICATION_ERROR.toString()); + } + @Test void testExtractErrorType_clientRequestError() { assertThat(ErrorTypeUtil.extractErrorType(new IllegalArgumentException())) @@ -144,36 +162,16 @@ void testExtractErrorType_fallbackToSimpleName() { } @Test - void testExtractErrorType_clientResponseDecodeError() { - assertThat(ErrorTypeUtil.extractErrorType(new com.google.gson.JsonSyntaxException("fail"))) - .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_RESPONSE_DECODE_ERROR.toString()); - } - - @Test - void testExtractErrorType_clientRequestBodyError() { - assertThat(ErrorTypeUtil.extractErrorType(new com.google.api.gax.httpjson.RestSerializationException("fail", null))) - .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_REQUEST_BODY_ERROR.toString()); - } - - @Test - void testExtractErrorType_nettyTimeouts() { - assertThat(ErrorTypeUtil.extractErrorType(new io.netty.handler.timeout.ReadTimeoutException())) - .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_TIMEOUT.toString()); - assertThat(ErrorTypeUtil.extractErrorType(new io.netty.handler.timeout.WriteTimeoutException())) - .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_TIMEOUT.toString()); - } - - @Test - void testExtractErrorType_jacksonDecodingError() { - assertThat(ErrorTypeUtil.extractErrorType(new com.fasterxml.jackson.core.JsonParseException("fail"))) - .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_RESPONSE_DECODE_ERROR.toString()); + void testExtractErrorType_otherNetworkErrors() { + assertThat(ErrorTypeUtil.extractErrorType(new NoRouteToHostException())) + .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_CONNECTION_ERROR.toString()); } @Test - void testExtractErrorType_otherNetworkErrors() { - assertThat(ErrorTypeUtil.extractErrorType(new java.net.NoRouteToHostException())) - .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_CONNECTION_ERROR.toString()); - assertThat(ErrorTypeUtil.extractErrorType(new java.net.BindException())) + void testExtractErrorType_causeChainTraversal() { + Exception root = new ConnectException("refused"); + Exception wrapped = new IOException("io fail", root); + assertThat(ErrorTypeUtil.extractErrorType(wrapped)) .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_CONNECTION_ERROR.toString()); } 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 2c2bf3c5c5..e0f4c7aced 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 @@ -39,7 +39,6 @@ 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; @@ -210,21 +209,6 @@ void testAttemptFailed_clientConnectionError() { 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); diff --git a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java new file mode 100644 index 0000000000..4577c2a5b9 --- /dev/null +++ b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java @@ -0,0 +1,420 @@ +/* + * 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.showcase.v1beta1.it; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.api.gax.rpc.DeadlineExceededException; +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.SpanTracerFactory; +import com.google.auth.Credentials; +import com.google.common.collect.ImmutableList; +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.it.util.TestClientInitializer; +import com.google.showcase.v1beta1.stub.EchoStubSettings; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannelBuilder; +import io.grpc.MethodDescriptor; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.security.GeneralSecurityException; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ITOtelErrorType { + private InMemorySpanExporter spanExporter; + private OpenTelemetrySdk openTelemetrySdk; + + @BeforeEach + void setup() { + spanExporter = InMemorySpanExporter.create(); + + SdkTracerProvider tracerProvider = + SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)) + .build(); + + openTelemetrySdk = + OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).buildAndRegisterGlobal(); + } + + @AfterEach + void tearDown() { + if (openTelemetrySdk != null) { + openTelemetrySdk.close(); + } + GlobalOpenTelemetry.resetForTest(); + } + + @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 + } + } + + @Test + void testTracing_clientConnectionError_ConnectException_grpc() throws Exception { + SpanTracerFactory tracingFactory = + new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); + + int port; + try (ServerSocket socket = new ServerSocket(0)) { + port = socket.getLocalPort(); + } // Port is now free but was recently used, likely to be refused. + + EchoSettings grpcEchoSettings = + EchoSettings.newBuilder() + .setTransportChannelProvider( + EchoSettings.defaultGrpcTransportProviderBuilder() + .setChannelConfigurator(ManagedChannelBuilder::usePlaintext) + .build()) + .setEndpoint("localhost:" + port) + .build(); + + EchoStubSettings.Builder echoStubSettingsBuilder = + (EchoStubSettings.Builder) grpcEchoSettings.getStubSettings().toBuilder(); + echoStubSettingsBuilder.setTracerFactory(tracingFactory); + + // Disable retries to fail fast + echoStubSettingsBuilder + .echoSettings() + .setRetrySettings( + echoStubSettingsBuilder.echoSettings().getRetrySettings().toBuilder() + .setMaxAttempts(1) + .build()); + + try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { + assertThrows(UnavailableException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).isNotEmpty(); + + SpanData errorSpan = + spans.stream() + .filter(span -> span.getAttributes().get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) != null) + .findFirst() + .orElseThrow(() -> new AssertionError("Span with error.type not found")); + + assertThat( + errorSpan + .getAttributes() + .get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE))) + .isEqualTo("CLIENT_CONNECTION_ERROR"); + } + } + + @Test + void testTracing_clientConnectionError_UnknownHost_grpc() throws Exception { + SpanTracerFactory tracingFactory = + new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); + + EchoSettings grpcEchoSettings = + EchoSettings.newBuilder() + .setTransportChannelProvider( + EchoSettings.defaultGrpcTransportProviderBuilder() + .setChannelConfigurator(ManagedChannelBuilder::usePlaintext) + .build()) + .setEndpoint("this.is.a.bogus.host.name:7469") + .build(); + + EchoStubSettings.Builder echoStubSettingsBuilder = + (EchoStubSettings.Builder) grpcEchoSettings.getStubSettings().toBuilder(); + echoStubSettingsBuilder.setTracerFactory(tracingFactory); + echoStubSettingsBuilder.echoSettings().setRetrySettings( + echoStubSettingsBuilder.echoSettings().getRetrySettings().toBuilder() + .setMaxAttempts(1) + .build()); + + try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { + assertThrows(UnavailableException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).isNotEmpty(); + + SpanData errorSpan = + spans.stream() + .filter(span -> span.getAttributes().get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) != null) + .findFirst() + .orElseThrow(() -> new AssertionError("Span with error.type not found")); + + assertThat( + errorSpan + .getAttributes() + .get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE))) + .isEqualTo("CLIENT_CONNECTION_ERROR"); + } + } + + @Test + void testTracing_clientTimeout_grpc() throws Exception { + SpanTracerFactory tracingFactory = + new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); + + try (ServerSocket serverSocket = new ServerSocket(0)) { + int port = serverSocket.getLocalPort(); + // Start a thread to accept the connection but do nothing else (causing timeout) + Thread serverThread = new Thread(() -> { + try { + try (Socket ignored = serverSocket.accept()) { + Thread.sleep(1000); + } + } catch (Exception ignored) {} + }); + serverThread.start(); + + EchoSettings grpcEchoSettings = + EchoSettings.newBuilder() + .setTransportChannelProvider( + EchoSettings.defaultGrpcTransportProviderBuilder() + .setChannelConfigurator(ManagedChannelBuilder::usePlaintext) + .build()) + .setEndpoint("localhost:" + port) + .build(); + + EchoStubSettings.Builder echoStubSettingsBuilder = + (EchoStubSettings.Builder) grpcEchoSettings.getStubSettings().toBuilder(); + echoStubSettingsBuilder.setTracerFactory(tracingFactory); + + // Set a very short timeout to trigger CLIENT_TIMEOUT + echoStubSettingsBuilder.echoSettings().setRetrySettings( + echoStubSettingsBuilder.echoSettings().getRetrySettings().toBuilder() + .setTotalTimeoutDuration(Duration.ofMillis(100)) + .setInitialRpcTimeoutDuration(Duration.ofMillis(100)) + .setMaxRpcTimeoutDuration(Duration.ofMillis(100)) + .setMaxAttempts(1) + .build() + ); + + try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { + assertThrows(DeadlineExceededException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).isNotEmpty(); + + SpanData errorSpan = + spans.stream() + .filter(span -> span.getAttributes().get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) != null) + .findFirst() + .orElseThrow(() -> new AssertionError("Span with error.type not found")); + + assertThat( + errorSpan + .getAttributes() + .get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE))) + .isEqualTo("CLIENT_TIMEOUT"); + } finally { + serverThread.join(); + } + } + } + + @Test + void testTracing_clientRequestError_grpc() throws Exception { + SpanTracerFactory tracingFactory = + new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); + + ClientInterceptor interceptor = new ClientInterceptor() { + @Override + public ClientCall interceptCall(MethodDescriptor method, CallOptions callOptions, Channel next) { + throw new IllegalArgumentException("Mock request error"); + } + }; + + EchoSettings grpcEchoSettings = + EchoSettings.newBuilder() + .setTransportChannelProvider( + EchoSettings.defaultGrpcTransportProviderBuilder() + .setChannelConfigurator(ManagedChannelBuilder::usePlaintext) + .setInterceptorProvider(() -> ImmutableList.of(interceptor)) + .build()) + .setEndpoint(TestClientInitializer.DEFAULT_GRPC_ENDPOINT) + .build(); + + EchoStubSettings.Builder echoStubSettingsBuilder = + (EchoStubSettings.Builder) grpcEchoSettings.getStubSettings().toBuilder(); + echoStubSettingsBuilder.setTracerFactory(tracingFactory); + + try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { + assertThrows(IllegalArgumentException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).isNotEmpty(); + + SpanData errorSpan = + spans.stream() + .filter(span -> span.getAttributes().get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) != null) + .findFirst() + .orElseThrow(() -> new AssertionError("Span with error.type not found")); + + assertThat( + errorSpan + .getAttributes() + .get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE))) + .isEqualTo("CLIENT_REQUEST_ERROR"); + } + } + + @Test + void testTracing_clientAuthenticationError_grpc() throws Exception { + SpanTracerFactory tracingFactory = + new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); + + Credentials credentials = new Credentials() { + @Override + public String getAuthenticationType() { return "mock"; } + @Override + public Map> getRequestMetadata(URI uri) throws IOException { + throw new IOException("Mock auth failure", new GeneralSecurityException("Root cause")); + } + @Override + public boolean hasRequestMetadata() { return true; } + @Override + public boolean hasRequestMetadataOnly() { return true; } + @Override + public void refresh() throws IOException {} + }; + + EchoSettings grpcEchoSettings = + EchoSettings.newBuilder() + .setCredentialsProvider(FixedCredentialsProvider.create(credentials)) + .setTransportChannelProvider( + EchoSettings.defaultGrpcTransportProviderBuilder() + .setChannelConfigurator(ManagedChannelBuilder::usePlaintext) + .build()) + .setEndpoint(TestClientInitializer.DEFAULT_GRPC_ENDPOINT) + .build(); + + EchoStubSettings.Builder echoStubSettingsBuilder = + (EchoStubSettings.Builder) grpcEchoSettings.getStubSettings().toBuilder(); + echoStubSettingsBuilder.setTracerFactory(tracingFactory); + + try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { + assertThrows(Exception.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).isNotEmpty(); + + SpanData errorSpan = + spans.stream() + .filter(span -> span.getAttributes().get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) != null) + .findFirst() + .orElseThrow(() -> new AssertionError("Span with error.type not found")); + + assertThat( + errorSpan + .getAttributes() + .get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE))) + .isEqualTo("CLIENT_AUTHENTICATION_ERROR"); + } + } +} 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 d2290b4fc4..18594429a0 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,15 +31,11 @@ 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; @@ -189,68 +185,4 @@ 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 - } - } } From fa5b594ce67ccb64a058d10fac9a6ea9fddf0108 Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Thu, 19 Mar 2026 16:31:51 -0400 Subject: [PATCH 13/16] test: use one test per exception --- .../showcase/v1beta1/it/ITOtelErrorType.java | 305 +++++++++--------- 1 file changed, 150 insertions(+), 155 deletions(-) diff --git a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java index 4577c2a5b9..14f8fff428 100644 --- a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java +++ b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java @@ -61,14 +61,21 @@ import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.data.SpanData; import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.io.FileNotFoundException; import java.io.IOException; +import java.net.BindException; +import java.net.ConnectException; +import java.net.NoRouteToHostException; import java.net.ServerSocket; import java.net.Socket; +import java.net.SocketTimeoutException; import java.net.URI; +import java.nio.channels.UnresolvedAddressException; import java.security.GeneralSecurityException; import java.time.Duration; import java.util.List; import java.util.Map; +import javax.net.ssl.SSLHandshakeException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -98,6 +105,63 @@ void tearDown() { GlobalOpenTelemetry.resetForTest(); } + private void verifyErrorTypeAttribute(String expectedErrorType) { + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).isNotEmpty(); + + SpanData errorSpan = + spans.stream() + .filter( + span -> + span.getAttributes() + .get( + AttributeKey.stringKey( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) + != null) + .findFirst() + .orElseThrow(() -> new AssertionError("Span with error.type not found")); + + assertThat( + errorSpan + .getAttributes() + .get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE))) + .isEqualTo(expectedErrorType); + } + + private EchoClient createInterceptorClient(Throwable toThrow) throws IOException { + SpanTracerFactory tracingFactory = + new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); + + ClientInterceptor interceptor = + new ClientInterceptor() { + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + if (toThrow instanceof RuntimeException) { + throw (RuntimeException) toThrow; + } else { + throw new RuntimeException(toThrow); + } + } + }; + + EchoSettings grpcEchoSettings = + EchoSettings.newBuilder() + .setTransportChannelProvider( + EchoSettings.defaultGrpcTransportProviderBuilder() + .setChannelConfigurator(ManagedChannelBuilder::usePlaintext) + .setInterceptorProvider(() -> ImmutableList.of(interceptor)) + .build()) + .setEndpoint(TestClientInitializer.DEFAULT_GRPC_ENDPOINT) + .build(); + + EchoStubSettings.Builder echoStubSettingsBuilder = + (EchoStubSettings.Builder) grpcEchoSettings.getStubSettings().toBuilder(); + echoStubSettingsBuilder.setTracerFactory(tracingFactory); + + return EchoClient.create(echoStubSettingsBuilder.build().createStub()); + } + @Test void testTracing_failedEcho_grpc_recordsErrorType() throws Exception { SpanTracerFactory tracingFactory = @@ -112,21 +176,7 @@ void testTracing_failedEcho_grpc_recordsErrorType() throws Exception { .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"); + verifyErrorTypeAttribute("UNAVAILABLE"); } } @@ -144,34 +194,19 @@ void testTracing_failedEcho_httpjson_recordsErrorType() throws Exception { .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 + verifyErrorTypeAttribute("503"); } } @Test void testTracing_clientConnectionError_ConnectException_grpc() throws Exception { - SpanTracerFactory tracingFactory = - new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); - int port; try (ServerSocket socket = new ServerSocket(0)) { port = socket.getLocalPort(); - } // Port is now free but was recently used, likely to be refused. + } + SpanTracerFactory tracingFactory = + new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); EchoSettings grpcEchoSettings = EchoSettings.newBuilder() .setTransportChannelProvider( @@ -184,8 +219,6 @@ void testTracing_clientConnectionError_ConnectException_grpc() throws Exception EchoStubSettings.Builder echoStubSettingsBuilder = (EchoStubSettings.Builder) grpcEchoSettings.getStubSettings().toBuilder(); echoStubSettingsBuilder.setTracerFactory(tracingFactory); - - // Disable retries to fail fast echoStubSettingsBuilder .echoSettings() .setRetrySettings( @@ -195,21 +228,7 @@ void testTracing_clientConnectionError_ConnectException_grpc() throws Exception try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { assertThrows(UnavailableException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); - - List spans = spanExporter.getFinishedSpanItems(); - assertThat(spans).isNotEmpty(); - - SpanData errorSpan = - spans.stream() - .filter(span -> span.getAttributes().get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) != null) - .findFirst() - .orElseThrow(() -> new AssertionError("Span with error.type not found")); - - assertThat( - errorSpan - .getAttributes() - .get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE))) - .isEqualTo("CLIENT_CONNECTION_ERROR"); + verifyErrorTypeAttribute("CLIENT_CONNECTION_ERROR"); } } @@ -217,7 +236,6 @@ void testTracing_clientConnectionError_ConnectException_grpc() throws Exception void testTracing_clientConnectionError_UnknownHost_grpc() throws Exception { SpanTracerFactory tracingFactory = new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); - EchoSettings grpcEchoSettings = EchoSettings.newBuilder() .setTransportChannelProvider( @@ -237,32 +255,57 @@ void testTracing_clientConnectionError_UnknownHost_grpc() throws Exception { try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { assertThrows(UnavailableException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + verifyErrorTypeAttribute("CLIENT_CONNECTION_ERROR"); + } + } - List spans = spanExporter.getFinishedSpanItems(); - assertThat(spans).isNotEmpty(); + @Test + void testTracing_clientConnectionError_SSLHandshakeException_grpc() throws Exception { + try (EchoClient client = createInterceptorClient(new SSLHandshakeException("Mock SSL failure"))) { + assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + verifyErrorTypeAttribute("CLIENT_CONNECTION_ERROR"); + } + } - SpanData errorSpan = - spans.stream() - .filter(span -> span.getAttributes().get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) != null) - .findFirst() - .orElseThrow(() -> new AssertionError("Span with error.type not found")); + @Test + void testTracing_clientConnectionError_UnresolvedAddressException_grpc() throws Exception { + try (EchoClient client = createInterceptorClient(new UnresolvedAddressException())) { + assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + verifyErrorTypeAttribute("CLIENT_CONNECTION_ERROR"); + } + } + + @Test + void testTracing_clientConnectionError_NoRouteToHostException_grpc() throws Exception { + try (EchoClient client = createInterceptorClient(new NoRouteToHostException())) { + assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + verifyErrorTypeAttribute("CLIENT_CONNECTION_ERROR"); + } + } + + @Test + void testTracing_clientConnectionError_BindException_grpc() throws Exception { + try (EchoClient client = createInterceptorClient(new BindException())) { + assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + verifyErrorTypeAttribute("CLIENT_CONNECTION_ERROR"); + } + } - assertThat( - errorSpan - .getAttributes() - .get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE))) - .isEqualTo("CLIENT_CONNECTION_ERROR"); + @Test + void testTracing_clientTimeout_SocketTimeoutException_grpc() throws Exception { + try (EchoClient client = createInterceptorClient(new SocketTimeoutException())) { + assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + verifyErrorTypeAttribute("CLIENT_TIMEOUT"); } } @Test - void testTracing_clientTimeout_grpc() throws Exception { + void testTracing_clientTimeout_DeadlineExceededException_grpc() throws Exception { SpanTracerFactory tracingFactory = new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); try (ServerSocket serverSocket = new ServerSocket(0)) { int port = serverSocket.getLocalPort(); - // Start a thread to accept the connection but do nothing else (causing timeout) Thread serverThread = new Thread(() -> { try { try (Socket ignored = serverSocket.accept()) { @@ -284,8 +327,6 @@ void testTracing_clientTimeout_grpc() throws Exception { EchoStubSettings.Builder echoStubSettingsBuilder = (EchoStubSettings.Builder) grpcEchoSettings.getStubSettings().toBuilder(); echoStubSettingsBuilder.setTracerFactory(tracingFactory); - - // Set a very short timeout to trigger CLIENT_TIMEOUT echoStubSettingsBuilder.echoSettings().setRetrySettings( echoStubSettingsBuilder.echoSettings().getRetrySettings().toBuilder() .setTotalTimeoutDuration(Duration.ofMillis(100)) @@ -297,21 +338,7 @@ void testTracing_clientTimeout_grpc() throws Exception { try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { assertThrows(DeadlineExceededException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); - - List spans = spanExporter.getFinishedSpanItems(); - assertThat(spans).isNotEmpty(); - - SpanData errorSpan = - spans.stream() - .filter(span -> span.getAttributes().get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) != null) - .findFirst() - .orElseThrow(() -> new AssertionError("Span with error.type not found")); - - assertThat( - errorSpan - .getAttributes() - .get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE))) - .isEqualTo("CLIENT_TIMEOUT"); + verifyErrorTypeAttribute("CLIENT_TIMEOUT"); } finally { serverThread.join(); } @@ -319,71 +346,19 @@ void testTracing_clientTimeout_grpc() throws Exception { } @Test - void testTracing_clientRequestError_grpc() throws Exception { - SpanTracerFactory tracingFactory = - new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); - - ClientInterceptor interceptor = new ClientInterceptor() { - @Override - public ClientCall interceptCall(MethodDescriptor method, CallOptions callOptions, Channel next) { - throw new IllegalArgumentException("Mock request error"); - } - }; - - EchoSettings grpcEchoSettings = - EchoSettings.newBuilder() - .setTransportChannelProvider( - EchoSettings.defaultGrpcTransportProviderBuilder() - .setChannelConfigurator(ManagedChannelBuilder::usePlaintext) - .setInterceptorProvider(() -> ImmutableList.of(interceptor)) - .build()) - .setEndpoint(TestClientInitializer.DEFAULT_GRPC_ENDPOINT) - .build(); - - EchoStubSettings.Builder echoStubSettingsBuilder = - (EchoStubSettings.Builder) grpcEchoSettings.getStubSettings().toBuilder(); - echoStubSettingsBuilder.setTracerFactory(tracingFactory); - - try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { - assertThrows(IllegalArgumentException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); - - List spans = spanExporter.getFinishedSpanItems(); - assertThat(spans).isNotEmpty(); - - SpanData errorSpan = - spans.stream() - .filter(span -> span.getAttributes().get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) != null) - .findFirst() - .orElseThrow(() -> new AssertionError("Span with error.type not found")); - - assertThat( - errorSpan - .getAttributes() - .get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE))) - .isEqualTo("CLIENT_REQUEST_ERROR"); - } - } - - @Test - void testTracing_clientAuthenticationError_grpc() throws Exception { - SpanTracerFactory tracingFactory = - new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); - + void testTracing_clientAuthenticationError_GeneralSecurityException_grpc() throws Exception { Credentials credentials = new Credentials() { - @Override - public String getAuthenticationType() { return "mock"; } - @Override - public Map> getRequestMetadata(URI uri) throws IOException { + @Override public String getAuthenticationType() { return "mock"; } + @Override public Map> getRequestMetadata(URI uri) throws IOException { throw new IOException("Mock auth failure", new GeneralSecurityException("Root cause")); } - @Override - public boolean hasRequestMetadata() { return true; } - @Override - public boolean hasRequestMetadataOnly() { return true; } - @Override - public void refresh() throws IOException {} + @Override public boolean hasRequestMetadata() { return true; } + @Override public boolean hasRequestMetadataOnly() { return true; } + @Override public void refresh() throws IOException {} }; + SpanTracerFactory tracingFactory = + new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); EchoSettings grpcEchoSettings = EchoSettings.newBuilder() .setCredentialsProvider(FixedCredentialsProvider.create(credentials)) @@ -400,21 +375,41 @@ public void refresh() throws IOException {} try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { assertThrows(Exception.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + verifyErrorTypeAttribute("CLIENT_AUTHENTICATION_ERROR"); + } + } - List spans = spanExporter.getFinishedSpanItems(); - assertThat(spans).isNotEmpty(); + @Test + void testTracing_clientAuthenticationError_FileNotFoundException_grpc() throws Exception { + try (EchoClient client = createInterceptorClient(new FileNotFoundException("Key not found"))) { + assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + verifyErrorTypeAttribute("CLIENT_AUTHENTICATION_ERROR"); + } + } - SpanData errorSpan = - spans.stream() - .filter(span -> span.getAttributes().get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) != null) - .findFirst() - .orElseThrow(() -> new AssertionError("Span with error.type not found")); + @Test + void testTracing_clientRequestError_IllegalArgumentException_grpc() throws Exception { + try (EchoClient client = createInterceptorClient(new IllegalArgumentException("Mock request error"))) { + assertThrows(IllegalArgumentException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + verifyErrorTypeAttribute("CLIENT_REQUEST_ERROR"); + } + } + + @Test + void testTracing_clientRedirectError_grpc() throws Exception { + try (EchoClient client = createInterceptorClient(new RuntimeException("Too many redirects"))) { + assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + verifyErrorTypeAttribute("CLIENT_REDIRECT_ERROR"); + } + } - assertThat( - errorSpan - .getAttributes() - .get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE))) - .isEqualTo("CLIENT_AUTHENTICATION_ERROR"); + @Test + void testTracing_clientUnknownError_grpc() throws Exception { + // Creating a custom exception class whose name contains "Unknown" + class MyUnknownException extends RuntimeException {} + try (EchoClient client = createInterceptorClient(new MyUnknownException())) { + assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + verifyErrorTypeAttribute("CLIENT_UNKNOWN_ERROR"); } } } From 0f3344548f2cb3e06a0bc8850b5eda71088d1add Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Tue, 24 Mar 2026 10:41:02 -0400 Subject: [PATCH 14/16] fix(tracing): align ErrorType mapping with updated specification - Removed heuristic exception mappings for CLIENT_REDIRECT_ERROR and CLIENT_UNKNOWN_ERROR to fall back to simple class name - Refined mapping for CLIENT_AUTHENTICATION_ERROR by removing FileNotFoundException - Added unmapped placeholders to ErrorType enum - Updated unit and integration tests to verify new fallback mechanisms --- .../google/api/gax/tracing/ErrorTypeUtil.java | 43 +++---------------- .../google/api/gax/rpc/ErrorTypeUtilTest.java | 27 +++++++++--- .../api/gax/tracing/SpanTracerTest.java | 8 +--- .../showcase/v1beta1/it/ITOtelErrorType.java | 23 ++++++++-- 4 files changed, 47 insertions(+), 54 deletions(-) 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 index e0fc885292..37af933159 100644 --- 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 @@ -34,7 +34,6 @@ import com.google.api.gax.rpc.WatchdogTimeoutException; import com.google.common.base.Strings; import com.google.common.collect.ImmutableSet; -import java.io.FileNotFoundException; import java.net.BindException; import java.net.ConnectException; import java.net.NoRouteToHostException; @@ -52,10 +51,14 @@ public enum ErrorType { CLIENT_TIMEOUT, CLIENT_CONNECTION_ERROR, CLIENT_REQUEST_ERROR, + /** Placeholder for potential future request body errors. */ CLIENT_REQUEST_BODY_ERROR, + /** Placeholder for potential future response decode errors. */ CLIENT_RESPONSE_DECODE_ERROR, + /** Placeholder for potential future redirect errors. */ CLIENT_REDIRECT_ERROR, CLIENT_AUTHENTICATION_ERROR, + /** Placeholder for potential future unknown errors. */ CLIENT_UNKNOWN_ERROR, INTERNAL; @@ -66,7 +69,7 @@ public String toString() { } private static final Set> AUTHENTICATION_EXCEPTION_CLASSES = - ImmutableSet.of(GeneralSecurityException.class, FileNotFoundException.class); + ImmutableSet.of(GeneralSecurityException.class); private static final Set> CLIENT_TIMEOUT_EXCEPTION_CLASSES = ImmutableSet.of( @@ -193,16 +196,10 @@ private static String getClientSideError(Throwable error) { if (isClientAuthenticationError(error)) { return ErrorType.CLIENT_AUTHENTICATION_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 (isClientUnknownError(error)) { - return ErrorType.CLIENT_UNKNOWN_ERROR.toString(); - } return null; } @@ -228,40 +225,10 @@ private static boolean isClientConnectionError(Throwable e) { return hasErrorClassInCauseChain(e, CLIENT_CONNECTION_EXCEPTIONS); } - /** - * 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 hasErrorClassInCauseChain(e, AUTHENTICATION_EXCEPTION_CLASSES); } - /** - * 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 classes. * diff --git a/gax-java/gax/src/test/java/com/google/api/gax/rpc/ErrorTypeUtilTest.java b/gax-java/gax/src/test/java/com/google/api/gax/rpc/ErrorTypeUtilTest.java index 1076faedb0..a18a796261 100644 --- a/gax-java/gax/src/test/java/com/google/api/gax/rpc/ErrorTypeUtilTest.java +++ b/gax-java/gax/src/test/java/com/google/api/gax/rpc/ErrorTypeUtilTest.java @@ -33,7 +33,6 @@ import com.google.api.gax.rpc.testing.FakeStatusCode; import com.google.api.gax.tracing.ErrorTypeUtil; -import java.io.FileNotFoundException; import java.io.IOException; import java.net.BindException; import java.net.ConnectException; @@ -113,7 +112,8 @@ void testExtractErrorType_realUnknownHostException() { void testExtractErrorType_realSSLHandshakeException() throws Exception { // Emulating a reliable SSLHandshakeException (vs a generic SSLException) requires // complex keystore setups which are brittle. We instantiate it directly here. - assertThat(ErrorTypeUtil.extractErrorType(new SSLHandshakeException("Cert path building failed"))) + assertThat( + ErrorTypeUtil.extractErrorType(new SSLHandshakeException("Cert path building failed"))) .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_CONNECTION_ERROR.toString()); } @@ -135,7 +135,10 @@ void testExtractErrorType_realBindException() throws Exception { void testExtractErrorType_clientTimeout_others() { assertThat(ErrorTypeUtil.extractErrorType(new WatchdogTimeoutException("timeout", false))) .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_TIMEOUT.toString()); - assertThat(ErrorTypeUtil.extractErrorType(new DeadlineExceededException("timeout", null, new FakeStatusCode(StatusCode.Code.DEADLINE_EXCEEDED), false))) + assertThat( + ErrorTypeUtil.extractErrorType( + new DeadlineExceededException( + "timeout", null, new FakeStatusCode(StatusCode.Code.DEADLINE_EXCEEDED), false))) .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_TIMEOUT.toString()); } @@ -143,8 +146,6 @@ void testExtractErrorType_clientTimeout_others() { void testExtractErrorType_clientAuthenticationError() { assertThat(ErrorTypeUtil.extractErrorType(new GeneralSecurityException("auth fail"))) .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_AUTHENTICATION_ERROR.toString()); - assertThat(ErrorTypeUtil.extractErrorType(new FileNotFoundException("key not found"))) - .isEqualTo(ErrorTypeUtil.ErrorType.CLIENT_AUTHENTICATION_ERROR.toString()); } @Test @@ -177,7 +178,19 @@ void testExtractErrorType_causeChainTraversal() { @Test void testExtractErrorType_unknownException() { - assertThat(ErrorTypeUtil.extractErrorType(new Exception("Unknown stuff"))) - .isEqualTo("Exception"); + assertThat(ErrorTypeUtil.extractErrorType(new Exception("Unknown stuff"))) + .isEqualTo("Exception"); + } + + @Test + void testExtractErrorType_redirectFallback() { + assertThat(ErrorTypeUtil.extractErrorType(new Exception("redirect"))).isEqualTo("Exception"); + } + + @Test + void testExtractErrorType_unknownClassNameFallback() { + class UnknownClientException extends Exception {} + assertThat(ErrorTypeUtil.extractErrorType(new UnknownClientException())) + .isEqualTo("UnknownClientException"); } } 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 e0f4c7aced..be0105fa82 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 @@ -218,9 +218,7 @@ void testAttemptFailed_clientRedirectError() { tracer.attemptFailedRetriesExhausted(new RedirectException("redirect failed")); verify(attemptHandle) - .addAttribute( - ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ErrorTypeUtil.ErrorType.CLIENT_REDIRECT_ERROR.toString()); + .addAttribute(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, "RedirectException"); verify(attemptHandle).end(); } @@ -248,9 +246,7 @@ void testAttemptFailed_clientUnknownError() { tracer.attemptFailedRetriesExhausted(new UnknownClientException()); verify(attemptHandle) - .addAttribute( - ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, - ErrorTypeUtil.ErrorType.CLIENT_UNKNOWN_ERROR.toString()); + .addAttribute(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, "UnknownClientException"); verify(attemptHandle).end(); } diff --git a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java index 14f8fff428..b2e8a300b3 100644 --- a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java +++ b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java @@ -34,6 +34,7 @@ import static org.junit.Assert.assertThrows; import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.api.gax.httpjson.RestSerializationException; import com.google.api.gax.rpc.DeadlineExceededException; import com.google.api.gax.rpc.StatusCode.Code; import com.google.api.gax.rpc.UnavailableException; @@ -383,7 +384,9 @@ void testTracing_clientAuthenticationError_GeneralSecurityException_grpc() throw void testTracing_clientAuthenticationError_FileNotFoundException_grpc() throws Exception { try (EchoClient client = createInterceptorClient(new FileNotFoundException("Key not found"))) { assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); - verifyErrorTypeAttribute("CLIENT_AUTHENTICATION_ERROR"); + // Wrapping non-RuntimeExceptions in RuntimeException during interceptCall() + // means the simple class name of the exception being recorded is "RuntimeException" + verifyErrorTypeAttribute("RuntimeException"); } } @@ -399,7 +402,9 @@ void testTracing_clientRequestError_IllegalArgumentException_grpc() throws Excep void testTracing_clientRedirectError_grpc() throws Exception { try (EchoClient client = createInterceptorClient(new RuntimeException("Too many redirects"))) { assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); - verifyErrorTypeAttribute("CLIENT_REDIRECT_ERROR"); + // Heuristic mapping of "redirect" in message has been removed. + // Expected result is now the simple class name of the exception. + verifyErrorTypeAttribute("RuntimeException"); } } @@ -409,7 +414,19 @@ void testTracing_clientUnknownError_grpc() throws Exception { class MyUnknownException extends RuntimeException {} try (EchoClient client = createInterceptorClient(new MyUnknownException())) { assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); - verifyErrorTypeAttribute("CLIENT_UNKNOWN_ERROR"); + // Heuristic mapping of "Unknown" in class name has been removed. + // Expected result is now the simple class name of the exception. + verifyErrorTypeAttribute("MyUnknownException"); + } + } + + @Test + void testTracing_clientRequestError_RestSerializationException_httpjson() throws Exception { + try (EchoClient client = createInterceptorClient(new RestSerializationException("failed to serialize", null))) { + assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + // RestSerializationException is not handled due to ambiguity (serialization vs deserialization). + // Expected result is now its simple class name. + verifyErrorTypeAttribute("RestSerializationException"); } } } From 9313dc159f8cf3a64786cb324a35d8d1e56f69f5 Mon Sep 17 00:00:00 2001 From: Diego Marquez Date: Tue, 24 Mar 2026 12:25:07 -0400 Subject: [PATCH 15/16] feat: Add status.message and exception.type attributes to OpenTelemetry spans Added recursive logic to trace the final underlying exception message and updated relevant telemetry tests to test iteration logic as requested by client. --- .../gax/tracing/ObservabilityAttributes.java | 6 ++++ .../google/api/gax/tracing/SpanTracer.java | 23 +++++++++++++ .../api/gax/tracing/SpanTracerTest.java | 33 +++++++++++++++++++ .../showcase/v1beta1/it/ITOtelErrorType.java | 17 ++++++++++ 4 files changed, 79 insertions(+) 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 07321282ee..d63cdecb9f 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 @@ -79,4 +79,10 @@ public class ObservabilityAttributes { * Code, Client-Side Network/Operational Error (e.g., CLIENT_TIMEOUT) or internal fallback. */ public static final String ERROR_TYPE_ATTRIBUTE = "error.type"; + + /** A human-readable error message, which may include details from the exception or response. */ + public static final String STATUS_MESSAGE_ATTRIBUTE = "status.message"; + + /** If the error was caused by an exception, the exception class name. */ + public static final String EXCEPTION_TYPE_ATTRIBUTE = "exception.type"; } 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 f82a461cb0..517fce1e7d 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 @@ -109,10 +109,33 @@ private void recordErrorAndEndAttempt(Throwable error) { if (attemptHandle != null) { attemptHandle.addAttribute( ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, ObservabilityUtils.extractErrorType(error)); + + if (error != null) { + attemptHandle.addAttribute( + ObservabilityAttributes.EXCEPTION_TYPE_ATTRIBUTE, error.getClass().getName()); + + String errorMessage = extractErrorMessage(error); + if (errorMessage != null) { + attemptHandle.addAttribute( + ObservabilityAttributes.STATUS_MESSAGE_ATTRIBUTE, errorMessage); + } + } + endAttempt(); } } + private String extractErrorMessage(Throwable error) { + Throwable cause = error; + while (cause != null) { + if (cause.getMessage() != null && !cause.getMessage().isEmpty()) { + return cause.getMessage(); + } + cause = cause.getCause(); + } + return null; + } + private void endAttempt() { if (attemptHandle != null) { attemptHandle.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 be0105fa82..7349d6a8e0 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 @@ -297,6 +297,39 @@ void testAttemptFailed_internalFallback_nullError() { verify(attemptHandle).end(); } + @Test + void testAttemptFailed_populatesExceptionTypeAndMessage() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + tracer.attemptStarted(new Object(), 1); + + tracer.attemptFailedRetriesExhausted(new IllegalStateException("custom error message")); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.EXCEPTION_TYPE_ATTRIBUTE, "java.lang.IllegalStateException"); + verify(attemptHandle) + .addAttribute(ObservabilityAttributes.STATUS_MESSAGE_ATTRIBUTE, "custom error message"); + verify(attemptHandle).end(); + } + + @Test + void testAttemptFailed_recursiveMessageSearch() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + tracer.attemptStarted(new Object(), 1); + + Throwable cause = new IllegalArgumentException("root cause message"); + Throwable wrapper = new IllegalStateException("", cause); + + tracer.attemptFailedRetriesExhausted(wrapper); + + verify(attemptHandle) + .addAttribute( + ObservabilityAttributes.EXCEPTION_TYPE_ATTRIBUTE, "java.lang.IllegalStateException"); + verify(attemptHandle) + .addAttribute(ObservabilityAttributes.STATUS_MESSAGE_ATTRIBUTE, "root cause message"); + verify(attemptHandle).end(); + } + private static class RedirectException extends RuntimeException { public RedirectException(String message) { super(message); diff --git a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java index b2e8a300b3..a224b6372a 100644 --- a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java +++ b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java @@ -395,6 +395,23 @@ void testTracing_clientRequestError_IllegalArgumentException_grpc() throws Excep try (EchoClient client = createInterceptorClient(new IllegalArgumentException("Mock request error"))) { assertThrows(IllegalArgumentException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); verifyErrorTypeAttribute("CLIENT_REQUEST_ERROR"); + + SpanData errorSpan = + spanExporter.getFinishedSpanItems().stream() + .filter(span -> span.getAttributes().get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) != null) + .findFirst() + .orElseThrow(() -> new AssertionError("Span with error.type not found")); + + assertThat( + errorSpan + .getAttributes() + .get(AttributeKey.stringKey(ObservabilityAttributes.EXCEPTION_TYPE_ATTRIBUTE))) + .isEqualTo("java.lang.IllegalArgumentException"); + assertThat( + errorSpan + .getAttributes() + .get(AttributeKey.stringKey(ObservabilityAttributes.STATUS_MESSAGE_ATTRIBUTE))) + .isEqualTo("Mock request error"); } } From 6bc03e5e27aae6959399cb7a9403fc19460dcf25 Mon Sep 17 00:00:00 2001 From: cloud-java-bot Date: Tue, 24 Mar 2026 18:43:13 +0000 Subject: [PATCH 16/16] chore: generate libraries at Tue Mar 24 18:41:13 UTC 2026 --- .../showcase/v1beta1/it/ITOtelErrorType.java | 160 ++++++++++++------ 1 file changed, 109 insertions(+), 51 deletions(-) diff --git a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java index a224b6372a..8eaf54fb1b 100644 --- a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java +++ b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelErrorType.java @@ -65,7 +65,6 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.net.BindException; -import java.net.ConnectException; import java.net.NoRouteToHostException; import java.net.ServerSocket; import java.net.Socket; @@ -228,7 +227,9 @@ void testTracing_clientConnectionError_ConnectException_grpc() throws Exception .build()); try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { - assertThrows(UnavailableException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + assertThrows( + UnavailableException.class, + () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); verifyErrorTypeAttribute("CLIENT_CONNECTION_ERROR"); } } @@ -249,21 +250,28 @@ void testTracing_clientConnectionError_UnknownHost_grpc() throws Exception { EchoStubSettings.Builder echoStubSettingsBuilder = (EchoStubSettings.Builder) grpcEchoSettings.getStubSettings().toBuilder(); echoStubSettingsBuilder.setTracerFactory(tracingFactory); - echoStubSettingsBuilder.echoSettings().setRetrySettings( - echoStubSettingsBuilder.echoSettings().getRetrySettings().toBuilder() - .setMaxAttempts(1) - .build()); + echoStubSettingsBuilder + .echoSettings() + .setRetrySettings( + echoStubSettingsBuilder.echoSettings().getRetrySettings().toBuilder() + .setMaxAttempts(1) + .build()); try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { - assertThrows(UnavailableException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + assertThrows( + UnavailableException.class, + () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); verifyErrorTypeAttribute("CLIENT_CONNECTION_ERROR"); } } @Test void testTracing_clientConnectionError_SSLHandshakeException_grpc() throws Exception { - try (EchoClient client = createInterceptorClient(new SSLHandshakeException("Mock SSL failure"))) { - assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + try (EchoClient client = + createInterceptorClient(new SSLHandshakeException("Mock SSL failure"))) { + assertThrows( + RuntimeException.class, + () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); verifyErrorTypeAttribute("CLIENT_CONNECTION_ERROR"); } } @@ -271,7 +279,9 @@ void testTracing_clientConnectionError_SSLHandshakeException_grpc() throws Excep @Test void testTracing_clientConnectionError_UnresolvedAddressException_grpc() throws Exception { try (EchoClient client = createInterceptorClient(new UnresolvedAddressException())) { - assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + assertThrows( + RuntimeException.class, + () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); verifyErrorTypeAttribute("CLIENT_CONNECTION_ERROR"); } } @@ -279,7 +289,9 @@ void testTracing_clientConnectionError_UnresolvedAddressException_grpc() throws @Test void testTracing_clientConnectionError_NoRouteToHostException_grpc() throws Exception { try (EchoClient client = createInterceptorClient(new NoRouteToHostException())) { - assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + assertThrows( + RuntimeException.class, + () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); verifyErrorTypeAttribute("CLIENT_CONNECTION_ERROR"); } } @@ -287,7 +299,9 @@ void testTracing_clientConnectionError_NoRouteToHostException_grpc() throws Exce @Test void testTracing_clientConnectionError_BindException_grpc() throws Exception { try (EchoClient client = createInterceptorClient(new BindException())) { - assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + assertThrows( + RuntimeException.class, + () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); verifyErrorTypeAttribute("CLIENT_CONNECTION_ERROR"); } } @@ -295,7 +309,9 @@ void testTracing_clientConnectionError_BindException_grpc() throws Exception { @Test void testTracing_clientTimeout_SocketTimeoutException_grpc() throws Exception { try (EchoClient client = createInterceptorClient(new SocketTimeoutException())) { - assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + assertThrows( + RuntimeException.class, + () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); verifyErrorTypeAttribute("CLIENT_TIMEOUT"); } } @@ -307,38 +323,44 @@ void testTracing_clientTimeout_DeadlineExceededException_grpc() throws Exception try (ServerSocket serverSocket = new ServerSocket(0)) { int port = serverSocket.getLocalPort(); - Thread serverThread = new Thread(() -> { - try { - try (Socket ignored = serverSocket.accept()) { - Thread.sleep(1000); - } - } catch (Exception ignored) {} - }); + Thread serverThread = + new Thread( + () -> { + try { + try (Socket ignored = serverSocket.accept()) { + Thread.sleep(1000); + } + } catch (Exception ignored) { + } + }); serverThread.start(); EchoSettings grpcEchoSettings = EchoSettings.newBuilder() .setTransportChannelProvider( - EchoSettings.defaultGrpcTransportProviderBuilder() - .setChannelConfigurator(ManagedChannelBuilder::usePlaintext) - .build()) + EchoSettings.defaultGrpcTransportProviderBuilder() + .setChannelConfigurator(ManagedChannelBuilder::usePlaintext) + .build()) .setEndpoint("localhost:" + port) .build(); EchoStubSettings.Builder echoStubSettingsBuilder = (EchoStubSettings.Builder) grpcEchoSettings.getStubSettings().toBuilder(); echoStubSettingsBuilder.setTracerFactory(tracingFactory); - echoStubSettingsBuilder.echoSettings().setRetrySettings( - echoStubSettingsBuilder.echoSettings().getRetrySettings().toBuilder() - .setTotalTimeoutDuration(Duration.ofMillis(100)) - .setInitialRpcTimeoutDuration(Duration.ofMillis(100)) - .setMaxRpcTimeoutDuration(Duration.ofMillis(100)) - .setMaxAttempts(1) - .build() - ); + echoStubSettingsBuilder + .echoSettings() + .setRetrySettings( + echoStubSettingsBuilder.echoSettings().getRetrySettings().toBuilder() + .setTotalTimeoutDuration(Duration.ofMillis(100)) + .setInitialRpcTimeoutDuration(Duration.ofMillis(100)) + .setMaxRpcTimeoutDuration(Duration.ofMillis(100)) + .setMaxAttempts(1) + .build()); try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { - assertThrows(DeadlineExceededException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + assertThrows( + DeadlineExceededException.class, + () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); verifyErrorTypeAttribute("CLIENT_TIMEOUT"); } finally { serverThread.join(); @@ -348,15 +370,31 @@ void testTracing_clientTimeout_DeadlineExceededException_grpc() throws Exception @Test void testTracing_clientAuthenticationError_GeneralSecurityException_grpc() throws Exception { - Credentials credentials = new Credentials() { - @Override public String getAuthenticationType() { return "mock"; } - @Override public Map> getRequestMetadata(URI uri) throws IOException { + Credentials credentials = + new Credentials() { + @Override + public String getAuthenticationType() { + return "mock"; + } + + @Override + public Map> getRequestMetadata(URI uri) throws IOException { throw new IOException("Mock auth failure", new GeneralSecurityException("Root cause")); - } - @Override public boolean hasRequestMetadata() { return true; } - @Override public boolean hasRequestMetadataOnly() { return true; } - @Override public void refresh() throws IOException {} - }; + } + + @Override + public boolean hasRequestMetadata() { + return true; + } + + @Override + public boolean hasRequestMetadataOnly() { + return true; + } + + @Override + public void refresh() throws IOException {} + }; SpanTracerFactory tracingFactory = new SpanTracerFactory(new OpenTelemetryTraceManager(openTelemetrySdk)); @@ -375,7 +413,8 @@ void testTracing_clientAuthenticationError_GeneralSecurityException_grpc() throw echoStubSettingsBuilder.setTracerFactory(tracingFactory); try (EchoClient client = EchoClient.create(echoStubSettingsBuilder.build().createStub())) { - assertThrows(Exception.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + assertThrows( + Exception.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); verifyErrorTypeAttribute("CLIENT_AUTHENTICATION_ERROR"); } } @@ -383,7 +422,9 @@ void testTracing_clientAuthenticationError_GeneralSecurityException_grpc() throw @Test void testTracing_clientAuthenticationError_FileNotFoundException_grpc() throws Exception { try (EchoClient client = createInterceptorClient(new FileNotFoundException("Key not found"))) { - assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + assertThrows( + RuntimeException.class, + () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); // Wrapping non-RuntimeExceptions in RuntimeException during interceptCall() // means the simple class name of the exception being recorded is "RuntimeException" verifyErrorTypeAttribute("RuntimeException"); @@ -392,13 +433,22 @@ void testTracing_clientAuthenticationError_FileNotFoundException_grpc() throws E @Test void testTracing_clientRequestError_IllegalArgumentException_grpc() throws Exception { - try (EchoClient client = createInterceptorClient(new IllegalArgumentException("Mock request error"))) { - assertThrows(IllegalArgumentException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + try (EchoClient client = + createInterceptorClient(new IllegalArgumentException("Mock request error"))) { + assertThrows( + IllegalArgumentException.class, + () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); verifyErrorTypeAttribute("CLIENT_REQUEST_ERROR"); - + SpanData errorSpan = spanExporter.getFinishedSpanItems().stream() - .filter(span -> span.getAttributes().get(AttributeKey.stringKey(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) != null) + .filter( + span -> + span.getAttributes() + .get( + AttributeKey.stringKey( + ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)) + != null) .findFirst() .orElseThrow(() -> new AssertionError("Span with error.type not found")); @@ -418,7 +468,9 @@ void testTracing_clientRequestError_IllegalArgumentException_grpc() throws Excep @Test void testTracing_clientRedirectError_grpc() throws Exception { try (EchoClient client = createInterceptorClient(new RuntimeException("Too many redirects"))) { - assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + assertThrows( + RuntimeException.class, + () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); // Heuristic mapping of "redirect" in message has been removed. // Expected result is now the simple class name of the exception. verifyErrorTypeAttribute("RuntimeException"); @@ -430,7 +482,9 @@ void testTracing_clientUnknownError_grpc() throws Exception { // Creating a custom exception class whose name contains "Unknown" class MyUnknownException extends RuntimeException {} try (EchoClient client = createInterceptorClient(new MyUnknownException())) { - assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + assertThrows( + RuntimeException.class, + () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); // Heuristic mapping of "Unknown" in class name has been removed. // Expected result is now the simple class name of the exception. verifyErrorTypeAttribute("MyUnknownException"); @@ -439,9 +493,13 @@ class MyUnknownException extends RuntimeException {} @Test void testTracing_clientRequestError_RestSerializationException_httpjson() throws Exception { - try (EchoClient client = createInterceptorClient(new RestSerializationException("failed to serialize", null))) { - assertThrows(RuntimeException.class, () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); - // RestSerializationException is not handled due to ambiguity (serialization vs deserialization). + try (EchoClient client = + createInterceptorClient(new RestSerializationException("failed to serialize", null))) { + assertThrows( + RuntimeException.class, + () -> client.echo(EchoRequest.newBuilder().setContent("test").build())); + // RestSerializationException is not handled due to ambiguity (serialization vs + // deserialization). // Expected result is now its simple class name. verifyErrorTypeAttribute("RestSerializationException"); }