diff --git a/google-cloud-spanner/pom.xml b/google-cloud-spanner/pom.xml index 390fbcfe21..4d217bcc0e 100644 --- a/google-cloud-spanner/pom.xml +++ b/google-cloud-spanner/pom.xml @@ -455,6 +455,24 @@ opentelemetry-sdk-testing test + + com.google.cloud.opentelemetry + exporter-trace + 0.33.0 + test + + + com.google.cloud + google-cloud-trace + 2.51.0 + test + + + com.google.api.grpc + proto-google-cloud-trace-v1 + 2.51.0 + test + diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/IntegrationTestEnv.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/IntegrationTestEnv.java index 4593c04cc1..b7cdcd3d47 100644 --- a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/IntegrationTestEnv.java +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/IntegrationTestEnv.java @@ -22,14 +22,27 @@ import com.google.api.client.util.ExponentialBackOff; import com.google.api.gax.longrunning.OperationFuture; import com.google.cloud.Timestamp; +import com.google.cloud.opentelemetry.trace.TraceConfiguration; +import com.google.cloud.opentelemetry.trace.TraceExporter; import com.google.cloud.spanner.DatabaseInfo.DatabaseField; import com.google.cloud.spanner.testing.EmulatorSpannerHelper; import com.google.cloud.spanner.testing.RemoteSpannerHelper; import com.google.common.collect.Iterators; import com.google.spanner.admin.instance.v1.CreateInstanceMetadata; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import java.util.Collection; +import java.util.Collections; import java.util.Objects; import java.util.Random; import java.util.concurrent.ExecutionException; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; @@ -67,10 +80,22 @@ public class IntegrationTestEnv extends ExternalResource { private final boolean alwaysCreateNewInstance; private RemoteSpannerHelper testHelper; + private Collection testEnvOptions = Collections.emptyList(); + + public enum TestEnvOptions { + USE_END_TO_END_TRACING; + // TODO : Move alwaysCreateNewInstance to TestEnvOptions + } + public IntegrationTestEnv() { this(false); } + public IntegrationTestEnv(Collection testEnvOptions) { + this(false); + this.testEnvOptions = testEnvOptions; + } + public IntegrationTestEnv(final boolean alwaysCreateNewInstance) { this.alwaysCreateNewInstance = alwaysCreateNewInstance; } @@ -107,8 +132,15 @@ protected void before() throws Throwable { assumeFalse(alwaysCreateNewInstance && isCloudDevel()); this.config.setUp(); - SpannerOptions options = config.spannerOptions(); + if (testEnvOptions.stream() + .anyMatch(testEnvOption -> TestEnvOptions.USE_END_TO_END_TRACING.equals(testEnvOption))) { + // OpenTelemetry set up for enabling End to End tracing for all integration test env. + // The gRPC stub and connections are created during test env set up using SpannerOptions and + // are + // reused for executing statements. + options = spannerOptionsWithEndToEndTracing(options); + } String instanceProperty = System.getProperty(TEST_INSTANCE_PROPERTY, ""); InstanceId instanceId; if (!instanceProperty.isEmpty() && !alwaysCreateNewInstance) { @@ -133,6 +165,38 @@ protected void before() throws Throwable { } } + public SpannerOptions spannerOptionsWithEndToEndTracing(SpannerOptions options) { + assumeFalse("This test requires credentials", EmulatorSpannerHelper.isUsingEmulator()); + + TraceConfiguration.Builder traceConfigurationBuilder = TraceConfiguration.builder(); + if (options.getCredentials() != null) { + traceConfigurationBuilder.setCredentials(options.getCredentials()); + } + SpanExporter traceExporter = + TraceExporter.createWithConfiguration( + traceConfigurationBuilder.setProjectId(options.getProjectId()).build()); + + String serviceName = "java-spanner-integration-tests-" + ThreadLocalRandom.current().nextInt(); + SdkTracerProvider sdkTracerProvider = + SdkTracerProvider.builder() + // Always sample in this test to ensure we know what we get. + .setSampler(Sampler.alwaysOn()) + .setResource(Resource.builder().put("service.name", serviceName).build()) + .addSpanProcessor(BatchSpanProcessor.builder(traceExporter).build()) + .build(); + OpenTelemetrySdk openTelemetry = + OpenTelemetrySdk.builder() + .setTracerProvider(sdkTracerProvider) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build(); + SpannerOptions.enableOpenTelemetryTraces(); + return options + .toBuilder() + .setOpenTelemetry(openTelemetry) + .setEnableEndToEndTracing(true) + .build(); + } + RemoteSpannerHelper createTestHelper(SpannerOptions options, InstanceId instanceId) throws Throwable { return RemoteSpannerHelper.create(options, instanceId); diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITEndToEndTracingTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITEndToEndTracingTest.java new file mode 100644 index 0000000000..6c63a11402 --- /dev/null +++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/it/ITEndToEndTracingTest.java @@ -0,0 +1,158 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.spanner.it; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.ResourceExhaustedException; +import com.google.api.gax.rpc.StatusCode; +import com.google.cloud.spanner.Database; +import com.google.cloud.spanner.DatabaseClient; +import com.google.cloud.spanner.IntegrationTestEnv; +import com.google.cloud.spanner.IntegrationTestEnv.TestEnvOptions; +import com.google.cloud.spanner.ParallelIntegrationTest; +import com.google.cloud.spanner.ResultSet; +import com.google.cloud.spanner.SpannerOptions; +import com.google.cloud.spanner.SpannerOptionsHelper; +import com.google.cloud.spanner.Statement; +import com.google.cloud.spanner.Struct; +import com.google.cloud.spanner.Type; +import com.google.cloud.spanner.Type.StructField; +import com.google.cloud.spanner.connection.ConnectionOptions; +import com.google.cloud.trace.v1.TraceServiceClient; +import com.google.cloud.trace.v1.TraceServiceSettings; +import com.google.common.base.Stopwatch; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.concurrent.TimeUnit; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Integration tests for End to End Tracing. */ +@Category(ParallelIntegrationTest.class) +@RunWith(JUnit4.class) +public class ITEndToEndTracingTest { + public static Collection testEnvOptions = + Arrays.asList(TestEnvOptions.USE_END_TO_END_TRACING); + @ClassRule public static IntegrationTestEnv env = new IntegrationTestEnv(testEnvOptions); + private static DatabaseClient googleStandardSQLClient; + + static { + SpannerOptionsHelper.resetActiveTracingFramework(); + SpannerOptions.enableOpenTelemetryTraces(); + } + + private static String selectValueQuery = "SELECT @p1 + @p1"; + + @BeforeClass + public static void setUp() { + setUpDatabase(); + } + + public static void setUpDatabase() { + // Empty database. + Database googleStandardSQLDatabase = env.getTestHelper().createTestDatabase(); + googleStandardSQLClient = env.getTestHelper().getDatabaseClient(googleStandardSQLDatabase); + } + + @AfterClass + public static void teardown() { + ConnectionOptions.closeSpanner(); + } + + private void assertTrace(String traceId) throws IOException, InterruptedException { + TraceServiceSettings settings = + env.getTestHelper().getOptions().getCredentials() == null + ? TraceServiceSettings.newBuilder().build() + : TraceServiceSettings.newBuilder() + .setCredentialsProvider( + FixedCredentialsProvider.create( + env.getTestHelper().getOptions().getCredentials())) + .build(); + try (TraceServiceClient client = TraceServiceClient.create(settings)) { + boolean foundTrace = false; + Stopwatch metricsPollingStopwatch = Stopwatch.createStarted(); + while (!foundTrace && metricsPollingStopwatch.elapsed(TimeUnit.SECONDS) < 30) { + // Try every 5 seconds + Thread.sleep(5000); + try { + foundTrace = + client.getTrace(env.getTestHelper().getInstanceId().getProject(), traceId) + .getSpansList().stream() + .anyMatch(span -> "Spanner.ExecuteStreamingSql".equals(span.getName())); + } catch (ApiException apiException) { + assumeTrue( + apiException.getStatusCode() != null + && StatusCode.Code.NOT_FOUND.equals(apiException.getStatusCode().getCode())); + System.out.println("Trace NOT_FOUND error ignored"); + } + } + assertTrue(foundTrace); + } catch (ResourceExhaustedException resourceExhaustedException) { + if (resourceExhaustedException + .getMessage() + .contains("Quota exceeded for quota metric 'Read requests (free)'")) { + // Ignore and allow the test to succeed. + System.out.println("RESOURCE_EXHAUSTED error ignored"); + } else { + throw resourceExhaustedException; + } + } + } + + private Struct executeWithRowResultType(Statement statement, Type expectedRowType) { + ResultSet resultSet = statement.executeQuery(googleStandardSQLClient.singleUse()); + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getType()).isEqualTo(expectedRowType); + Struct row = resultSet.getCurrentRowAsStruct(); + assertThat(resultSet.next()).isFalse(); + return row; + } + + @Test + public void simpleSelect() throws IOException, InterruptedException { + Tracer tracer = + env.getTestHelper() + .getOptions() + .getOpenTelemetry() + .getTracer(ITEndToEndTracingTest.class.getName()); + Span span = tracer.spanBuilder("simpleSelect").startSpan(); + Scope scope = span.makeCurrent(); + Type rowType = Type.struct(StructField.of("", Type.int64())); + Struct row = + executeWithRowResultType( + Statement.newBuilder(selectValueQuery).bind("p1").to(1234).build(), rowType); + assertThat(row.isNull(0)).isFalse(); + assertThat(row.getLong(0)).isEqualTo(2468); + scope.close(); + span.end(); + assertTrace(span.getSpanContext().getTraceId()); + } +}