diff --git a/java-storage/google-cloud-storage/pom.xml b/java-storage/google-cloud-storage/pom.xml
index 3fadd4c12810..3e7ae71ea283 100644
--- a/java-storage/google-cloud-storage/pom.xml
+++ b/java-storage/google-cloud-storage/pom.xml
@@ -241,6 +241,11 @@
opentelemetry-sdk-trace
test
+
+ io.opentelemetry
+ opentelemetry-sdk-testing
+ test
+
io.grpc
diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtils.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtils.java
index 53fea0b8b6c2..f8e6179af5b6 100644
--- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtils.java
+++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtils.java
@@ -38,10 +38,12 @@
import io.opentelemetry.sdk.common.export.MemoryMode;
import io.opentelemetry.sdk.metrics.Aggregation;
import io.opentelemetry.sdk.metrics.InstrumentSelector;
+import io.opentelemetry.sdk.metrics.InstrumentSelectorBuilder;
import io.opentelemetry.sdk.metrics.InstrumentType;
import io.opentelemetry.sdk.metrics.SdkMeterProvider;
import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder;
import io.opentelemetry.sdk.metrics.View;
+import io.opentelemetry.sdk.metrics.ViewBuilder;
import io.opentelemetry.sdk.metrics.data.AggregationTemporality;
import io.opentelemetry.sdk.metrics.data.MetricData;
import io.opentelemetry.sdk.metrics.export.DefaultAggregationSelector;
@@ -51,9 +53,11 @@
import java.math.BigDecimal;
import java.math.MathContext;
import java.net.NoRouteToHostException;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
+import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Logger;
@@ -205,6 +209,31 @@ static String getCloudMonitoringEndpoint(String endpoint, String universeDomain)
return metricServiceEndpoint + ":" + endpoint.split(":")[1];
}
+ static final ImmutableList CLIENT_LATENCY_HISTOGRAMS =
+ ImmutableList.of(
+ StorageClientMetrics.METRIC_RPC_CLIENT_CALL_DURATION,
+ StorageClientMetrics.METRIC_HTTP_CLIENT_REQUEST_DURATION,
+ StorageClientMetrics.METRIC_GCP_CLIENT_REQUEST_DURATION,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_TTFB,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_SERVER_DURATION,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_STALL_DURATION,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_AUTH_CREDENTIAL_REFRESH_DURATION);
+
+ static final ImmutableList CLIENT_SIZE_HISTOGRAMS =
+ ImmutableList.of(
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_REQUEST_BODY_SIZE,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_RESPONSE_BODY_SIZE);
+
+ static final ImmutableList CLIENT_COUNTERS =
+ ImmutableList.of(
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_RETRIES,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_REQUEST_ACTIVE,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_SERVER_UNREACHED,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_NETWORK_IO);
+
@VisibleForTesting
static SdkMeterProvider createMeterProvider(
String metricServiceEndpoint,
@@ -239,80 +268,244 @@ static SdkMeterProvider createMeterProvider(
InstrumentSelector.builder().setName(metric).build(),
View.builder().setName(metric.replace(".", "/")).build());
}
+ addHistogramView(
+ providerBuilder,
+ latencyHistogramBoundaries(),
+ "grpc/client/attempt/duration",
+ "grpc/client/attempt/duration",
+ "grpc-java",
+ "s",
+ "A view of grpc/client/attempt/duration with histogram boundaries more appropriate for Google Cloud Storage RPCs");
+ addHistogramView(
+ providerBuilder,
+ sizeHistogramBoundaries(),
+ "grpc/client/attempt/rcvd_total_compressed_message_size",
+ "grpc/client/attempt/rcvd_total_compressed_message_size",
+ "grpc-java",
+ "By",
+ "A view of grpc/client/attempt/rcvd_total_compressed_message_size with histogram boundaries more appropriate for Google Cloud Storage RPCs");
+ addHistogramView(
+ providerBuilder,
+ sizeHistogramBoundaries(),
+ "grpc/client/attempt/sent_total_compressed_message_size",
+ "grpc/client/attempt/sent_total_compressed_message_size",
+ "grpc-java",
+ "By",
+ "A view of grpc/client/attempt/sent_total_compressed_message_size with histogram boundaries more appropriate for Google Cloud Storage RPCs");
+
+ return buildMeterProvider(
+ cloudMonitoringExporter,
+ providerBuilder,
+ projectIdToUse,
+ null,
+ "grpc",
+ "storage.googleapis.com/Client",
+ detectedAttributes,
+ Duration.ofSeconds(60),
+ shouldSuppressExceptions);
+ }
+
+ @VisibleForTesting
+ static SdkMeterProvider createClientMeterProvider(
+ String metricServiceEndpoint,
+ String projectIdToUse,
+ Attributes detectedAttributes,
+ boolean shouldSuppressExceptions,
+ String api) {
+ return createClientMeterProvider(
+ metricServiceEndpoint,
+ projectIdToUse,
+ null,
+ detectedAttributes,
+ Duration.ofSeconds(60),
+ shouldSuppressExceptions,
+ api);
+ }
+
+ @VisibleForTesting
+ static SdkMeterProvider createClientMeterProvider(
+ String metricServiceEndpoint,
+ String projectIdToUse,
+ Attributes detectedAttributes,
+ Duration metricInterval,
+ boolean shouldSuppressExceptions,
+ String api) {
+ return createClientMeterProvider(
+ metricServiceEndpoint,
+ projectIdToUse,
+ null,
+ detectedAttributes,
+ metricInterval,
+ shouldSuppressExceptions,
+ api);
+ }
+
+ /**
+ * Creates an {@link SdkMeterProvider} configured for custom client metrics with the {@code
+ * custom.googleapis.com} prefix and default monitored resource with client metric views and Cloud
+ * Monitoring exporter.
+ *
+ * @param metricServiceEndpoint the Cloud Monitoring service endpoint to export metrics to
+ * @param projectIdToUse the GCP project ID to associate with the monitored resource
+ * @param instanceId the client instance ID to associate with the monitored resource, or null to
+ * generate a random UUID
+ * @param detectedAttributes environment-detected resource attributes
+ * @param metricInterval export interval for the periodic metric reader
+ * @param shouldSuppressExceptions whether exporter exceptions should be suppressed
+ * @param api the transport/API protocol distinguishing the client in Cloud Monitoring (e.g.,
+ * {@code "grpc"} for gRPC transport or {@code "json"} for HTTP/JSON transport).
+ * @return the configured {@link SdkMeterProvider}
+ */
+ @VisibleForTesting
+ static SdkMeterProvider createClientMeterProvider(
+ String metricServiceEndpoint,
+ String projectIdToUse,
+ @Nullable String instanceId,
+ Attributes detectedAttributes,
+ @Nullable Duration metricInterval,
+ boolean shouldSuppressExceptions,
+ String api) {
+ Objects.requireNonNull(api, "api must not be null");
+
+ MetricExporter cloudMonitoringExporter =
+ GoogleCloudMetricExporter.createWithConfiguration(
+ MetricConfiguration.builder()
+ .setInstrumentationLibraryLabelsEnabled(false)
+ .setMetricServiceEndpoint(metricServiceEndpoint)
+ .setPrefix("custom.googleapis.com")
+ .setUseServiceTimeSeries(false)
+ .setProjectId(projectIdToUse)
+ .build());
+
+ SdkMeterProviderBuilder providerBuilder = SdkMeterProvider.builder();
+
+ // Register views for client histograms and counters
+ registerClientViews(providerBuilder);
+
+ return buildMeterProvider(
+ cloudMonitoringExporter,
+ providerBuilder,
+ projectIdToUse,
+ instanceId,
+ api,
+ null,
+ detectedAttributes,
+ metricInterval,
+ shouldSuppressExceptions);
+ }
+
+ private static SdkMeterProvider buildMeterProvider(
+ MetricExporter cloudMonitoringExporter,
+ SdkMeterProviderBuilder providerBuilder,
+ String projectIdToUse,
+ @Nullable String instanceId,
+ String api,
+ @Nullable String customResourceType,
+ Attributes detectedAttributes,
+ @Nullable Duration metricInterval,
+ boolean shouldSuppressExceptions) {
MetricExporter exporter =
shouldSuppressExceptions
? new PermissionDeniedSingleReportMetricsExporter(cloudMonitoringExporter)
: cloudMonitoringExporter;
- AttributesBuilder attributesBuilder =
- Attributes.builder()
- .put("gcp.resource_type", "storage.googleapis.com/Client")
- .put("project_id", projectIdToUse)
- .put("instance_id", UUID.randomUUID().toString())
- .put("api", "grpc");
- String detectedLocation = detectedAttributes.get(AttributeKey.stringKey("cloud.region"));
- if (detectedLocation != null) {
- attributesBuilder.put("location", detectedLocation);
- } else {
- attributesBuilder.put("location", "global");
- }
- String detectedCloudPlatform = detectedAttributes.get(AttributeKey.stringKey("cloud.platform"));
- if (detectedCloudPlatform != null) {
- attributesBuilder.put("cloud_platform", detectedCloudPlatform);
- } else {
- attributesBuilder.put("cloud_platform", "unknown");
+ AttributesBuilder attributesBuilder = Attributes.builder();
+ if (detectedAttributes != null) {
+ attributesBuilder.putAll(detectedAttributes);
}
- String detectedHostId = detectedAttributes.get(AttributeKey.stringKey("host.id"));
- if (detectedHostId != null) {
- attributesBuilder.put("host_id", detectedHostId);
- } else {
- attributesBuilder.put("host_id", "unknown");
+ attributesBuilder
+ .put("project_id", projectIdToUse)
+ .put("instance_id", instanceId != null ? instanceId : UUID.randomUUID().toString())
+ .put("api", api);
+ if (customResourceType != null) {
+ attributesBuilder.put("gcp.resource_type", customResourceType);
}
+ String detectedLocation =
+ detectedAttributes != null
+ ? detectedAttributes.get(AttributeKey.stringKey("cloud.region"))
+ : null;
+ attributesBuilder.put("location", detectedLocation != null ? detectedLocation : "global");
+ String detectedCloudPlatform =
+ detectedAttributes != null
+ ? detectedAttributes.get(AttributeKey.stringKey("cloud.platform"))
+ : null;
+ attributesBuilder.put(
+ "cloud_platform", detectedCloudPlatform != null ? detectedCloudPlatform : "unknown");
+ String detectedHostId =
+ detectedAttributes != null
+ ? detectedAttributes.get(AttributeKey.stringKey("host.id"))
+ : null;
+ attributesBuilder.put("host_id", detectedHostId != null ? detectedHostId : "unknown");
+
providerBuilder
.registerMetricReader(
PeriodicMetricReader.builder(exporter)
- .setInterval(java.time.Duration.ofSeconds(60))
+ .setInterval(metricInterval != null ? metricInterval : Duration.ofSeconds(60))
.build())
.setResource(Resource.create(attributesBuilder.build()));
- addHistogramView(
- providerBuilder, latencyHistogramBoundaries(), "grpc/client/attempt/duration", "s");
- addHistogramView(
- providerBuilder,
- sizeHistogramBoundaries(),
- "grpc/client/attempt/rcvd_total_compressed_message_size",
- "By");
- addHistogramView(
- providerBuilder,
- sizeHistogramBoundaries(),
- "grpc/client/attempt/sent_total_compressed_message_size",
- "By");
-
return providerBuilder.build();
}
+ @VisibleForTesting
+ static SdkMeterProviderBuilder registerClientViews(SdkMeterProviderBuilder providerBuilder) {
+ for (String metric : CLIENT_COUNTERS) {
+ providerBuilder.registerView(
+ InstrumentSelector.builder()
+ .setMeterName(StorageClientMetrics.METER_NAME)
+ .setName(metric)
+ .build(),
+ View.builder().setName(metric.replace(".", "/")).build());
+ }
+ for (String metric : CLIENT_LATENCY_HISTOGRAMS) {
+ addHistogramView(
+ providerBuilder,
+ latencyHistogramBoundaries(),
+ metric,
+ metric.replace(".", "/"),
+ StorageClientMetrics.METER_NAME,
+ null,
+ null);
+ }
+ for (String metric : CLIENT_SIZE_HISTOGRAMS) {
+ addHistogramView(
+ providerBuilder,
+ sizeHistogramBoundaries(),
+ metric,
+ metric.replace(".", "/"),
+ StorageClientMetrics.METER_NAME,
+ null,
+ null);
+ }
+ return providerBuilder;
+ }
+
private static void addHistogramView(
- SdkMeterProviderBuilder provider, List boundaries, String name, String unit) {
- InstrumentSelector instrumentSelector =
+ SdkMeterProviderBuilder provider,
+ List boundaries,
+ String instrumentName,
+ String viewName,
+ String meterName,
+ @Nullable String unit,
+ @Nullable String description) {
+ InstrumentSelectorBuilder selector =
InstrumentSelector.builder()
.setType(InstrumentType.HISTOGRAM)
- .setUnit(unit)
- .setName(name)
- .setMeterName("grpc-java")
- .setMeterSchemaUrl("")
- .build();
- View view =
+ .setMeterName(meterName)
+ .setName(instrumentName);
+ if (unit != null) {
+ selector.setUnit(unit);
+ }
+ ViewBuilder view =
View.builder()
- .setName(name)
- .setDescription(
- "A view of "
- + name
- + " with histogram boundaries more appropriate for Google Cloud Storage RPCs")
- .setAggregation(Aggregation.explicitBucketHistogram(boundaries))
- .build();
- provider.registerView(instrumentSelector, view);
+ .setName(viewName)
+ .setAggregation(Aggregation.explicitBucketHistogram(boundaries));
+ if (description != null) {
+ view.setDescription(description);
+ }
+ provider.registerView(selector.build(), view.build());
}
- private static List latencyHistogramBoundaries() {
+ static List latencyHistogramBoundaries() {
List boundaries = new ArrayList<>();
BigDecimal boundary = new BigDecimal(0, MathContext.UNLIMITED);
BigDecimal increment = new BigDecimal("0.002", MathContext.UNLIMITED); // 2ms
@@ -337,7 +530,7 @@ private static List latencyHistogramBoundaries() {
return boundaries;
}
- private static List sizeHistogramBoundaries() {
+ static List sizeHistogramBoundaries() {
long kb = 1024;
long mb = 1024 * kb;
long gb = 1024 * mb;
@@ -357,6 +550,10 @@ private static List sizeHistogramBoundaries() {
return boundaries;
}
+ static List sizeHistogramLongBoundaries() {
+ return sizeHistogramBoundaries().stream().map(Double::longValue).collect(Collectors.toList());
+ }
+
private static final class PermissionDeniedSingleReportMetricsExporter implements MetricExporter {
private final MetricExporter delegate;
private final AtomicBoolean seenPermissionDenied = new AtomicBoolean(false);
diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageClientMetrics.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageClientMetrics.java
new file mode 100644
index 000000000000..4d28ef7f279e
--- /dev/null
+++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageClientMetrics.java
@@ -0,0 +1,412 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.storage;
+
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.common.AttributesBuilder;
+import io.opentelemetry.api.metrics.DoubleHistogram;
+import io.opentelemetry.api.metrics.LongCounter;
+import io.opentelemetry.api.metrics.LongHistogram;
+import io.opentelemetry.api.metrics.LongUpDownCounter;
+import io.opentelemetry.api.metrics.Meter;
+import io.opentelemetry.api.metrics.MeterProvider;
+import java.util.UUID;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** Package-private instrument registry for OpenTelemetry client metrics. */
+final class StorageClientMetrics {
+ static final String METER_NAME = "com.google.cloud.storage";
+
+ // Standard client attribute keys and values
+ static final AttributeKey KEY_GCP_CLIENT_VERSION =
+ AttributeKey.stringKey("gcp.client.version");
+ static final AttributeKey KEY_GCP_CLIENT_SERVICE =
+ AttributeKey.stringKey("gcp.client.service");
+ static final AttributeKey KEY_GCP_CLIENT_ARTIFACT =
+ AttributeKey.stringKey("gcp.client.artifact");
+ static final AttributeKey KEY_GCP_CLIENT_INSTANCE_ID =
+ AttributeKey.stringKey("gcp.client.instance_id");
+
+ static final AttributeKey KEY_GCP_STORAGE_OPERATION =
+ AttributeKey.stringKey("gcp.storage.operation");
+ static final AttributeKey KEY_GCP_CLIENT_METHOD =
+ AttributeKey.stringKey("gcp.client.method");
+ static final AttributeKey KEY_RPC_METHOD = AttributeKey.stringKey("rpc.method");
+ static final AttributeKey KEY_RPC_SYSTEM_NAME = AttributeKey.stringKey("rpc.system.name");
+ static final AttributeKey KEY_RPC_RESPONSE_STATUS_CODE =
+ AttributeKey.stringKey("rpc.response.status_code");
+ static final AttributeKey KEY_HTTP_REQUEST_METHOD =
+ AttributeKey.stringKey("http.request.method");
+ static final AttributeKey KEY_HTTP_RESPONSE_STATUS_CODE =
+ AttributeKey.longKey("http.response.status_code");
+ static final AttributeKey KEY_URL_TEMPLATE = AttributeKey.stringKey("url.template");
+ static final AttributeKey KEY_SERVER_ADDRESS = AttributeKey.stringKey("server.address");
+ static final AttributeKey KEY_SERVER_PORT = AttributeKey.longKey("server.port");
+ static final AttributeKey KEY_ERROR_TYPE = AttributeKey.stringKey("error.type");
+ static final AttributeKey KEY_NETWORK_PATH = AttributeKey.stringKey("network.path");
+ static final AttributeKey KEY_NETWORK_IO_DIRECTION =
+ AttributeKey.stringKey("network.io.direction");
+
+ static final String NETWORK_PATH_DIRECTPATH = "directpath";
+ static final String NETWORK_PATH_CLOUDPATH = "cloudpath";
+ static final String NETWORK_PATH_UNKNOWN = "unknown";
+
+ static final String NETWORK_IO_DIRECTION_RECEIVE = "receive";
+ static final String NETWORK_IO_DIRECTION_TRANSMIT = "transmit";
+
+ static final String ATTRIBUTE_GCP_CLIENT_VERSION = "gcp.client.version";
+ static final String ATTRIBUTE_GCP_CLIENT_SERVICE = "gcp.client.service";
+ static final String ATTRIBUTE_GCP_CLIENT_ARTIFACT = "gcp.client.artifact";
+ static final String ATTRIBUTE_GCP_CLIENT_INSTANCE_ID = "gcp.client.instance_id";
+
+ static final String SERVICE_STORAGE = "storage";
+ static final String ARTIFACT_STORAGE = "com.google.cloud:google-cloud-storage";
+
+ // Standard metric names
+ static final String METRIC_RPC_CLIENT_CALL_DURATION = "rpc.client.call.duration";
+ static final String METRIC_HTTP_CLIENT_REQUEST_DURATION = "http.client.request.duration";
+ static final String METRIC_GCP_CLIENT_REQUEST_DURATION = "gcp.client.request.duration";
+ static final String METRIC_GCP_STORAGE_CLIENT_OPERATIONS = "gcp.storage.client.operations";
+ static final String METRIC_GCP_STORAGE_CLIENT_ATTEMPTS = "gcp.storage.client.attempts";
+ static final String METRIC_GCP_STORAGE_CLIENT_RETRIES = "gcp.storage.client.retries";
+ static final String METRIC_GCP_STORAGE_CLIENT_ERRORS = "gcp.storage.client.errors";
+ static final String METRIC_GCP_STORAGE_CLIENT_TTFB = "gcp.storage.client.ttfb";
+ static final String METRIC_GCP_STORAGE_CLIENT_REQUEST_BODY_SIZE =
+ "gcp.storage.client.request.body.size";
+ static final String METRIC_GCP_STORAGE_CLIENT_RESPONSE_BODY_SIZE =
+ "gcp.storage.client.response.body.size";
+
+ // Debug metric names
+ static final String METRIC_GCP_STORAGE_CLIENT_REQUEST_ACTIVE =
+ "gcp.storage.client.request.active";
+ static final String METRIC_GCP_STORAGE_CLIENT_SERVER_DURATION =
+ "gcp.storage.client.server.duration";
+ static final String METRIC_GCP_STORAGE_CLIENT_SERVER_UNREACHED =
+ "gcp.storage.client.server.unreached";
+ static final String METRIC_GCP_STORAGE_CLIENT_STALL_DURATION =
+ "gcp.storage.client.stall.duration";
+ static final String METRIC_GCP_STORAGE_CLIENT_NETWORK_IO = "gcp.storage.client.network.io";
+ static final String METRIC_GCP_STORAGE_CLIENT_AUTH_CREDENTIAL_REFRESH_DURATION =
+ "gcp.storage.client.auth.credential_refresh.duration";
+
+ @Nullable private final DoubleHistogram rpcClientCallDuration;
+ @Nullable private final DoubleHistogram httpClientRequestDuration;
+ @Nullable private final DoubleHistogram gcpClientRequestDuration;
+ @Nullable private final LongCounter operations;
+ @Nullable private final LongCounter attempts;
+ @Nullable private final LongCounter retries;
+ @Nullable private final LongCounter errors;
+ @Nullable private final DoubleHistogram ttfb;
+ @Nullable private final LongHistogram requestBodySize;
+ @Nullable private final LongHistogram responseBodySize;
+
+ @Nullable private final LongUpDownCounter requestActive;
+ @Nullable private final DoubleHistogram serverDuration;
+ @Nullable private final LongCounter serverUnreached;
+ @Nullable private final DoubleHistogram stallDuration;
+ @Nullable private final LongCounter networkIo;
+ @Nullable private final DoubleHistogram credentialRefreshDuration;
+
+ private final Attributes clientAttributes;
+
+ static StorageClientMetrics create(
+ MeterProvider meterProvider, boolean enableOtelMetrics, boolean enableOtelDebugMetrics) {
+ return create(
+ meterProvider, UUID.randomUUID().toString(), enableOtelMetrics, enableOtelDebugMetrics);
+ }
+
+ static StorageClientMetrics create(
+ MeterProvider meterProvider,
+ String instanceId,
+ boolean enableOtelMetrics,
+ boolean enableOtelDebugMetrics) {
+ Meter meter =
+ meterProvider
+ .meterBuilder(METER_NAME)
+ .setInstrumentationVersion(StorageOptions.version())
+ .build();
+ Attributes clientAttributes =
+ Attributes.builder()
+ .put(KEY_GCP_CLIENT_SERVICE, SERVICE_STORAGE)
+ .put(KEY_GCP_CLIENT_VERSION, StorageOptions.version())
+ .put(KEY_GCP_CLIENT_ARTIFACT, ARTIFACT_STORAGE)
+ .put(KEY_GCP_CLIENT_INSTANCE_ID, instanceId)
+ .build();
+ return new StorageClientMetrics(
+ meter, clientAttributes, enableOtelMetrics, enableOtelDebugMetrics);
+ }
+
+ StorageClientMetrics(Meter meter, boolean enableOtelMetrics, boolean enableOtelDebugMetrics) {
+ this(
+ meter,
+ Attributes.builder()
+ .put(KEY_GCP_CLIENT_SERVICE, SERVICE_STORAGE)
+ .put(KEY_GCP_CLIENT_VERSION, StorageOptions.version())
+ .put(KEY_GCP_CLIENT_ARTIFACT, ARTIFACT_STORAGE)
+ .put(KEY_GCP_CLIENT_INSTANCE_ID, UUID.randomUUID().toString())
+ .build(),
+ enableOtelMetrics,
+ enableOtelDebugMetrics);
+ }
+
+ StorageClientMetrics(
+ Meter meter,
+ Attributes clientAttributes,
+ boolean enableOtelMetrics,
+ boolean enableOtelDebugMetrics) {
+ this.clientAttributes = clientAttributes;
+
+ if (enableOtelMetrics) {
+ this.rpcClientCallDuration =
+ meter
+ .histogramBuilder(METRIC_RPC_CLIENT_CALL_DURATION)
+ .setDescription("Duration of a single gRPC client call attempt to Cloud Storage.")
+ .setUnit("s")
+ .setExplicitBucketBoundariesAdvice(
+ OpenTelemetryBootstrappingUtils.latencyHistogramBoundaries())
+ .build();
+ this.httpClientRequestDuration =
+ meter
+ .histogramBuilder(METRIC_HTTP_CLIENT_REQUEST_DURATION)
+ .setDescription("Duration of a single HTTP request attempt to Cloud Storage.")
+ .setUnit("s")
+ .setExplicitBucketBoundariesAdvice(
+ OpenTelemetryBootstrappingUtils.latencyHistogramBoundaries())
+ .build();
+ this.gcpClientRequestDuration =
+ meter
+ .histogramBuilder(METRIC_GCP_CLIENT_REQUEST_DURATION)
+ .setDescription("Latency of a client operation, including all retries.")
+ .setUnit("s")
+ .setExplicitBucketBoundariesAdvice(
+ OpenTelemetryBootstrappingUtils.latencyHistogramBoundaries())
+ .build();
+ this.operations =
+ meter
+ .counterBuilder(METRIC_GCP_STORAGE_CLIENT_OPERATIONS)
+ .setDescription("Number of GCS client operations")
+ .setUnit("{operation}")
+ .build();
+ this.attempts =
+ meter
+ .counterBuilder(METRIC_GCP_STORAGE_CLIENT_ATTEMPTS)
+ .setDescription(
+ "Number of GCS client attempts (individual HTTP requests or gRPC calls), including"
+ + " retries, resumable upload chunks and list pages.")
+ .setUnit("{attempt}")
+ .build();
+ this.retries =
+ meter
+ .counterBuilder(METRIC_GCP_STORAGE_CLIENT_RETRIES)
+ .setDescription("Number of GCS client retry attempts.")
+ .setUnit("{retry}")
+ .build();
+ this.errors =
+ meter
+ .counterBuilder(METRIC_GCP_STORAGE_CLIENT_ERRORS)
+ .setDescription("Number of failed GCS client attempts, by error.type.")
+ .setUnit("{error}")
+ .build();
+ this.ttfb =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_TTFB)
+ .setDescription(
+ "Time from the start of an attempt until the first byte of the response was"
+ + " received. Not recorded for attempts that received no response.")
+ .setUnit("s")
+ .setExplicitBucketBoundariesAdvice(
+ OpenTelemetryBootstrappingUtils.latencyHistogramBoundaries())
+ .build();
+ this.requestBodySize =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_REQUEST_BODY_SIZE)
+ .ofLongs()
+ .setDescription(
+ "Number of object bytes written by an upload operation (recorded once per"
+ + " operation, including empty objects).")
+ .setUnit("By")
+ .setExplicitBucketBoundariesAdvice(
+ OpenTelemetryBootstrappingUtils.sizeHistogramLongBoundaries())
+ .build();
+ this.responseBodySize =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_RESPONSE_BODY_SIZE)
+ .ofLongs()
+ .setDescription(
+ "Number of object bytes delivered to the application by a download operation"
+ + " (recorded once per operation).")
+ .setUnit("By")
+ .setExplicitBucketBoundariesAdvice(
+ OpenTelemetryBootstrappingUtils.sizeHistogramLongBoundaries())
+ .build();
+ } else {
+ this.rpcClientCallDuration = null;
+ this.httpClientRequestDuration = null;
+ this.gcpClientRequestDuration = null;
+ this.operations = null;
+ this.attempts = null;
+ this.retries = null;
+ this.errors = null;
+ this.ttfb = null;
+ this.requestBodySize = null;
+ this.responseBodySize = null;
+ }
+
+ if (enableOtelDebugMetrics) {
+ this.requestActive =
+ meter
+ .upDownCounterBuilder(METRIC_GCP_STORAGE_CLIENT_REQUEST_ACTIVE)
+ .setDescription("Number of active GCS client requests")
+ .setUnit("{request}")
+ .build();
+ this.serverDuration =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_SERVER_DURATION)
+ .setDescription(
+ "Server elapsed processing time for gRPC requests, decoded from"
+ + " grpc-server-stats-bin response trailer (gRPC only).")
+ .setUnit("s")
+ .setExplicitBucketBoundariesAdvice(
+ OpenTelemetryBootstrappingUtils.latencyHistogramBoundaries())
+ .build();
+ this.serverUnreached =
+ meter
+ .counterBuilder(METRIC_GCP_STORAGE_CLIENT_SERVER_UNREACHED)
+ .setDescription(
+ "Number of attempts that received no response from a GCS server (gRPC:"
+ + " grpc-server-stats-bin trailer absent; HTTP: X-GUploader-UploadID header"
+ + " absent).")
+ .setUnit("{attempt}")
+ .build();
+ this.stallDuration =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_STALL_DURATION)
+ .setDescription(
+ "Stall timeout after which a read attempt was aborted while waiting for the"
+ + " initial response.")
+ .setUnit("s")
+ .setExplicitBucketBoundariesAdvice(
+ OpenTelemetryBootstrappingUtils.latencyHistogramBoundaries())
+ .build();
+ this.networkIo =
+ meter
+ .counterBuilder(METRIC_GCP_STORAGE_CLIENT_NETWORK_IO)
+ .setDescription(
+ "Bytes read from and written to the TCP connections to Cloud Storage, by"
+ + " network.io.direction (receive, transmit). gRPC only.")
+ .setUnit("By")
+ .build();
+ this.credentialRefreshDuration =
+ meter
+ .histogramBuilder(METRIC_GCP_STORAGE_CLIENT_AUTH_CREDENTIAL_REFRESH_DURATION)
+ .setDescription(
+ "Time a request was blocked obtaining an access token. Tokens served from the"
+ + " credential cache are not recorded.")
+ .setUnit("s")
+ .setExplicitBucketBoundariesAdvice(
+ OpenTelemetryBootstrappingUtils.latencyHistogramBoundaries())
+ .build();
+ } else {
+ this.requestActive = null;
+ this.serverDuration = null;
+ this.serverUnreached = null;
+ this.stallDuration = null;
+ this.networkIo = null;
+ this.credentialRefreshDuration = null;
+ }
+ }
+
+ Attributes getClientAttributes() {
+ return clientAttributes;
+ }
+
+ AttributesBuilder clientAttributesBuilder() {
+ return clientAttributes.toBuilder();
+ }
+
+ Attributes attachClientAttributes(@Nullable Attributes other) {
+ if (other == null || other.isEmpty()) {
+ return clientAttributes;
+ }
+ return clientAttributes.toBuilder().putAll(other).build();
+ }
+
+ @Nullable DoubleHistogram getRpcClientCallDuration() {
+ return rpcClientCallDuration;
+ }
+
+ @Nullable DoubleHistogram getHttpClientRequestDuration() {
+ return httpClientRequestDuration;
+ }
+
+ @Nullable DoubleHistogram getGcpClientRequestDuration() {
+ return gcpClientRequestDuration;
+ }
+
+ @Nullable LongCounter getOperations() {
+ return operations;
+ }
+
+ @Nullable LongCounter getAttempts() {
+ return attempts;
+ }
+
+ @Nullable LongCounter getRetries() {
+ return retries;
+ }
+
+ @Nullable LongCounter getErrors() {
+ return errors;
+ }
+
+ @Nullable DoubleHistogram getTtfb() {
+ return ttfb;
+ }
+
+ @Nullable LongHistogram getRequestBodySize() {
+ return requestBodySize;
+ }
+
+ @Nullable LongHistogram getResponseBodySize() {
+ return responseBodySize;
+ }
+
+ @Nullable LongUpDownCounter getRequestActive() {
+ return requestActive;
+ }
+
+ @Nullable DoubleHistogram getServerDuration() {
+ return serverDuration;
+ }
+
+ @Nullable LongCounter getServerUnreached() {
+ return serverUnreached;
+ }
+
+ @Nullable DoubleHistogram getStallDuration() {
+ return stallDuration;
+ }
+
+ @Nullable LongCounter getNetworkIo() {
+ return networkIo;
+ }
+
+ @Nullable DoubleHistogram getCredentialRefreshDuration() {
+ return credentialRefreshDuration;
+ }
+}
diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtilsTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtilsTest.java
index 5e93c95a988b..c5844cfaee41 100644
--- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtilsTest.java
+++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/OpenTelemetryBootstrappingUtilsTest.java
@@ -17,11 +17,16 @@
package com.google.cloud.storage;
import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
import static org.junit.Assume.assumeFalse;
import static org.mockito.Mockito.mock;
import com.google.cloud.storage.OpenTelemetryBootstrappingUtils.ChannelConfigurator;
import io.grpc.ManagedChannelBuilder;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.sdk.metrics.SdkMeterProvider;
+import java.time.Duration;
+import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.Test;
@@ -94,4 +99,120 @@ public void channelConfigurator_andThen_nullsafe() {
ChannelConfigurator actual = ChannelConfigurator.identity().andThen(null);
assertThat(actual).isSameInstanceAs(ChannelConfigurator.identity());
}
+
+ @Test
+ public void histogramBoundaries_areValidAndIncreasing() {
+ List latencyBoundaries = OpenTelemetryBootstrappingUtils.latencyHistogramBoundaries();
+ assertThat(latencyBoundaries).isNotEmpty();
+ assertThat(latencyBoundaries).isInStrictOrder();
+
+ List sizeBoundaries = OpenTelemetryBootstrappingUtils.sizeHistogramBoundaries();
+ assertThat(sizeBoundaries).isNotEmpty();
+ assertThat(sizeBoundaries).isInStrictOrder();
+
+ List sizeLongBoundaries = OpenTelemetryBootstrappingUtils.sizeHistogramLongBoundaries();
+ assertThat(sizeLongBoundaries).hasSize(sizeBoundaries.size());
+ assertThat(sizeLongBoundaries).isInStrictOrder();
+ }
+
+ @Test
+ public void registerClientViews_doesNotThrow() {
+ io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder builder =
+ io.opentelemetry.sdk.metrics.SdkMeterProvider.builder();
+ io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder returned =
+ OpenTelemetryBootstrappingUtils.registerClientViews(builder);
+ assertThat(returned).isSameInstanceAs(builder);
+ }
+
+ @Test
+ public void createClientMeterProvider_attachesApiAttribute() {
+ Attributes detectedAttributes = Attributes.empty();
+
+ try (SdkMeterProvider grpcProvider =
+ OpenTelemetryBootstrappingUtils.createClientMeterProvider(
+ "monitoring.googleapis.com:443",
+ "test-project",
+ detectedAttributes,
+ Duration.ofSeconds(60),
+ true,
+ "grpc")) {
+ assertThat(grpcProvider.toString()).contains("api=\"grpc\"");
+ assertThat(grpcProvider.toString())
+ .doesNotContain("gcp.resource_type=\"storage.googleapis.com/Client\"");
+ }
+
+ try (SdkMeterProvider jsonProvider =
+ OpenTelemetryBootstrappingUtils.createClientMeterProvider(
+ "monitoring.googleapis.com:443",
+ "test-project",
+ detectedAttributes,
+ Duration.ofSeconds(60),
+ true,
+ "json")) {
+ assertThat(jsonProvider.toString()).contains("api=\"json\"");
+ assertThat(jsonProvider.toString())
+ .doesNotContain("gcp.resource_type=\"storage.googleapis.com/Client\"");
+ }
+
+ assertThrows(
+ NullPointerException.class,
+ () ->
+ OpenTelemetryBootstrappingUtils.createClientMeterProvider(
+ "monitoring.googleapis.com:443",
+ "test-project",
+ detectedAttributes,
+ Duration.ofSeconds(60),
+ true,
+ null));
+
+ try (SdkMeterProvider legacyProvider =
+ OpenTelemetryBootstrappingUtils.createMeterProvider(
+ "monitoring.googleapis.com:443", "test-project", detectedAttributes, true)) {
+ assertThat(legacyProvider.toString()).contains("api=\"grpc\"");
+ assertThat(legacyProvider.toString())
+ .contains("gcp.resource_type=\"storage.googleapis.com/Client\"");
+ }
+ }
+
+ @Test
+ public void createClientMeterProvider_customInstanceId_attachesInstanceIdAttribute() {
+ Attributes detectedAttributes = Attributes.empty();
+ String customInstanceId = "custom-instance-12345";
+
+ try (SdkMeterProvider provider =
+ OpenTelemetryBootstrappingUtils.createClientMeterProvider(
+ "monitoring.googleapis.com:443",
+ "test-project",
+ customInstanceId,
+ detectedAttributes,
+ Duration.ofSeconds(60),
+ true,
+ "grpc")) {
+ assertThat(provider.toString()).contains("instance_id=\"" + customInstanceId + "\"");
+ }
+ }
+
+ @Test
+ public void createClientMeterProvider_preservesDetectedAttributes() {
+ Attributes detectedAttributes =
+ Attributes.builder()
+ .put("cloud.platform", "gcp_kubernetes_engine")
+ .put("cloud.availability_zone", "us-central1-a")
+ .put("host.id", "test-host-123")
+ .build();
+
+ try (SdkMeterProvider provider =
+ OpenTelemetryBootstrappingUtils.createClientMeterProvider(
+ "monitoring.googleapis.com:443",
+ "test-project",
+ "test-instance",
+ detectedAttributes,
+ Duration.ofSeconds(60),
+ true,
+ "grpc")) {
+ assertThat(provider.toString()).contains("cloud.platform=\"gcp_kubernetes_engine\"");
+ assertThat(provider.toString()).contains("cloud.availability_zone=\"us-central1-a\"");
+ assertThat(provider.toString()).contains("host.id=\"test-host-123\"");
+ }
+ }
}
diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageClientMetricsTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageClientMetricsTest.java
new file mode 100644
index 000000000000..49966661f117
--- /dev/null
+++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageClientMetricsTest.java
@@ -0,0 +1,362 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.storage;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.sdk.metrics.SdkMeterProvider;
+import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder;
+import io.opentelemetry.sdk.metrics.data.HistogramPointData;
+import io.opentelemetry.sdk.metrics.data.MetricData;
+import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.junit.Before;
+import org.junit.Test;
+
+public final class StorageClientMetricsTest {
+
+ private static final List> STANDARD_INSTRUMENTS =
+ ImmutableList.of(
+ StorageClientMetrics::getRpcClientCallDuration,
+ StorageClientMetrics::getHttpClientRequestDuration,
+ StorageClientMetrics::getGcpClientRequestDuration,
+ StorageClientMetrics::getOperations,
+ StorageClientMetrics::getAttempts,
+ StorageClientMetrics::getRetries,
+ StorageClientMetrics::getErrors,
+ StorageClientMetrics::getTtfb,
+ StorageClientMetrics::getRequestBodySize,
+ StorageClientMetrics::getResponseBodySize);
+
+ private static final List> DEBUG_INSTRUMENTS =
+ ImmutableList.of(
+ StorageClientMetrics::getRequestActive,
+ StorageClientMetrics::getServerDuration,
+ StorageClientMetrics::getServerUnreached,
+ StorageClientMetrics::getStallDuration,
+ StorageClientMetrics::getNetworkIo,
+ StorageClientMetrics::getCredentialRefreshDuration);
+
+ private InMemoryMetricReader reader;
+
+ @Before
+ public void setUp() {
+ reader = InMemoryMetricReader.create();
+ }
+
+ @Test
+ public void standardMetrics_initializedWithoutDebug() {
+ SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build();
+
+ StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false);
+
+ assertInstruments(metrics, STANDARD_INSTRUMENTS, /* isNull= */ false);
+
+ assertThat(metrics.getClientAttributes()).isNotNull();
+ assertThat(metrics.getClientAttributes().get(StorageClientMetrics.KEY_GCP_CLIENT_SERVICE))
+ .isEqualTo("storage");
+ assertThat(metrics.getClientAttributes().get(StorageClientMetrics.KEY_GCP_CLIENT_VERSION))
+ .isEqualTo(StorageOptions.version());
+ assertThat(metrics.getClientAttributes().get(StorageClientMetrics.KEY_GCP_CLIENT_ARTIFACT))
+ .isEqualTo("com.google.cloud:google-cloud-storage");
+ assertThat(metrics.getClientAttributes().get(StorageClientMetrics.KEY_GCP_CLIENT_INSTANCE_ID))
+ .isNotEmpty();
+
+ assertInstruments(metrics, DEBUG_INSTRUMENTS, /* isNull= */ true);
+ }
+
+ @Test
+ public void debugMetrics_initializedWhenEnabled() {
+ SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build();
+
+ StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, true);
+
+ assertInstruments(metrics, STANDARD_INSTRUMENTS, /* isNull= */ false);
+ assertInstruments(metrics, DEBUG_INSTRUMENTS, /* isNull= */ false);
+ }
+
+ @Test
+ public void debugOnly_standardMetricsNull() {
+ SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build();
+
+ StorageClientMetrics metrics = StorageClientMetrics.create(provider, false, true);
+
+ assertInstruments(metrics, STANDARD_INSTRUMENTS, /* isNull= */ true);
+ assertInstruments(metrics, DEBUG_INSTRUMENTS, /* isNull= */ false);
+ }
+
+ @Test
+ public void neitherEnabled_allInstrumentsNull() {
+ SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build();
+
+ StorageClientMetrics metrics = StorageClientMetrics.create(provider, false, false);
+
+ assertInstruments(metrics, STANDARD_INSTRUMENTS, /* isNull= */ true);
+ assertInstruments(metrics, DEBUG_INSTRUMENTS, /* isNull= */ true);
+ }
+
+ @Test
+ public void inMemoryMetricReader_verifiesViewsAndBoundaries() {
+ SdkMeterProviderBuilder providerBuilder =
+ SdkMeterProvider.builder().registerMetricReader(reader);
+ OpenTelemetryBootstrappingUtils.registerClientViews(providerBuilder);
+ SdkMeterProvider provider = providerBuilder.build();
+
+ StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, true);
+
+ // Record data for each instrument
+ metrics.getRpcClientCallDuration().record(0.123);
+ metrics.getHttpClientRequestDuration().record(0.234);
+ metrics.getGcpClientRequestDuration().record(0.345);
+ metrics.getTtfb().record(0.045);
+ metrics.getServerDuration().record(0.012);
+ metrics.getStallDuration().record(0.010);
+ metrics.getNetworkIo().add(1024 * 256);
+ metrics.getCredentialRefreshDuration().record(0.080);
+
+ metrics.getRequestBodySize().record(1024 * 512);
+ metrics.getResponseBodySize().record(1024 * 1024);
+
+ metrics.getOperations().add(1);
+ metrics.getAttempts().add(2);
+ metrics.getRetries().add(1);
+ metrics.getErrors().add(1);
+ metrics.getRequestActive().add(1);
+ metrics.getServerUnreached().add(1);
+
+ Collection collectedMetrics = reader.collectAllMetrics();
+ Map metricsMap =
+ collectedMetrics.stream()
+ .collect(Collectors.toMap(MetricData::getName, Function.identity()));
+
+ List expectedLatencyBoundaries =
+ OpenTelemetryBootstrappingUtils.latencyHistogramBoundaries();
+ List expectedSizeBoundaries = OpenTelemetryBootstrappingUtils.sizeHistogramBoundaries();
+
+ // Verify standard latency histograms have custom latency boundaries and slash names
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_RPC_CLIENT_CALL_DURATION,
+ expectedLatencyBoundaries);
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_HTTP_CLIENT_REQUEST_DURATION,
+ expectedLatencyBoundaries);
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_CLIENT_REQUEST_DURATION,
+ expectedLatencyBoundaries);
+ assertHistogramBoundaries(
+ metricsMap, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_TTFB, expectedLatencyBoundaries);
+
+ // Verify debug latency histograms have custom latency boundaries and slash names
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_SERVER_DURATION,
+ expectedLatencyBoundaries);
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_STALL_DURATION,
+ expectedLatencyBoundaries);
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_AUTH_CREDENTIAL_REFRESH_DURATION,
+ expectedLatencyBoundaries);
+
+ // Verify size histograms have custom size boundaries and slash names
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_REQUEST_BODY_SIZE,
+ expectedSizeBoundaries);
+ assertHistogramBoundaries(
+ metricsMap,
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_RESPONSE_BODY_SIZE,
+ expectedSizeBoundaries);
+
+ // Verify counter metrics and units
+ String operationsName =
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS.replace(".", "/");
+ assertThat(metricsMap).containsKey(operationsName);
+ assertThat(metricsMap.get(operationsName).getUnit()).isEqualTo("{operation}");
+
+ String attemptsName = StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS.replace(".", "/");
+ assertThat(metricsMap).containsKey(attemptsName);
+ assertThat(metricsMap.get(attemptsName).getUnit()).isEqualTo("{attempt}");
+
+ String retriesName = StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_RETRIES.replace(".", "/");
+ assertThat(metricsMap).containsKey(retriesName);
+ assertThat(metricsMap.get(retriesName).getUnit()).isEqualTo("{retry}");
+
+ String errorsName = StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS.replace(".", "/");
+ assertThat(metricsMap).containsKey(errorsName);
+ assertThat(metricsMap.get(errorsName).getUnit()).isEqualTo("{error}");
+
+ String requestActiveName =
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_REQUEST_ACTIVE.replace(".", "/");
+ assertThat(metricsMap).containsKey(requestActiveName);
+ assertThat(metricsMap.get(requestActiveName).getUnit()).isEqualTo("{request}");
+
+ String serverUnreachedName =
+ StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_SERVER_UNREACHED.replace(".", "/");
+ assertThat(metricsMap).containsKey(serverUnreachedName);
+ assertThat(metricsMap.get(serverUnreachedName).getUnit()).isEqualTo("{attempt}");
+
+ assertThat(metricsMap)
+ .containsKey(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_NETWORK_IO.replace(".", "/"));
+ }
+
+ @Test
+ public void latencyHistogram_recordsSecondsScaleValuesIntoExpectedBuckets() {
+ SdkMeterProviderBuilder providerBuilder =
+ SdkMeterProvider.builder().registerMetricReader(reader);
+ OpenTelemetryBootstrappingUtils.registerClientViews(providerBuilder);
+ SdkMeterProvider provider = providerBuilder.build();
+
+ StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false);
+ metrics.getGcpClientRequestDuration().record(0.003);
+ metrics.getGcpClientRequestDuration().record(1.2);
+ metrics.getGcpClientRequestDuration().record(45.0);
+
+ Collection collectedMetrics = reader.collectAllMetrics();
+ Map metricsMap =
+ collectedMetrics.stream()
+ .collect(Collectors.toMap(MetricData::getName, Function.identity()));
+
+ String metricViewName =
+ StorageClientMetrics.METRIC_GCP_CLIENT_REQUEST_DURATION.replace(".", "/");
+ assertThat(metricsMap).containsKey(metricViewName);
+
+ MetricData metricData = metricsMap.get(metricViewName);
+ HistogramPointData pointData = metricData.getHistogramData().getPoints().iterator().next();
+
+ List boundaries = pointData.getBoundaries();
+ List counts = pointData.getCounts();
+
+ int index003 = findBucketIndex(boundaries, 0.003);
+ int index12 = findBucketIndex(boundaries, 1.2);
+ int index45 = findBucketIndex(boundaries, 45.0);
+
+ // Verify bucket index for 0.003s is 2 (between 0.002s and 0.004s)
+ assertThat(index003).isEqualTo(2);
+ assertThat(boundaries.get(index003 - 1)).isEqualTo(0.002);
+ assertThat(boundaries.get(index003)).isEqualTo(0.004);
+ assertThat(counts.get(index003)).isEqualTo(1L);
+
+ // Verify 1.2s lands in its expected bucket
+ assertThat(boundaries.get(index12)).isEqualTo(1.2);
+ assertThat(boundaries.get(index12 - 1)).isLessThan(1.2);
+ assertThat(counts.get(index12)).isEqualTo(1L);
+
+ // Verify 45.0s lands in its expected bucket
+ assertThat(boundaries.get(index45)).isAtLeast(45.0);
+ assertThat(boundaries.get(index45 - 1)).isLessThan(45.0);
+ assertThat(counts.get(index45)).isEqualTo(1L);
+
+ // Total count across all buckets must be 3
+ long totalRecorded = counts.stream().mapToLong(Long::longValue).sum();
+ assertThat(totalRecorded).isEqualTo(3L);
+ }
+
+ @Test
+ public void attachClientAttributes_mergesAdditionalAttributes() {
+ SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build();
+ StorageClientMetrics metrics =
+ StorageClientMetrics.create(provider, "test-instance", true, false);
+
+ Attributes extra =
+ Attributes.of(
+ AttributeKey.stringKey("custom.key"),
+ "custom.value",
+ StorageClientMetrics.KEY_GCP_CLIENT_SERVICE,
+ "override-service");
+
+ Attributes merged = metrics.attachClientAttributes(extra);
+
+ assertThat(merged.get(StorageClientMetrics.KEY_GCP_CLIENT_INSTANCE_ID))
+ .isEqualTo("test-instance");
+ assertThat(merged.get(StorageClientMetrics.KEY_GCP_CLIENT_VERSION))
+ .isEqualTo(StorageOptions.version());
+ assertThat(merged.get(StorageClientMetrics.KEY_GCP_CLIENT_ARTIFACT))
+ .isEqualTo("com.google.cloud:google-cloud-storage");
+ assertThat(merged.get(AttributeKey.stringKey("custom.key"))).isEqualTo("custom.value");
+ assertThat(merged.get(StorageClientMetrics.KEY_GCP_CLIENT_SERVICE))
+ .isEqualTo("override-service");
+
+ // Null or empty handling
+ assertThat(metrics.attachClientAttributes(null))
+ .isSameInstanceAs(metrics.getClientAttributes());
+ assertThat(metrics.attachClientAttributes(Attributes.empty()))
+ .isSameInstanceAs(metrics.getClientAttributes());
+ }
+
+ @Test
+ public void clientAttributesBuilder_containsBaseClientAttributes() {
+ SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build();
+ StorageClientMetrics metrics =
+ StorageClientMetrics.create(provider, "test-instance", true, false);
+
+ Attributes built = metrics.clientAttributesBuilder().build();
+ assertThat(built).isEqualTo(metrics.getClientAttributes());
+ }
+
+ @Test
+ public void attributeKeys_haveExpectedNames() {
+ assertThat(StorageClientMetrics.KEY_URL_TEMPLATE.getKey()).isEqualTo("url.template");
+ assertThat(StorageClientMetrics.KEY_HTTP_REQUEST_METHOD.getKey())
+ .isEqualTo("http.request.method");
+ assertThat(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE.getKey())
+ .isEqualTo("http.response.status_code");
+ }
+
+ private static int findBucketIndex(List boundaries, double value) {
+ for (int i = 0; i < boundaries.size(); i++) {
+ if (value <= boundaries.get(i)) {
+ return i;
+ }
+ }
+ return boundaries.size();
+ }
+
+ private static void assertHistogramBoundaries(
+ Map metricsMap, String metricName, List expectedBoundaries) {
+ String viewName = metricName.replace(".", "/");
+ assertThat(metricsMap).containsKey(viewName);
+ MetricData metricData = metricsMap.get(viewName);
+ HistogramPointData pointData = metricData.getHistogramData().getPoints().iterator().next();
+ assertThat(pointData.getBoundaries()).isEqualTo(expectedBoundaries);
+ }
+
+ private static void assertInstruments(
+ StorageClientMetrics metrics,
+ List> instruments,
+ boolean isNull) {
+ for (Function getter : instruments) {
+ if (isNull) {
+ assertThat(getter.apply(metrics)).isNull();
+ } else {
+ assertThat(getter.apply(metrics)).isNotNull();
+ }
+ }
+ }
+}