diff --git a/gax-java/dependencies.properties b/gax-java/dependencies.properties index 3b46b82fff..2fb4fb1316 100644 --- a/gax-java/dependencies.properties +++ b/gax-java/dependencies.properties @@ -40,6 +40,7 @@ maven.com_google_api_grpc_grpc_google_common_protos=com.google.api.grpc:grpc-goo maven.com_google_auth_google_auth_library_oauth2_http=com.google.auth:google-auth-library-oauth2-http:1.42.1 maven.com_google_auth_google_auth_library_credentials=com.google.auth:google-auth-library-credentials:1.42.1 maven.io_opentelemetry_opentelemetry_api=io.opentelemetry:opentelemetry-api:1.47.0 +maven.io_opentelemetry_opentelemetry_context=io.opentelemetry:opentelemetry-context:1.47.0 maven.io_opencensus_opencensus_api=io.opencensus:opencensus-api:0.31.1 maven.io_opencensus_opencensus_contrib_grpc_metrics=io.opencensus:opencensus-contrib-grpc-metrics:0.31.1 maven.io_opencensus_opencensus_contrib_http_util=io.opencensus:opencensus-contrib-http-util:0.31.1 diff --git a/gax-java/gax/BUILD.bazel b/gax-java/gax/BUILD.bazel index 80b26ad785..15ed36bcbd 100644 --- a/gax-java/gax/BUILD.bazel +++ b/gax-java/gax/BUILD.bazel @@ -19,6 +19,7 @@ _COMPILE_DEPS = [ "@com_google_errorprone_error_prone_annotations//jar", "@com_google_guava_guava//jar", "@io_opentelemetry_opentelemetry_api//jar", + "@io_opentelemetry_opentelemetry_context//jar", "@io_opencensus_opencensus_api//jar", "@io_opencensus_opencensus_contrib_http_util//jar", "@io_grpc_grpc_java//context:context", diff --git a/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientContext.java b/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientContext.java index 72d54356b0..e782fdd926 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientContext.java +++ b/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientContext.java @@ -41,6 +41,7 @@ import com.google.api.gax.core.ExecutorAsBackgroundResource; import com.google.api.gax.core.ExecutorProvider; import com.google.api.gax.rpc.internal.QuotaProjectIdHidingCredentials; +import com.google.api.gax.tracing.ApiTracerContext; import com.google.api.gax.tracing.ApiTracerFactory; import com.google.api.gax.tracing.BaseApiTracerFactory; import com.google.auth.ApiKeyCredentials; @@ -269,6 +270,11 @@ public static ClientContext create(StubSettings settings) throws IOException { if (watchdogProvider != null && watchdogProvider.shouldAutoClose()) { backgroundResources.add(watchdog); } + ApiTracerContext apiTracerContext = + ApiTracerContext.newBuilder() + .setServerAddress(endpointContext.resolvedServerAddress()) + .build(); + ApiTracerFactory apiTracerFactory = settings.getTracerFactory().withContext(apiTracerContext); return newBuilder() .setBackgroundResources(backgroundResources.build()) @@ -284,7 +290,7 @@ public static ClientContext create(StubSettings settings) throws IOException { .setQuotaProjectId(settings.getQuotaProjectId()) .setStreamWatchdog(watchdog) .setStreamWatchdogCheckIntervalDuration(settings.getStreamWatchdogCheckIntervalDuration()) - .setTracerFactory(settings.getTracerFactory()) + .setTracerFactory(apiTracerFactory) .setEndpointContext(endpointContext) .build(); } diff --git a/gax-java/gax/src/main/java/com/google/api/gax/rpc/EndpointContext.java b/gax-java/gax/src/main/java/com/google/api/gax/rpc/EndpointContext.java index a2e44d8a8b..84111dd620 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/rpc/EndpointContext.java +++ b/gax-java/gax/src/main/java/com/google/api/gax/rpc/EndpointContext.java @@ -40,6 +40,7 @@ import com.google.auto.value.AutoValue; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; +import com.google.common.net.HostAndPort; import java.io.IOException; import java.util.logging.Level; import java.util.logging.Logger; @@ -133,6 +134,8 @@ public static EndpointContext getDefaultInstance() { public abstract String resolvedEndpoint(); + public abstract String resolvedServerAddress(); + public abstract Builder toBuilder(); public static Builder newBuilder() { @@ -228,6 +231,8 @@ public abstract static class Builder { public abstract Builder setResolvedEndpoint(String resolvedEndpoint); + public abstract Builder setResolvedServerAddress(String serverAddress); + public abstract Builder setResolvedUniverseDomain(String resolvedUniverseDomain); abstract Builder setUseS2A(boolean useS2A); @@ -382,6 +387,23 @@ boolean shouldUseS2A() { return mtlsEndpoint().contains(Credentials.GOOGLE_DEFAULT_UNIVERSE); } + private String parseServerAddress(String endpoint) { + if (Strings.isNullOrEmpty(endpoint)) { + return endpoint; + } + String hostPort = endpoint; + if (hostPort.contains("://")) { + // Strip the scheme if present. HostAndPort doesn't support schemes. + hostPort = hostPort.substring(hostPort.indexOf("://") + 3); + } + try { + return HostAndPort.fromString(hostPort).getHost(); + } catch (IllegalArgumentException e) { + // Fallback for cases HostAndPort can't handle. + return hostPort; + } + } + // Default to port 443 for HTTPS. Using HTTP requires explicitly setting the endpoint private String buildEndpointTemplate(String serviceName, String resolvedUniverseDomain) { return serviceName + "." + resolvedUniverseDomain + ":443"; @@ -416,7 +438,9 @@ String mtlsEndpointResolver( public EndpointContext build() throws IOException { // The Universe Domain is used to resolve the Endpoint. It should be resolved first setResolvedUniverseDomain(determineUniverseDomain()); - setResolvedEndpoint(determineEndpoint()); + String endpoint = determineEndpoint(); + setResolvedEndpoint(endpoint); + setResolvedServerAddress(parseServerAddress(endpoint)); setUseS2A(shouldUseS2A()); return autoBuild(); } diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracerContext.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracerContext.java new file mode 100644 index 0000000000..1c92bd8db3 --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracerContext.java @@ -0,0 +1,60 @@ +/* + * 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.InternalApi; +import com.google.auto.value.AutoValue; +import javax.annotation.Nullable; + +/** + * A context object that contains information used to infer attributes that are common for all + * {@link ApiTracer}s. + * + *

For internal use only. + */ +@InternalApi +@AutoValue +public abstract class ApiTracerContext { + + @Nullable + public abstract String getServerAddress(); + + public static Builder newBuilder() { + return new AutoValue_ApiTracerContext.Builder(); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setServerAddress(String serverAddress); + + public abstract ApiTracerContext build(); + } +} diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracerFactory.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracerFactory.java index bb8345b88c..07a0fcf12d 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracerFactory.java +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ApiTracerFactory.java @@ -61,4 +61,15 @@ enum OperationType { * @param operationType the type of operation that the tracer will trace */ ApiTracer newTracer(ApiTracer parent, SpanName spanName, OperationType operationType); + + /** + * Returns a new {@link ApiTracerFactory} that will use the provided context to infer attributes + * for all tracers created by the factory. + * + * @param context an {@link ApiTracerContext} object containing information to construct + * attributes + */ + default ApiTracerFactory withContext(ApiTracerContext context) { + return this; + } } diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/AppCentricAttributes.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/AppCentricAttributes.java new file mode 100644 index 0000000000..a060ba4a88 --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/AppCentricAttributes.java @@ -0,0 +1,64 @@ +/* + * 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.InternalApi; +import java.util.HashMap; +import java.util.Map; + +/** + * Utility class for providing common attributes used in app-centric observability. + * + *

This class extracts information from {@link ApiTracerContext} and maps it to standardized + * attribute keys that are expected by {@link ApiTracerFactory} implementations that conform to + * app-centric observability + * + *

For internal use only. + */ +@InternalApi +public class AppCentricAttributes { + /** The address of the server being called (e.g., "pubsub.googleapis.com"). */ + public static final String SERVER_ADDRESS_ATTRIBUTE = "server.address"; + + /** + * Extracts attempt-level attributes from the provided {@link ApiTracerContext}. + * + * @param context the context containing information about the current API call + * @return a map of attributes to be included in attempt-level spans + */ + public static Map getAttemptAttributes(ApiTracerContext context) { + Map attributes = new HashMap<>(); + if (context.getServerAddress() != null) { + attributes.put(SERVER_ADDRESS_ATTRIBUTE, context.getServerAddress()); + } + return attributes; + } +} diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/AppCentricTracer.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/AppCentricTracer.java new file mode 100644 index 0000000000..c951e71415 --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/AppCentricTracer.java @@ -0,0 +1,90 @@ +/* + * 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 java.util.HashMap; +import java.util.Map; + +/** + * An implementation of {@link ApiTracer} that uses a {@link TraceRecorder} to record traces. This + * implementation is agnostic to the specific {@link TraceRecorder} in order to allow extensions + * that interact with other backends. + */ +@BetaApi +@InternalApi +public class AppCentricTracer implements ApiTracer { + public static final String LANGUAGE_ATTRIBUTE = "gcp.client.language"; + + public static final String DEFAULT_LANGUAGE = "Java"; + + private final TraceRecorder recorder; + private final Map attemptAttributes; + private final String attemptSpanName; + private TraceRecorder.TraceSpan attemptHandle; + + /** + * Creates a new instance of {@code AppCentricTracer}. + * + * @param recorder the {@link TraceRecorder} to use for recording spans + * @param attemptSpanName the name of the individual attempt spans + * @param attemptAttributes attributes to be added to each attempt span + */ + public AppCentricTracer( + TraceRecorder recorder, String attemptSpanName, Map attemptAttributes) { + this.recorder = recorder; + this.attemptSpanName = attemptSpanName; + this.attemptAttributes = new HashMap<>(attemptAttributes); + this.attemptAttributes.put(LANGUAGE_ATTRIBUTE, DEFAULT_LANGUAGE); + + // Start the long-lived operation span. + } + + @Override + public void attemptStarted(Object request, int attemptNumber) { + Map attemptAttributes = new HashMap<>(this.attemptAttributes); + // Start the specific attempt span with the operation span as parent + this.attemptHandle = recorder.createSpan(attemptSpanName, attemptAttributes); + } + + @Override + public void attemptSucceeded() { + endAttempt(); + } + + private void endAttempt() { + if (attemptHandle != null) { + attemptHandle.end(); + attemptHandle = null; + } + } +} diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/AppCentricTracerFactory.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/AppCentricTracerFactory.java new file mode 100644 index 0000000000..c227095179 --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/AppCentricTracerFactory.java @@ -0,0 +1,96 @@ +/* + * 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.common.annotations.VisibleForTesting; +import java.util.HashMap; +import java.util.Map; + +/** + * A {@link ApiTracerFactory} to build instances of {@link AppCentricTracer}. + * + *

This class wraps the {@link TraceRecorder} and pass it to {@link AppCentricTracer}. It will be + * used to record traces in {@link AppCentricTracer}. + * + *

This class is expected to be initialized once during client initialization. + */ +@BetaApi +@InternalApi +public class AppCentricTracerFactory implements ApiTracerFactory { + private final TraceRecorder traceRecorder; + + /** Mapping of client attributes that are set for every AppCentricTracer at operation level */ + private final Map operationAttributes; + + /** Mapping of client attributes that are set for every AppCentricTracer at attempt level */ + private final Map attemptAttributes; + + /** Creates a AppCentricTracerFactory */ + public AppCentricTracerFactory(TraceRecorder traceRecorder) { + this(traceRecorder, new HashMap<>(), new HashMap<>()); + } + + /** + * Pass in a Map of client level attributes which will be added to every single AppCentricTracer + * created from the ApiTracerFactory. This is package private since span attributes are determined + * internally. + */ + @VisibleForTesting + AppCentricTracerFactory( + TraceRecorder traceRecorder, + Map operationAttributes, + Map attemptAttributes) { + this.traceRecorder = traceRecorder; + + this.operationAttributes = new HashMap<>(operationAttributes); + this.attemptAttributes = new HashMap<>(attemptAttributes); + } + + @Override + public ApiTracer newTracer(ApiTracer parent, SpanName spanName, OperationType operationType) { + // TODO(diegomarquezp): this is a placeholder for span names and will be adjusted as the + // feature is developed. + String attemptSpanName = spanName.getClientName() + "/" + spanName.getMethodName() + "/attempt"; + + AppCentricTracer appCentricTracer = + new AppCentricTracer(traceRecorder, attemptSpanName, this.attemptAttributes); + return appCentricTracer; + } + + @Override + public ApiTracerFactory withContext(ApiTracerContext context) { + Map newAttemptAttributes = new HashMap<>(this.attemptAttributes); + newAttemptAttributes.putAll(AppCentricAttributes.getAttemptAttributes(context)); + return new AppCentricTracerFactory(traceRecorder, operationAttributes, newAttemptAttributes); + } +} diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryTraceRecorder.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryTraceRecorder.java new file mode 100644 index 0000000000..8e5202e3d9 --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryTraceRecorder.java @@ -0,0 +1,83 @@ +/* + * 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 io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.Tracer; +import java.util.Map; + +/** + * OpenTelemetry implementation of recording traces. This implementation collects the measurements + * related to the lifecyle of an RPC. + */ +@BetaApi +@InternalApi +public class OpenTelemetryTraceRecorder implements TraceRecorder { + private final Tracer tracer; + + public OpenTelemetryTraceRecorder(OpenTelemetry openTelemetry) { + this.tracer = openTelemetry.getTracer("gax-java"); + } + + @Override + public TraceSpan createSpan(String name, Map attributes) { + SpanBuilder spanBuilder = tracer.spanBuilder(name); + + // Attempt spans are of the CLIENT kind + spanBuilder.setSpanKind(SpanKind.CLIENT); + + if (attributes != null) { + attributes.forEach((k, v) -> spanBuilder.setAttribute(k, v)); + } + + Span span = spanBuilder.startSpan(); + + return new OtelTraceSpan(span); + } + + private static class OtelTraceSpan implements TraceSpan { + private final Span span; + + private OtelTraceSpan(Span span) { + this.span = span; + } + + @Override + public void end() { + span.end(); + } + } +} diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/TraceRecorder.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/TraceRecorder.java new file mode 100644 index 0000000000..64f063fe62 --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/TraceRecorder.java @@ -0,0 +1,50 @@ +/* + * 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 java.util.Map; + +/** + * Provides an interface for tracing recording. The implementer is expected to use an observability + * framework, e.g. OpenTelemetry. There should be only one instance of TraceRecorder per client. + */ +@BetaApi +@InternalApi +public interface TraceRecorder { + /** Starts a span and returns a handle to manage its lifecycle. */ + TraceSpan createSpan(String name, Map attributes); + + interface TraceSpan { + void end(); + } +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/rpc/EndpointContextTest.java b/gax-java/gax/src/test/java/com/google/api/gax/rpc/EndpointContextTest.java index ef64ccd726..c1bcc50512 100644 --- a/gax-java/gax/src/test/java/com/google/api/gax/rpc/EndpointContextTest.java +++ b/gax-java/gax/src/test/java/com/google/api/gax/rpc/EndpointContextTest.java @@ -593,4 +593,49 @@ void shouldUseS2A_success() throws IOException { .setUsingGDCH(false); Truth.assertThat(defaultEndpointContextBuilder.shouldUseS2A()).isTrue(); } + + @Test + void endpointContextBuild_resolvesPortAndServerAddress() throws IOException { + String endpoint = "http://localhost:7469"; + EndpointContext endpointContext = + defaultEndpointContextBuilder + .setClientSettingsEndpoint(endpoint) + .setTransportChannelProviderEndpoint(null) + .build(); + Truth.assertThat(endpointContext.resolvedServerAddress()).isEqualTo("localhost"); + + endpoint = "localhost:7469"; + endpointContext = + defaultEndpointContextBuilder + .setClientSettingsEndpoint(endpoint) + .setTransportChannelProviderEndpoint(null) + .build(); + Truth.assertThat(endpointContext.resolvedServerAddress()).isEqualTo("localhost"); + + endpoint = "test.googleapis.com:443"; + endpointContext = + defaultEndpointContextBuilder + .setClientSettingsEndpoint(endpoint) + .setTransportChannelProviderEndpoint(null) + .build(); + Truth.assertThat(endpointContext.resolvedServerAddress()).isEqualTo("test.googleapis.com"); + + // IPv6 literal with port + endpoint = "[2001:db8::1]:443"; + endpointContext = + defaultEndpointContextBuilder + .setClientSettingsEndpoint(endpoint) + .setTransportChannelProviderEndpoint(null) + .build(); + Truth.assertThat(endpointContext.resolvedServerAddress()).isEqualTo("2001:db8::1"); + + // Bare IPv6 literal (no port) + endpoint = "2001:db8::1"; + endpointContext = + defaultEndpointContextBuilder + .setClientSettingsEndpoint(endpoint) + .setTransportChannelProviderEndpoint(null) + .build(); + Truth.assertThat(endpointContext.resolvedServerAddress()).isEqualTo("2001:db8::1"); + } } diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/AppCentricAttributesTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/AppCentricAttributesTest.java new file mode 100644 index 0000000000..8256101b57 --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/AppCentricAttributesTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.api.gax.tracing; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AppCentricAttributesTest { + + @Test + void testGetAttemptAttributes_serverAddress() { + ApiTracerContext context = + ApiTracerContext.newBuilder().setServerAddress("test-address").build(); + + Map attributes = AppCentricAttributes.getAttemptAttributes(context); + + assertThat(attributes).hasSize(1); + assertThat(attributes) + .containsEntry(AppCentricAttributes.SERVER_ADDRESS_ATTRIBUTE, "test-address"); + } + + @Test + void testGetAttemptAttributes_nonePresent() { + ApiTracerContext context = ApiTracerContext.newBuilder().build(); + + Map attributes = AppCentricAttributes.getAttemptAttributes(context); + + assertThat(attributes).isEmpty(); + } +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/AppCentricTracerFactoryTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/AppCentricTracerFactoryTest.java new file mode 100644 index 0000000000..9cf81b457c --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/AppCentricTracerFactoryTest.java @@ -0,0 +1,132 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.api.gax.tracing; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class AppCentricTracerFactoryTest { + + @Test + void testNewTracer_createsOpenTelemetryTracingTracer() { + TraceRecorder recorder = mock(TraceRecorder.class); + when(recorder.createSpan(anyString(), anyMap())) + .thenReturn(mock(TraceRecorder.TraceSpan.class)); + + AppCentricTracerFactory factory = new AppCentricTracerFactory(recorder); + ApiTracer tracer = + factory.newTracer( + null, SpanName.of("service", "method"), ApiTracerFactory.OperationType.Unary); + assertThat(tracer).isInstanceOf(AppCentricTracer.class); + } + + @Test + void testNewTracer_addsAttributes() { + TraceRecorder recorder = mock(TraceRecorder.class); + TraceRecorder.TraceSpan attemptHandle = mock(TraceRecorder.TraceSpan.class); + when(recorder.createSpan(anyString(), anyMap())).thenReturn(attemptHandle); + + AppCentricTracerFactory factory = + new AppCentricTracerFactory( + recorder, ImmutableMap.of(), ImmutableMap.of("server.port", "443")); + ApiTracer tracer = + factory.newTracer( + null, SpanName.of("service", "method"), ApiTracerFactory.OperationType.Unary); + + tracer.attemptStarted(null, 1); + + ArgumentCaptor> attributesCaptor = ArgumentCaptor.forClass(Map.class); + verify(recorder, atLeastOnce()).createSpan(anyString(), attributesCaptor.capture()); + + Map attemptAttributes = attributesCaptor.getValue(); + assertThat(attemptAttributes).containsEntry("server.port", "443"); + } + + @Test + void testWithContext_addsInferredAttributes() { + TraceRecorder recorder = mock(TraceRecorder.class); + TraceRecorder.TraceSpan attemptHandle = mock(TraceRecorder.TraceSpan.class); + when(recorder.createSpan(anyString(), anyMap())).thenReturn(attemptHandle); + + ApiTracerContext context = + ApiTracerContext.newBuilder().setServerAddress("example.com").build(); + + AppCentricTracerFactory factory = new AppCentricTracerFactory(recorder); + ApiTracerFactory factoryWithContext = factory.withContext(context); + + ApiTracer tracer = + factoryWithContext.newTracer( + null, SpanName.of("service", "method"), ApiTracerFactory.OperationType.Unary); + + tracer.attemptStarted(null, 1); + + ArgumentCaptor> attributesCaptor = ArgumentCaptor.forClass(Map.class); + verify(recorder, atLeastOnce()).createSpan(anyString(), attributesCaptor.capture()); + + Map attemptAttributes = attributesCaptor.getValue(); + assertThat(attemptAttributes) + .containsEntry(AppCentricAttributes.SERVER_ADDRESS_ATTRIBUTE, "example.com"); + } + + @Test + void testWithContext_noEndpointContext_doesNotAddAttributes() { + TraceRecorder recorder = mock(TraceRecorder.class); + TraceRecorder.TraceSpan attemptHandle = mock(TraceRecorder.TraceSpan.class); + when(recorder.createSpan(anyString(), anyMap())).thenReturn(attemptHandle); + + ApiTracerContext context = ApiTracerContext.newBuilder().build(); + + AppCentricTracerFactory factory = new AppCentricTracerFactory(recorder); + ApiTracerFactory factoryWithContext = factory.withContext(context); + + ApiTracer tracer = + factoryWithContext.newTracer( + null, SpanName.of("service", "method"), ApiTracerFactory.OperationType.Unary); + + tracer.attemptStarted(null, 1); + + ArgumentCaptor> attributesCaptor = ArgumentCaptor.forClass(Map.class); + verify(recorder, atLeastOnce()).createSpan(anyString(), attributesCaptor.capture()); + + Map attemptAttributes = attributesCaptor.getValue(); + assertThat(attemptAttributes).doesNotContainKey(AppCentricAttributes.SERVER_ADDRESS_ATTRIBUTE); + } +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/AppCentricTracerTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/AppCentricTracerTest.java new file mode 100644 index 0000000000..d04bd94f53 --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/AppCentricTracerTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.tracing; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AppCentricTracerTest { + @Mock private TraceRecorder recorder; + @Mock private TraceRecorder.TraceSpan attemptHandle; + private AppCentricTracer tracer; + private static final String ATTEMPT_SPAN_NAME = "Service/Method/attempt"; + + @BeforeEach + void setUp() { + tracer = new AppCentricTracer(recorder, ATTEMPT_SPAN_NAME, new HashMap<>()); + } + + @Test + void testAttemptLifecycle_startsAndEndsAttemptSpan() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + tracer.attemptStarted(new Object(), 1); + tracer.attemptSucceeded(); + + verify(attemptHandle).end(); + } + + @Test + void testAttemptStarted_includesLanguageAttribute() { + when(recorder.createSpan(eq(ATTEMPT_SPAN_NAME), anyMap())).thenReturn(attemptHandle); + + tracer.attemptStarted(new Object(), 1); + + ArgumentCaptor> attributesCaptor = ArgumentCaptor.forClass(Map.class); + verify(recorder).createSpan(eq(ATTEMPT_SPAN_NAME), attributesCaptor.capture()); + + assertThat(attributesCaptor.getValue()) + .containsEntry(AppCentricTracer.LANGUAGE_ATTRIBUTE, AppCentricTracer.DEFAULT_LANGUAGE); + } +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryTraceRecorderTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryTraceRecorderTest.java new file mode 100644 index 0000000000..44e8b84433 --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryTraceRecorderTest.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 static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableMap; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.Tracer; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class OpenTelemetryTraceRecorderTest { + @Mock private OpenTelemetry openTelemetry; + @Mock private Tracer tracer; + @Mock private SpanBuilder spanBuilder; + @Mock private Span span; + + private OpenTelemetryTraceRecorder recorder; + + @BeforeEach + void setUp() { + when(openTelemetry.getTracer(anyString())).thenReturn(tracer); + recorder = new OpenTelemetryTraceRecorder(openTelemetry); + } + + @Test + void testCreateSpan_operation_isInternal() { + String spanName = "operation-span"; + when(tracer.spanBuilder(spanName)).thenReturn(spanBuilder); + when(spanBuilder.setSpanKind(SpanKind.CLIENT)).thenReturn(spanBuilder); + when(spanBuilder.startSpan()).thenReturn(span); + + recorder.createSpan(spanName, null); + + verify(spanBuilder).setSpanKind(SpanKind.CLIENT); + } + + @Test + void testCreateSpan_attempt_isClient() { + String spanName = "attempt-span"; + + when(tracer.spanBuilder(spanName)).thenReturn(spanBuilder); + when(spanBuilder.setSpanKind(SpanKind.CLIENT)).thenReturn(spanBuilder); + when(spanBuilder.startSpan()).thenReturn(span); + + recorder.createSpan(spanName, null); + + verify(spanBuilder).setSpanKind(SpanKind.CLIENT); + } + + @Test + void testCreateSpan_recordsSpan() { + String spanName = "test-span"; + Map attributes = ImmutableMap.of("key1", "value1"); + + when(tracer.spanBuilder(spanName)).thenReturn(spanBuilder); + when(spanBuilder.setSpanKind(SpanKind.CLIENT)).thenReturn(spanBuilder); + when(spanBuilder.setAttribute("key1", "value1")).thenReturn(spanBuilder); + when(spanBuilder.startSpan()).thenReturn(span); + + TraceRecorder.TraceSpan handle = recorder.createSpan(spanName, attributes); + handle.end(); + + verify(span).end(); + } +} 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 new file mode 100644 index 0000000000..f8602a6797 --- /dev/null +++ b/java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITOtelTracing.java @@ -0,0 +1,145 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.showcase.v1beta1.it; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.api.gax.tracing.AppCentricAttributes; +import com.google.api.gax.tracing.AppCentricTracer; +import com.google.api.gax.tracing.AppCentricTracerFactory; +import com.google.api.gax.tracing.OpenTelemetryTraceRecorder; +import com.google.showcase.v1beta1.EchoClient; +import com.google.showcase.v1beta1.EchoRequest; +import com.google.showcase.v1beta1.it.util.TestClientInitializer; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ITOtelTracing { + private static final String SHOWCASE_SERVER_ADDRESS = "localhost"; + + private InMemorySpanExporter spanExporter; + private OpenTelemetrySdk openTelemetrySdk; + + @BeforeEach + void setup() { + spanExporter = InMemorySpanExporter.create(); + + SdkTracerProvider tracerProvider = + SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)) + .build(); + + openTelemetrySdk = + OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).buildAndRegisterGlobal(); + } + + @AfterEach + void tearDown() { + if (openTelemetrySdk != null) { + openTelemetrySdk.close(); + } + GlobalOpenTelemetry.resetForTest(); + } + + @Test + void testTracing_successfulEcho_grpc() throws Exception { + AppCentricTracerFactory tracingFactory = + new AppCentricTracerFactory(new OpenTelemetryTraceRecorder(openTelemetrySdk)); + + try (EchoClient client = + TestClientInitializer.createGrpcEchoClientOpentelemetry(tracingFactory)) { + + client.echo(EchoRequest.newBuilder().setContent("tracing-test").build()); + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).isNotEmpty(); + + SpanData attemptSpan = + spans.stream() + .filter(span -> span.getName().equals("Echo/Echo/attempt")) + .findFirst() + .orElseThrow(() -> new AssertionError("Attempt span 'Echo/Echo/attempt' not found")); + assertThat(attemptSpan.getKind()).isEqualTo(SpanKind.CLIENT); + assertThat( + attemptSpan + .getAttributes() + .get(AttributeKey.stringKey(AppCentricTracer.LANGUAGE_ATTRIBUTE))) + .isEqualTo(AppCentricTracer.DEFAULT_LANGUAGE); + assertThat( + attemptSpan + .getAttributes() + .get(AttributeKey.stringKey(AppCentricAttributes.SERVER_ADDRESS_ATTRIBUTE))) + .isEqualTo(SHOWCASE_SERVER_ADDRESS); + } + } + + @Test + void testTracing_successfulEcho_httpjson() throws Exception { + AppCentricTracerFactory tracingFactory = + new AppCentricTracerFactory(new OpenTelemetryTraceRecorder(openTelemetrySdk)); + + try (EchoClient client = + TestClientInitializer.createHttpJsonEchoClientOpentelemetry(tracingFactory)) { + + client.echo(EchoRequest.newBuilder().setContent("tracing-test").build()); + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).isNotEmpty(); + + SpanData attemptSpan = + spans.stream() + .filter(span -> span.getName().equals("google.showcase.v1beta1/Echo/Echo/attempt")) + .findFirst() + .orElseThrow(() -> new AssertionError("Attempt span 'Echo/Echo/attempt' not found")); + assertThat(attemptSpan.getKind()).isEqualTo(SpanKind.CLIENT); + assertThat( + attemptSpan + .getAttributes() + .get(AttributeKey.stringKey(AppCentricTracer.LANGUAGE_ATTRIBUTE))) + .isEqualTo(AppCentricTracer.DEFAULT_LANGUAGE); + assertThat( + attemptSpan + .getAttributes() + .get(AttributeKey.stringKey(AppCentricAttributes.SERVER_ADDRESS_ATTRIBUTE))) + .isEqualTo(SHOWCASE_SERVER_ADDRESS); + } + } +}