Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,27 @@ void recordRpcCallDuration(
rpcClientCallDuration.record(durationSeconds, attributes, otelContext);
}

void recordTtfb(
Context otelContext,
String operationName,
double ttfbSeconds,
String rpcSystemName,
String networkPath,
@Nullable String serverAddress) {
if (ttfb == null) {
return;
}
AttributesBuilder builder =
baseAttributes.toBuilder()
.put(KEY_RPC_SYSTEM_NAME, rpcSystemName)
.put(KEY_GCP_STORAGE_OPERATION, operationName)
.put(KEY_NETWORK_PATH, networkPath);
if (serverAddress != null) {
builder.put(KEY_SERVER_ADDRESS, serverAddress);
}
ttfb.record(ttfbSeconds, builder.build(), otelContext);
}

@Nullable
static StorageMetricsContext createContext(
@Nullable StorageClientMetrics metrics, String operationName) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

Expand All @@ -56,26 +57,50 @@ final class StorageGrpcMetricsInterceptor implements ClientInterceptor {
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
if (metrics.getRpcClientCallDuration() == null) {
boolean recordDuration = metrics.getRpcClientCallDuration() != null;
boolean recordTtfb = metrics.getTtfb() != null;
if (!recordDuration && !recordTtfb) {
return next.newCall(method, callOptions);
}

String rpcMethod = method.getFullMethodName();
StorageMetricsContext metricsCtx = StorageMetricsContext.from(callOptions);
Context otelContext = metricsCtx != null ? metricsCtx.getOtelContext() : Context.current();
String operationName =
metricsCtx != null
? metricsCtx.getOperationName()
: defaultOperationName(method.getBareMethodName());

return new SimpleForwardingClientCall<ReqT, RespT>(next.newCall(method, callOptions)) {
private final AtomicBoolean ttfbRecorded = new AtomicBoolean(false);
private final AtomicLong sendTimeNanos = new AtomicLong();

@Override
public void start(Listener<RespT> responseListener, Metadata headers) {
long startTimeNanos = clock.nanoTime();
sendTimeNanos.set(startTimeNanos);
AtomicBoolean callRecorded = new AtomicBoolean(false);

super.start(
new SimpleForwardingClientCallListener<RespT>(responseListener) {
@Override
public void onMessage(@Nullable 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() - sendTimeNanos.get()) / 1_000_000_000.0);
String networkPath = detectNetworkPath(getAttributes());
metrics.recordTtfb(
otelContext, operationName, ttfbSeconds, "grpc", networkPath, null);
}
super.onMessage(message);
}

@Override
public void onClose(@Nullable Status status, @Nullable Metadata trailers) {
try {
if (callRecorded.compareAndSet(false, true)) {
if (recordDuration && callRecorded.compareAndSet(false, true)) {
double durationSeconds =
Math.max(0.0, (clock.nanoTime() - startTimeNanos) / 1_000_000_000.0);
String networkPath = detectNetworkPath(getAttributes());
Expand Down Expand Up @@ -103,9 +128,27 @@ public void onClose(@Nullable Status status, @Nullable Metadata trailers) {
},
headers);
}

@Override
public void sendMessage(ReqT message) {
if (!ttfbRecorded.get()) {
sendTimeNanos.set(clock.nanoTime());
}
super.sendMessage(message);
}
};
}

private static String defaultOperationName(@Nullable String bareMethod) {
if ("BidiReadObject".equals(bareMethod)) {
return "ReadObject";
}
if ("BidiWriteObject".equals(bareMethod)) {
return "WriteObject";
}
return bareMethod != null && !bareMethod.isEmpty() ? bareMethod : "UNKNOWN";
}

static String detectNetworkPath(@Nullable Attributes callAttributes) {
SocketAddress remoteAddr =
callAttributes != null ? callAttributes.get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR) : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import com.google.api.client.http.HttpUnsuccessfulResponseHandler;
import com.google.api.core.ApiClock;
import com.google.common.collect.ImmutableSet;
import io.opentelemetry.context.Context;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
Expand Down Expand Up @@ -68,7 +69,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 Down Expand Up @@ -97,11 +103,17 @@ public void initialize(HttpRequest request) throws IOException {
existingUnsuccessfulHandler.handleResponse(req, response, supportsRetry);
if (handled && recorded.compareAndSet(false, true)) {
int statusCode = response.getStatusCode();
String errorType =
statusCode >= 400
? StorageErrorTypeMapper.mapHttpResponseCode(statusCode)
: StorageErrorTypeMapper.STATUS_OK;
recordDuration(req, startTimeNanos.get(), (long) statusCode, errorType);
long startNanos = startTimeNanos.get();
if (recordDuration) {
String errorType =
statusCode >= 400
? StorageErrorTypeMapper.mapHttpResponseCode(statusCode)
: StorageErrorTypeMapper.STATUS_OK;
recordDuration(req, startNanos, (long) statusCode, errorType);
}
if (recordTtfb && isMediaDownloadRequest(req)) {
recordTtfb(req, startNanos);
}
}
return handled;
});
Expand All @@ -114,29 +126,55 @@ 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(), (long) statusCode, errorType);
long startNanos = startTimeNanos.get();
if (recordDuration) {
String errorType =
statusCode >= 400
? StorageErrorTypeMapper.mapHttpResponseCode(statusCode)
: StorageErrorTypeMapper.STATUS_OK;
recordDuration(request, startNanos, (long) 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 && isMediaDownloadRequest(request)) {
recordTtfb(request, startNanos);
}
}
});

// HttpIOExceptionHandler does not receive the thrown IOException instance from
// google-http-client, so transport-level IOExceptions default to STATUS_CONNECTION_ERROR 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(),
StorageMetricsContext.HTTP_STATUS_UNSET,
StorageErrorTypeMapper.STATUS_CONNECTION_ERROR);
}
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_CONNECTION_ERROR 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(),
StorageMetricsContext.HTTP_STATUS_UNSET,
StorageErrorTypeMapper.STATUS_CONNECTION_ERROR);
}
return existingIoHandler != null
&& existingIoHandler.handleIOException(req, supportsRetry);
});
}
}

private void recordTtfb(HttpRequest request, long startNanos) {
if (metrics == null) {
return;
}
double ttfbSeconds = Math.max(0.0, (clock.nanoTime() - startNanos) / 1_000_000_000.0);
String serverAddress = resolveServerAddress(request.getUrl(), defaultServerAddress);
metrics.recordTtfb(
Context.current(),
"ReadObject",
ttfbSeconds,
"http",
StorageClientMetrics.NETWORK_PATH_CLOUDPATH,
serverAddress != null ? serverAddress : "");
}

private void recordDuration(
Expand Down Expand Up @@ -257,4 +295,17 @@ private static String extractObjectUrlTemplate(String[] segments) {
}
return "/storage/v1/b/{bucket}/o/{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"));
}
}
Loading
Loading