diff --git a/java-storage/google-cloud-storage/pom.xml b/java-storage/google-cloud-storage/pom.xml
index f704d90e90ed..cebe48b6afab 100644
--- a/java-storage/google-cloud-storage/pom.xml
+++ b/java-storage/google-cloud-storage/pom.xml
@@ -241,6 +241,11 @@
opentelemetry-sdk-trace
test
+
+ io.opentelemetry
+ opentelemetry-sdk-testing
+ test
+
io.grpc
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..80de7d9b2c0a 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.sdk.metrics.SdkMeterProvider;
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
@@ -148,6 +150,10 @@ 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;
+ private final transient SdkMeterProvider meterProvider;
+ private final Duration metricInterval;
private GrpcStorageOptions(Builder builder, GrpcStorageDefaults serviceDefaults) {
super(builder, serviceDefaults);
@@ -165,6 +171,16 @@ private GrpcStorageOptions(Builder builder, GrpcStorageDefaults serviceDefaults)
this.grpcInterceptorProvider = builder.grpcInterceptorProvider;
this.blobWriteSessionConfig = builder.blobWriteSessionConfig;
this.openTelemetry = builder.openTelemetry;
+ this.enableOtelMetrics =
+ builder.enableOtelMetrics != null
+ ? builder.enableOtelMetrics
+ : StorageMetricsConfig.isEnableOtelMetrics();
+ this.enableOtelDebugMetrics =
+ builder.enableOtelDebugMetrics != null
+ ? builder.enableOtelDebugMetrics
+ : StorageMetricsConfig.isEnableOtelDebugMetrics();
+ this.meterProvider = builder.meterProvider;
+ this.metricInterval = builder.metricInterval;
}
@Override
@@ -414,6 +430,42 @@ 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 SdkMeterProvider 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;
+ }
+
/**
* @since 2.14.0
*/
@@ -432,6 +484,10 @@ public int hashCode() {
grpcInterceptorProvider,
blobWriteSessionConfig,
openTelemetry,
+ enableOtelMetrics,
+ enableOtelDebugMetrics,
+ meterProvider,
+ metricInterval,
baseHashCode());
}
@@ -446,11 +502,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(meterProvider, that.meterProvider)
+ && Objects.equals(metricInterval, that.metricInterval)
&& this.baseEquals(that);
}
@@ -501,6 +561,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 SdkMeterProvider meterProvider = null;
+ private Duration metricInterval = Duration.ofSeconds(60);
private boolean grpcMetricsManuallyEnabled = false;
@@ -516,6 +580,10 @@ public static final class Builder extends StorageOptions.Builder {
this.grpcInterceptorProvider = gso.grpcInterceptorProvider;
this.blobWriteSessionConfig = gso.blobWriteSessionConfig;
this.openTelemetry = gso.openTelemetry;
+ this.enableOtelMetrics = gso.isEnableOtelMetrics();
+ this.enableOtelDebugMetrics = gso.isEnableOtelDebugMetrics();
+ this.meterProvider = gso.getMeterProvider();
+ this.metricInterval = gso.getMetricInterval();
}
/**
@@ -750,6 +818,58 @@ 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 SdkMeterProvider} for recording client metrics.
+ *
+ * @param meterProvider custom SdkMeterProvider to use
+ * @since 2.50.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ @Override
+ public GrpcStorageOptions.Builder setMeterProvider(SdkMeterProvider meterProvider) {
+ this.meterProvider = meterProvider;
+ return this;
+ }
+
+ /**
+ * Set the metric export interval for periodic metric reading.
+ *
+ * @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) {
+ this.metricInterval = requireNonNull(metricInterval, "metricInterval must be non null");
+ return this;
+ }
+
/**
* @since 2.14.0
*/
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..e276fc8b5497 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.sdk.metrics.SdkMeterProvider;
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
@@ -66,6 +69,10 @@ public class HttpStorageOptions extends StorageOptions {
private final BlobWriteSessionConfig blobWriteSessionConfig;
private transient OpenTelemetry openTelemetry;
+ private final boolean enableOtelMetrics;
+ private final boolean enableOtelDebugMetrics;
+ private final transient SdkMeterProvider meterProvider;
+ private final Duration metricInterval;
private HttpStorageOptions(Builder builder, StorageDefaults serviceDefaults) {
super(builder, serviceDefaults);
@@ -76,6 +83,16 @@ private HttpStorageOptions(Builder builder, StorageDefaults serviceDefaults) {
retryDepsAdapter = new RetryDependenciesAdapter();
blobWriteSessionConfig = builder.blobWriteSessionConfig;
openTelemetry = builder.openTelemetry;
+ this.enableOtelMetrics =
+ builder.enableOtelMetrics != null
+ ? builder.enableOtelMetrics
+ : StorageMetricsConfig.isEnableOtelMetrics();
+ this.enableOtelDebugMetrics =
+ builder.enableOtelDebugMetrics != null
+ ? builder.enableOtelDebugMetrics
+ : StorageMetricsConfig.isEnableOtelDebugMetrics();
+ this.meterProvider = builder.meterProvider;
+ this.metricInterval = builder.metricInterval;
}
@Override
@@ -102,6 +119,42 @@ 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 SdkMeterProvider 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;
+ }
+
@Override
public HttpStorageOptions.Builder toBuilder() {
return new HttpStorageOptions.Builder(this);
@@ -110,7 +163,14 @@ public HttpStorageOptions.Builder toBuilder() {
@Override
public int hashCode() {
return Objects.hash(
- retryAlgorithmManager, blobWriteSessionConfig, openTelemetry, baseHashCode());
+ retryAlgorithmManager,
+ blobWriteSessionConfig,
+ openTelemetry,
+ enableOtelMetrics,
+ enableOtelDebugMetrics,
+ meterProvider,
+ metricInterval,
+ baseHashCode());
}
@Override
@@ -125,6 +185,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(meterProvider, that.meterProvider)
+ && Objects.equals(metricInterval, that.metricInterval)
&& this.baseEquals(that);
}
@@ -157,6 +221,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 SdkMeterProvider meterProvider = null;
+ private Duration metricInterval = Duration.ofSeconds(60);
Builder() {}
@@ -166,6 +234,10 @@ public static class Builder extends StorageOptions.Builder {
this.storageRetryStrategy = hso.retryAlgorithmManager.retryStrategy;
this.blobWriteSessionConfig = hso.blobWriteSessionConfig;
this.openTelemetry = hso.getOpenTelemetry();
+ this.enableOtelMetrics = hso.isEnableOtelMetrics();
+ this.enableOtelDebugMetrics = hso.isEnableOtelDebugMetrics();
+ this.meterProvider = hso.getMeterProvider();
+ this.metricInterval = hso.getMetricInterval();
}
@Override
@@ -317,6 +389,58 @@ 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 SdkMeterProvider} for recording client metrics.
+ *
+ * @param meterProvider custom SdkMeterProvider to use
+ * @since 2.50.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ @Override
+ public HttpStorageOptions.Builder setMeterProvider(SdkMeterProvider meterProvider) {
+ this.meterProvider = meterProvider;
+ return this;
+ }
+
+ /**
+ * Set the metric export interval for periodic metric reading.
+ *
+ * @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) {
+ this.metricInterval = requireNonNull(metricInterval, "metricInterval must be non null");
+ return this;
+ }
}
public static final class HttpStorageDefaults extends StorageDefaults {
diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtils.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtils.java
index 53fea0b8b6c2..34ee219c8310 100644
--- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtils.java
+++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtils.java
@@ -51,6 +51,7 @@
import java.math.BigDecimal;
import java.math.MathContext;
import java.net.NoRouteToHostException;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
@@ -205,12 +206,62 @@ static String getCloudMonitoringEndpoint(String endpoint, String universeDomain)
return metricServiceEndpoint + ":" + endpoint.split(":")[1];
}
+ static final ImmutableList CLIENT_LATENCY_HISTOGRAMS =
+ ImmutableList.of(
+ StorageClientMetrics.METRIC_RPC_CLIENT_CALL_DURATION,
+ StorageClientMetrics.METRIC_HTTP_CLIENT_REQUEST_DURATION,
+ StorageClientMetrics.METRIC_GCP_CLIENT_REQUEST_DURATION,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATION_TTFB,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_GFE_DURATION,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_NETWORK_DNS_LOOKUP_DURATION,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_NETWORK_TCP_CONNECT_DURATION,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_NETWORK_TLS_HANDSHAKE_DURATION,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_AUTH_CREDENTIAL_REFRESH_DURATION);
+
+ static final ImmutableList CLIENT_SIZE_HISTOGRAMS =
+ ImmutableList.of(
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_REQUEST_BODY_SIZE,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_RESPONSE_BODY_SIZE);
+
@VisibleForTesting
static SdkMeterProvider createMeterProvider(
String metricServiceEndpoint,
String projectIdToUse,
Attributes detectedAttributes,
boolean shouldSuppressExceptions) {
+ return createClientMeterProvider(
+ metricServiceEndpoint,
+ projectIdToUse,
+ detectedAttributes,
+ Duration.ofSeconds(60),
+ shouldSuppressExceptions,
+ "grpc");
+ }
+
+ @VisibleForTesting
+ static SdkMeterProvider createClientMeterProvider(
+ String metricServiceEndpoint,
+ String projectIdToUse,
+ Attributes detectedAttributes,
+ Duration metricInterval,
+ boolean shouldSuppressExceptions) {
+ return createClientMeterProvider(
+ metricServiceEndpoint,
+ projectIdToUse,
+ detectedAttributes,
+ metricInterval,
+ shouldSuppressExceptions,
+ "storage");
+ }
+
+ @VisibleForTesting
+ static SdkMeterProvider createClientMeterProvider(
+ String metricServiceEndpoint,
+ String projectIdToUse,
+ Attributes detectedAttributes,
+ Duration metricInterval,
+ boolean shouldSuppressExceptions,
+ String api) {
MonitoredResourceDescription monitoredResourceDescription =
new MonitoredResourceDescription(
@@ -239,6 +290,22 @@ static SdkMeterProvider createMeterProvider(
InstrumentSelector.builder().setName(metric).build(),
View.builder().setName(metric.replace(".", "/")).build());
}
+ addHistogramView(
+ providerBuilder, latencyHistogramBoundaries(), "grpc/client/attempt/duration", "s");
+ addHistogramView(
+ providerBuilder,
+ sizeHistogramBoundaries(),
+ "grpc/client/attempt/rcvd_total_compressed_message_size",
+ "By");
+ addHistogramView(
+ providerBuilder,
+ sizeHistogramBoundaries(),
+ "grpc/client/attempt/sent_total_compressed_message_size",
+ "By");
+
+ // Register views for client histograms
+ registerClientViews(providerBuilder);
+
MetricExporter exporter =
shouldSuppressExceptions
? new PermissionDeniedSingleReportMetricsExporter(cloudMonitoringExporter)
@@ -248,7 +315,7 @@ static SdkMeterProvider createMeterProvider(
.put("gcp.resource_type", "storage.googleapis.com/Client")
.put("project_id", projectIdToUse)
.put("instance_id", UUID.randomUUID().toString())
- .put("api", "grpc");
+ .put("api", api != null ? api : "storage");
String detectedLocation = detectedAttributes.get(AttributeKey.stringKey("cloud.region"));
if (detectedLocation != null) {
attributesBuilder.put("location", detectedLocation);
@@ -270,26 +337,44 @@ static SdkMeterProvider createMeterProvider(
providerBuilder
.registerMetricReader(
PeriodicMetricReader.builder(exporter)
- .setInterval(java.time.Duration.ofSeconds(60))
+ .setInterval(metricInterval != null ? metricInterval : Duration.ofSeconds(60))
.build())
.setResource(Resource.create(attributesBuilder.build()));
- addHistogramView(
- providerBuilder, latencyHistogramBoundaries(), "grpc/client/attempt/duration", "s");
- addHistogramView(
- providerBuilder,
- sizeHistogramBoundaries(),
- "grpc/client/attempt/rcvd_total_compressed_message_size",
- "By");
- addHistogramView(
- providerBuilder,
- sizeHistogramBoundaries(),
- "grpc/client/attempt/sent_total_compressed_message_size",
- "By");
-
return providerBuilder.build();
}
+ @VisibleForTesting
+ static SdkMeterProviderBuilder registerClientViews(SdkMeterProviderBuilder providerBuilder) {
+ for (String metric : CLIENT_LATENCY_HISTOGRAMS) {
+ addClientHistogramView(providerBuilder, latencyHistogramBoundaries(), metric, "s");
+ }
+ for (String metric : CLIENT_SIZE_HISTOGRAMS) {
+ addClientHistogramView(providerBuilder, sizeHistogramBoundaries(), metric, "By");
+ }
+ return providerBuilder;
+ }
+
+ private static void addClientHistogramView(
+ SdkMeterProviderBuilder provider, List boundaries, String name, String unit) {
+ InstrumentSelector instrumentSelector =
+ InstrumentSelector.builder()
+ .setType(InstrumentType.HISTOGRAM)
+ .setUnit(unit)
+ .setName(name)
+ .build();
+ View view =
+ View.builder()
+ .setName(name)
+ .setDescription(
+ "A view of "
+ + name
+ + " with histogram boundaries more appropriate for Google Cloud Storage RPCs")
+ .setAggregation(Aggregation.explicitBucketHistogram(boundaries))
+ .build();
+ provider.registerView(instrumentSelector, view);
+ }
+
private static void addHistogramView(
SdkMeterProviderBuilder provider, List boundaries, String name, String unit) {
InstrumentSelector instrumentSelector =
@@ -312,7 +397,8 @@ private static void addHistogramView(
provider.registerView(instrumentSelector, view);
}
- private static List latencyHistogramBoundaries() {
+ @VisibleForTesting
+ static List latencyHistogramBoundaries() {
List boundaries = new ArrayList<>();
BigDecimal boundary = new BigDecimal(0, MathContext.UNLIMITED);
BigDecimal increment = new BigDecimal("0.002", MathContext.UNLIMITED); // 2ms
@@ -337,7 +423,8 @@ private static List latencyHistogramBoundaries() {
return boundaries;
}
- private static List sizeHistogramBoundaries() {
+ @VisibleForTesting
+ static List sizeHistogramBoundaries() {
long kb = 1024;
long mb = 1024 * kb;
long gb = 1024 * mb;
diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageClientMetrics.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageClientMetrics.java
new file mode 100644
index 000000000000..a62aad85a213
--- /dev/null
+++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageClientMetrics.java
@@ -0,0 +1,265 @@
+/*
+ * 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 io.opentelemetry.api.metrics.DoubleHistogram;
+import io.opentelemetry.api.metrics.LongCounter;
+import io.opentelemetry.api.metrics.LongHistogram;
+import io.opentelemetry.api.metrics.LongUpDownCounter;
+import io.opentelemetry.api.metrics.Meter;
+import io.opentelemetry.api.metrics.MeterProvider;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** Package-private instrument registry for OpenTelemetry client metrics. */
+final class StorageClientMetrics {
+ static final String METER_NAME = "com.google.cloud.storage";
+
+ // Standard metric names
+ static final String METRIC_RPC_CLIENT_CALL_DURATION = "rpc.client.call.duration";
+ static final String METRIC_HTTP_CLIENT_REQUEST_DURATION = "http.client.request.duration";
+ static final String METRIC_GCP_CLIENT_REQUEST_DURATION = "gcp.client.request.duration";
+ static final String METRIC_GCP_STORAGE_CLIENT_OPERATIONS = "gcp.storage.client.operations";
+ static final String METRIC_GCP_STORAGE_CLIENT_ATTEMPTS = "gcp.storage.client.attempts";
+ static final String METRIC_GCP_STORAGE_CLIENT_ERRORS = "gcp.storage.client.errors";
+ static final String METRIC_GCP_STORAGE_CLIENT_OPERATION_TTFB =
+ "gcp.storage.client.operation.ttfb";
+ static final String METRIC_GCP_STORAGE_CLIENT_REQUEST_BODY_SIZE =
+ "gcp.storage.client.request.body.size";
+ static final String METRIC_GCP_STORAGE_CLIENT_RESPONSE_BODY_SIZE =
+ "gcp.storage.client.response.body.size";
+
+ // Debug metric names
+ static final String METRIC_GCP_STORAGE_CLIENT_REQUEST_ACTIVE =
+ "gcp.storage.client.request.active";
+ static final String METRIC_GCP_STORAGE_CLIENT_GFE_DURATION = "gcp.storage.client.gfe.duration";
+ static final String METRIC_GCP_STORAGE_CLIENT_GFE_HEADER_MISSING =
+ "gcp.storage.client.gfe.header_missing";
+ static final String METRIC_GCP_STORAGE_CLIENT_NETWORK_DNS_LOOKUP_DURATION =
+ "gcp.storage.client.network.dns.lookup.duration";
+ static final String METRIC_GCP_STORAGE_CLIENT_NETWORK_TCP_CONNECT_DURATION =
+ "gcp.storage.client.network.tcp.connect.duration";
+ static final String METRIC_GCP_STORAGE_CLIENT_NETWORK_TLS_HANDSHAKE_DURATION =
+ "gcp.storage.client.network.tls.handshake.duration";
+ static final String METRIC_GCP_STORAGE_CLIENT_AUTH_CREDENTIAL_REFRESH_DURATION =
+ "gcp.storage.client.auth.credential_refresh.duration";
+
+ private final DoubleHistogram rpcClientCallDuration;
+ private final DoubleHistogram httpClientRequestDuration;
+ private final DoubleHistogram gcpClientRequestDuration;
+ private final LongCounter operations;
+ private final LongCounter attempts;
+ private final LongCounter errors;
+ private final DoubleHistogram operationTtfb;
+ private final LongHistogram requestBodySize;
+ private final LongHistogram responseBodySize;
+
+ @Nullable private final LongUpDownCounter requestActive;
+ @Nullable private final DoubleHistogram gfeDuration;
+ @Nullable private final LongCounter gfeHeaderMissing;
+ @Nullable private final DoubleHistogram dnsLookupDuration;
+ @Nullable private final DoubleHistogram tcpConnectDuration;
+ @Nullable private final DoubleHistogram tlsHandshakeDuration;
+ @Nullable private final DoubleHistogram credentialRefreshDuration;
+
+ static StorageClientMetrics create(MeterProvider meterProvider, boolean enableOtelDebugMetrics) {
+ Meter meter =
+ meterProvider
+ .meterBuilder(METER_NAME)
+ .setInstrumentationVersion(StorageOptions.version())
+ .build();
+ return new StorageClientMetrics(meter, enableOtelDebugMetrics);
+ }
+
+ StorageClientMetrics(Meter meter, boolean enableOtelDebugMetrics) {
+ this.rpcClientCallDuration =
+ meter
+ .histogramBuilder(METRIC_RPC_CLIENT_CALL_DURATION)
+ .setDescription("Duration of one gRPC request. Retries not included (Otel)")
+ .setUnit("s")
+ .build();
+ this.httpClientRequestDuration =
+ meter
+ .histogramBuilder(METRIC_HTTP_CLIENT_REQUEST_DURATION)
+ .setDescription("Duration of one HTTP client request. Retried not included (Otel)")
+ .setUnit("s")
+ .build();
+ this.gcpClientRequestDuration =
+ meter
+ .histogramBuilder(METRIC_GCP_CLIENT_REQUEST_DURATION)
+ .setDescription("Latency of a client operation")
+ .setUnit("s")
+ .build();
+ this.operations =
+ meter
+ .counterBuilder(METRIC_GCP_STORAGE_CLIENT_OPERATIONS)
+ .setDescription("Number of GCS client operations")
+ .setUnit("1")
+ .build();
+ this.attempts =
+ meter
+ .counterBuilder(METRIC_GCP_STORAGE_CLIENT_ATTEMPTS)
+ .setDescription("Number of GCS client attempts")
+ .setUnit("1")
+ .build();
+ this.errors =
+ meter
+ .counterBuilder(METRIC_GCP_STORAGE_CLIENT_ERRORS)
+ .setDescription("Number of GCS client errors")
+ .setUnit("1")
+ .build();
+ this.operationTtfb =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_OPERATION_TTFB)
+ .setDescription("Time to first byte of GCS client operations")
+ .setUnit("s")
+ .build();
+ this.requestBodySize =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_REQUEST_BODY_SIZE)
+ .ofLongs()
+ .setDescription("Size of GCS client request body")
+ .setUnit("By")
+ .build();
+ this.responseBodySize =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_RESPONSE_BODY_SIZE)
+ .ofLongs()
+ .setDescription("Size of GCS client response body")
+ .setUnit("By")
+ .build();
+
+ if (enableOtelDebugMetrics) {
+ this.requestActive =
+ meter
+ .upDownCounterBuilder(METRIC_GCP_STORAGE_CLIENT_REQUEST_ACTIVE)
+ .setDescription("Number of active GCS client requests")
+ .setUnit("1")
+ .build();
+ this.gfeDuration =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_GFE_DURATION)
+ .setDescription("GFE proxy processing time")
+ .setUnit("s")
+ .build();
+ this.gfeHeaderMissing =
+ meter
+ .counterBuilder(METRIC_GCP_STORAGE_CLIENT_GFE_HEADER_MISSING)
+ .setDescription(
+ "Number of GCS requests where the X-Goog-Gfe-Service-Time header was missing")
+ .setUnit("1")
+ .build();
+ this.dnsLookupDuration =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_NETWORK_DNS_LOOKUP_DURATION)
+ .setDescription("Time taken for DNS lookup")
+ .setUnit("s")
+ .build();
+ this.tcpConnectDuration =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_NETWORK_TCP_CONNECT_DURATION)
+ .setDescription("Time taken for TCP connection")
+ .setUnit("s")
+ .build();
+ this.tlsHandshakeDuration =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_NETWORK_TLS_HANDSHAKE_DURATION)
+ .setDescription("Time taken to perform a TLS handshake")
+ .setUnit("s")
+ .build();
+ this.credentialRefreshDuration =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_AUTH_CREDENTIAL_REFRESH_DURATION)
+ .setDescription(
+ "Duration of the background API/network calls made to refresh OAuth2/JWT access"
+ + " credentials.")
+ .setUnit("s")
+ .build();
+ } else {
+ this.requestActive = null;
+ this.gfeDuration = null;
+ this.gfeHeaderMissing = null;
+ this.dnsLookupDuration = null;
+ this.tcpConnectDuration = null;
+ this.tlsHandshakeDuration = null;
+ this.credentialRefreshDuration = null;
+ }
+ }
+
+ DoubleHistogram getRpcClientCallDuration() {
+ return rpcClientCallDuration;
+ }
+
+ DoubleHistogram getHttpClientRequestDuration() {
+ return httpClientRequestDuration;
+ }
+
+ DoubleHistogram getGcpClientRequestDuration() {
+ return gcpClientRequestDuration;
+ }
+
+ LongCounter getOperations() {
+ return operations;
+ }
+
+ LongCounter getAttempts() {
+ return attempts;
+ }
+
+ LongCounter getErrors() {
+ return errors;
+ }
+
+ DoubleHistogram getOperationTtfb() {
+ return operationTtfb;
+ }
+
+ LongHistogram getRequestBodySize() {
+ return requestBodySize;
+ }
+
+ LongHistogram getResponseBodySize() {
+ return responseBodySize;
+ }
+
+ @Nullable LongUpDownCounter getRequestActive() {
+ return requestActive;
+ }
+
+ @Nullable DoubleHistogram getGfeDuration() {
+ return gfeDuration;
+ }
+
+ @Nullable LongCounter getGfeHeaderMissing() {
+ return gfeHeaderMissing;
+ }
+
+ @Nullable DoubleHistogram getDnsLookupDuration() {
+ return dnsLookupDuration;
+ }
+
+ @Nullable DoubleHistogram getTcpConnectDuration() {
+ return tcpConnectDuration;
+ }
+
+ @Nullable DoubleHistogram getTlsHandshakeDuration() {
+ return tlsHandshakeDuration;
+ }
+
+ @Nullable DoubleHistogram getCredentialRefreshDuration() {
+ return credentialRefreshDuration;
+ }
+}
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..df1a505a9bf4
--- /dev/null
+++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageMetricsConfig.java
@@ -0,0 +1,87 @@
+/*
+ * 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.function.Function;
+
+final class StorageMetricsConfig {
+ static final String SYS_PROP_ENABLE_OTEL_METRICS = "com.google.cloud.storage.enable_otel_metrics";
+ static final String SYS_PROP_ENABLE_OTEL_DEBUG_METRICS =
+ "com.google.cloud.storage.enable_otel_debug_metrics";
+ static final String ENV_ENABLE_OTEL_METRICS_JAVA = "GCP_STORAGE_JAVA_ENABLE_OTEL_METRICS";
+ static final String ENV_ENABLE_OTEL_METRICS_FALLBACK = "GCP_STORAGE_ENABLE_OTEL_METRICS";
+ static final String ENV_ENABLE_OTEL_DEBUG_METRICS = "GCP_STORAGE_JAVA_ENABLE_OTEL_DEBUG_METRICS";
+
+ private static Function sysPropResolver = System::getProperty;
+ private static Function envResolver = System::getenv;
+
+ private StorageMetricsConfig() {}
+
+ static boolean isEnableOtelMetrics() {
+ return isEnableOtelMetrics(sysPropResolver, envResolver);
+ }
+
+ static boolean isEnableOtelDebugMetrics() {
+ return isEnableOtelDebugMetrics(sysPropResolver, envResolver);
+ }
+
+ @VisibleForTesting
+ static boolean isEnableOtelMetrics(
+ Function sysProps, Function env) {
+ String sysProp = sysProps.apply(SYS_PROP_ENABLE_OTEL_METRICS);
+ if (sysProp != null) {
+ return Boolean.parseBoolean(sysProp);
+ }
+ String javaEnv = env.apply(ENV_ENABLE_OTEL_METRICS_JAVA);
+ if (javaEnv != null) {
+ return Boolean.parseBoolean(javaEnv);
+ }
+ String fallbackEnv = env.apply(ENV_ENABLE_OTEL_METRICS_FALLBACK);
+ if (fallbackEnv != null) {
+ return Boolean.parseBoolean(fallbackEnv);
+ }
+ return false;
+ }
+
+ @VisibleForTesting
+ static boolean isEnableOtelDebugMetrics(
+ Function sysProps, Function env) {
+ String sysProp = sysProps.apply(SYS_PROP_ENABLE_OTEL_DEBUG_METRICS);
+ if (sysProp != null) {
+ return Boolean.parseBoolean(sysProp);
+ }
+ String javaEnv = env.apply(ENV_ENABLE_OTEL_DEBUG_METRICS);
+ if (javaEnv != null) {
+ return Boolean.parseBoolean(javaEnv);
+ }
+ return false;
+ }
+
+ @VisibleForTesting
+ static void setResolversForTesting(
+ Function testSysProps, Function testEnv) {
+ sysPropResolver = testSysProps != null ? testSysProps : System::getProperty;
+ envResolver = testEnv != null ? testEnv : System::getenv;
+ }
+
+ @VisibleForTesting
+ static void resetResolversForTesting() {
+ sysPropResolver = System::getProperty;
+ 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..e6647ef80555 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.sdk.metrics.SdkMeterProvider;
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,43 @@ 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 SdkMeterProvider} for recording client metrics.
+ *
+ * @param meterProvider custom SdkMeterProvider to use
+ * @since 2.50.0 This new api is in preview and is subject to breaking changes.
+ */
+ @BetaApi
+ public abstract StorageOptions.Builder setMeterProvider(SdkMeterProvider meterProvider);
+
+ /**
+ * Set the metric export interval for periodic metric reading.
+ *
+ * @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 +225,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 SdkMeterProvider} 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 SdkMeterProvider 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/OpenTelemetryBootstrappingUtilsTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtilsTest.java
index 5e93c95a988b..73799e05dac3 100644
--- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtilsTest.java
+++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtilsTest.java
@@ -22,6 +22,7 @@
import com.google.cloud.storage.OpenTelemetryBootstrappingUtils.ChannelConfigurator;
import io.grpc.ManagedChannelBuilder;
+import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.Test;
@@ -94,4 +95,28 @@ public void channelConfigurator_andThen_nullsafe() {
ChannelConfigurator actual = ChannelConfigurator.identity().andThen(null);
assertThat(actual).isSameInstanceAs(ChannelConfigurator.identity());
}
+
+ @Test
+ public void histogramBoundaries_areValidAndIncreasing() {
+ List latencyBoundaries = OpenTelemetryBootstrappingUtils.latencyHistogramBoundaries();
+ assertThat(latencyBoundaries).isNotEmpty();
+ for (int i = 1; i < latencyBoundaries.size(); i++) {
+ assertThat(latencyBoundaries.get(i)).isGreaterThan(latencyBoundaries.get(i - 1));
+ }
+
+ List sizeBoundaries = OpenTelemetryBootstrappingUtils.sizeHistogramBoundaries();
+ assertThat(sizeBoundaries).isNotEmpty();
+ for (int i = 1; i < sizeBoundaries.size(); i++) {
+ assertThat(sizeBoundaries.get(i)).isGreaterThan(sizeBoundaries.get(i - 1));
+ }
+ }
+
+ @Test
+ public void registerClientViews_doesNotThrow() {
+ io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder builder =
+ io.opentelemetry.sdk.metrics.SdkMeterProvider.builder();
+ io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder returned =
+ OpenTelemetryBootstrappingUtils.registerClientViews(builder);
+ assertThat(returned).isSameInstanceAs(builder);
+ }
}
diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageClientMetricsTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageClientMetricsTest.java
new file mode 100644
index 000000000000..be7043b4e425
--- /dev/null
+++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageClientMetricsTest.java
@@ -0,0 +1,195 @@
+/*
+ * 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 io.opentelemetry.sdk.metrics.SdkMeterProvider;
+import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder;
+import io.opentelemetry.sdk.metrics.data.HistogramPointData;
+import io.opentelemetry.sdk.metrics.data.MetricData;
+import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.junit.Test;
+
+public final class StorageClientMetricsTest {
+
+ @Test
+ public void standardMetrics_initializedWithoutDebug() {
+ InMemoryMetricReader reader = InMemoryMetricReader.create();
+ SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build();
+
+ StorageClientMetrics metrics = StorageClientMetrics.create(provider, false);
+
+ assertThat(metrics.getRpcClientCallDuration()).isNotNull();
+ assertThat(metrics.getHttpClientRequestDuration()).isNotNull();
+ assertThat(metrics.getGcpClientRequestDuration()).isNotNull();
+ assertThat(metrics.getOperations()).isNotNull();
+ assertThat(metrics.getAttempts()).isNotNull();
+ assertThat(metrics.getErrors()).isNotNull();
+ assertThat(metrics.getOperationTtfb()).isNotNull();
+ assertThat(metrics.getRequestBodySize()).isNotNull();
+ assertThat(metrics.getResponseBodySize()).isNotNull();
+
+ // Debug instruments should be null
+ assertThat(metrics.getRequestActive()).isNull();
+ assertThat(metrics.getGfeDuration()).isNull();
+ assertThat(metrics.getGfeHeaderMissing()).isNull();
+ assertThat(metrics.getDnsLookupDuration()).isNull();
+ assertThat(metrics.getTcpConnectDuration()).isNull();
+ assertThat(metrics.getTlsHandshakeDuration()).isNull();
+ assertThat(metrics.getCredentialRefreshDuration()).isNull();
+ }
+
+ @Test
+ public void debugMetrics_initializedWhenEnabled() {
+ InMemoryMetricReader reader = InMemoryMetricReader.create();
+ SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build();
+
+ StorageClientMetrics metrics = StorageClientMetrics.create(provider, true);
+
+ assertThat(metrics.getRpcClientCallDuration()).isNotNull();
+ assertThat(metrics.getHttpClientRequestDuration()).isNotNull();
+ assertThat(metrics.getGcpClientRequestDuration()).isNotNull();
+ assertThat(metrics.getOperations()).isNotNull();
+ assertThat(metrics.getAttempts()).isNotNull();
+ assertThat(metrics.getErrors()).isNotNull();
+ assertThat(metrics.getOperationTtfb()).isNotNull();
+ assertThat(metrics.getRequestBodySize()).isNotNull();
+ assertThat(metrics.getResponseBodySize()).isNotNull();
+
+ // Debug instruments should be present
+ assertThat(metrics.getRequestActive()).isNotNull();
+ assertThat(metrics.getGfeDuration()).isNotNull();
+ assertThat(metrics.getGfeHeaderMissing()).isNotNull();
+ assertThat(metrics.getDnsLookupDuration()).isNotNull();
+ assertThat(metrics.getTcpConnectDuration()).isNotNull();
+ assertThat(metrics.getTlsHandshakeDuration()).isNotNull();
+ assertThat(metrics.getCredentialRefreshDuration()).isNotNull();
+ }
+
+ @Test
+ public void inMemoryMetricReader_verifiesViewsAndBoundaries() {
+ InMemoryMetricReader reader = InMemoryMetricReader.create();
+ SdkMeterProviderBuilder providerBuilder =
+ SdkMeterProvider.builder().registerMetricReader(reader);
+ OpenTelemetryBootstrappingUtils.registerClientViews(providerBuilder);
+ SdkMeterProvider provider = providerBuilder.build();
+
+ StorageClientMetrics metrics = StorageClientMetrics.create(provider, true);
+
+ // Record data for each instrument
+ metrics.getRpcClientCallDuration().record(0.123);
+ metrics.getHttpClientRequestDuration().record(0.234);
+ metrics.getGcpClientRequestDuration().record(0.345);
+ metrics.getOperationTtfb().record(0.045);
+ metrics.getGfeDuration().record(0.012);
+ metrics.getDnsLookupDuration().record(0.005);
+ metrics.getTcpConnectDuration().record(0.015);
+ metrics.getTlsHandshakeDuration().record(0.025);
+ metrics.getCredentialRefreshDuration().record(0.080);
+
+ metrics.getRequestBodySize().record(1024 * 512);
+ metrics.getResponseBodySize().record(1024 * 1024);
+
+ metrics.getOperations().add(1);
+ metrics.getAttempts().add(2);
+ metrics.getErrors().add(1);
+ metrics.getRequestActive().add(1);
+ metrics.getGfeHeaderMissing().add(1);
+
+ Collection collectedMetrics = reader.collectAllMetrics();
+ Map metricsMap =
+ collectedMetrics.stream()
+ .collect(Collectors.toMap(MetricData::getName, Function.identity()));
+
+ List expectedLatencyBoundaries =
+ OpenTelemetryBootstrappingUtils.latencyHistogramBoundaries();
+ List expectedSizeBoundaries = OpenTelemetryBootstrappingUtils.sizeHistogramBoundaries();
+
+ // Verify standard latency histograms have custom latency boundaries
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_RPC_CLIENT_CALL_DURATION,
+ expectedLatencyBoundaries);
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_HTTP_CLIENT_REQUEST_DURATION,
+ expectedLatencyBoundaries);
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_CLIENT_REQUEST_DURATION,
+ expectedLatencyBoundaries);
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATION_TTFB,
+ expectedLatencyBoundaries);
+
+ // Verify debug latency histograms have custom latency boundaries
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_GFE_DURATION,
+ expectedLatencyBoundaries);
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_NETWORK_DNS_LOOKUP_DURATION,
+ expectedLatencyBoundaries);
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_NETWORK_TCP_CONNECT_DURATION,
+ expectedLatencyBoundaries);
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_NETWORK_TLS_HANDSHAKE_DURATION,
+ expectedLatencyBoundaries);
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_AUTH_CREDENTIAL_REFRESH_DURATION,
+ expectedLatencyBoundaries);
+
+ // Verify size histograms have custom size boundaries
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_REQUEST_BODY_SIZE,
+ expectedSizeBoundaries);
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_RESPONSE_BODY_SIZE,
+ expectedSizeBoundaries);
+
+ // Verify counter metrics
+ assertThat(metricsMap).containsKey(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS);
+ assertThat(metricsMap).containsKey(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS);
+ assertThat(metricsMap).containsKey(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS);
+ assertThat(metricsMap)
+ .containsKey(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_REQUEST_ACTIVE);
+ assertThat(metricsMap)
+ .containsKey(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_GFE_HEADER_MISSING);
+ }
+
+ private static void assertHistogramBoundaries(
+ Map metricsMap, String metricName, List expectedBoundaries) {
+ assertThat(metricsMap).containsKey(metricName);
+ MetricData metricData = metricsMap.get(metricName);
+ HistogramPointData pointData = metricData.getHistogramData().getPoints().iterator().next();
+ assertThat(pointData.getBoundaries()).isEqualTo(expectedBoundaries);
+ }
+}
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..7cb568b29a5b
--- /dev/null
+++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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 io.opentelemetry.sdk.metrics.SdkMeterProvider;
+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() {
+ System.clearProperty(StorageMetricsConfig.SYS_PROP_ENABLE_OTEL_METRICS);
+ System.clearProperty(StorageMetricsConfig.SYS_PROP_ENABLE_OTEL_DEBUG_METRICS);
+ 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() {
+ SdkMeterProvider 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 builder_explicitDisablingOverridesGate() {
+ System.setProperty(StorageMetricsConfig.SYS_PROP_ENABLE_OTEL_METRICS, "true");
+
+ HttpStorageOptions httpOptions = HttpStorageOptions.http().setEnableOtelMetrics(false).build();
+ assertThat(httpOptions.isEnableOtelMetrics()).isFalse();
+
+ GrpcStorageOptions grpcOptions = GrpcStorageOptions.grpc().setEnableOtelMetrics(false).build();
+ assertThat(grpcOptions.isEnableOtelMetrics()).isFalse();
+ }
+
+ @Test
+ public void developmentGate_systemProperty() {
+ System.setProperty(StorageMetricsConfig.SYS_PROP_ENABLE_OTEL_METRICS, "true");
+ System.setProperty(StorageMetricsConfig.SYS_PROP_ENABLE_OTEL_DEBUG_METRICS, "true");
+
+ 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 developmentGate_environmentVariableJava() {
+ Map env = new HashMap<>();
+ env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "true");
+ env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_DEBUG_METRICS, "true");
+ StorageMetricsConfig.setResolversForTesting(k -> null, 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 developmentGate_environmentVariableFallback() {
+ Map env = new HashMap<>();
+ env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_FALLBACK, "true");
+ StorageMetricsConfig.setResolversForTesting(k -> null, env::get);
+
+ HttpStorageOptions httpOptions = HttpStorageOptions.http().build();
+ assertThat(httpOptions.isEnableOtelMetrics()).isTrue();
+
+ GrpcStorageOptions grpcOptions = GrpcStorageOptions.grpc().build();
+ assertThat(grpcOptions.isEnableOtelMetrics()).isTrue();
+ }
+
+ @Test
+ public void developmentGate_systemPropertyTakesPrecedenceOverEnv() {
+ Map env = new HashMap<>();
+ env.put(StorageMetricsConfig.ENV_ENABLE_OTEL_METRICS_JAVA, "true");
+ Map sysProps = new HashMap<>();
+ sysProps.put(StorageMetricsConfig.SYS_PROP_ENABLE_OTEL_METRICS, "false");
+ StorageMetricsConfig.setResolversForTesting(sysProps::get, env::get);
+
+ HttpStorageOptions httpOptions = HttpStorageOptions.http().build();
+ assertThat(httpOptions.isEnableOtelMetrics()).isFalse();
+
+ GrpcStorageOptions grpcOptions = GrpcStorageOptions.grpc().build();
+ assertThat(grpcOptions.isEnableOtelMetrics()).isFalse();
+ }
+}