From 2590f0e9f249b715e04c782765bd04b77107eade Mon Sep 17 00:00:00 2001 From: Nidhi Nandwani Date: Thu, 22 Oct 2026 07:39:50 +0000 Subject: [PATCH] feat: add http.client.request.duration metric for HTTP requests --- .../StorageHttpMetricsInterceptor.java | 226 +++++++++++ .../google/cloud/storage/StorageOptions.java | 11 + .../cloud/storage/spi/v1/HttpStorageRpc.java | 4 +- .../StorageHttpMetricsInterceptorTest.java | 374 ++++++++++++++++++ 4 files changed, 614 insertions(+), 1 deletion(-) create mode 100644 java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageHttpMetricsInterceptor.java create mode 100644 java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageHttpMetricsInterceptorTest.java 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..9c50c5d7448c --- /dev/null +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageHttpMetricsInterceptor.java @@ -0,0 +1,226 @@ +/* + * 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 io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.context.Context; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * HTTP request initializer that records HTTP client metrics such as http.client.request.duration. + */ +final class StorageHttpMetricsInterceptor implements HttpRequestInitializer { + + @Nullable private final HttpRequestInitializer delegate; + private final StorageClientMetrics metrics; + private final ApiClock clock; + @Nullable private final String defaultServerAddress; + + StorageHttpMetricsInterceptor( + @Nullable HttpRequestInitializer delegate, + StorageClientMetrics metrics, + ApiClock clock, + @Nullable String host) { + this.delegate = delegate; + this.metrics = metrics; + this.clock = clock; + this.defaultServerAddress = StorageOptions.extractServerAddress(host); + } + + @Override + public void initialize(HttpRequest request) throws java.io.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(clock.nanoTime()); + AtomicBoolean recorded = new AtomicBoolean(false); + + 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) + && metrics.getHttpClientRequestDuration() != null) { + double durationSeconds = + Math.max(0.0, (clock.nanoTime() - startTimeNanos.get()) / 1_000_000_000.0); + int statusCode = response.getStatusCode(); + String method = + request.getRequestMethod() != null ? request.getRequestMethod() : "UNKNOWN"; + String urlTemplate = extractUrlTemplate(request.getUrl()); + String serverAddress = resolveServerAddress(request.getUrl(), defaultServerAddress); + + AttributesBuilder attrBuilder = + metrics + .clientAttributesBuilder() + .put(StorageClientMetrics.KEY_HTTP_REQUEST_METHOD, method) + .put(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, (long) 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, + statusCode >= 400 + ? StorageErrorTypeMapper.mapHttpResponseCode(statusCode) + : StorageErrorTypeMapper.STATUS_OK); + + metrics + .getHttpClientRequestDuration() + .record(durationSeconds, attrBuilder.build(), Context.current()); + } + }); + + request.setIOExceptionHandler( + (req, supportsRetry) -> { + if (recorded.compareAndSet(false, true) + && metrics.getHttpClientRequestDuration() != null) { + double durationSeconds = + Math.max(0.0, (clock.nanoTime() - startTimeNanos.get()) / 1_000_000_000.0); + String method = req.getRequestMethod() != null ? req.getRequestMethod() : "UNKNOWN"; + String urlTemplate = extractUrlTemplate(req.getUrl()); + String serverAddress = resolveServerAddress(req.getUrl(), defaultServerAddress); + + AttributesBuilder attrBuilder = + metrics + .clientAttributesBuilder() + .put(StorageClientMetrics.KEY_HTTP_REQUEST_METHOD, method) + .put(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, 0L) + .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, + StorageErrorTypeMapper.STATUS_CONNECTIVITY); + + metrics + .getHttpClientRequestDuration() + .record(durationSeconds, attrBuilder.build(), Context.current()); + } + return existingIoHandler != null + && existingIoHandler.handleIOException(req, supportsRetry); + }); + } + + static String resolveServerAddress( + @Nullable GenericUrl url, @Nullable String defaultServerAddress) { + String host = url != null ? url.getHost() : null; + return (host != null && !host.isEmpty()) ? host : defaultServerAddress; + } + + 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 "/"; + } + 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/")) { + String rest = path.substring("/storage/v1/".length()); + String[] segments = rest.split("/"); + if (segments.length >= 2 && "b".equals(segments[0])) { + segments[1] = "{bucket}"; + if (segments.length >= 4 && "o".equals(segments[2])) { + segments[3] = "{object}"; + if (segments.length >= 6) { + if ("acl".equals(segments[4])) { + segments[5] = "{entity}"; + } else if ("copyTo".equals(segments[4]) || "rewriteTo".equals(segments[4])) { + if (segments.length >= 7 && "b".equals(segments[5])) { + segments[6] = "{destinationBucket}"; + } + if (segments.length >= 9 && "o".equals(segments[7])) { + segments[8] = "{destinationObject}"; + } + } else if ("moveTo".equals(segments[4]) + && "o".equals(segments[5]) + && segments.length >= 7) { + segments[6] = "{destinationObject}"; + } + } + } else if (segments.length >= 4) { + if ("acl".equals(segments[2]) || "defaultObjectAcl".equals(segments[2])) { + segments[3] = "{entity}"; + } else if ("notifications".equals(segments[2])) { + segments[3] = "{notification}"; + } + } + } else 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); + } + String trimmed = path.startsWith("/") ? path.substring(1) : path; + if (trimmed.isEmpty()) { + return "/"; + } + String[] parts = trimmed.split("/", 2); + return parts.length == 1 ? "/{bucket}" : "/{bucket}/{object}"; + } +} 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 330be593875f..9ad129f50f3e 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; @@ -518,6 +519,16 @@ private Attributes buildBaseMetricsAttributes(StorageClientMetrics metrics) { return builder.build(); } + @InternalApi + @Nullable + public HttpRequestInitializer decorateWithMetrics(@Nullable HttpRequestInitializer initializer) { + StorageClientMetrics metrics = getStorageClientMetrics(); + if (metrics == null) { + return initializer; + } + return new StorageHttpMetricsInterceptor(initializer, metrics, getClock(), getHost()); + } + @Nullable static String extractServerAddress(@Nullable String host) { if (host == null || host.trim().isEmpty()) { 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..930388f78572 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()) 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..0ce3d3376225 --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageHttpMetricsInterceptorTest.java @@ -0,0 +1,374 @@ +/* + * 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.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.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/my-obj"))) + .isEqualTo("/storage/v1/b/{bucket}/o/{object}"); + 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/projects/my-p/hmacKeys/key1"))) + .isEqualTo("/storage/v1/projects/{project}/hmacKeys/{accessId}"); + 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("/"); + } + + @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"); + + 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/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(); + + Attributes attrs = point.getAttributes(); + assertThat(attrs.get(StorageClientMetrics.KEY_HTTP_REQUEST_METHOD)).isEqualTo("GET"); + assertThat(attrs.get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)).isEqualTo(200L); + 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"); + + MockLowLevelHttpResponse lowLevelResponse = new MockLowLevelHttpResponse(); + lowLevelResponse.setStatusCode(308); + HttpTransport transport = + new MockHttpTransport.Builder().setLowLevelHttpResponse(lowLevelResponse).build(); + + 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(); + Attributes attrs = point.getAttributes(); + assertThat(attrs.get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)).isEqualTo(308L); + 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"); + + MockLowLevelHttpResponse lowLevelResponse = new MockLowLevelHttpResponse(); + lowLevelResponse.setStatusCode(404); + HttpTransport transport = + new MockHttpTransport.Builder().setLowLevelHttpResponse(lowLevelResponse).build(); + + 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(); + Attributes attrs = point.getAttributes(); + assertThat(attrs.get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)).isEqualTo(404L); + 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"); + + MockLowLevelHttpResponse lowLevelResponse = new MockLowLevelHttpResponse(); + lowLevelResponse.setStatusCode(503); + 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/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(); + Attributes attrs = point.getAttributes(); + assertThat(attrs.get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)).isEqualTo(503L); + 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() { + 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 { + throw new IOException("Connection reset by peer"); + } + }; + } + }; + + assertThrows( + IOException.class, + () -> { + HttpRequest req = + transport + .createRequestFactory(interceptor) + .buildGetRequest( + new GenericUrl("https://storage.googleapis.com/storage/v1/b/my-b")); + 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(); + Attributes attrs = point.getAttributes(); + assertThat(attrs.get(StorageClientMetrics.KEY_ERROR_TYPE)).isEqualTo("CONNECTIVITY"); + assertThat(attrs.get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)).isEqualTo(0L); + assertThat(attrs.get(StorageClientMetrics.KEY_SERVER_ADDRESS)) + .isEqualTo("storage.googleapis.com"); + } + + @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 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); + } + } +}