Repository: googleapis/java-storage
Pull request #2651 (2024-07-29 18:47:18)

fix: add UnknownHostException to set of retriable IOExceptions


Patch:
diff --git a/.github/sync-repo-settings.yaml b/.github/sync-repo-settings.yaml
index 6b6b9030..d0f61d51 100644
--- a/.github/sync-repo-settings.yaml
+++ b/.github/sync-repo-settings.yaml
@@ -18,7 +18,8 @@ branchProtectionRules:
       - 'Kokoro - Test: Java GraalVM Native Image'
       - 'Kokoro - Test: Java 17 GraalVM Native Image'
       - javadoc
-      - unmanaged_dependency_check
+#      TODO: re-enabled once https://github.com/googleapis/sdk-platform-java/pull/3078 is merged and released
+#      - unmanaged_dependency_check
   - pattern: 1.113.14-sp
     isAdminEnforced: true
     requiredApprovingReviewCount: 1
diff --git a/gapic-google-cloud-storage-v2/src/main/java/com/google/storage/v2/stub/StorageStubSettings.java b/gapic-google-cloud-storage-v2/src/main/java/com/google/storage/v2/stub/StorageStubSettings.java
index 1b42b3c7..3ced26a1 100644
--- a/gapic-google-cloud-storage-v2/src/main/java/com/google/storage/v2/stub/StorageStubSettings.java
+++ b/gapic-google-cloud-storage-v2/src/main/java/com/google/storage/v2/stub/StorageStubSettings.java
@@ -23,6 +23,7 @@ import static com.google.storage.v2.StorageClient.ListObjectsPagedResponse;
 
 import com.google.api.core.ApiFunction;
 import com.google.api.core.ApiFuture;
+import com.google.api.core.ObsoleteApi;
 import com.google.api.gax.core.GaxProperties;
 import com.google.api.gax.core.GoogleCredentialsProvider;
 import com.google.api.gax.core.InstantiatingExecutorProvider;
@@ -637,6 +638,7 @@ public class StorageStubSettings extends StubSettings<StorageStubSettings> {
   }
 
   /** Returns the default service endpoint. */
+  @ObsoleteApi("Use getEndpoint() instead")
   public static String getDefaultEndpoint() {
     return "storage.googleapis.com:443";
   }
diff --git a/generation_config.yaml b/generation_config.yaml
index 372408e7..ceae17f9 100644
--- a/generation_config.yaml
+++ b/generation_config.yaml
@@ -1,5 +1,5 @@
-gapic_generator_version: 2.42.0
-googleapis_commitish: a91d1a37cb86a0e49fdc21d8b4416eb1c2a083ec
+gapic_generator_version: 2.43.0
+googleapis_commitish: d8fce50eea92bac3a6612ee61559989ce3b38776
 libraries_bom_version: 26.43.0
 libraries:
   - api_shortname: storage
diff --git a/google-cloud-storage-control/src/main/java/com/google/storage/control/v2/stub/StorageControlStubSettings.java b/google-cloud-storage-control/src/main/java/com/google/storage/control/v2/stub/StorageControlStubSettings.java
index eb717d38..b9231bb5 100644
--- a/google-cloud-storage-control/src/main/java/com/google/storage/control/v2/stub/StorageControlStubSettings.java
+++ b/google-cloud-storage-control/src/main/java/com/google/storage/control/v2/stub/StorageControlStubSettings.java
@@ -21,6 +21,7 @@ import static com.google.storage.control.v2.StorageControlClient.ListManagedFold
 
 import com.google.api.core.ApiFunction;
 import com.google.api.core.ApiFuture;
+import com.google.api.core.ObsoleteApi;
 import com.google.api.gax.core.GaxProperties;
 import com.google.api.gax.core.GoogleCredentialsProvider;
 import com.google.api.gax.core.InstantiatingExecutorProvider;
@@ -334,6 +335,7 @@ public class StorageControlStubSettings extends StubSettings<StorageControlStubS
   }
 
   /** Returns the default service endpoint. */
+  @ObsoleteApi("Use getEndpoint() instead")
   public static String getDefaultEndpoint() {
     return "storage.googleapis.com:443";
   }
diff --git a/google-cloud-storage-control/src/main/resources/META-INF/native-image/com.google.storage.control.v2/reflect-config.json b/google-cloud-storage-control/src/main/resources/META-INF/native-image/com.google.storage.control.v2/reflect-config.json
index 36399f50..c10085e0 100644
--- a/google-cloud-storage-control/src/main/resources/META-INF/native-image/com.google.storage.control.v2/reflect-config.json
+++ b/google-cloud-storage-control/src/main/resources/META-INF/native-image/com.google.storage.control.v2/reflect-config.json
@@ -440,6 +440,24 @@
     "allDeclaredClasses": true,
     "allPublicClasses": true
   },
+  {
+    "name": "com.google.api.TypeReference",
+    "queryAllDeclaredConstructors": true,
+    "queryAllPublicConstructors": true,
+    "queryAllDeclaredMethods": true,
+    "allPublicMethods": true,
+    "allDeclaredClasses": true,
+    "allPublicClasses": true
+  },
+  {
+    "name": "com.google.api.TypeReference$Builder",
+    "queryAllDeclaredConstructors": true,
+    "queryAllPublicConstructors": true,
+    "queryAllDeclaredMethods": true,
+    "allPublicMethods": true,
+    "allDeclaredClasses": true,
+    "allPublicClasses": true
+  },
   {
     "name": "com.google.longrunning.CancelOperationRequest",
     "queryAllDeclaredConstructors": true,
diff --git a/google-cloud-storage/pom.xml b/google-cloud-storage/pom.xml
index b6bc155e..839cc395 100644
--- a/google-cloud-storage/pom.xml
+++ b/google-cloud-storage/pom.xml
@@ -124,6 +124,53 @@
       <groupId>com.google.api.grpc</groupId>
       <artifactId>gapic-google-cloud-storage-v2</artifactId>
     </dependency>
+    <!-- Open Telemetry dependencies -->
+    <dependency>
+      <groupId>io.opentelemetry</groupId>
+      <artifactId>opentelemetry-sdk</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>io.grpc</groupId>
+      <artifactId>grpc-opentelemetry</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>io.opentelemetry</groupId>
+      <artifactId>opentelemetry-api</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>io.opentelemetry</groupId>
+      <artifactId>opentelemetry-sdk-metrics</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>io.opentelemetry</groupId>
+      <artifactId>opentelemetry-sdk-common</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>io.opentelemetry</groupId>
+      <artifactId>opentelemetry-sdk-extension-autoconfigure-spi</artifactId>
+    </dependency>
+
+    <dependency>
+    <groupId>io.opentelemetry.semconv</groupId>
+    <artifactId>opentelemetry-semconv</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>com.google.cloud.opentelemetry</groupId>
+      <artifactId>exporter-metrics</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>io.opentelemetry.contrib</groupId>
+      <artifactId>opentelemetry-gcp-resources</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>org.checkerframework</groupId>
+      <artifactId>checker-qual</artifactId>
+    </dependency>
 
     <!-- Access to exception for retry handling -->
     <dependency>
@@ -159,10 +206,7 @@
       <artifactId>grpc-googleapis</artifactId>
       <scope>runtime</scope>
     </dependency>
-    <dependency>
-      <groupId>org.checkerframework</groupId>
-      <artifactId>checker-qual</artifactId>
-    </dependency>
+
     <!--
     We're not using this directly, however there appears to be some resolution
     issues when we don't list this dependency. Specifically, the check to ensure the flattened
@@ -184,6 +228,7 @@
       <version>0.140.0</version>
       <scope>test</scope>
     </dependency>
+
     <dependency>
       <groupId>com.google.cloud</groupId>
       <artifactId>google-cloud-kms</artifactId>
@@ -307,6 +352,12 @@
               Add in vintage engine so that both JUnit4 and JUnit5 tests are run by the JUnit5 runner.
               -->
               <dependency>org.junit.vintage:junit-vintage-engine</dependency>
+
+              <!--
+              These are needed at runtime, but it causes unused declared dependency errors
+              -->
+              <dependency>io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi</dependency>
+              <dependency>io.opentelemetry.semconv:opentelemetry-semconv</dependency>
             </ignoredDependencies>
           </configuration>
         </plugin>
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/DefaultStorageRetryStrategy.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/DefaultStorageRetryStrategy.java
index cf85b740..b9a8df0b 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/DefaultStorageRetryStrategy.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/DefaultStorageRetryStrategy.java
@@ -26,6 +26,7 @@ import com.google.common.collect.ImmutableSet;
 import com.google.gson.stream.MalformedJsonException;
 import java.io.IOException;
 import java.net.SocketException;
+import java.net.UnknownHostException;
 import java.util.Set;
 import javax.net.ssl.SSLException;
 
@@ -114,6 +115,8 @@ final class DefaultStorageRetryStrategy implements StorageRetryStrategy {
           SocketException se = (SocketException) cause;
           return shouldRetryIOException(se);
         }
+      } else if (ioException instanceof UnknownHostException && idempotent) {
+        return RetryResult.RETRY;
       }
       if (BaseServiceException.isRetryable(idempotent, ioException)) {
         return RetryResult.RETRY;
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java
index 92081e03..64376cc1 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java
@@ -116,6 +116,9 @@ public final class GrpcStorageOptions extends StorageOptions
   private final GrpcRetryAlgorithmManager retryAlgorithmManager;
   private final Duration terminationAwaitDuration;
   private final boolean attemptDirectPath;
+  private final boolean enableGrpcClientMetrics;
+
+  private final boolean grpcClientMetricsManuallyEnabled;
   private final GrpcInterceptorProvider grpcInterceptorProvider;
   private final BlobWriteSessionConfig blobWriteSessionConfig;
 
@@ -129,6 +132,8 @@ public final class GrpcStorageOptions extends StorageOptions
         MoreObjects.firstNonNull(
             builder.terminationAwaitDuration, serviceDefaults.getTerminationAwaitDuration());
     this.attemptDirectPath = builder.attemptDirectPath;
+    this.enableGrpcClientMetrics = builder.enableGrpcClientMetrics;
+    this.grpcClientMetricsManuallyEnabled = builder.grpcMetricsManuallyEnabled;
     this.grpcInterceptorProvider = builder.grpcInterceptorProvider;
     this.blobWriteSessionConfig = builder.blobWriteSessionConfig;
   }
@@ -287,6 +292,16 @@ public final class GrpcStorageOptions extends StorageOptions
     if (scheme.equals("http")) {
       channelProviderBuilder.setChannelConfigurator(ManagedChannelBuilder::usePlaintext);
     }
+
+    if (enableGrpcClientMetrics) {
+      OpenTelemetryBootstrappingUtils.enableGrpcMetrics(
+          channelProviderBuilder,
+          endpoint,
+          this.getProjectId(),
+          this.getUniverseDomain(),
+          !grpcClientMetricsManuallyEnabled);
+    }
+
     builder.setTransportChannelProvider(channelProviderBuilder.build());
     RetrySettings baseRetrySettings = getRetrySettings();
     RetrySettings readRetrySettings =
@@ -350,6 +365,7 @@ public final class GrpcStorageOptions extends StorageOptions
         retryAlgorithmManager,
         terminationAwaitDuration,
         attemptDirectPath,
+        enableGrpcClientMetrics,
         grpcInterceptorProvider,
         blobWriteSessionConfig,
         baseHashCode());
@@ -365,6 +381,7 @@ public final class GrpcStorageOptions extends StorageOptions
     }
     GrpcStorageOptions that = (GrpcStorageOptions) o;
     return attemptDirectPath == that.attemptDirectPath
+        && enableGrpcClientMetrics == that.enableGrpcClientMetrics
         && Objects.equals(retryAlgorithmManager, that.retryAlgorithmManager)
         && Objects.equals(terminationAwaitDuration, that.terminationAwaitDuration)
         && Objects.equals(grpcInterceptorProvider, that.grpcInterceptorProvider)
@@ -408,11 +425,15 @@ public final class GrpcStorageOptions extends StorageOptions
     private StorageRetryStrategy storageRetryStrategy;
     private Duration terminationAwaitDuration;
     private boolean attemptDirectPath = GrpcStorageDefaults.INSTANCE.isAttemptDirectPath();
+    private boolean enableGrpcClientMetrics =
+        GrpcStorageDefaults.INSTANCE.isEnableGrpcClientMetrics();
     private GrpcInterceptorProvider grpcInterceptorProvider =
         GrpcStorageDefaults.INSTANCE.grpcInterceptorProvider();
     private BlobWriteSessionConfig blobWriteSessionConfig =
         GrpcStorageDefaults.INSTANCE.getDefaultStorageWriterConfig();
 
+    private boolean grpcMetricsManuallyEnabled = false;
+
     Builder() {}
 
     Builder(StorageOptions options) {
@@ -421,6 +442,7 @@ public final class GrpcStorageOptions extends StorageOptions
       this.storageRetryStrategy = gso.getRetryAlgorithmManager().retryStrategy;
       this.terminationAwaitDuration = gso.getTerminationAwaitDuration();
       this.attemptDirectPath = gso.attemptDirectPath;
+      this.enableGrpcClientMetrics = gso.enableGrpcClientMetrics;
       this.grpcInterceptorProvider = gso.grpcInterceptorProvider;
       this.blobWriteSessionConfig = gso.blobWriteSessionConfig;
     }
@@ -454,6 +476,21 @@ public final class GrpcStorageOptions extends StorageOptions
       this.attemptDirectPath = attemptDirectPath;
       return this;
     }
+    /**
+     * Option for whether this client should emit internal gRPC client internal metrics to Cloud
+     * Monitoring. To disable metric reporting, set this to false. True by default. Emitting metrics
+     * is free and requires minimal CPU and memory.
+     *
+     * @since 2.41.0 This new api is in preview and is subject to breaking changes.
+     */
+    @BetaApi
+    public GrpcStorageOptions.Builder setEnableGrpcClientMetrics(boolean enableGrpcClientMetrics) {
+      this.enableGrpcClientMetrics = enableGrpcClientMetrics;
+      if (enableGrpcClientMetrics) {
+        grpcMetricsManuallyEnabled = true;
+      }
+      return this;
+    }
 
     /** @since 2.14.0 This new api is in preview and is subject to breaking changes. */
     @BetaApi
@@ -660,6 +697,12 @@ public final class GrpcStorageOptions extends StorageOptions
       return false;
     }
 
+    /** @since 2.41.0 This new api is in preview and is subject to breaking changes. */
+    @BetaApi
+    public boolean isEnableGrpcClientMetrics() {
+      return true;
+    }
+
     /** @since 2.22.3 This new api is in preview and is subject to breaking changes. */
     @BetaApi
     public GrpcInterceptorProvider grpcInterceptorProvider() {
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtils.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtils.java
new file mode 100644
index 00000000..ed0ba544
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtils.java
@@ -0,0 +1,382 @@
+/*
+ * Copyright 2024 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.api.core.ApiFunction;
+import com.google.api.gax.grpc.InstantiatingGrpcChannelProvider;
+import com.google.api.gax.rpc.PermissionDeniedException;
+import com.google.api.gax.rpc.UnavailableException;
+import com.google.cloud.opentelemetry.metric.GoogleCloudMetricExporter;
+import com.google.cloud.opentelemetry.metric.MetricConfiguration;
+import com.google.cloud.opentelemetry.metric.MonitoredResourceDescription;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import io.grpc.ManagedChannelBuilder;
+import io.grpc.opentelemetry.GrpcOpenTelemetry;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.internal.StringUtils;
+import io.opentelemetry.contrib.gcp.resource.GCPResourceProvider;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.common.CompletableResultCode;
+import io.opentelemetry.sdk.common.export.MemoryMode;
+import io.opentelemetry.sdk.metrics.Aggregation;
+import io.opentelemetry.sdk.metrics.InstrumentSelector;
+import io.opentelemetry.sdk.metrics.InstrumentType;
+import io.opentelemetry.sdk.metrics.SdkMeterProvider;
+import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder;
+import io.opentelemetry.sdk.metrics.View;
+import io.opentelemetry.sdk.metrics.data.AggregationTemporality;
+import io.opentelemetry.sdk.metrics.data.MetricData;
+import io.opentelemetry.sdk.metrics.export.DefaultAggregationSelector;
+import io.opentelemetry.sdk.metrics.export.MetricExporter;
+import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader;
+import io.opentelemetry.sdk.resources.Resource;
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.net.NoRouteToHostException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+final class OpenTelemetryBootstrappingUtils {
+  private static final Collection<String> METRICS_TO_ENABLE =
+      ImmutableList.of(
+          "grpc.lb.wrr.rr_fallback",
+          "grpc.lb.wrr.endpoint_weight_not_yet_usable",
+          "grpc.lb.wrr.endpoint_weight_stale",
+          "grpc.lb.wrr.endpoint_weights",
+          "grpc.lb.rls.cache_entries",
+          "grpc.lb.rls.cache_size",
+          "grpc.lb.rls.default_target_picks",
+          "grpc.lb.rls.target_picks",
+          "grpc.lb.rls.failed_picks",
+          "grpc.xds_client.connected",
+          "grpc.xds_client.server_failure",
+          "grpc.xds_client.resource_updates_valid",
+          "grpc.xds_client.resource_updates_invalid",
+          "grpc.xds_client.resources");
+
+  private static final Collection<String> METRICS_ENABLED_BY_DEFAULT =
+      ImmutableList.of(
+          "grpc.client.attempt.sent_total_compressed_message_size",
+          "grpc.client.attempt.rcvd_total_compressed_message_size",
+          "grpc.client.attempt.started",
+          "grpc.client.attempt.duration",
+          "grpc.client.call.duration");
+
+  static final Logger log = Logger.getLogger(OpenTelemetryBootstrappingUtils.class.getName());
+
+  static void enableGrpcMetrics(
+      InstantiatingGrpcChannelProvider.Builder channelProviderBuilder,
+      String endpoint,
+      String projectId,
+      String universeDomain,
+      boolean shouldSuppressExceptions) {
+    String metricServiceEndpoint = getCloudMonitoringEndpoint(endpoint, universeDomain);
+    SdkMeterProvider provider =
+        createMeterProvider(metricServiceEndpoint, projectId, shouldSuppressExceptions);
+
+    OpenTelemetrySdk openTelemetrySdk =
+        OpenTelemetrySdk.builder().setMeterProvider(provider).build();
+    GrpcOpenTelemetry grpcOpenTelemetry =
+        GrpcOpenTelemetry.newBuilder()
+            .sdk(openTelemetrySdk)
+            .enableMetrics(METRICS_TO_ENABLE)
+            .build();
+    ApiFunction<ManagedChannelBuilder, ManagedChannelBuilder> channelConfigurator =
+        channelProviderBuilder.getChannelConfigurator();
+    channelProviderBuilder.setChannelConfigurator(
+        b -> {
+          grpcOpenTelemetry.configureChannelBuilder(b);
+          if (channelConfigurator != null) {
+            return channelConfigurator.apply(b);
+          }
+          return b;
+        });
+  }
+
+  @VisibleForTesting
+  static String getCloudMonitoringEndpoint(String endpoint, String universeDomain) {
+    String metricServiceEndpoint = "monitoring.googleapis.com";
+
+    // use contains instead of equals because endpoint has a port in it
+    if (universeDomain != null && endpoint.contains("storage." + universeDomain)) {
+      metricServiceEndpoint = "monitoring." + universeDomain;
+    } else if (!endpoint.contains("storage.googleapis.com")) {
+      String canonicalEndpoint = "storage.googleapis.com";
+      String privateEndpoint = "private.googleapis.com";
+      String restrictedEndpoint = "restricted.googleapis.com";
+      if (universeDomain != null) {
+        canonicalEndpoint = "storage." + universeDomain;
+        privateEndpoint = "private." + universeDomain;
+        restrictedEndpoint = "restricted." + universeDomain;
+      }
+      String match =
+          ImmutableList.of(canonicalEndpoint, privateEndpoint, restrictedEndpoint).stream()
+              .filter(s -> endpoint.contains(s) || endpoint.contains("google-c2p:///" + s))
+              .collect(Collectors.joining());
+      if (!StringUtils.isNullOrEmpty(match)) {
+        metricServiceEndpoint = match;
+      }
+    }
+    return metricServiceEndpoint + ":" + endpoint.split(":")[1];
+  }
+
+  @VisibleForTesting
+  static SdkMeterProvider createMeterProvider(
+      String metricServiceEndpoint, String projectId, boolean shouldSuppressExceptions) {
+    GCPResourceProvider resourceProvider = new GCPResourceProvider();
+    Attributes detectedAttributes = resourceProvider.getAttributes();
+
+    String detectedProjectId = detectedAttributes.get(AttributeKey.stringKey("cloud.account.id"));
+    String projectIdToUse = detectedProjectId == null ? projectId : detectedProjectId;
+
+    if (!projectIdToUse.equals(projectId)) {
+      log.warning(
+          "The Project ID configured for metrics is "
+              + projectIdToUse
+              + ", but the Project ID of the storage client is "
+              + projectId
+              + ". Make sure that the service account in use has the required metric writing role "
+              + "(roles/monitoring.metricWriter) in the project "
+              + projectIdToUse
+              + ", or metrics will not be written.");
+    }
+
+    MonitoredResourceDescription monitoredResourceDescription =
+        new MonitoredResourceDescription(
+            "storage.googleapis.com/Client",
+            ImmutableSet.of(
+                "project_id", "location", "cloud_platform", "host_id", "instance_id", "api"));
+
+    MetricExporter cloudMonitoringExporter =
+        GoogleCloudMetricExporter.createWithConfiguration(
+            MetricConfiguration.builder()
+                .setMonitoredResourceDescription(monitoredResourceDescription)
+                .setInstrumentationLibraryLabelsEnabled(false)
+                .setMetricServiceEndpoint(metricServiceEndpoint)
+                .setPrefix("storage.googleapis.com/client")
+                .setUseServiceTimeSeries(true)
+                .setProjectId(projectIdToUse)
+                .build());
+
+    SdkMeterProviderBuilder providerBuilder = SdkMeterProvider.builder();
+
+    // This replaces the dots with slashes in each metric, which is the format needed for this
+    // monitored resource
+    for (String metric :
+        ImmutableList.copyOf(Iterables.concat(METRICS_TO_ENABLE, METRICS_ENABLED_BY_DEFAULT))) {
+      providerBuilder.registerView(
+          InstrumentSelector.builder().setName(metric).build(),
+          View.builder().setName(metric.replace(".", "/")).build());
+    }
+    MetricExporter exporter =
+        shouldSuppressExceptions
+            ? new PermissionDeniedSingleReportMetricsExporter(cloudMonitoringExporter)
+            : cloudMonitoringExporter;
+    providerBuilder
+        .registerMetricReader(
+            PeriodicMetricReader.builder(exporter)
+                .setInterval(java.time.Duration.ofSeconds(60))
+                .build())
+        .setResource(
+            Resource.create(
+                Attributes.builder()
+                    .put("gcp.resource_type", "storage.googleapis.com/Client")
+                    .put("location", detectedAttributes.get(AttributeKey.stringKey("cloud.region")))
+                    .put("project_id", projectIdToUse)
+                    .put(
+                        "cloud_platform",
+                        detectedAttributes.get(AttributeKey.stringKey("cloud.platform")))
+                    .put("host_id", detectedAttributes.get(AttributeKey.stringKey("host.id")))
+                    .put("instance_id", UUID.randomUUID().toString())
+                    .put("api", "grpc")
+                    .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();
+  }
+
+  private static void addHistogramView(
+      SdkMeterProviderBuilder provider, List<Double> boundaries, String name, String unit) {
+    InstrumentSelector instrumentSelector =
+        InstrumentSelector.builder()
+            .setType(InstrumentType.HISTOGRAM)
+            .setUnit(unit)
+            .setName(name)
+            .setMeterName("grpc-java")
+            .setMeterSchemaUrl("")
+            .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 List<Double> latencyHistogramBoundaries() {
+    List<Double> boundaries = new ArrayList<>();
+    BigDecimal boundary = new BigDecimal(0, MathContext.UNLIMITED);
+    BigDecimal increment = new BigDecimal("0.002", MathContext.UNLIMITED); // 2ms
+
+    // 2ms buckets for the first 100ms, so we can have higher resolution for uploads and downloads
+    // in the 100 KiB range
+    for (int i = 0; i != 50; i++) {
+      boundaries.add(boundary.doubleValue());
+      boundary = boundary.add(increment);
+    }
+
+    // For the remaining buckets do 10 10ms, 10 20ms, and so on, up until 5 minutes
+    increment = new BigDecimal("0.01", MathContext.UNLIMITED); // 10 ms
+    for (int i = 0; i != 150 && boundary.compareTo(new BigDecimal(300)) < 1; i++) {
+      boundaries.add(boundary.doubleValue());
+      if (i != 0 && i % 10 == 0) {
+        increment = increment.multiply(new BigDecimal(2));
+      }
+      boundary = boundary.add(increment);
+    }
+
+    return boundaries;
+  }
+
+  private static List<Double> sizeHistogramBoundaries() {
+    long kb = 1024;
+    long mb = 1024 * kb;
+    long gb = 1024 * mb;
+
+    List<Double> boundaries = new ArrayList<>();
+    long boundary = 0;
+    long increment = 128 * kb;
+
+    // 128 KiB increments up to 4MiB, then exponential growth
+    while (boundaries.size() < 200 && boundary <= 16 * gb) {
+      boundaries.add((double) boundary);
+      boundary += increment;
+      if (boundary >= 4 * mb) {
+        increment *= 2;
+      }
+    }
+    return boundaries;
+  }
+
+  private static final class PermissionDeniedSingleReportMetricsExporter implements MetricExporter {
+    private final MetricExporter delegate;
+    private final AtomicBoolean seenPermissionDenied = new AtomicBoolean(false);
+    private final AtomicBoolean seenNoRouteToHost = new AtomicBoolean(false);
+
+    private PermissionDeniedSingleReportMetricsExporter(MetricExporter delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public CompletableResultCode export(Collection<MetricData> metrics) {
+      if (seenPermissionDenied.get() && seenNoRouteToHost.get()) {
+        return CompletableResultCode.ofFailure();
+      }
+
+      try {
+        return delegate.export(metrics);
+      } catch (PermissionDeniedException e) {
+        if (!seenPermissionDenied.get()) {
+          seenPermissionDenied.set(true);
+          throw e;
+        }
+        return CompletableResultCode.ofFailure();
+      } catch (UnavailableException e) {
+        if (seenPermissionDenied.get()
+            && !seenNoRouteToHost.get()
+            && ultimateCause(e, NoRouteToHostException.class)) {
+          seenNoRouteToHost.set(true);
+          throw e;
+        }
+        return CompletableResultCode.ofFailure();
+      }
+    }
+
+    @Override
+    public Aggregation getDefaultAggregation(InstrumentType instrumentType) {
+      return delegate.getDefaultAggregation(instrumentType);
+    }
+
+    @Override
+    public MemoryMode getMemoryMode() {
+      return delegate.getMemoryMode();
+    }
+
+    @Override
+    public CompletableResultCode flush() {
+      return delegate.flush();
+    }
+
+    @Override
+    public CompletableResultCode shutdown() {
+      return delegate.shutdown();
+    }
+
+    @Override
+    public void close() {
+      delegate.close();
+    }
+
+    @Override
+    public AggregationTemporality getAggregationTemporality(InstrumentType instrumentType) {
+      return delegate.getAggregationTemporality(instrumentType);
+    }
+
+    @Override
+    public DefaultAggregationSelector with(InstrumentType instrumentType, Aggregation aggregation) {
+      return delegate.with(instrumentType, aggregation);
+    }
+
+    private static boolean ultimateCause(Throwable t, Class<? extends Throwable> c) {
+      if (t == null) {
+        return false;
+      }
+
+      Throwable cause = t.getCause();
+      if (cause != null && c.isAssignableFrom(cause.getClass())) {
+        return true;
+      } else {
+        return ultimateCause(cause, c);
+      }
+    }
+  }
+}
diff --git a/pom.xml b/pom.xml
index 6087fd29..47784687 100644
--- a/pom.xml
+++ b/pom.xml
@@ -54,7 +54,7 @@
     <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
     <github.global.server>github</github.global.server>
     <site.installationModule>google-cloud-storage-parent</site.installationModule>
-    <google.cloud.shared-dependencies.version>3.25.0</google.cloud.shared-dependencies.version>
+    <google.cloud.shared-dependencies.version>3.31.0</google.cloud.shared-dependencies.version>
   </properties>
 
   <dependencyManagement>
@@ -65,10 +65,61 @@
         <version>${google-cloud-shared-dependencies.version}</version>
         <type>pom</type>
         <scope>import</scope>
+      </dependency>
+      <!--
+      Each of the following dependencies declares a dependency on opentelemtry-semconv,
+      but they differ in what version. Explicitly declare the version here to break the tie.
+      * com.google.cloud.opentelemetry:exporter-metrics
+      * com.google.cloud.opentelemetry:detector-resources
+      * io.opentelemetry.instrumentation:opentelemetry-resources
+      * io.opentelemetry.contrib:opentelemetry-gcp-resources
+      -->
+      <dependency>
+        <groupId>io.opentelemetry.semconv</groupId>
+        <artifactId>opentelemetry-semconv</artifactId>
+        <version>1.25.0-alpha</version>
+      </dependency>
+      <dependency>
+        <groupId>com.google.cloud.opentelemetry</groupId>
+        <artifactId>exporter-metrics</artifactId>
+        <version>0.31.0</version>
+        <exclusions>
+          <exclusion>
+            <groupId>io.opentelemetry.semconv</groupId>
+            <artifactId>opentelemetry-semconv</artifactId>
+          </exclusion>
+        </exclusions>
+      </dependency>
+      <dependency>
+        <groupId>com.google.cloud.opentelemetry</groupId>
+        <artifactId>detector-resources</artifactId>
+        <version>0.27.0-alpha</version>
+        <exclusions>
+          <exclusion>
+            <groupId>io.opentelemetry.semconv</groupId>
+            <artifactId>opentelemetry-semconv</artifactId>
+          </exclusion>
+        </exclusions>
+      </dependency>
+      <dependency>
+        <groupId>io.opentelemetry.instrumentation</groupId>
+        <artifactId>opentelemetry-resources</artifactId>
+        <version>2.6.0-alpha</version>
+        <exclusions>
+          <exclusion>
+            <groupId>io.opentelemetry.semconv</groupId>
+            <artifactId>opentelemetry-semconv</artifactId>
+          </exclusion>
+        </exclusions>
+      </dependency>
+      <dependency>
+        <groupId>io.opentelemetry.contrib</groupId>
+        <artifactId>opentelemetry-gcp-resources</artifactId>
+        <version>1.37.0-alpha</version>
         <exclusions>
           <exclusion>
-            <groupId>io.grpc</groupId>
-            <artifactId>*</artifactId>
+            <groupId>io.opentelemetry.semconv</groupId>
+            <artifactId>opentelemetry-semconv</artifactId>
           </exclusion>
         </exclusions>
       </dependency>
diff --git a/renovate.json b/renovate.json
index c7467c6b..42380a2b 100644
--- a/renovate.json
+++ b/renovate.json
@@ -111,6 +111,16 @@
       ],
       "semanticCommitType": "test",
       "semanticCommitScope": "deps"
+    },
+    {
+      "groupName": "OpenTelemetry extended dependencies",
+      "packagePatterns": [
+        "^io.opentelemetry.semconv:",
+        "^io.opentelemetry.instrumentation:",
+        "^io.opentelemetry.contrib:",
+        "^com.google.cloud.opentelemetry:",
+        "$com.google.cloud.opentelemetry:shared-resourcemapping"
+      ]
     }
   ],
   "semanticCommits": true,


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/DefaultRetryHandlingBehaviorTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/DefaultRetryHandlingBehaviorTest.java
index f1c37b09..0f90c752 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/DefaultRetryHandlingBehaviorTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/DefaultRetryHandlingBehaviorTest.java
@@ -35,6 +35,7 @@ import com.google.gson.stream.MalformedJsonException;
 import java.io.IOException;
 import java.net.SocketException;
 import java.net.SocketTimeoutException;
+import java.net.UnknownHostException;
 import java.security.cert.CertificateException;
 import java.util.Arrays;
 import java.util.Collection;
@@ -342,6 +343,7 @@ public final class DefaultRetryHandlingBehaviorTest {
     IO_EXCEPTION(new IOException("no retry")),
     AUTH_RETRYABLE_TRUE(new RetryableException(true)),
     AUTH_RETRYABLE_FALSE(new RetryableException(false)),
+    UNKNOWN_HOST_EXCEPTION(C.UNKNOWN_HOST_EXCEPTION),
     ;
 
     private final Throwable throwable;
@@ -415,6 +417,8 @@ public final class DefaultRetryHandlingBehaviorTest {
       private static final MalformedJsonException GSON_MALFORMED_EXCEPTION =
           new MalformedJsonException("parse-exception");
       private static final IOException IO_PREMATURE_EOF = new IOException("Premature EOF");
+      private static final UnknownHostException UNKNOWN_HOST_EXCEPTION =
+          new UnknownHostException("fake.fake");
 
       private static HttpResponseException newHttpResponseException(
           int httpStatusCode, String name) {
@@ -1065,6 +1069,16 @@ public final class DefaultRetryHandlingBehaviorTest {
                 ThrowableCategory.AUTH_RETRYABLE_FALSE,
                 HandlerCategory.NONIDEMPOTENT,
                 ExpectRetry.NO,
+                Behavior.SAME),
+            new Case(
+                ThrowableCategory.UNKNOWN_HOST_EXCEPTION,
+                HandlerCategory.IDEMPOTENT,
+                ExpectRetry.YES,
+                Behavior.DEFAULT_MORE_PERMISSIBLE),
+            new Case(
+                ThrowableCategory.UNKNOWN_HOST_EXCEPTION,
+                HandlerCategory.NONIDEMPOTENT,
+                ExpectRetry.NO,
                 Behavior.SAME))
         .build();
   }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ITGrpcMetricsTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITGrpcMetricsTest.java
new file mode 100644
index 00000000..f1a406ca
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITGrpcMetricsTest.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2024 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 com.google.cloud.storage.it.runner.StorageITRunner;
+import com.google.cloud.storage.it.runner.annotations.Backend;
+import com.google.cloud.storage.it.runner.annotations.SingleBackend;
+import io.opentelemetry.sdk.metrics.SdkMeterProvider;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(StorageITRunner.class)
+@SingleBackend(Backend.PROD)
+public class ITGrpcMetricsTest {
+  @Test
+  public void testGrpcMetrics() {
+    GrpcStorageOptions grpcStorageOptions = StorageOptions.grpc().build();
+    assertThat(
+            OpenTelemetryBootstrappingUtils.getCloudMonitoringEndpoint(
+                "storage.googleapis.com:443", "storage.googleapis.com"))
+        .isEqualTo("monitoring.googleapis.com:443");
+
+    SdkMeterProvider provider =
+        OpenTelemetryBootstrappingUtils.createMeterProvider(
+            "monitoring.googleapis.com:443", grpcStorageOptions.getProjectId(), false);
+
+    /*
+     * SDKMeterProvider doesn't expose the relevant fields we want to test, but they are present in
+     * the String representation, so we'll check that instead. Most of the resources are auto-set,
+     * and will depend on environment, which could cause flakes to check. We're only responsible for
+     * setting the project ID, endpoint, and Histogram boundaries, so we'll just check those
+     */
+    String result = provider.toString();
+
+    // What the project ID will be will depend on the environment, so we just make sure it's present
+    // and not null/empty
+    assertThat(result.contains("project_id"));
+    assertThat(result).doesNotContain("project_id=\"\"");
+    assertThat(result).doesNotContain("project_id=null");
+
+    // This is the check for the Seconds histogram boundary. We can't practically check for every
+    // boundary,
+    // but if *any* are present, that means they're different from the results and we successfully
+    // set them
+    assertThat(result).contains("1.2");
+
+    // This is the check for the Size boundary
+    assertThat(result).contains("131072");
+  }
+
+  @Test
+  public void testGrpcMetrics_universeDomain() {
+    assertThat("monitoring.my-universe-domain.com:443")
+        .isEqualTo(
+            OpenTelemetryBootstrappingUtils.getCloudMonitoringEndpoint(
+                "storage.my-universe-domain.com:443", "my-universe-domain.com"));
+  }
+
+  @Test
+  public void testGrpcMetrics_private() {
+    assertThat("private.googleapis.com:443")
+        .isEqualTo(
+            OpenTelemetryBootstrappingUtils.getCloudMonitoringEndpoint(
+                "private.googleapis.com:443", null));
+  }
+
+  @Test
+  public void testGrpcMetrics_restricted() {
+    assertThat("restricted.googleapis.com:443")
+        .isEqualTo(
+            OpenTelemetryBootstrappingUtils.getCloudMonitoringEndpoint(
+                "restricted.googleapis.com:443", null));
+  }
+}