Repository: googleapis/java-storage
Pull request #2837 (2024-12-06 22:02:36)

feat: introductory beta level support for OpenTelemetry tracing on c.g.c.storage.Storage methods
Introducing OpenTelemetry Tracing


generated-files-bot[bot]: <!-- probot comment [12634855]-->
*Warning*: This pull request is touching the following templated files:

* .github/sync-repo-settings.yaml

Patch:
diff --git a/gapic-google-cloud-storage-v2/pom.xml b/gapic-google-cloud-storage-v2/pom.xml
index b674e821..260248ee 100644
--- a/gapic-google-cloud-storage-v2/pom.xml
+++ b/gapic-google-cloud-storage-v2/pom.xml
@@ -4,7 +4,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.api.grpc</groupId>
   <artifactId>gapic-google-cloud-storage-v2</artifactId>
-  <version>2.46.1-beta-SNAPSHOT</version><!-- {x-version-update:gapic-google-cloud-storage-v2:current} -->
+  <version>2.46.1-SNAPSHOT</version><!-- {x-version-update:gapic-google-cloud-storage-v2:current} -->
   <name>gapic-google-cloud-storage-v2</name>
   <description>GRPC library for gapic-google-cloud-storage-v2</description>
   <parent>
diff --git a/google-cloud-storage-bom/pom.xml b/google-cloud-storage-bom/pom.xml
index 2ad826d1..a904c8d1 100644
--- a/google-cloud-storage-bom/pom.xml
+++ b/google-cloud-storage-bom/pom.xml
@@ -74,17 +74,17 @@
       <dependency>
         <groupId>com.google.api.grpc</groupId>
         <artifactId>gapic-google-cloud-storage-v2</artifactId>
-        <version>2.46.1-beta-SNAPSHOT</version><!-- {x-version-update:gapic-google-cloud-storage-v2:current} -->
+        <version>2.46.1-SNAPSHOT</version><!-- {x-version-update:gapic-google-cloud-storage-v2:current} -->
       </dependency>
       <dependency>
         <groupId>com.google.api.grpc</groupId>
         <artifactId>grpc-google-cloud-storage-v2</artifactId>
-        <version>2.46.1-beta-SNAPSHOT</version><!-- {x-version-update:grpc-google-cloud-storage-v2:current} -->
+        <version>2.46.1-SNAPSHOT</version><!-- {x-version-update:grpc-google-cloud-storage-v2:current} -->
       </dependency>
       <dependency>
         <groupId>com.google.api.grpc</groupId>
         <artifactId>proto-google-cloud-storage-v2</artifactId>
-        <version>2.46.1-beta-SNAPSHOT</version><!-- {x-version-update:proto-google-cloud-storage-v2:current} -->
+        <version>2.46.1-SNAPSHOT</version><!-- {x-version-update:proto-google-cloud-storage-v2:current} -->
       </dependency>
       <dependency>
         <groupId>com.google.cloud</groupId>
diff --git a/google-cloud-storage/clirr-ignored-differences.xml b/google-cloud-storage/clirr-ignored-differences.xml
index d81c37bb..0b19230f 100644
--- a/google-cloud-storage/clirr-ignored-differences.xml
+++ b/google-cloud-storage/clirr-ignored-differences.xml
@@ -96,4 +96,17 @@
     <method>boolean equals(java.lang.Object)</method>
   </difference>
 
+  <difference>
+    <differenceType>7013</differenceType>
+    <className>com/google/cloud/storage/StorageOptions$Builder</className>
+    <method>com.google.cloud.storage.StorageOptions$Builder setOpenTelemetry(io.opentelemetry.api.OpenTelemetry)</method>
+  </difference>
+
+  <difference>
+    <differenceType>7013</differenceType>
+    <className>com/google/cloud/storage/StorageOptions</className>
+    <method>io.opentelemetry.api.OpenTelemetry getOpenTelemetry()</method>
+  </difference>
+
+
 </differences>
diff --git a/google-cloud-storage/pom.xml b/google-cloud-storage/pom.xml
index 665a854d..a246beb3 100644
--- a/google-cloud-storage/pom.xml
+++ b/google-cloud-storage/pom.xml
@@ -83,6 +83,10 @@
       <groupId>io.opencensus</groupId>
       <artifactId>opencensus-api</artifactId>
     </dependency>
+    <dependency>
+      <groupId>io.opentelemetry</groupId>
+      <artifactId>opentelemetry-context</artifactId>
+    </dependency>
     <dependency>
       <groupId>com.google.api.grpc</groupId>
       <artifactId>proto-google-iam-v1</artifactId>
@@ -205,6 +209,11 @@
       <artifactId>grpc-googleapis</artifactId>
       <scope>runtime</scope>
     </dependency>
+    <dependency>
+      <groupId>io.opentelemetry</groupId>
+      <artifactId>opentelemetry-sdk-trace</artifactId>
+      <scope>test</scope>
+    </dependency>
 
     <!--
     We're not using this directly, however there appears to be some resolution
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicCopyWriter.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicCopyWriter.java
index 038ff466..9d6a1f87 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicCopyWriter.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicCopyWriter.java
@@ -31,6 +31,7 @@ final class GapicCopyWriter extends CopyWriter {
   private final GrpcStorageOptions options;
   private final UnaryCallable<RewriteObjectRequest, RewriteResponse> callable;
   private final ResultRetryAlgorithm<?> alg;
+  private final RewriteObjectRequest originalRequest;
   private final RewriteResponse initialResponse;
 
   private RewriteResponse mostRecentResponse;
@@ -39,6 +40,7 @@ final class GapicCopyWriter extends CopyWriter {
       GrpcStorageImpl storage,
       UnaryCallable<RewriteObjectRequest, RewriteResponse> callable,
       ResultRetryAlgorithm<?> alg,
+      RewriteObjectRequest originalRequest,
       RewriteResponse initialResponse) {
     this.storage = storage;
     this.options = storage.getOptions();
@@ -46,6 +48,7 @@ final class GapicCopyWriter extends CopyWriter {
     this.alg = alg;
     this.initialResponse = initialResponse;
     this.mostRecentResponse = initialResponse;
+    this.originalRequest = originalRequest;
   }
 
   @Override
@@ -76,9 +79,7 @@ final class GapicCopyWriter extends CopyWriter {
   public void copyChunk() {
     if (!isDone()) {
       RewriteObjectRequest req =
-          RewriteObjectRequest.newBuilder()
-              .setRewriteToken(mostRecentResponse.getRewriteToken())
-              .build();
+          originalRequest.toBuilder().setRewriteToken(mostRecentResponse.getRewriteToken()).build();
       GrpcCallContext retryContext = Retrying.newCallContext();
       mostRecentResponse =
           Retrying.run(options, alg, () -> callable.call(req, retryContext), Decoder.identity());
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicUnbufferedReadableByteChannel.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicUnbufferedReadableByteChannel.java
index 2efc340c..aa2bc9f6 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicUnbufferedReadableByteChannel.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicUnbufferedReadableByteChannel.java
@@ -234,35 +234,42 @@ final class GapicUnbufferedReadableByteChannel
   }
 
   private void drainQueue() throws IOException {
-    IOException ioException = null;
-    while (queue.nonEmpty()) {
-      try {
-        java.lang.Object queueValue = queue.poll();
-        if (queueValue instanceof ReadObjectResponse) {
-          ReadObjectResponse resp = (ReadObjectResponse) queueValue;
-          ResponseContentLifecycleHandle handle = rclm.get(resp);
-          handle.close();
-        } else if (queueValue == EOF_MARKER || queueValue instanceof Throwable) {
-          break;
-        }
-      } catch (IOException e) {
-        if (ioException == null) {
-          ioException = e;
-        } else if (ioException != e) {
-          ioException.addSuppressed(e);
+    boolean shouldInterupt = false;
+    try {
+      IOException ioException = null;
+      while (queue.nonEmpty()) {
+        try {
+          java.lang.Object queueValue = queue.poll();
+          if (queueValue instanceof ReadObjectResponse) {
+            ReadObjectResponse resp = (ReadObjectResponse) queueValue;
+            ResponseContentLifecycleHandle handle = rclm.get(resp);
+            handle.close();
+          } else if (queueValue == EOF_MARKER || queueValue instanceof Throwable) {
+            break;
+          }
+        } catch (IOException e) {
+          if (ioException == null) {
+            ioException = e;
+          } else if (ioException != e) {
+            ioException.addSuppressed(e);
+          }
+        } catch (InterruptedException e) {
+          shouldInterupt = true;
+          if (ioException == null) {
+            ioException = new InterruptedIOException();
+          } else {
+            ioException.addSuppressed(e);
+          }
         }
-      } catch (InterruptedException e) {
+      }
+      if (ioException != null) {
+        throw ioException;
+      }
+    } finally {
+      if (shouldInterupt) {
         Thread.currentThread().interrupt();
-        if (ioException == null) {
-          ioException = new InterruptedIOException();
-        } else {
-          ioException.addSuppressed(e);
-        }
       }
     }
-    if (ioException != null) {
-      throw ioException;
-    }
   }
 
   ApiFuture<Object> getResult() {
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
index d7472f5f..428f85bc 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
@@ -656,17 +656,24 @@ final class GrpcStorageImpl extends BaseService<StorageOptions>
         RewriteObjectRequest.newBuilder()
             .setDestinationName(dstProto.getName())
             .setDestinationBucket(dstProto.getBucket())
-            // destination_kms_key comes from dstOpts
-            // according to the docs in the protos, it is illegal to populate the following fields,
-            // clear them out if they are set
-            // destination_predefined_acl comes from dstOpts
-            // if_*_match come from srcOpts and dstOpts
-            // copy_source_encryption_* come from srcOpts
-            // common_object_request_params come from dstOpts
-            .setDestination(dstProto.toBuilder().clearName().clearBucket().clearKmsKey().build())
             .setSourceBucket(srcProto.getBucket())
             .setSourceObject(srcProto.getName());
 
+    // according to the docs in the protos, it is illegal to populate the following fields,
+    // clear them out if they are set
+    // * destination_kms_key comes from dstOpts
+    // * destination_predefined_acl comes from dstOpts
+    // * if_*_match come from srcOpts and dstOpts
+    // * copy_source_encryption_* come from srcOpts
+    // * common_object_request_params come from dstOpts
+    Object cleanedDst = dstProto.toBuilder().clearName().clearBucket().clearKmsKey().build();
+    // only set the destination if it is not equal to the default instance
+    // otherwise we will clobber default values populated in the gcs server side for the object
+    // metadata
+    if (!cleanedDst.equals(Object.getDefaultInstance())) {
+      b.setDestination(cleanedDst);
+    }
+
     if (src.getGeneration() != null) {
       b.setSourceGeneration(src.getGeneration());
     }
@@ -685,7 +692,8 @@ final class GrpcStorageImpl extends BaseService<StorageOptions>
         getOptions(),
         retryAlgorithmManager.getFor(req),
         () -> callable.call(req, retryContext),
-        (resp) -> new GapicCopyWriter(this, callable, retryAlgorithmManager.idempotent(), resp));
+        (resp) ->
+            new GapicCopyWriter(this, callable, retryAlgorithmManager.idempotent(), req, resp));
   }
 
   @Override
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 436be1e7..279418c5 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
@@ -81,9 +81,11 @@ import io.grpc.ManagedChannelBuilder;
 import io.grpc.MethodDescriptor;
 import io.grpc.Status;
 import io.grpc.protobuf.ProtoUtils;
+import io.opentelemetry.api.OpenTelemetry;
 import java.io.Closeable;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.ObjectInputStream;
 import java.io.Serializable;
 import java.net.URI;
 import java.nio.ByteBuffer;
@@ -119,6 +121,7 @@ public final class GrpcStorageOptions extends StorageOptions
   private final boolean grpcClientMetricsManuallyEnabled;
   private final GrpcInterceptorProvider grpcInterceptorProvider;
   private final BlobWriteSessionConfig blobWriteSessionConfig;
+  private transient OpenTelemetry openTelemetry;
 
   private GrpcStorageOptions(Builder builder, GrpcStorageDefaults serviceDefaults) {
     super(builder, serviceDefaults);
@@ -135,6 +138,7 @@ public final class GrpcStorageOptions extends StorageOptions
     this.grpcClientMetricsManuallyEnabled = builder.grpcMetricsManuallyEnabled;
     this.grpcInterceptorProvider = builder.grpcInterceptorProvider;
     this.blobWriteSessionConfig = builder.blobWriteSessionConfig;
+    this.openTelemetry = builder.openTelemetry;
   }
 
   @Override
@@ -157,6 +161,11 @@ public final class GrpcStorageOptions extends StorageOptions
     return resolveSettingsAndOpts().x();
   }
 
+  private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
+    in.defaultReadObject();
+    this.openTelemetry = HttpStorageOptions.getDefaultInstance().getOpenTelemetry();
+  }
+
   /**
    * We have to perform several introspections and detections to cross-wire/support several features
    * that are either gapic primitives, ServiceOption primitives or GCS semantic requirements.
@@ -349,6 +358,13 @@ public final class GrpcStorageOptions extends StorageOptions
     return Tuple.of(builder.build(), defaultOpts);
   }
 
+  /** @since 2.47.0 This new api is in preview and is subject to breaking changes. */
+  @BetaApi
+  @Override
+  public OpenTelemetry getOpenTelemetry() {
+    return openTelemetry;
+  }
+
   /** @since 2.14.0 */
   @Override
   public GrpcStorageOptions.Builder toBuilder() {
@@ -364,6 +380,7 @@ public final class GrpcStorageOptions extends StorageOptions
         enableGrpcClientMetrics,
         grpcInterceptorProvider,
         blobWriteSessionConfig,
+        openTelemetry,
         baseHashCode());
   }
 
@@ -382,6 +399,7 @@ public final class GrpcStorageOptions extends StorageOptions
         && Objects.equals(terminationAwaitDuration, that.terminationAwaitDuration)
         && Objects.equals(grpcInterceptorProvider, that.grpcInterceptorProvider)
         && Objects.equals(blobWriteSessionConfig, that.blobWriteSessionConfig)
+        && Objects.equals(openTelemetry, that.openTelemetry)
         && this.baseEquals(that);
   }
 
@@ -423,6 +441,7 @@ public final class GrpcStorageOptions extends StorageOptions
         GrpcStorageDefaults.INSTANCE.grpcInterceptorProvider();
     private BlobWriteSessionConfig blobWriteSessionConfig =
         GrpcStorageDefaults.INSTANCE.getDefaultStorageWriterConfig();
+    private OpenTelemetry openTelemetry = GrpcStorageDefaults.INSTANCE.getDefaultOpenTelemetry();
 
     private boolean grpcMetricsManuallyEnabled = false;
 
@@ -437,6 +456,7 @@ public final class GrpcStorageOptions extends StorageOptions
       this.enableGrpcClientMetrics = gso.enableGrpcClientMetrics;
       this.grpcInterceptorProvider = gso.grpcInterceptorProvider;
       this.blobWriteSessionConfig = gso.blobWriteSessionConfig;
+      this.openTelemetry = gso.openTelemetry;
     }
 
     /**
@@ -619,6 +639,19 @@ public final class GrpcStorageOptions extends StorageOptions
       return this;
     }
 
+    /**
+     * Enable OpenTelemetry Tracing and provide an instance for the client to use.
+     *
+     * @param openTelemetry User defined instance of OpenTelemetry to be used by the library
+     * @since 2.47.0 This new api is in preview and is subject to breaking changes.
+     */
+    @BetaApi
+    public GrpcStorageOptions.Builder setOpenTelemetry(OpenTelemetry openTelemetry) {
+      requireNonNull(openTelemetry, "openTelemetry must be non null");
+      this.openTelemetry = openTelemetry;
+      return this;
+    }
+
     /** @since 2.14.0 */
     @Override
     public GrpcStorageOptions build() {
@@ -695,6 +728,12 @@ public final class GrpcStorageOptions extends StorageOptions
     public BlobWriteSessionConfig getDefaultStorageWriterConfig() {
       return BlobWriteSessionConfigs.getDefault();
     }
+
+    /** @since 2.47.0 This new api is in preview and is subject to breaking changes. */
+    @BetaApi
+    public OpenTelemetry getDefaultOpenTelemetry() {
+      return OpenTelemetry.noop();
+    }
   }
 
   /**
@@ -751,22 +790,27 @@ public final class GrpcStorageOptions extends StorageOptions
                 new InternalZeroCopyGrpcStorageStub(
                     stubSettings, clientContext, grpcStorageCallableFactory);
             StorageClient client = new InternalStorageClient(stub);
-            return new GrpcStorageImpl(
-                grpcStorageOptions,
-                client,
-                stub.getObjectMediaResponseMarshaller,
-                grpcStorageOptions.blobWriteSessionConfig.createFactory(Clock.systemUTC()),
-                defaultOpts);
+            GrpcStorageImpl grpcStorage =
+                new GrpcStorageImpl(
+                    grpcStorageOptions,
+                    client,
+                    stub.getObjectMediaResponseMarshaller,
+                    grpcStorageOptions.blobWriteSessionConfig.createFactory(Clock.systemUTC()),
+                    defaultOpts);
+            return OtelStorageDecorator.decorate(
+                grpcStorage, options.getOpenTelemetry(), Transport.GRPC);
           } else {
             StorageClient client = StorageClient.create(storageSettings);
-            return new GrpcStorageImpl(
-                grpcStorageOptions,
-                client,
-                ResponseContentLifecycleManager.noop(),
-                grpcStorageOptions.blobWriteSessionConfig.createFactory(Clock.systemUTC()),
-                defaultOpts);
+            GrpcStorageImpl grpcStorage =
+                new GrpcStorageImpl(
+                    grpcStorageOptions,
+                    client,
+                    ResponseContentLifecycleManager.noop(),
+                    grpcStorageOptions.blobWriteSessionConfig.createFactory(Clock.systemUTC()),
+                    defaultOpts);
+            return OtelStorageDecorator.decorate(
+                grpcStorage, options.getOpenTelemetry(), Transport.GRPC);
           }
-
         } catch (IOException e) {
           throw new IllegalStateException(
               "Unable to instantiate gRPC com.google.cloud.storage.Storage client.", e);
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java
index a14bad42..e440765f 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java
@@ -38,6 +38,7 @@ import com.google.cloud.storage.spi.v1.HttpStorageRpc;
 import com.google.cloud.storage.spi.v1.StorageRpc;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableSet;
+import io.opentelemetry.api.OpenTelemetry;
 import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.Serializable;
@@ -59,6 +60,8 @@ public class HttpStorageOptions extends StorageOptions {
   private transient RetryDependenciesAdapter retryDepsAdapter;
   private final BlobWriteSessionConfig blobWriteSessionConfig;
 
+  private transient OpenTelemetry openTelemetry;
+
   private HttpStorageOptions(Builder builder, StorageDefaults serviceDefaults) {
     super(builder, serviceDefaults);
     this.retryAlgorithmManager =
@@ -67,6 +70,7 @@ public class HttpStorageOptions extends StorageOptions {
                 builder.storageRetryStrategy, defaults().getStorageRetryStrategy()));
     retryDepsAdapter = new RetryDependenciesAdapter();
     blobWriteSessionConfig = builder.blobWriteSessionConfig;
+    openTelemetry = builder.openTelemetry;
   }
 
   @Override
@@ -84,6 +88,13 @@ public class HttpStorageOptions extends StorageOptions {
     return (StorageRpc) getRpc();
   }
 
+  /** @since 2.47.0 This new api is in preview and is subject to breaking changes. */
+  @BetaApi
+  @Override
+  public OpenTelemetry getOpenTelemetry() {
+    return openTelemetry;
+  }
+
   @Override
   public HttpStorageOptions.Builder toBuilder() {
     return new HttpStorageOptions.Builder(this);
@@ -91,7 +102,8 @@ public class HttpStorageOptions extends StorageOptions {
 
   @Override
   public int hashCode() {
-    return Objects.hash(retryAlgorithmManager, blobWriteSessionConfig, baseHashCode());
+    return Objects.hash(
+        retryAlgorithmManager, blobWriteSessionConfig, openTelemetry, baseHashCode());
   }
 
   @Override
@@ -105,12 +117,14 @@ public class HttpStorageOptions extends StorageOptions {
     HttpStorageOptions that = (HttpStorageOptions) o;
     return Objects.equals(retryAlgorithmManager, that.retryAlgorithmManager)
         && Objects.equals(blobWriteSessionConfig, that.blobWriteSessionConfig)
+        && Objects.equals(openTelemetry, that.openTelemetry)
         && this.baseEquals(that);
   }
 
   private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
     in.defaultReadObject();
     this.retryDepsAdapter = new RetryDependenciesAdapter();
+    this.openTelemetry = HttpStorageOptions.getDefaultInstance().getOpenTelemetry();
   }
 
   public static HttpStorageOptions.Builder newBuilder() {
@@ -135,6 +149,7 @@ public class HttpStorageOptions extends StorageOptions {
     private StorageRetryStrategy storageRetryStrategy;
     private BlobWriteSessionConfig blobWriteSessionConfig =
         HttpStorageDefaults.INSTANCE.getDefaultStorageWriterConfig();
+    private OpenTelemetry openTelemetry = HttpStorageDefaults.INSTANCE.getDefaultOpenTelemetry();
 
     Builder() {}
 
@@ -143,6 +158,7 @@ public class HttpStorageOptions extends StorageOptions {
       HttpStorageOptions hso = (HttpStorageOptions) options;
       this.storageRetryStrategy = hso.retryAlgorithmManager.retryStrategy;
       this.blobWriteSessionConfig = hso.blobWriteSessionConfig;
+      this.openTelemetry = hso.getOpenTelemetry();
     }
 
     @Override
@@ -268,6 +284,19 @@ public class HttpStorageOptions extends StorageOptions {
       }
       return options;
     }
+
+    /**
+     * Enable OpenTelemetry Tracing and provide an instance for the client to use.
+     *
+     * @param openTelemetry User defined instance of OpenTelemetry to be used by the library
+     * @since 2.47.0 This new api is in preview and is subject to breaking changes.
+     */
+    @BetaApi
+    public HttpStorageOptions.Builder setOpenTelemetry(OpenTelemetry openTelemetry) {
+      requireNonNull(openTelemetry, "openTelemetry must be non null");
+      this.openTelemetry = openTelemetry;
+      return this;
+    }
   }
 
   public static final class HttpStorageDefaults extends StorageDefaults {
@@ -301,6 +330,12 @@ public class HttpStorageOptions extends StorageOptions {
     public BlobWriteSessionConfig getDefaultStorageWriterConfig() {
       return BlobWriteSessionConfigs.getDefault();
     }
+
+    /** @since 2.47.0 This new api is in preview and is subject to breaking changes. */
+    @BetaApi
+    public OpenTelemetry getDefaultOpenTelemetry() {
+      return OpenTelemetry.noop();
+    }
   }
 
   /**
@@ -341,8 +376,12 @@ public class HttpStorageOptions extends StorageOptions {
         HttpStorageOptions httpStorageOptions = (HttpStorageOptions) options;
         Clock clock = Clock.systemUTC();
         try {
-          return new StorageImpl(
-              httpStorageOptions, httpStorageOptions.blobWriteSessionConfig.createFactory(clock));
+          StorageImpl storage =
+              new StorageImpl(
+                  httpStorageOptions,
+                  httpStorageOptions.blobWriteSessionConfig.createFactory(clock));
+          return OtelStorageDecorator.decorate(
+              storage, httpStorageOptions.getOpenTelemetry(), Transport.HTTP);
         } catch (IOException e) {
           throw new IllegalStateException(
               "Unable to instantiate HTTP com.google.cloud.storage.Storage client.", e);
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java
new file mode 100644
index 00000000..a1813a98
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/OtelStorageDecorator.java
@@ -0,0 +1,1733 @@
+/*
+ * Copyright 2025 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 java.util.Objects.requireNonNull;
+
+import com.google.api.core.ApiFuture;
+import com.google.api.core.BetaApi;
+import com.google.api.gax.paging.Page;
+import com.google.cloud.Policy;
+import com.google.cloud.ReadChannel;
+import com.google.cloud.RestorableState;
+import com.google.cloud.WriteChannel;
+import com.google.cloud.storage.Acl.Entity;
+import com.google.cloud.storage.HmacKey.HmacKeyMetadata;
+import com.google.cloud.storage.HmacKey.HmacKeyState;
+import com.google.cloud.storage.PostPolicyV4.PostConditionsV4;
+import com.google.cloud.storage.PostPolicyV4.PostFieldsV4;
+import com.google.cloud.storage.TransportCompatibility.Transport;
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanBuilder;
+import io.opentelemetry.api.trace.StatusCode;
+import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.context.Context;
+import io.opentelemetry.context.Scope;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URL;
+import java.nio.ByteBuffer;
+import java.nio.channels.WritableByteChannel;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+@SuppressWarnings("DuplicatedCode")
+final class OtelStorageDecorator implements Storage {
+
+  /** Becomes the {@code otel.scope.name} attribute in a span */
+  private static final String OTEL_SCOPE_NAME = "cloud.google.com/java/storage";
+
+  private final Storage delegate;
+  private final OpenTelemetry otel;
+  private final Attributes baseAttributes;
+  private final Tracer tracer;
+
+  private OtelStorageDecorator(Storage delegate, OpenTelemetry otel, Attributes baseAttributes) {
+    this.delegate = delegate;
+    this.otel = otel;
+    this.baseAttributes = baseAttributes;
+    this.tracer =
+        TracerDecorator.decorate(null, otel, baseAttributes, Storage.class.getName() + "/");
+  }
+
+  @Override
+  public Bucket create(BucketInfo bucketInfo, BucketTargetOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("create")
+            .setAttribute("gsutil.uri", fmtBucket(bucketInfo.getName()))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.create(bucketInfo, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob create(BlobInfo blobInfo, BlobTargetOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("create")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.create(blobInfo, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob create(BlobInfo blobInfo, byte[] content, BlobTargetOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("create")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.create(blobInfo, content, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob create(
+      BlobInfo blobInfo, byte[] content, int offset, int length, BlobTargetOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("create")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.create(blobInfo, content, offset, length, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  @Deprecated
+  public Blob create(BlobInfo blobInfo, InputStream content, BlobWriteOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("create")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.create(blobInfo, content, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob createFrom(BlobInfo blobInfo, Path path, BlobWriteOption... options)
+      throws IOException {
+    Span span =
+        tracer
+            .spanBuilder("createFrom")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.createFrom(blobInfo, path, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob createFrom(BlobInfo blobInfo, Path path, int bufferSize, BlobWriteOption... options)
+      throws IOException {
+    Span span =
+        tracer
+            .spanBuilder("createFrom")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.createFrom(blobInfo, path, bufferSize, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob createFrom(BlobInfo blobInfo, InputStream content, BlobWriteOption... options)
+      throws IOException {
+    Span span =
+        tracer
+            .spanBuilder("createFrom")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.createFrom(blobInfo, content, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob createFrom(
+      BlobInfo blobInfo, InputStream content, int bufferSize, BlobWriteOption... options)
+      throws IOException {
+    Span span =
+        tracer
+            .spanBuilder("createFrom")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.createFrom(blobInfo, content, bufferSize, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Bucket get(String bucket, BucketGetOption... options) {
+    Span span = tracer.spanBuilder("get").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.get(bucket, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Bucket lockRetentionPolicy(BucketInfo bucket, BucketTargetOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("lockRetentionPolicy")
+            .setAttribute("gsutil.uri", fmtBucket(bucket.getName()))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.lockRetentionPolicy(bucket, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob get(String bucket, String blob, BlobGetOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("get")
+            .setAttribute("gsutil.uri", String.format("gs://%s/%s", bucket, blob))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.get(bucket, blob, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob get(BlobId blob, BlobGetOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("get")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.get(blob, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob get(BlobId blob) {
+    Span span =
+        tracer
+            .spanBuilder("get")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.get(blob);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob restore(BlobId blob, BlobRestoreOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("restore")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.restore(blob, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Page<Bucket> list(BucketListOption... options) {
+    Span span = tracer.spanBuilder("list").setAttribute("gsutil.uri", "gs://").startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.list(options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Page<Blob> list(String bucket, BlobListOption... options) {
+    Span span =
+        tracer.spanBuilder("list").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.list(bucket, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Bucket update(BucketInfo bucketInfo, BucketTargetOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("update")
+            .setAttribute("gsutil.uri", fmtBucket(bucketInfo.getName()))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.update(bucketInfo, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob update(BlobInfo blobInfo, BlobTargetOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("update")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.update(blobInfo, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob update(BlobInfo blobInfo) {
+    Span span =
+        tracer
+            .spanBuilder("update")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.update(blobInfo);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public boolean delete(String bucket, BucketSourceOption... options) {
+    Span span =
+        tracer.spanBuilder("delete").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.delete(bucket, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public boolean delete(String bucket, String blob, BlobSourceOption... options) {
+    Span span =
+        tracer.spanBuilder("delete").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.delete(bucket, blob, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public boolean delete(BlobId blob, BlobSourceOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("delete")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.delete(blob, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public boolean delete(BlobId blob) {
+    Span span =
+        tracer
+            .spanBuilder("delete")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.delete(blob);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Blob compose(ComposeRequest composeRequest) {
+    Span span =
+        tracer
+            .spanBuilder("compose")
+            .setAttribute("gsutil.uri", composeRequest.getTarget().getBlobId().toGsUtilUri())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.compose(composeRequest);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public CopyWriter copy(CopyRequest copyRequest) {
+    Span span =
+        tracer
+            .spanBuilder("copy")
+            .setAttribute("gsutil.uri", copyRequest.getTarget().getBlobId().toGsUtilUri())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      CopyWriter copyWriter = delegate.copy(copyRequest);
+      return new OtelDecoratedCopyWriter(copyWriter, span);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      span.end();
+      throw t;
+    }
+  }
+
+  @Override
+  public byte[] readAllBytes(String bucket, String blob, BlobSourceOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("readAllBytes")
+            .setAttribute("gsutil.uri", BlobId.of(bucket, blob).toGsUtilUri())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.readAllBytes(bucket, blob, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public byte[] readAllBytes(BlobId blob, BlobSourceOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("readAllBytes")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.readAllBytes(blob, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public StorageBatch batch() {
+    return delegate.batch();
+  }
+
+  @Override
+  public ReadChannel reader(String bucket, String blob, BlobSourceOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("reader")
+            .setAttribute("gsutil.uri", BlobId.of(bucket, blob).toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      ReadChannel reader = delegate.reader(bucket, blob, options);
+      return new OtelDecoratedReadChannel(reader, span);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      span.end();
+      throw t;
+    }
+  }
+
+  @Override
+  public ReadChannel reader(BlobId blob, BlobSourceOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("reader")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      ReadChannel reader = delegate.reader(blob, options);
+      return new OtelDecoratedReadChannel(reader, span);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      span.end();
+      throw t;
+    }
+  }
+
+  @Override
+  public void downloadTo(BlobId blob, Path path, BlobSourceOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("downloadTo")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      delegate.downloadTo(blob, path, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public void downloadTo(BlobId blob, OutputStream outputStream, BlobSourceOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("downloadTo")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      delegate.downloadTo(blob, outputStream, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public WriteChannel writer(BlobInfo blobInfo, BlobWriteOption... options) {
+    Span sessionSpan =
+        tracer
+            .spanBuilder("writer")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = sessionSpan.makeCurrent()) {
+      WriteChannel writer = delegate.writer(blobInfo, options);
+      return new OtelDecoratedWriteChannel(writer, sessionSpan);
+    } catch (Throwable t) {
+      sessionSpan.recordException(t);
+      sessionSpan.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      sessionSpan.end();
+      throw t;
+    }
+  }
+
+  @Override
+  public WriteChannel writer(URL signedURL) {
+    Span sessionSpan = tracer.spanBuilder("writer").startSpan();
+    try (Scope ignore = sessionSpan.makeCurrent()) {
+      WriteChannel writer = delegate.writer(signedURL);
+      return new OtelDecoratedWriteChannel(writer, sessionSpan);
+    } catch (Throwable t) {
+      sessionSpan.recordException(t);
+      sessionSpan.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      sessionSpan.end();
+      throw t;
+    }
+  }
+
+  @Override
+  public URL signUrl(BlobInfo blobInfo, long duration, TimeUnit unit, SignUrlOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("signUrl")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.signUrl(blobInfo, duration, unit, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public PostPolicyV4 generateSignedPostPolicyV4(
+      BlobInfo blobInfo,
+      long duration,
+      TimeUnit unit,
+      PostFieldsV4 fields,
+      PostConditionsV4 conditions,
+      PostPolicyV4Option... options) {
+    Span span =
+        tracer
+            .spanBuilder("generateSignedPostPolicyV4")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.generateSignedPostPolicyV4(
+          blobInfo, duration, unit, fields, conditions, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public PostPolicyV4 generateSignedPostPolicyV4(
+      BlobInfo blobInfo,
+      long duration,
+      TimeUnit unit,
+      PostFieldsV4 fields,
+      PostPolicyV4Option... options) {
+    Span span =
+        tracer
+            .spanBuilder("generateSignedPostPolicyV4")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.generateSignedPostPolicyV4(blobInfo, duration, unit, fields, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public PostPolicyV4 generateSignedPostPolicyV4(
+      BlobInfo blobInfo,
+      long duration,
+      TimeUnit unit,
+      PostConditionsV4 conditions,
+      PostPolicyV4Option... options) {
+    Span span =
+        tracer
+            .spanBuilder("generateSignedPostPolicyV4")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.generateSignedPostPolicyV4(blobInfo, duration, unit, conditions, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public PostPolicyV4 generateSignedPostPolicyV4(
+      BlobInfo blobInfo, long duration, TimeUnit unit, PostPolicyV4Option... options) {
+    Span span =
+        tracer
+            .spanBuilder("generateSignedPostPolicyV4")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.generateSignedPostPolicyV4(blobInfo, duration, unit, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public List<Blob> get(BlobId... blobIds) {
+    Span span = tracer.spanBuilder("get").startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.get(blobIds);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public List<Blob> get(Iterable<BlobId> blobIds) {
+    Span span = tracer.spanBuilder("get").startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.get(blobIds);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public List<Blob> update(BlobInfo... blobInfos) {
+    Span span = tracer.spanBuilder("update").startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.update(blobInfos);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public List<Blob> update(Iterable<BlobInfo> blobInfos) {
+    Span span = tracer.spanBuilder("update").startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.update(blobInfos);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public List<Boolean> delete(BlobId... blobIds) {
+    Span span = tracer.spanBuilder("delete").startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.delete(blobIds);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public List<Boolean> delete(Iterable<BlobId> blobIds) {
+    Span span = tracer.spanBuilder("delete").startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.delete(blobIds);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Acl getAcl(String bucket, Entity entity, BucketSourceOption... options) {
+    Span span =
+        tracer.spanBuilder("getAcl").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.getAcl(bucket, entity, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Acl getAcl(String bucket, Entity entity) {
+    Span span =
+        tracer.spanBuilder("getAcl").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.getAcl(bucket, entity);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public boolean deleteAcl(String bucket, Entity entity, BucketSourceOption... options) {
+    Span span =
+        tracer.spanBuilder("deleteAcl").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.deleteAcl(bucket, entity, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public boolean deleteAcl(String bucket, Entity entity) {
+    Span span =
+        tracer.spanBuilder("deleteAcl").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.deleteAcl(bucket, entity);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Acl createAcl(String bucket, Acl acl, BucketSourceOption... options) {
+    Span span =
+        tracer.spanBuilder("createAcl").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.createAcl(bucket, acl, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Acl createAcl(String bucket, Acl acl) {
+    Span span =
+        tracer.spanBuilder("createAcl").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.createAcl(bucket, acl);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Acl updateAcl(String bucket, Acl acl, BucketSourceOption... options) {
+    Span span =
+        tracer.spanBuilder("updateAcl").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.updateAcl(bucket, acl, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Acl updateAcl(String bucket, Acl acl) {
+    Span span =
+        tracer.spanBuilder("updateAcl").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.updateAcl(bucket, acl);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public List<Acl> listAcls(String bucket, BucketSourceOption... options) {
+    Span span =
+        tracer.spanBuilder("listAcls").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.listAcls(bucket, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public List<Acl> listAcls(String bucket) {
+    Span span =
+        tracer.spanBuilder("listAcls").setAttribute("gsutil.uri", fmtBucket(bucket)).startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.listAcls(bucket);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Acl getDefaultAcl(String bucket, Entity entity) {
+    Span span =
+        tracer
+            .spanBuilder("getDefaultAcl")
+            .setAttribute("gsutil.uri", fmtBucket(bucket))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.getDefaultAcl(bucket, entity);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public boolean deleteDefaultAcl(String bucket, Entity entity) {
+    Span span =
+        tracer
+            .spanBuilder("deleteDefaultAcl")
+            .setAttribute("gsutil.uri", fmtBucket(bucket))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.deleteDefaultAcl(bucket, entity);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Acl createDefaultAcl(String bucket, Acl acl) {
+    Span span =
+        tracer
+            .spanBuilder("createDefaultAcl")
+            .setAttribute("gsutil.uri", fmtBucket(bucket))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.createDefaultAcl(bucket, acl);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Acl updateDefaultAcl(String bucket, Acl acl) {
+    Span span =
+        tracer
+            .spanBuilder("updateDefaultAcl")
+            .setAttribute("gsutil.uri", fmtBucket(bucket))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.updateDefaultAcl(bucket, acl);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public List<Acl> listDefaultAcls(String bucket) {
+    Span span =
+        tracer
+            .spanBuilder("listDefaultAcls")
+            .setAttribute("gsutil.uri", fmtBucket(bucket))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.listDefaultAcls(bucket);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Acl getAcl(BlobId blob, Entity entity) {
+    Span span =
+        tracer
+            .spanBuilder("getAcl")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.getAcl(blob, entity);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public boolean deleteAcl(BlobId blob, Entity entity) {
+    Span span =
+        tracer
+            .spanBuilder("deleteAcl")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.deleteAcl(blob, entity);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Acl createAcl(BlobId blob, Acl acl) {
+    Span span =
+        tracer
+            .spanBuilder("createAcl")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.createAcl(blob, acl);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Acl updateAcl(BlobId blob, Acl acl) {
+    Span span =
+        tracer
+            .spanBuilder("updateAcl")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.updateAcl(blob, acl);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public List<Acl> listAcls(BlobId blob) {
+    Span span =
+        tracer
+            .spanBuilder("listAcls")
+            .setAttribute("gsutil.uri", blob.toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.listAcls(blob);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public HmacKey createHmacKey(ServiceAccount serviceAccount, CreateHmacKeyOption... options) {
+    Span span = tracer.spanBuilder("createHmacKey").startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.createHmacKey(serviceAccount, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Page<HmacKeyMetadata> listHmacKeys(ListHmacKeysOption... options) {
+    Span span = tracer.spanBuilder("listHmacKeys").startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.listHmacKeys(options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public HmacKeyMetadata getHmacKey(String accessId, GetHmacKeyOption... options) {
+    Span span = tracer.spanBuilder("getHmacKey").startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.getHmacKey(accessId, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public void deleteHmacKey(HmacKeyMetadata hmacKeyMetadata, DeleteHmacKeyOption... options) {
+    Span span = tracer.spanBuilder("deleteHmacKey").startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      delegate.deleteHmacKey(hmacKeyMetadata, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public HmacKeyMetadata updateHmacKeyState(
+      HmacKeyMetadata hmacKeyMetadata, HmacKeyState state, UpdateHmacKeyOption... options) {
+    Span span = tracer.spanBuilder("updateHmacKeyState").startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.updateHmacKeyState(hmacKeyMetadata, state, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Policy getIamPolicy(String bucket, BucketSourceOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("getIamPolicy")
+            .setAttribute("gsutil.uri", fmtBucket(bucket))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.getIamPolicy(bucket, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Policy setIamPolicy(String bucket, Policy policy, BucketSourceOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("setIamPolicy")
+            .setAttribute("gsutil.uri", fmtBucket(bucket))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.setIamPolicy(bucket, policy, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public List<Boolean> testIamPermissions(
+      String bucket, List<String> permissions, BucketSourceOption... options) {
+    Span span =
+        tracer
+            .spanBuilder("testIamPermissions")
+            .setAttribute("gsutil.uri", fmtBucket(bucket))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.testIamPermissions(bucket, permissions, options);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public ServiceAccount getServiceAccount(String projectId) {
+    Span span = tracer.spanBuilder("getServiceAccount").startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.getServiceAccount(projectId);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Notification createNotification(String bucket, NotificationInfo notificationInfo) {
+    Span span =
+        tracer
+            .spanBuilder("createNotification")
+            .setAttribute("gsutil.uri", fmtBucket(bucket))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.createNotification(bucket, notificationInfo);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public Notification getNotification(String bucket, String notificationId) {
+    Span span =
+        tracer
+            .spanBuilder("getNotification")
+            .setAttribute("gsutil.uri", fmtBucket(bucket))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.getNotification(bucket, notificationId);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public List<Notification> listNotifications(String bucket) {
+    Span span =
+        tracer
+            .spanBuilder("listNotifications")
+            .setAttribute("gsutil.uri", fmtBucket(bucket))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.listNotifications(bucket);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public boolean deleteNotification(String bucket, String notificationId) {
+    Span span =
+        tracer
+            .spanBuilder("deleteNotification")
+            .setAttribute("gsutil.uri", fmtBucket(bucket))
+            .startSpan();
+    try (Scope ignore = span.makeCurrent()) {
+      return delegate.deleteNotification(bucket, notificationId);
+    } catch (Throwable t) {
+      span.recordException(t);
+      span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      span.end();
+    }
+  }
+
+  @Override
+  public void close() throws Exception {
+    delegate.close();
+  }
+
+  @Override
+  @BetaApi
+  public BlobWriteSession blobWriteSession(BlobInfo blobInfo, BlobWriteOption... options) {
+    Span sessionSpan =
+        tracer
+            .spanBuilder("blobWriteSession")
+            .setAttribute("gsutil.uri", blobInfo.getBlobId().toGsUtilUriWithGeneration())
+            .startSpan();
+    try (Scope ignore = sessionSpan.makeCurrent()) {
+      BlobWriteSession session = delegate.blobWriteSession(blobInfo, options);
+      return new OtelDecoratedBlobWriteSession(session);
+    } catch (Throwable t) {
+      sessionSpan.recordException(t);
+      sessionSpan.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+      throw t;
+    } finally {
+      sessionSpan.end();
+    }
+  }
+
+  @Override
+  public StorageOptions getOptions() {
+    return delegate.getOptions();
+  }
+
+  static Storage decorate(Storage delegate, OpenTelemetry otel, Transport transport) {
+    requireNonNull(delegate, "delegate must be non null");
+    requireNonNull(otel, "otel must be non null");
+    if (otel == OpenTelemetry.noop()) {
+      return delegate;
+    }
+    Attributes baseAttributes =
+        Attributes.builder()
+            .put("gcp.client.service", "Storage")
+            .put("gcp.client.version", StorageOptions.getDefaultInstance().getLibraryVersion())
+            .put("gcp.client.repo", "googleapis/java-storage")
+            .put("gcp.client.artifact", "com.google.cloud:google-cloud-storage")
+            .put("rpc.system", transport.toString().toLowerCase(Locale.ROOT))
+            .put("service.name", "storage.googleapis.com")
+            .build();
+    return new OtelStorageDecorator(delegate, otel, baseAttributes);
+  }
+
+  private static @NonNull String fmtBucket(String bucket) {
+    return String.format("gs://%s/", bucket);
+  }
+
+  private static final class TracerDecorator implements Tracer {
+    @Nullable private final Context parentContextOverride;
+    private final Tracer delegate;
+    private final Attributes baseAttributes;
+    private final String spanNamePrefix;
+
+    private TracerDecorator(
+        @Nullable Context parentContextOverride,
+        Tracer delegate,
+        Attributes baseAttributes,
+        String spanNamePrefix) {
+      this.parentContextOverride = parentContextOverride;
+      this.delegate = delegate;
+      this.baseAttributes = baseAttributes;
+      this.spanNamePrefix = spanNamePrefix;
+    }
+
+    private static TracerDecorator decorate(
+        @Nullable Context parentContextOverride,
+        OpenTelemetry otel,
+        Attributes baseAttributes,
+        String spanNamePrefix) {
+      requireNonNull(otel, "otel must be non null");
+      requireNonNull(baseAttributes, "baseAttributes must be non null");
+      requireNonNull(spanNamePrefix, "spanNamePrefix must be non null");
+      Tracer tracer =
+          otel.getTracer(OTEL_SCOPE_NAME, StorageOptions.getDefaultInstance().getLibraryVersion());
+      return new TracerDecorator(parentContextOverride, tracer, baseAttributes, spanNamePrefix);
+    }
+
+    @Override
+    public SpanBuilder spanBuilder(String spanName) {
+      SpanBuilder spanBuilder =
+          delegate.spanBuilder(spanNamePrefix + spanName).setAllAttributes(baseAttributes);
+      if (parentContextOverride != null) {
+        spanBuilder.setParent(parentContextOverride);
+      }
+      return spanBuilder;
+    }
+  }
+
+  private static final class OtelDecoratedReadChannel implements ReadChannel {
+
+    private final ReadChannel reader;
+    private final Span span;
+
+    private OtelDecoratedReadChannel(ReadChannel reader, Span span) {
+      this.reader = reader;
+      this.span = span;
+    }
+
+    @Override
+    public void seek(long position) throws IOException {
+      reader.seek(position);
+    }
+
+    @Override
+    public void setChunkSize(int chunkSize) {
+      reader.setChunkSize(chunkSize);
+    }
+
+    @Override
+    public RestorableState<ReadChannel> capture() {
+      return reader.capture();
+    }
+
+    @Override
+    public ReadChannel limit(long limit) {
+      return reader.limit(limit);
+    }
+
+    @Override
+    public long limit() {
+      return reader.limit();
+    }
+
+    @Override
+    public int read(ByteBuffer dst) throws IOException {
+      return reader.read(dst);
+    }
+
+    @Override
+    public boolean isOpen() {
+      return reader.isOpen();
+    }
+
+    @Override
+    public void close() {
+      try {
+        reader.close();
+      } finally {
+        span.end();
+      }
+    }
+  }
+
+  private final class OtelDecoratedBlobWriteSession implements BlobWriteSession {
+
+    private final BlobWriteSession delegate;
+    private final Tracer tracer;
+
+    public OtelDecoratedBlobWriteSession(BlobWriteSession delegate) {
+      this.delegate = delegate;
+      this.tracer =
+          TracerDecorator.decorate(
+              Context.current(),
+              otel,
+              OtelStorageDecorator.this.baseAttributes,
+              BlobWriteSession.class.getName() + "/");
+    }
+
+    @Override
+    public WritableByteChannel open() throws IOException {
+      Span openSpan = tracer.spanBuilder("open").startSpan();
+      try (Scope ignore = openSpan.makeCurrent()) {
+        WritableByteChannel delegate = this.delegate.open();
+        return new OtelDecoratingWritableByteChannel(delegate, openSpan);
+      } catch (Throwable t) {
+        openSpan.recordException(t);
+        openSpan.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+        throw t;
+      }
+    }
+
+    @Override
+    public ApiFuture<BlobInfo> getResult() {
+      return delegate.getResult();
+    }
+
+    private class OtelDecoratingWritableByteChannel implements WritableByteChannel {
+
+      private final WritableByteChannel delegate;
+      private final Span openSpan;
+
+      private OtelDecoratingWritableByteChannel(WritableByteChannel delegate, Span openSpan) {
+        this.delegate = delegate;
+        this.openSpan = openSpan;
+      }
+
+      @Override
+      public int write(ByteBuffer src) throws IOException {
+        return delegate.write(src);
+      }
+
+      @Override
+      public boolean isOpen() {
+        return delegate.isOpen();
+      }
+
+      @Override
+      public void close() throws IOException {
+        try {
+          delegate.close();
+        } finally {
+          openSpan.end();
+        }
+      }
+    }
+  }
+
+  private static final class OtelDecoratedWriteChannel implements WriteChannel {
+    private final WriteChannel delegate;
+    private final Span openSpan;
+
+    private OtelDecoratedWriteChannel(WriteChannel delegate, Span openSpan) {
+      this.delegate = delegate;
+      this.openSpan = openSpan;
+    }
+
+    @Override
+    public void setChunkSize(int chunkSize) {
+      delegate.setChunkSize(chunkSize);
+    }
+
+    @Override
+    public RestorableState<WriteChannel> capture() {
+      return delegate.capture();
+    }
+
+    @Override
+    public int write(ByteBuffer src) throws IOException {
+      return delegate.write(src);
+    }
+
+    @Override
+    public boolean isOpen() {
+      return delegate.isOpen();
+    }
+
+    @Override
+    public void close() throws IOException {
+      try {
+        delegate.close();
+      } finally {
+        openSpan.end();
+      }
+    }
+  }
+
+  private final class OtelDecoratedCopyWriter extends CopyWriter {
+
+    private final CopyWriter copyWriter;
+    private final Span span;
+    private final Context parentContext;
+    private final Tracer tracer;
+
+    public OtelDecoratedCopyWriter(CopyWriter copyWriter, Span span) {
+      this.copyWriter = copyWriter;
+      this.span = span;
+      this.parentContext = Context.current();
+      this.tracer =
+          TracerDecorator.decorate(
+              Context.current(),
+              otel,
+              OtelStorageDecorator.this.baseAttributes,
+              CopyWriter.class.getName() + "/");
+    }
+
+    @Override
+    public Blob getResult() {
+      try {
+        return copyWriter.getResult();
+      } catch (Throwable t) {
+        span.recordException(t);
+        span.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+        throw t;
+      } finally {
+        span.end();
+      }
+    }
+
+    @Override
+    public long getBlobSize() {
+      return copyWriter.getBlobSize();
+    }
+
+    @Override
+    public boolean isDone() {
+      boolean done = copyWriter.isDone();
+      if (done) {
+        span.end();
+      }
+      return done;
+    }
+
+    @Override
+    public long getTotalBytesCopied() {
+      return copyWriter.getTotalBytesCopied();
+    }
+
+    @Override
+    public RestorableState<CopyWriter> capture() {
+      return copyWriter.capture();
+    }
+
+    @Override
+    public void copyChunk() {
+      Span copyChunkSpan = tracer.spanBuilder("copyChunk").setParent(parentContext).startSpan();
+      try (Scope ignore = copyChunkSpan.makeCurrent()) {
+        copyWriter.copyChunk();
+      } catch (Throwable t) {
+        copyChunkSpan.recordException(t);
+        copyChunkSpan.setStatus(StatusCode.ERROR, t.getClass().getSimpleName());
+        span.end();
+        throw t;
+      } finally {
+        copyChunkSpan.end();
+      }
+    }
+  }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ParallelCompositeUploadWritableByteChannel.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ParallelCompositeUploadWritableByteChannel.java
index db807ce6..b824acf4 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/ParallelCompositeUploadWritableByteChannel.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/ParallelCompositeUploadWritableByteChannel.java
@@ -54,6 +54,7 @@ import com.google.common.collect.ImmutableMap;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
 import io.grpc.Status.Code;
+import io.opentelemetry.context.Context;
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.nio.channels.AsynchronousCloseException;
@@ -145,7 +146,7 @@ final class ParallelCompositeUploadWritableByteChannel implements BufferedWritab
       BlobInfo ultimateObject,
       Opts<ObjectTargetOpt> opts) {
     this.bufferPool = bufferPool;
-    this.exec = exec;
+    this.exec = Context.current().wrap(exec);
     this.partNamingStrategy = partNamingStrategy;
     this.partCleanupStrategy = partCleanupStrategy;
     this.maxElementsPerCompact = maxElementsPerCompact;
@@ -154,7 +155,7 @@ final class ParallelCompositeUploadWritableByteChannel implements BufferedWritab
     this.storage = storage;
     this.ultimateObject = ultimateObject;
     this.opts = opts;
-    this.queue = AsyncAppendingQueue.of(exec, maxElementsPerCompact, this::compose);
+    this.queue = AsyncAppendingQueue.of(this.exec, maxElementsPerCompact, this::compose);
     this.pendingParts = new ArrayList<>();
     // this can be modified by another thread
     this.successfulParts = Collections.synchronizedList(new ArrayList<>());
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOptions.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOptions.java
index 81d787f7..f149db11 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOptions.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOptions.java
@@ -29,6 +29,7 @@ import com.google.cloud.storage.HttpStorageOptions.HttpStorageRpcFactory;
 import com.google.cloud.storage.Storage.BlobWriteOption;
 import com.google.cloud.storage.TransportCompatibility.Transport;
 import com.google.cloud.storage.spi.StorageRpcFactory;
+import io.opentelemetry.api.OpenTelemetry;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.Properties;
@@ -110,6 +111,15 @@ public abstract class StorageOptions extends ServiceOptions<Storage, StorageOpti
     public abstract StorageOptions.Builder setBlobWriteSessionConfig(
         @NonNull BlobWriteSessionConfig blobWriteSessionConfig);
 
+    /**
+     * Enable OpenTelemetry Tracing and provide an instance for the client to use.
+     *
+     * @param openTelemetry User defined instance of OpenTelemetry to be used by the library
+     * @since 2.47.0 This new api is in preview and is subject to breaking changes.
+     */
+    @BetaApi
+    public abstract StorageOptions.Builder setOpenTelemetry(OpenTelemetry openTelemetry);
+
     @Override
     public abstract StorageOptions build();
   }
@@ -144,6 +154,10 @@ public abstract class StorageOptions extends ServiceOptions<Storage, StorageOpti
     return VERSION;
   }
 
+  /** @since 2.47.0 This new api is in preview and is subject to breaking changes. */
+  @BetaApi
+  public abstract OpenTelemetry getOpenTelemetry();
+
   @SuppressWarnings("unchecked")
   @Override
   public abstract StorageOptions.Builder toBuilder();
diff --git a/grpc-google-cloud-storage-v2/pom.xml b/grpc-google-cloud-storage-v2/pom.xml
index 9a8decac..c7c7e50d 100644
--- a/grpc-google-cloud-storage-v2/pom.xml
+++ b/grpc-google-cloud-storage-v2/pom.xml
@@ -4,7 +4,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.api.grpc</groupId>
   <artifactId>grpc-google-cloud-storage-v2</artifactId>
-  <version>2.46.1-beta-SNAPSHOT</version><!-- {x-version-update:grpc-google-cloud-storage-v2:current} -->
+  <version>2.46.1-SNAPSHOT</version><!-- {x-version-update:grpc-google-cloud-storage-v2:current} -->
   <name>grpc-google-cloud-storage-v2</name>
   <description>GRPC library for grpc-google-cloud-storage-v2</description>
   <parent>
diff --git a/pom.xml b/pom.xml
index 603217cd..5754bf8f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -145,17 +145,17 @@
       <dependency>
         <groupId>com.google.api.grpc</groupId>
         <artifactId>proto-google-cloud-storage-v2</artifactId>
-        <version>2.46.1-beta-SNAPSHOT</version><!-- {x-version-update:proto-google-cloud-storage-v2:current} -->
+        <version>2.46.1-SNAPSHOT</version><!-- {x-version-update:proto-google-cloud-storage-v2:current} -->
       </dependency>
       <dependency>
         <groupId>com.google.api.grpc</groupId>
         <artifactId>grpc-google-cloud-storage-v2</artifactId>
-        <version>2.46.1-beta-SNAPSHOT</version><!-- {x-version-update:grpc-google-cloud-storage-v2:current} -->
+        <version>2.46.1-SNAPSHOT</version><!-- {x-version-update:grpc-google-cloud-storage-v2:current} -->
       </dependency>
       <dependency>
         <groupId>com.google.api.grpc</groupId>
         <artifactId>gapic-google-cloud-storage-v2</artifactId>
-        <version>2.46.1-beta-SNAPSHOT</version><!-- {x-version-update:grpc-google-cloud-storage-v2:current} -->
+        <version>2.46.1-SNAPSHOT</version><!-- {x-version-update:grpc-google-cloud-storage-v2:current} -->
       </dependency>
       <dependency>
         <groupId>com.google.api.grpc</groupId>
diff --git a/proto-google-cloud-storage-v2/pom.xml b/proto-google-cloud-storage-v2/pom.xml
index ca878a15..477f45aa 100644
--- a/proto-google-cloud-storage-v2/pom.xml
+++ b/proto-google-cloud-storage-v2/pom.xml
@@ -4,7 +4,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.api.grpc</groupId>
   <artifactId>proto-google-cloud-storage-v2</artifactId>
-  <version>2.46.1-beta-SNAPSHOT</version><!-- {x-version-update:proto-google-cloud-storage-v2:current} -->
+  <version>2.46.1-SNAPSHOT</version><!-- {x-version-update:proto-google-cloud-storage-v2:current} -->
   <name>proto-google-cloud-storage-v2</name>
   <description>PROTO library for proto-google-cloud-storage-v2</description>
   <parent>
diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml
index 0c6d1414..1639090f 100644
--- a/samples/snippets/pom.xml
+++ b/samples/snippets/pom.xml
@@ -31,7 +31,7 @@
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>libraries-bom</artifactId>
-        <version>26.51.0</version>
+        <version>26.52.0</version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
diff --git a/versions.txt b/versions.txt
index 862a0a0c..77f664c8 100644
--- a/versions.txt
+++ b/versions.txt
@@ -2,9 +2,9 @@
 # module:released-version:current-version
 
 google-cloud-storage:2.46.0:2.46.1-SNAPSHOT
-gapic-google-cloud-storage-v2:2.46.0-beta:2.46.1-beta-SNAPSHOT
-grpc-google-cloud-storage-v2:2.46.0-beta:2.46.1-beta-SNAPSHOT
-proto-google-cloud-storage-v2:2.46.0-beta:2.46.1-beta-SNAPSHOT
+gapic-google-cloud-storage-v2:2.46.0:2.46.1-SNAPSHOT
+grpc-google-cloud-storage-v2:2.46.0:2.46.1-SNAPSHOT
+proto-google-cloud-storage-v2:2.46.0:2.46.1-SNAPSHOT
 google-cloud-storage-control:2.46.0:2.46.1-SNAPSHOT
 proto-google-cloud-storage-control-v2:2.46.0:2.46.1-SNAPSHOT
 grpc-google-cloud-storage-control-v2:2.46.0:2.46.1-SNAPSHOT


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ITOpenTelemetryTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITOpenTelemetryTest.java
new file mode 100644
index 00000000..3b8957bb
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITOpenTelemetryTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.cloud.storage.TestUtils.assertAll;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.cloud.storage.TransportCompatibility.Transport;
+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.CrossRun;
+import com.google.cloud.storage.it.runner.annotations.Inject;
+import com.google.cloud.storage.it.runner.registry.Generator;
+import com.google.cloud.storage.otel.TestExporter;
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import io.opentelemetry.sdk.trace.data.SpanData;
+import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(StorageITRunner.class)
+@CrossRun(
+    backends = Backend.PROD,
+    transports = {Transport.HTTP, Transport.GRPC})
+public final class ITOpenTelemetryTest {
+
+  @Inject public Storage storage;
+
+  @Inject public BucketInfo bucket;
+
+  @Inject public Generator generator;
+  @Inject public Transport transport;
+
+  @Test
+  public void checkInstrumentation() throws Exception {
+    TestExporter exporter = new TestExporter();
+
+    OpenTelemetrySdk openTelemetrySdk =
+        OpenTelemetrySdk.builder()
+            .setTracerProvider(
+                SdkTracerProvider.builder()
+                    .addSpanProcessor(SimpleSpanProcessor.create(exporter))
+                    .build())
+            .build();
+    StorageOptions storageOptions =
+        storage.getOptions().toBuilder().setOpenTelemetry(openTelemetrySdk).build();
+    try (Storage storage = storageOptions.getService()) {
+      storage.create(BlobInfo.newBuilder(bucket, generator.randomObjectName()).build());
+    }
+
+    SpanData spanData = exporter.getExportedSpans().get(0);
+    assertAll(
+        () -> assertThat(getAttributeValue(spanData, "gcp.client.service")).isEqualTo("Storage"),
+        () ->
+            assertThat(getAttributeValue(spanData, "gcp.client.repo"))
+                .isEqualTo("googleapis/java-storage"),
+        () ->
+            assertThat(getAttributeValue(spanData, "gcp.client.artifact"))
+                .isEqualTo("com.google.cloud:google-cloud-storage"),
+        () ->
+            assertThat(getAttributeValue(spanData, "rpc.system"))
+                .isEqualTo(transport.name().toLowerCase()));
+  }
+
+  @Test
+  public void noOpDoesNothing() {
+    assertThat(storage.getOptions().getOpenTelemetry()).isSameInstanceAs(OpenTelemetry.noop());
+    storage.create(BlobInfo.newBuilder(bucket, generator.randomObjectName()).build());
+  }
+
+  private static String getAttributeValue(SpanData spanData, String key) {
+    return spanData.getAttributes().get(AttributeKey.stringKey(key));
+  }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ITOpenTelemetryTestbenchTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITOpenTelemetryTestbenchTest.java
new file mode 100644
index 00000000..f813fcf4
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ITOpenTelemetryTestbenchTest.java
@@ -0,0 +1,217 @@
+/*
+ * 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 static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.cloud.ReadChannel;
+import com.google.cloud.WriteChannel;
+import com.google.cloud.storage.Storage.BlobSourceOption;
+import com.google.cloud.storage.Storage.BlobTargetOption;
+import com.google.cloud.storage.Storage.CopyRequest;
+import com.google.cloud.storage.TransportCompatibility.Transport;
+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.CrossRun;
+import com.google.cloud.storage.it.runner.annotations.Inject;
+import com.google.cloud.storage.it.runner.registry.Generator;
+import com.google.cloud.storage.otel.TestExporter;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import io.opentelemetry.sdk.trace.data.SpanData;
+import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
+import io.opentelemetry.sdk.trace.export.SpanExporter;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.UUID;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(StorageITRunner.class)
+@CrossRun(
+    backends = Backend.TEST_BENCH,
+    transports = {Transport.HTTP, Transport.GRPC})
+public class ITOpenTelemetryTestbenchTest {
+  @Inject public Transport transport;
+  @Inject public Generator generator;
+  @Inject public BucketInfo testBucket;
+  @Inject public Storage storage;
+  private SpanExporter exporter;
+  private static final byte[] helloWorldTextBytes = "hello world".getBytes();
+  private BlobId blobId;
+  private static final Path tmpDir = Paths.get(System.getProperty("java.io.tmpdir"));
+
+  @Before
+  public void setUp() {
+    exporter = new TestExporter();
+    OpenTelemetrySdk openTelemetrySdk =
+        OpenTelemetrySdk.builder()
+            .setTracerProvider(
+                SdkTracerProvider.builder()
+                    .addSpanProcessor(SimpleSpanProcessor.create(exporter))
+                    .build())
+            .build();
+    StorageOptions options =
+        storage.getOptions().toBuilder().setOpenTelemetry(openTelemetrySdk).build();
+    storage = options.getService();
+    String objectString = generator.randomObjectName();
+    blobId = BlobId.of(testBucket.getName(), objectString);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (storage != null) {
+      storage.close();
+    }
+  }
+
+  @Test
+  public void runCreateBucket() {
+    String bucket = "random-bucket" + UUID.randomUUID();
+    storage.create(BucketInfo.of(bucket));
+    TestExporter testExported = (TestExporter) exporter;
+    List<SpanData> spanData = testExported.getExportedSpans();
+    checkCommonAttributes(spanData);
+  }
+
+  @Test
+  public void runCreateBlob() {
+    byte[] content = "Hello, World!".getBytes(UTF_8);
+    BlobId toCreate = BlobId.of(testBucket.getName(), generator.randomObjectName());
+    storage.create(BlobInfo.newBuilder(toCreate).build(), content);
+    TestExporter testExported = (TestExporter) exporter;
+    List<SpanData> spanData = testExported.getExportedSpans();
+    checkCommonAttributes(spanData);
+    Assert.assertTrue(spanData.stream().anyMatch(x -> x.getName().contains("create")));
+  }
+
+  @Test
+  public void runReadAllBytes() {
+    BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setContentType("text/plain").build();
+    storage.create(blobInfo, helloWorldTextBytes);
+    byte[] read = storage.readAllBytes(blobId);
+    TestExporter testExported = (TestExporter) exporter;
+    List<SpanData> spanData = testExported.getExportedSpans();
+    checkCommonAttributes(spanData);
+    Assert.assertTrue(spanData.stream().anyMatch(x -> x.getName().contains("readAllBytes")));
+  }
+
+  @Test
+  public void runCreateFrom() throws IOException {
+    Path helloWorldTxtGz = File.createTempFile(blobId.getName(), ".txt.gz").toPath();
+    storage.createFrom(BlobInfo.newBuilder(blobId).build(), helloWorldTxtGz);
+    TestExporter testExported = (TestExporter) exporter;
+    List<SpanData> spanData = testExported.getExportedSpans();
+    checkCommonAttributes(spanData);
+    Assert.assertTrue(spanData.stream().anyMatch(x -> x.getName().contains("createFrom")));
+  }
+
+  @Test
+  public void runDownloadToPath() throws IOException {
+    BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setContentType("text/plain").build();
+    storage.create(blobInfo, helloWorldTextBytes);
+    try (TmpFile file = TmpFile.of(tmpDir, "download-to", ".txt")) {
+      storage.downloadTo(blobId, file.getPath());
+      TestExporter testExported = (TestExporter) exporter;
+      List<SpanData> spanData = testExported.getExportedSpans();
+      checkCommonAttributes(spanData);
+      Assert.assertTrue(spanData.stream().anyMatch(x -> x.getName().contains("downloadTo")));
+    }
+  }
+
+  @Test
+  public void runDownloadToOutputStream() {
+    BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setContentType("text/plain").build();
+    storage.create(blobInfo, helloWorldTextBytes);
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    storage.downloadTo(blobId, baos);
+    TestExporter testExported = (TestExporter) exporter;
+    List<SpanData> spanData = testExported.getExportedSpans();
+    checkCommonAttributes(spanData);
+    Assert.assertTrue(spanData.stream().anyMatch(x -> x.getName().contains("downloadTo")));
+  }
+
+  @Test
+  public void runCopy() {
+    BlobInfo info =
+        BlobInfo.newBuilder(testBucket, generator.randomObjectName() + "copy/src").build();
+    Blob cpySrc = storage.create(info, helloWorldTextBytes, BlobTargetOption.doesNotExist());
+    BlobInfo dst =
+        BlobInfo.newBuilder(testBucket, generator.randomObjectName() + "copy/dst").build();
+    CopyRequest copyRequest =
+        CopyRequest.newBuilder()
+            .setSource(cpySrc.getBlobId())
+            .setSourceOptions(BlobSourceOption.generationMatch(cpySrc.getGeneration()))
+            .setTarget(dst, BlobTargetOption.doesNotExist())
+            .build();
+    CopyWriter copyWriter = storage.copy(copyRequest);
+    BlobInfo result = copyWriter.getResult();
+    assertThat(result).isNotNull();
+    TestExporter testExported = (TestExporter) exporter;
+    List<SpanData> spanData = testExported.getExportedSpans();
+    checkCommonAttributes(spanData);
+    Assert.assertTrue(spanData.stream().anyMatch(x -> x.getName().contains("copy")));
+  }
+
+  @Test
+  public void runWriter() throws IOException {
+    BlobInfo info = BlobInfo.newBuilder(testBucket, generator.randomObjectName()).build();
+    try (WriteChannel writer = storage.writer(info)) {
+      // Do nothing
+    }
+    TestExporter testExported = (TestExporter) exporter;
+    List<SpanData> spanData = testExported.getExportedSpans();
+    checkCommonAttributes(spanData);
+    Assert.assertTrue(spanData.stream().anyMatch(x -> x.getName().contains("writer")));
+  }
+
+  @Test
+  public void runReader() throws IOException {
+    BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setContentType("text/plain").build();
+    storage.create(blobInfo, helloWorldTextBytes);
+    try (ReadChannel reader = storage.reader(blobId)) {
+      // Do nothing
+    }
+    TestExporter testExported = (TestExporter) exporter;
+    List<SpanData> spanData = testExported.getExportedSpans();
+    checkCommonAttributes(spanData);
+    Assert.assertTrue(spanData.stream().anyMatch(x -> x.getName().contains("reader")));
+  }
+
+  private void checkCommonAttributes(List<SpanData> spanData) {
+    for (SpanData span : spanData) {
+      Assert.assertEquals("Storage", getAttributeValue(span, "gcp.client.service"));
+      Assert.assertEquals("googleapis/java-storage", getAttributeValue(span, "gcp.client.repo"));
+      Assert.assertEquals(
+          "com.google.cloud:google-cloud-storage", getAttributeValue(span, "gcp.client.artifact"));
+      Assert.assertEquals(transport.name().toLowerCase(), getAttributeValue(span, "rpc.system"));
+    }
+  }
+
+  private String getAttributeValue(SpanData spanData, String key) {
+    return spanData.getAttributes().get(AttributeKey.stringKey(key)).toString();
+  }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITObjectTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITObjectTest.java
index 7a88da5e..46a1099a 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITObjectTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITObjectTest.java
@@ -39,6 +39,7 @@ import com.google.cloud.storage.BlobInfo;
 import com.google.cloud.storage.Bucket;
 import com.google.cloud.storage.BucketInfo;
 import com.google.cloud.storage.CopyWriter;
+import com.google.cloud.storage.DataGenerator;
 import com.google.cloud.storage.PackagePrivateMethodWorkarounds;
 import com.google.cloud.storage.Storage;
 import com.google.cloud.storage.Storage.BlobField;
@@ -62,6 +63,7 @@ import com.google.cloud.storage.it.runner.annotations.BucketType;
 import com.google.cloud.storage.it.runner.annotations.CrossRun;
 import com.google.cloud.storage.it.runner.annotations.CrossRun.Exclude;
 import com.google.cloud.storage.it.runner.annotations.Inject;
+import com.google.cloud.storage.it.runner.annotations.ParallelFriendly;
 import com.google.cloud.storage.it.runner.registry.Generator;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -90,6 +92,7 @@ import java.util.Optional;
 import java.util.Random;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
@@ -101,6 +104,7 @@ import org.junit.runner.RunWith;
 @CrossRun(
     transports = {Transport.HTTP, Transport.GRPC},
     backends = {Backend.PROD})
+@ParallelFriendly
 public class ITObjectTest {
 
   private static final String CONTENT_TYPE = "text/plain";
@@ -129,6 +133,10 @@ public class ITObjectTest {
   @BucketFixture(BucketType.REQUESTER_PAYS)
   public BucketInfo requesterPaysBucket;
 
+  @Inject
+  @BucketFixture(BucketType.VERSIONED)
+  public BucketInfo versionedBucket;
+
   @Inject public Storage storage;
 
   @Test
@@ -319,7 +327,7 @@ public class ITObjectTest {
 
   @Test
   public void testGetBlobFailNonExistingGeneration() {
-    String blobName = "test-get-blob-fail-non-existing-generation";
+    String blobName = generator.randomObjectName();
     BlobInfo blob = BlobInfo.newBuilder(bucket, blobName).build();
     Blob remoteBlob = storage.create(blob);
     assertNotNull(remoteBlob);
@@ -422,98 +430,132 @@ public class ITObjectTest {
 
   @Test
   public void testListBlobsVersioned() throws ExecutionException, InterruptedException {
-    String bucketName = generator.randomBucketName();
-    Bucket bucket =
-        storage.create(BucketInfo.newBuilder(bucketName).setVersioningEnabled(true).build());
-    try {
-      String[] blobNames = {"test-list-blobs-versioned-blob1", "test-list-blobs-versioned-blob2"};
-      BlobInfo blob1 =
-          BlobInfo.newBuilder(bucket, blobNames[0]).setContentType(CONTENT_TYPE).build();
-      BlobInfo blob2 =
-          BlobInfo.newBuilder(bucket, blobNames[1]).setContentType(CONTENT_TYPE).build();
-      Blob remoteBlob1 = storage.create(blob1);
-      Blob remoteBlob2 = storage.create(blob2);
-      Blob remoteBlob3 = storage.create(blob2);
-      assertNotNull(remoteBlob1);
-      assertNotNull(remoteBlob2);
-      assertNotNull(remoteBlob3);
-      Page<Blob> page =
+    String bucketName = versionedBucket.getName();
+    String baseName = generator.randomObjectName();
+    String[] blobNames = {baseName + "-blob1", baseName + "-blob2"};
+    BlobInfo blob1 =
+        BlobInfo.newBuilder(versionedBucket, blobNames[0]).setContentType(CONTENT_TYPE).build();
+    BlobInfo blob2 =
+        BlobInfo.newBuilder(versionedBucket, blobNames[1]).setContentType(CONTENT_TYPE).build();
+    Blob remoteBlob1 = storage.create(blob1);
+    Blob remoteBlob2 = storage.create(blob2);
+    Blob remoteBlob3 = storage.create(blob2);
+    assertNotNull(remoteBlob1);
+    assertNotNull(remoteBlob2);
+    assertNotNull(remoteBlob3);
+    Page<Blob> page =
+        storage.list(
+            bucketName, BlobListOption.prefix(baseName + "-blob"), BlobListOption.versions(true));
+    // https://cloud.google.com/storage/docs/consistency#strongly_consistent_operations
+    // enabling versioning on an existing bucket seems to have some backpressure on when new
+    // versions can safely be made, but listing is not eventually consistent.
+
+    // TODO: make hermetic
+    // Listing blobs is eventually consistent, we loop until the list is of the expected size. The
+    // test fails if timeout is reached.
+    while (Iterators.size(page.iterateAll().iterator()) != 3) {
+      Thread.sleep(500);
+      page =
           storage.list(
-              bucketName,
-              BlobListOption.prefix("test-list-blobs-versioned-blob"),
-              BlobListOption.versions(true));
-      // https://cloud.google.com/storage/docs/consistency#strongly_consistent_operations
-      // enabling versioning on an existing bucket seems to have some backpressure on when new
-      // versions can safely be made, but listing is not eventually consistent.
-
-      // TODO: make hermetic
-      // Listing blobs is eventually consistent, we loop until the list is of the expected size. The
-      // test fails if timeout is reached.
-      while (Iterators.size(page.iterateAll().iterator()) != 3) {
-        Thread.sleep(500);
-        page =
-            storage.list(
-                bucketName,
-                BlobListOption.prefix("test-list-blobs-versioned-blob"),
-                BlobListOption.versions(true));
-      }
-      Set<String> blobSet = ImmutableSet.of(blobNames[0], blobNames[1]);
-      Iterator<Blob> iterator = page.iterateAll().iterator();
-      while (iterator.hasNext()) {
-        Blob remoteBlob = iterator.next();
-        assertEquals(bucketName, remoteBlob.getBucket());
-        assertTrue(blobSet.contains(remoteBlob.getName()));
-        assertNotNull(remoteBlob.getGeneration());
-      }
-    } finally {
-      BucketCleaner.doCleanup(bucketName, storage);
+              bucketName, BlobListOption.prefix(baseName + "-blob"), BlobListOption.versions(true));
+    }
+    Set<String> blobSet = ImmutableSet.of(blobNames[0], blobNames[1]);
+    Iterator<Blob> iterator = page.iterateAll().iterator();
+    while (iterator.hasNext()) {
+      Blob remoteBlob = iterator.next();
+      assertEquals(bucketName, remoteBlob.getBucket());
+      assertTrue(blobSet.contains(remoteBlob.getName()));
+      assertNotNull(remoteBlob.getGeneration());
     }
   }
 
   @Test
-  public void testListBlobsWithOffset() throws ExecutionException, InterruptedException {
-    String bucketName = generator.randomBucketName();
-    Bucket bucket =
-        storage.create(BucketInfo.newBuilder(bucketName).setVersioningEnabled(true).build());
-    try {
-      List<String> blobNames =
-          ImmutableList.of("startOffset_blob1", "startOffset_blob2", "blob3_endOffset");
-      BlobInfo blob1 =
-          BlobInfo.newBuilder(bucket, blobNames.get(0)).setContentType(CONTENT_TYPE).build();
-      BlobInfo blob2 =
-          BlobInfo.newBuilder(bucket, blobNames.get(1)).setContentType(CONTENT_TYPE).build();
-      BlobInfo blob3 =
-          BlobInfo.newBuilder(bucket, blobNames.get(2)).setContentType(CONTENT_TYPE).build();
-
-      Blob remoteBlob1 = storage.create(blob1);
-      Blob remoteBlob2 = storage.create(blob2);
-      Blob remoteBlob3 = storage.create(blob3);
-      assertNotNull(remoteBlob1);
-      assertNotNull(remoteBlob2);
-      assertNotNull(remoteBlob3);
-
-      // Listing blobs without BlobListOptions.
-      Page<Blob> page1 = storage.list(bucketName);
-      assertEquals(3, Iterators.size(page1.iterateAll().iterator()));
-
-      // Listing blobs with startOffset.
-      Page<Blob> page2 = storage.list(bucketName, BlobListOption.startOffset("startOffset"));
-      assertEquals(2, Iterators.size(page2.iterateAll().iterator()));
-
-      // Listing blobs with endOffset.
-      Page<Blob> page3 = storage.list(bucketName, BlobListOption.endOffset("endOffset"));
-      assertEquals(1, Iterators.size(page3.iterateAll().iterator()));
-
-      // Listing blobs with startOffset and endOffset.
-      Page<Blob> page4 =
-          storage.list(
-              bucketName,
-              BlobListOption.startOffset("startOffset"),
-              BlobListOption.endOffset("endOffset"));
-      assertEquals(0, Iterators.size(page4.iterateAll().iterator()));
-    } finally {
-      BucketCleaner.doCleanup(bucketName, storage);
-    }
+  public void testListBlobsWithOffset() throws Exception {
+    String bucketName = bucket.getName();
+    String baseName = generator.randomObjectName();
+
+    List<BlobId> blobs =
+        IntStream.rangeClosed(0, 2)
+            .mapToObj(i -> baseName + "-" + i)
+            .map(n -> BlobInfo.newBuilder(bucket, n).build())
+            .map(info -> storage.create(info, BlobTargetOption.doesNotExist()))
+            .map(BlobInfo::getBlobId)
+            .collect(Collectors.toList());
+
+    assertAll(
+        () -> {
+          // Listing blobs without BlobListOptions.
+          Page<Blob> page1 = storage.list(bucketName, BlobListOption.prefix(baseName));
+          assertThat(
+                  page1
+                      .streamAll()
+                      .map(BlobInfo::getBlobId)
+                      .map(BlobId::toGsUtilUriWithGeneration)
+                      .collect(Collectors.toList()))
+              .isEqualTo(
+                  blobs.stream()
+                      .map(BlobId::toGsUtilUriWithGeneration)
+                      .collect(Collectors.toList()));
+        },
+        () -> {
+          // Listing blobs starting from 1.
+          Page<Blob> page2 =
+              storage.list(
+                  bucketName,
+                  BlobListOption.prefix(baseName),
+                  BlobListOption.startOffset(blobs.get(1).getName()));
+          assertThat(
+                  page2
+                      .streamAll()
+                      .map(BlobInfo::getBlobId)
+                      .map(BlobId::toGsUtilUriWithGeneration)
+                      .collect(Collectors.toList()))
+              .isEqualTo(
+                  blobs.stream()
+                      .skip(1)
+                      .map(BlobId::toGsUtilUriWithGeneration)
+                      .collect(Collectors.toList()));
+        },
+        () -> {
+          // Listing blobs until 2.
+          Page<Blob> page3 =
+              storage.list(
+                  bucketName,
+                  BlobListOption.prefix(baseName),
+                  BlobListOption.endOffset(blobs.get(2).getName()));
+          assertThat(
+                  page3
+                      .streamAll()
+                      .map(BlobInfo::getBlobId)
+                      .map(BlobId::toGsUtilUriWithGeneration)
+                      .collect(Collectors.toList()))
+              .isEqualTo(
+                  blobs.stream()
+                      .limit(2)
+                      .map(BlobId::toGsUtilUriWithGeneration)
+                      .collect(Collectors.toList()));
+        },
+        () -> {
+          // Listing blobs with startOffset and endOffset.
+          Page<Blob> page4 =
+              storage.list(
+                  bucketName,
+                  BlobListOption.prefix(baseName),
+                  BlobListOption.startOffset(blobs.get(1).getName()),
+                  BlobListOption.endOffset(blobs.get(2).getName()));
+          assertThat(
+                  page4
+                      .streamAll()
+                      .map(BlobInfo::getBlobId)
+                      .map(BlobId::toGsUtilUriWithGeneration)
+                      .collect(Collectors.toList()))
+              .isEqualTo(
+                  blobs.stream()
+                      .skip(1)
+                      .limit(1)
+                      .map(BlobId::toGsUtilUriWithGeneration)
+                      .collect(Collectors.toList()));
+        });
   }
 
   @Test
@@ -546,7 +588,7 @@ public class ITObjectTest {
     ImmutableSet<BlobInfo> actual =
         blobs.stream()
             .map(Blob::asBlobInfo)
-            .map(info -> PackagePrivateMethodWorkarounds.noAcl(info))
+            .map(PackagePrivateMethodWorkarounds::noAcl)
             .collect(ImmutableSet.toImmutableSet());
 
     // obj1Gen1 is "in subdirectory" and we don't expect to receive it as a result when listing
@@ -562,56 +604,48 @@ public class ITObjectTest {
   }
 
   @Test
-  // When gRPC support is added for matchGlob, enable this test for gRPC.
   public void testListBlobsWithMatchGlob() throws Exception {
-    BucketInfo bucketInfo = BucketInfo.newBuilder(generator.randomBucketName()).build();
-    try (TemporaryBucket tempBucket =
-        TemporaryBucket.newBuilder().setBucketInfo(bucketInfo).setStorage(storage).build()) {
-      BucketInfo bucket = tempBucket.getBucket();
-      assertNotNull(storage.create(BlobInfo.newBuilder(bucket, "foo/bar").build()));
-      assertNotNull(storage.create(BlobInfo.newBuilder(bucket, "foo/baz").build()));
-      assertNotNull(storage.create(BlobInfo.newBuilder(bucket, "foo/foobar").build()));
-      assertNotNull(storage.create(BlobInfo.newBuilder(bucket, "foobar").build()));
-
-      Page<Blob> page1 = storage.list(bucket.getName(), BlobListOption.matchGlob("foo*bar"));
-      Page<Blob> page2 = storage.list(bucket.getName(), BlobListOption.matchGlob("foo**bar"));
-      Page<Blob> page3 = storage.list(bucket.getName(), BlobListOption.matchGlob("**/foobar"));
-      Page<Blob> page4 = storage.list(bucket.getName(), BlobListOption.matchGlob("*/ba[rz]"));
-      Page<Blob> page5 = storage.list(bucket.getName(), BlobListOption.matchGlob("*/ba[!a-y]"));
-      Page<Blob> page6 =
-          storage.list(bucket.getName(), BlobListOption.matchGlob("**/{foobar,baz}"));
-      Page<Blob> page7 =
-          storage.list(bucket.getName(), BlobListOption.matchGlob("foo/{foo*,*baz}"));
-      assertAll(
-          () ->
-              assertThat(Iterables.transform(page1.iterateAll(), blob -> blob.getName()))
-                  .containsExactly("foobar")
-                  .inOrder(),
-          () ->
-              assertThat(Iterables.transform(page2.iterateAll(), blob -> blob.getName()))
-                  .containsExactly("foo/bar", "foo/foobar", "foobar")
-                  .inOrder(),
-          () ->
-              assertThat(Iterables.transform(page3.iterateAll(), blob -> blob.getName()))
-                  .containsExactly("foo/foobar", "foobar")
-                  .inOrder(),
-          () ->
-              assertThat(Iterables.transform(page4.iterateAll(), blob -> blob.getName()))
-                  .containsExactly("foo/bar", "foo/baz")
-                  .inOrder(),
-          () ->
-              assertThat(Iterables.transform(page5.iterateAll(), blob -> blob.getName()))
-                  .containsExactly("foo/baz")
-                  .inOrder(),
-          () ->
-              assertThat(Iterables.transform(page6.iterateAll(), blob -> blob.getName()))
-                  .containsExactly("foo/baz", "foo/foobar", "foobar")
-                  .inOrder(),
-          () ->
-              assertThat(Iterables.transform(page7.iterateAll(), blob -> blob.getName()))
-                  .containsExactly("foo/baz", "foo/foobar")
-                  .inOrder());
-    }
+    assertNotNull(storage.create(BlobInfo.newBuilder(bucket, "foo/bar").build()));
+    assertNotNull(storage.create(BlobInfo.newBuilder(bucket, "foo/baz").build()));
+    assertNotNull(storage.create(BlobInfo.newBuilder(bucket, "foo/foobar").build()));
+    assertNotNull(storage.create(BlobInfo.newBuilder(bucket, "foobar").build()));
+
+    Page<Blob> page1 = storage.list(bucket.getName(), BlobListOption.matchGlob("foo*bar"));
+    Page<Blob> page2 = storage.list(bucket.getName(), BlobListOption.matchGlob("foo**bar"));
+    Page<Blob> page3 = storage.list(bucket.getName(), BlobListOption.matchGlob("**/foobar"));
+    Page<Blob> page4 = storage.list(bucket.getName(), BlobListOption.matchGlob("*/ba[rz]"));
+    Page<Blob> page5 = storage.list(bucket.getName(), BlobListOption.matchGlob("*/ba[!a-y]"));
+    Page<Blob> page6 = storage.list(bucket.getName(), BlobListOption.matchGlob("**/{foobar,baz}"));
+    Page<Blob> page7 = storage.list(bucket.getName(), BlobListOption.matchGlob("foo/{foo*,*baz}"));
+    assertAll(
+        () ->
+            assertThat(Iterables.transform(page1.iterateAll(), BlobInfo::getName))
+                .containsExactly("foobar")
+                .inOrder(),
+        () ->
+            assertThat(Iterables.transform(page2.iterateAll(), BlobInfo::getName))
+                .containsExactly("foo/bar", "foo/foobar", "foobar")
+                .inOrder(),
+        () ->
+            assertThat(Iterables.transform(page3.iterateAll(), BlobInfo::getName))
+                .containsExactly("foo/foobar", "foobar")
+                .inOrder(),
+        () ->
+            assertThat(Iterables.transform(page4.iterateAll(), BlobInfo::getName))
+                .containsExactly("foo/bar", "foo/baz")
+                .inOrder(),
+        () ->
+            assertThat(Iterables.transform(page5.iterateAll(), BlobInfo::getName))
+                .containsExactly("foo/baz")
+                .inOrder(),
+        () ->
+            assertThat(Iterables.transform(page6.iterateAll(), BlobInfo::getName))
+                .containsExactly("foo/baz", "foo/foobar", "foobar")
+                .inOrder(),
+        () ->
+            assertThat(Iterables.transform(page7.iterateAll(), BlobInfo::getName))
+                .containsExactly("foo/baz", "foo/foobar")
+                .inOrder());
   }
 
   @Test
@@ -623,7 +657,7 @@ public class ITObjectTest {
             .mapToObj(i -> String.format("%s/%2d", basePath, i))
             .map(name -> BlobInfo.newBuilder(bucket, name).build())
             .map(info -> storage.create(info, BlobTargetOption.doesNotExist()))
-            .map(info1 -> PackagePrivateMethodWorkarounds.noAcl(info1))
+            .map(PackagePrivateMethodWorkarounds::noAcl)
             .collect(ImmutableList.toImmutableList());
 
     Page<Blob> page =
@@ -631,7 +665,7 @@ public class ITObjectTest {
 
     ImmutableList<BlobInfo> actual =
         ImmutableList.copyOf(page.iterateAll()).stream()
-            .map(info -> PackagePrivateMethodWorkarounds.noAcl(info))
+            .map(PackagePrivateMethodWorkarounds::noAcl)
             .collect(ImmutableList.toImmutableList());
 
     try {
@@ -644,7 +678,7 @@ public class ITObjectTest {
 
   @Test
   public void testUpdateBlob() {
-    String blobName = "test-update-blob";
+    String blobName = generator.randomObjectName();
     BlobInfo blob = BlobInfo.newBuilder(bucket, blobName).build();
     Blob remoteBlob = storage.create(blob);
     assertNotNull(remoteBlob);
@@ -657,7 +691,7 @@ public class ITObjectTest {
 
   @Test
   public void testUpdateBlobReplaceMetadata() {
-    String blobName = "test-update-blob-replace-metadata";
+    String blobName = generator.randomObjectName();
     ImmutableMap<String, String> metadata = ImmutableMap.of("k1", "a");
     ImmutableMap<String, String> newMetadata = ImmutableMap.of("k2", "b");
     BlobInfo blob =
@@ -678,7 +712,7 @@ public class ITObjectTest {
 
   @Test
   public void testUpdateBlobMergeMetadata() {
-    String blobName = "test-update-blob-merge-metadata";
+    String blobName = generator.randomObjectName();
     ImmutableMap<String, String> metadata = ImmutableMap.of("k1", "a");
     ImmutableMap<String, String> newMetadata = ImmutableMap.of("k2", "b");
     ImmutableMap<String, String> expectedMetadata = ImmutableMap.of("k1", "a", "k2", "b");
@@ -699,7 +733,7 @@ public class ITObjectTest {
   @Test
   public void testUpdateBlobUnsetMetadata() {
 
-    String blobName = "test-update-blob-unset-metadata";
+    String blobName = generator.randomObjectName();
     ImmutableMap<String, String> metadata = ImmutableMap.of("k1", "a", "k2", "b");
     Map<String, String> newMetadata = new HashMap<>();
     newMetadata.put("k1", "a");
@@ -721,7 +755,7 @@ public class ITObjectTest {
 
   @Test
   public void testUpdateBlobFail() {
-    String blobName = "test-update-blob-fail";
+    String blobName = generator.randomObjectName();
     BlobInfo blob = BlobInfo.newBuilder(bucket, blobName).build();
     Blob remoteBlob = storage.create(blob);
     assertNotNull(remoteBlob);
@@ -737,13 +771,13 @@ public class ITObjectTest {
 
   @Test
   public void testDeleteNonExistingBlob() {
-    String blobName = "test-delete-non-existing-blob";
+    String blobName = generator.randomObjectName();
     assertFalse(storage.delete(bucket.getName(), blobName));
   }
 
   @Test
   public void testDeleteBlobNonExistingGeneration() {
-    String blobName = "test-delete-blob-non-existing-generation";
+    String blobName = generator.randomObjectName();
     BlobInfo blob = BlobInfo.newBuilder(bucket, blobName).build();
     assertNotNull(storage.create(blob));
     try {
@@ -756,7 +790,7 @@ public class ITObjectTest {
 
   @Test
   public void testDeleteBlobFail() {
-    String blobName = "test-delete-blob-fail";
+    String blobName = generator.randomObjectName();
     BlobInfo blob = BlobInfo.newBuilder(bucket, blobName).build();
     Blob remoteBlob = storage.create(blob);
     assertNotNull(remoteBlob);
@@ -771,15 +805,16 @@ public class ITObjectTest {
 
   @Test
   public void testComposeBlob() {
-    String sourceBlobName1 = "test-compose-blob-source-1";
-    String sourceBlobName2 = "test-compose-blob-source-2";
+    String baseName = generator.randomObjectName();
+    String sourceBlobName1 = baseName + "-1";
+    String sourceBlobName2 = baseName + "-2";
     BlobInfo sourceBlob1 = BlobInfo.newBuilder(bucket, sourceBlobName1).build();
     BlobInfo sourceBlob2 = BlobInfo.newBuilder(bucket, sourceBlobName2).build();
     Blob remoteSourceBlob1 = storage.create(sourceBlob1, BLOB_BYTE_CONTENT);
     Blob remoteSourceBlob2 = storage.create(sourceBlob2, BLOB_BYTE_CONTENT);
     assertNotNull(remoteSourceBlob1);
     assertNotNull(remoteSourceBlob2);
-    String targetBlobName = "test-compose-blob-target";
+    String targetBlobName = baseName + "-target";
     BlobInfo targetBlob = BlobInfo.newBuilder(bucket, targetBlobName).build();
     ComposeRequest req =
         ComposeRequest.of(ImmutableList.of(sourceBlobName1, sourceBlobName2), targetBlob);
@@ -796,15 +831,16 @@ public class ITObjectTest {
 
   @Test
   public void testComposeBlobWithContentType() {
-    String sourceBlobName1 = "test-compose-blob-with-content-type-source-1";
-    String sourceBlobName2 = "test-compose-blob-with-content-type-source-2";
+    String baseName = generator.randomObjectName();
+    String sourceBlobName1 = baseName + "-source-1";
+    String sourceBlobName2 = baseName + "-source-2";
     BlobInfo sourceBlob1 = BlobInfo.newBuilder(bucket, sourceBlobName1).build();
     BlobInfo sourceBlob2 = BlobInfo.newBuilder(bucket, sourceBlobName2).build();
     Blob remoteSourceBlob1 = storage.create(sourceBlob1, BLOB_BYTE_CONTENT);
     Blob remoteSourceBlob2 = storage.create(sourceBlob2, BLOB_BYTE_CONTENT);
     assertNotNull(remoteSourceBlob1);
     assertNotNull(remoteSourceBlob2);
-    String targetBlobName = "test-compose-blob-with-content-type-target";
+    String targetBlobName = baseName + "-target";
     BlobInfo targetBlob =
         BlobInfo.newBuilder(bucket, targetBlobName).setContentType(CONTENT_TYPE).build();
     ComposeRequest req =
@@ -823,15 +859,16 @@ public class ITObjectTest {
 
   @Test
   public void testComposeBlobFail() {
-    String sourceBlobName1 = "test-compose-blob-fail-source-1";
-    String sourceBlobName2 = "test-compose-blob-fail-source-2";
+    String baseName = generator.randomObjectName();
+    String sourceBlobName1 = baseName + "-source-1";
+    String sourceBlobName2 = baseName + "-source-2";
     BlobInfo sourceBlob1 = BlobInfo.newBuilder(bucket, sourceBlobName1).build();
     BlobInfo sourceBlob2 = BlobInfo.newBuilder(bucket, sourceBlobName2).build();
     Blob remoteSourceBlob1 = storage.create(sourceBlob1);
     Blob remoteSourceBlob2 = storage.create(sourceBlob2);
     assertNotNull(remoteSourceBlob1);
     assertNotNull(remoteSourceBlob2);
-    String targetBlobName = "test-compose-blob-fail-target";
+    String targetBlobName = baseName + "-target";
     BlobInfo targetBlob = BlobInfo.newBuilder(bucket, targetBlobName).build();
     ComposeRequest req =
         ComposeRequest.newBuilder()
@@ -848,8 +885,6 @@ public class ITObjectTest {
   }
 
   @Test
-  // Bucket attribute extration on allowlist bug b/246634709
-  @Exclude(transports = Transport.GRPC)
   public void testCopyBlob() {
 
     String sourceBlobName = generator.randomObjectName() + "-source";
@@ -872,8 +907,35 @@ public class ITObjectTest {
   }
 
   @Test
-  // Bucket attribute extration on allowlist bug b/246634709
-  @Exclude(transports = Transport.GRPC)
+  public void copyBlob_classChange_multipleChunks() {
+
+    String sourceBlobName = generator.randomObjectName() + "-source";
+    BlobId source = BlobId.of(bucket.getName(), sourceBlobName);
+    ImmutableMap<String, String> metadata = ImmutableMap.of("k", "v");
+    BlobInfo blob = BlobInfo.newBuilder(source).setMetadata(metadata).build();
+    int _5MiB = 5 * 1024 * 1024;
+    byte[] bytes = DataGenerator.base64Characters().genBytes(_5MiB);
+    Blob remoteBlob = storage.create(blob, bytes);
+    assertThat(remoteBlob).isNotNull();
+    String targetBlobName = generator.randomObjectName() + "-target";
+    CopyRequest req =
+        CopyRequest.newBuilder()
+            .setSource(source)
+            .setTarget(
+                BlobInfo.newBuilder(bucket, targetBlobName)
+                    // change the storage class to force GCS to copy bytes
+                    .setStorageClass(StorageClass.NEARLINE)
+                    .build(),
+                BlobTargetOption.doesNotExist())
+            .setMegabytesCopiedPerChunk(2L)
+            .build();
+    CopyWriter copyWriter = storage.copy(req);
+    BlobInfo remoteBlob2 = copyWriter.getResult();
+    assertThat(copyWriter.isDone()).isTrue();
+    assertThat(remoteBlob2).isNotNull();
+  }
+
+  @Test
   public void testCopyBlobWithPredefinedAcl() {
 
     String sourceBlobName = generator.randomObjectName() + "-source";
@@ -903,8 +965,6 @@ public class ITObjectTest {
   }
 
   @Test
-  // Bucket attribute extration on allowlist bug b/246634709
-  @Exclude(transports = Transport.GRPC)
   public void testCopyBlobWithEncryptionKeys() {
 
     String sourceBlobName = generator.randomObjectName() + "-source";
@@ -955,8 +1015,6 @@ public class ITObjectTest {
   }
 
   @Test
-  // Bucket attribute extration on allowlist bug b/246634709
-  @Exclude(transports = Transport.GRPC)
   public void testCopyBlobUpdateMetadata() {
 
     String sourceBlobName = generator.randomObjectName() + "-source";
@@ -981,9 +1039,7 @@ public class ITObjectTest {
     assertTrue(storage.delete(bucket.getName(), targetBlobName));
   }
 
-  // Re-enable this test when it stops failing
-  // @Test
-  @Exclude(transports = Transport.GRPC)
+  @Test
   public void testCopyBlobUpdateStorageClass() {
     String sourceBlobName = generator.randomObjectName() + "-source";
     BlobId source = BlobId.of(bucket.getName(), sourceBlobName);
@@ -1007,8 +1063,6 @@ public class ITObjectTest {
   }
 
   @Test
-  // Bucket attribute extration on allowlist bug b/246634709
-  @Exclude(transports = Transport.GRPC)
   public void testCopyBlobNoContentType() {
 
     String sourceBlobName = generator.randomObjectName() + "-source";
@@ -1022,7 +1076,9 @@ public class ITObjectTest {
     CopyWriter copyWriter = storage.copy(req);
     assertEquals(bucket.getName(), copyWriter.getResult().getBucket());
     assertEquals(targetBlobName, copyWriter.getResult().getName());
-    assertNull(copyWriter.getResult().getContentType());
+    assertTrue(
+        copyWriter.getResult().getContentType() == null
+            || copyWriter.getResult().getContentType().isEmpty());
     assertEquals(metadata, copyWriter.getResult().getMetadata());
     assertTrue(copyWriter.isDone());
     assertTrue(remoteSourceBlob.delete());
@@ -1030,16 +1086,14 @@ public class ITObjectTest {
   }
 
   @Test
-  // Verified against testbench
-  // Bucket attribute extration on allowlist bug b/246634709
-  @Exclude(transports = Transport.GRPC)
   public void testCopyBlobFail() {
 
-    String sourceBlobName = "test-copy-blob-source-fail";
+    String baseName = generator.randomObjectName();
+    String sourceBlobName = baseName + "-source-fail";
     BlobId source = BlobId.of(bucket.getName(), sourceBlobName, -1L);
     Blob remoteSourceBlob = storage.create(BlobInfo.newBuilder(source).build(), BLOB_BYTE_CONTENT);
     assertNotNull(remoteSourceBlob);
-    String targetBlobName = "test-copy-blob-target-fail";
+    String targetBlobName = baseName + "-target-fail";
     BlobInfo target =
         BlobInfo.newBuilder(bucket, targetBlobName).setContentType(CONTENT_TYPE).build();
     CopyRequest req =
@@ -1070,7 +1124,7 @@ public class ITObjectTest {
 
   @Test
   public void testReadAndWriteChannelWithEncryptionKey() throws IOException {
-    String blobName = "test-read-write-channel-with-customer-key-blob";
+    String blobName = generator.randomObjectName();
     BlobInfo blob = BlobInfo.newBuilder(bucket, blobName).build();
     byte[] stringBytes;
     try (WriteChannel writer = storage.writer(blob, BlobWriteOption.encryptionKey(BASE64_KEY))) {
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/annotations/BucketType.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/annotations/BucketType.java
index c3335235..6a7a8251 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/annotations/BucketType.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/annotations/BucketType.java
@@ -23,5 +23,7 @@ public enum BucketType {
   /** A bucket created using all GCS defaults except that it has requester_pays enabled. */
   REQUESTER_PAYS,
   /** A bucket created with Hierarchical Namespace enabled */
-  HNS
+  HNS,
+  /** A bucket created using all GCS default except that object versioning is enabled */
+  VERSIONED
 }
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BackendResources.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BackendResources.java
index 237a5c67..a00d4e61 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BackendResources.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/BackendResources.java
@@ -114,6 +114,8 @@ final class BackendResources implements ManagedLifecycle {
                 case TEST_BENCH:
                   optionsBuilder =
                       StorageOptions.grpc()
+                          .setGrpcInterceptorProvider(
+                              GrpcPlainRequestLoggingInterceptor.getInterceptorProvider())
                           .setCredentials(NoCredentials.getInstance())
                           .setHost(Registry.getInstance().testBench().getGRPCBaseUri())
                           .setProjectId("test-project-id");
@@ -190,6 +192,17 @@ final class BackendResources implements ManagedLifecycle {
                   storageJson.get().getStorage(),
                   ctrl.get().getCtrl());
             });
+    TestRunScopedInstance<BucketInfoShim> bucketVersioned =
+        TestRunScopedInstance.of(
+            "BUCKET_VERSIONED_" + backend.name(),
+            () -> {
+              String bucketName = String.format("java-storage-grpc-v-%s", UUID.randomUUID());
+              protectedBucketNames.add(bucketName);
+              return new BucketInfoShim(
+                  BucketInfo.newBuilder(bucketName).setVersioningEnabled(true).build(),
+                  storageJson.get().getStorage(),
+                  ctrl.get().getCtrl());
+            });
     TestRunScopedInstance<BucketInfoShim> bucketHns =
         TestRunScopedInstance.of(
             "BUCKET_HNS_" + backend.name(),
@@ -246,6 +259,11 @@ final class BackendResources implements ManagedLifecycle {
                 BucketInfo.class,
                 bucketHns,
                 backendIs(backend).and(bucketTypeIs(BucketType.HNS))),
+            RegistryEntry.of(
+                62,
+                BucketInfo.class,
+                bucketVersioned,
+                backendIs(backend).and(bucketTypeIs(BucketType.VERSIONED))),
             RegistryEntry.of(
                 70, BucketInfo.class, bucket, backendIs(backend).and(isDefaultBucket())),
             RegistryEntry.of(
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/TestBench.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/TestBench.java
index d40a2995..470006c9 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/TestBench.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/runner/registry/TestBench.java
@@ -112,7 +112,7 @@ public final class TestBench implements ManagedLifecycle {
       String dockerImageName,
       String dockerImageTag,
       String containerName) {
-    this.ignorePullError = ignorePullError;
+    this.ignorePullError = true;
     this.baseUri = baseUri;
     this.gRPCBaseUri = gRPCBaseUri;
     this.dockerImageName = dockerImageName;
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/otel/TestExporter.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/otel/TestExporter.java
new file mode 100644
index 00000000..1532fa83
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/otel/TestExporter.java
@@ -0,0 +1,50 @@
+/*
+ * 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.otel;
+
+import io.opentelemetry.sdk.common.CompletableResultCode;
+import io.opentelemetry.sdk.trace.data.SpanData;
+import io.opentelemetry.sdk.trace.export.SpanExporter;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+public class TestExporter implements SpanExporter {
+
+  public final List<SpanData> exportedSpans = Collections.synchronizedList(new ArrayList<>());
+
+  @Override
+  public CompletableResultCode export(Collection<SpanData> spans) {
+    exportedSpans.addAll(spans);
+    return CompletableResultCode.ofSuccess();
+  }
+
+  @Override
+  public CompletableResultCode flush() {
+    return null;
+  }
+
+  @Override
+  public CompletableResultCode shutdown() {
+    return null;
+  }
+
+  public List<SpanData> getExportedSpans() {
+    return exportedSpans;
+  }
+}