diff --git a/java-compute/google-cloud-compute/src/test/java/com/google/cloud/compute/v1/integration/ITComputeGoldenSignals.java b/java-compute/google-cloud-compute/src/test/java/com/google/cloud/compute/v1/integration/ITComputeGoldenSignals.java index a42ad49c4be8..d4734a7269d9 100644 --- a/java-compute/google-cloud-compute/src/test/java/com/google/cloud/compute/v1/integration/ITComputeGoldenSignals.java +++ b/java-compute/google-cloud-compute/src/test/java/com/google/cloud/compute/v1/integration/ITComputeGoldenSignals.java @@ -318,17 +318,18 @@ private void fetchAndValidateTrace(String traceId, boolean expectError) throws E continue; } - // Assert RPC span name pattern {method} {url template} - assertThat(span.getName()) - .isEqualTo("GET compute/v1/projects/{project=*}/zones/{zone=*}/instances"); + boolean isOperationSpan = span.getName().equals("google.cloud.compute.v1.Instances.List"); + if (!isOperationSpan) { + // Assert RPC attempt span name pattern {method} {url template} + assertThat(span.getName()) + .isEqualTo("GET compute/v1/projects/{project=*}/zones/{zone=*}/instances"); + } // Compute uses HTTP/REST, so we check for rpc.system.name and other HTTP attributes assertThat(span.getLabelsMap().get(ObservabilityAttributes.RPC_SYSTEM_NAME_ATTRIBUTE)) .isEqualTo("http"); assertThat(span.getLabelsMap().get(ObservabilityAttributes.URL_DOMAIN_ATTRIBUTE)) .isEqualTo("compute.googleapis.com"); - assertThat(span.getLabelsMap().get(ObservabilityAttributes.HTTP_METHOD_ATTRIBUTE)) - .isEqualTo("GET"); assertThat(span.getLabelsMap().get(ObservabilityAttributes.GCP_CLIENT_SERVICE_ATTRIBUTE)) .isEqualTo("compute"); assertThat(span.getLabelsMap().get(ObservabilityAttributes.REPO_ATTRIBUTE)) @@ -345,18 +346,25 @@ private void fetchAndValidateTrace(String traceId, boolean expectError) throws E assertThat(span.getLabelsMap().get(ObservabilityAttributes.DESTINATION_RESOURCE_ID_ATTRIBUTE)) .startsWith(expectedDestinationResource); - String expectedUrl; - if (expectError) { - expectedUrl = - "https://compute.googleapis.com:443/compute/v1/projects/invalid-project-id/zones/us-central1-a/instances"; + if (isOperationSpan) { + assertThat(span.getLabelsMap().get(ObservabilityAttributes.GRPC_RPC_METHOD_ATTRIBUTE)) + .isEqualTo("google.cloud.compute.v1.Instances/List"); } else { - expectedUrl = - "https://compute.googleapis.com:443/compute/v1/projects/" - + DEFAULT_PROJECT - + "/zones/us-central1-a/instances"; + assertThat(span.getLabelsMap().get(ObservabilityAttributes.HTTP_METHOD_ATTRIBUTE)) + .isEqualTo("GET"); + String expectedUrl; + if (expectError) { + expectedUrl = + "https://compute.googleapis.com:443/compute/v1/projects/invalid-project-id/zones/us-central1-a/instances"; + } else { + expectedUrl = + "https://compute.googleapis.com:443/compute/v1/projects/" + + DEFAULT_PROJECT + + "/zones/us-central1-a/instances"; + } + assertThat(span.getLabelsMap().get(ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE)) + .isEqualTo(expectedUrl); } - assertThat(span.getLabelsMap().get(ObservabilityAttributes.HTTP_URL_FULL_ATTRIBUTE)) - .isEqualTo(expectedUrl); if (expectError) { assertThat(span.getLabelsMap().get(ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE)) 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 362438b53f35..b1f5642a2703 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 @@ -78,7 +78,9 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.List; +import org.awaitility.Awaitility; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -149,6 +151,19 @@ void tearDown() { GlobalOpenTelemetry.resetForTest(); } + /** + * Waits for the expected number of spans to be exported and returns the finished spans. + * + * @param expectedCount the expected number of exported spans + * @return the list of finished spans + */ + private List waitForSpans(int expectedCount) { + Awaitility.await() + .atMost(Duration.ofSeconds(5)) + .until(() -> spanExporter.getFinishedSpanItems().size() == expectedCount); + return spanExporter.getFinishedSpanItems(); + } + @Test void testTracing_successfulEcho_grpc() throws Exception { OpenTelemetryTracingFactory tracingFactory = new OpenTelemetryTracingFactory(openTelemetrySdk); @@ -160,7 +175,7 @@ void testTracing_successfulEcho_grpc() throws Exception { client.echo(EchoRequest.newBuilder().setContent("tracing-test").build()); - List spans = spanExporter.getFinishedSpanItems(); + List spans = waitForSpans(2); assertThat(spans).isNotEmpty(); SpanData attemptSpan = @@ -229,7 +244,7 @@ void testTracing_successfulEcho_httpjson() throws Exception { client.echo(EchoRequest.newBuilder().setContent("tracing-test").build()); - List spans = spanExporter.getFinishedSpanItems(); + List spans = waitForSpans(2); assertThat(spans).isNotEmpty(); SpanData attemptSpan = @@ -327,7 +342,7 @@ void testTracing_successfulIdentityGetUser_grpc() throws Exception { // generated. } - List spans = spanExporter.getFinishedSpanItems(); + List spans = waitForSpans(2); assertThat(spans).isNotEmpty(); SpanData attemptSpan = @@ -356,7 +371,7 @@ void testTracing_successfulIdentityGetUser_httpjson() throws Exception { // generated. } - List spans = spanExporter.getFinishedSpanItems(); + List spans = waitForSpans(2); assertThat(spans).isNotEmpty(); SpanData attemptSpan = @@ -413,8 +428,8 @@ void testTracing_retry_grpc() throws Exception { assertThrows(UnavailableException.class, () -> grpcClient.echo(echoRequest)); - List spans = spanExporter.getFinishedSpanItems(); - assertThat(spans).hasSize(attempts); // Expect exactly one span for the successful retry + List spans = waitForSpans(attempts + 1); + assertThat(spans).hasSize(attempts + 1); // attempts CLIENT spans + 1 INTERNAL operation span // This single span represents the successful retry, which has resend_count=1 // The first attempt has no resend_count. The subsequent retries will have a @@ -491,8 +506,8 @@ void testTracing_retry_httpjson() throws Exception { assertThrows(InvalidArgumentException.class, () -> httpClient.echo(echoRequest)); - List spans = spanExporter.getFinishedSpanItems(); - assertThat(spans).hasSize(attempts); // Expect exactly one span for the successful retry + List spans = waitForSpans(attempts + 1); + assertThat(spans).hasSize(attempts + 1); // attempts CLIENT spans + 1 INTERNAL operation span // This single span represents the successful retry, which has resend_count=1 // The first attempt has no resend_count. The subsequent retries will have a @@ -520,6 +535,12 @@ void testTracing_retry_httpjson() throws Exception { } private SpanData getErrorSpan() { + Awaitility.await() + .atMost(Duration.ofSeconds(5)) + .until( + () -> + spanExporter.getFinishedSpanItems().stream() + .anyMatch(span -> !span.getParentSpanContext().isValid())); List spans = spanExporter.getFinishedSpanItems(); assertThat(spans).isNotEmpty(); @@ -707,26 +728,30 @@ void testTracing_statusCodes_grpc() throws Exception { com.google.api.gax.rpc.InvalidArgumentException.class, () -> grpcClient.echo(errorRequest)); - List spans = spanExporter.getFinishedSpanItems(); - assertThat(spans).hasSize(2); - - SpanData grpcSuccessSpan = spans.get(0); - assertThat( - grpcSuccessSpan - .getAttributes() - .get( - AttributeKey.stringKey( - ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE))) - .isEqualTo("OK"); + List spans = waitForSpans(4); + assertThat(spans).hasSize(4); + + for (int i = 0; i < 2; i++) { + assertThat( + spans + .get(i) + .getAttributes() + .get( + AttributeKey.stringKey( + ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE))) + .isEqualTo("OK"); + } - SpanData grpcErrorSpan = spans.get(1); - assertThat( - grpcErrorSpan - .getAttributes() - .get( - AttributeKey.stringKey( - ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE))) - .isEqualTo("INVALID_ARGUMENT"); + for (int i = 2; i < 4; i++) { + assertThat( + spans + .get(i) + .getAttributes() + .get( + AttributeKey.stringKey( + ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE))) + .isEqualTo("INVALID_ARGUMENT"); + } } } @@ -748,24 +773,30 @@ void testTracing_statusCodes_httpjson() throws Exception { com.google.api.gax.rpc.InvalidArgumentException.class, () -> httpClient.echo(errorRequest)); - List spans = spanExporter.getFinishedSpanItems(); - assertThat(spans).hasSize(2); - - SpanData httpSuccessSpan = spans.get(0); - assertThat( - httpSuccessSpan - .getAttributes() - .get( - AttributeKey.longKey(ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE))) - .isEqualTo(200L); + List spans = waitForSpans(4); + assertThat(spans).hasSize(4); + + for (int i = 0; i < 2; i++) { + assertThat( + spans + .get(i) + .getAttributes() + .get( + AttributeKey.longKey( + ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE))) + .isEqualTo(200L); + } - SpanData httpErrorSpan = spans.get(1); - assertThat( - httpErrorSpan - .getAttributes() - .get( - AttributeKey.longKey(ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE))) - .isEqualTo((long) StatusCode.Code.INVALID_ARGUMENT.getHttpStatusCode()); + for (int i = 2; i < 4; i++) { + assertThat( + spans + .get(i) + .getAttributes() + .get( + AttributeKey.longKey( + ObservabilityAttributes.HTTP_RESPONSE_STATUS_ATTRIBUTE))) + .isEqualTo((long) StatusCode.Code.INVALID_ARGUMENT.getHttpStatusCode()); + } } } 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..2bbd51145886 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 @@ -268,6 +268,18 @@ Map getMetricsAttributes() { return attributes; } + /** + * @return a map of attributes to be included in operation-level spans + */ + Map getOperationAttributes() { + Map attributes = getMetricsAttributes(); + if (!Strings.isNullOrEmpty(destinationResourceId())) { + attributes.put( + ObservabilityAttributes.DESTINATION_RESOURCE_ID_ATTRIBUTE, destinationResourceId()); + } + return attributes; + } + /** * Merges this context with another context. The values in the other context take precedence. * 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 6ec280e48543..cd95610d7cab 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 @@ -35,7 +35,10 @@ import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanBuilder; import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.Context; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CancellationException; @@ -51,19 +54,24 @@ class OpenTelemetryTracingTracer implements ApiTracer { private final Tracer tracer; private final Map attemptAttributes; private final String attemptSpanName; + private final String operationSpanName; private final ApiTracerContext apiTracerContext; // Captures the active trace context from the calling thread at RPC initiation. - // This allows attempt spans—including retries dispatched on background threads—to - // link back to the original parent trace. - private final io.opentelemetry.context.Context parentContext; + // This allows the operation span and attempt spans to link back to the caller's trace. + private final Context parentContext; + // Trace context containing the operationSpan, serving as the parent for attempt spans. + private final Context operationContext; + private @Nullable Span operationSpan; private @Nullable Span attemptSpan; @Override - public void injectTraceContext(java.util.Map carrier) { - if (attemptSpan != null) { - io.opentelemetry.context.Context context = - io.opentelemetry.context.Context.current().with(attemptSpan); - io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator.getInstance() + public void injectTraceContext(Map carrier) { + // Prefer the active attempt span so outgoing RPC wire context reflects the specific attempt; + // fall back to the overall operation span if no attempt is currently in-flight. + Span currentSpan = attemptSpan != null ? attemptSpan : operationSpan; + if (currentSpan != null) { + Context context = Context.current().with(currentSpan); + W3CTraceContextPropagator.getInstance() .inject( context, carrier, @@ -82,12 +90,7 @@ public void injectTraceContext(java.util.Map carrier) { * @param apiTracerContext the {@link ApiTracerContext} to use for recording spans */ OpenTelemetryTracingTracer(Tracer tracer, ApiTracerContext apiTracerContext) { - this.tracer = tracer; - this.apiTracerContext = apiTracerContext; - this.attemptSpanName = resolveAttemptSpanName(apiTracerContext); - this.attemptAttributes = new HashMap<>(); - this.parentContext = io.opentelemetry.context.Context.current(); - buildAttributes(); + this(tracer, apiTracerContext, resolveAttemptSpanName(apiTracerContext)); } /** @@ -102,13 +105,50 @@ public void injectTraceContext(java.util.Map carrier) { OpenTelemetryTracingTracer( Tracer tracer, ApiTracerContext apiTracerContext, String attemptSpanName) { this.tracer = tracer; - this.attemptSpanName = attemptSpanName; this.apiTracerContext = apiTracerContext; + this.attemptSpanName = attemptSpanName; + this.operationSpanName = resolveOperationSpanName(apiTracerContext); this.attemptAttributes = new HashMap<>(); - this.parentContext = io.opentelemetry.context.Context.current(); + this.parentContext = Context.current(); buildAttributes(); + this.operationSpan = startOperationSpan(); + this.operationContext = parentContext.with(this.operationSpan); + } + + /** + * Starts and initializes the operation-level client request span. + * + * @return the newly started {@link Span} for the overall operation + */ + private Span startOperationSpan() { + SpanBuilder operationSpanBuilder = tracer.spanBuilder(operationSpanName); + operationSpanBuilder.setSpanKind(SpanKind.INTERNAL); + operationSpanBuilder.setParent(parentContext); + operationSpanBuilder.setAllAttributes( + ObservabilityUtils.toOtelAttributes(this.apiTracerContext.getOperationAttributes())); + return operationSpanBuilder.startSpan(); + } + + /** + * Resolves the operation-level span name from the tracer context. + * + * @param apiTracerContext the tracer context containing method metadata + * @return the operation span name + */ + private static String resolveOperationSpanName(ApiTracerContext apiTracerContext) { + String fullMethodName = apiTracerContext.fullMethodName(); + if (!Strings.isNullOrEmpty(fullMethodName)) { + return fullMethodName.replace('/', '.'); + } + return "Client Request"; } + /** + * Resolves the canonical attempt-level span name based on transport and context. + * + * @param apiTracerContext the tracer context containing transport and method metadata + * @return the attempt span name + */ private static String resolveAttemptSpanName(ApiTracerContext apiTracerContext) { if (apiTracerContext.transport() == ApiTracerContext.Transport.GRPC) { // gRPC Uses the full method name as span name. @@ -124,6 +164,7 @@ private static String resolveAttemptSpanName(ApiTracerContext apiTracerContext) } } + /** Copies attempt-level attributes from the tracer context into the local attribute cache. */ private void buildAttributes() { this.attemptAttributes.putAll(this.apiTracerContext.getAttemptAttributes()); } @@ -148,8 +189,8 @@ public void attemptStarted(Object request, int attemptNumber) { // Attempt spans are of the CLIENT kind spanBuilder.setSpanKind(SpanKind.CLIENT); - // Link attempt span to parent context - spanBuilder.setParent(parentContext); + // Link attempt span to operation context (parent operation span) + spanBuilder.setParent(operationContext); // Pass the combined attributes to the new SpanBuilder method spanBuilder.setAllAttributes(ObservabilityUtils.toOtelAttributes(currentAttemptAttributes)); @@ -160,33 +201,51 @@ public void attemptStarted(Object request, int attemptNumber) { /** * Signals that the overall logical operation succeeded. * - *

Closes any remaining in-flight attempt span. + *

Closes any remaining in-flight attempt span and ends the operation span. */ @Override public void operationSucceeded() { - recordErrorAndEndAttempt(null); + recordErrorAndEndOperation(null); } /** * Signals that the overall logical operation was cancelled. * - *

Closes any remaining in-flight attempt span with a {@link CancellationException}. + *

Closes any remaining in-flight attempt span with a {@link CancellationException} and ends + * the operation span with an ERROR status. */ @Override public void operationCancelled() { - recordErrorAndEndAttempt(new CancellationException()); + recordErrorAndEndOperation(new CancellationException()); } /** * Signals that the overall logical operation failed permanently. * - *

Closes any remaining in-flight attempt span with the provided error details. + *

Closes any remaining in-flight attempt span with the provided error details and ends the + * operation span with an ERROR status. * * @param error the cause of the operation failure */ @Override public void operationFailed(Throwable error) { - recordErrorAndEndAttempt(error); + recordErrorAndEndOperation(error); + } + + /** + * Records error details and ends both the active attempt span and the operation span. + * + * @param error the exception associated with the operation failure, or {@code null} if successful + */ + private void recordErrorAndEndOperation(@Nullable Throwable error) { + if (attemptSpan != null) { + endSpan(attemptSpan, error); + attemptSpan = null; + } + if (operationSpan != null) { + endSpan(operationSpan, error); + operationSpan = null; + } } @Override @@ -195,7 +254,7 @@ public void attemptSucceeded() { } @Override - public void responseHeadersReceived(java.util.Map headers) { + public void responseHeadersReceived(Map headers) { if (attemptSpan == null) { return; } @@ -215,7 +274,7 @@ public void responseHeadersReceived(java.util.Map headers) { * @param headers the map of response headers. * @return the content length in bytes, or -1 if the header is missing or malformed. */ - private long extractContentLength(java.util.Map headers) { + private long extractContentLength(Map headers) { try { if (headers == null || headers.isEmpty()) return -1; // google-http-client HttpHeaders uses a case-insensitive map but we copy it for safety @@ -265,20 +324,31 @@ private void recordErrorAndEndAttempt(@Nullable Throwable error) { if (attemptSpan == null) { return; } + endSpan(attemptSpan, error); + attemptSpan = null; + } + /** + * Attaches response status attributes and error messages to the span and ends it. + * + * @param span the span to finish + * @param error the exception that caused the span to end, or {@code null} if successful + */ + private void endSpan(Span span, @Nullable Throwable error) { Map responseAttributes = ObservabilityUtils.getResponseAttributes(error, this.apiTracerContext.transport()); if (!responseAttributes.isEmpty()) { - attemptSpan.setAllAttributes(ObservabilityUtils.toOtelAttributes(responseAttributes)); + span.setAllAttributes(ObservabilityUtils.toOtelAttributes(responseAttributes)); } - if (error != null && !Strings.isNullOrEmpty(error.getMessage())) { - attemptSpan.setAttribute( - ObservabilityAttributes.STATUS_MESSAGE_ATTRIBUTE, error.getMessage()); + if (error != null) { + span.setStatus(StatusCode.ERROR); + if (!Strings.isNullOrEmpty(error.getMessage())) { + span.setAttribute(ObservabilityAttributes.STATUS_MESSAGE_ATTRIBUTE, error.getMessage()); + } } - attemptSpan.end(); - attemptSpan = null; + span.end(); } @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..b9639a2e1293 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,43 @@ void testDestinationResourceId_evaluatedEveryTime() { assertThat(result.destinationResourceId()).isEqualTo("extracted-id-2"); assertThat(counter[0]).isEqualTo(2); } + + @Test + void testGetOperationAttributes() { + ApiTracerContext context = + ApiTracerContext.newBuilder() + .setLibraryMetadata(LibraryMetadata.newBuilder().setRepository("test-repo").build()) + .setServerAddress("test-address") + .setServerPort(443) + .setServiceName("test-service") + .setTransport(ApiTracerContext.Transport.HTTP) + .setHttpMethod("POST") + .setHttpPathTemplate("/v1/{name}:access") + .setFullMethodName("google.test.v1.TestService/Access") + .setUrlDomain("test.googleapis.com") + .setDestinationResourceIdSupplier(() -> "projects/p/secrets/s") + .build(); + + Map attributes = context.getOperationAttributes(); + + assertThat(attributes) + .containsEntry(ObservabilityAttributes.SERVER_ADDRESS_ATTRIBUTE, "test-address"); + assertThat(attributes).containsEntry(ObservabilityAttributes.SERVER_PORT_ATTRIBUTE, 443); + assertThat(attributes).containsEntry(ObservabilityAttributes.REPO_ATTRIBUTE, "test-repo"); + assertThat(attributes) + .containsEntry(ObservabilityAttributes.GCP_CLIENT_SERVICE_ATTRIBUTE, "test-service"); + assertThat(attributes).containsEntry(ObservabilityAttributes.RPC_SYSTEM_NAME_ATTRIBUTE, "http"); + assertThat(attributes) + .containsEntry( + ObservabilityAttributes.GRPC_RPC_METHOD_ATTRIBUTE, "google.test.v1.TestService/Access"); + assertThat(attributes) + .containsEntry(ObservabilityAttributes.URL_DOMAIN_ATTRIBUTE, "test.googleapis.com"); + assertThat(attributes) + .containsEntry(ObservabilityAttributes.URL_TEMPLATE_ATTRIBUTE, "/v1/{name}:access"); + assertThat(attributes) + .containsEntry( + ObservabilityAttributes.DESTINATION_RESOURCE_ID_ATTRIBUTE, + "//test.googleapis.com/projects/p/secrets/s"); + assertThat(attributes).doesNotContainKey(ObservabilityAttributes.HTTP_METHOD_ATTRIBUTE); + } } diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryTracingTracerFactoryTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryTracingTracerFactoryTest.java index de7941522587..4a58a3b07bfd 100644 --- a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryTracingTracerFactoryTest.java +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryTracingTracerFactoryTest.java @@ -229,7 +229,7 @@ void testNewTracer_withContext_grpc_usesFullMethodName() { tracerInstance.attemptStarted(null, 1); - verify(tracer).spanBuilder("google.cloud.v1.Service/Method"); + verify(tracer, atLeastOnce()).spanBuilder("google.cloud.v1.Service/Method"); } @ParameterizedTest @@ -256,7 +256,7 @@ void testNewTracer_withContext_http_usesHttpMethodAndPathTemplate( tracerInstance.attemptStarted(null, 1); - verify(tracer).spanBuilder(expectedSpanName); + verify(tracer, atLeastOnce()).spanBuilder(expectedSpanName); } @Test @@ -274,7 +274,7 @@ void testNewTracer_withContext_http_noHttpMethodOrPathTemplate_usesFullMethodNam tracerInstance.attemptStarted(null, 1); - verify(tracer).spanBuilder("google.cloud.v1.Service.Method"); + verify(tracer, atLeastOnce()).spanBuilder("google.cloud.v1.Service.Method"); } @Test @@ -310,7 +310,7 @@ void testNewTracer_mergesFactoryContext() { tracerInstance.attemptStarted(null, 1); ArgumentCaptor attributesCaptor = ArgumentCaptor.forClass(Attributes.class); - verify(spanBuilder).setAllAttributes(attributesCaptor.capture()); + verify(spanBuilder, atLeastOnce()).setAllAttributes(attributesCaptor.capture()); Attributes attributes = attributesCaptor.getValue(); assertThat(attributes.asMap()) diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryTracingTracerTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryTracingTracerTest.java index 76cd143782fe..3c647c3b8dc4 100644 --- a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryTracingTracerTest.java +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryTracingTracerTest.java @@ -30,6 +30,7 @@ package com.google.api.gax.tracing; import static com.google.common.truth.Truth.assertThat; +import static io.opentelemetry.api.trace.StatusCode.ERROR; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -52,6 +53,7 @@ import io.opentelemetry.api.trace.Tracer; import java.net.ConnectException; import java.net.SocketTimeoutException; +import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -65,6 +67,8 @@ class OpenTelemetryTracingTracerTest { @Mock private Tracer tracer; @Mock private SpanBuilder spanBuilder; @Mock private Span span; + @Mock private SpanBuilder operationSpanBuilder; + @Mock private Span operationSpan; private OpenTelemetryTracingTracer openTelemetryTracingTracer; private static final String ATTEMPT_SPAN_NAME = "Service/Method/attempt"; @@ -75,6 +79,20 @@ void setUp() { lenient().when(spanBuilder.setParent(any())).thenReturn(spanBuilder); lenient().when(spanBuilder.setAllAttributes(any(Attributes.class))).thenReturn(spanBuilder); lenient().when(spanBuilder.startSpan()).thenReturn(span); + + lenient() + .when(operationSpanBuilder.setSpanKind(any(SpanKind.class))) + .thenReturn(operationSpanBuilder); + lenient().when(operationSpanBuilder.setParent(any())).thenReturn(operationSpanBuilder); + lenient() + .when(operationSpanBuilder.setAllAttributes(any(Attributes.class))) + .thenReturn(operationSpanBuilder); + lenient().when(operationSpanBuilder.startSpan()).thenReturn(operationSpan); + lenient() + .when(operationSpan.storeInContext(any(io.opentelemetry.context.Context.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + lenient().when(tracer.spanBuilder("Client Request")).thenReturn(operationSpanBuilder); + openTelemetryTracingTracer = new OpenTelemetryTracingTracer(tracer, ApiTracerContext.empty(), ATTEMPT_SPAN_NAME); } @@ -695,6 +713,7 @@ void testOperationSucceeded_endsActiveAttemptSpan() { openTelemetryTracingTracer.operationSucceeded(); verify(span).end(); + verify(operationSpan).end(); } @Test @@ -704,6 +723,8 @@ void testOperationFailed_endsActiveAttemptSpanWithErrorAttributes() { verify(span).setAttribute(ObservabilityAttributes.STATUS_MESSAGE_ATTRIBUTE, "operation failed"); verify(span).end(); + verify(operationSpan).setStatus(ERROR); + verify(operationSpan).end(); } @Test @@ -714,10 +735,52 @@ void testOperationCancelled_endsActiveAttemptSpanWithCancellation() { ArgumentCaptor attrsCaptor = ArgumentCaptor.forClass(Attributes.class); verify(span).setAllAttributes(attrsCaptor.capture()); verify(span).end(); + verify(operationSpan).setStatus(ERROR); + verify(operationSpan).end(); assertThat(attrsCaptor.getValue().asMap()) .containsEntry( AttributeKey.stringKey(ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE), "CANCELLED"); } + + @Test + void testInjectTraceContext_withOperationSpanFallback() { + // Verifies that injectTraceContext() injects the operation span context into the carrier + // when between attempts so that context propagation doesn't drop trace state. + io.opentelemetry.api.trace.SpanContext mockSpanContext = + io.opentelemetry.api.trace.SpanContext.create( + "00000000000000000000000000000003", + "0000000000000004", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault()); + Span realSpan = Span.wrap(mockSpanContext); + when(operationSpanBuilder.startSpan()).thenReturn(realSpan); + + openTelemetryTracingTracer = + new OpenTelemetryTracingTracer(tracer, ApiTracerContext.empty(), ATTEMPT_SPAN_NAME); + + Map carrier = new HashMap<>(); + openTelemetryTracingTracer.injectTraceContext(carrier); + + assertThat(carrier).containsKey("traceparent"); + assertThat(carrier.get("traceparent")).contains("00000000000000000000000000000003"); + assertThat(carrier.get("traceparent")).contains("0000000000000004"); + } + + @Test + void testOperationSpanName_replacesSlashWithDotInFullMethodName() { + ApiTracerContext context = + ApiTracerContext.newBuilder() + .setLibraryMetadata(com.google.api.gax.rpc.LibraryMetadata.empty()) + .setFullMethodName( + "google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion") + .setTransport(ApiTracerContext.Transport.GRPC) + .build(); + + new OpenTelemetryTracingTracer(tracer, context); + + verify(tracer) + .spanBuilder("google.cloud.secretmanager.v1.SecretManagerService.AccessSecretVersion"); + } }