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