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..0793d4614223 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 @@ -432,6 +434,10 @@ public int hashCode() { grpcInterceptorProvider, blobWriteSessionConfig, openTelemetry, + isEnableOtelMetrics(), + isEnableOtelDebugMetrics(), + getMeterProvider(), + getMetricInterval(), baseHashCode()); } @@ -446,11 +452,15 @@ public boolean equals(Object o) { GrpcStorageOptions that = (GrpcStorageOptions) o; return attemptDirectPath == that.attemptDirectPath && enableGrpcClientMetrics == that.enableGrpcClientMetrics + && isEnableOtelMetrics() == that.isEnableOtelMetrics() + && isEnableOtelDebugMetrics() == that.isEnableOtelDebugMetrics() && 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); } @@ -747,6 +757,64 @@ public GrpcStorageOptions.Builder setApiTracerFactory(ApiTracerFactory apiTracer public GrpcStorageOptions.Builder setOpenTelemetry(OpenTelemetry openTelemetry) { requireNonNull(openTelemetry, "openTelemetry must be non null"); this.openTelemetry = openTelemetry; + setCustomOpenTelemetryConfigured(true); + return this; + } + + /** + * Enable or disable OpenTelemetry client metrics. + * + * @param enableOtelMetrics whether OpenTelemetry client metrics should be enabled + * @since 2.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public GrpcStorageOptions.Builder setEnableOtelMetrics(boolean enableOtelMetrics) { + super.setEnableOtelMetrics(enableOtelMetrics); + return this; + } + + /** + * Enable or disable OpenTelemetry debug client metrics. + * + * @param enableOtelDebugMetrics whether OpenTelemetry debug client metrics should be enabled + * @since 2.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public GrpcStorageOptions.Builder setEnableOtelDebugMetrics(boolean enableOtelDebugMetrics) { + super.setEnableOtelDebugMetrics(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.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public GrpcStorageOptions.Builder setMeterProvider(@Nullable MeterProvider meterProvider) { + super.setMeterProvider(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)} or explicit + * OpenTelemetry is configured via {@link #setOpenTelemetry(OpenTelemetry)}. + * + * @param metricInterval interval duration + * @since 2.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public GrpcStorageOptions.Builder setMetricInterval(Duration metricInterval) { + super.setMetricInterval(metricInterval); return this; } 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..8dff1c8185c1 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,16 @@ 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 org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; /** * @since 2.14.0 @@ -110,7 +113,14 @@ public HttpStorageOptions.Builder toBuilder() { @Override public int hashCode() { return Objects.hash( - retryAlgorithmManager, blobWriteSessionConfig, openTelemetry, baseHashCode()); + retryAlgorithmManager, + blobWriteSessionConfig, + openTelemetry, + isEnableOtelMetrics(), + isEnableOtelDebugMetrics(), + getMeterProvider(), + getMetricInterval(), + baseHashCode()); } @Override @@ -125,6 +135,10 @@ public boolean equals(Object o) { return Objects.equals(retryAlgorithmManager, that.retryAlgorithmManager) && Objects.equals(blobWriteSessionConfig, that.blobWriteSessionConfig) && Objects.equals(openTelemetry, that.openTelemetry) + && isEnableOtelMetrics() == that.isEnableOtelMetrics() + && isEnableOtelDebugMetrics() == that.isEnableOtelDebugMetrics() + && Objects.equals(getMeterProvider(), that.getMeterProvider()) + && Objects.equals(getMetricInterval(), that.getMetricInterval()) && this.baseEquals(that); } @@ -315,6 +329,64 @@ public HttpStorageOptions build() { public HttpStorageOptions.Builder setOpenTelemetry(OpenTelemetry openTelemetry) { requireNonNull(openTelemetry, "openTelemetry must be non null"); this.openTelemetry = openTelemetry; + setCustomOpenTelemetryConfigured(true); + return this; + } + + /** + * Enable or disable OpenTelemetry client metrics. + * + * @param enableOtelMetrics whether OpenTelemetry client metrics should be enabled + * @since 2.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public HttpStorageOptions.Builder setEnableOtelMetrics(boolean enableOtelMetrics) { + super.setEnableOtelMetrics(enableOtelMetrics); + return this; + } + + /** + * Enable or disable OpenTelemetry debug client metrics. + * + * @param enableOtelDebugMetrics whether OpenTelemetry debug client metrics should be enabled + * @since 2.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public HttpStorageOptions.Builder setEnableOtelDebugMetrics(boolean enableOtelDebugMetrics) { + super.setEnableOtelDebugMetrics(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.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public HttpStorageOptions.Builder setMeterProvider(@Nullable MeterProvider meterProvider) { + super.setMeterProvider(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)} or explicit + * OpenTelemetry is configured via {@link #setOpenTelemetry(OpenTelemetry)}. + * + * @param metricInterval interval duration + * @since 2.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + @Override + public HttpStorageOptions.Builder setMetricInterval(Duration metricInterval) { + super.setMetricInterval(metricInterval); return this; } } 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..923d72d146b8 --- /dev/null +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageMetricsConfig.java @@ -0,0 +1,112 @@ +/* + * 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) { + return resolveFlag(env, builderSetting, ENV_ENABLE_OTEL_METRICS_JAVA); + } + + @VisibleForTesting + static boolean isEnableOtelDebugMetrics( + Function env, @Nullable Boolean builderSetting) { + return resolveFlag(env, builderSetting, ENV_ENABLE_OTEL_DEBUG_METRICS); + } + + private static boolean resolveFlag( + Function env, @Nullable Boolean builderSetting, String envVarName) { + if (builderSetting != null) { + return builderSetting; + } + String javaEnv = env.apply(envVarName); + Boolean parsed = parseBooleanValue(javaEnv, envVarName); + if (parsed != null) { + return parsed; + } + return 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..672c9ce34041 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 @@ -16,6 +16,8 @@ package com.google.cloud.storage; +import static com.google.common.base.Preconditions.checkArgument; + import com.google.api.core.BetaApi; import com.google.api.core.InternalApi; import com.google.cloud.NoCredentials; @@ -33,11 +35,16 @@ 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.io.ObjectInputStream; +import java.time.Duration; import java.util.Locale; import java.util.Properties; +import java.util.logging.Logger; import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; public abstract class StorageOptions extends ServiceOptions { @@ -112,12 +119,53 @@ public DefaultStorageRpcFactory() { public abstract static class Builder extends ServiceOptions.Builder { + private @Nullable Boolean enableOtelMetrics = null; + private @Nullable Boolean enableOtelDebugMetrics = null; + private @Nullable MeterProvider meterProvider = null; + private Duration metricInterval = Duration.ofSeconds(60); + private boolean customOpenTelemetryConfigured = false; + private boolean customMeterProviderConfigured = false; + Builder() { setUseJwtAccessWithScope(false); } Builder(StorageOptions options) { super(options); + this.enableOtelMetrics = options.getRawEnableOtelMetrics(); + this.enableOtelDebugMetrics = options.getRawEnableOtelDebugMetrics(); + this.meterProvider = options.getMeterProvider(); + this.metricInterval = options.metricInterval; + this.customMeterProviderConfigured = options.isCustomMeterProviderConfigured(); + this.customOpenTelemetryConfigured = options.isCustomOpenTelemetryConfigured(); + } + + @Nullable Boolean getEnableOtelMetrics() { + return enableOtelMetrics; + } + + @Nullable Boolean getEnableOtelDebugMetrics() { + return enableOtelDebugMetrics; + } + + @Nullable MeterProvider getMeterProvider() { + return meterProvider; + } + + Duration getMetricInterval() { + return metricInterval; + } + + boolean isCustomOpenTelemetryConfigured() { + return customOpenTelemetryConfigured; + } + + void setCustomOpenTelemetryConfigured(boolean customOpenTelemetryConfigured) { + this.customOpenTelemetryConfigured = customOpenTelemetryConfigured; + } + + boolean isCustomMeterProviderConfigured() { + return customMeterProviderConfigured; } public abstract Builder setStorageRetryStrategy(StorageRetryStrategy storageRetryStrategy); @@ -143,12 +191,124 @@ 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.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public StorageOptions.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.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public StorageOptions.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.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public StorageOptions.Builder setMeterProvider(@Nullable MeterProvider meterProvider) { + this.meterProvider = meterProvider; + this.customMeterProviderConfigured = meterProvider != null; + 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)} or explicit + * OpenTelemetry is configured via {@link #setOpenTelemetry(OpenTelemetry)}. + * + * @param metricInterval interval duration + * @since 2.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public StorageOptions.Builder setMetricInterval(Duration metricInterval) { + checkArgument( + metricInterval != null && !metricInterval.isNegative() && !metricInterval.isZero(), + "metricInterval must be positive"); + this.metricInterval = metricInterval; + return this; + } + @Override public abstract StorageOptions build(); } + private static final Logger LOGGER = Logger.getLogger(StorageOptions.class.getName()); + + 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 final boolean customOpenTelemetryConfigured; + StorageOptions(Builder builder, StorageDefaults serviceDefaults) { super(StorageFactory.class, StorageRpcFactory.class, builder, serviceDefaults); + this.rawEnableOtelMetrics = builder.getEnableOtelMetrics(); + this.rawEnableOtelDebugMetrics = builder.getEnableOtelDebugMetrics(); + this.enableOtelMetrics = + StorageMetricsConfig.isEnableOtelMetrics(builder.getEnableOtelMetrics()); + this.enableOtelDebugMetrics = + this.enableOtelMetrics + && StorageMetricsConfig.isEnableOtelDebugMetrics(builder.getEnableOtelDebugMetrics()); + this.meterProvider = builder.getMeterProvider(); + this.customMeterProviderConfigured = + builder.isCustomMeterProviderConfigured() || builder.getMeterProvider() != null; + this.customOpenTelemetryConfigured = builder.isCustomOpenTelemetryConfigured(); + this.metricInterval = + builder.getMetricInterval() != null ? builder.getMetricInterval() : Duration.ofSeconds(60); + boolean hasCustomOtelOrMeterProvider = + this.customMeterProviderConfigured || this.customOpenTelemetryConfigured; + if (!hasCustomOtelOrMeterProvider && 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"); + } + } + + @Nullable Boolean getRawEnableOtelMetrics() { + return rawEnableOtelMetrics; + } + + @Nullable Boolean getRawEnableOtelDebugMetrics() { + return rawEnableOtelDebugMetrics; + } + + boolean isCustomMeterProviderConfigured() { + return customMeterProviderConfigured; + } + + boolean isCustomOpenTelemetryConfigured() { + return customOpenTelemetryConfigured; + } + + private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { + in.defaultReadObject(); + if (this.customMeterProviderConfigured || this.meterProvider != null) { + LOGGER.warning( + "Custom MeterProvider is transient and cannot be serialized across workers; falling back to default provider."); + } } abstract static class StorageDefaults implements ServiceDefaults {} @@ -185,6 +345,52 @@ public static String version() { @BetaApi public abstract OpenTelemetry getOpenTelemetry(); + /** + * Whether OpenTelemetry client metrics are enabled. + * + * @since 2.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public boolean isEnableOtelMetrics() { + return enableOtelMetrics; + } + + /** + * Whether OpenTelemetry debug client metrics are enabled. + * + * @since 2.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public boolean isEnableOtelDebugMetrics() { + return enableOtelDebugMetrics; + } + + /** + * The {@link MeterProvider} configured for recording client metrics. + * + * @since 2.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public @Nullable MeterProvider getMeterProvider() { + return meterProvider; + } + + /** + * The metric export interval configured for periodic metric reading. + * + * @since 2.76.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public Duration getMetricInterval() { + Duration interval = metricInterval != null ? metricInterval : Duration.ofSeconds(60); + if (meterProvider == null && !customOpenTelemetryConfigured) { + if (interval.compareTo(Duration.ofSeconds(60)) < 0) { + return Duration.ofSeconds(60); + } + } + return interval; + } + @SuppressWarnings("unchecked") @Override public abstract StorageOptions.Builder toBuilder(); diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageMetricsConfigTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageMetricsConfigTest.java new file mode 100644 index 000000000000..3a44ef23dc10 --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageMetricsConfigTest.java @@ -0,0 +1,184 @@ +/* + * 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 com.google.common.truth.Truth.assertWithMessage; + +import java.util.HashMap; +import java.util.Map; +import org.junit.After; +import org.junit.Test; + +public final class StorageMetricsConfigTest { + + @After + public void tearDown() { + StorageMetricsConfig.resetResolversForTesting(); + } + + @Test + public void isEnableOtelMetrics_explicitBuilderSettingTakesPrecedenceOverEnvVar() { + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "true"); + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, false)).isFalse(); + + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "false"); + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, true)).isTrue(); + } + + @Test + public void isEnableOtelMetrics_envVarRespectedWhenBuilderSettingNull() { + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "true"); + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, null)).isTrue(); + + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "false"); + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, null)).isFalse(); + } + + @Test + public void isEnableOtelMetrics_defaultsToFalseWhenUnset() { + Map env = new HashMap<>(); + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, null)).isFalse(); + } + + @Test + public void isEnableOtelDebugMetrics_explicitBuilderSettingTakesPrecedenceOverEnvVar() { + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "true"); + assertThat(StorageMetricsConfig.isEnableOtelDebugMetrics(env::get, false)).isFalse(); + + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "false"); + assertThat(StorageMetricsConfig.isEnableOtelDebugMetrics(env::get, true)).isTrue(); + } + + @Test + public void isEnableOtelDebugMetrics_envVarRespectedWhenBuilderSettingNull() { + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "true"); + assertThat(StorageMetricsConfig.isEnableOtelDebugMetrics(env::get, null)).isTrue(); + + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "false"); + assertThat(StorageMetricsConfig.isEnableOtelDebugMetrics(env::get, null)).isFalse(); + } + + @Test + public void isEnableOtelDebugMetrics_defaultsToFalseWhenUnset() { + Map env = new HashMap<>(); + assertThat(StorageMetricsConfig.isEnableOtelDebugMetrics(env::get, null)).isFalse(); + } + + @Test + public void parseBooleanValue_handlesPermissiveValues() { + String[] trueValues = {"1", "t", "T", "true", "TRUE", "True", " true "}; + for (String val : trueValues) { + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.parseBooleanValue(val, "TEST_VAR")) + .isTrue(); + } + + String[] falseValues = {"0", "f", "F", "false", "FALSE", "False", " false "}; + for (String val : falseValues) { + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.parseBooleanValue(val, "TEST_VAR")) + .isFalse(); + } + + String[] invalidValues = {"invalid", "2", "yes", "no", "enabled", ""}; + for (String val : invalidValues) { + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.parseBooleanValue(val, "TEST_VAR")) + .isNull(); + } + + assertThat(StorageMetricsConfig.parseBooleanValue(null, "TEST_VAR")).isNull(); + } + + @Test + public void setEnvResolverForTesting_resolution() { + 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); + + assertThat(StorageMetricsConfig.isEnableOtelMetrics()).isTrue(); + assertThat(StorageMetricsConfig.isEnableOtelDebugMetrics()).isTrue(); + + StorageMetricsConfig.resetResolversForTesting(); + } + + @Test + public void debugMetricsGatedOnBaseMetrics_inStorageOptions() { + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "true"); + StorageMetricsConfig.setEnvResolverForTesting(env::get); + + // Base metrics unset in env (defaults to false) -> options evaluate debug metrics to false + HttpStorageOptions httpEnvDebugOnly = HttpStorageOptions.http().build(); + assertThat(httpEnvDebugOnly.isEnableOtelMetrics()).isFalse(); + assertThat(httpEnvDebugOnly.isEnableOtelDebugMetrics()).isFalse(); + + GrpcStorageOptions grpcEnvDebugOnly = GrpcStorageOptions.grpc().build(); + assertThat(grpcEnvDebugOnly.isEnableOtelMetrics()).isFalse(); + assertThat(grpcEnvDebugOnly.isEnableOtelDebugMetrics()).isFalse(); + + // Base metrics set to true in env -> options evaluate debug metrics to true + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "true"); + HttpStorageOptions httpEnvBoth = HttpStorageOptions.http().build(); + assertThat(httpEnvBoth.isEnableOtelMetrics()).isTrue(); + assertThat(httpEnvBoth.isEnableOtelDebugMetrics()).isTrue(); + + GrpcStorageOptions grpcEnvBoth = GrpcStorageOptions.grpc().build(); + assertThat(grpcEnvBoth.isEnableOtelMetrics()).isTrue(); + assertThat(grpcEnvBoth.isEnableOtelDebugMetrics()).isTrue(); + + // Explicit builder disabling base metrics overrides env and keeps debug metrics false + HttpStorageOptions httpBuilderGated = + HttpStorageOptions.http() + .setEnableOtelMetrics(false) + .setEnableOtelDebugMetrics(true) + .build(); + assertThat(httpBuilderGated.isEnableOtelMetrics()).isFalse(); + assertThat(httpBuilderGated.isEnableOtelDebugMetrics()).isFalse(); + + GrpcStorageOptions grpcBuilderGated = + GrpcStorageOptions.grpc() + .setEnableOtelMetrics(false) + .setEnableOtelDebugMetrics(true) + .build(); + assertThat(grpcBuilderGated.isEnableOtelMetrics()).isFalse(); + assertThat(grpcBuilderGated.isEnableOtelDebugMetrics()).isFalse(); + + // Explicit builder enabling both evaluates debug metrics to true + HttpStorageOptions httpBuilderBoth = + HttpStorageOptions.http() + .setEnableOtelMetrics(true) + .setEnableOtelDebugMetrics(true) + .build(); + assertThat(httpBuilderBoth.isEnableOtelMetrics()).isTrue(); + assertThat(httpBuilderBoth.isEnableOtelDebugMetrics()).isTrue(); + + GrpcStorageOptions grpcBuilderBoth = + GrpcStorageOptions.grpc() + .setEnableOtelMetrics(true) + .setEnableOtelDebugMetrics(true) + .build(); + assertThat(grpcBuilderBoth.isEnableOtelMetrics()).isTrue(); + assertThat(grpcBuilderBoth.isEnableOtelDebugMetrics()).isTrue(); + } +} 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..f2dc6f3fda8d --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsTest.java @@ -0,0 +1,461 @@ +/* + * 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 com.google.common.truth.Truth.assertWithMessage; +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 { + + @FunctionalInterface + private interface BuilderConsumer { + void accept(StorageOptions.Builder builder) throws Exception; + } + + private static void forEachTransport(BuilderConsumer consumer) throws Exception { + consumer.accept(HttpStorageOptions.http()); + consumer.accept(GrpcStorageOptions.grpc()); + } + + @After + public void tearDown() { + StorageMetricsConfig.resetResolversForTesting(); + } + + @Test + public void defaultState_metricsDisabled() throws Exception { + forEachTransport( + builder -> { + StorageOptions options = builder.build(); + assertThat(options.isEnableOtelMetrics()).isFalse(); + assertThat(options.isEnableOtelDebugMetrics()).isFalse(); + assertThat(options.getMeterProvider()).isNull(); + assertThat(options.getMetricInterval()).isEqualTo(Duration.ofSeconds(60)); + }); + } + + @Test + public void builder_explicitEnabling() throws Exception { + MeterProvider mockMeterProvider = SdkMeterProvider.builder().build(); + Duration interval = Duration.ofSeconds(30); + + forEachTransport( + builder -> { + StorageOptions options = + builder + .setEnableOtelMetrics(true) + .setEnableOtelDebugMetrics(true) + .setMeterProvider(mockMeterProvider) + .setMetricInterval(interval) + .build(); + assertThat(options.isEnableOtelMetrics()).isTrue(); + assertThat(options.isEnableOtelDebugMetrics()).isTrue(); + assertThat(options.getMeterProvider()).isSameInstanceAs(mockMeterProvider); + assertThat(options.getMetricInterval()).isEqualTo(interval); + + StorageOptions rebuilt = options.toBuilder().build(); + assertThat(rebuilt.isEnableOtelMetrics()).isTrue(); + assertThat(rebuilt.isEnableOtelDebugMetrics()).isTrue(); + assertThat(rebuilt.getMeterProvider()).isSameInstanceAs(mockMeterProvider); + assertThat(rebuilt.getMetricInterval()).isEqualTo(interval); + }); + } + + @Test + public void precedence_builderTakesPrecedenceOverEnv() throws Exception { + 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); + + forEachTransport( + builder -> { + StorageOptions disabled = + builder.setEnableOtelMetrics(false).setEnableOtelDebugMetrics(false).build(); + assertThat(disabled.isEnableOtelMetrics()).isFalse(); + assertThat(disabled.isEnableOtelDebugMetrics()).isFalse(); + }); + + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "false"); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "false"); + + forEachTransport( + builder -> { + StorageOptions enabled = + builder.setEnableOtelMetrics(true).setEnableOtelDebugMetrics(true).build(); + assertThat(enabled.isEnableOtelMetrics()).isTrue(); + assertThat(enabled.isEnableOtelDebugMetrics()).isTrue(); + }); + } + + @Test + public void precedence_builderTakesPrecedenceOverDefaultWhenEnvUnset() throws Exception { + Map env = new HashMap<>(); + StorageMetricsConfig.setEnvResolverForTesting(env::get); + + forEachTransport( + builder -> { + StorageOptions enabled = + builder.setEnableOtelMetrics(true).setEnableOtelDebugMetrics(true).build(); + assertThat(enabled.isEnableOtelMetrics()).isTrue(); + assertThat(enabled.isEnableOtelDebugMetrics()).isTrue(); + }); + + forEachTransport( + builder -> { + StorageOptions disabled = + builder.setEnableOtelMetrics(false).setEnableOtelDebugMetrics(false).build(); + assertThat(disabled.isEnableOtelMetrics()).isFalse(); + assertThat(disabled.isEnableOtelDebugMetrics()).isFalse(); + }); + } + + @Test + public void debugMetricsGatedOnBaseMetrics_builder() throws Exception { + forEachTransport( + builder -> { + StorageOptions debugOnly = + builder.setEnableOtelMetrics(false).setEnableOtelDebugMetrics(true).build(); + assertThat(debugOnly.isEnableOtelMetrics()).isFalse(); + assertThat(debugOnly.isEnableOtelDebugMetrics()).isFalse(); + }); + + forEachTransport( + builder -> { + StorageOptions both = + builder.setEnableOtelMetrics(true).setEnableOtelDebugMetrics(true).build(); + assertThat(both.isEnableOtelMetrics()).isTrue(); + assertThat(both.isEnableOtelDebugMetrics()).isTrue(); + }); + } + + @Test + public void debugMetricsGatedOnBaseMetrics_env() throws Exception { + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "true"); + StorageMetricsConfig.setEnvResolverForTesting(env::get); + + // Base metrics unset (defaults to false) -> debug metrics should evaluate to false + forEachTransport( + builder -> { + StorageOptions envDebugOnly = builder.build(); + assertThat(envDebugOnly.isEnableOtelMetrics()).isFalse(); + assertThat(envDebugOnly.isEnableOtelDebugMetrics()).isFalse(); + }); + + // Base metrics explicitly false in env -> debug metrics should evaluate to false + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "false"); + forEachTransport( + builder -> { + StorageOptions envExplicitFalse = builder.build(); + assertThat(envExplicitFalse.isEnableOtelMetrics()).isFalse(); + assertThat(envExplicitFalse.isEnableOtelDebugMetrics()).isFalse(); + }); + + // Base metrics enabled in env -> debug metrics should evaluate to true + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "true"); + forEachTransport( + builder -> { + StorageOptions envBoth = builder.build(); + assertThat(envBoth.isEnableOtelMetrics()).isTrue(); + assertThat(envBoth.isEnableOtelDebugMetrics()).isTrue(); + }); + } + + @Test + public void permissiveBooleanParsing_trueValues() { + String[] trueValues = {"1", "t", "T", "true", "TRUE", "True", " true "}; + for (String val : trueValues) { + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.parseBooleanValue(val, "TEST_VAR")) + .isTrue(); + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, val); + // When builderSetting is null, env var is parsed and respected + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.isEnableOtelMetrics(env::get, null)) + .isTrue(); + // When builderSetting is provided, builder takes precedence over env var + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.isEnableOtelMetrics(env::get, false)) + .isFalse(); + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.isEnableOtelMetrics(env::get, true)) + .isTrue(); + } + } + + @Test + public void permissiveBooleanParsing_falseValues() { + String[] falseValues = {"0", "f", "F", "false", "FALSE", "False", " false "}; + for (String val : falseValues) { + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.parseBooleanValue(val, "TEST_VAR")) + .isFalse(); + Map env = new HashMap<>(); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, val); + // When builderSetting is null, env var is parsed and respected + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.isEnableOtelMetrics(env::get, null)) + .isFalse(); + // When builderSetting is provided, builder takes precedence over env var + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.isEnableOtelMetrics(env::get, true)) + .isTrue(); + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.isEnableOtelMetrics(env::get, false)) + .isFalse(); + } + } + + @Test + public void permissiveBooleanParsing_invalidValuesFallback() { + String[] invalidValues = {"invalid", "2", "yes", "no", "enabled", ""}; + for (String val : invalidValues) { + assertWithMessage("Failed for input value: '%s'", val) + .that(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 if provided + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.isEnableOtelMetrics(env::get, true)) + .isTrue(); + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.isEnableOtelMetrics(env::get, false)) + .isFalse(); + // When builder setting is null, falls back to default false + assertWithMessage("Failed for input value: '%s'", val) + .that(StorageMetricsConfig.isEnableOtelMetrics(env::get, null)) + .isFalse(); + } + } + + @Test + public void permissiveBooleanParsing_nullHandled() { + Map emptyEnv = new HashMap<>(); + assertThat(StorageMetricsConfig.isEnableOtelMetrics(emptyEnv::get, null)).isFalse(); + assertThat(StorageMetricsConfig.parseBooleanValue(null, "TEST_VAR")).isNull(); + } + + @Test + public void toBuilder_doesNotBakeInEnvironmentVariable() throws Exception { + Map env = new HashMap<>(); + + forEachTransport( + builder -> { + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "true"); + env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "true"); + StorageMetricsConfig.setEnvResolverForTesting(env::get); + + StorageOptions options = builder.build(); + assertThat(options.isEnableOtelMetrics()).isTrue(); + assertThat(options.isEnableOtelDebugMetrics()).isTrue(); + + // Clear environment variable and rebuild + env.clear(); + StorageOptions rebuilt = options.toBuilder().build(); + assertThat(rebuilt.isEnableOtelMetrics()).isFalse(); + assertThat(rebuilt.isEnableOtelDebugMetrics()).isFalse(); + }); + } + + @Test + public void environmentVariableJava() throws Exception { + 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); + + forEachTransport( + builder -> { + StorageOptions options = builder.build(); + assertThat(options.isEnableOtelMetrics()).isTrue(); + assertThat(options.isEnableOtelDebugMetrics()).isTrue(); + }); + } + + @Test + public void setMetricInterval_rejectsNullZeroOrNegative() throws Exception { + forEachTransport( + builder -> { + assertThrows( + IllegalArgumentException.class, + () -> builder.setMetricInterval(Duration.ofSeconds(-1))); + assertThrows( + IllegalArgumentException.class, () -> builder.setMetricInterval(Duration.ZERO)); + assertThrows(IllegalArgumentException.class, () -> builder.setMetricInterval(null)); + }); + } + + @Test + public void setMetricInterval_rejectsSubSixtySecondsWithoutCustomProvider() throws Exception { + // Reject intervals < 60s when meterProvider == null (SDK-owned Cloud Monitoring exporter) + forEachTransport( + builder -> { + assertThrows( + IllegalArgumentException.class, + () -> builder.setMetricInterval(Duration.ofSeconds(59)).build()); + assertThrows( + IllegalArgumentException.class, + () -> builder.setMetricInterval(Duration.ofSeconds(30)).build()); + }); + } + + @Test + public void setMetricInterval_acceptsSixtySecondsOrGreaterWithDefaultProvider() throws Exception { + forEachTransport( + builder -> { + StorageOptions options = builder.setMetricInterval(Duration.ofSeconds(60)).build(); + assertThat(options.getMetricInterval()).isEqualTo(Duration.ofSeconds(60)); + }); + } + + @Test + public void setMetricInterval_acceptsSubSixtySecondsWithCustomMeterProvider() throws Exception { + MeterProvider customMeterProvider = SdkMeterProvider.builder().build(); + + forEachTransport( + builder -> { + StorageOptions customOptions = + builder + .setMeterProvider(customMeterProvider) + .setMetricInterval(Duration.ofSeconds(30)) + .build(); + assertThat(customOptions.getMetricInterval()).isEqualTo(Duration.ofSeconds(30)); + }); + } + + @Test + public void setMetricInterval_acceptsSubSixtySecondsWithCustomOpenTelemetry() throws Exception { + io.opentelemetry.api.OpenTelemetry customOtel = io.opentelemetry.api.OpenTelemetry.noop(); + + forEachTransport( + builder -> { + StorageOptions customOptionsOtel = + builder + .setOpenTelemetry(customOtel) + .setMetricInterval(Duration.ofSeconds(30)) + .build(); + assertThat(customOptionsOtel.getMetricInterval()).isEqualTo(Duration.ofSeconds(30)); + }); + } + + @Test + public void serializationRoundTrip_customMeterProviderDoesNotCarryOver() throws Exception { + MeterProvider customMeterProvider = SdkMeterProvider.builder().build(); + + forEachTransport( + builder -> { + StorageOptions options = + builder.setEnableOtelMetrics(true).setMeterProvider(customMeterProvider).build(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(options); + } + + StorageOptions deserialized; + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + deserialized = (StorageOptions) ois.readObject(); + } + + assertThat(deserialized).isNotNull(); + assertThat(deserialized.isEnableOtelMetrics()).isTrue(); + // Transient meterProvider does not carry over; returns null + assertThat(deserialized.getMeterProvider()).isNull(); + }); + } + + @Test + public void serializationRoundTrip_customMeterProviderWithSub60sInterval_clampsToSixtySeconds() + throws Exception { + MeterProvider customMeterProvider = SdkMeterProvider.builder().build(); + + forEachTransport( + builder -> { + StorageOptions options = + builder + .setMeterProvider(customMeterProvider) + .setMetricInterval(Duration.ofSeconds(30)) + .build(); + assertThat(options.getMetricInterval()).isEqualTo(Duration.ofSeconds(30)); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(options); + } + + StorageOptions deserialized; + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + deserialized = (StorageOptions) ois.readObject(); + } + + assertThat(deserialized).isNotNull(); + assertThat(deserialized.getMeterProvider()).isNull(); + assertThat(deserialized.getMetricInterval()).isEqualTo(Duration.ofSeconds(60)); + }); + } + + @Test + public void deserializationWithNullMetricInterval_defaultsToSixtySeconds() throws Exception { + Field builderField = StorageOptions.Builder.class.getDeclaredField("metricInterval"); + builderField.setAccessible(true); + + forEachTransport( + builder -> { + StorageOptions defaultOptions = builder.build(); + builderField.set(builder, null); + StorageOptions optionsWithNullInterval = builder.build(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(optionsWithNullInterval); + } + + StorageOptions deserialized; + try (ObjectInputStream ois = + new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + deserialized = (StorageOptions) ois.readObject(); + } + + assertThat(deserialized).isNotNull(); + // Verify NPE guard defaults to 60 seconds + assertThat(deserialized.getMetricInterval()).isEqualTo(Duration.ofSeconds(60)); + // Verify equals and hashCode match a default instance + assertThat(deserialized).isEqualTo(defaultOptions); + assertThat(defaultOptions).isEqualTo(deserialized); + assertThat(deserialized.hashCode()).isEqualTo(defaultOptions.hashCode()); + }); + } +}