diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageHttpMetricsInterceptor.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageHttpMetricsInterceptor.java new file mode 100644 index 000000000000..b032ff77529e --- /dev/null +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageHttpMetricsInterceptor.java @@ -0,0 +1,239 @@ +/* + * 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.api.client.http.GenericUrl; +import com.google.api.client.http.HttpExecuteInterceptor; +import com.google.api.client.http.HttpIOExceptionHandler; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.http.HttpResponseInterceptor; +import com.google.api.core.ApiClock; +import com.google.common.collect.ImmutableSet; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * {@link HttpRequestInitializer} that instruments outgoing HTTP requests to record OpenTelemetry + * client metrics. + */ +@NullMarked +final class StorageHttpMetricsInterceptor implements HttpRequestInitializer { + + private static final ImmutableSet OBJECT_SUB_RESOURCES = + ImmutableSet.of("acl", "compose", "restore", "watchAll"); + + @Nullable private final HttpRequestInitializer delegate; + @Nullable private final StorageClientMetrics metrics; + private final ApiClock clock; + @Nullable private final String defaultServerAddress; + + StorageHttpMetricsInterceptor( + @Nullable HttpRequestInitializer delegate, + @Nullable StorageClientMetrics metrics, + ApiClock clock, + @Nullable String host) { + this.delegate = delegate; + this.metrics = metrics; + this.clock = clock; + this.defaultServerAddress = OpenTelemetryBootstrappingUtils.extractServerAddress(host); + } + + @Override + public void initialize(HttpRequest request) throws IOException { + if (delegate != null) { + delegate.initialize(request); + } + if (metrics == null || metrics.getHttpClientRequestDuration() == null) { + return; + } + + HttpExecuteInterceptor existingExec = request.getInterceptor(); + HttpResponseInterceptor existingResp = request.getResponseInterceptor(); + HttpIOExceptionHandler existingIoHandler = request.getIOExceptionHandler(); + + AtomicLong startTimeNanos = new AtomicLong(); + AtomicBoolean recorded = new AtomicBoolean(true); + + request.setInterceptor( + req -> { + if (existingExec != null) { + existingExec.intercept(req); + } + startTimeNanos.set(clock.nanoTime()); + recorded.set(false); + }); + + request.setResponseInterceptor( + response -> { + if (existingResp != null) { + existingResp.interceptResponse(response); + } + if (recorded.compareAndSet(false, true)) { + int statusCode = response.getStatusCode(); + String errorType = + statusCode >= 400 + ? StorageErrorTypeMapper.mapHttpResponseCode(statusCode) + : StorageErrorTypeMapper.STATUS_OK; + recordDuration(request, startTimeNanos.get(), String.valueOf(statusCode), errorType); + } + }); + + // HttpIOExceptionHandler does not receive the thrown IOException instance from + // google-http-client, so transport-level IOExceptions default to STATUS_CONNECTIVITY here + // (specific subtypes like TIMEOUT, DNS_FAILURE, and TLS_FAILURE are mapped in Retrying). + request.setIOExceptionHandler( + (req, supportsRetry) -> { + if (recorded.compareAndSet(false, true)) { + recordDuration( + req, startTimeNanos.get(), "0", StorageErrorTypeMapper.STATUS_CONNECTIVITY); + } + return existingIoHandler != null + && existingIoHandler.handleIOException(req, supportsRetry); + }); + } + + private void recordDuration( + HttpRequest request, long startNanos, String statusCode, String errorType) { + if (metrics == null) { + return; + } + double durationSeconds = Math.max(0.0, (clock.nanoTime() - startNanos) / 1_000_000_000.0); + String method = request.getRequestMethod() != null ? request.getRequestMethod() : "UNKNOWN"; + String urlTemplate = extractUrlTemplate(request.getUrl()); + String serverAddress = resolveServerAddress(request.getUrl(), defaultServerAddress); + + StorageMetricsContext.recordHttpRequestDuration( + metrics, durationSeconds, method, statusCode, urlTemplate, serverAddress, errorType); + } + + @Nullable + static String resolveServerAddress( + @Nullable GenericUrl url, @Nullable String defaultServerAddress) { + String host = url != null ? url.getHost() : null; + if (host == null || host.isEmpty()) { + return defaultServerAddress; + } + if (host.endsWith(".storage.googleapis.com") && !host.equals("storage.googleapis.com")) { + return "storage.googleapis.com"; + } + if (defaultServerAddress != null + && !defaultServerAddress.isEmpty() + && host.endsWith("." + defaultServerAddress)) { + return defaultServerAddress; + } + return host; + } + + static String extractUrlTemplate(@Nullable GenericUrl url) { + if (url == null) { + return "/"; + } + if (url.getFirst("upload_id") != null) { + return "/upload/storage/v1/b/{bucket}/o"; + } + String path = url.getRawPath(); + if (path == null || path.isEmpty()) { + return "/"; + } + String host = url.getHost(); + if (host != null + && host.endsWith(".storage.googleapis.com") + && !host.equals("storage.googleapis.com")) { + return "/".equals(path) ? "/" : "/{object}"; + } + if (path.length() > 1 && path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + if (path.startsWith("/upload/storage/v1/b/")) { + return "/upload/storage/v1/b/{bucket}/o"; + } + if (path.startsWith("/download/storage/v1/b/")) { + return "/download/storage/v1/b/{bucket}/o/{object}"; + } + if (path.startsWith("/batch/storage/v1")) { + return "/batch/storage/v1"; + } + if (path.startsWith("/storage/v1/")) { + return extractV1UrlTemplate(path.substring("/storage/v1/".length())); + } + String trimmed = path.startsWith("/") ? path.substring(1) : path; + if (trimmed.isEmpty()) { + return "/"; + } + String[] parts = trimmed.split("/", 2); + return parts.length == 1 ? "/{bucket}" : "/{bucket}/{object}"; + } + + private static String extractV1UrlTemplate(String rest) { + String[] segments = rest.split("/"); + if (segments.length >= 2 && "b".equals(segments[0])) { + segments[1] = "{bucket}"; + if (segments.length >= 3 && "o".equals(segments[2])) { + return extractObjectUrlTemplate(segments); + } + if (segments.length >= 4) { + if ("acl".equals(segments[2]) || "defaultObjectAcl".equals(segments[2])) { + segments[3] = "{entity}"; + } else if ("notifications".equals(segments[2]) + || "notificationConfigs".equals(segments[2])) { + segments[3] = "{notification}"; + } + } + return "/storage/v1/" + String.join("/", segments); + } + if (segments.length >= 2 && "projects".equals(segments[0])) { + segments[1] = "{project}"; + if (segments.length >= 4 && "hmacKeys".equals(segments[2])) { + segments[3] = "{accessId}"; + } + return "/storage/v1/" + String.join("/", segments); + } + return "/storage/v1"; + } + + private static String extractObjectUrlTemplate(String[] segments) { + if (segments.length == 3) { + return "/storage/v1/b/{bucket}/o"; + } + if (segments.length == 4) { + return "/storage/v1/b/{bucket}/o/{object}"; + } + String action = segments[4]; + if (segments.length == 5 && OBJECT_SUB_RESOURCES.contains(action)) { + return "/storage/v1/b/{bucket}/o/{object}/" + action; + } + if ("acl".equals(action)) { + return "/storage/v1/b/{bucket}/o/{object}/acl/{entity}"; + } + if (("copyTo".equals(action) || "rewriteTo".equals(action)) + && segments.length >= 8 + && "b".equals(segments[5]) + && "o".equals(segments[7])) { + return "/storage/v1/b/{bucket}/o/{object}/" + + action + + "/b/{destinationBucket}/o/{destinationObject}"; + } + if ("moveTo".equals(action) && segments.length >= 7 && "o".equals(segments[5])) { + return "/storage/v1/b/{bucket}/o/{object}/moveTo/o/{destinationObject}"; + } + return "/storage/v1/b/{bucket}/o/{object}"; + } +} diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageMetricsContext.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageMetricsContext.java index 93715ad69c2e..9caf52d602cb 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageMetricsContext.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageMetricsContext.java @@ -295,6 +295,32 @@ static void recordRpcCallDuration( metrics.getRpcClientCallDuration().record(durationSeconds, attributes, otelContext); } + static void recordHttpRequestDuration( + @Nullable StorageClientMetrics metrics, + double durationSeconds, + String httpMethod, + String statusCode, + String urlTemplate, + @Nullable String serverAddress, + String errorType) { + if (metrics == null || metrics.getHttpClientRequestDuration() == null) { + return; + } + Attributes attributes = + metrics + .clientAttributesBuilder() + .put(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "http") + .put(StorageClientMetrics.KEY_HTTP_REQUEST_METHOD, httpMethod) + .put(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, statusCode) + .put(StorageClientMetrics.KEY_NETWORK_PATH, StorageClientMetrics.NETWORK_PATH_CLOUDPATH) + .put(StorageClientMetrics.KEY_URL_TEMPLATE, urlTemplate) + .put( + StorageClientMetrics.KEY_SERVER_ADDRESS, serverAddress != null ? serverAddress : "") + .put(StorageClientMetrics.KEY_ERROR_TYPE, errorType) + .build(); + metrics.getHttpClientRequestDuration().record(durationSeconds, attributes, Context.current()); + } + private Attributes buildAttemptBaseAttributes(StorageClientMetrics metrics) { AttributesBuilder baseBuilder = Attributes.builder(); baseBuilder.putAll(metrics.getClientAttributes()); 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 15c2255059dc..fe1230cc1f46 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 @@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkArgument; +import com.google.api.client.http.HttpRequestInitializer; import com.google.api.core.BetaApi; import com.google.api.core.InternalApi; import com.google.cloud.NoCredentials; @@ -397,6 +398,16 @@ synchronized void closeStorageClientMetrics() { } } + @InternalApi + @Nullable + public HttpRequestInitializer decorateWithMetrics(@Nullable HttpRequestInitializer initializer) { + StorageClientMetrics metrics = getStorageClientMetrics(); + if (metrics == null) { + return initializer; + } + return new StorageHttpMetricsInterceptor(initializer, metrics, getClock(), getHost()); + } + /** * The metric export interval configured for periodic metric reading. * diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java index ec7d8e02e411..5de7d60be4c4 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java @@ -157,7 +157,9 @@ public HttpStorageRpc(StorageOptions options, JsonFactory jsonFactory) { CensusHttpModule censusHttpModule = new CensusHttpModule(tracer, IS_RECORD_EVENTS); initializer = censusHttpModule.getHttpRequestInitializer(initializer); initializer = new InvocationIdInitializer(initializer, applicationName, tm); - batchRequestInitializer = censusHttpModule.getHttpRequestInitializer(null); + initializer = options.decorateWithMetrics(initializer); + batchRequestInitializer = + options.decorateWithMetrics(censusHttpModule.getHttpRequestInitializer(null)); storage = new Storage.Builder(transport, jsonFactory, initializer) .setRootUrl(options.getHost()) @@ -262,7 +264,7 @@ public void addDelete( StorageObject storageObject, RpcBatch.Callback callback, Map options) { try { if (currentBatchSize == MAX_BATCH_SIZE) { - batches.add(storage.batch()); + batches.add(storage.batch(batchRequestInitializer)); currentBatchSize = 0; } Delete call = deleteCall(storageObject, options); @@ -281,7 +283,7 @@ public void addPatch( Map options) { try { if (currentBatchSize == MAX_BATCH_SIZE) { - batches.add(storage.batch()); + batches.add(storage.batch(batchRequestInitializer)); currentBatchSize = 0; } Patch call = patchCall(storageObject, options); @@ -300,7 +302,7 @@ public void addGet( Map options) { try { if (currentBatchSize == MAX_BATCH_SIZE) { - batches.add(storage.batch()); + batches.add(storage.batch(batchRequestInitializer)); currentBatchSize = 0; } Get call = getCall(storageObject, options); diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageHttpMetricsInterceptorTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageHttpMetricsInterceptorTest.java new file mode 100644 index 000000000000..25cd88f605b9 --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageHttpMetricsInterceptorTest.java @@ -0,0 +1,513 @@ +/* + * 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 static org.junit.Assert.assertThrows; + +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.http.LowLevelHttpResponse; +import com.google.api.client.testing.http.MockHttpTransport; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; +import com.google.api.client.testing.http.MockLowLevelHttpResponse; +import com.google.api.core.ApiClock; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.data.HistogramPointData; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.io.IOException; +import java.util.Collection; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Before; +import org.junit.Test; + +public final class StorageHttpMetricsInterceptorTest { + + private InMemoryMetricReader reader; + private SdkMeterProvider meterProvider; + private FakeClock clock; + + @Before + public void setUp() { + reader = InMemoryMetricReader.create(); + meterProvider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + clock = new FakeClock(); + } + + @Test + public void extractUrlTemplate_cases() { + assertThat(StorageHttpMetricsInterceptor.extractUrlTemplate(null)).isEqualTo("/"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/upload/storage/v1/b/my-b/o?upload_id=xyz"))) + .isEqualTo("/upload/storage/v1/b/{bucket}/o"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://storage.googleapis.com/upload/storage/v1/b/my-b/o"))) + .isEqualTo("/upload/storage/v1/b/{bucket}/o"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/download/storage/v1/b/my-b/o/my-obj"))) + .isEqualTo("/download/storage/v1/b/{bucket}/o/{object}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://storage.googleapis.com/batch/storage/v1"))) + .isEqualTo("/batch/storage/v1"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://storage.googleapis.com/storage/v1/b/my-b"))) + .isEqualTo("/storage/v1/b/{bucket}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://storage.googleapis.com/storage/v1/b/my-b/o"))) + .isEqualTo("/storage/v1/b/{bucket}/o"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://storage.googleapis.com/storage/v1/b/my-b/o/my-obj"))) + .isEqualTo("/storage/v1/b/{bucket}/o/{object}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/storage/v1/b/my-b/o/dir/sub/file.txt"))) + .isEqualTo("/storage/v1/b/{bucket}/o/{object}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/storage/v1/b/my-b/o/my-obj/compose"))) + .isEqualTo("/storage/v1/b/{bucket}/o/{object}/compose"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/storage/v1/b/my-b/o/my-obj/restore"))) + .isEqualTo("/storage/v1/b/{bucket}/o/{object}/restore"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/storage/v1/b/my-b/o/my-obj/watchAll"))) + .isEqualTo("/storage/v1/b/{bucket}/o/{object}/watchAll"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/storage/v1/b/my-b/o/my-obj/acl/allUsers"))) + .isEqualTo("/storage/v1/b/{bucket}/o/{object}/acl/{entity}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/storage/v1/b/b1/o/o1/copyTo/b/b2/o/o2"))) + .isEqualTo( + "/storage/v1/b/{bucket}/o/{object}/copyTo/b/{destinationBucket}/o/{destinationObject}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/storage/v1/b/b1/o/o1/rewriteTo/b/b2/o/o2"))) + .isEqualTo( + "/storage/v1/b/{bucket}/o/{object}/rewriteTo/b/{destinationBucket}/o/{destinationObject}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://storage.googleapis.com/storage/v1/b/b1/o/o1/moveTo/o/o2"))) + .isEqualTo("/storage/v1/b/{bucket}/o/{object}/moveTo/o/{destinationObject}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://storage.googleapis.com/storage/v1/b/b1/acl/allUsers"))) + .isEqualTo("/storage/v1/b/{bucket}/acl/{entity}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/storage/v1/b/b1/defaultObjectAcl/allUsers"))) + .isEqualTo("/storage/v1/b/{bucket}/defaultObjectAcl/{entity}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/storage/v1/b/b1/notifications/notif1"))) + .isEqualTo("/storage/v1/b/{bucket}/notifications/{notification}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://storage.googleapis.com/storage/v1/b/b1/iam"))) + .isEqualTo("/storage/v1/b/{bucket}/iam"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/storage/v1/b/b1/iam/testPermissions"))) + .isEqualTo("/storage/v1/b/{bucket}/iam/testPermissions"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/storage/v1/projects/my-p/hmacKeys/key1"))) + .isEqualTo("/storage/v1/projects/{project}/hmacKeys/{accessId}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl( + "https://storage.googleapis.com/storage/v1/projects/my-p/serviceAccount"))) + .isEqualTo("/storage/v1/projects/{project}/serviceAccount"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://storage.googleapis.com/my-b"))) + .isEqualTo("/{bucket}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://storage.googleapis.com/my-b/my-obj"))) + .isEqualTo("/{bucket}/{object}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://storage.googleapis.com/"))) + .isEqualTo("/"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://my-bucket.storage.googleapis.com/"))) + .isEqualTo("/"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://my-bucket.storage.googleapis.com/my-obj"))) + .isEqualTo("/{object}"); + assertThat( + StorageHttpMetricsInterceptor.extractUrlTemplate( + new GenericUrl("https://my-bucket.storage.googleapis.com/dir/my-obj"))) + .isEqualTo("/{object}"); + } + + @Test + public void resolveServerAddress_stripsVirtualHostedBucketPrefix() { + assertThat( + StorageHttpMetricsInterceptor.resolveServerAddress( + new GenericUrl("https://my-bucket.storage.googleapis.com/my-obj"), + "storage.googleapis.com")) + .isEqualTo("storage.googleapis.com"); + assertThat( + StorageHttpMetricsInterceptor.resolveServerAddress( + new GenericUrl("https://storage.googleapis.com/storage/v1/b/my-b"), + "storage.googleapis.com")) + .isEqualTo("storage.googleapis.com"); + assertThat(StorageHttpMetricsInterceptor.resolveServerAddress(null, "storage.googleapis.com")) + .isEqualTo("storage.googleapis.com"); + } + + @Test + public void initialize_200Ok_recordsDurationWithoutErrorType() throws IOException { + StorageClientMetrics metrics = StorageClientMetrics.create(meterProvider, true, false); + StorageHttpMetricsInterceptor interceptor = + new StorageHttpMetricsInterceptor(null, metrics, clock, "https://storage.googleapis.com"); + + HttpTransport transport = createAdvancingTransport(200, 250_000_000L); + + HttpRequest request = + transport + .createRequestFactory(interceptor) + .buildGetRequest( + new GenericUrl("https://storage.googleapis.com/storage/v1/b/my-b/o/my-obj")); + request.execute(); + + Collection metricData = reader.collectAllMetrics(); + MetricData durationData = + metricData.stream() + .filter( + m -> m.getName().equals(StorageClientMetrics.METRIC_HTTP_CLIENT_REQUEST_DURATION)) + .findFirst() + .orElse(null); + + assertThat(durationData).isNotNull(); + assertThat(durationData.getHistogramData().getPoints()).hasSize(1); + HistogramPointData point = durationData.getHistogramData().getPoints().iterator().next(); + assertThat(point.getSum()).isWithin(1e-6).of(0.25); + + Attributes attrs = point.getAttributes(); + assertThat(attrs.get(StorageClientMetrics.KEY_RPC_SYSTEM_NAME)).isEqualTo("http"); + assertThat(attrs.get(StorageClientMetrics.KEY_HTTP_REQUEST_METHOD)).isEqualTo("GET"); + assertThat(attrs.get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)).isEqualTo("200"); + assertThat(attrs.get(StorageClientMetrics.KEY_NETWORK_PATH)) + .isEqualTo(StorageClientMetrics.NETWORK_PATH_CLOUDPATH); + assertThat(attrs.get(StorageClientMetrics.KEY_URL_TEMPLATE)) + .isEqualTo("/storage/v1/b/{bucket}/o/{object}"); + assertThat(attrs.get(StorageClientMetrics.KEY_SERVER_ADDRESS)) + .isEqualTo("storage.googleapis.com"); + assertThat(attrs.get(StorageClientMetrics.KEY_ERROR_TYPE)).isEqualTo("OK"); + } + + @Test + public void initialize_308ResumeIncomplete_recordsWithoutErrorType() throws IOException { + StorageClientMetrics metrics = StorageClientMetrics.create(meterProvider, true, false); + StorageHttpMetricsInterceptor interceptor = + new StorageHttpMetricsInterceptor(null, metrics, clock, "https://storage.googleapis.com"); + + HttpTransport transport = createAdvancingTransport(308, 120_000_000L); + + HttpRequest request = + transport + .createRequestFactory(interceptor) + .buildPutRequest( + new GenericUrl( + "https://storage.googleapis.com/upload/storage/v1/b/my-b/o?upload_id=123"), + null); + request.setThrowExceptionOnExecuteError(false); + request.execute(); + + Collection metricData = reader.collectAllMetrics(); + MetricData durationData = + metricData.stream() + .filter( + m -> m.getName().equals(StorageClientMetrics.METRIC_HTTP_CLIENT_REQUEST_DURATION)) + .findFirst() + .orElse(null); + + assertThat(durationData).isNotNull(); + HistogramPointData point = durationData.getHistogramData().getPoints().iterator().next(); + assertThat(point.getSum()).isWithin(1e-6).of(0.12); + Attributes attrs = point.getAttributes(); + assertThat(attrs.get(StorageClientMetrics.KEY_RPC_SYSTEM_NAME)).isEqualTo("http"); + assertThat(attrs.get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)).isEqualTo("308"); + assertThat(attrs.get(StorageClientMetrics.KEY_ERROR_TYPE)).isEqualTo("OK"); + assertThat(attrs.get(StorageClientMetrics.KEY_SERVER_ADDRESS)) + .isEqualTo("storage.googleapis.com"); + } + + @Test + public void initialize_404NotFound_recordsErrorType() throws IOException { + StorageClientMetrics metrics = StorageClientMetrics.create(meterProvider, true, false); + StorageHttpMetricsInterceptor interceptor = + new StorageHttpMetricsInterceptor(null, metrics, clock, "https://storage.googleapis.com"); + + HttpTransport transport = createAdvancingTransport(404, 80_000_000L); + + HttpRequest request = + transport + .createRequestFactory(interceptor) + .buildGetRequest( + new GenericUrl("https://storage.googleapis.com/storage/v1/b/missing-bucket")); + request.setThrowExceptionOnExecuteError(false); + request.execute(); + + Collection metricData = reader.collectAllMetrics(); + MetricData durationData = + metricData.stream() + .filter( + m -> m.getName().equals(StorageClientMetrics.METRIC_HTTP_CLIENT_REQUEST_DURATION)) + .findFirst() + .orElse(null); + + assertThat(durationData).isNotNull(); + HistogramPointData point = durationData.getHistogramData().getPoints().iterator().next(); + assertThat(point.getSum()).isWithin(1e-6).of(0.08); + Attributes attrs = point.getAttributes(); + assertThat(attrs.get(StorageClientMetrics.KEY_RPC_SYSTEM_NAME)).isEqualTo("http"); + assertThat(attrs.get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)).isEqualTo("404"); + assertThat(attrs.get(StorageClientMetrics.KEY_ERROR_TYPE)).isEqualTo("NOT_FOUND"); + assertThat(attrs.get(StorageClientMetrics.KEY_SERVER_ADDRESS)) + .isEqualTo("storage.googleapis.com"); + } + + @Test + public void initialize_503ServiceUnavailable_recordsErrorType() throws IOException { + StorageClientMetrics metrics = StorageClientMetrics.create(meterProvider, true, false); + StorageHttpMetricsInterceptor interceptor = + new StorageHttpMetricsInterceptor(null, metrics, clock, "https://storage.googleapis.com"); + + HttpTransport transport = createAdvancingTransport(503, 400_000_000L); + + HttpRequest request = + transport + .createRequestFactory(interceptor) + .buildGetRequest( + new GenericUrl("https://storage.googleapis.com/storage/v1/b/my-b/o/my-obj")); + request.setThrowExceptionOnExecuteError(false); + request.execute(); + + Collection metricData = reader.collectAllMetrics(); + MetricData durationData = + metricData.stream() + .filter( + m -> m.getName().equals(StorageClientMetrics.METRIC_HTTP_CLIENT_REQUEST_DURATION)) + .findFirst() + .orElse(null); + + assertThat(durationData).isNotNull(); + HistogramPointData point = durationData.getHistogramData().getPoints().iterator().next(); + assertThat(point.getSum()).isWithin(1e-6).of(0.40); + Attributes attrs = point.getAttributes(); + assertThat(attrs.get(StorageClientMetrics.KEY_RPC_SYSTEM_NAME)).isEqualTo("http"); + assertThat(attrs.get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)).isEqualTo("503"); + assertThat(attrs.get(StorageClientMetrics.KEY_ERROR_TYPE)).isEqualTo("UNAVAILABLE"); + assertThat(attrs.get(StorageClientMetrics.KEY_SERVER_ADDRESS)) + .isEqualTo("storage.googleapis.com"); + } + + @Test + public void initialize_networkIOException_recordsErrorTypeWithoutStatusCode() throws IOException { + StorageClientMetrics metrics = StorageClientMetrics.create(meterProvider, true, false); + StorageHttpMetricsInterceptor interceptor = + new StorageHttpMetricsInterceptor(null, metrics, clock, "https://storage.googleapis.com"); + + HttpTransport transport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest() { + @Override + public LowLevelHttpResponse execute() throws IOException { + clock.advanceNanos(150_000_000L); + throw new IOException("Connection reset by peer"); + } + }; + } + }; + + HttpRequest req = + transport + .createRequestFactory(interceptor) + .buildGetRequest(new GenericUrl("https://storage.googleapis.com/storage/v1/b/my-b")); + + assertThrows(IOException.class, req::execute); + + Collection metricData = reader.collectAllMetrics(); + MetricData durationData = + metricData.stream() + .filter( + m -> m.getName().equals(StorageClientMetrics.METRIC_HTTP_CLIENT_REQUEST_DURATION)) + .findFirst() + .orElse(null); + + assertThat(durationData).isNotNull(); + HistogramPointData point = durationData.getHistogramData().getPoints().iterator().next(); + assertThat(point.getSum()).isWithin(1e-6).of(0.15); + Attributes attrs = point.getAttributes(); + assertThat(attrs.get(StorageClientMetrics.KEY_RPC_SYSTEM_NAME)).isEqualTo("http"); + assertThat(attrs.get(StorageClientMetrics.KEY_ERROR_TYPE)).isEqualTo("CONNECTIVITY"); + assertThat(attrs.get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)).isEqualTo("0"); + assertThat(attrs.get(StorageClientMetrics.KEY_SERVER_ADDRESS)) + .isEqualTo("storage.googleapis.com"); + } + + @Test + public void initialize_ioExceptionBeforeExecuteInterceptorRuns_doesNotRecordMetric() + throws IOException { + StorageClientMetrics metrics = StorageClientMetrics.create(meterProvider, true, false); + StorageHttpMetricsInterceptor interceptor = + new StorageHttpMetricsInterceptor(null, metrics, clock, "https://storage.googleapis.com"); + + HttpTransport transport = new MockHttpTransport(); + HttpRequest req = + transport + .createRequestFactory(interceptor) + .buildGetRequest(new GenericUrl("https://storage.googleapis.com/storage/v1/b/my-b")); + + clock.advanceNanos(500_000_000L); + boolean handled = req.getIOExceptionHandler().handleIOException(req, false); + + assertThat(handled).isFalse(); + assertThat(reader.collectAllMetrics()).isEmpty(); + } + + @Test + public void initialize_chainsExistingDelegateAndInterceptors() throws IOException { + StorageClientMetrics metrics = StorageClientMetrics.create(meterProvider, true, false); + AtomicBoolean delegateCalled = new AtomicBoolean(false); + AtomicBoolean execCalled = new AtomicBoolean(false); + AtomicBoolean respCalled = new AtomicBoolean(false); + AtomicBoolean ioHandlerCalled = new AtomicBoolean(false); + + HttpRequestInitializer delegate = + request -> { + delegateCalled.set(true); + request.setInterceptor(req -> execCalled.set(true)); + request.setResponseInterceptor(resp -> respCalled.set(true)); + request.setIOExceptionHandler( + (req, supportsRetry) -> { + ioHandlerCalled.set(true); + return false; + }); + }; + + StorageHttpMetricsInterceptor interceptor = + new StorageHttpMetricsInterceptor( + delegate, metrics, clock, "https://storage.googleapis.com"); + + HttpTransport transport = createAdvancingTransport(200, 100_000_000L); + HttpRequest request = + transport + .createRequestFactory(interceptor) + .buildGetRequest(new GenericUrl("https://storage.googleapis.com/storage/v1/b/my-b")); + request.execute(); + + assertThat(delegateCalled.get()).isTrue(); + assertThat(execCalled.get()).isTrue(); + assertThat(respCalled.get()).isTrue(); + + request.getIOExceptionHandler().handleIOException(request, false); + assertThat(ioHandlerCalled.get()).isTrue(); + } + + @Test + public void initialize_metricsDisabled_doesNotRecord() throws IOException { + StorageClientMetrics metrics = StorageClientMetrics.create(meterProvider, false, false); + StorageHttpMetricsInterceptor interceptor = + new StorageHttpMetricsInterceptor(null, metrics, clock, "https://storage.googleapis.com"); + + MockLowLevelHttpResponse lowLevelResponse = new MockLowLevelHttpResponse(); + lowLevelResponse.setStatusCode(200); + HttpTransport transport = + new MockHttpTransport.Builder().setLowLevelHttpResponse(lowLevelResponse).build(); + + HttpRequest request = + transport + .createRequestFactory(interceptor) + .buildGetRequest(new GenericUrl("https://storage.googleapis.com/storage/v1/b/my-b")); + request.execute(); + + assertThat(reader.collectAllMetrics()).isEmpty(); + } + + private HttpTransport createAdvancingTransport(int statusCode, long advanceNanos) { + return new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest() { + @Override + public LowLevelHttpResponse execute() { + clock.advanceNanos(advanceNanos); + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(statusCode); + return response; + } + }; + } + }; + } + + private static final class FakeClock implements ApiClock { + private final AtomicLong nanos = new AtomicLong(1_000_000_000L); + + @Override + public long nanoTime() { + return nanos.get(); + } + + @Override + public long millisTime() { + return nanos.get() / 1_000_000L; + } + + void advanceNanos(long amount) { + nanos.addAndGet(amount); + } + } +}