diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java index 1a6726b9c01b..1589b1b25cd6 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java @@ -100,6 +100,7 @@ import io.grpc.StatusRuntimeException; import io.grpc.protobuf.ProtoUtils; import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.metrics.MeterProvider; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; @@ -122,6 +123,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.logging.Logger; import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; /** * @since 2.14.0 @@ -131,6 +133,7 @@ public final class GrpcStorageOptions extends StorageOptions implements Retrying.RetryingDependencies { private static final long serialVersionUID = -4499446543857945349L; + private static final Logger LOGGER = Logger.getLogger(GrpcStorageOptions.class.getName()); private static final String GCS_SCOPE = "https://www.googleapis.com/auth/devstorage.full_control"; private static final Set SCOPES = ImmutableSet.of(GCS_SCOPE); private static final String DEFAULT_HOST = "https://storage.googleapis.com"; @@ -148,6 +151,13 @@ public final class GrpcStorageOptions extends StorageOptions private final GrpcInterceptorProvider grpcInterceptorProvider; private final BlobWriteSessionConfig blobWriteSessionConfig; private transient OpenTelemetry openTelemetry; + private final boolean enableOtelMetrics; + private final boolean enableOtelDebugMetrics; + @Nullable private final Boolean rawEnableOtelMetrics; + @Nullable private final Boolean rawEnableOtelDebugMetrics; + private transient MeterProvider meterProvider; + private final Duration metricInterval; + private final boolean customMeterProviderConfigured; private GrpcStorageOptions(Builder builder, GrpcStorageDefaults serviceDefaults) { super(builder, serviceDefaults); @@ -165,6 +175,19 @@ private GrpcStorageOptions(Builder builder, GrpcStorageDefaults serviceDefaults) this.grpcInterceptorProvider = builder.grpcInterceptorProvider; this.blobWriteSessionConfig = builder.blobWriteSessionConfig; this.openTelemetry = builder.openTelemetry; + this.rawEnableOtelMetrics = builder.enableOtelMetrics; + this.rawEnableOtelDebugMetrics = builder.enableOtelDebugMetrics; + this.enableOtelMetrics = StorageMetricsConfig.isEnableOtelMetrics(builder.enableOtelMetrics); + this.enableOtelDebugMetrics = + StorageMetricsConfig.isEnableOtelDebugMetrics(builder.enableOtelDebugMetrics); + this.meterProvider = builder.meterProvider; + this.customMeterProviderConfigured = builder.meterProvider != null; + this.metricInterval = builder.metricInterval; + if (this.meterProvider == null && this.metricInterval != null) { + checkArgument( + this.metricInterval.compareTo(Duration.ofSeconds(60)) >= 0, + "Metric export interval cannot be less than 60 seconds when using Cloud Monitoring exporter"); + } } @Override @@ -178,7 +201,7 @@ GrpcRetryAlgorithmManager getRetryAlgorithmManager() { } @InternalApi - java.time.Duration getTerminationAwaitDuration() { + Duration getTerminationAwaitDuration() { return terminationAwaitDuration; } @@ -195,6 +218,10 @@ GrpcInterceptorProvider getGrpcInterceptorProvider() { private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); this.openTelemetry = HttpStorageOptions.getDefaultInstance().getOpenTelemetry(); + if (this.customMeterProviderConfigured || this.meterProvider != null) { + LOGGER.warning( + "Custom MeterProvider is transient and cannot be serialized across workers; falling back to default provider."); + } } /** @@ -363,9 +390,9 @@ private Tuple> resolveSettingsAndOpts() throw // seconds. // To allow read streams to have longer lifespans, crank up their timeouts, instead rely // on idleTimeout below. - .setLogicalTimeout(java.time.Duration.ofDays(28)) + .setLogicalTimeout(Duration.ofDays(28)) .build(); - java.time.Duration totalTimeout = baseRetrySettings.getTotalTimeoutDuration(); + Duration totalTimeout = baseRetrySettings.getTotalTimeoutDuration(); // retries for unary methods are generally handled at a different level, except // StartResumableWrite @@ -414,6 +441,45 @@ public OpenTelemetry getOpenTelemetry() { return openTelemetry; } + /** + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public boolean isEnableOtelMetrics() { + return enableOtelMetrics; + } + + /** + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public boolean isEnableOtelDebugMetrics() { + return enableOtelDebugMetrics; + } + + /** + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public @Nullable MeterProvider getMeterProvider() { + if (meterProvider == null && openTelemetry != null) { + return openTelemetry.getMeterProvider(); + } + return meterProvider; + } + + /** + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public Duration getMetricInterval() { + return metricInterval != null ? metricInterval : Duration.ofSeconds(60); + } + /** * @since 2.14.0 */ @@ -432,6 +498,10 @@ public int hashCode() { grpcInterceptorProvider, blobWriteSessionConfig, openTelemetry, + enableOtelMetrics, + enableOtelDebugMetrics, + getMeterProvider(), + getMetricInterval(), baseHashCode()); } @@ -446,11 +516,15 @@ public boolean equals(Object o) { GrpcStorageOptions that = (GrpcStorageOptions) o; return attemptDirectPath == that.attemptDirectPath && enableGrpcClientMetrics == that.enableGrpcClientMetrics + && enableOtelMetrics == that.enableOtelMetrics + && enableOtelDebugMetrics == that.enableOtelDebugMetrics && Objects.equals(retryAlgorithmManager, that.retryAlgorithmManager) && Objects.equals(terminationAwaitDuration, that.terminationAwaitDuration) && Objects.equals(grpcInterceptorProvider, that.grpcInterceptorProvider) && Objects.equals(blobWriteSessionConfig, that.blobWriteSessionConfig) && Objects.equals(openTelemetry, that.openTelemetry) + && Objects.equals(getMeterProvider(), that.getMeterProvider()) + && Objects.equals(getMetricInterval(), that.getMetricInterval()) && this.baseEquals(that); } @@ -492,7 +566,7 @@ protected boolean shouldRefreshService(Storage cachedService) { public static final class Builder extends StorageOptions.Builder { private StorageRetryStrategy storageRetryStrategy; - private java.time.Duration terminationAwaitDuration; + private Duration terminationAwaitDuration; private boolean attemptDirectPath = GrpcStorageDefaults.INSTANCE.isAttemptDirectPath(); private boolean enableGrpcClientMetrics = GrpcStorageDefaults.INSTANCE.isEnableGrpcClientMetrics(); @@ -501,6 +575,10 @@ public static final class Builder extends StorageOptions.Builder { private BlobWriteSessionConfig blobWriteSessionConfig = GrpcStorageDefaults.INSTANCE.getDefaultStorageWriterConfig(); private OpenTelemetry openTelemetry = GrpcStorageDefaults.INSTANCE.getDefaultOpenTelemetry(); + private Boolean enableOtelMetrics = null; + private Boolean enableOtelDebugMetrics = null; + private MeterProvider meterProvider = null; + private Duration metricInterval = Duration.ofSeconds(60); private boolean grpcMetricsManuallyEnabled = false; @@ -516,13 +594,16 @@ public static final class Builder extends StorageOptions.Builder { this.grpcInterceptorProvider = gso.grpcInterceptorProvider; this.blobWriteSessionConfig = gso.blobWriteSessionConfig; this.openTelemetry = gso.openTelemetry; + this.enableOtelMetrics = gso.rawEnableOtelMetrics; + this.enableOtelDebugMetrics = gso.rawEnableOtelDebugMetrics; + this.meterProvider = gso.meterProvider; + this.metricInterval = gso.getMetricInterval(); } /** - * This method is obsolete. Use {@link #setTerminationAwaitJavaTimeDuration(java.time.Duration)} - * instead. + * This method is obsolete. Use {@link #setTerminationAwaitJavaTimeDuration(Duration)} instead. */ - @ObsoleteApi("Use setTerminationAwaitJavaTimeDuration(java.time.Duration) instead") + @ObsoleteApi("Use setTerminationAwaitJavaTimeDuration(Duration) instead") public Builder setTerminationAwaitDuration(org.threeten.bp.Duration terminationAwaitDuration) { return setTerminationAwaitJavaTimeDuration(toJavaTimeDuration(terminationAwaitDuration)); } @@ -535,8 +616,7 @@ public Builder setTerminationAwaitDuration(org.threeten.bp.Duration terminationA * @return the builder * @since 2.14.0 */ - public Builder setTerminationAwaitJavaTimeDuration( - java.time.Duration terminationAwaitDuration) { + public Builder setTerminationAwaitJavaTimeDuration(Duration terminationAwaitDuration) { this.terminationAwaitDuration = requireNonNull(terminationAwaitDuration, "terminationAwaitDuration must be non null"); return this; @@ -750,6 +830,65 @@ public GrpcStorageOptions.Builder setOpenTelemetry(OpenTelemetry openTelemetry) return this; } + /** + * Enable or disable OpenTelemetry client metrics. + * + * @param enableOtelMetrics whether OpenTelemetry client metrics should be enabled + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public GrpcStorageOptions.Builder setEnableOtelMetrics(boolean enableOtelMetrics) { + this.enableOtelMetrics = enableOtelMetrics; + return this; + } + + /** + * Enable or disable OpenTelemetry debug client metrics. + * + * @param enableOtelDebugMetrics whether OpenTelemetry debug client metrics should be enabled + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public GrpcStorageOptions.Builder setEnableOtelDebugMetrics(boolean enableOtelDebugMetrics) { + this.enableOtelDebugMetrics = enableOtelDebugMetrics; + return this; + } + + /** + * Set a custom {@link MeterProvider} for recording client metrics. + * + * @param meterProvider custom MeterProvider to use, or null to use the default + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public GrpcStorageOptions.Builder setMeterProvider(@Nullable MeterProvider meterProvider) { + this.meterProvider = meterProvider; + return this; + } + + /** + * Set the metric export interval for periodic metric reading. + * + *

When using the default Cloud Monitoring exporter, the interval must be at least 60 seconds + * (1 minute). Intervals less than 60 seconds are permitted only when a custom {@link + * MeterProvider} is configured via {@link #setMeterProvider(MeterProvider)}. + * + * @param metricInterval interval duration + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public GrpcStorageOptions.Builder setMetricInterval(Duration metricInterval) { + checkArgument( + metricInterval != null && !metricInterval.isNegative() && !metricInterval.isZero(), + "metricInterval must be positive"); + this.metricInterval = metricInterval; + return this; + } + /** * @since 2.14.0 */ @@ -817,8 +956,8 @@ public org.threeten.bp.Duration getTerminationAwaitDuration() { /** * @since 2.14.0 */ - public java.time.Duration getTerminationAwaitDurationJavaTime() { - return java.time.Duration.ofMinutes(1); + public Duration getTerminationAwaitDurationJavaTime() { + return Duration.ofMinutes(1); } /** diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java index dac8a010cdfa..3dbde5029d5b 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java @@ -42,13 +42,17 @@ import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableSet; import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.metrics.MeterProvider; import java.io.IOException; import java.io.ObjectInputStream; import java.io.Serializable; import java.time.Clock; +import java.time.Duration; import java.util.Objects; import java.util.Set; +import java.util.logging.Logger; import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; /** * @since 2.14.0 @@ -58,6 +62,7 @@ public class HttpStorageOptions extends StorageOptions { private static final long serialVersionUID = -5302637952911052045L; + private static final Logger LOGGER = Logger.getLogger(HttpStorageOptions.class.getName()); private static final String API_SHORT_NAME = "Storage"; private static final String GCS_SCOPE = "https://www.googleapis.com/auth/devstorage.full_control"; private static final Set SCOPES = ImmutableSet.of(GCS_SCOPE); @@ -66,6 +71,13 @@ public class HttpStorageOptions extends StorageOptions { private final BlobWriteSessionConfig blobWriteSessionConfig; private transient OpenTelemetry openTelemetry; + private final boolean enableOtelMetrics; + private final boolean enableOtelDebugMetrics; + @Nullable private final Boolean rawEnableOtelMetrics; + @Nullable private final Boolean rawEnableOtelDebugMetrics; + private transient MeterProvider meterProvider; + private final Duration metricInterval; + private final boolean customMeterProviderConfigured; private HttpStorageOptions(Builder builder, StorageDefaults serviceDefaults) { super(builder, serviceDefaults); @@ -76,6 +88,19 @@ private HttpStorageOptions(Builder builder, StorageDefaults serviceDefaults) { retryDepsAdapter = new RetryDependenciesAdapter(); blobWriteSessionConfig = builder.blobWriteSessionConfig; openTelemetry = builder.openTelemetry; + this.rawEnableOtelMetrics = builder.enableOtelMetrics; + this.rawEnableOtelDebugMetrics = builder.enableOtelDebugMetrics; + this.enableOtelMetrics = StorageMetricsConfig.isEnableOtelMetrics(builder.enableOtelMetrics); + this.enableOtelDebugMetrics = + StorageMetricsConfig.isEnableOtelDebugMetrics(builder.enableOtelDebugMetrics); + this.meterProvider = builder.meterProvider; + this.customMeterProviderConfigured = builder.meterProvider != null; + this.metricInterval = builder.metricInterval; + if (this.meterProvider == null && this.metricInterval != null) { + checkArgument( + this.metricInterval.compareTo(Duration.ofSeconds(60)) >= 0, + "Metric export interval cannot be less than 60 seconds when using Cloud Monitoring exporter"); + } } @Override @@ -102,6 +127,45 @@ public OpenTelemetry getOpenTelemetry() { return openTelemetry; } + /** + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public boolean isEnableOtelMetrics() { + return enableOtelMetrics; + } + + /** + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public boolean isEnableOtelDebugMetrics() { + return enableOtelDebugMetrics; + } + + /** + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public @Nullable MeterProvider getMeterProvider() { + if (meterProvider == null && openTelemetry != null) { + return openTelemetry.getMeterProvider(); + } + return meterProvider; + } + + /** + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public Duration getMetricInterval() { + return metricInterval != null ? metricInterval : Duration.ofSeconds(60); + } + @Override public HttpStorageOptions.Builder toBuilder() { return new HttpStorageOptions.Builder(this); @@ -110,7 +174,14 @@ public HttpStorageOptions.Builder toBuilder() { @Override public int hashCode() { return Objects.hash( - retryAlgorithmManager, blobWriteSessionConfig, openTelemetry, baseHashCode()); + retryAlgorithmManager, + blobWriteSessionConfig, + openTelemetry, + enableOtelMetrics, + enableOtelDebugMetrics, + getMeterProvider(), + getMetricInterval(), + baseHashCode()); } @Override @@ -125,6 +196,10 @@ public boolean equals(Object o) { return Objects.equals(retryAlgorithmManager, that.retryAlgorithmManager) && Objects.equals(blobWriteSessionConfig, that.blobWriteSessionConfig) && Objects.equals(openTelemetry, that.openTelemetry) + && enableOtelMetrics == that.enableOtelMetrics + && enableOtelDebugMetrics == that.enableOtelDebugMetrics + && Objects.equals(getMeterProvider(), that.getMeterProvider()) + && Objects.equals(getMetricInterval(), that.getMetricInterval()) && this.baseEquals(that); } @@ -132,6 +207,10 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE in.defaultReadObject(); this.retryDepsAdapter = new RetryDependenciesAdapter(); this.openTelemetry = HttpStorageOptions.getDefaultInstance().getOpenTelemetry(); + if (this.customMeterProviderConfigured || this.meterProvider != null) { + LOGGER.warning( + "Custom MeterProvider is transient and cannot be serialized across workers; falling back to default provider."); + } } public static HttpStorageOptions.Builder newBuilder() { @@ -157,6 +236,10 @@ public static class Builder extends StorageOptions.Builder { private BlobWriteSessionConfig blobWriteSessionConfig = HttpStorageDefaults.INSTANCE.getDefaultStorageWriterConfig(); private OpenTelemetry openTelemetry = HttpStorageDefaults.INSTANCE.getDefaultOpenTelemetry(); + private Boolean enableOtelMetrics = null; + private Boolean enableOtelDebugMetrics = null; + private MeterProvider meterProvider = null; + private Duration metricInterval = Duration.ofSeconds(60); Builder() {} @@ -166,6 +249,10 @@ public static class Builder extends StorageOptions.Builder { this.storageRetryStrategy = hso.retryAlgorithmManager.retryStrategy; this.blobWriteSessionConfig = hso.blobWriteSessionConfig; this.openTelemetry = hso.getOpenTelemetry(); + this.enableOtelMetrics = hso.rawEnableOtelMetrics; + this.enableOtelDebugMetrics = hso.rawEnableOtelDebugMetrics; + this.meterProvider = hso.meterProvider; + this.metricInterval = hso.getMetricInterval(); } @Override @@ -317,6 +404,65 @@ public HttpStorageOptions.Builder setOpenTelemetry(OpenTelemetry openTelemetry) this.openTelemetry = openTelemetry; return this; } + + /** + * Enable or disable OpenTelemetry client metrics. + * + * @param enableOtelMetrics whether OpenTelemetry client metrics should be enabled + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public HttpStorageOptions.Builder setEnableOtelMetrics(boolean enableOtelMetrics) { + this.enableOtelMetrics = enableOtelMetrics; + return this; + } + + /** + * Enable or disable OpenTelemetry debug client metrics. + * + * @param enableOtelDebugMetrics whether OpenTelemetry debug client metrics should be enabled + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public HttpStorageOptions.Builder setEnableOtelDebugMetrics(boolean enableOtelDebugMetrics) { + this.enableOtelDebugMetrics = enableOtelDebugMetrics; + return this; + } + + /** + * Set a custom {@link MeterProvider} for recording client metrics. + * + * @param meterProvider custom MeterProvider to use, or null to use the default + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public HttpStorageOptions.Builder setMeterProvider(@Nullable MeterProvider meterProvider) { + this.meterProvider = meterProvider; + return this; + } + + /** + * Set the metric export interval for periodic metric reading. + * + *

When using the default Cloud Monitoring exporter, the interval must be at least 60 seconds + * (1 minute). Intervals less than 60 seconds are permitted only when a custom {@link + * MeterProvider} is configured via {@link #setMeterProvider(MeterProvider)}. + * + * @param metricInterval interval duration + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public HttpStorageOptions.Builder setMetricInterval(Duration metricInterval) { + checkArgument( + metricInterval != null && !metricInterval.isNegative() && !metricInterval.isZero(), + "metricInterval must be positive"); + this.metricInterval = metricInterval; + return this; + } } public static final class HttpStorageDefaults extends StorageDefaults { diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageMetricsConfig.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageMetricsConfig.java new file mode 100644 index 000000000000..1fbc3801d903 --- /dev/null +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageMetricsConfig.java @@ -0,0 +1,109 @@ +/* + * 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.storage; + +import com.google.common.annotations.VisibleForTesting; +import java.util.Locale; +import java.util.function.Function; +import java.util.logging.Logger; +import org.checkerframework.checker.nullness.qual.Nullable; + +final class StorageMetricsConfig { + private static final Logger LOGGER = Logger.getLogger(StorageMetricsConfig.class.getName()); + + static final String ENV_ENABLE_OTEL_METRICS_JAVA = "GCP_STORAGE_JAVA_ENABLE_OTEL_METRICS"; + static final String ENV_ENABLE_OTEL_DEBUG_METRICS = "GCP_STORAGE_JAVA_ENABLE_OTEL_DEBUG_METRICS"; + + private static volatile Function envResolver = System::getenv; + + private StorageMetricsConfig() {} + + static boolean isEnableOtelMetrics() { + return isEnableOtelMetrics(null); + } + + static boolean isEnableOtelDebugMetrics() { + return isEnableOtelDebugMetrics(null); + } + + static boolean isEnableOtelMetrics(@Nullable Boolean builderSetting) { + return isEnableOtelMetrics(envResolver, builderSetting); + } + + static boolean isEnableOtelDebugMetrics(@Nullable Boolean builderSetting) { + return isEnableOtelDebugMetrics(envResolver, builderSetting); + } + + @VisibleForTesting + static boolean isEnableOtelMetrics( + Function env, @Nullable Boolean builderSetting) { + String javaEnv = env.apply(ENV_ENABLE_OTEL_METRICS_JAVA); + Boolean parsed = parseBooleanValue(javaEnv, ENV_ENABLE_OTEL_METRICS_JAVA); + if (parsed != null) { + return parsed; + } + return builderSetting != null ? builderSetting : false; + } + + @VisibleForTesting + static boolean isEnableOtelDebugMetrics( + Function env, @Nullable Boolean builderSetting) { + String javaEnv = env.apply(ENV_ENABLE_OTEL_DEBUG_METRICS); + Boolean parsed = parseBooleanValue(javaEnv, ENV_ENABLE_OTEL_DEBUG_METRICS); + if (parsed != null) { + return parsed; + } + return builderSetting != null ? builderSetting : false; + } + + @Nullable + @VisibleForTesting + static Boolean parseBooleanValue(@Nullable String val, String varName) { + if (val == null) { + return null; + } + String normalized = val.trim().toLowerCase(Locale.ENGLISH); + switch (normalized) { + case "1": + case "t": + case "true": + return Boolean.TRUE; + case "0": + case "f": + case "false": + return Boolean.FALSE; + default: + LOGGER.warning( + "Unrecognized boolean value '" + + val + + "' for " + + varName + + "; ignoring and falling back to builder/default"); + return null; + } + } + + @VisibleForTesting + static void setEnvResolverForTesting(Function testEnv) { + envResolver = testEnv != null ? testEnv : System::getenv; + } + + @VisibleForTesting + static void resetResolversForTesting() { + envResolver = System::getenv; + } +} diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOptions.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOptions.java index 97eecedaeef1..9de103e65f5f 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOptions.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOptions.java @@ -33,11 +33,14 @@ import com.google.cloud.storage.TransportCompatibility.Transport; import com.google.cloud.storage.spi.StorageRpcFactory; import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.metrics.MeterProvider; import java.io.IOException; import java.io.InputStream; +import java.time.Duration; import java.util.Locale; import java.util.Properties; import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; public abstract class StorageOptions extends ServiceOptions { @@ -143,6 +146,47 @@ public abstract StorageOptions.Builder setBlobWriteSessionConfig( @BetaApi public abstract StorageOptions.Builder setOpenTelemetry(OpenTelemetry openTelemetry); + /** + * Enable or disable OpenTelemetry client metrics. + * + * @param enableOtelMetrics whether OpenTelemetry client metrics should be enabled + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public abstract StorageOptions.Builder setEnableOtelMetrics(boolean enableOtelMetrics); + + /** + * Enable or disable OpenTelemetry debug client metrics. + * + * @param enableOtelDebugMetrics whether OpenTelemetry debug client metrics should be enabled + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public abstract StorageOptions.Builder setEnableOtelDebugMetrics( + boolean enableOtelDebugMetrics); + + /** + * Set a custom {@link MeterProvider} for recording client metrics. + * + * @param meterProvider custom MeterProvider to use, or null to use the default + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public abstract StorageOptions.Builder setMeterProvider(@Nullable MeterProvider meterProvider); + + /** + * Set the metric export interval for periodic metric reading. + * + *

When using the default Cloud Monitoring exporter, the interval must be at least 60 seconds + * (1 minute). Intervals less than 60 seconds are permitted only when a custom {@link + * MeterProvider} is configured via {@link #setMeterProvider(MeterProvider)}. + * + * @param metricInterval interval duration + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public abstract StorageOptions.Builder setMetricInterval(Duration metricInterval); + @Override public abstract StorageOptions build(); } @@ -185,6 +229,38 @@ public static String version() { @BetaApi public abstract OpenTelemetry getOpenTelemetry(); + /** + * Whether OpenTelemetry client metrics are enabled. + * + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public abstract boolean isEnableOtelMetrics(); + + /** + * Whether OpenTelemetry debug client metrics are enabled. + * + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public abstract boolean isEnableOtelDebugMetrics(); + + /** + * The {@link MeterProvider} configured for recording client metrics. + * + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public abstract @Nullable MeterProvider getMeterProvider(); + + /** + * The metric export interval configured for periodic metric reading. + * + * @since 2.50.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public abstract Duration getMetricInterval(); + @SuppressWarnings("unchecked") @Override public abstract StorageOptions.Builder toBuilder(); diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsTest.java new file mode 100644 index 000000000000..b00710c08456 --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsTest.java @@ -0,0 +1,429 @@ +/* + * 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.storage; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import io.opentelemetry.api.metrics.MeterProvider; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import org.junit.After; +import org.junit.Test; + +public final class StorageOptionsTest { + + @After + public void tearDown() { + StorageMetricsConfig.resetResolversForTesting(); + } + + @Test + public void defaultState_metricsDisabled() { + HttpStorageOptions httpOptions = HttpStorageOptions.http().build(); + assertThat(httpOptions.isEnableOtelMetrics()).isFalse(); + assertThat(httpOptions.isEnableOtelDebugMetrics()).isFalse(); + assertThat(httpOptions.getMeterProvider()) + .isEqualTo(httpOptions.getOpenTelemetry().getMeterProvider()); + assertThat(httpOptions.getMetricInterval()).isEqualTo(Duration.ofSeconds(60)); + + GrpcStorageOptions grpcOptions = GrpcStorageOptions.grpc().build(); + assertThat(grpcOptions.isEnableOtelMetrics()).isFalse(); + assertThat(grpcOptions.isEnableOtelDebugMetrics()).isFalse(); + assertThat(grpcOptions.getMeterProvider()) + .isEqualTo(grpcOptions.getOpenTelemetry().getMeterProvider()); + assertThat(grpcOptions.getMetricInterval()).isEqualTo(Duration.ofSeconds(60)); + } + + @Test + public void builder_explicitEnabling() { + MeterProvider mockMeterProvider = SdkMeterProvider.builder().build(); + Duration interval = Duration.ofSeconds(30); + + HttpStorageOptions httpOptions = + HttpStorageOptions.http() + .setEnableOtelMetrics(true) + .setEnableOtelDebugMetrics(true) + .setMeterProvider(mockMeterProvider) + .setMetricInterval(interval) + .build(); + assertThat(httpOptions.isEnableOtelMetrics()).isTrue(); + assertThat(httpOptions.isEnableOtelDebugMetrics()).isTrue(); + assertThat(httpOptions.getMeterProvider()).isSameInstanceAs(mockMeterProvider); + assertThat(httpOptions.getMetricInterval()).isEqualTo(interval); + + HttpStorageOptions rebuiltHttp = httpOptions.toBuilder().build(); + assertThat(rebuiltHttp.isEnableOtelMetrics()).isTrue(); + assertThat(rebuiltHttp.isEnableOtelDebugMetrics()).isTrue(); + assertThat(rebuiltHttp.getMeterProvider()).isSameInstanceAs(mockMeterProvider); + assertThat(rebuiltHttp.getMetricInterval()).isEqualTo(interval); + + GrpcStorageOptions grpcOptions = + GrpcStorageOptions.grpc() + .setEnableOtelMetrics(true) + .setEnableOtelDebugMetrics(true) + .setMeterProvider(mockMeterProvider) + .setMetricInterval(interval) + .build(); + assertThat(grpcOptions.isEnableOtelMetrics()).isTrue(); + assertThat(grpcOptions.isEnableOtelDebugMetrics()).isTrue(); + assertThat(grpcOptions.getMeterProvider()).isSameInstanceAs(mockMeterProvider); + assertThat(grpcOptions.getMetricInterval()).isEqualTo(interval); + + GrpcStorageOptions rebuiltGrpc = grpcOptions.toBuilder().build(); + assertThat(rebuiltGrpc.isEnableOtelMetrics()).isTrue(); + assertThat(rebuiltGrpc.isEnableOtelDebugMetrics()).isTrue(); + assertThat(rebuiltGrpc.getMeterProvider()).isSameInstanceAs(mockMeterProvider); + assertThat(rebuiltGrpc.getMetricInterval()).isEqualTo(interval); + } + + @Test + public void precedence_envTakesPrecedenceOverBuilder() { + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "true"); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "true"); + StorageMetricsConfig.setEnvResolverForTesting(env::get); + + HttpStorageOptions httpDisabled = + HttpStorageOptions.http() + .setEnableOtelMetrics(false) + .setEnableOtelDebugMetrics(false) + .build(); + assertThat(httpDisabled.isEnableOtelMetrics()).isTrue(); + assertThat(httpDisabled.isEnableOtelDebugMetrics()).isTrue(); + + GrpcStorageOptions grpcDisabled = + GrpcStorageOptions.grpc() + .setEnableOtelMetrics(false) + .setEnableOtelDebugMetrics(false) + .build(); + assertThat(grpcDisabled.isEnableOtelMetrics()).isTrue(); + assertThat(grpcDisabled.isEnableOtelDebugMetrics()).isTrue(); + + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "false"); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "false"); + + HttpStorageOptions httpEnabled = + HttpStorageOptions.http() + .setEnableOtelMetrics(true) + .setEnableOtelDebugMetrics(true) + .build(); + assertThat(httpEnabled.isEnableOtelMetrics()).isFalse(); + assertThat(httpEnabled.isEnableOtelDebugMetrics()).isFalse(); + + GrpcStorageOptions grpcEnabled = + GrpcStorageOptions.grpc() + .setEnableOtelMetrics(true) + .setEnableOtelDebugMetrics(true) + .build(); + assertThat(grpcEnabled.isEnableOtelMetrics()).isFalse(); + assertThat(grpcEnabled.isEnableOtelDebugMetrics()).isFalse(); + } + + @Test + public void precedence_builderTakesPrecedenceOverDefaultWhenEnvUnset() { + Map env = new HashMap<>(); + StorageMetricsConfig.setEnvResolverForTesting(env::get); + + HttpStorageOptions httpEnabled = + HttpStorageOptions.http() + .setEnableOtelMetrics(true) + .setEnableOtelDebugMetrics(true) + .build(); + assertThat(httpEnabled.isEnableOtelMetrics()).isTrue(); + assertThat(httpEnabled.isEnableOtelDebugMetrics()).isTrue(); + + GrpcStorageOptions grpcEnabled = + GrpcStorageOptions.grpc() + .setEnableOtelMetrics(true) + .setEnableOtelDebugMetrics(true) + .build(); + assertThat(grpcEnabled.isEnableOtelMetrics()).isTrue(); + assertThat(grpcEnabled.isEnableOtelDebugMetrics()).isTrue(); + + HttpStorageOptions httpDisabled = + HttpStorageOptions.http() + .setEnableOtelMetrics(false) + .setEnableOtelDebugMetrics(false) + .build(); + assertThat(httpDisabled.isEnableOtelMetrics()).isFalse(); + assertThat(httpDisabled.isEnableOtelDebugMetrics()).isFalse(); + + GrpcStorageOptions grpcDisabled = + GrpcStorageOptions.grpc() + .setEnableOtelMetrics(false) + .setEnableOtelDebugMetrics(false) + .build(); + assertThat(grpcDisabled.isEnableOtelMetrics()).isFalse(); + assertThat(grpcDisabled.isEnableOtelDebugMetrics()).isFalse(); + } + + @Test + public void permissiveBooleanParsing() { + String[] trueValues = {"1", "t", "T", "true", "TRUE", "True", " true "}; + for (String val : trueValues) { + assertThat(StorageMetricsConfig.parseBooleanValue(val, "TEST_VAR")).isTrue(); + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, val); + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, false)).isTrue(); + } + + String[] falseValues = {"0", "f", "F", "false", "FALSE", "False", " false "}; + for (String val : falseValues) { + assertThat(StorageMetricsConfig.parseBooleanValue(val, "TEST_VAR")).isFalse(); + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, val); + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, true)).isFalse(); + } + + String[] invalidValues = {"invalid", "2", "yes", "no", "enabled", ""}; + for (String val : invalidValues) { + assertThat(StorageMetricsConfig.parseBooleanValue(val, "TEST_VAR")).isNull(); + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, val); + // Unrecognized values fall back to builder setting + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, true)).isTrue(); + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, false)).isFalse(); + // When builder setting is null, falls back to default false + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, null)).isFalse(); + } + + assertThat(StorageMetricsConfig.parseBooleanValue(null, "TEST_VAR")).isNull(); + } + + @Test + public void toBuilder_doesNotBakeInEnvironmentVariable() { + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "true"); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "true"); + StorageMetricsConfig.setEnvResolverForTesting(env::get); + + HttpStorageOptions httpOptions = HttpStorageOptions.http().build(); + assertThat(httpOptions.isEnableOtelMetrics()).isTrue(); + assertThat(httpOptions.isEnableOtelDebugMetrics()).isTrue(); + + // Clear environment variable and rebuild + env.clear(); + HttpStorageOptions rebuiltHttp = httpOptions.toBuilder().build(); + assertThat(rebuiltHttp.isEnableOtelMetrics()).isFalse(); + assertThat(rebuiltHttp.isEnableOtelDebugMetrics()).isFalse(); + + // Repeat for GrpcStorageOptions + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "true"); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "true"); + GrpcStorageOptions grpcOptions = GrpcStorageOptions.grpc().build(); + assertThat(grpcOptions.isEnableOtelMetrics()).isTrue(); + assertThat(grpcOptions.isEnableOtelDebugMetrics()).isTrue(); + + env.clear(); + GrpcStorageOptions rebuiltGrpc = grpcOptions.toBuilder().build(); + assertThat(rebuiltGrpc.isEnableOtelMetrics()).isFalse(); + assertThat(rebuiltGrpc.isEnableOtelDebugMetrics()).isFalse(); + } + + @Test + public void environmentVariableJava() { + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "true"); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "true"); + StorageMetricsConfig.setEnvResolverForTesting(env::get); + + HttpStorageOptions httpOptions = HttpStorageOptions.http().build(); + assertThat(httpOptions.isEnableOtelMetrics()).isTrue(); + assertThat(httpOptions.isEnableOtelDebugMetrics()).isTrue(); + + GrpcStorageOptions grpcOptions = GrpcStorageOptions.grpc().build(); + assertThat(grpcOptions.isEnableOtelMetrics()).isTrue(); + assertThat(grpcOptions.isEnableOtelDebugMetrics()).isTrue(); + } + + @Test + public void setMetricInterval_validation() { + MeterProvider customMeterProvider = SdkMeterProvider.builder().build(); + + assertThrows( + IllegalArgumentException.class, + () -> HttpStorageOptions.http().setMetricInterval(Duration.ofSeconds(-1))); + assertThrows( + IllegalArgumentException.class, + () -> HttpStorageOptions.http().setMetricInterval(Duration.ZERO)); + assertThrows( + IllegalArgumentException.class, () -> HttpStorageOptions.http().setMetricInterval(null)); + + assertThrows( + IllegalArgumentException.class, + () -> GrpcStorageOptions.grpc().setMetricInterval(Duration.ofSeconds(-1))); + assertThrows( + IllegalArgumentException.class, + () -> GrpcStorageOptions.grpc().setMetricInterval(Duration.ZERO)); + assertThrows( + IllegalArgumentException.class, () -> GrpcStorageOptions.grpc().setMetricInterval(null)); + + // Reject intervals < 60s when meterProvider == null (SDK-owned Cloud Monitoring exporter) + assertThrows( + IllegalArgumentException.class, + () -> HttpStorageOptions.http().setMetricInterval(Duration.ofSeconds(59)).build()); + assertThrows( + IllegalArgumentException.class, + () -> HttpStorageOptions.http().setMetricInterval(Duration.ofSeconds(30)).build()); + + assertThrows( + IllegalArgumentException.class, + () -> GrpcStorageOptions.grpc().setMetricInterval(Duration.ofSeconds(59)).build()); + assertThrows( + IllegalArgumentException.class, + () -> GrpcStorageOptions.grpc().setMetricInterval(Duration.ofSeconds(30)).build()); + + // Accept intervals >= 60s when meterProvider == null + HttpStorageOptions http60 = + HttpStorageOptions.http().setMetricInterval(Duration.ofSeconds(60)).build(); + assertThat(http60.getMetricInterval()).isEqualTo(Duration.ofSeconds(60)); + + GrpcStorageOptions grpc60 = + GrpcStorageOptions.grpc().setMetricInterval(Duration.ofSeconds(60)).build(); + assertThat(grpc60.getMetricInterval()).isEqualTo(Duration.ofSeconds(60)); + + // Accept intervals < 60s when custom meterProvider is configured + HttpStorageOptions httpCustom = + HttpStorageOptions.http() + .setMeterProvider(customMeterProvider) + .setMetricInterval(Duration.ofSeconds(30)) + .build(); + assertThat(httpCustom.getMetricInterval()).isEqualTo(Duration.ofSeconds(30)); + + GrpcStorageOptions grpcCustom = + GrpcStorageOptions.grpc() + .setMeterProvider(customMeterProvider) + .setMetricInterval(Duration.ofSeconds(30)) + .build(); + assertThat(grpcCustom.getMetricInterval()).isEqualTo(Duration.ofSeconds(30)); + } + + @Test + public void serializationRoundTrip_customMeterProviderDoesNotCarryOver() throws Exception { + MeterProvider customMeterProvider = SdkMeterProvider.builder().build(); + HttpStorageOptions httpOptions = + HttpStorageOptions.http() + .setEnableOtelMetrics(true) + .setMeterProvider(customMeterProvider) + .build(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(httpOptions); + } + + HttpStorageOptions deserializedHttp; + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + deserializedHttp = (HttpStorageOptions) ois.readObject(); + } + + assertThat(deserializedHttp).isNotNull(); + assertThat(deserializedHttp.isEnableOtelMetrics()).isTrue(); + // Transient meterProvider does not carry over; falls back to openTelemetry.getMeterProvider() + // without NPE + assertThat(deserializedHttp.getMeterProvider()).isNotNull(); + assertThat(deserializedHttp.getMeterProvider()).isNotSameInstanceAs(customMeterProvider); + assertThat(deserializedHttp.getMeterProvider()) + .isEqualTo(deserializedHttp.getOpenTelemetry().getMeterProvider()); + + GrpcStorageOptions grpcOptions = + GrpcStorageOptions.grpc() + .setEnableOtelMetrics(true) + .setMeterProvider(customMeterProvider) + .build(); + + baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(grpcOptions); + } + + GrpcStorageOptions deserializedGrpc; + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + deserializedGrpc = (GrpcStorageOptions) ois.readObject(); + } + + assertThat(deserializedGrpc).isNotNull(); + assertThat(deserializedGrpc.isEnableOtelMetrics()).isTrue(); + assertThat(deserializedGrpc.getMeterProvider()).isNotNull(); + assertThat(deserializedGrpc.getMeterProvider()).isNotSameInstanceAs(customMeterProvider); + assertThat(deserializedGrpc.getMeterProvider()) + .isEqualTo(deserializedGrpc.getOpenTelemetry().getMeterProvider()); + } + + @Test + public void deserializationWithNullMetricInterval_defaultsToSixtySeconds() throws Exception { + Field httpBuilderField = HttpStorageOptions.Builder.class.getDeclaredField("metricInterval"); + httpBuilderField.setAccessible(true); + HttpStorageOptions.Builder httpBuilder = HttpStorageOptions.http(); + httpBuilderField.set(httpBuilder, null); + HttpStorageOptions httpWithNullInterval = httpBuilder.build(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(httpWithNullInterval); + } + + HttpStorageOptions deserializedHttp; + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + deserializedHttp = (HttpStorageOptions) ois.readObject(); + } + + assertThat(deserializedHttp).isNotNull(); + // Verify NPE guard defaults to 60 seconds + assertThat(deserializedHttp.getMetricInterval()).isEqualTo(Duration.ofSeconds(60)); + // Verify equals and hashCode match a default instance + HttpStorageOptions defaultHttp = HttpStorageOptions.http().build(); + assertThat(deserializedHttp).isEqualTo(defaultHttp); + assertThat(defaultHttp).isEqualTo(deserializedHttp); + assertThat(deserializedHttp.hashCode()).isEqualTo(defaultHttp.hashCode()); + + Field grpcBuilderField = GrpcStorageOptions.Builder.class.getDeclaredField("metricInterval"); + grpcBuilderField.setAccessible(true); + GrpcStorageOptions.Builder grpcBuilder = GrpcStorageOptions.grpc(); + grpcBuilderField.set(grpcBuilder, null); + GrpcStorageOptions grpcWithNullInterval = grpcBuilder.build(); + + baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(grpcWithNullInterval); + } + + GrpcStorageOptions deserializedGrpc; + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + deserializedGrpc = (GrpcStorageOptions) ois.readObject(); + } + + assertThat(deserializedGrpc).isNotNull(); + assertThat(deserializedGrpc.getMetricInterval()).isEqualTo(Duration.ofSeconds(60)); + GrpcStorageOptions defaultGrpc = GrpcStorageOptions.grpc().build(); + assertThat(deserializedGrpc).isEqualTo(defaultGrpc); + assertThat(defaultGrpc).isEqualTo(deserializedGrpc); + assertThat(deserializedGrpc.hashCode()).isEqualTo(defaultGrpc.hashCode()); + } +}