diff --git a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/ITActionableErrorsLogging.java b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/ITActionableErrorsLogging.java index ed71435fb4ad..81b9e48c08bf 100644 --- a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/ITActionableErrorsLogging.java +++ b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/ITActionableErrorsLogging.java @@ -294,4 +294,114 @@ void testGrpc_clientLevelFailureAttributes() throws Exception { assertThat(kvps).containsEntry(ObservabilityAttributes.RPC_SYSTEM_NAME_ATTRIBUTE, "grpc"); } } + + @Test + void testHttpJson_spanContextCapturedInScopeDuringErrorLog() throws Exception { + io.opentelemetry.sdk.trace.SdkTracerProvider tracerProvider = + io.opentelemetry.sdk.trace.SdkTracerProvider.builder().build(); + io.opentelemetry.api.OpenTelemetry openTelemetry = + io.opentelemetry.sdk.OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build(); + com.google.api.gax.tracing.OpenTelemetryTracingFactory otelFactory = + new com.google.api.gax.tracing.OpenTelemetryTracingFactory(openTelemetry); + com.google.api.gax.tracing.CompositeTracerFactory compositeTracerFactory = + new com.google.api.gax.tracing.CompositeTracerFactory( + com.google.common.collect.ImmutableList.of(otelFactory, new LoggingTracerFactory())); + + EchoSettings httpJsonEchoSettings = + EchoSettings.newHttpJsonBuilder() + .setCredentialsProvider(NoCredentialsProvider.create()) + .setTransportChannelProvider( + EchoSettings.defaultHttpJsonTransportProviderBuilder() + .setHttpTransport( + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest() { + @Override + public LowLevelHttpResponse execute() { + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(409); + response.setContentType("application/json"); + response.setContent( + "{\"error\": {\"code\": 409, \"message\": \"Aborted" + + " failure\"}}"); + return response; + } + }; + } + }) + .setEndpoint(TestClientInitializer.DEFAULT_HTTPJSON_ENDPOINT) + .build()) + .build(); + + com.google.showcase.v1beta1.stub.EchoStubSettings echoStubSettings = + (com.google.showcase.v1beta1.stub.EchoStubSettings) + httpJsonEchoSettings.getStubSettings().toBuilder() + .setTracerFactory(compositeTracerFactory) + .build(); + + try (EchoClient client = EchoClient.create(echoStubSettings.createStub())) { + assertThrows(ApiException.class, () -> client.echo(EchoRequest.newBuilder().build())); + + assertThat(testAppender.events.size()).isAtLeast(1); + assertThat(testAppender.eventSpanContexts.size()).isAtLeast(1); + io.opentelemetry.api.trace.SpanContext capturedContext = + testAppender.eventSpanContexts.get(testAppender.eventSpanContexts.size() - 1); + + // Verify that the log was emitted while the attempt span was made current by LoggingTracer + assertThat(capturedContext.isValid()).isTrue(); + assertThat(capturedContext.getTraceId()).isNotEmpty(); + assertThat(capturedContext.getSpanId()).isNotEmpty(); + + // Verify that after the call, the current thread has no active span leak + assertThat(io.opentelemetry.api.trace.Span.current().getSpanContext().isValid()).isFalse(); + } + } + + @Test + void testGrpc_spanContextCapturedInScopeDuringErrorLog() throws Exception { + io.opentelemetry.sdk.trace.SdkTracerProvider tracerProvider = + io.opentelemetry.sdk.trace.SdkTracerProvider.builder().build(); + io.opentelemetry.api.OpenTelemetry openTelemetry = + io.opentelemetry.sdk.OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build(); + com.google.api.gax.tracing.OpenTelemetryTracingFactory otelFactory = + new com.google.api.gax.tracing.OpenTelemetryTracingFactory(openTelemetry); + com.google.api.gax.tracing.CompositeTracerFactory compositeTracerFactory = + new com.google.api.gax.tracing.CompositeTracerFactory( + com.google.common.collect.ImmutableList.of(otelFactory, new LoggingTracerFactory())); + + EchoSettings grpcEchoSettings = + EchoSettings.newBuilder() + .setCredentialsProvider(NoCredentialsProvider.create()) + .setTransportChannelProvider( + EchoSettings.defaultGrpcTransportProviderBuilder() + .setChannelConfigurator(io.grpc.ManagedChannelBuilder::usePlaintext) + .build()) + .setEndpoint(TestClientInitializer.DEFAULT_GRPC_ENDPOINT) + .build(); + + com.google.showcase.v1beta1.stub.EchoStubSettings echoStubSettings = + (com.google.showcase.v1beta1.stub.EchoStubSettings) + grpcEchoSettings.getStubSettings().toBuilder() + .setTracerFactory(compositeTracerFactory) + .build(); + + try (EchoClient client = EchoClient.create(echoStubSettings.createStub())) { + EchoRequest request = buildErrorRequest(); + assertThrows(ApiException.class, () -> client.echo(request)); + + assertThat(testAppender.events.size()).isAtLeast(1); + assertThat(testAppender.eventSpanContexts.size()).isAtLeast(1); + io.opentelemetry.api.trace.SpanContext capturedContext = + testAppender.eventSpanContexts.get(testAppender.eventSpanContexts.size() - 1); + + // Verify that the log was emitted while the attempt span was made current by LoggingTracer + assertThat(capturedContext.isValid()).isTrue(); + assertThat(capturedContext.getTraceId()).isNotEmpty(); + assertThat(capturedContext.getSpanId()).isNotEmpty(); + + // Verify that after the call, the current thread has no active span leak + assertThat(io.opentelemetry.api.trace.Span.current().getSpanContext().isValid()).isFalse(); + } + } } diff --git a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/TestAppender.java b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/TestAppender.java index 92a69969367e..5a2b861ff376 100644 --- a/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/TestAppender.java +++ b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/TestAppender.java @@ -18,12 +18,15 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.AppenderBase; -import java.util.ArrayList; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; /** Logback appender used to set up tests. */ public class TestAppender extends AppenderBase { - public List events = new ArrayList<>(); + public final List events = new CopyOnWriteArrayList<>(); + public final List eventSpanContexts = new CopyOnWriteArrayList<>(); @Override protected void append(ILoggingEvent eventObject) { @@ -32,9 +35,11 @@ protected void append(ILoggingEvent eventObject) { eventObject.getMDCPropertyMap(); events.add(eventObject); + eventSpanContexts.add(Span.current().getSpanContext()); } public void clearEvents() { events.clear(); + eventSpanContexts.clear(); } } 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 79841eb718d6..960908cfc595 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 @@ -38,6 +38,7 @@ import com.google.common.base.Strings; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -53,6 +54,76 @@ @AutoValue public abstract class ApiTracerContext { + @FunctionalInterface + public interface Scope extends AutoCloseable { + @Override + void close(); + } + + /** + * Holds mutable shared state between sibling {@link ApiTracer} instances belonging to the same + * attempt. + * + *

Note: Preserving {@link SharedContext} across {@link #toBuilder()} allows sibling tracers + * within an attempt or callable chain to share scope state. To prevent concurrent operations or + * hedged requests from overwriting each other's active scope provider, callers initiating + * distinct operations or attempts can supply a fresh {@link SharedContext} via {@link + * #withNewSharedContext()} or {@link Builder#setSharedContext(SharedContext)}. + */ + public static class SharedContext { + static final SharedContext EMPTY = + new SharedContext() { + @Override + public @Nullable Scope openAttemptScope() { + return null; + } + + @Override + public void setAttemptScopeProvider(@Nullable Supplier provider) { + // No-op for empty sentinel + } + + @Override + public boolean compareAndSetAttemptScopeProvider( + @Nullable Supplier expect, @Nullable Supplier update) { + return false; + } + + @Override + public boolean hasAttemptScope() { + return false; + } + }; + + private final AtomicReference> attemptScopeProvider = new AtomicReference<>(); + + public @Nullable Scope openAttemptScope() { + Supplier provider = attemptScopeProvider.get(); + return provider != null ? provider.get() : null; + } + + public void setAttemptScopeProvider(@Nullable Supplier provider) { + attemptScopeProvider.set(provider); + } + + public boolean compareAndSetAttemptScopeProvider( + @Nullable Supplier expect, @Nullable Supplier update) { + return attemptScopeProvider.compareAndSet(expect, update); + } + + public boolean hasAttemptScope() { + return attemptScopeProvider.get() != null; + } + } + + /** + * Returns the shared context for sibling tracers in an attempt. Never returns {@code null}; + * returns an empty {@link SharedContext} if not explicitly set. + * + * @return the shared context + */ + public abstract SharedContext sharedContext(); + public enum Transport { GRPC("grpc"), HTTP("http"); @@ -309,18 +380,47 @@ ApiTracerContext merge(ApiTracerContext other) { if (other.destinationResourceIdSupplier() != null) { builder.setDestinationResourceIdSupplier(other.destinationResourceIdSupplier()); } + SharedContext shared; + if (this.sharedContext() == SharedContext.EMPTY) { + shared = other.sharedContext(); + } else if (other.sharedContext() == SharedContext.EMPTY + || this.sharedContext() == other.sharedContext()) { + shared = this.sharedContext(); + } else { + shared = + other.sharedContext().hasAttemptScope() ? other.sharedContext() : this.sharedContext(); + } + builder.setSharedContext(shared); return builder.build(); } + private static final ApiTracerContext EMPTY = + new AutoValue_ApiTracerContext.Builder() + .setLibraryMetadata(LibraryMetadata.empty()) + .setSharedContext(SharedContext.EMPTY) + .build(); + static ApiTracerContext empty() { - return newBuilder().setLibraryMetadata(LibraryMetadata.empty()).build(); + return EMPTY; } public static Builder newBuilder() { - return new AutoValue_ApiTracerContext.Builder(); + return new AutoValue_ApiTracerContext.Builder().setSharedContext(new SharedContext()); } - abstract Builder toBuilder(); + public Builder toBuilder() { + Builder builder = autoToBuilder(); + if (sharedContext() == SharedContext.EMPTY) { + builder.setSharedContext(new SharedContext()); + } + return builder; + } + + public ApiTracerContext withNewSharedContext() { + return toBuilder().setSharedContext(new SharedContext()).build(); + } + + abstract Builder autoToBuilder(); @AutoValue.Builder public abstract static class Builder { @@ -347,6 +447,8 @@ public abstract static class Builder { abstract Builder setDestinationResourceIdSupplier( @Nullable Supplier destinationResourceIdSupplier); + public abstract Builder setSharedContext(SharedContext sharedContext); + public abstract ApiTracerContext build(); } } diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/CompositeTracerFactory.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/CompositeTracerFactory.java index 62cab0811d6e..295a7cf6b2f1 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/CompositeTracerFactory.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/CompositeTracerFactory.java @@ -58,10 +58,12 @@ public ApiTracer newTracer(ApiTracer parent, SpanName spanName, OperationType op @Override public ApiTracer newTracer(ApiTracer parent, ApiTracerContext tracerContext) { + ApiTracerContext contextToUse = + tracerContext != null ? tracerContext.withNewSharedContext() : null; List children = new ArrayList<>(apiTracerFactories.size()); for (ApiTracerFactory factory : apiTracerFactories) { - children.add(factory.newTracer(parent, tracerContext)); + children.add(factory.newTracer(parent, contextToUse)); } return new CompositeTracer(children); } 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 index 51483728d5bb..7e04b62e4eac 100644 --- 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 @@ -98,6 +98,9 @@ void recordActionableError(Throwable error) { } String message = error.getMessage() != null ? error.getMessage() : error.getClass().getName(); - LoggingUtils.logActionableError(logContext, LOGGER_PROVIDER, message); + try (ApiTracerContext.Scope scope = + apiTracerContext != null ? apiTracerContext.sharedContext().openAttemptScope() : null) { + LoggingUtils.logActionableError(logContext, LOGGER_PROVIDER, message); + } } } diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryTracingTracer.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryTracingTracer.java index cb41da9ccbeb..a89b4b4276e4 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryTracingTracer.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryTracingTracer.java @@ -39,6 +39,7 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.CancellationException; +import java.util.function.Supplier; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -53,6 +54,7 @@ class OpenTelemetryTracingTracer implements ApiTracer { private final String attemptSpanName; private final ApiTracerContext apiTracerContext; private @Nullable Span attemptSpan; + private volatile @Nullable Supplier attemptScopeProvider; @Override public void injectTraceContext(java.util.Map carrier) { @@ -146,6 +148,15 @@ public void attemptStarted(Object request, int attemptNumber) { spanBuilder.setAllAttributes(ObservabilityUtils.toOtelAttributes(currentAttemptAttributes)); this.attemptSpan = spanBuilder.startSpan(); + if (apiTracerContext != null) { + Span span = this.attemptSpan; + this.attemptScopeProvider = + () -> { + io.opentelemetry.context.Scope otelScope = span.makeCurrent(); + return otelScope::close; + }; + apiTracerContext.sharedContext().setAttemptScopeProvider(this.attemptScopeProvider); + } } @Override @@ -153,6 +164,15 @@ public void attemptSucceeded() { recordErrorAndEndAttempt(null); } + private void clearSharedAttemptScope() { + if (apiTracerContext != null && this.attemptScopeProvider != null) { + apiTracerContext + .sharedContext() + .compareAndSetAttemptScopeProvider(this.attemptScopeProvider, null); + this.attemptScopeProvider = null; + } + } + @Override public void responseHeadersReceived(java.util.Map headers) { if (attemptSpan == null) { @@ -240,6 +260,7 @@ private void endAttempt() { attemptSpan.end(); attemptSpan = null; + clearSharedAttemptScope(); } @Override diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/ApiTracerContextTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/ApiTracerContextTest.java index 9c0c01e54408..28af4c09da60 100644 --- a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/ApiTracerContextTest.java +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/ApiTracerContextTest.java @@ -529,4 +529,98 @@ void testDestinationResourceId_evaluatedEveryTime() { assertThat(result.destinationResourceId()).isEqualTo("extracted-id-2"); assertThat(counter[0]).isEqualTo(2); } + + @Test + void testSharedContext_defaultNonNullAndMerge() { + ApiTracerContext context1 = ApiTracerContext.empty(); + assertThat(context1.sharedContext()).isNotNull(); + assertThat(context1.sharedContext().hasAttemptScope()).isFalse(); + + ApiTracerContext.SharedContext shared = new ApiTracerContext.SharedContext(); + ApiTracerContext context2 = + ApiTracerContext.newBuilder() + .setLibraryMetadata(LibraryMetadata.empty()) + .setSharedContext(shared) + .build(); + assertThat(context2.sharedContext()).isSameInstanceAs(shared); + + ApiTracerContext merged = context1.merge(context2); + assertThat(merged.sharedContext()).isSameInstanceAs(shared); + + ApiTracerContext merged2 = context2.merge(context1); + assertThat(merged2.sharedContext()).isSameInstanceAs(shared); + + // Verify active scope provider preservation during merge + java.util.concurrent.atomic.AtomicBoolean scopeClosed = + new java.util.concurrent.atomic.AtomicBoolean(false); + ApiTracerContext.SharedContext activeShared = new ApiTracerContext.SharedContext(); + activeShared.setAttemptScopeProvider(() -> () -> scopeClosed.set(true)); + ApiTracerContext activeContext = + ApiTracerContext.newBuilder() + .setLibraryMetadata(LibraryMetadata.empty()) + .setSharedContext(activeShared) + .build(); + + ApiTracerContext.SharedContext inactiveShared = new ApiTracerContext.SharedContext(); + ApiTracerContext inactiveContext = + ApiTracerContext.newBuilder() + .setLibraryMetadata(LibraryMetadata.empty()) + .setSharedContext(inactiveShared) + .build(); + + // Active context in 'this' should be preserved in the merged context, without mutating the + // original + ApiTracerContext mergedActive1 = activeContext.merge(inactiveContext); + assertThat(mergedActive1.sharedContext()).isSameInstanceAs(activeShared); + assertThat(mergedActive1.sharedContext().hasAttemptScope()).isTrue(); + assertThat(inactiveContext.sharedContext().hasAttemptScope()).isFalse(); + + // Active context in 'other' should be reused in the merged context, without mutating the + // original + ApiTracerContext mergedActive2 = inactiveContext.merge(activeContext); + assertThat(mergedActive2.sharedContext()).isSameInstanceAs(activeShared); + assertThat(mergedActive2.sharedContext().hasAttemptScope()).isTrue(); + assertThat(inactiveContext.sharedContext().hasAttemptScope()).isFalse(); + + // When both contexts share the same non-empty SharedContext instance, reuse it directly + ApiTracerContext context3 = + ApiTracerContext.newBuilder() + .setLibraryMetadata(LibraryMetadata.empty()) + .setSharedContext(shared) + .build(); + ApiTracerContext mergedSame = context2.merge(context3); + assertThat(mergedSame.sharedContext()).isSameInstanceAs(shared); + + // When both contexts have distinct SharedContext and neither has an active attempt span, + // merge prefers 'this' context over 'other' + ApiTracerContext inactiveContext2 = + ApiTracerContext.newBuilder() + .setLibraryMetadata(LibraryMetadata.empty()) + .setSharedContext(new ApiTracerContext.SharedContext()) + .build(); + ApiTracerContext mergedInactive = inactiveContext.merge(inactiveContext2); + assertThat(mergedInactive.sharedContext()).isSameInstanceAs(inactiveShared); + + // toBuilder on an empty context creates a fresh non-empty SharedContext + ApiTracerContext builtFromEmpty = ApiTracerContext.empty().toBuilder().build(); + assertThat(builtFromEmpty.sharedContext()) + .isNotSameInstanceAs(ApiTracerContext.SharedContext.EMPTY); + assertThat(builtFromEmpty.sharedContext().hasAttemptScope()).isFalse(); + + // toBuilder on an initialized context preserves the shared context reference for callable + // wrapping + ApiTracerContext copied = context2.toBuilder().build(); + assertThat(copied.sharedContext()).isSameInstanceAs(shared); + + // withNewSharedContext instantiates a fresh SharedContext to avoid race conditions across + // distinct operations or attempts + ApiTracerContext freshContext = context2.withNewSharedContext(); + assertThat(freshContext.sharedContext()).isNotSameInstanceAs(shared); + assertThat(freshContext.sharedContext().hasAttemptScope()).isFalse(); + + // SharedContext relies on reference equality, so distinct SharedContext instances are not equal + assertThat(new ApiTracerContext.SharedContext()) + .isNotEqualTo(new ApiTracerContext.SharedContext()); + assertThat(context2).isNotEqualTo(freshContext); + } } diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/CompositeTracerFactoryTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/CompositeTracerFactoryTest.java index cf24c3279374..22ec2a67c894 100644 --- a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/CompositeTracerFactoryTest.java +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/CompositeTracerFactoryTest.java @@ -82,16 +82,27 @@ void testNewTracerWithApiTracerContext() { ApiTracer tracer1 = mock(ApiTracer.class, Mockito.withSettings().withoutAnnotations()); ApiTracer tracer2 = mock(ApiTracer.class, Mockito.withSettings().withoutAnnotations()); - when(childFactory1.newTracer(parent, context)).thenReturn(tracer1); - when(childFactory2.newTracer(parent, context)).thenReturn(tracer2); + org.mockito.ArgumentCaptor captor1 = + org.mockito.ArgumentCaptor.forClass(ApiTracerContext.class); + org.mockito.ArgumentCaptor captor2 = + org.mockito.ArgumentCaptor.forClass(ApiTracerContext.class); + + when(childFactory1.newTracer(org.mockito.ArgumentMatchers.eq(parent), captor1.capture())) + .thenReturn(tracer1); + when(childFactory2.newTracer(org.mockito.ArgumentMatchers.eq(parent), captor2.capture())) + .thenReturn(tracer2); ApiTracer compositeTracer = compositeFactory.newTracer(parent, context); // Verify that the composite delegates correctly compositeTracer.operationSucceeded(); - verify(childFactory1).newTracer(parent, context); - verify(childFactory2).newTracer(parent, context); + // Sibling tracers within the composite share the exact same fresh SharedContext instance + com.google.common.truth.Truth.assertThat(captor1.getValue().sharedContext()) + .isSameInstanceAs(captor2.getValue().sharedContext()); + com.google.common.truth.Truth.assertThat(captor1.getValue().sharedContext()) + .isNotSameInstanceAs(context.sharedContext()); + verify(tracer1).operationSucceeded(); verify(tracer2).operationSucceeded(); } @@ -118,16 +129,28 @@ void testWithContext() { ApiTracer tracer1 = mock(ApiTracer.class, Mockito.withSettings().withoutAnnotations()); ApiTracer tracer2 = mock(ApiTracer.class, Mockito.withSettings().withoutAnnotations()); - when(contextualizedFactory1.newTracer(parent, tracerContext)).thenReturn(tracer1); - when(contextualizedFactory2.newTracer(parent, tracerContext)).thenReturn(tracer2); + when(contextualizedFactory1.newTracer( + org.mockito.ArgumentMatchers.eq(parent), + org.mockito.ArgumentMatchers.any(ApiTracerContext.class))) + .thenReturn(tracer1); + when(contextualizedFactory2.newTracer( + org.mockito.ArgumentMatchers.eq(parent), + org.mockito.ArgumentMatchers.any(ApiTracerContext.class))) + .thenReturn(tracer2); ApiTracer compositeTracer = newCompositeFactory.newTracer(parent, tracerContext); compositeTracer.operationSucceeded(); verify(childFactory1).withContext(context); verify(childFactory2).withContext(context); - verify(contextualizedFactory1).newTracer(parent, tracerContext); - verify(contextualizedFactory2).newTracer(parent, tracerContext); + verify(contextualizedFactory1) + .newTracer( + org.mockito.ArgumentMatchers.eq(parent), + org.mockito.ArgumentMatchers.any(ApiTracerContext.class)); + verify(contextualizedFactory2) + .newTracer( + org.mockito.ArgumentMatchers.eq(parent), + org.mockito.ArgumentMatchers.any(ApiTracerContext.class)); verify(tracer1).operationSucceeded(); verify(tracer2).operationSucceeded(); } 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 index d1ba025d3867..84e620b4837a 100644 --- 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 @@ -31,16 +31,19 @@ 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.LibraryMetadata; 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 java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; @@ -214,6 +217,28 @@ void testRecordActionableError_logsHttpStatus() { assertEquals(400L, attributesMap.get(ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE)); } + @Test + void testRecordActionableError_withSharedContextAttemptSpan_activatesSpanDuringLogging() { + AtomicBoolean scopeOpened = new AtomicBoolean(false); + AtomicBoolean scopeClosed = new AtomicBoolean(false); + + ApiTracerContext context = + ApiTracerContext.newBuilder().setLibraryMetadata(LibraryMetadata.empty()).build(); + context + .sharedContext() + .setAttemptScopeProvider( + () -> { + scopeOpened.set(true); + return () -> scopeClosed.set(true); + }); + LoggingTracer tracer = new LoggingTracer(context); + + tracer.recordActionableError(new RuntimeException("test error")); + + assertTrue(scopeOpened.get(), "Scope should have been opened"); + assertTrue(scopeClosed.get(), "Scope should have been closed"); + } + private Map getAttributesMap() { if (!testLogger.getMDCMap().isEmpty()) { return testLogger.getMDCMap();