diff --git a/.github/scripts/release_manager_merge_bot b/.github/scripts/release_manager_merge_bot deleted file mode 100755 index 8b7504a0b2f0..000000000000 Binary files a/.github/scripts/release_manager_merge_bot and /dev/null differ diff --git a/.github/scripts/release_manager_merge_bot.go b/.github/scripts/release_manager_merge_bot.go index 3be1130d55e6..1e4de54bce53 100644 --- a/.github/scripts/release_manager_merge_bot.go +++ b/.github/scripts/release_manager_merge_bot.go @@ -20,12 +20,6 @@ // 3. If the status is "success", it will squash and merge the pull request. // 4. If the status is "pending", it will wait and check again. // -// Flags: -// -skip-kokoro (Optional) If set, skips applying Kokoro rerunning labels on failure. -// -email (Optional) Email address to send success/failure notifications to. -// Note: This relies on the internal sendgmr tool and is only -// supported on Cloudtop/gLinux with valid LOAS credentials. -// // Prerequisites: // - Go must be installed (https://golang.org/doc/install). // - A GitHub personal access token with repo scope must be set in the GITHUB_TOKEN environment variable. @@ -34,18 +28,16 @@ // // export GITHUB_TOKEN="" // cd .github/scripts -// go run ./release_manager_merge_bot.go -skip-kokoro -email="user@google.com" +// go run ./release_manager_merge_bot.go package main import ( "context" - "flag" "fmt" "log" "net/url" "os" - "os/exec" "strconv" "strings" "time" @@ -60,11 +52,6 @@ var labelsToAdd = []string{"kokoro:force-run", "kokoro:run"} // --- End of Configuration --- -var ( - skipKokoroOpt bool - emailOpt string -) - // parseURL parses a GitHub pull request URL and returns the owner, repository, and PR number. func parseURL(prURL string) (string, string, int, error) { parsedURL, err := url.Parse(prURL) @@ -108,43 +95,13 @@ func getMissingLabels(ctx context.Context, client *github.Client, owner, repo st return missingLabels, nil } -// sendEmail sends an email notification using the internal sendgmr tool. -func sendEmail(to, subject, body string) { - if to == "" { - return - } - sendgmrPath := "/google/bin/releases/gws-sre/files/sendgmr/sendgmr" - cmd := exec.Command(sendgmrPath, "--to="+to, "--subject="+subject) - cmd.Stdin = strings.NewReader(body) - if err := cmd.Run(); err != nil { - log.Printf("Warning: Failed to send email: %v", err) - } else { - log.Printf("Email successfully sent to %s", to) - } -} - -// fatalError logs an error message, optionally sends an email, and exits. -func fatalError(format string, v ...interface{}) { - msg := fmt.Sprintf(format, v...) - log.Printf("Error: %s", msg) - if emailOpt != "" { - sendEmail(emailOpt, "❌ Release Manager Merge Bot Failed", msg) - } - os.Exit(1) -} - func main() { log.Println("Starting the release manager merge bot.") - flag.BoolVar(&skipKokoroOpt, "skip-kokoro", false, "Skip applying kokoro rerunning labels on failure") - flag.StringVar(&emailOpt, "email", "", "Email address to send notifications to (requires Cloudtop/gLinux and LOAS/gcert)") - flag.Parse() - - args := flag.Args() - if len(args) < 1 { - log.Fatal("Error: Pull request URL is required. Example: go run ./release_manager_merge_bot.go [flags] ") + if len(os.Args) < 2 { + log.Fatal("Error: Pull request URL is required. Example: go run ./release_manager_merge_bot.go ") } - prURL := args[0] + prURL := os.Args[1] githubToken := os.Getenv("GITHUB_TOKEN") if githubToken == "" { @@ -153,11 +110,7 @@ func main() { owner, repo, prNumber, err := parseURL(prURL) if err != nil { - fatalError("Error parsing URL: %v", err) - } - - if emailOpt != "" { - log.Printf("Notifications will be sent to: %s", emailOpt) + log.Fatalf("Error parsing URL: %v", err) } ctx := context.Background() @@ -167,25 +120,21 @@ func main() { // --- Initial Label Check --- retryCount := 0 - if !skipKokoroOpt { - log.Printf("Performing initial label check for PR #%d...", prNumber) - missingLabels, err := getMissingLabels(ctx, client, owner, repo, prNumber) - if err != nil { - log.Printf("Warning: could not perform initial label check: %v", err) - } else { - if len(missingLabels) > 0 { - log.Println("Required Kokoro labels are missing. Adding them now...") - _, _, err := client.Issues.AddLabelsToIssue(ctx, owner, repo, prNumber, missingLabels) - if err != nil { - log.Printf("Warning: failed to add labels: %v", err) - } - retryCount++ - } else { - log.Println("Required Kokoro labels are already present.") + log.Printf("Performing initial label check for PR #%d...", prNumber) + missingLabels, err := getMissingLabels(ctx, client, owner, repo, prNumber) + if err != nil { + log.Printf("Warning: could not perform initial label check: %v", err) + } else { + if len(missingLabels) > 0 { + log.Println("Required Kokoro labels are missing. Adding them now...") + _, _, err := client.Issues.AddLabelsToIssue(ctx, owner, repo, prNumber, missingLabels) + if err != nil { + log.Printf("Warning: failed to add labels: %v", err) } + retryCount++ + } else { + log.Println("Required Kokoro labels are already present.") } - } else { - log.Println("Skipping initial Kokoro label check due to -skip-kokoro flag.") } // --- End of Initial Label Check --- @@ -217,11 +166,8 @@ func main() { switch state { case "failure": - if skipKokoroOpt { - fatalError("PR #%d has failed checks and -skip-kokoro is enabled. Failing the script.", prNumber) - } if retryCount >= 2 { - fatalError("The PR has failed twice after applying the Kokoro labels. Failing the script.") + log.Fatal("The PR has failed twice after applying the Kokoro labels. Failing the script.") } log.Println("Some checks have failed. Retrying the tests...") _, _, err := client.Issues.AddLabelsToIssue(ctx, owner, repo, prNumber, labelsToAdd) @@ -236,13 +182,9 @@ func main() { MergeMethod: "squash", }) if err != nil { - fatalError("Failed to merge PR: %v", err) - } - successMsg := fmt.Sprintf("Successfully squashed and merged PR #%d: %s", prNumber, *mergeResult.Message) - log.Println(successMsg) - if emailOpt != "" { - sendEmail(emailOpt, fmt.Sprintf("✅ PR #%d Merged Successfully", prNumber), successMsg) + log.Fatalf("Failed to merge PR: %v", err) } + log.Printf("Successfully squashed and merged PR #%d: %s", prNumber, *mergeResult.Message) return // Exit the program on success case "pending": log.Println("Some checks are still pending. Waiting for them to complete.") diff --git a/java-compute/google-cloud-compute/pom.xml b/java-compute/google-cloud-compute/pom.xml index ec8552b98f44..df57c0681280 100644 --- a/java-compute/google-cloud-compute/pom.xml +++ b/java-compute/google-cloud-compute/pom.xml @@ -92,6 +92,11 @@ google-cloud-core test + + com.google.api + gax-grpc + test + diff --git a/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreException.java b/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreException.java index 44bde2c107ff..1be84b859c6c 100644 --- a/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreException.java +++ b/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreException.java @@ -160,4 +160,33 @@ static DatastoreException throwInvalidRequest(String massage, Object... params) static DatastoreException propagateUserException(Exception ex) { throw new DatastoreException(BaseServiceException.UNKNOWN_CODE, ex.getMessage(), null, ex); } + + /** + * Extracts the status code name from the given throwable. Walks the exception cause chain looking + * for a {@link DatastoreException} that carries a reason string representing the status code + * (e.g. "ABORTED", "UNAVAILABLE"). The reason is set from {@link + * com.google.api.gax.rpc.StatusCode.Code} which is transport-neutral, supporting both gRPC and + * HttpJson. Falls back to "UNKNOWN" if the status cannot be determined. + * + *

Note: Some {@link DatastoreException} instances are constructed without a reason (e.g. via + * {@link DatastoreException#DatastoreException(int, String, Throwable)}). If all {@link + * DatastoreException} instances in the cause chain have a null or empty reason, this method + * returns "UNKNOWN" even if the underlying error carries a meaningful status. + * + * @param throwable the throwable to extract the status code from + * @return the status code name, or "UNKNOWN" if not determinable + */ + public static String extractStatusCode(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof DatastoreException) { + String reason = ((DatastoreException) current).getReason(); + if (!Strings.isNullOrEmpty(reason)) { + return reason; + } + } + current = current.getCause(); + } + return StatusCode.Code.UNKNOWN.toString(); + } } diff --git a/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreImpl.java b/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreImpl.java index 63d09b0a0ed4..a8c71d5e49c8 100644 --- a/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreImpl.java +++ b/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreImpl.java @@ -38,6 +38,7 @@ import com.google.api.core.BetaApi; import com.google.api.gax.retrying.RetrySettings; +import com.google.api.gax.rpc.StatusCode; import com.google.cloud.BaseService; import com.google.cloud.ExceptionHandler; import com.google.cloud.RetryHelper; @@ -45,10 +46,13 @@ import com.google.cloud.ServiceOptions; import com.google.cloud.datastore.execution.AggregationQueryExecutor; import com.google.cloud.datastore.spi.v1.DatastoreRpc; +import com.google.cloud.datastore.telemetry.MetricsRecorder; +import com.google.cloud.datastore.telemetry.TelemetryConstants; import com.google.cloud.datastore.telemetry.TraceUtil; import com.google.cloud.datastore.telemetry.TraceUtil.Scope; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; +import com.google.common.base.Stopwatch; import com.google.common.collect.AbstractIterator; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -61,10 +65,12 @@ import com.google.datastore.v1.RunQueryResponse; import com.google.datastore.v1.TransactionOptions; import com.google.protobuf.ByteString; +import io.grpc.Status; import io.opentelemetry.context.Context; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -73,6 +79,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Nullable; @@ -89,6 +96,7 @@ final class DatastoreImpl extends BaseService implements Datas private final com.google.cloud.datastore.telemetry.TraceUtil otelTraceUtil = getOptions().getTraceUtil(); + private final MetricsRecorder metricsRecorder = getOptions().getMetricsRecorder(); private final ReadOptionProtoPreparer readOptionProtoPreparer; private final AggregationQueryExecutor aggregationQueryExecutor; @@ -122,63 +130,31 @@ public Transaction newTransaction() { return new TransactionImpl(this); } + /** + * A wrapper around {@link ReadWriteTransactionCallable} that adds OpenTelemetry tracing context + * propagation. All transaction logic (begin, run, commit, rollback, metrics recording) is + * delegated to the underlying {@link ReadWriteTransactionCallable}. + */ static class TracedReadWriteTransactionCallable implements Callable { - private final Datastore datastore; - private final TransactionCallable callable; - private volatile TransactionOptions options; - private volatile Transaction transaction; - + private final ReadWriteTransactionCallable delegate; private final TraceUtil.Span parentSpan; TracedReadWriteTransactionCallable( - Datastore datastore, - TransactionCallable callable, - TransactionOptions options, + ReadWriteTransactionCallable delegate, @Nullable com.google.cloud.datastore.telemetry.TraceUtil.Span parentSpan) { - this.datastore = datastore; - this.callable = callable; - this.options = options; - this.transaction = null; + this.delegate = delegate; this.parentSpan = parentSpan; } - Datastore getDatastore() { - return datastore; - } - - TransactionOptions getOptions() { - return options; - } - - Transaction getTransaction() { - return transaction; - } - - void setPrevTransactionId(ByteString transactionId) { - TransactionOptions.ReadWrite readWrite = - TransactionOptions.ReadWrite.newBuilder().setPreviousTransaction(transactionId).build(); - options = options.toBuilder().setReadWrite(readWrite).build(); + ReadWriteTransactionCallable getDelegate() { + return delegate; } @Override public T call() throws DatastoreException { try (io.opentelemetry.context.Scope ignored = Context.current().with(parentSpan.getSpan()).makeCurrent()) { - transaction = datastore.newTransaction(options); - T value = callable.run(transaction); - transaction.commit(); - return value; - } catch (Exception ex) { - transaction.rollback(); - throw DatastoreException.propagateUserException(ex); - } finally { - if (transaction.isActive()) { - transaction.rollback(); - } - if (options != null - && options.getModeCase().equals(TransactionOptions.ModeCase.READ_WRITE)) { - setPrevTransactionId(transaction.getTransactionId()); - } + return delegate.call(); } } } @@ -200,14 +176,19 @@ public boolean isClosed() { static class ReadWriteTransactionCallable implements Callable { private final Datastore datastore; private final TransactionCallable callable; + private final MetricsRecorder metricsRecorder; private volatile TransactionOptions options; private volatile Transaction transaction; ReadWriteTransactionCallable( - Datastore datastore, TransactionCallable callable, TransactionOptions options) { + Datastore datastore, + TransactionCallable callable, + TransactionOptions options, + MetricsRecorder metricsRecorder) { this.datastore = datastore; this.callable = callable; this.options = options; + this.metricsRecorder = metricsRecorder; this.transaction = null; } @@ -231,15 +212,28 @@ void setPrevTransactionId(ByteString transactionId) { @Override public T call() throws DatastoreException { + String attemptStatus = StatusCode.Code.UNKNOWN.toString(); try { transaction = datastore.newTransaction(options); T value = callable.run(transaction); transaction.commit(); + attemptStatus = Status.Code.OK.toString(); return value; } catch (Exception ex) { - transaction.rollback(); + attemptStatus = DatastoreException.extractStatusCode(ex); + // An exception here can originate from either callable.run() (before commit was attempted) + // or from transaction.commit() itself. In both cases the transaction is still active. + // isActive() returns false if the transaction was already committed or rolled back, so + // it is safe to use as the sole guard here without tracking a separate committed flag. + if (transaction.isActive()) { + transaction.rollback(); + } throw DatastoreException.propagateUserException(ex); } finally { + recordAttempt(attemptStatus); + // transaction.isActive() returns false after both a successful commit or a completed + // rollback, so it already guards against rolling back a committed transaction or + // rolling back a transaction that has already been rolled back. if (transaction.isActive()) { transaction.rollback(); } @@ -249,28 +243,27 @@ public T call() throws DatastoreException { } } } + + /** + * Records a single transaction commit attempt with the given status code. This is called once + * per invocation of {@link #call()}, capturing the outcome of each individual commit attempt. + */ + private void recordAttempt(String status) { + Map attributes = new HashMap<>(); + attributes.put(TelemetryConstants.ATTRIBUTES_KEY_STATUS, status); + attributes.put( + TelemetryConstants.ATTRIBUTES_KEY_METHOD, TelemetryConstants.METHOD_TRANSACTION_COMMIT); + attributes.put( + TelemetryConstants.ATTRIBUTES_KEY_PROJECT_ID, datastore.getOptions().getProjectId()); + attributes.put( + TelemetryConstants.ATTRIBUTES_KEY_DATABASE_ID, datastore.getOptions().getDatabaseId()); + metricsRecorder.recordTransactionAttemptCount(1, attributes); + } } @Override public T runInTransaction(final TransactionCallable callable) { - TraceUtil.Span span = otelTraceUtil.startSpan(SPAN_NAME_TRANSACTION_RUN); - Callable transactionCallable = - (getOptions().getOpenTelemetryOptions().isTracingEnabled() - ? new TracedReadWriteTransactionCallable( - this, callable, /* transactionOptions= */ null, span) - : new ReadWriteTransactionCallable(this, callable, /* transactionOptions= */ null)); - try (Scope ignored = span.makeCurrent()) { - return RetryHelper.runWithRetries( - transactionCallable, - retrySettings, - TRANSACTION_EXCEPTION_HANDLER, - getOptions().getClock()); - } catch (RetryHelperException e) { - span.end(e); - throw DatastoreException.translateAndThrow(e); - } finally { - span.end(); - } + return runInTransaction(callable, /* transactionOptions= */ null); } @Override @@ -278,11 +271,16 @@ public T runInTransaction( final TransactionCallable callable, TransactionOptions transactionOptions) { TraceUtil.Span span = otelTraceUtil.startSpan(SPAN_NAME_TRANSACTION_RUN); - Callable transactionCallable = - (getOptions().getOpenTelemetryOptions().isTracingEnabled() - ? new TracedReadWriteTransactionCallable(this, callable, transactionOptions, span) - : new ReadWriteTransactionCallable(this, callable, transactionOptions)); + ReadWriteTransactionCallable baseCallable = + new ReadWriteTransactionCallable<>(this, callable, transactionOptions, metricsRecorder); + + Callable transactionCallable = baseCallable; + if (getOptions().getOpenTelemetryOptions().isTracingEnabled()) { + transactionCallable = new TracedReadWriteTransactionCallable<>(baseCallable, span); + } + String status = StatusCode.Code.OK.toString(); + Stopwatch stopwatch = Stopwatch.createStarted(); try (Scope ignored = span.makeCurrent()) { return RetryHelper.runWithRetries( transactionCallable, @@ -290,9 +288,18 @@ public T runInTransaction( TRANSACTION_EXCEPTION_HANDLER, getOptions().getClock()); } catch (RetryHelperException e) { + status = DatastoreException.extractStatusCode(e); span.end(e); throw DatastoreException.translateAndThrow(e); } finally { + long latencyMs = stopwatch.elapsed(TimeUnit.MILLISECONDS); + Map attributes = new HashMap<>(); + attributes.put(TelemetryConstants.ATTRIBUTES_KEY_STATUS, status); + attributes.put( + TelemetryConstants.ATTRIBUTES_KEY_METHOD, TelemetryConstants.METHOD_TRANSACTION_RUN); + attributes.put(TelemetryConstants.ATTRIBUTES_KEY_PROJECT_ID, getOptions().getProjectId()); + attributes.put(TelemetryConstants.ATTRIBUTES_KEY_DATABASE_ID, getOptions().getDatabaseId()); + metricsRecorder.recordTransactionLatency(latencyMs, attributes); span.end(); } } diff --git a/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreOptions.java b/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreOptions.java index 242ce3b01776..1cd8e4038314 100644 --- a/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreOptions.java +++ b/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/DatastoreOptions.java @@ -31,6 +31,7 @@ import com.google.cloud.datastore.spi.v1.DatastoreRpc; import com.google.cloud.datastore.spi.v1.GrpcDatastoreRpc; import com.google.cloud.datastore.spi.v1.HttpDatastoreRpc; +import com.google.cloud.datastore.telemetry.MetricsRecorder; import com.google.cloud.datastore.v1.DatastoreSettings; import com.google.cloud.grpc.GrpcTransportOptions; import com.google.cloud.http.HttpTransportOptions; @@ -64,6 +65,7 @@ public class DatastoreOptions extends ServiceOptions { private String namespace; @@ -216,6 +223,7 @@ private DatastoreOptions(Builder builder) { ? builder.openTelemetryOptions : DatastoreOpenTelemetryOptions.newBuilder().build(); this.traceUtil = com.google.cloud.datastore.telemetry.TraceUtil.getInstance(this); + this.metricsRecorder = MetricsRecorder.getInstance(openTelemetryOptions); namespace = MoreObjects.firstNonNull(builder.namespace, defaultNamespace()); databaseId = MoreObjects.firstNonNull(builder.databaseId, DEFAULT_DATABASE_ID); diff --git a/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/telemetry/MetricsRecorder.java b/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/telemetry/MetricsRecorder.java index a8599e019b3c..ced2efc1da1f 100644 --- a/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/telemetry/MetricsRecorder.java +++ b/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/telemetry/MetricsRecorder.java @@ -16,14 +16,21 @@ package com.google.cloud.datastore.telemetry; +import com.google.api.core.InternalExtensionOnly; import com.google.cloud.datastore.DatastoreOpenTelemetryOptions; import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.OpenTelemetry; import java.util.Map; import javax.annotation.Nonnull; -/** Interface to record specific metric operations. */ -interface MetricsRecorder { +/** + * Interface to record specific metric operations. + * + *

Warning: This is an internal API and is not intended for external use. Do not implement + * or extend this interface. + */ +@InternalExtensionOnly +public interface MetricsRecorder { /** Records the total latency of a transaction in milliseconds. */ void recordTransactionLatency(double latencyMs, Map attributes); diff --git a/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/telemetry/OpenTelemetryMetricsRecorder.java b/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/telemetry/OpenTelemetryMetricsRecorder.java index 470ffe118b5c..73225dca9971 100644 --- a/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/telemetry/OpenTelemetryMetricsRecorder.java +++ b/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/telemetry/OpenTelemetryMetricsRecorder.java @@ -42,7 +42,7 @@ class OpenTelemetryMetricsRecorder implements MetricsRecorder { this.transactionLatency = meter .histogramBuilder(TelemetryConstants.SERVICE_NAME + "/transaction_latency") - .setDescription("Total latency for successful transaction operations") + .setDescription("Total latency of transaction operations") .setUnit("ms") .build(); diff --git a/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/telemetry/TelemetryConstants.java b/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/telemetry/TelemetryConstants.java index 263b85526501..7c0b49f75037 100644 --- a/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/telemetry/TelemetryConstants.java +++ b/java-datastore/google-cloud-datastore/src/main/java/com/google/cloud/datastore/telemetry/TelemetryConstants.java @@ -33,6 +33,18 @@ public class TelemetryConstants { public static final String ATTRIBUTES_KEY_DEFERRED = "Deferred"; public static final String ATTRIBUTES_KEY_MORE_RESULTS = "more_results"; + /** Attribute key for the gRPC status code (e.g. "OK", "ABORTED", "UNAVAILABLE"). */ + public static final String ATTRIBUTES_KEY_STATUS = "status"; + + /** Attribute key for the RPC method name (e.g. "Transaction.Run"). */ + public static final String ATTRIBUTES_KEY_METHOD = "method"; + + /** Attribute key for the GCP project ID. */ + public static final String ATTRIBUTES_KEY_PROJECT_ID = "project_id"; + + /** Attribute key for the Datastore database ID. */ + public static final String ATTRIBUTES_KEY_DATABASE_ID = "database_id"; + /* TODO(lawrenceqiu): For now, these are a duplicate of method names in TraceUtil. Those will use these eventually */ // Format is not SnakeCase to keep backward compatibility with the existing values TraceUtil spans public static final String METHOD_ALLOCATE_IDS = "AllocateIds"; diff --git a/java-datastore/google-cloud-datastore/src/test/java/com/google/cloud/datastore/DatastoreImplMetricsTest.java b/java-datastore/google-cloud-datastore/src/test/java/com/google/cloud/datastore/DatastoreImplMetricsTest.java new file mode 100644 index 000000000000..7879c0dbddb2 --- /dev/null +++ b/java-datastore/google-cloud-datastore/src/test/java/com/google/cloud/datastore/DatastoreImplMetricsTest.java @@ -0,0 +1,333 @@ +/* + * Copyright 2026 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.datastore; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.cloud.NoCredentials; +import com.google.cloud.ServiceOptions; +import com.google.cloud.datastore.spi.DatastoreRpcFactory; +import com.google.cloud.datastore.spi.v1.DatastoreRpc; +import com.google.cloud.datastore.telemetry.TelemetryConstants; +import com.google.datastore.v1.BeginTransactionRequest; +import com.google.datastore.v1.BeginTransactionResponse; +import com.google.datastore.v1.CommitRequest; +import com.google.datastore.v1.CommitResponse; +import com.google.datastore.v1.RollbackRequest; +import com.google.datastore.v1.RollbackResponse; +import com.google.datastore.v1.TransactionOptions; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.data.HistogramPointData; +import io.opentelemetry.sdk.metrics.data.LongPointData; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.util.Collection; +import java.util.Optional; +import org.easymock.EasyMock; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Tests for transaction metrics recording in {@link DatastoreImpl}. These tests verify that + * transaction latency and per-attempt metrics are correctly recorded via the {@link + * com.google.cloud.datastore.telemetry.MetricsRecorder}. + */ +@RunWith(JUnit4.class) +public class DatastoreImplMetricsTest { + + private static final String PROJECT_ID = "test-project"; + private static final String LATENCY_METRIC_NAME = "datastore.googleapis.com/transaction_latency"; + private static final String ATTEMPT_COUNT_METRIC_NAME = + "datastore.googleapis.com/transaction_attempt_count"; + + private InMemoryMetricReader metricReader; + private DatastoreRpcFactory rpcFactoryMock; + private DatastoreRpc rpcMock; + private DatastoreOptions datastoreOptions; + + @Before + public void setUp() { + metricReader = InMemoryMetricReader.create(); + SdkMeterProvider meterProvider = + SdkMeterProvider.builder().registerMetricReader(metricReader).build(); + OpenTelemetrySdk openTelemetry = + OpenTelemetrySdk.builder().setMeterProvider(meterProvider).build(); + + rpcFactoryMock = EasyMock.createStrictMock(DatastoreRpcFactory.class); + rpcMock = EasyMock.createStrictMock(DatastoreRpc.class); + + datastoreOptions = + DatastoreOptions.newBuilder() + .setProjectId(PROJECT_ID) + .setCredentials(NoCredentials.getInstance()) + .setRetrySettings(ServiceOptions.getDefaultRetrySettings()) + .setServiceRpcFactory(rpcFactoryMock) + .setOpenTelemetryOptions( + DatastoreOpenTelemetryOptions.newBuilder() + .setMetricsEnabled(true) + .setOpenTelemetry(openTelemetry) + .build()) + .build(); + + EasyMock.expect(rpcFactoryMock.create(datastoreOptions)).andReturn(rpcMock); + } + + @Test + public void runInTransaction_recordsLatencyOnSuccess() { + // Mock a successful transaction: begin -> commit + EasyMock.expect(rpcMock.beginTransaction(EasyMock.anyObject(BeginTransactionRequest.class))) + .andReturn(BeginTransactionResponse.getDefaultInstance()); + EasyMock.expect(rpcMock.commit(EasyMock.anyObject(CommitRequest.class))) + .andReturn(CommitResponse.newBuilder().build()); + EasyMock.replay(rpcFactoryMock, rpcMock); + + Datastore datastore = datastoreOptions.getService(); + datastore.runInTransaction(transaction -> null); + + // Verify latency metric was recorded with status OK + Optional latencyMetric = findMetric(LATENCY_METRIC_NAME); + assertThat(latencyMetric.isPresent()).isTrue(); + + HistogramPointData point = + latencyMetric.get().getHistogramData().getPoints().stream().findFirst().orElse(null); + assertThat(point).isNotNull(); + assertThat(point.getCount()).isEqualTo(1); + assertThat(point.getSum()).isAtLeast(0.0); + assertThat(point.getAttributes().get(AttributeKey.stringKey("status"))).isEqualTo("OK"); + assertThat(point.getAttributes().get(AttributeKey.stringKey("method"))) + .isEqualTo(TelemetryConstants.METHOD_TRANSACTION_RUN); + assertThat(point.getAttributes().get(AttributeKey.stringKey("project_id"))) + .isEqualTo(PROJECT_ID); + assertThat(point.getAttributes().get(AttributeKey.stringKey("database_id"))).isNotNull(); + + EasyMock.verify(rpcFactoryMock, rpcMock); + } + + @Test + public void runInTransaction_recordsPerAttemptCountOnSuccess() { + // Mock a successful transaction: begin -> commit + EasyMock.expect(rpcMock.beginTransaction(EasyMock.anyObject(BeginTransactionRequest.class))) + .andReturn(BeginTransactionResponse.getDefaultInstance()); + EasyMock.expect(rpcMock.commit(EasyMock.anyObject(CommitRequest.class))) + .andReturn(CommitResponse.newBuilder().build()); + EasyMock.replay(rpcFactoryMock, rpcMock); + + Datastore datastore = datastoreOptions.getService(); + datastore.runInTransaction(transaction -> null); + + // Verify attempt count was recorded once with status OK + Optional attemptMetric = findMetric(ATTEMPT_COUNT_METRIC_NAME); + assertThat(attemptMetric.isPresent()).isTrue(); + + LongPointData point = + attemptMetric.get().getLongSumData().getPoints().stream() + .filter(p -> "OK".equals(p.getAttributes().get(AttributeKey.stringKey("status")))) + .findFirst() + .orElse(null); + assertThat(point).isNotNull(); + assertThat(point.getValue()).isEqualTo(1); + assertThat(point.getAttributes().get(AttributeKey.stringKey("method"))) + .isEqualTo(TelemetryConstants.METHOD_TRANSACTION_COMMIT); + + EasyMock.verify(rpcFactoryMock, rpcMock); + } + + @Test + public void runInTransaction_recordsPerAttemptOnRetry() { + // First attempt: begin -> ABORTED -> rollback + EasyMock.expect(rpcMock.beginTransaction(EasyMock.anyObject(BeginTransactionRequest.class))) + .andReturn(BeginTransactionResponse.getDefaultInstance()); + EasyMock.expect(rpcMock.rollback(EasyMock.anyObject(RollbackRequest.class))) + .andReturn(RollbackResponse.getDefaultInstance()); + + // Second attempt: begin -> commit (success) + EasyMock.expect(rpcMock.beginTransaction(EasyMock.anyObject(BeginTransactionRequest.class))) + .andReturn(BeginTransactionResponse.getDefaultInstance()); + EasyMock.expect(rpcMock.commit(EasyMock.anyObject(CommitRequest.class))) + .andReturn(CommitResponse.newBuilder().build()); + EasyMock.replay(rpcFactoryMock, rpcMock); + + Datastore datastore = datastoreOptions.getService(); + + TransactionOptions options = + TransactionOptions.newBuilder() + .setReadWrite(TransactionOptions.ReadWrite.getDefaultInstance()) + .build(); + + Datastore.TransactionCallable callable = + new Datastore.TransactionCallable() { + private int attempts = 0; + + @Override + public Integer run(DatastoreReaderWriter transaction) { + attempts++; + if (attempts < 2) { + throw new DatastoreException(10, "", "ABORTED", false, null); + } + return attempts; + } + }; + + Integer result = datastore.runInTransaction(callable, options); + assertThat(result).isEqualTo(2); + + // Verify attempt count has two data points: one with ABORTED and one with OK + Optional attemptMetric = findMetric(ATTEMPT_COUNT_METRIC_NAME); + assertThat(attemptMetric.isPresent()).isTrue(); + assertThat(attemptMetric.get().getLongSumData().getPoints()).hasSize(2); + + // Verify ABORTED attempt + LongPointData abortedPoint = + attemptMetric.get().getLongSumData().getPoints().stream() + .filter(p -> "ABORTED".equals(p.getAttributes().get(AttributeKey.stringKey("status")))) + .findFirst() + .orElse(null); + assertThat(abortedPoint).isNotNull(); + assertThat(abortedPoint.getValue()).isEqualTo(1); + + // Verify OK attempt + LongPointData okPoint = + attemptMetric.get().getLongSumData().getPoints().stream() + .filter(p -> "OK".equals(p.getAttributes().get(AttributeKey.stringKey("status")))) + .findFirst() + .orElse(null); + assertThat(okPoint).isNotNull(); + assertThat(okPoint.getValue()).isEqualTo(1); + + // Verify latency was recorded with OK (overall transaction succeeded) + Optional latencyMetric = findMetric(LATENCY_METRIC_NAME); + assertThat(latencyMetric.isPresent()).isTrue(); + HistogramPointData latencyPoint = + latencyMetric.get().getHistogramData().getPoints().stream().findFirst().orElse(null); + assertThat(latencyPoint).isNotNull(); + assertThat(latencyPoint.getAttributes().get(AttributeKey.stringKey("status"))).isEqualTo("OK"); + + EasyMock.verify(rpcFactoryMock, rpcMock); + } + + @Test + public void runInTransaction_recordsGrpcStatusCodeOnFailure() { + // This test uses a separate set of nice mocks since the retry loop makes + // multiple begin/rollback calls whose exact count depends on retry settings. + DatastoreRpcFactory localRpcFactoryMock = EasyMock.createNiceMock(DatastoreRpcFactory.class); + DatastoreRpc localRpcMock = EasyMock.createNiceMock(DatastoreRpc.class); + + InMemoryMetricReader localMetricReader = InMemoryMetricReader.create(); + SdkMeterProvider localMeterProvider = + SdkMeterProvider.builder().registerMetricReader(localMetricReader).build(); + OpenTelemetrySdk localOpenTelemetry = + OpenTelemetrySdk.builder().setMeterProvider(localMeterProvider).build(); + + DatastoreOptions localOptions = + DatastoreOptions.newBuilder() + .setProjectId(PROJECT_ID) + .setCredentials(NoCredentials.getInstance()) + .setRetrySettings(ServiceOptions.getDefaultRetrySettings()) + .setServiceRpcFactory(localRpcFactoryMock) + .setOpenTelemetryOptions( + DatastoreOpenTelemetryOptions.newBuilder() + .setMetricsEnabled(true) + .setOpenTelemetry(localOpenTelemetry) + .build()) + .build(); + + EasyMock.expect(localRpcFactoryMock.create(localOptions)).andReturn(localRpcMock); + EasyMock.expect( + localRpcMock.beginTransaction(EasyMock.anyObject(BeginTransactionRequest.class))) + .andReturn(BeginTransactionResponse.getDefaultInstance()) + .anyTimes(); + EasyMock.expect(localRpcMock.rollback(EasyMock.anyObject(RollbackRequest.class))) + .andReturn(RollbackResponse.getDefaultInstance()) + .anyTimes(); + EasyMock.replay(localRpcFactoryMock, localRpcMock); + + Datastore datastore = localOptions.getService(); + + Datastore.TransactionCallable callable = + transaction -> { + throw new DatastoreException(10, "ABORTED", "ABORTED", false, null); + }; + + assertThrows(DatastoreException.class, () -> datastore.runInTransaction(callable)); + + // Verify that attempt count was recorded with ABORTED status + Optional attemptMetric = + localMetricReader.collectAllMetrics().stream() + .filter(m -> m.getName().equals(ATTEMPT_COUNT_METRIC_NAME)) + .findFirst(); + assertThat(attemptMetric.isPresent()).isTrue(); + + LongPointData abortedPoint = + attemptMetric.get().getLongSumData().getPoints().stream() + .filter(p -> "ABORTED".equals(p.getAttributes().get(AttributeKey.stringKey("status")))) + .findFirst() + .orElse(null); + assertThat(abortedPoint).isNotNull(); + assertThat(abortedPoint.getValue()).isAtLeast(1); + + // Verify that latency was recorded with the failure status code + Optional latencyMetric = + localMetricReader.collectAllMetrics().stream() + .filter(m -> m.getName().equals(LATENCY_METRIC_NAME)) + .findFirst(); + assertThat(latencyMetric.isPresent()).isTrue(); + HistogramPointData latencyPoint = + latencyMetric.get().getHistogramData().getPoints().stream().findFirst().orElse(null); + assertThat(latencyPoint).isNotNull(); + assertThat(latencyPoint.getAttributes().get(AttributeKey.stringKey("status"))) + .isEqualTo("ABORTED"); + } + + @Test + public void runInTransaction_withTransactionOptions_recordsMetrics() { + // Verify metrics are recorded when calling the overload with TransactionOptions + EasyMock.expect(rpcMock.beginTransaction(EasyMock.anyObject(BeginTransactionRequest.class))) + .andReturn(BeginTransactionResponse.getDefaultInstance()); + EasyMock.expect(rpcMock.commit(EasyMock.anyObject(CommitRequest.class))) + .andReturn(CommitResponse.newBuilder().build()); + EasyMock.replay(rpcFactoryMock, rpcMock); + + Datastore datastore = datastoreOptions.getService(); + + TransactionOptions options = + TransactionOptions.newBuilder() + .setReadWrite(TransactionOptions.ReadWrite.getDefaultInstance()) + .build(); + datastore.runInTransaction(transaction -> null, options); + + // Verify both metrics were recorded + assertThat(findMetric(LATENCY_METRIC_NAME).isPresent()).isTrue(); + assertThat(findMetric(ATTEMPT_COUNT_METRIC_NAME).isPresent()).isTrue(); + + EasyMock.verify(rpcFactoryMock, rpcMock); + } + + /** + * Finds a specific metric by name from the in-memory metric reader. + * + * @param metricName The fully qualified name of the metric to find. + * @return An {@link Optional} containing the {@link MetricData} if found. + */ + private Optional findMetric(String metricName) { + Collection metrics = metricReader.collectAllMetrics(); + return metrics.stream().filter(m -> m.getName().equals(metricName)).findFirst(); + } +} diff --git a/java-datastore/google-cloud-datastore/src/test/java/com/google/cloud/datastore/telemetry/MetricsRecorderTest.java b/java-datastore/google-cloud-datastore/src/test/java/com/google/cloud/datastore/telemetry/MetricsRecorderTest.java new file mode 100644 index 000000000000..c2543dd2cfb6 --- /dev/null +++ b/java-datastore/google-cloud-datastore/src/test/java/com/google/cloud/datastore/telemetry/MetricsRecorderTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026 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.datastore.telemetry; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.cloud.datastore.DatastoreOpenTelemetryOptions; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Tests for {@link MetricsRecorder#getInstance(DatastoreOpenTelemetryOptions)}. + * + *

Note: Since {@code setMetricsEnabled()} is package-private on {@link + * DatastoreOpenTelemetryOptions.Builder}, these tests can only verify the default (metrics + * disabled) behavior and the behavior when an explicit OpenTelemetry instance is provided. The + * metrics-enabled paths are exercised through the {@link DatastoreImplMetricsTest} which operates + * in the {@code com.google.cloud.datastore} package. + */ +@RunWith(JUnit4.class) +public class MetricsRecorderTest { + + @Test + public void defaultOptionsReturnNoOp() { + DatastoreOpenTelemetryOptions options = DatastoreOpenTelemetryOptions.newBuilder().build(); + + MetricsRecorder recorder = MetricsRecorder.getInstance(options); + + assertThat(recorder).isInstanceOf(NoOpMetricsRecorder.class); + } + + @Test + public void tracingEnabledButMetricsDisabledReturnsNoOp() { + // Enabling tracing alone should not enable metrics + DatastoreOpenTelemetryOptions options = + DatastoreOpenTelemetryOptions.newBuilder().setTracingEnabled(true).build(); + + MetricsRecorder recorder = MetricsRecorder.getInstance(options); + + assertThat(recorder).isInstanceOf(NoOpMetricsRecorder.class); + } + + @Test + public void noOpRecorderDoesNotThrow() { + // Verify NoOpMetricsRecorder methods do not throw + NoOpMetricsRecorder recorder = new NoOpMetricsRecorder(); + recorder.recordTransactionLatency(100.0, null); + recorder.recordTransactionAttemptCount(1, null); + } + + @Test + public void openTelemetryRecorderCreatedWithExplicitOpenTelemetry() { + InMemoryMetricReader metricReader = InMemoryMetricReader.create(); + SdkMeterProvider meterProvider = + SdkMeterProvider.builder().registerMetricReader(metricReader).build(); + OpenTelemetry openTelemetry = + OpenTelemetrySdk.builder().setMeterProvider(meterProvider).build(); + + OpenTelemetryMetricsRecorder recorder = new OpenTelemetryMetricsRecorder(openTelemetry); + + assertThat(recorder.getOpenTelemetry()).isSameInstanceAs(openTelemetry); + } +} diff --git a/java-datastore/google-cloud-datastore/src/test/java/com/google/cloud/datastore/telemetry/OpenTelemetryMetricsRecorderTest.java b/java-datastore/google-cloud-datastore/src/test/java/com/google/cloud/datastore/telemetry/OpenTelemetryMetricsRecorderTest.java new file mode 100644 index 000000000000..85a449827851 --- /dev/null +++ b/java-datastore/google-cloud-datastore/src/test/java/com/google/cloud/datastore/telemetry/OpenTelemetryMetricsRecorderTest.java @@ -0,0 +1,176 @@ +/* + * Copyright 2026 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.datastore.telemetry; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.api.gax.rpc.StatusCode; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.data.HistogramPointData; +import io.opentelemetry.sdk.metrics.data.LongPointData; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class OpenTelemetryMetricsRecorderTest { + + private InMemoryMetricReader metricReader; + private OpenTelemetryMetricsRecorder recorder; + + @Before + public void setUp() { + metricReader = InMemoryMetricReader.create(); + SdkMeterProvider meterProvider = + SdkMeterProvider.builder().registerMetricReader(metricReader).build(); + OpenTelemetry openTelemetry = + OpenTelemetrySdk.builder().setMeterProvider(meterProvider).build(); + recorder = new OpenTelemetryMetricsRecorder(openTelemetry); + } + + @Test + public void recordTransactionLatency_recordsHistogramWithAttributes() { + Map attributes = new HashMap<>(); + attributes.put(TelemetryConstants.ATTRIBUTES_KEY_STATUS, StatusCode.Code.OK.toString()); + attributes.put( + TelemetryConstants.ATTRIBUTES_KEY_METHOD, TelemetryConstants.METHOD_TRANSACTION_COMMIT); + + recorder.recordTransactionLatency(150.0, attributes); + + Collection metrics = metricReader.collectAllMetrics(); + MetricData latencyMetric = + metrics.stream() + .filter( + m -> m.getName().equals(TelemetryConstants.SERVICE_NAME + "/transaction_latency")) + .findFirst() + .orElse(null); + + assertThat(latencyMetric).isNotNull(); + assertThat(latencyMetric.getDescription()).isEqualTo("Total latency of transaction operations"); + assertThat(latencyMetric.getUnit()).isEqualTo("ms"); + + HistogramPointData point = + latencyMetric.getHistogramData().getPoints().stream().findFirst().orElse(null); + assertThat(point).isNotNull(); + assertThat(point.getSum()).isEqualTo(150.0); + assertThat(point.getCount()).isEqualTo(1); + assertThat( + point + .getAttributes() + .get(AttributeKey.stringKey(TelemetryConstants.ATTRIBUTES_KEY_STATUS))) + .isEqualTo(StatusCode.Code.OK.toString()); + assertThat( + point + .getAttributes() + .get(AttributeKey.stringKey(TelemetryConstants.ATTRIBUTES_KEY_METHOD))) + .isEqualTo(TelemetryConstants.METHOD_TRANSACTION_COMMIT); + } + + @Test + public void recordTransactionAttemptCount_recordsCounterWithAttributes() { + Map attributes = new HashMap<>(); + attributes.put(TelemetryConstants.ATTRIBUTES_KEY_STATUS, StatusCode.Code.ABORTED.toString()); + attributes.put( + TelemetryConstants.ATTRIBUTES_KEY_METHOD, TelemetryConstants.METHOD_TRANSACTION_COMMIT); + + recorder.recordTransactionAttemptCount(1, attributes); + + Collection metrics = metricReader.collectAllMetrics(); + MetricData attemptMetric = + metrics.stream() + .filter( + m -> + m.getName() + .equals(TelemetryConstants.SERVICE_NAME + "/transaction_attempt_count")) + .findFirst() + .orElse(null); + + assertThat(attemptMetric).isNotNull(); + assertThat(attemptMetric.getDescription()) + .isEqualTo("Number of attempts to commit a transaction"); + + LongPointData point = + attemptMetric.getLongSumData().getPoints().stream().findFirst().orElse(null); + assertThat(point).isNotNull(); + assertThat(point.getValue()).isEqualTo(1); + assertThat( + point + .getAttributes() + .get(AttributeKey.stringKey(TelemetryConstants.ATTRIBUTES_KEY_STATUS))) + .isEqualTo(StatusCode.Code.ABORTED.toString()); + assertThat( + point + .getAttributes() + .get(AttributeKey.stringKey(TelemetryConstants.ATTRIBUTES_KEY_METHOD))) + .isEqualTo(TelemetryConstants.METHOD_TRANSACTION_COMMIT); + } + + @Test + public void recordTransactionAttemptCount_multipleAttempts_accumulates() { + Map abortedAttributes = new HashMap<>(); + abortedAttributes.put( + TelemetryConstants.ATTRIBUTES_KEY_STATUS, StatusCode.Code.ABORTED.toString()); + abortedAttributes.put( + TelemetryConstants.ATTRIBUTES_KEY_METHOD, TelemetryConstants.METHOD_TRANSACTION_COMMIT); + + Map okAttributes = new HashMap<>(); + okAttributes.put(TelemetryConstants.ATTRIBUTES_KEY_STATUS, StatusCode.Code.OK.toString()); + okAttributes.put( + TelemetryConstants.ATTRIBUTES_KEY_METHOD, TelemetryConstants.METHOD_TRANSACTION_COMMIT); + + // Simulate a retry scenario: first attempt ABORTED, second attempt OK + recorder.recordTransactionAttemptCount(1, abortedAttributes); + recorder.recordTransactionAttemptCount(1, okAttributes); + + Collection metrics = metricReader.collectAllMetrics(); + MetricData attemptMetric = + metrics.stream() + .filter( + m -> + m.getName() + .equals(TelemetryConstants.SERVICE_NAME + "/transaction_attempt_count")) + .findFirst() + .orElse(null); + + assertThat(attemptMetric).isNotNull(); + + // There should be two data points — one for ABORTED and one for OK + assertThat(attemptMetric.getLongSumData().getPoints()).hasSize(2); + } + + @Test + public void recordTransactionLatency_nullAttributes_doesNotThrow() { + // Should not throw even when attributes are null + recorder.recordTransactionLatency(100.0, null); + + Collection metrics = metricReader.collectAllMetrics(); + assertThat(metrics).isNotEmpty(); + } + + @Test + public void getOpenTelemetry_returnsConfiguredInstance() { + assertThat(recorder.getOpenTelemetry()).isNotNull(); + } +} diff --git a/java-recommender/google-cloud-recommender/pom.xml b/java-recommender/google-cloud-recommender/pom.xml index e361b174121c..ca33306dd1f0 100644 --- a/java-recommender/google-cloud-recommender/pom.xml +++ b/java-recommender/google-cloud-recommender/pom.xml @@ -78,6 +78,12 @@ + + com.google.api + gax + testlib + test + junit junit diff --git a/java-speech/google-cloud-speech/pom.xml b/java-speech/google-cloud-speech/pom.xml index c328c112efa2..36d86c489f6a 100644 --- a/java-speech/google-cloud-speech/pom.xml +++ b/java-speech/google-cloud-speech/pom.xml @@ -76,6 +76,12 @@ + + com.google.api + gax + testlib + test + junit junit