Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,18 @@ final class StorageGrpcMetricsInterceptor implements ClientInterceptor {
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> 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
Expand All @@ -79,14 +86,28 @@ public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
public void start(Listener<RespT> responseListener, Metadata headers) {
long startTimeNanos = clock.nanoTime();
AtomicBoolean callRecorded = new AtomicBoolean(false);
AtomicBoolean ttfbRecorded = new AtomicBoolean(false);

super.start(
new SimpleForwardingClientCallListener<RespT>(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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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(
Expand Down Expand Up @@ -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])) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ public final class StorageGrpcMetricsInterceptorTest {
.setResponseMarshaller(new NoopMarshaller())
.build();

private static final MethodDescriptor<Void, Void> BIDI_READ_OBJECT_METHOD =
MethodDescriptor.<Void, Void>newBuilder()
.setType(MethodDescriptor.MethodType.BIDI_STREAMING)
.setFullMethodName("google.storage.v2.Storage/BidiReadObject")
.setRequestMarshaller(new NoopMarshaller())
.setResponseMarshaller(new NoopMarshaller())
.build();

private static final MethodDescriptor<Void, Void> WRITE_OBJECT_METHOD =
MethodDescriptor.<Void, Void>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;
Expand Down Expand Up @@ -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<Void, Void> testCall = new TestClientCall<>(io.grpc.Attributes.EMPTY);
TestChannel testChannel = new TestChannel(testCall, "storage.googleapis.com");

ClientCall<Void, Void> 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> 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<Void, Void> testCall = new TestClientCall<>(io.grpc.Attributes.EMPTY);
TestChannel testChannel = new TestChannel(testCall, "storage.googleapis.com");

ClientCall<Void, Void> 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> 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<Void, Void> testCall = new TestClientCall<>(io.grpc.Attributes.EMPTY);
TestChannel testChannel = new TestChannel(testCall, "storage.googleapis.com");

ClientCall<Void, Void> 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> 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
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading