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..e2415c798154 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, + enableOtelMetrics, + enableOtelDebugMetrics, + getMeterProvider(), + getMetricInterval(), baseHashCode()); } @@ -446,11 +452,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); } @@ -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; + this.customOpenTelemetryConfigured = true; + return this; + } + + /** + * Enable or disable OpenTelemetry client metrics. + * + * @param enableOtelMetrics whether OpenTelemetry client metrics should be enabled + * @since 2.74.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.74.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.74.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.74.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..ee3e73487464 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, + enableOtelMetrics, + enableOtelDebugMetrics, + 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) + && enableOtelMetrics == that.enableOtelMetrics + && enableOtelDebugMetrics == that.enableOtelDebugMetrics + && 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; + this.customOpenTelemetryConfigured = true; + return this; + } + + /** + * Enable or disable OpenTelemetry client metrics. + * + * @param enableOtelMetrics whether OpenTelemetry client metrics should be enabled + * @since 2.74.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.74.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.74.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.74.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..30be2ba85ef2 --- /dev/null +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageMetricsConfig.java @@ -0,0 +1,115 @@ +/* + * 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) { + if (builderSetting != null) { + return 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 false; + } + + @VisibleForTesting + static boolean isEnableOtelDebugMetrics( + Function env, @Nullable Boolean builderSetting) { + if (builderSetting != null) { + return 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 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..2d123041ca20 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,25 @@ public DefaultStorageRpcFactory() { public abstract static class Builder extends ServiceOptions.Builder { + @Nullable Boolean enableOtelMetrics = null; + @Nullable Boolean enableOtelDebugMetrics = null; + @Nullable MeterProvider meterProvider = null; + Duration metricInterval = Duration.ofSeconds(60); + boolean customOpenTelemetryConfigured = false; + boolean customMeterProviderConfigured = false; + Builder() { setUseJwtAccessWithScope(false); } Builder(StorageOptions options) { super(options); + this.enableOtelMetrics = options.rawEnableOtelMetrics; + this.enableOtelDebugMetrics = options.rawEnableOtelDebugMetrics; + this.meterProvider = options.meterProvider; + this.metricInterval = options.metricInterval; + this.customMeterProviderConfigured = options.customMeterProviderConfigured; + this.customOpenTelemetryConfigured = options.customOpenTelemetryConfigured; } public abstract Builder setStorageRetryStrategy(StorageRetryStrategy storageRetryStrategy); @@ -143,12 +163,106 @@ 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.74.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.74.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.74.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.74.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()); + + final boolean enableOtelMetrics; + final boolean enableOtelDebugMetrics; + @Nullable final Boolean rawEnableOtelMetrics; + @Nullable final Boolean rawEnableOtelDebugMetrics; + transient MeterProvider meterProvider; + final Duration metricInterval; + final boolean customMeterProviderConfigured; + final boolean customOpenTelemetryConfigured; + StorageOptions(Builder builder, StorageDefaults serviceDefaults) { super(StorageFactory.class, StorageRpcFactory.class, builder, serviceDefaults); + 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.customMeterProviderConfigured || builder.meterProvider != null; + this.customOpenTelemetryConfigured = builder.customOpenTelemetryConfigured; + this.metricInterval = + builder.metricInterval != null ? builder.metricInterval : 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"); + } + } + + 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 +299,46 @@ public static String version() { @BetaApi public abstract OpenTelemetry getOpenTelemetry(); + /** + * Whether OpenTelemetry client metrics are enabled. + * + * @since 2.74.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.74.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.74.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.74.0 This new api is in preview and is subject to breaking changes. + */ + @BetaApi + public Duration getMetricInterval() { + return metricInterval != null ? metricInterval : Duration.ofSeconds(60); + } + @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..5cb4f3946490 --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageMetricsConfigTest.java @@ -0,0 +1,111 @@ +/* + * 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 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_precedenceOrder() { + Map env = new HashMap<>(); + + // 1. Explicit programmatic builder setting takes precedence over environment variable + 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(); + + // 2. When builder setting is null, environment variable is respected + 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(); + + // 3. When both builder setting and env var are absent/null, default is false + env.clear(); + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, null)).isFalse(); + } + + @Test + public void isEnableOtelDebugMetrics_precedenceOrder() { + Map env = new HashMap<>(); + + // 1. Explicit programmatic builder setting takes precedence over environment variable + 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(); + + // 2. When builder setting is null, environment variable is respected + 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(); + + // 3. When both builder setting and env var are absent/null, default is false + env.clear(); + 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) { + assertThat(StorageMetricsConfig.parseBooleanValue(val, "TEST_VAR")).isTrue(); + } + + String[] falseValues = {"0", "f", "F", "false", "FALSE", "False", " false "}; + for (String val : falseValues) { + assertThat(StorageMetricsConfig.parseBooleanValue(val, "TEST_VAR")).isFalse(); + } + + String[] invalidValues = {"invalid", "2", "yes", "no", "enabled", ""}; + for (String val : invalidValues) { + assertThat(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(); + } +} 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..a54b9a01dba3 --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsTest.java @@ -0,0 +1,448 @@ +/* + * 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()).isNull(); + assertThat(httpOptions.getMetricInterval()).isEqualTo(Duration.ofSeconds(60)); + + GrpcStorageOptions grpcOptions = GrpcStorageOptions.grpc().build(); + assertThat(grpcOptions.isEnableOtelMetrics()).isFalse(); + assertThat(grpcOptions.isEnableOtelDebugMetrics()).isFalse(); + assertThat(grpcOptions.getMeterProvider()).isNull(); + 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_builderTakesPrecedenceOverEnv() { + 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()).isFalse(); + assertThat(httpDisabled.isEnableOtelDebugMetrics()).isFalse(); + + GrpcStorageOptions grpcDisabled = + GrpcStorageOptions.grpc() + .setEnableOtelMetrics(false) + .setEnableOtelDebugMetrics(false) + .build(); + assertThat(grpcDisabled.isEnableOtelMetrics()).isFalse(); + assertThat(grpcDisabled.isEnableOtelDebugMetrics()).isFalse(); + + 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()).isTrue(); + assertThat(httpEnabled.isEnableOtelDebugMetrics()).isTrue(); + + GrpcStorageOptions grpcEnabled = + GrpcStorageOptions.grpc() + .setEnableOtelMetrics(true) + .setEnableOtelDebugMetrics(true) + .build(); + assertThat(grpcEnabled.isEnableOtelMetrics()).isTrue(); + assertThat(grpcEnabled.isEnableOtelDebugMetrics()).isTrue(); + } + + @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); + // When builderSetting is null, env var is parsed and respected + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, null)).isTrue(); + // When builderSetting is provided, builder takes precedence over env var + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, false)).isFalse(); + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, true)).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); + // When builderSetting is null, env var is parsed and respected + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, null)).isFalse(); + // When builderSetting is provided, builder takes precedence over env var + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, true)).isTrue(); + assertThat(StorageMetricsConfig.isEnableOtelMetrics(env::get, false)).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 if provided + 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(); + } + + // When both are absent/null, default is false + Map emptyEnv = new HashMap<>(); + assertThat(StorageMetricsConfig.isEnableOtelMetrics(emptyEnv::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)); + + // Accept intervals < 60s when explicit openTelemetry is configured + io.opentelemetry.api.OpenTelemetry customOtel = io.opentelemetry.api.OpenTelemetry.noop(); + HttpStorageOptions httpCustomOtel = + HttpStorageOptions.http() + .setOpenTelemetry(customOtel) + .setMetricInterval(Duration.ofSeconds(30)) + .build(); + assertThat(httpCustomOtel.getMetricInterval()).isEqualTo(Duration.ofSeconds(30)); + + GrpcStorageOptions grpcCustomOtel = + GrpcStorageOptions.grpc() + .setOpenTelemetry(customOtel) + .setMetricInterval(Duration.ofSeconds(30)) + .build(); + assertThat(grpcCustomOtel.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; returns null + assertThat(deserializedHttp.getMeterProvider()).isNull(); + + 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(); + // Transient meterProvider does not carry over; returns null + assertThat(deserializedGrpc.getMeterProvider()).isNull(); + } + + @Test + public void deserializationWithNullMetricInterval_defaultsToSixtySeconds() throws Exception { + Field httpBuilderField = StorageOptions.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 = StorageOptions.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()); + } +}