diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java index 8dff1c8185c1..3fabc6ba197b 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/HttpStorageOptions.java @@ -551,5 +551,10 @@ public RetrySettings getRetrySettings() { public ApiClock getClock() { return HttpStorageOptions.this.getClock(); } + + @Override + public @Nullable StorageClientMetrics getStorageClientMetrics() { + return HttpStorageOptions.this.getStorageClientMetrics(); + } } } diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/Retrying.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/Retrying.java index d2f46d959e4c..c524dd86d27c 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/Retrying.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/Retrying.java @@ -22,18 +22,25 @@ import com.google.api.gax.retrying.BasicResultRetryAlgorithm; import com.google.api.gax.retrying.ResultRetryAlgorithm; import com.google.api.gax.retrying.RetrySettings; +import com.google.cloud.BaseServiceException; import com.google.cloud.storage.Backoff.Jitterer; import com.google.cloud.storage.Conversions.Decoder; import com.google.cloud.storage.spi.v1.HttpRpcContext; import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.context.Scope; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.UnaryOperator; import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; final class Retrying { @@ -43,7 +50,7 @@ final class Retrying { */ interface Retrier { /** - * A convenience method to invoke a callable, and possibly retry it again if and exception is + * A convenience method to invoke a callable, and possibly retry it again if an exception is * thrown. If the result of {@code c} is non-null, Decoder {@code d} will be applied to the * result before returning. * @@ -54,18 +61,23 @@ interface Retrier { * T}, can optionally return null * @param d A post process mapping {@link Function} which can be used to transform the result * from {@code c} if it is successful and non-null + * @param metricsContext optional metrics context for tracking logical operation metrics * @return A {@code U} (possibly null) after applying {@code f} to the result of {@code c} * @throws StorageException if {@code c} fails due to any retry exhaustion */ Model run( - ResultRetryAlgorithm alg, Callable c, Decoder d); + ResultRetryAlgorithm alg, + Callable c, + Decoder d, + @Nullable StorageMetricsContext metricsContext); + + default Model run( + ResultRetryAlgorithm alg, Callable c, Decoder d) { + return run(alg, c, d, null); + } - /** - * Produce a new {@link RetrierWithAlg} where the provided ResultRetryAlgorithm is bound for the - * life of the produced instance. - */ default RetrierWithAlg withAlg(ResultRetryAlgorithm alg) { - return new RetrierWithAlgImpl(this, alg); + return new DefaultRetrierWithAlg(this, alg); } static Retrier attemptOnce() { @@ -80,22 +92,15 @@ static Retrier attemptOnce() { */ interface RetrierWithAlg extends Retrier { - /** - * A convenience method to invoke a callable, and possibly retry it again if and exception is - * thrown. If the result of {@code c} is non-null, Decoder {@code d} will be applied to the - * result before returning. - * - * @param The result type of {@code c} - * @param The result type of any mapping that takes place via {@code d} - * @param c The {@link Callable} which will be passed to runWithRetries producing some {@code - * T}, can optionally return null - * @param d A post process mapping {@link Function} which can be used to transform the result - * from {@code c} if it is successful and non-null - * @return A {@code U} (possibly null) after applying {@code f} to the result of {@code c} - * @throws StorageException if {@code c} fails due to any retry exhaustion - */ Model run(Callable c, Decoder d); + default Model run( + Callable c, + Decoder d, + @Nullable StorageMetricsContext metricsContext) { + return run(neverRetry(), c, d, metricsContext); + } + static RetrierWithAlg attemptOnce() { return AttemptOnceRetrier.INSTANCE_WITH_ALG; } @@ -107,7 +112,10 @@ static final class AttemptOnceRetrier implements Retrier { @Override public Model run( - ResultRetryAlgorithm alg, Callable c, Decoder d) { + ResultRetryAlgorithm alg, + Callable c, + Decoder d, + @Nullable StorageMetricsContext metricsContext) { try { Response call = c.call(); return call == null ? null : d.decode(call); @@ -117,38 +125,286 @@ public Model run( } } - static final class RetrierWithAlgImpl implements RetrierWithAlg { + static final class DefaultRetrierWithAlg implements RetrierWithAlg { private final Retrier retrier; private final ResultRetryAlgorithm alg; - private RetrierWithAlgImpl(Retrier retrier, ResultRetryAlgorithm alg) { + private DefaultRetrierWithAlg(Retrier retrier, ResultRetryAlgorithm alg) { this.retrier = retrier; this.alg = alg; } @Override public Model run( - ResultRetryAlgorithm alg, Callable c, Decoder d) { - return retrier.run(alg, c, d); + ResultRetryAlgorithm alg, + Callable c, + Decoder d, + @Nullable StorageMetricsContext metricsContext) { + return retrier.run(alg, c, d, metricsContext); } @Override public Model run(Callable c, Decoder d) { - return retrier.run(alg, c, d); + return retrier.run(alg, c, d, null); + } + + @Override + public Model run( + Callable c, + Decoder d, + @Nullable StorageMetricsContext metricsContext) { + return retrier.run(alg, c, d, metricsContext); } } static final class DefaultRetrier implements Retrier { private final UnaryOperator decorator; private final RetryingDependencies deps; + @Nullable private final StorageClientMetrics metrics; DefaultRetrier(UnaryOperator decorator, RetryingDependencies deps) { + this(decorator, deps, deps != null ? deps.getStorageClientMetrics() : null); + } + + DefaultRetrier( + UnaryOperator decorator, + RetryingDependencies deps, + @Nullable StorageClientMetrics metrics) { this.decorator = decorator; this.deps = deps; + this.metrics = + metrics != null ? metrics : (deps != null ? deps.getStorageClientMetrics() : null); } @Override public Model run( + ResultRetryAlgorithm alg, + Callable c, + Decoder d, + @Nullable StorageMetricsContext metricsContext) { + if (metrics == null || metricsContext == null) { + return runWithoutMetrics(alg, c, d); + } + + StorageMetricsContext activeContext = metricsContext; + Attributes clientAttributes = metrics.getClientAttributes(); + + AttributesBuilder baseBuilder = Attributes.builder(); + baseBuilder.putAll(clientAttributes); + activeContext + .getBaseAttributes() + .forEach( + (key, val) -> { + if (!key.equals(StorageClientMetrics.KEY_RPC_METHOD) + && !key.equals(StorageClientMetrics.KEY_NETWORK_PATH) + && !key.equals(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE) + && !key.equals(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) { + @SuppressWarnings("unchecked") + AttributeKey objKey = (AttributeKey) key; + baseBuilder.put(objKey, val); + } + }); + baseBuilder.put( + StorageClientMetrics.KEY_GCP_STORAGE_OPERATION, activeContext.getOperationName()); + Attributes baseAttributes = baseBuilder.build(); + + LongCounter attemptsCounter = metrics.getAttempts(); + LongCounter retriesCounter = metrics.getRetries(); + LongCounter errorsCounter = metrics.getErrors(); + + try (Scope ignore = activeContext.getOtelContext().makeCurrent()) { + RetryContext ctx = + decorator.apply( + RetryContext.of( + RetryContext.directScheduledExecutorService(), + deps, + alg, + Jitterer.threadLocalRandom())); + AtomicReference failure = new AtomicReference<>(); + AtomicReference lastAttemptErrorType = new AtomicReference<>("UNKNOWN"); + AtomicBoolean attemptAgain = new AtomicBoolean(false); + Throwable terminalThrowable = null; + + try { + do { + attemptAgain.set(false); + activeContext.incrementAttempts(); + try { + Response result = c.call(); + recordAttemptSuccess(attemptsCounter, baseAttributes, activeContext); + return result == null ? null : d.decode(result); + } catch (StorageException se) { + String errorType = StorageErrorTypeMapper.mapThrowable(se); + lastAttemptErrorType.set(errorType); + recordAttemptFailure( + attemptsCounter, errorsCounter, baseAttributes, activeContext, se, errorType); + ctx.recordError(se, () -> attemptAgain.set(true), failure::set); + } catch (IllegalArgumentException iae) { + String errorType = StorageErrorTypeMapper.mapThrowable(iae); + lastAttemptErrorType.set(errorType); + recordAttemptFailure( + attemptsCounter, errorsCounter, baseAttributes, activeContext, iae, errorType); + ctx.recordError(iae, () -> attemptAgain.set(true), failure::set); + } catch (Exception e) { + String errorType = StorageErrorTypeMapper.mapThrowable(e); + lastAttemptErrorType.set(errorType); + recordAttemptFailure( + attemptsCounter, errorsCounter, baseAttributes, activeContext, e, errorType); + BaseServiceException coalesced = StorageException.coalesce(e); + ctx.recordError(coalesced, () -> attemptAgain.set(true), failure::set); + } + if (attemptAgain.get() && retriesCounter != null) { + Attributes retryAttributes = + baseAttributes.toBuilder() + .put(StorageClientMetrics.KEY_ERROR_TYPE, lastAttemptErrorType.get()) + .put(StorageClientMetrics.KEY_NETWORK_PATH, resolveNetworkPath(activeContext)) + .build(); + retriesCounter.add(1, retryAttributes); + } + } while (attemptAgain.get()); + + Exception throwable = failure.get(); + terminalThrowable = throwable; + if (throwable instanceof StorageException) { + throw (StorageException) throwable; + } else if (throwable != null) { + throw StorageException.coalesce(throwable); + } else { + throw new StorageException(0, "Retry failed without exception"); + } + } catch (Throwable t) { + terminalThrowable = t; + throw t; + } finally { + if (activeContext.shouldRecordOperationOnRetrierExit()) { + activeContext.recordOperation(metrics, terminalThrowable); + } + } + } + } + + private static void recordAttemptSuccess( + @Nullable LongCounter attemptsCounter, + Attributes baseAttributes, + StorageMetricsContext activeContext) { + if (attemptsCounter == null) { + return; + } + AttributesBuilder builder = + baseAttributes.toBuilder() + .put(StorageClientMetrics.KEY_ERROR_TYPE, StorageErrorTypeMapper.STATUS_OK) + .put(StorageClientMetrics.KEY_NETWORK_PATH, resolveNetworkPath(activeContext)); + String rpcSystemName = + activeContext.getBaseAttributes().get(StorageClientMetrics.KEY_RPC_SYSTEM_NAME); + if ("grpc".equals(rpcSystemName)) { + builder.put(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE, "OK"); + } else if ("http".equals(rpcSystemName)) { + builder.put(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, 200L); + } + String rpcCode = + activeContext.getBaseAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE); + if (rpcCode != null) { + builder.put(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE, rpcCode); + } + Long httpCode = + activeContext.getBaseAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE); + if (httpCode != null) { + builder.put(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, httpCode); + } + attemptsCounter.add(1, builder.build()); + } + + private static void recordAttemptFailure( + @Nullable LongCounter attemptsCounter, + @Nullable LongCounter errorsCounter, + Attributes baseAttributes, + StorageMetricsContext activeContext, + Throwable e, + String errorType) { + String networkPath = resolveNetworkPath(activeContext); + if (errorsCounter != null) { + Attributes errorAttributes = + baseAttributes.toBuilder() + .put(StorageClientMetrics.KEY_ERROR_TYPE, errorType) + .put(StorageClientMetrics.KEY_NETWORK_PATH, networkPath) + .build(); + errorsCounter.add(1, errorAttributes); + } + if (attemptsCounter != null) { + AttributesBuilder builder = + baseAttributes.toBuilder() + .put(StorageClientMetrics.KEY_ERROR_TYPE, errorType) + .put(StorageClientMetrics.KEY_NETWORK_PATH, networkPath); + String rpcStatus = extractRpcStatusCode(e, activeContext); + if (rpcStatus != null) { + builder.put(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE, rpcStatus); + } + Long httpStatus = extractHttpStatusCode(e, activeContext); + if (httpStatus != null) { + builder.put(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, httpStatus); + } + attemptsCounter.add(1, builder.build()); + } + } + + private static String resolveNetworkPath(StorageMetricsContext ctx) { + String path = ctx.getBaseAttributes().get(StorageClientMetrics.KEY_NETWORK_PATH); + if (path != null && !path.isEmpty()) { + return path; + } + return StorageClientMetrics.NETWORK_PATH_CLOUDPATH; + } + + @Nullable + private static String extractRpcStatusCode(Throwable e, StorageMetricsContext activeContext) { + String fromContext = + activeContext.getBaseAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE); + if (fromContext != null) { + return fromContext; + } + Throwable curr = e; + while (curr != null) { + if (curr instanceof io.grpc.StatusRuntimeException) { + return ((io.grpc.StatusRuntimeException) curr).getStatus().getCode().name(); + } + if (curr instanceof io.grpc.StatusException) { + return ((io.grpc.StatusException) curr).getStatus().getCode().name(); + } + if (curr instanceof com.google.api.gax.rpc.ApiException) { + return ((com.google.api.gax.rpc.ApiException) curr).getStatusCode().getCode().name(); + } + curr = curr.getCause(); + } + return null; + } + + @Nullable + private static Long extractHttpStatusCode(Throwable e, StorageMetricsContext activeContext) { + Long fromContext = + activeContext.getBaseAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE); + if (fromContext != null) { + return fromContext; + } + Throwable curr = e; + while (curr != null) { + if (curr instanceof StorageException) { + int code = ((StorageException) curr).getCode(); + if (code > 0) { + return (long) code; + } + } + if (curr instanceof com.google.api.client.http.HttpResponseException) { + int code = ((com.google.api.client.http.HttpResponseException) curr).getStatusCode(); + if (code > 0) { + return (long) code; + } + } + curr = curr.getCause(); + } + return null; + } + + private Model runWithoutMetrics( ResultRetryAlgorithm alg, Callable c, Decoder d) { RetryContext ctx = decorator.apply( @@ -159,25 +415,19 @@ public Model run( Jitterer.threadLocalRandom())); AtomicReference failure = new AtomicReference<>(); AtomicBoolean attemptAgain = new AtomicBoolean(false); + do { attemptAgain.set(false); try { Response result = c.call(); return result == null ? null : d.decode(result); } catch (StorageException se) { - // we hope for this case ctx.recordError(se, () -> attemptAgain.set(true), failure::set); } catch (IllegalArgumentException iae) { - // IllegalArgumentException can happen if there is no json in the body and we try - // to parse it Our retry algorithms have special case for this, so in an effort to - // keep compatibility with those existing behaviors, explicitly rethrow an - // IllegalArgumentException that may have happened ctx.recordError(iae, () -> attemptAgain.set(true), failure::set); } catch (Exception e) { - // Wire in this fall through just in case. - // all of our retry algorithms are centered around StorageException so this helps - // those be more effective - ctx.recordError(StorageException.coalesce(e), () -> attemptAgain.set(true), failure::set); + BaseServiceException coalesced = StorageException.coalesce(e); + ctx.recordError(coalesced, () -> attemptAgain.set(true), failure::set); } } while (attemptAgain.get()); @@ -199,11 +449,14 @@ static final class HttpRetrier implements Retrier { @Override public Model run( - ResultRetryAlgorithm alg, Callable c, Decoder d) { + ResultRetryAlgorithm alg, + Callable c, + Decoder d, + @Nullable StorageMetricsContext metricsContext) { HttpRpcContext httpRpcContext = HttpRpcContext.getInstance(); try { httpRpcContext.newInvocationId(); - return delegate.run(alg, c, d); + return delegate.run(alg, c, d, metricsContext); } finally { httpRpcContext.clearInvocationId(); } @@ -212,10 +465,21 @@ public Model run( @NonNull static GrpcCallContext newCallContext() { - return GrpcCallContext.createDefault() - .withExtraHeaders( - ImmutableMap.of( - "x-goog-gcs-idempotency-token", ImmutableList.of(UUID.randomUUID().toString()))); + return newCallContext(null); + } + + @NonNull + static GrpcCallContext newCallContext(@Nullable StorageMetricsContext metricsContext) { + GrpcCallContext context = + GrpcCallContext.createDefault() + .withExtraHeaders( + ImmutableMap.of( + "x-goog-gcs-idempotency-token", + ImmutableList.of(UUID.randomUUID().toString()))); + if (metricsContext != null) { + context = metricsContext.attach(context); + } + return context; } static ResultRetryAlgorithm neverRetry() { @@ -246,23 +510,37 @@ interface RetryingDependencies { ApiClock getClock(); + default @Nullable StorageClientMetrics getStorageClientMetrics() { + return null; + } + static RetryingDependencies attemptOnce() { return RetryingDependencies.simple( NanoClock.getDefaultClock(), RetrySettings.newBuilder().setMaxAttempts(1).build()); } static RetryingDependencies simple(ApiClock clock, RetrySettings retrySettings) { - return new SimpleRetryingDependencies(clock, retrySettings); + return new SimpleRetryingDependencies(clock, retrySettings, null); + } + + static RetryingDependencies simple( + ApiClock clock, RetrySettings retrySettings, @Nullable StorageClientMetrics metrics) { + return new SimpleRetryingDependencies(clock, retrySettings, metrics); } } private static final class SimpleRetryingDependencies implements RetryingDependencies { private final ApiClock clock; private final RetrySettings retrySettings; + @Nullable private final StorageClientMetrics storageClientMetrics; - private SimpleRetryingDependencies(ApiClock clock, RetrySettings retrySettings) { + private SimpleRetryingDependencies( + ApiClock clock, + RetrySettings retrySettings, + @Nullable StorageClientMetrics storageClientMetrics) { this.retrySettings = retrySettings; this.clock = clock; + this.storageClientMetrics = storageClientMetrics; } @Override @@ -275,11 +553,17 @@ public RetrySettings getRetrySettings() { return retrySettings; } + @Override + public @Nullable StorageClientMetrics getStorageClientMetrics() { + return storageClientMetrics; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) .add("clock", clock) .add("retrySettings", retrySettings) + .add("storageClientMetrics", storageClientMetrics) .toString(); } } diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageClientMetrics.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageClientMetrics.java index 4d28ef7f279e..0f395f1f0b55 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageClientMetrics.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageClientMetrics.java @@ -25,11 +25,11 @@ import io.opentelemetry.api.metrics.LongUpDownCounter; import io.opentelemetry.api.metrics.Meter; import io.opentelemetry.api.metrics.MeterProvider; -import java.util.UUID; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; import org.checkerframework.checker.nullness.qual.Nullable; /** Package-private instrument registry for OpenTelemetry client metrics. */ -final class StorageClientMetrics { +final class StorageClientMetrics implements AutoCloseable { static final String METER_NAME = "com.google.cloud.storage"; // Standard client attribute keys and values @@ -123,11 +123,12 @@ final class StorageClientMetrics { @Nullable private final DoubleHistogram credentialRefreshDuration; private final Attributes clientAttributes; + @Nullable private final SdkMeterProvider ownedMeterProvider; static StorageClientMetrics create( MeterProvider meterProvider, boolean enableOtelMetrics, boolean enableOtelDebugMetrics) { return create( - meterProvider, UUID.randomUUID().toString(), enableOtelMetrics, enableOtelDebugMetrics); + meterProvider, (SdkMeterProvider) null, enableOtelMetrics, enableOtelDebugMetrics); } static StorageClientMetrics create( @@ -135,6 +136,15 @@ static StorageClientMetrics create( String instanceId, boolean enableOtelMetrics, boolean enableOtelDebugMetrics) { + return create( + meterProvider, (SdkMeterProvider) null, enableOtelMetrics, enableOtelDebugMetrics); + } + + static StorageClientMetrics create( + MeterProvider meterProvider, + @Nullable SdkMeterProvider ownedMeterProvider, + boolean enableOtelMetrics, + boolean enableOtelDebugMetrics) { Meter meter = meterProvider .meterBuilder(METER_NAME) @@ -145,10 +155,9 @@ static StorageClientMetrics create( .put(KEY_GCP_CLIENT_SERVICE, SERVICE_STORAGE) .put(KEY_GCP_CLIENT_VERSION, StorageOptions.version()) .put(KEY_GCP_CLIENT_ARTIFACT, ARTIFACT_STORAGE) - .put(KEY_GCP_CLIENT_INSTANCE_ID, instanceId) .build(); return new StorageClientMetrics( - meter, clientAttributes, enableOtelMetrics, enableOtelDebugMetrics); + meter, clientAttributes, ownedMeterProvider, enableOtelMetrics, enableOtelDebugMetrics); } StorageClientMetrics(Meter meter, boolean enableOtelMetrics, boolean enableOtelDebugMetrics) { @@ -158,8 +167,8 @@ static StorageClientMetrics create( .put(KEY_GCP_CLIENT_SERVICE, SERVICE_STORAGE) .put(KEY_GCP_CLIENT_VERSION, StorageOptions.version()) .put(KEY_GCP_CLIENT_ARTIFACT, ARTIFACT_STORAGE) - .put(KEY_GCP_CLIENT_INSTANCE_ID, UUID.randomUUID().toString()) .build(), + null, enableOtelMetrics, enableOtelDebugMetrics); } @@ -169,7 +178,17 @@ static StorageClientMetrics create( Attributes clientAttributes, boolean enableOtelMetrics, boolean enableOtelDebugMetrics) { + this(meter, clientAttributes, null, enableOtelMetrics, enableOtelDebugMetrics); + } + + StorageClientMetrics( + Meter meter, + Attributes clientAttributes, + @Nullable SdkMeterProvider ownedMeterProvider, + boolean enableOtelMetrics, + boolean enableOtelDebugMetrics) { this.clientAttributes = clientAttributes; + this.ownedMeterProvider = ownedMeterProvider; if (enableOtelMetrics) { this.rpcClientCallDuration = @@ -409,4 +428,11 @@ Attributes attachClientAttributes(@Nullable Attributes other) { @Nullable DoubleHistogram getCredentialRefreshDuration() { return credentialRefreshDuration; } + + @Override + public void close() { + if (ownedMeterProvider != null) { + ownedMeterProvider.close(); + } + } } 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 34d105c05458..bae073e1e19f 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 @@ -21,10 +21,13 @@ import com.google.api.gax.grpc.GrpcCallContext; import com.google.api.gax.rpc.ApiCallContext; import io.grpc.CallOptions; +import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import org.checkerframework.checker.nullness.qual.Nullable; @@ -48,6 +51,8 @@ final class StorageMetricsContext { private final AtomicLong requestBytes; private final AtomicLong responseBytes; private final Attributes baseAttributes; + private final boolean recordOperationOnRetrierExit; + private final AtomicBoolean operationRecorded; StorageMetricsContext(String operationName, Attributes baseAttributes) { this(operationName, baseAttributes, System.nanoTime(), Context.current()); @@ -59,13 +64,37 @@ final class StorageMetricsContext { StorageMetricsContext( String operationName, Attributes baseAttributes, long startTimeNanos, Context otelContext) { + this( + operationName, + baseAttributes, + startTimeNanos, + otelContext, + true, + new AtomicBoolean(false), + new AtomicInteger(0), + new AtomicLong(0L), + new AtomicLong(0L)); + } + + StorageMetricsContext( + String operationName, + Attributes baseAttributes, + long startTimeNanos, + Context otelContext, + boolean recordOperationOnRetrierExit, + AtomicBoolean operationRecorded, + AtomicInteger attemptCount, + AtomicLong requestBytes, + AtomicLong responseBytes) { this.operationName = requireNonNull(operationName, "operationName cannot be null"); this.baseAttributes = requireNonNull(baseAttributes, "baseAttributes cannot be null"); this.otelContext = requireNonNull(otelContext, "otelContext cannot be null"); this.startTimeNanos = startTimeNanos; - this.attemptCount = new AtomicInteger(0); - this.requestBytes = new AtomicLong(0L); - this.responseBytes = new AtomicLong(0L); + this.recordOperationOnRetrierExit = recordOperationOnRetrierExit; + this.operationRecorded = requireNonNull(operationRecorded, "operationRecorded cannot be null"); + this.attemptCount = requireNonNull(attemptCount, "attemptCount cannot be null"); + this.requestBytes = requireNonNull(requestBytes, "requestBytes cannot be null"); + this.responseBytes = requireNonNull(responseBytes, "responseBytes cannot be null"); } static StorageMetricsContext create(String operationName) { @@ -78,7 +107,103 @@ static StorageMetricsContext create(String operationName, Attributes baseAttribu static StorageMetricsContext create( String operationName, Attributes baseAttributes, Context otelContext) { - return new StorageMetricsContext(operationName, baseAttributes, System.nanoTime(), otelContext); + return new StorageMetricsContext( + operationName, + baseAttributes, + System.nanoTime(), + otelContext, + true, + new AtomicBoolean(false), + new AtomicInteger(0), + new AtomicLong(0L), + new AtomicLong(0L)); + } + + static StorageMetricsContext createForMultiStep(String operationName) { + return createForMultiStep(operationName, Attributes.empty()); + } + + static StorageMetricsContext createForMultiStep(String operationName, Attributes baseAttributes) { + return createForMultiStep(operationName, baseAttributes, Context.current()); + } + + static StorageMetricsContext createForMultiStep( + String operationName, Attributes baseAttributes, Context otelContext) { + return new StorageMetricsContext( + operationName, + baseAttributes, + System.nanoTime(), + otelContext, + false, + new AtomicBoolean(false), + new AtomicInteger(0), + new AtomicLong(0L), + new AtomicLong(0L)); + } + + StorageMetricsContext asSubAttemptContext() { + return new StorageMetricsContext( + operationName, + baseAttributes, + startTimeNanos, + otelContext, + false, + operationRecorded, + attemptCount, + requestBytes, + responseBytes); + } + + boolean shouldRecordOperationOnRetrierExit() { + return recordOperationOnRetrierExit; + } + + void recordOperation( + @Nullable StorageClientMetrics metrics, @Nullable Throwable terminalThrowable) { + if (metrics == null || !operationRecorded.compareAndSet(false, true)) { + return; + } + try (Scope ignore = otelContext.makeCurrent()) { + String terminalErrorType = StorageErrorTypeMapper.mapThrowable(terminalThrowable); + String clientMethod = baseAttributes.get(StorageClientMetrics.KEY_GCP_CLIENT_METHOD); + if (clientMethod == null || clientMethod.isEmpty()) { + clientMethod = operationName; + } + + AttributesBuilder opAttrBuilder = Attributes.builder(); + opAttrBuilder.putAll(metrics.getClientAttributes()); + baseAttributes.forEach( + (key, val) -> { + if (!key.equals(StorageClientMetrics.KEY_RPC_METHOD) + && !key.equals(StorageClientMetrics.KEY_NETWORK_PATH) + && !key.equals(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE) + && !key.equals(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) { + @SuppressWarnings("unchecked") + AttributeKey objKey = (AttributeKey) key; + opAttrBuilder.put(objKey, val); + } + }); + opAttrBuilder.put(StorageClientMetrics.KEY_GCP_STORAGE_OPERATION, operationName); + opAttrBuilder.put(StorageClientMetrics.KEY_GCP_CLIENT_METHOD, clientMethod); + opAttrBuilder.put(StorageClientMetrics.KEY_ERROR_TYPE, terminalErrorType); + Attributes operationAttributes = opAttrBuilder.build(); + + if (metrics.getOperations() != null) { + metrics.getOperations().add(1, operationAttributes); + } + + if (metrics.getGcpClientRequestDuration() != null) { + String rpcMethod = baseAttributes.get(StorageClientMetrics.KEY_RPC_METHOD); + if (rpcMethod == null || !rpcMethod.contains("/")) { + rpcMethod = "google.storage.v2.Storage/" + operationName; + } + Attributes durationAttributes = + operationAttributes.toBuilder() + .put(StorageClientMetrics.KEY_RPC_METHOD, rpcMethod) + .build(); + metrics.getGcpClientRequestDuration().record(getElapsedSeconds(), durationAttributes); + } + } } static @Nullable StorageMetricsContext from(@Nullable CallOptions callOptions) { 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 672c9ce34041..b76d711e8d2a 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 @@ -35,7 +35,11 @@ import com.google.cloud.storage.TransportCompatibility.Transport; import com.google.cloud.storage.spi.StorageRpcFactory; import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.metrics.MeterProvider; +import io.opentelemetry.contrib.gcp.resource.GCPResourceProvider; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; @@ -81,7 +85,7 @@ public abstract class StorageOptions extends ServiceOptions= 0) { + s = s.substring(0, slashIdx); + } + if (s.startsWith("[") && s.contains("]")) { + int closeBracket = s.indexOf(']'); + int colonIdx = s.indexOf(':', closeBracket); + if (colonIdx >= 0) { + s = s.substring(0, colonIdx); + } + if (s.startsWith("[") && s.endsWith("]")) { + s = s.substring(1, s.length() - 1); + } + } else { + int colonIdx = s.indexOf(':'); + if (colonIdx >= 0) { + s = s.substring(0, colonIdx); + } + } + return s.isEmpty() ? null : s; + } + /** * The metric export interval configured for periodic metric reading. * diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/RetryingMetricsTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/RetryingMetricsTest.java new file mode 100644 index 000000000000..8459389c9e8f --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/RetryingMetricsTest.java @@ -0,0 +1,816 @@ +/* + * 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.core.NanoClock; +import com.google.api.gax.retrying.RetrySettings; +import com.google.cloud.storage.Conversions.Decoder; +import com.google.cloud.storage.Retrying.DefaultRetrier; +import com.google.cloud.storage.Retrying.Retrier; +import com.google.cloud.storage.Retrying.RetrierWithAlg; +import com.google.cloud.storage.Retrying.RetryingDependencies; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.data.HistogramPointData; +import io.opentelemetry.sdk.metrics.data.LongPointData; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.time.Duration; +import java.util.Collection; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.UnaryOperator; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.junit.Test; + +public final class RetryingMetricsTest { + + private static final RetrySettings RETRY_SETTINGS = + RetrySettings.newBuilder() + .setMaxAttempts(3) + .setInitialRetryDelayDuration(Duration.ofMillis(10)) + .setMaxRetryDelayDuration(Duration.ofMillis(50)) + .setRetryDelayMultiplier(1.0) + .build(); + + @Test + public void successfulCallOnFirstAttempt_recordsExpectedMetrics() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false); + + RetryingDependencies deps = + RetryingDependencies.simple(NanoClock.getDefaultClock(), RETRY_SETTINGS, metrics); + RetrierWithAlg retrier = + new DefaultRetrier(UnaryOperator.identity(), deps).withAlg(Retrying.alwaysRetry()); + + String result = + retrier.run(() -> "success", Decoder.identity(), StorageMetricsContext.create("GetObject")); + assertThat(result).isEqualTo("success"); + + Collection collected = reader.collectAllMetrics(); + + // operations = 1 with error.type = "OK" + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_OK)) + .isEqualTo(1L); + + // attempts = 1 + assertThat(getCounterValue(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS)) + .isEqualTo(1L); + + // errors = 0 + assertThat(getCounterValue(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS)) + .isEqualTo(0L); + + // retries = 0 + assertThat(getCounterValue(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_RETRIES)) + .isEqualTo(0L); + + // duration recorded with attributes + HistogramPointData durationPoint = + getHistogramPoint(collected, StorageClientMetrics.METRIC_GCP_CLIENT_REQUEST_DURATION); + assertThat(durationPoint).isNotNull(); + assertThat(durationPoint.getCount()).isEqualTo(1L); + assertThat(durationPoint.getAttributes().get(StorageClientMetrics.KEY_GCP_CLIENT_SERVICE)) + .isEqualTo("storage"); + assertThat(durationPoint.getAttributes().get(StorageClientMetrics.KEY_ERROR_TYPE)) + .isEqualTo(StorageErrorTypeMapper.STATUS_OK); + } + + @Test + public void retriedCallSuccessOnThirdAttempt_recordsRetriesAndErrors() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false); + + RetryingDependencies deps = + RetryingDependencies.simple(NanoClock.getDefaultClock(), RETRY_SETTINGS, metrics); + RetrierWithAlg retrier = + new DefaultRetrier(UnaryOperator.identity(), deps).withAlg(Retrying.alwaysRetry()); + + AtomicInteger attemptCounter = new AtomicInteger(0); + String result = + retrier.run( + () -> { + int count = attemptCounter.incrementAndGet(); + if (count < 3) { + throw new StorageException(503, "Service Unavailable attempt " + count); + } + return "recovered"; + }, + Decoder.identity(), + StorageMetricsContext.create("GetObject")); + + assertThat(result).isEqualTo("recovered"); + assertThat(attemptCounter.get()).isEqualTo(3); + + Collection collected = reader.collectAllMetrics(); + + // operations = 1 with error.type = "OK" + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_OK)) + .isEqualTo(1L); + + // attempts = 3 + assertThat(getCounterValue(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS)) + .isEqualTo(3L); + + // errors = 2 with error.type = "UNAVAILABLE" + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_UNAVAILABLE)) + .isEqualTo(2L); + + // retries = 2 + assertThat(getCounterValue(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_RETRIES)) + .isEqualTo(2L); + + // duration recorded with terminal error.type = "OK" + HistogramPointData durationPoint = + getHistogramPoint(collected, StorageClientMetrics.METRIC_GCP_CLIENT_REQUEST_DURATION); + assertThat(durationPoint).isNotNull(); + assertThat(durationPoint.getCount()).isEqualTo(1L); + assertThat(durationPoint.getAttributes().get(StorageClientMetrics.KEY_ERROR_TYPE)) + .isEqualTo(StorageErrorTypeMapper.STATUS_OK); + } + + @Test + public void terminalFailureAllAttemptsFail_recordsExhaustedRetries() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false); + + RetryingDependencies deps = + RetryingDependencies.simple(NanoClock.getDefaultClock(), RETRY_SETTINGS, metrics); + RetrierWithAlg retrier = + new DefaultRetrier(UnaryOperator.identity(), deps).withAlg(Retrying.alwaysRetry()); + + AtomicInteger attemptCounter = new AtomicInteger(0); + StorageException se = + assertThrows( + StorageException.class, + () -> + retrier.run( + () -> { + int count = attemptCounter.incrementAndGet(); + throw new StorageException(503, "Permanent failure attempt " + count); + }, + Decoder.identity(), + StorageMetricsContext.create("GetObject"))); + + assertThat(attemptCounter.get()).isEqualTo(3); + assertThat(se.getCode()).isEqualTo(503); + + Collection collected = reader.collectAllMetrics(); + + // operations = 1 with error.type = "UNAVAILABLE" + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_UNAVAILABLE)) + .isEqualTo(1L); + + // attempts = 3 + assertThat(getCounterValue(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS)) + .isEqualTo(3L); + + // errors = 3 with error.type = "UNAVAILABLE" + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_UNAVAILABLE)) + .isEqualTo(3L); + + // retries = 2 (attempts 1 and 2 triggered a retry, attempt 3 exhausted budget) + assertThat(getCounterValue(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_RETRIES)) + .isEqualTo(2L); + + // duration recorded with terminal error.type = "UNAVAILABLE" + HistogramPointData durationPoint = + getHistogramPoint(collected, StorageClientMetrics.METRIC_GCP_CLIENT_REQUEST_DURATION); + assertThat(durationPoint).isNotNull(); + assertThat(durationPoint.getCount()).isEqualTo(1L); + assertThat(durationPoint.getAttributes().get(StorageClientMetrics.KEY_ERROR_TYPE)) + .isEqualTo(StorageErrorTypeMapper.STATUS_UNAVAILABLE); + } + + @Test + public void metricsDisabled_recordsNoMetrics() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + // metrics disabled + StorageClientMetrics metrics = StorageClientMetrics.create(provider, false, false); + + RetryingDependencies deps = + RetryingDependencies.simple(NanoClock.getDefaultClock(), RETRY_SETTINGS, metrics); + RetrierWithAlg retrier = + new DefaultRetrier(UnaryOperator.identity(), deps).withAlg(Retrying.alwaysRetry()); + + String result = retrier.run(() -> "no-op metrics", Decoder.identity()); + assertThat(result).isEqualTo("no-op metrics"); + + Collection collected = reader.collectAllMetrics(); + assertThat(collected).isEmpty(); + } + + @Test + public void nullMetrics_executesWithoutRecording() { + RetryingDependencies deps = + RetryingDependencies.simple(NanoClock.getDefaultClock(), RETRY_SETTINGS, null); + RetrierWithAlg retrier = + new DefaultRetrier(UnaryOperator.identity(), deps).withAlg(Retrying.alwaysRetry()); + + String result = retrier.run(() -> "null-metrics", Decoder.identity()); + assertThat(result).isEqualTo("null-metrics"); + } + + @Test + public void callingRunWithoutMetricsContext_whenMetricsEnabled_recordsNoMetrics() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false); + + RetryingDependencies deps = + RetryingDependencies.simple(NanoClock.getDefaultClock(), RETRY_SETTINGS, metrics); + RetrierWithAlg retrier = + new DefaultRetrier(UnaryOperator.identity(), deps).withAlg(Retrying.alwaysRetry()); + + String result = retrier.run(() -> "no-context", Decoder.identity()); + assertThat(result).isEqualTo("no-context"); + + Collection collected = reader.collectAllMetrics(); + assertThat(collected).isEmpty(); + } + + @Test + public void httpStorageOptions_createRetrier_wiresMetricsWhenEnabled() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + + HttpStorageOptions options = + HttpStorageOptions.newBuilder() + .setMeterProvider(provider) + .setEnableOtelMetrics(true) + .build(); + + assertThat(options.getStorageClientMetrics()).isNotNull(); + Retrying.Retrier retrier = options.createRetrier(); + assertThat(retrier).isNotNull(); + + StorageMetricsContext context = options.createMetricsContext("GetObject"); + String result = + retrier.run(Retrying.neverRetry(), () -> "options-success", Decoder.identity(), context); + assertThat(result).isEqualTo("options-success"); + + Collection collected = reader.collectAllMetrics(); + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_OK)) + .isEqualTo(1L); + } + + @Test + public void httpStorageOptions_createRetrier_noMetricsWhenDisabled() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + + HttpStorageOptions options = + HttpStorageOptions.newBuilder() + .setMeterProvider(provider) + .setEnableOtelMetrics(false) + .build(); + + assertThat(options.getStorageClientMetrics()).isNull(); + Retrying.Retrier retrier = options.createRetrier(); + + String result = + retrier.run(Retrying.neverRetry(), () -> "options-disabled", Decoder.identity()); + assertThat(result).isEqualTo("options-disabled"); + + Collection collected = reader.collectAllMetrics(); + assertThat(collected).isEmpty(); + } + + @Test + public void grpcStorageOptions_createRetrier_wiresMetricsWhenEnabled() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + + GrpcStorageOptions options = + GrpcStorageOptions.newBuilder() + .setMeterProvider(provider) + .setEnableOtelMetrics(true) + .build(); + + assertThat(options.getStorageClientMetrics()).isNotNull(); + Retrying.Retrier retrier = options.createRetrier(); + assertThat(retrier).isNotNull(); + + StorageMetricsContext context = options.createMetricsContext("GetObject"); + String result = + retrier.run( + Retrying.neverRetry(), () -> "grpc-options-success", Decoder.identity(), context); + assertThat(result).isEqualTo("grpc-options-success"); + + Collection collected = reader.collectAllMetrics(); + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_OK)) + .isEqualTo(1L); + } + + @Test + public void grpcStorageOptions_createRetrier_noMetricsWhenDisabled() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + + GrpcStorageOptions options = + GrpcStorageOptions.newBuilder() + .setMeterProvider(provider) + .setEnableOtelMetrics(false) + .build(); + + assertThat(options.getStorageClientMetrics()).isNull(); + Retrying.Retrier retrier = options.createRetrier(); + + String result = + retrier.run(Retrying.neverRetry(), () -> "grpc-options-disabled", Decoder.identity()); + assertThat(result).isEqualTo("grpc-options-disabled"); + + Collection collected = reader.collectAllMetrics(); + assertThat(collected).isEmpty(); + } + + @Test + public void nonRetryableError_singleAttemptTerminalFailure_recordsErrorAndTerminalOperation() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false); + + RetryingDependencies deps = + RetryingDependencies.simple(NanoClock.getDefaultClock(), RETRY_SETTINGS, metrics); + RetrierWithAlg retrier = + new DefaultRetrier(UnaryOperator.identity(), deps).withAlg(Retrying.neverRetry()); + + StorageException se = + assertThrows( + StorageException.class, + () -> + retrier.run( + () -> { + throw new StorageException(404, "Not Found"); + }, + Decoder.identity(), + StorageMetricsContext.create("GetObject"))); + + assertThat(se.getCode()).isEqualTo(404); + + Collection collected = reader.collectAllMetrics(); + + // operations = 1 with error.type = "NOT_FOUND" + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_NOT_FOUND)) + .isEqualTo(1L); + + // attempts = 1 + assertThat(getCounterValue(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS)) + .isEqualTo(1L); + + // retries = 0 + assertThat(getCounterValue(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_RETRIES)) + .isEqualTo(0L); + + // errors = 1 with error.type = "NOT_FOUND" + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_NOT_FOUND)) + .isEqualTo(1L); + + HistogramPointData durationPoint = + getHistogramPoint(collected, StorageClientMetrics.METRIC_GCP_CLIENT_REQUEST_DURATION); + assertThat(durationPoint).isNotNull(); + assertThat(durationPoint.getCount()).isEqualTo(1L); + assertThat(durationPoint.getAttributes().get(StorageClientMetrics.KEY_ERROR_TYPE)) + .isEqualTo(StorageErrorTypeMapper.STATUS_NOT_FOUND); + } + + @Test + public void recordAttemptError_specificExceptions_mappedToCanonicalTypes() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false); + + RetryingDependencies deps = + RetryingDependencies.simple(NanoClock.getDefaultClock(), RETRY_SETTINGS, metrics); + RetrierWithAlg retrier = + new DefaultRetrier(UnaryOperator.identity(), deps).withAlg(Retrying.alwaysRetry()); + + AtomicInteger attemptCounter = new AtomicInteger(0); + StorageException se = + assertThrows( + StorageException.class, + () -> + retrier.run( + () -> { + int count = attemptCounter.incrementAndGet(); + if (count == 1) { + throw new StatusRuntimeException(Status.DEADLINE_EXCEEDED); + } else if (count == 2) { + throw new SocketTimeoutException("Read timed out"); + } else { + throw new UnknownHostException("storage.googleapis.com"); + } + }, + Decoder.identity(), + StorageMetricsContext.create("GetObject"))); + + assertThat(attemptCounter.get()).isEqualTo(3); + assertThat(se).isNotNull(); + + Collection collected = reader.collectAllMetrics(); + + // attempts = 3 + assertThat(getCounterValue(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS)) + .isEqualTo(3L); + + // retries = 2 + assertThat(getCounterValue(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_RETRIES)) + .isEqualTo(2L); + + // errors mapped to TIMEOUT (attempt 1 and 2) = 2L + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_TIMEOUT)) + .isEqualTo(2L); + + // errors mapped to DNS_FAILURE (attempt 3) = 1L + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_DNS_FAILURE)) + .isEqualTo(1L); + + // terminal operations = 1 with DNS_FAILURE + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_DNS_FAILURE)) + .isEqualTo(1L); + } + + @Test + public void illegalArgumentException_parsingFailure_recordedAndRethrown() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false); + + RetryingDependencies deps = + RetryingDependencies.simple(NanoClock.getDefaultClock(), RETRY_SETTINGS, metrics); + RetrierWithAlg retrier = + new DefaultRetrier(UnaryOperator.identity(), deps).withAlg(Retrying.neverRetry()); + + StorageException se = + assertThrows( + StorageException.class, + () -> + retrier.run( + () -> { + throw new IllegalArgumentException("malformed json response"); + }, + Decoder.identity(), + StorageMetricsContext.create("GetObject"))); + + assertThat(se.getCause()).isInstanceOf(IllegalArgumentException.class); + assertThat(se.getCause()).hasMessageThat().contains("malformed json response"); + + Collection collected = reader.collectAllMetrics(); + + // operations = 1 with error.type = "UNKNOWN" + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_UNKNOWN)) + .isEqualTo(1L); + + // attempts = 1 + assertThat(getCounterValue(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS)) + .isEqualTo(1L); + + // errors = 1 with error.type = "UNKNOWN" + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS, + StorageClientMetrics.KEY_ERROR_TYPE, + StorageErrorTypeMapper.STATUS_UNKNOWN)) + .isEqualTo(1L); + } + + @Test + public void run_withCustomMetricsContext_recordsSpecificOperationAttribute() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false); + + RetryingDependencies deps = + RetryingDependencies.simple(NanoClock.getDefaultClock(), RETRY_SETTINGS, metrics); + Retrier retrier = new DefaultRetrier(UnaryOperator.identity(), deps); + + StorageMetricsContext customContext = StorageMetricsContext.create("GetObject"); + + String result = + retrier.run( + Retrying.neverRetry(), () -> "custom-op-result", Decoder.identity(), customContext); + + assertThat(result).isEqualTo("custom-op-result"); + + Collection collected = reader.collectAllMetrics(); + + // operations counter has gcp.storage.operation = "GetObject" and gcp.client.method = + // "GetObject" + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS, + StorageClientMetrics.KEY_GCP_STORAGE_OPERATION, + "GetObject")) + .isEqualTo(1L); + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS, + StorageClientMetrics.KEY_GCP_CLIENT_METHOD, + "GetObject")) + .isEqualTo(1L); + + // operations counter does NOT have rpc.method + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS, + StorageClientMetrics.KEY_RPC_METHOD, + "GetObject")) + .isEqualTo(0L); + + // duration histogram has gcp.storage.operation = "GetObject" and rpc.method = + // "google.storage.v2.Storage/GetObject" + HistogramPointData durationPoint = + getHistogramPoint(collected, StorageClientMetrics.METRIC_GCP_CLIENT_REQUEST_DURATION); + assertThat(durationPoint).isNotNull(); + assertThat(durationPoint.getAttributes().get(StorageClientMetrics.KEY_GCP_STORAGE_OPERATION)) + .isEqualTo("GetObject"); + assertThat(durationPoint.getAttributes().get(StorageClientMetrics.KEY_GCP_CLIENT_METHOD)) + .isEqualTo("GetObject"); + assertThat(durationPoint.getAttributes().get(StorageClientMetrics.KEY_RPC_METHOD)) + .isEqualTo("google.storage.v2.Storage/GetObject"); + + // attempts counter has gcp.storage.operation = "GetObject" and does NOT have rpc.method + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS, + StorageClientMetrics.KEY_GCP_STORAGE_OPERATION, + "GetObject")) + .isEqualTo(1L); + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS, + StorageClientMetrics.KEY_RPC_METHOD, + "GetObject")) + .isEqualTo(0L); + } + + @Test + public void withAlg_withCustomMetricsContext_propagatesContext() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false); + + RetryingDependencies deps = + RetryingDependencies.simple(NanoClock.getDefaultClock(), RETRY_SETTINGS, metrics); + StorageMetricsContext customContext = StorageMetricsContext.create("GetObject"); + RetrierWithAlg retrier = + new DefaultRetrier(UnaryOperator.identity(), deps).withAlg(Retrying.neverRetry()); + + String result = retrier.run(() -> "custom-op-result", Decoder.identity(), customContext); + assertThat(result).isEqualTo("custom-op-result"); + + Collection collected = reader.collectAllMetrics(); + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS, + StorageClientMetrics.KEY_GCP_STORAGE_OPERATION, + "GetObject")) + .isEqualTo(1L); + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS, + StorageClientMetrics.KEY_GCP_STORAGE_OPERATION, + "GetObject")) + .isEqualTo(1L); + } + + @Test + public void newCallContext_attachesMetricsContext() { + StorageMetricsContext customContext = StorageMetricsContext.create("GetObject"); + com.google.api.gax.grpc.GrpcCallContext callContext = Retrying.newCallContext(customContext); + assertThat(StorageMetricsContext.from(callContext)).isSameInstanceAs(customContext); + + com.google.api.gax.grpc.GrpcCallContext nullContext = Retrying.newCallContext(null); + assertThat(StorageMetricsContext.from(nullContext)).isNull(); + } + + @Test + public void run_withCustomMetricsContext_recordsOperationOnRetriesAndErrors() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false); + + RetryingDependencies deps = + RetryingDependencies.simple(NanoClock.getDefaultClock(), RETRY_SETTINGS, metrics); + Retrier retrier = new DefaultRetrier(UnaryOperator.identity(), deps); + + StorageMetricsContext customContext = StorageMetricsContext.create("GetObject"); + + AtomicInteger attempts = new AtomicInteger(0); + String result = + retrier.run( + Retrying.alwaysRetry(), + () -> { + int count = attempts.incrementAndGet(); + if (count < 2) { + throw new StorageException(503, "Unavailable"); + } + return "recovered"; + }, + Decoder.identity(), + customContext); + + assertThat(result).isEqualTo("recovered"); + + Collection collected = reader.collectAllMetrics(); + + // retries counter has gcp.storage.operation and does NOT have rpc.method + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_RETRIES, + StorageClientMetrics.KEY_GCP_STORAGE_OPERATION, + "GetObject")) + .isEqualTo(1L); + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_RETRIES, + StorageClientMetrics.KEY_RPC_METHOD, + "GetObject")) + .isEqualTo(0L); + + // errors counter has gcp.storage.operation and does NOT have rpc.method + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS, + StorageClientMetrics.KEY_GCP_STORAGE_OPERATION, + "GetObject")) + .isEqualTo(1L); + assertThat( + getCounterValueWithAttribute( + collected, + StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS, + StorageClientMetrics.KEY_RPC_METHOD, + "GetObject")) + .isEqualTo(0L); + } + + @Test + public void run_withCustomMetricsContext_propagatesOtelContextScope() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false); + + RetryingDependencies deps = + RetryingDependencies.simple(NanoClock.getDefaultClock(), RETRY_SETTINGS, metrics); + Retrier retrier = new DefaultRetrier(UnaryOperator.identity(), deps); + + StorageMetricsContext customContext = StorageMetricsContext.create("GetObject"); + + io.opentelemetry.context.Context[] observedContext = new io.opentelemetry.context.Context[1]; + + retrier.run( + Retrying.neverRetry(), + () -> { + observedContext[0] = io.opentelemetry.context.Context.current(); + return "ok"; + }, + Decoder.identity(), + customContext); + + assertThat(observedContext[0]).isEqualTo(customContext.getOtelContext()); + } + + private static @Nullable MetricData findMetric( + Collection metrics, String metricName) { + for (MetricData md : metrics) { + if (md.getName().equals(metricName) || md.getName().equals(metricName.replace(".", "/"))) { + return md; + } + } + return null; + } + + private static long getCounterValue(Collection metrics, String metricName) { + MetricData md = findMetric(metrics, metricName); + if (md == null) { + return 0L; + } + return md.getLongSumData().getPoints().stream().mapToLong(LongPointData::getValue).sum(); + } + + private static long getCounterValueWithAttribute( + Collection metrics, + String metricName, + AttributeKey key, + String expectedValue) { + MetricData md = findMetric(metrics, metricName); + if (md == null) { + return 0L; + } + return md.getLongSumData().getPoints().stream() + .filter(p -> expectedValue.equals(p.getAttributes().get(key))) + .mapToLong(LongPointData::getValue) + .sum(); + } + + private static @Nullable HistogramPointData getHistogramPoint( + Collection metrics, String metricName) { + MetricData md = findMetric(metrics, metricName); + if (md == null) { + return null; + } + return md.getHistogramData().getPoints().iterator().hasNext() + ? md.getHistogramData().getPoints().iterator().next() + : null; + } +} diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageClientMetricsTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageClientMetricsTest.java index 49966661f117..a6b32c164c0f 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageClientMetricsTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageClientMetricsTest.java @@ -21,14 +21,21 @@ import com.google.common.collect.ImmutableList; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.metrics.Aggregation; +import io.opentelemetry.sdk.metrics.InstrumentType; import io.opentelemetry.sdk.metrics.SdkMeterProvider; import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder; +import io.opentelemetry.sdk.metrics.data.AggregationTemporality; import io.opentelemetry.sdk.metrics.data.HistogramPointData; import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.metrics.export.CollectionRegistration; +import io.opentelemetry.sdk.metrics.export.MetricReader; import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import java.util.stream.Collectors; import org.junit.Before; @@ -81,7 +88,7 @@ public void standardMetrics_initializedWithoutDebug() { assertThat(metrics.getClientAttributes().get(StorageClientMetrics.KEY_GCP_CLIENT_ARTIFACT)) .isEqualTo("com.google.cloud:google-cloud-storage"); assertThat(metrics.getClientAttributes().get(StorageClientMetrics.KEY_GCP_CLIENT_INSTANCE_ID)) - .isNotEmpty(); + .isNull(); assertInstruments(metrics, DEBUG_INSTRUMENTS, /* isNull= */ true); } @@ -293,8 +300,7 @@ public void attachClientAttributes_mergesAdditionalAttributes() { Attributes merged = metrics.attachClientAttributes(extra); - assertThat(merged.get(StorageClientMetrics.KEY_GCP_CLIENT_INSTANCE_ID)) - .isEqualTo("test-instance"); + assertThat(merged.get(StorageClientMetrics.KEY_GCP_CLIENT_INSTANCE_ID)).isNull(); assertThat(merged.get(StorageClientMetrics.KEY_GCP_CLIENT_VERSION)) .isEqualTo(StorageOptions.version()); assertThat(merged.get(StorageClientMetrics.KEY_GCP_CLIENT_ARTIFACT)) @@ -347,6 +353,56 @@ private static void assertHistogramBoundaries( assertThat(pointData.getBoundaries()).isEqualTo(expectedBoundaries); } + @Test + public void close_closesOwnedMeterProvider() { + TestMetricReader reader = new TestMetricReader(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, provider, true, false); + metrics.close(); + assertThat(reader.shutdownCalled.get()).isTrue(); + } + + @Test + public void close_doesNotThrowWhenOwnedMeterProviderIsNull() { + TestMetricReader reader = new TestMetricReader(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + StorageClientMetrics metrics = + StorageClientMetrics.create(provider, (SdkMeterProvider) null, true, false); + metrics.close(); + assertThat(reader.shutdownCalled.get()).isFalse(); + } + + static final class TestMetricReader implements MetricReader { + private final MetricReader delegate = InMemoryMetricReader.create(); + final AtomicBoolean shutdownCalled = new AtomicBoolean(false); + + @Override + public void register(CollectionRegistration registration) { + delegate.register(registration); + } + + @Override + public AggregationTemporality getAggregationTemporality(InstrumentType instrumentType) { + return delegate.getAggregationTemporality(instrumentType); + } + + @Override + public Aggregation getDefaultAggregation(InstrumentType instrumentType) { + return delegate.getDefaultAggregation(instrumentType); + } + + @Override + public CompletableResultCode forceFlush() { + return delegate.forceFlush(); + } + + @Override + public CompletableResultCode shutdown() { + shutdownCalled.set(true); + return delegate.shutdown(); + } + } + private static void assertInstruments( StorageClientMetrics metrics, List> instruments, diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageMetricsContextTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageMetricsContextTest.java index 6aa6d936384c..0a09523b5689 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageMetricsContextTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageMetricsContextTest.java @@ -191,4 +191,44 @@ public void transportAttachment_propertiesMap() { Map unattached = new HashMap<>(); assertThat(StorageMetricsContext.from(unattached)).isNull(); } + + @Test + public void multiStepContext_behavior() { + StorageMetricsContext parent = StorageMetricsContext.createForMultiStep("WriteObject"); + assertThat(parent.shouldRecordOperationOnRetrierExit()).isFalse(); + + StorageMetricsContext subAttempt = parent.asSubAttemptContext(); + assertThat(subAttempt.shouldRecordOperationOnRetrierExit()).isFalse(); + assertThat(subAttempt.getOperationName()).isEqualTo("WriteObject"); + + subAttempt.incrementAttempts(); + assertThat(parent.getAttemptCount()).isEqualTo(1); + assertThat(subAttempt.getAttemptCount()).isEqualTo(1); + } + + @Test + public void recordOperation_isIdempotent() { + io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader reader = + io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader.create(); + io.opentelemetry.sdk.metrics.SdkMeterProvider provider = + io.opentelemetry.sdk.metrics.SdkMeterProvider.builder() + .registerMetricReader(reader) + .build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, true, false); + + StorageMetricsContext context = StorageMetricsContext.createForMultiStep("WriteObject"); + context.recordOperation(metrics, null); + context.recordOperation(metrics, null); + + java.util.Collection collected = + reader.collectAllMetrics(); + long opCount = + collected.stream() + .filter( + m -> m.getName().equals(StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_OPERATIONS)) + .flatMap(m -> m.getLongSumData().getPoints().stream()) + .mapToLong(p -> p.getValue()) + .sum(); + assertThat(opCount).isEqualTo(1L); + } } diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsTest.java index f2dc6f3fda8d..dfe074b80753 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsTest.java @@ -20,6 +20,7 @@ import static com.google.common.truth.Truth.assertWithMessage; import static org.junit.Assert.assertThrows; +import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.metrics.MeterProvider; import io.opentelemetry.sdk.metrics.SdkMeterProvider; import java.io.ByteArrayInputStream; @@ -458,4 +459,104 @@ public void deserializationWithNullMetricInterval_defaultsToSixtySeconds() throw assertThat(deserialized.hashCode()).isEqualTo(defaultOptions.hashCode()); }); } + + @Test + public void extractServerAddress_parsesVariousHostFormats() { + assertThat(StorageOptions.extractServerAddress("https://storage.googleapis.com")) + .isEqualTo("storage.googleapis.com"); + assertThat(StorageOptions.extractServerAddress("http://localhost:8080")).isEqualTo("localhost"); + assertThat(StorageOptions.extractServerAddress("google-c2p:///storage.googleapis.com")) + .isEqualTo("storage.googleapis.com"); + assertThat(StorageOptions.extractServerAddress("https://storage.googleapis.com/path")) + .isEqualTo("storage.googleapis.com"); + assertThat(StorageOptions.extractServerAddress("http://[::1]:8080")).isEqualTo("::1"); + assertThat(StorageOptions.extractServerAddress("[::1]")).isEqualTo("::1"); + assertThat(StorageOptions.extractServerAddress(null)).isNull(); + assertThat(StorageOptions.extractServerAddress(" ")).isNull(); + } + + @Test + public void createMetricsContext_populatesRpcSystemNameAndServerAddress() { + MeterProvider meterProvider = SdkMeterProvider.builder().build(); + + HttpStorageOptions httpOptions = + StorageOptions.http() + .setEnableOtelMetrics(true) + .setMeterProvider(meterProvider) + .setHost("https://storage.googleapis.com") + .build(); + StorageMetricsContext httpContext = httpOptions.createMetricsContext("GetObject"); + assertThat(httpContext).isNotNull(); + assertThat(httpContext.getBaseAttributes().get(StorageClientMetrics.KEY_RPC_SYSTEM_NAME)) + .isEqualTo("http"); + assertThat(httpContext.getBaseAttributes().get(StorageClientMetrics.KEY_SERVER_ADDRESS)) + .isEqualTo("storage.googleapis.com"); + + GrpcStorageOptions grpcOptions = + StorageOptions.grpc() + .setEnableOtelMetrics(true) + .setMeterProvider(meterProvider) + .setHost("google-c2p:///storage.googleapis.com") + .build(); + StorageMetricsContext grpcContext = grpcOptions.createMetricsContext("GetObject"); + assertThat(grpcContext).isNotNull(); + assertThat(grpcContext.getBaseAttributes().get(StorageClientMetrics.KEY_RPC_SYSTEM_NAME)) + .isEqualTo("grpc"); + assertThat(grpcContext.getBaseAttributes().get(StorageClientMetrics.KEY_SERVER_ADDRESS)) + .isEqualTo("storage.googleapis.com"); + } + + @Test + public void meterProvider_usedWhenExplicitlySet() { + MeterProvider customMeterProvider = SdkMeterProvider.builder().build(); + HttpStorageOptions options = + StorageOptions.http() + .setEnableOtelMetrics(true) + .setMeterProvider(customMeterProvider) + .build(); + StorageClientMetrics metrics = options.getStorageClientMetrics(); + assertThat(metrics).isNotNull(); + } + + @Test + public void customOpenTelemetry_meterProviderUsedWhenConfigured() { + SdkMeterProvider sdkMeterProvider = SdkMeterProvider.builder().build(); + OpenTelemetry customOtel = + io.opentelemetry.sdk.OpenTelemetrySdk.builder().setMeterProvider(sdkMeterProvider).build(); + HttpStorageOptions options = + StorageOptions.http().setEnableOtelMetrics(true).setOpenTelemetry(customOtel).build(); + assertThat(options.isCustomOpenTelemetryConfigured()).isTrue(); + StorageClientMetrics metrics = options.getStorageClientMetrics(); + assertThat(metrics).isNotNull(); + } + + @Test + public void closeStorageClientMetrics_cleansUpMetricsAndClosesSdkOwnedProvider() { + StorageClientMetricsTest.TestMetricReader reader = + new StorageClientMetricsTest.TestMetricReader(); + SdkMeterProvider mockOwnedProvider = + SdkMeterProvider.builder().registerMetricReader(reader).build(); + HttpStorageOptions options = + StorageOptions.http() + .setEnableOtelMetrics(true) + .setMeterProvider(mockOwnedProvider) + .build(); + StorageClientMetrics metrics = + StorageClientMetrics.create(mockOwnedProvider, mockOwnedProvider, true, false); + options.storageClientMetrics = metrics; + + options.closeStorageClientMetrics(); + assertThat(options.storageClientMetrics).isNull(); + assertThat(reader.shutdownCalled.get()).isTrue(); + } + + @Test + public void closeStorageClientMetrics_doesNotFailOnUserSuppliedMeterProvider() { + MeterProvider userProvider = SdkMeterProvider.builder().build(); + HttpStorageOptions options = + StorageOptions.http().setEnableOtelMetrics(true).setMeterProvider(userProvider).build(); + assertThat(options.getStorageClientMetrics()).isNotNull(); + options.closeStorageClientMetrics(); + assertThat(options.storageClientMetrics).isNull(); + } }