diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageGrpcMetricsInterceptor.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageGrpcMetricsInterceptor.java index c2b36f5f8c9f..06ccb3a37330 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageGrpcMetricsInterceptor.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageGrpcMetricsInterceptor.java @@ -61,11 +61,18 @@ final class StorageGrpcMetricsInterceptor implements ClientInterceptor { @Override public ClientCall interceptCall( MethodDescriptor method, CallOptions callOptions, Channel next) { - if (metrics == null || metrics.getRpcClientCallDuration() == null) { + if (metrics == null) { return next.newCall(method, callOptions); } String shortMethod = extractShortMethodName(method.getFullMethodName()); + boolean isReadMethod = "ReadObject".equals(shortMethod) || "BidiReadObject".equals(shortMethod); + boolean recordDuration = metrics.getRpcClientCallDuration() != null; + boolean recordTtfb = isReadMethod && metrics.getTtfb() != null; + if (!recordDuration && !recordTtfb) { + return next.newCall(method, callOptions); + } + String resolvedServerAddress = defaultServerAddress != null ? defaultServerAddress @@ -79,14 +86,28 @@ public ClientCall interceptCall( public void start(Listener responseListener, Metadata headers) { long startTimeNanos = clock.nanoTime(); AtomicBoolean callRecorded = new AtomicBoolean(false); + AtomicBoolean ttfbRecorded = new AtomicBoolean(false); super.start( new SimpleForwardingClientCallListener(responseListener) { + @Override + public void onMessage(RespT message) { + // The first response message from the server (including metadata-only + // responses for 0-byte objects) marks Time To First Byte (TTFB). + if (recordTtfb && ttfbRecorded.compareAndSet(false, true)) { + double ttfbSeconds = + Math.max(0.0, (clock.nanoTime() - startTimeNanos) / 1_000_000_000.0); + String networkPath = detectNetworkPath(getAttributes(), isDirectPathEndpoint); + StorageMetricsContext.recordTtfb( + metrics, metricsCtx, ttfbSeconds, "grpc", networkPath, serverAddress); + } + super.onMessage(message); + } + @Override public void onClose(Status status, Metadata trailers) { try { - if (callRecorded.compareAndSet(false, true) - && metrics.getRpcClientCallDuration() != null) { + if (recordDuration && callRecorded.compareAndSet(false, true)) { double durationSeconds = Math.max(0.0, (clock.nanoTime() - startTimeNanos) / 1_000_000_000.0); String networkPath = detectNetworkPath(getAttributes(), isDirectPathEndpoint); 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 index b032ff77529e..6df7382a1b6b 100644 --- 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 @@ -61,7 +61,12 @@ public void initialize(HttpRequest request) throws IOException { if (delegate != null) { delegate.initialize(request); } - if (metrics == null || metrics.getHttpClientRequestDuration() == null) { + if (metrics == null) { + return; + } + boolean recordDuration = metrics.getHttpClientRequestDuration() != null; + boolean recordTtfb = metrics.getTtfb() != null; + if (!recordDuration && !recordTtfb) { return; } @@ -88,26 +93,48 @@ public void initialize(HttpRequest request) throws IOException { } 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); + long startNanos = startTimeNanos.get(); + if (recordDuration) { + String errorType = + statusCode >= 400 + ? StorageErrorTypeMapper.mapHttpResponseCode(statusCode) + : StorageErrorTypeMapper.STATUS_OK; + recordDuration(request, startNanos, String.valueOf(statusCode), errorType); + } + // Evaluate isMediaDownloadRequest(request) at response time rather than in + // initialize(HttpRequest), because callers such as MediaHttpDownloader may append + // query parameters (e.g. alt=media) to request.getUrl() after buildGetRequest(). + if (recordTtfb + && statusCode >= 200 + && statusCode < 300 + && isMediaDownloadRequest(request)) { + double ttfbSeconds = Math.max(0.0, (clock.nanoTime() - startNanos) / 1_000_000_000.0); + String serverAddress = resolveServerAddress(request.getUrl(), defaultServerAddress); + StorageMetricsContext.recordTtfb( + metrics, + null, + ttfbSeconds, + "http", + StorageClientMetrics.NETWORK_PATH_CLOUDPATH, + serverAddress); + } } }); - // 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); - }); + if (recordDuration) { + // 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( @@ -182,6 +209,19 @@ static String extractUrlTemplate(@Nullable GenericUrl url) { return parts.length == 1 ? "/{bucket}" : "/{bucket}/{object}"; } + static boolean isMediaDownloadRequest(@Nullable HttpRequest request) { + if (request == null) { + return false; + } + if (!"GET".equalsIgnoreCase(request.getRequestMethod()) || request.getUrl() == null) { + return false; + } + GenericUrl url = request.getUrl(); + String path = url.getRawPath(); + return (path != null && path.startsWith("/download/storage/v1/")) + || "media".equals(url.getFirst("alt")); + } + private static String extractV1UrlTemplate(String rest) { String[] segments = rest.split("/"); if (segments.length >= 2 && "b".equals(segments[0])) { 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 9caf52d602cb..4398657793f4 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 @@ -321,6 +321,34 @@ static void recordHttpRequestDuration( metrics.getHttpClientRequestDuration().record(durationSeconds, attributes, Context.current()); } + static void recordTtfb( + @Nullable StorageClientMetrics metrics, + @Nullable StorageMetricsContext metricsContext, + double ttfbSeconds, + String rpcSystemName, + String networkPath, + @Nullable String serverAddress) { + if (metrics == null || metrics.getTtfb() == null) { + return; + } + Context otelContext = + metricsContext != null ? metricsContext.getOtelContext() : Context.current(); + String operationName = + metricsContext != null && metricsContext.getOperationName() != null + ? metricsContext.getOperationName() + : "ReadObject"; + Attributes attributes = + metrics + .clientAttributesBuilder() + .put(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, rpcSystemName) + .put(StorageClientMetrics.KEY_GCP_STORAGE_OPERATION, operationName) + .put(StorageClientMetrics.KEY_NETWORK_PATH, networkPath) + .put( + StorageClientMetrics.KEY_SERVER_ADDRESS, serverAddress != null ? serverAddress : "") + .build(); + metrics.getTtfb().record(ttfbSeconds, attributes, otelContext); + } + private Attributes buildAttemptBaseAttributes(StorageClientMetrics metrics) { AttributesBuilder baseBuilder = Attributes.builder(); baseBuilder.putAll(metrics.getClientAttributes()); diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageGrpcMetricsInterceptorTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageGrpcMetricsInterceptorTest.java index 214bb5462175..f80ec35e7ea9 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageGrpcMetricsInterceptorTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageGrpcMetricsInterceptorTest.java @@ -59,6 +59,22 @@ public final class StorageGrpcMetricsInterceptorTest { .setResponseMarshaller(new NoopMarshaller()) .build(); + private static final MethodDescriptor BIDI_READ_OBJECT_METHOD = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.BIDI_STREAMING) + .setFullMethodName("google.storage.v2.Storage/BidiReadObject") + .setRequestMarshaller(new NoopMarshaller()) + .setResponseMarshaller(new NoopMarshaller()) + .build(); + + private static final MethodDescriptor WRITE_OBJECT_METHOD = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.CLIENT_STREAMING) + .setFullMethodName("google.storage.v2.Storage/WriteObject") + .setRequestMarshaller(new NoopMarshaller()) + .setResponseMarshaller(new NoopMarshaller()) + .build(); + private InMemoryMetricReader reader; private SdkMeterProvider meterProvider; private FakeClock clock; @@ -217,6 +233,134 @@ public void interceptCall_callFails_recordsErrorType() throws Exception { .isEqualTo("NOT_FOUND"); assertThat(point.getAttributes().get(StorageClientMetrics.KEY_SERVER_ADDRESS)) .isEqualTo("storage.googleapis.com"); + + MetricData ttfbData = + metricData.stream() + .filter(m -> m.getName().equals(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_TTFB)) + .findFirst() + .orElse(null); + assertThat(ttfbData).isNull(); + } + + @Test + public void interceptCall_readObject_recordsTtfbOnFirstMessage() throws Exception { + StorageClientMetrics metrics = StorageClientMetrics.create(meterProvider, true, false); + StorageGrpcMetricsInterceptor interceptor = + new StorageGrpcMetricsInterceptor(metrics, clock, "storage.googleapis.com"); + + TestClientCall testCall = new TestClientCall<>(io.grpc.Attributes.EMPTY); + TestChannel testChannel = new TestChannel(testCall, "storage.googleapis.com"); + + ClientCall call = + interceptor.interceptCall(READ_OBJECT_METHOD, CallOptions.DEFAULT, testChannel); + call.start(new TestListener<>(), new Metadata()); + + clock.advanceNanos(200_000_000L); // 0.2s + testCall.deliverMessage(null); // First response message: triggers TTFB + + clock.advanceNanos(300_000_000L); // 0.3s more (total 0.5s) + testCall.deliverMessage(null); // Second message: should NOT record TTFB again + testCall.close(Status.OK, new Metadata()); + + Collection metricData = reader.collectAllMetrics(); + MetricData ttfbData = + metricData.stream() + .filter(m -> m.getName().equals(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_TTFB)) + .findFirst() + .orElse(null); + + assertThat(ttfbData).isNotNull(); + assertThat(ttfbData.getHistogramData().getPoints()).hasSize(1); + HistogramPointData ttfbPoint = ttfbData.getHistogramData().getPoints().iterator().next(); + assertThat(ttfbPoint.getCount()).isEqualTo(1L); + assertThat(ttfbPoint.getSum()).isWithin(1e-6).of(0.2); + assertThat(ttfbPoint.getAttributes().get(StorageClientMetrics.KEY_RPC_SYSTEM_NAME)) + .isEqualTo("grpc"); + assertThat(ttfbPoint.getAttributes().get(StorageClientMetrics.KEY_GCP_STORAGE_OPERATION)) + .isEqualTo("ReadObject"); + assertThat(ttfbPoint.getAttributes().get(StorageClientMetrics.KEY_NETWORK_PATH)) + .isEqualTo(StorageClientMetrics.NETWORK_PATH_UNKNOWN); + assertThat(ttfbPoint.getAttributes().get(StorageClientMetrics.KEY_SERVER_ADDRESS)) + .isEqualTo("storage.googleapis.com"); + + MetricData durationData = + metricData.stream() + .filter(m -> m.getName().equals(StorageClientMetrics.METRIC_RPC_CLIENT_CALL_DURATION)) + .findFirst() + .orElse(null); + assertThat(durationData).isNotNull(); + HistogramPointData durationPoint = + durationData.getHistogramData().getPoints().iterator().next(); + assertThat(durationPoint.getSum()).isWithin(1e-6).of(0.5); + } + + @Test + public void interceptCall_bidiReadObject_recordsTtfbOnFirstMessageWithUnifiedOperation() + throws Exception { + StorageClientMetrics metrics = StorageClientMetrics.create(meterProvider, true, false); + StorageGrpcMetricsInterceptor interceptor = + new StorageGrpcMetricsInterceptor(metrics, clock, "google-c2p:///storage.googleapis.com"); + + TestClientCall testCall = new TestClientCall<>(io.grpc.Attributes.EMPTY); + TestChannel testChannel = new TestChannel(testCall, "storage.googleapis.com"); + + ClientCall call = + interceptor.interceptCall(BIDI_READ_OBJECT_METHOD, CallOptions.DEFAULT, testChannel); + call.start(new TestListener<>(), new Metadata()); + + clock.advanceNanos(150_000_000L); // 0.15s + testCall.deliverMessage(null); + + clock.advanceNanos(250_000_000L); // 0.25s more (total 0.4s) + testCall.deliverMessage(null); + testCall.close(Status.OK, new Metadata()); + + Collection metricData = reader.collectAllMetrics(); + MetricData ttfbData = + metricData.stream() + .filter(m -> m.getName().equals(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_TTFB)) + .findFirst() + .orElse(null); + + assertThat(ttfbData).isNotNull(); + assertThat(ttfbData.getHistogramData().getPoints()).hasSize(1); + HistogramPointData ttfbPoint = ttfbData.getHistogramData().getPoints().iterator().next(); + assertThat(ttfbPoint.getCount()).isEqualTo(1L); + assertThat(ttfbPoint.getSum()).isWithin(1e-6).of(0.15); + assertThat(ttfbPoint.getAttributes().get(StorageClientMetrics.KEY_RPC_SYSTEM_NAME)) + .isEqualTo("grpc"); + assertThat(ttfbPoint.getAttributes().get(StorageClientMetrics.KEY_GCP_STORAGE_OPERATION)) + .isEqualTo("ReadObject"); + assertThat(ttfbPoint.getAttributes().get(StorageClientMetrics.KEY_NETWORK_PATH)) + .isEqualTo(StorageClientMetrics.NETWORK_PATH_DIRECTPATH); + assertThat(ttfbPoint.getAttributes().get(StorageClientMetrics.KEY_SERVER_ADDRESS)) + .isEqualTo("storage.googleapis.com"); + } + + @Test + public void interceptCall_nonReadMethod_doesNotRecordTtfb() throws Exception { + StorageClientMetrics metrics = StorageClientMetrics.create(meterProvider, true, false); + StorageGrpcMetricsInterceptor interceptor = + new StorageGrpcMetricsInterceptor(metrics, clock, "storage.googleapis.com"); + + TestClientCall testCall = new TestClientCall<>(io.grpc.Attributes.EMPTY); + TestChannel testChannel = new TestChannel(testCall, "storage.googleapis.com"); + + ClientCall call = + interceptor.interceptCall(WRITE_OBJECT_METHOD, CallOptions.DEFAULT, testChannel); + call.start(new TestListener<>(), new Metadata()); + + clock.advanceNanos(200_000_000L); + testCall.deliverMessage(null); + testCall.close(Status.OK, new Metadata()); + + Collection metricData = reader.collectAllMetrics(); + MetricData ttfbData = + metricData.stream() + .filter(m -> m.getName().equals(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_TTFB)) + .findFirst() + .orElse(null); + assertThat(ttfbData).isNull(); } @Test @@ -499,6 +643,12 @@ public Attributes getAttributes() { return attributes; } + void deliverMessage(RespT message) { + if (listener != null) { + listener.onMessage(message); + } + } + void close(Status status, Metadata trailers) { if (listener != null) { listener.onClose(status, trailers); 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 index 25cd88f605b9..3cc76448f5d2 100644 --- 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 @@ -397,6 +397,198 @@ public LowLevelHttpResponse execute() throws IOException { .isEqualTo("storage.googleapis.com"); } + @Test + public void isMediaDownloadRequest_cases() throws IOException { + HttpTransport transport = new MockHttpTransport(); + HttpRequest getDownload = + transport + .createRequestFactory() + .buildGetRequest( + new GenericUrl( + "https://storage.googleapis.com/download/storage/v1/b/my-b/o/my-obj")); + assertThat(StorageHttpMetricsInterceptor.isMediaDownloadRequest(getDownload)).isTrue(); + + HttpRequest getAltMedia = + transport + .createRequestFactory() + .buildGetRequest( + new GenericUrl( + "https://storage.googleapis.com/storage/v1/b/my-b/o/my-obj?alt=media")); + assertThat(StorageHttpMetricsInterceptor.isMediaDownloadRequest(getAltMedia)).isTrue(); + + HttpRequest getMetadata = + transport + .createRequestFactory() + .buildGetRequest( + new GenericUrl("https://storage.googleapis.com/storage/v1/b/my-b/o/my-obj")); + assertThat(StorageHttpMetricsInterceptor.isMediaDownloadRequest(getMetadata)).isFalse(); + + HttpRequest postReq = + transport + .createRequestFactory() + .buildPostRequest( + new GenericUrl( + "https://storage.googleapis.com/download/storage/v1/b/my-b/o/my-obj"), + null); + assertThat(StorageHttpMetricsInterceptor.isMediaDownloadRequest(postReq)).isFalse(); + + assertThat(StorageHttpMetricsInterceptor.isMediaDownloadRequest(null)).isFalse(); + } + + @Test + public void initialize_mediaDownload_recordsTtfb() 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() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest() { + @Override + public LowLevelHttpResponse execute() throws IOException { + clock.advanceNanos(250_000_000L); // 0.25s network duration + return lowLevelResponse; + } + }; + } + }; + + HttpRequest request = + transport + .createRequestFactory(interceptor) + .buildGetRequest( + new GenericUrl( + "https://storage.googleapis.com/download/storage/v1/b/my-b/o/my-obj")); + request.execute(); + + Collection metricData = reader.collectAllMetrics(); + MetricData ttfbData = + metricData.stream() + .filter(m -> m.getName().equals(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_TTFB)) + .findFirst() + .orElse(null); + + assertThat(ttfbData).isNotNull(); + assertThat(ttfbData.getHistogramData().getPoints()).hasSize(1); + HistogramPointData ttfbPoint = ttfbData.getHistogramData().getPoints().iterator().next(); + assertThat(ttfbPoint.getCount()).isEqualTo(1L); + assertThat(ttfbPoint.getSum()).isWithin(1e-6).of(0.25); + Attributes attrs = ttfbPoint.getAttributes(); + assertThat(attrs.get(StorageClientMetrics.KEY_RPC_SYSTEM_NAME)).isEqualTo("http"); + assertThat(attrs.get(StorageClientMetrics.KEY_GCP_STORAGE_OPERATION)).isEqualTo("ReadObject"); + assertThat(attrs.get(StorageClientMetrics.KEY_NETWORK_PATH)) + .isEqualTo(StorageClientMetrics.NETWORK_PATH_CLOUDPATH); + assertThat(attrs.get(StorageClientMetrics.KEY_SERVER_ADDRESS)) + .isEqualTo("storage.googleapis.com"); + } + + @Test + public void initialize_mediaDownloadNon2xxResponse_doesNotRecordTtfb() throws IOException { + StorageClientMetrics metrics = StorageClientMetrics.create(meterProvider, true, false); + StorageHttpMetricsInterceptor interceptor = + new StorageHttpMetricsInterceptor(null, metrics, clock, "https://storage.googleapis.com"); + + for (int statusCode : new int[] {304, 404}) { + MockLowLevelHttpResponse lowLevelResponse = new MockLowLevelHttpResponse(); + lowLevelResponse.setStatusCode(statusCode); + HttpTransport transport = + new MockHttpTransport.Builder().setLowLevelHttpResponse(lowLevelResponse).build(); + + HttpRequest request = + transport + .createRequestFactory(interceptor) + .buildGetRequest( + new GenericUrl( + "https://storage.googleapis.com/download/storage/v1/b/my-b/o/my-obj?alt=media")); + request.setThrowExceptionOnExecuteError(false); + request.execute(); + } + + Collection metricData = reader.collectAllMetrics(); + MetricData ttfbData = + metricData.stream() + .filter(m -> m.getName().equals(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_TTFB)) + .findFirst() + .orElse(null); + assertThat(ttfbData).isNull(); + } + + @Test + public void initialize_altMediaAddedAfterBuildGetRequest_recordsTtfb() 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() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest() { + @Override + public LowLevelHttpResponse execute() { + clock.advanceNanos(120_000_000L); + return lowLevelResponse; + } + }; + } + }; + + HttpRequest request = + transport + .createRequestFactory(interceptor) + .buildGetRequest( + new GenericUrl("https://storage.googleapis.com/storage/v1/b/my-b/o/my-obj")); + request.getUrl().put("alt", "media"); + request.execute(); + + Collection metricData = reader.collectAllMetrics(); + MetricData ttfbData = + metricData.stream() + .filter(m -> m.getName().equals(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_TTFB)) + .findFirst() + .orElse(null); + + assertThat(ttfbData).isNotNull(); + HistogramPointData ttfbPoint = ttfbData.getHistogramData().getPoints().iterator().next(); + assertThat(ttfbPoint.getCount()).isEqualTo(1L); + assertThat(ttfbPoint.getSum()).isWithin(1e-6).of(0.12); + assertThat(ttfbPoint.getAttributes().get(StorageClientMetrics.KEY_GCP_STORAGE_OPERATION)) + .isEqualTo("ReadObject"); + } + + @Test + public void initialize_nonDownloadGet_doesNotRecordTtfb() 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 ttfbData = + metricData.stream() + .filter(m -> m.getName().equals(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_TTFB)) + .findFirst() + .orElse(null); + assertThat(ttfbData).isNull(); + } + @Test public void initialize_ioExceptionBeforeExecuteInterceptorRuns_doesNotRecordMetric() throws IOException {