diff --git a/sdk-platform-java/gax-java/gax/pom.xml b/sdk-platform-java/gax-java/gax/pom.xml index 7a03fad54d29..2feb9c9aba81 100644 --- a/sdk-platform-java/gax-java/gax/pom.xml +++ b/sdk-platform-java/gax-java/gax/pom.xml @@ -139,7 +139,7 @@ @{argLine} -Djava.util.logging.SimpleFormatter.format="%1$tY %1$tl:%1$tM:%1$tS.%1$tL %2$s %4$s: %5$s%6$s%n" - !EndpointContextTest#endpointContextBuild_universeDomainEnvVarSet+endpointContextBuild_multipleUniverseDomainConfigurations_clientSettingsHasPriority,!LoggingEnabledTest + !EndpointContextTest#endpointContextBuild_universeDomainEnvVarSet+endpointContextBuild_multipleUniverseDomainConfigurations_clientSettingsHasPriority,!LoggingEnabledTest,!LoggingTracerTest @@ -154,7 +154,7 @@ org.apache.maven.plugins maven-surefire-plugin - EndpointContextTest#endpointContextBuild_universeDomainEnvVarSet+endpointContextBuild_multipleUniverseDomainConfigurations_clientSettingsHasPriority,LoggingEnabledTest + EndpointContextTest#endpointContextBuild_universeDomainEnvVarSet+endpointContextBuild_multipleUniverseDomainConfigurations_clientSettingsHasPriority,LoggingEnabledTest,LoggingTracerTest diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracerContext.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracerContext.java index d6f7566c7461..49bce86cd1fe 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracerContext.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracerContext.java @@ -205,6 +205,9 @@ public Map getAttemptAttributes() { attributes.put(ObservabilityAttributes.HTTP_URL_TEMPLATE_ATTRIBUTE, httpPathTemplate()); } } + if (!Strings.isNullOrEmpty(serviceName())) { + attributes.put(ObservabilityAttributes.GCP_CLIENT_SERVICE_ATTRIBUTE, serviceName()); + } if (!Strings.isNullOrEmpty(destinationResourceId())) { attributes.put( ObservabilityAttributes.DESTINATION_RESOURCE_ID_ATTRIBUTE, destinationResourceId()); diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/LoggingTracer.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/LoggingTracer.java new file mode 100644 index 000000000000..ec1a39c33337 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/LoggingTracer.java @@ -0,0 +1,105 @@ +/* + * 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.core.BetaApi; +import com.google.api.core.InternalApi; +import com.google.api.gax.logging.LoggerProvider; +import com.google.api.gax.logging.LoggingUtils; +import com.google.common.annotations.VisibleForTesting; +import com.google.rpc.ErrorInfo; +import java.util.HashMap; +import java.util.Map; + +/** + * An {@link ApiTracer} that logs actionable errors using {@link LoggingUtils} when an RPC attempt + * fails. + */ +@BetaApi +@InternalApi +class LoggingTracer extends BaseApiTracer { + private static final LoggerProvider LOGGER_PROVIDER = + LoggerProvider.forClazz(LoggingTracer.class); + + private final ApiTracerContext apiTracerContext; + + LoggingTracer(ApiTracerContext apiTracerContext) { + this.apiTracerContext = apiTracerContext; + } + + @Override + public void attemptFailedDuration(Throwable error, java.time.Duration delay) { + recordActionableError(error); + } + + @Override + public void attemptFailedRetriesExhausted(Throwable error) { + recordActionableError(error); + } + + @Override + public void attemptPermanentFailure(Throwable error) { + recordActionableError(error); + } + + @VisibleForTesting + void recordActionableError(Throwable error) { + if (error == null) { + return; + } + + Map logContext = new HashMap<>(apiTracerContext.getAttemptAttributes()); + + logContext.put( + ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE, + ObservabilityUtils.extractStatus(error)); + + ErrorInfo errorInfo = ObservabilityUtils.extractErrorInfo(error); + if (errorInfo != null) { + if (errorInfo.getReason() != null && !errorInfo.getReason().isEmpty()) { + logContext.put(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, errorInfo.getReason()); + } + if (errorInfo.getDomain() != null && !errorInfo.getDomain().isEmpty()) { + logContext.put(ObservabilityAttributes.ERROR_DOMAIN_ATTRIBUTE, errorInfo.getDomain()); + } + if (errorInfo.getMetadataMap() != null) { + for (Map.Entry entry : errorInfo.getMetadataMap().entrySet()) { + logContext.put( + ObservabilityAttributes.ERROR_METADATA_ATTRIBUTE_PREFIX + entry.getKey(), + entry.getValue()); + } + } + } + + String message = error.getMessage() != null ? error.getMessage() : error.getClass().getName(); + LoggingUtils.logActionableError(logContext, LOGGER_PROVIDER, message); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/LoggingTracerFactory.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/LoggingTracerFactory.java new file mode 100644 index 000000000000..101500aaf53b --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/LoggingTracerFactory.java @@ -0,0 +1,69 @@ +/* + * 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.core.BetaApi; +import com.google.api.core.InternalApi; + +/** A {@link ApiTracerFactory} that creates instances of {@link LoggingTracer}. */ +@BetaApi +@InternalApi +public class LoggingTracerFactory implements ApiTracerFactory { + private final ApiTracerContext apiTracerContext; + + public LoggingTracerFactory() { + this(ApiTracerContext.empty()); + } + + private LoggingTracerFactory(ApiTracerContext apiTracerContext) { + this.apiTracerContext = apiTracerContext; + } + + @Override + public ApiTracer newTracer(ApiTracer parent, SpanName spanName, OperationType operationType) { + return new LoggingTracer(apiTracerContext); + } + + @Override + public ApiTracer newTracer(ApiTracer parent, ApiTracerContext context) { + return new LoggingTracer(apiTracerContext.merge(context)); + } + + @Override + public ApiTracerContext getApiTracerContext() { + return apiTracerContext; + } + + @Override + public ApiTracerFactory withContext(ApiTracerContext context) { + return new LoggingTracerFactory(apiTracerContext.merge(context)); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java index c04c76362df1..2729e147d08a 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java @@ -96,4 +96,13 @@ public class ObservabilityAttributes { /** The destination resource id of the request (e.g. projects/p/locations/l/topics/t). */ public static final String DESTINATION_RESOURCE_ID_ATTRIBUTE = "gcp.resource.destination.id"; + + /** The type of error that occurred (e.g., from google.rpc.ErrorInfo.reason). */ + public static final String ERROR_TYPE_ATTRIBUTE = "error.type"; + + /** The domain of the error (e.g., from google.rpc.ErrorInfo.domain). */ + public static final String ERROR_DOMAIN_ATTRIBUTE = "gcp.errors.domain"; + + /** The prefix for error metadata (e.g., from google.rpc.ErrorInfo.metadata). */ + public static final String ERROR_METADATA_ATTRIBUTE_PREFIX = "gcp.errors.metadata."; } diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityUtils.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityUtils.java index 2487964370ca..f0062e38505a 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityUtils.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityUtils.java @@ -31,6 +31,7 @@ import com.google.api.gax.rpc.ApiException; import com.google.api.gax.rpc.StatusCode; +import com.google.rpc.ErrorInfo; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; import java.util.Map; @@ -56,6 +57,18 @@ static String extractStatus(@Nullable Throwable error) { return statusString; } + /** Function to extract the ErrorInfo payload from the error, if available */ + @Nullable + static ErrorInfo extractErrorInfo(@Nullable Throwable error) { + if (error instanceof ApiException) { + ApiException apiException = (ApiException) error; + if (apiException.getErrorDetails() != null) { + return apiException.getErrorDetails().getErrorInfo(); + } + } + return null; + } + static Attributes toOtelAttributes(Map attributes) { AttributesBuilder attributesBuilder = Attributes.builder(); if (attributes == null) { diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/logging/TestLogger.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/logging/TestLogger.java index 3ee7e513cd8f..36e8d47e4827 100644 --- a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/logging/TestLogger.java +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/logging/TestLogger.java @@ -48,8 +48,20 @@ public class TestLogger implements Logger, LoggingEventAware { List messageList = new ArrayList<>(); Level level; + public List getMessageList() { + return messageList; + } + Map keyValuePairsMap = new HashMap<>(); + public Map getMDCMap() { + return MDCMap; + } + + public Map getKeyValuePairsMap() { + return keyValuePairsMap; + } + private String loggerName; private boolean infoEnabled; private boolean debugEnabled; diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/logging/TestServiceProvider.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/logging/TestServiceProvider.java index 69cc43ba2a96..2b7f0f3b8268 100644 --- a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/logging/TestServiceProvider.java +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/logging/TestServiceProvider.java @@ -30,12 +30,11 @@ package com.google.api.gax.logging; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import org.slf4j.ILoggerFactory; import org.slf4j.IMarkerFactory; +import org.slf4j.Logger; import org.slf4j.spi.MDCAdapter; import org.slf4j.spi.SLF4JServiceProvider; @@ -45,12 +44,18 @@ */ public class TestServiceProvider implements SLF4JServiceProvider { + private final ConcurrentMap loggers = new ConcurrentHashMap<>(); + private final ILoggerFactory loggerFactory = + new ILoggerFactory() { + @Override + public Logger getLogger(String name) { + return loggers.computeIfAbsent(name, TestLogger::new); + } + }; + @Override public ILoggerFactory getLoggerFactory() { - // mock behavior when provider present - ILoggerFactory mockLoggerFactory = mock(ILoggerFactory.class); - when(mockLoggerFactory.getLogger(anyString())).thenReturn(new TestLogger("test-logger")); - return mockLoggerFactory; + return loggerFactory; } @Override diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/LoggingTracerFactoryTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/LoggingTracerFactoryTest.java new file mode 100644 index 000000000000..db36746ab56a --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/LoggingTracerFactoryTest.java @@ -0,0 +1,74 @@ +/* + * 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 org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class LoggingTracerFactoryTest { + + @Test + void testNewTracer_CreatesLoggingTracer() { + LoggingTracerFactory factory = new LoggingTracerFactory(); + ApiTracer tracer = + factory.newTracer( + BaseApiTracer.getInstance(), + SpanName.of("client", "method"), + ApiTracerFactory.OperationType.Unary); + + assertNotNull(tracer); + assertTrue(tracer instanceof LoggingTracer); + } + + @Test + void testNewTracer_WithContext_CreatesLoggingTracer() { + LoggingTracerFactory factory = new LoggingTracerFactory(); + ApiTracer tracer = factory.newTracer(BaseApiTracer.getInstance(), ApiTracerContext.empty()); + + assertNotNull(tracer); + assertTrue(tracer instanceof LoggingTracer); + } + + @Test + void testWithContext_ReturnsNewFactoryWithMergedContext() { + LoggingTracerFactory factory = new LoggingTracerFactory(); + ApiTracerContext context = + ApiTracerContext.empty().toBuilder().setServerAddress("address").build(); + ApiTracerFactory updatedFactory = factory.withContext(context); + + assertNotNull(updatedFactory); + assertTrue(updatedFactory instanceof LoggingTracerFactory); + assertEquals("address", updatedFactory.getApiTracerContext().serverAddress()); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/LoggingTracerTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/LoggingTracerTest.java new file mode 100644 index 000000000000..62e4506a5247 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/LoggingTracerTest.java @@ -0,0 +1,192 @@ +/* + * 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 org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.api.gax.logging.TestLogger; +import com.google.api.gax.rpc.ApiExceptionFactory; +import com.google.api.gax.rpc.ErrorDetails; +import com.google.api.gax.rpc.StatusCode; +import com.google.api.gax.rpc.testing.FakeStatusCode; +import com.google.protobuf.Any; +import com.google.rpc.ErrorInfo; +import java.util.Collections; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +class LoggingTracerTest { + + private TestLogger testLogger; + + @BeforeEach + void setUp() { + testLogger = (TestLogger) LoggerFactory.getLogger(LoggingTracer.class); + testLogger.getMessageList().clear(); + testLogger.getMDCMap().clear(); + testLogger.getKeyValuePairsMap().clear(); + } + + @Test + void testAttemptFailedDuration_LogsError() { + ApiTracerContext context = ApiTracerContext.empty(); + LoggingTracer tracer = new LoggingTracer(context); + + Exception error = new RuntimeException("generic failure duration"); + tracer.attemptFailedDuration(error, java.time.Duration.ZERO); + + assertEquals(1, testLogger.getMessageList().size()); + assertEquals("generic failure duration", testLogger.getMessageList().get(0)); + } + + @Test + void testAttemptFailedRetriesExhausted_LogsError() { + ApiTracerContext context = ApiTracerContext.empty(); + LoggingTracer tracer = new LoggingTracer(context); + + Exception error = new RuntimeException("generic failure retries exhausted"); + tracer.attemptFailedRetriesExhausted(error); + + assertEquals(1, testLogger.getMessageList().size()); + assertEquals("generic failure retries exhausted", testLogger.getMessageList().get(0)); + } + + @Test + void testAttemptPermanentFailure_LogsError() { + ApiTracerContext context = ApiTracerContext.empty(); + LoggingTracer tracer = new LoggingTracer(context); + + Exception error = new RuntimeException("generic permanent failure"); + tracer.attemptPermanentFailure(error); + + assertEquals(1, testLogger.getMessageList().size()); + assertEquals("generic permanent failure", testLogger.getMessageList().get(0)); + } + + @Test + void testRecordActionableError_logsErrorMessage() { + ApiTracerContext context = ApiTracerContext.empty(); + LoggingTracer tracer = new LoggingTracer(context); + + Exception error = new RuntimeException("test error message"); + tracer.recordActionableError(error); + + assertEquals(1, testLogger.getMessageList().size()); + assertEquals("test error message", testLogger.getMessageList().get(0)); + } + + @Test + void testRecordActionableError_logsStatus() { + ApiTracerContext context = ApiTracerContext.empty(); + LoggingTracer tracer = new LoggingTracer(context); + + Exception error = + ApiExceptionFactory.createException( + "test error message", + new RuntimeException("cause"), + FakeStatusCode.of(StatusCode.Code.INVALID_ARGUMENT), + false); + + tracer.recordActionableError(error); + + Map attributesMap = getAttributesMap(); + + assertTrue(attributesMap != null && !attributesMap.isEmpty()); + assertEquals( + "INVALID_ARGUMENT", + attributesMap.get(ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE)); + } + + @Test + void testRecordActionableError_logsAttributes() { + ApiTracerContext context = + ApiTracerContext.empty().toBuilder().setServiceName("test-service").build(); + LoggingTracer tracer = new LoggingTracer(context); + + Exception error = new RuntimeException("generic failure"); + tracer.recordActionableError(error); + + Map attributesMap = getAttributesMap(); + + assertTrue(attributesMap != null && !attributesMap.isEmpty()); + assertEquals( + "test-service", attributesMap.get(ObservabilityAttributes.GCP_CLIENT_SERVICE_ATTRIBUTE)); + } + + @Test + void testRecordActionableError_logsErrorInfo() { + ApiTracerContext context = ApiTracerContext.empty(); + LoggingTracer tracer = new LoggingTracer(context); + + ErrorInfo errorInfo = + ErrorInfo.newBuilder() + .setReason("TEST_REASON") + .setDomain("test.domain.com") + .putMetadata("test_key", "test_value") + .build(); + + ErrorDetails errorDetails = + ErrorDetails.builder() + .setRawErrorMessages(Collections.singletonList(Any.pack(errorInfo))) + .build(); + + Exception error = + ApiExceptionFactory.createException( + "test error message", + new RuntimeException("cause"), + FakeStatusCode.of(StatusCode.Code.INVALID_ARGUMENT), + false, + errorDetails); + + tracer.recordActionableError(error); + + Map attributesMap = getAttributesMap(); + + assertTrue(attributesMap != null && !attributesMap.isEmpty()); + assertEquals("TEST_REASON", attributesMap.get(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE)); + assertEquals( + "test.domain.com", attributesMap.get(ObservabilityAttributes.ERROR_DOMAIN_ATTRIBUTE)); + assertEquals( + "test_value", + attributesMap.get(ObservabilityAttributes.ERROR_METADATA_ATTRIBUTE_PREFIX + "test_key")); + } + + private Map getAttributesMap() { + if (!testLogger.getMDCMap().isEmpty()) { + return testLogger.getMDCMap(); + } else { + return testLogger.getKeyValuePairsMap(); + } + } +}