Skip to content

Commit 8d23279

Browse files
authored
feat(gax): Actionable Errors Logging API Tracer (#12202)
This PR implements the "Actionable Errors" logging framework in the Java client libraries by hooking directly into the `ApiTracer` framework. By capturing errors at the tracer level (specifically tied to T4 spans), we ensure that developers receive detailed, actionable context (like `google.rpc.ErrorInfo` payloads including reason, domain, and metadata) directly in their SLF4J logs. **Introduced `LoggingTracer` and `LoggingTracerFactory`:** Created a new tracer in `com.google.api.gax.tracing` extending `BaseApiTracer`. If the thrown error is an `ApiException` containing `ErrorInfo`, the tracer builds a context map (flattening the `metadata` map and including `reason`/`domain`) and emits a structured SLF4J log record via `LoggingUtils.logActionableError` at the `DEBUG` level. Testing * Updated `TestServiceProvider` to act as a proper `ILoggerFactory` that caches and returns identical `TestLogger` instances. * Configured `gax-java/gax/pom.xml` to exclude `LoggingTracerTest` from default executions and explicitly bind it to the `envVarTest` profile.
1 parent 630d83d commit 8d23279

File tree

10 files changed

+492
-10
lines changed

10 files changed

+492
-10
lines changed

sdk-platform-java/gax-java/gax/pom.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@
139139
<configuration>
140140
<argLine>@{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"</argLine>
141141
<!-- These tests require an Env Var to be set. Use -PenvVarTest to ONLY run these tests -->
142-
<test>!EndpointContextTest#endpointContextBuild_universeDomainEnvVarSet+endpointContextBuild_multipleUniverseDomainConfigurations_clientSettingsHasPriority,!LoggingEnabledTest</test>
142+
<test>!EndpointContextTest#endpointContextBuild_universeDomainEnvVarSet+endpointContextBuild_multipleUniverseDomainConfigurations_clientSettingsHasPriority,!LoggingEnabledTest,!LoggingTracerTest</test>
143143
</configuration>
144144
</plugin>
145145
</plugins>
@@ -154,7 +154,7 @@
154154
<groupId>org.apache.maven.plugins</groupId>
155155
<artifactId>maven-surefire-plugin</artifactId>
156156
<configuration>
157-
<test>EndpointContextTest#endpointContextBuild_universeDomainEnvVarSet+endpointContextBuild_multipleUniverseDomainConfigurations_clientSettingsHasPriority,LoggingEnabledTest</test>
157+
<test>EndpointContextTest#endpointContextBuild_universeDomainEnvVarSet+endpointContextBuild_multipleUniverseDomainConfigurations_clientSettingsHasPriority,LoggingEnabledTest,LoggingTracerTest</test>
158158
</configuration>
159159
</plugin>
160160
</plugins>

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracerContext.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,9 @@ public Map<String, Object> getAttemptAttributes() {
205205
attributes.put(ObservabilityAttributes.HTTP_URL_TEMPLATE_ATTRIBUTE, httpPathTemplate());
206206
}
207207
}
208+
if (!Strings.isNullOrEmpty(serviceName())) {
209+
attributes.put(ObservabilityAttributes.GCP_CLIENT_SERVICE_ATTRIBUTE, serviceName());
210+
}
208211
if (!Strings.isNullOrEmpty(destinationResourceId())) {
209212
attributes.put(
210213
ObservabilityAttributes.DESTINATION_RESOURCE_ID_ATTRIBUTE, destinationResourceId());
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
31+
package com.google.api.gax.tracing;
32+
33+
import com.google.api.core.BetaApi;
34+
import com.google.api.core.InternalApi;
35+
import com.google.api.gax.logging.LoggerProvider;
36+
import com.google.api.gax.logging.LoggingUtils;
37+
import com.google.common.annotations.VisibleForTesting;
38+
import com.google.rpc.ErrorInfo;
39+
import java.util.HashMap;
40+
import java.util.Map;
41+
42+
/**
43+
* An {@link ApiTracer} that logs actionable errors using {@link LoggingUtils} when an RPC attempt
44+
* fails.
45+
*/
46+
@BetaApi
47+
@InternalApi
48+
class LoggingTracer extends BaseApiTracer {
49+
private static final LoggerProvider LOGGER_PROVIDER =
50+
LoggerProvider.forClazz(LoggingTracer.class);
51+
52+
private final ApiTracerContext apiTracerContext;
53+
54+
LoggingTracer(ApiTracerContext apiTracerContext) {
55+
this.apiTracerContext = apiTracerContext;
56+
}
57+
58+
@Override
59+
public void attemptFailedDuration(Throwable error, java.time.Duration delay) {
60+
recordActionableError(error);
61+
}
62+
63+
@Override
64+
public void attemptFailedRetriesExhausted(Throwable error) {
65+
recordActionableError(error);
66+
}
67+
68+
@Override
69+
public void attemptPermanentFailure(Throwable error) {
70+
recordActionableError(error);
71+
}
72+
73+
@VisibleForTesting
74+
void recordActionableError(Throwable error) {
75+
if (error == null) {
76+
return;
77+
}
78+
79+
Map<String, Object> logContext = new HashMap<>(apiTracerContext.getAttemptAttributes());
80+
81+
logContext.put(
82+
ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE,
83+
ObservabilityUtils.extractStatus(error));
84+
85+
ErrorInfo errorInfo = ObservabilityUtils.extractErrorInfo(error);
86+
if (errorInfo != null) {
87+
if (errorInfo.getReason() != null && !errorInfo.getReason().isEmpty()) {
88+
logContext.put(ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, errorInfo.getReason());
89+
}
90+
if (errorInfo.getDomain() != null && !errorInfo.getDomain().isEmpty()) {
91+
logContext.put(ObservabilityAttributes.ERROR_DOMAIN_ATTRIBUTE, errorInfo.getDomain());
92+
}
93+
if (errorInfo.getMetadataMap() != null) {
94+
for (Map.Entry<String, String> entry : errorInfo.getMetadataMap().entrySet()) {
95+
logContext.put(
96+
ObservabilityAttributes.ERROR_METADATA_ATTRIBUTE_PREFIX + entry.getKey(),
97+
entry.getValue());
98+
}
99+
}
100+
}
101+
102+
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getName();
103+
LoggingUtils.logActionableError(logContext, LOGGER_PROVIDER, message);
104+
}
105+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
31+
package com.google.api.gax.tracing;
32+
33+
import com.google.api.core.BetaApi;
34+
import com.google.api.core.InternalApi;
35+
36+
/** A {@link ApiTracerFactory} that creates instances of {@link LoggingTracer}. */
37+
@BetaApi
38+
@InternalApi
39+
public class LoggingTracerFactory implements ApiTracerFactory {
40+
private final ApiTracerContext apiTracerContext;
41+
42+
public LoggingTracerFactory() {
43+
this(ApiTracerContext.empty());
44+
}
45+
46+
private LoggingTracerFactory(ApiTracerContext apiTracerContext) {
47+
this.apiTracerContext = apiTracerContext;
48+
}
49+
50+
@Override
51+
public ApiTracer newTracer(ApiTracer parent, SpanName spanName, OperationType operationType) {
52+
return new LoggingTracer(apiTracerContext);
53+
}
54+
55+
@Override
56+
public ApiTracer newTracer(ApiTracer parent, ApiTracerContext context) {
57+
return new LoggingTracer(apiTracerContext.merge(context));
58+
}
59+
60+
@Override
61+
public ApiTracerContext getApiTracerContext() {
62+
return apiTracerContext;
63+
}
64+
65+
@Override
66+
public ApiTracerFactory withContext(ApiTracerContext context) {
67+
return new LoggingTracerFactory(apiTracerContext.merge(context));
68+
}
69+
}

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,13 @@ public class ObservabilityAttributes {
9696

9797
/** The destination resource id of the request (e.g. projects/p/locations/l/topics/t). */
9898
public static final String DESTINATION_RESOURCE_ID_ATTRIBUTE = "gcp.resource.destination.id";
99+
100+
/** The type of error that occurred (e.g., from google.rpc.ErrorInfo.reason). */
101+
public static final String ERROR_TYPE_ATTRIBUTE = "error.type";
102+
103+
/** The domain of the error (e.g., from google.rpc.ErrorInfo.domain). */
104+
public static final String ERROR_DOMAIN_ATTRIBUTE = "gcp.errors.domain";
105+
106+
/** The prefix for error metadata (e.g., from google.rpc.ErrorInfo.metadata). */
107+
public static final String ERROR_METADATA_ATTRIBUTE_PREFIX = "gcp.errors.metadata.";
99108
}

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityUtils.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
import com.google.api.gax.rpc.ApiException;
3333
import com.google.api.gax.rpc.StatusCode;
34+
import com.google.rpc.ErrorInfo;
3435
import io.opentelemetry.api.common.Attributes;
3536
import io.opentelemetry.api.common.AttributesBuilder;
3637
import java.util.Map;
@@ -56,6 +57,18 @@ static String extractStatus(@Nullable Throwable error) {
5657
return statusString;
5758
}
5859

60+
/** Function to extract the ErrorInfo payload from the error, if available */
61+
@Nullable
62+
static ErrorInfo extractErrorInfo(@Nullable Throwable error) {
63+
if (error instanceof ApiException) {
64+
ApiException apiException = (ApiException) error;
65+
if (apiException.getErrorDetails() != null) {
66+
return apiException.getErrorDetails().getErrorInfo();
67+
}
68+
}
69+
return null;
70+
}
71+
5972
static Attributes toOtelAttributes(Map<String, Object> attributes) {
6073
AttributesBuilder attributesBuilder = Attributes.builder();
6174
if (attributes == null) {

sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/logging/TestLogger.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,20 @@ public class TestLogger implements Logger, LoggingEventAware {
4848
List<String> messageList = new ArrayList<>();
4949
Level level;
5050

51+
public List<String> getMessageList() {
52+
return messageList;
53+
}
54+
5155
Map<String, Object> keyValuePairsMap = new HashMap<>();
5256

57+
public Map<String, String> getMDCMap() {
58+
return MDCMap;
59+
}
60+
61+
public Map<String, Object> getKeyValuePairsMap() {
62+
return keyValuePairsMap;
63+
}
64+
5365
private String loggerName;
5466
private boolean infoEnabled;
5567
private boolean debugEnabled;

sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/logging/TestServiceProvider.java

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,11 @@
3030

3131
package com.google.api.gax.logging;
3232

33-
import static org.mockito.ArgumentMatchers.anyString;
34-
import static org.mockito.Mockito.mock;
35-
import static org.mockito.Mockito.when;
36-
33+
import java.util.concurrent.ConcurrentHashMap;
34+
import java.util.concurrent.ConcurrentMap;
3735
import org.slf4j.ILoggerFactory;
3836
import org.slf4j.IMarkerFactory;
37+
import org.slf4j.Logger;
3938
import org.slf4j.spi.MDCAdapter;
4039
import org.slf4j.spi.SLF4JServiceProvider;
4140

@@ -45,12 +44,18 @@
4544
*/
4645
public class TestServiceProvider implements SLF4JServiceProvider {
4746

47+
private final ConcurrentMap<String, Logger> loggers = new ConcurrentHashMap<>();
48+
private final ILoggerFactory loggerFactory =
49+
new ILoggerFactory() {
50+
@Override
51+
public Logger getLogger(String name) {
52+
return loggers.computeIfAbsent(name, TestLogger::new);
53+
}
54+
};
55+
4856
@Override
4957
public ILoggerFactory getLoggerFactory() {
50-
// mock behavior when provider present
51-
ILoggerFactory mockLoggerFactory = mock(ILoggerFactory.class);
52-
when(mockLoggerFactory.getLogger(anyString())).thenReturn(new TestLogger("test-logger"));
53-
return mockLoggerFactory;
58+
return loggerFactory;
5459
}
5560

5661
@Override
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
31+
package com.google.api.gax.tracing;
32+
33+
import static org.junit.jupiter.api.Assertions.assertEquals;
34+
import static org.junit.jupiter.api.Assertions.assertNotNull;
35+
import static org.junit.jupiter.api.Assertions.assertTrue;
36+
37+
import org.junit.jupiter.api.Test;
38+
39+
class LoggingTracerFactoryTest {
40+
41+
@Test
42+
void testNewTracer_CreatesLoggingTracer() {
43+
LoggingTracerFactory factory = new LoggingTracerFactory();
44+
ApiTracer tracer =
45+
factory.newTracer(
46+
BaseApiTracer.getInstance(),
47+
SpanName.of("client", "method"),
48+
ApiTracerFactory.OperationType.Unary);
49+
50+
assertNotNull(tracer);
51+
assertTrue(tracer instanceof LoggingTracer);
52+
}
53+
54+
@Test
55+
void testNewTracer_WithContext_CreatesLoggingTracer() {
56+
LoggingTracerFactory factory = new LoggingTracerFactory();
57+
ApiTracer tracer = factory.newTracer(BaseApiTracer.getInstance(), ApiTracerContext.empty());
58+
59+
assertNotNull(tracer);
60+
assertTrue(tracer instanceof LoggingTracer);
61+
}
62+
63+
@Test
64+
void testWithContext_ReturnsNewFactoryWithMergedContext() {
65+
LoggingTracerFactory factory = new LoggingTracerFactory();
66+
ApiTracerContext context =
67+
ApiTracerContext.empty().toBuilder().setServerAddress("address").build();
68+
ApiTracerFactory updatedFactory = factory.withContext(context);
69+
70+
assertNotNull(updatedFactory);
71+
assertTrue(updatedFactory instanceof LoggingTracerFactory);
72+
assertEquals("address", updatedFactory.getApiTracerContext().serverAddress());
73+
}
74+
}

0 commit comments

Comments
 (0)