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 659fb698dcd4..d7e1d2308e67 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 @@ -52,8 +52,8 @@ final class StorageClientMetrics { AttributeKey.stringKey("rpc.response.status_code"); static final AttributeKey KEY_HTTP_REQUEST_METHOD = AttributeKey.stringKey("http.request.method"); - static final AttributeKey KEY_HTTP_RESPONSE_STATUS_CODE = - AttributeKey.longKey("http.response.status_code"); + static final AttributeKey KEY_HTTP_RESPONSE_STATUS_CODE = + AttributeKey.stringKey("http.response.status_code"); static final AttributeKey KEY_URL_TEMPLATE = AttributeKey.stringKey("url.template"); static final AttributeKey KEY_SERVER_ADDRESS = AttributeKey.stringKey("server.address"); static final AttributeKey KEY_SERVER_PORT = AttributeKey.longKey("server.port"); 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 new file mode 100644 index 000000000000..47d4b83ee73f --- /dev/null +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageMetricsContext.java @@ -0,0 +1,458 @@ +/* + * 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 java.util.Objects.requireNonNull; + +import com.google.api.gax.grpc.GrpcCallContext; +import com.google.api.gax.rpc.ApiCallContext; +import com.google.api.gax.rpc.ApiException; +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.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Package-private holder for operation metric state across asynchronous and thread-hopping + * boundaries. + */ +@NullMarked +final class StorageMetricsContext { + + static final CallOptions.Key GRPC_CALL_OPTIONS_KEY = + CallOptions.Key.create("storage-metrics-context"); + static final ApiCallContext.Key API_CALL_CONTEXT_KEY = + ApiCallContext.Key.create("storage-metrics-context"); + static final String HTTP_REQUEST_PROPERTY_KEY = "com.google.cloud.storage.StorageMetricsContext"; + + private final String operationName; + private final long startTimeNanos; + private final Context otelContext; + private final AtomicInteger attemptCount; + private final AtomicLong requestBytes; + private final AtomicLong responseBytes; + private final Attributes baseAttributes; + private final boolean recordOperationOnRetrierExit; + private final AtomicBoolean operationRecorded; + + private 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.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, Attributes baseAttributes, long startTimeNanos) { + return new StorageMetricsContext( + operationName, + baseAttributes, + startTimeNanos, + Context.current(), + true, + new AtomicBoolean(false), + new AtomicInteger(0), + new AtomicLong(0L), + new AtomicLong(0L)); + } + + static StorageMetricsContext create(String operationName) { + return create(operationName, Attributes.empty()); + } + + static StorageMetricsContext create(String operationName, Attributes baseAttributes) { + return create(operationName, baseAttributes, Context.current()); + } + + static StorageMetricsContext create( + String operationName, Attributes baseAttributes, Context 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)); + } + + 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); + } + } + } + + void recordAttemptSuccess(@Nullable StorageClientMetrics metrics) { + if (metrics == null || metrics.getAttempts() == null) { + return; + } + try (Scope ignore = otelContext.makeCurrent()) { + Attributes attemptBase = buildAttemptBaseAttributes(metrics); + AttributesBuilder builder = + attemptBase.toBuilder() + .put(StorageClientMetrics.KEY_ERROR_TYPE, StorageErrorTypeMapper.STATUS_OK) + .put(StorageClientMetrics.KEY_NETWORK_PATH, resolveNetworkPath()); + String rpcSystemName = baseAttributes.get(StorageClientMetrics.KEY_RPC_SYSTEM_NAME); + if ("grpc".equals(rpcSystemName)) { + String rpcCode = baseAttributes.get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE); + builder.put( + StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE, rpcCode != null ? rpcCode : "OK"); + builder.put(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, ""); + } else if ("http".equals(rpcSystemName)) { + String httpCode = baseAttributes.get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE); + builder.put( + StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, + httpCode != null ? httpCode : "200"); + builder.put(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE, ""); + } else { + String rpcCode = baseAttributes.get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE); + String httpCode = baseAttributes.get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE); + builder.put( + StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE, rpcCode != null ? rpcCode : ""); + builder.put( + StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, httpCode != null ? httpCode : ""); + } + metrics.getAttempts().add(1, builder.build()); + } + } + + void recordAttemptFailure(@Nullable StorageClientMetrics metrics, Throwable e) { + if (metrics == null) { + return; + } + try (Scope ignore = otelContext.makeCurrent()) { + String errorType = StorageErrorTypeMapper.mapThrowable(e); + String networkPath = resolveNetworkPath(); + Attributes attemptBase = buildAttemptBaseAttributes(metrics); + if (metrics.getErrors() != null) { + Attributes errorAttributes = + attemptBase.toBuilder() + .put(StorageClientMetrics.KEY_ERROR_TYPE, errorType) + .put(StorageClientMetrics.KEY_NETWORK_PATH, networkPath) + .build(); + metrics.getErrors().add(1, errorAttributes); + } + if (metrics.getAttempts() != null) { + AttributesBuilder builder = + attemptBase.toBuilder() + .put(StorageClientMetrics.KEY_ERROR_TYPE, errorType) + .put(StorageClientMetrics.KEY_NETWORK_PATH, networkPath); + String rpcSystemName = baseAttributes.get(StorageClientMetrics.KEY_RPC_SYSTEM_NAME); + if ("grpc".equals(rpcSystemName)) { + String rpcStatus = extractRpcStatusCode(e); + builder.put( + StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE, + rpcStatus != null ? rpcStatus : ""); + builder.put(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, ""); + } else if ("http".equals(rpcSystemName)) { + String httpStatus = extractHttpStatusCode(e); + builder.put( + StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, + httpStatus != null ? httpStatus : ""); + builder.put(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE, ""); + } else { + String rpcStatus = extractRpcStatusCode(e); + String httpStatus = extractHttpStatusCode(e); + builder.put( + StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE, + rpcStatus != null ? rpcStatus : ""); + builder.put( + StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, + httpStatus != null ? httpStatus : ""); + } + metrics.getAttempts().add(1, builder.build()); + } + } + } + + private Attributes buildAttemptBaseAttributes(StorageClientMetrics metrics) { + AttributesBuilder baseBuilder = Attributes.builder(); + baseBuilder.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; + baseBuilder.put(objKey, val); + } + }); + baseBuilder.put(StorageClientMetrics.KEY_GCP_STORAGE_OPERATION, operationName); + return baseBuilder.build(); + } + + private String resolveNetworkPath() { + String path = baseAttributes.get(StorageClientMetrics.KEY_NETWORK_PATH); + if (path != null && !path.isEmpty()) { + return path; + } + return StorageClientMetrics.NETWORK_PATH_CLOUDPATH; + } + + private @Nullable String extractRpcStatusCode(@Nullable Throwable e) { + String fromContext = baseAttributes.get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE); + if (fromContext != null) { + return fromContext; + } + Throwable curr = e; + while (curr != null && curr.getCause() != curr) { + 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 ApiException) { + return ((ApiException) curr).getStatusCode().getCode().name(); + } + curr = curr.getCause(); + } + return null; + } + + private @Nullable String extractHttpStatusCode(@Nullable Throwable e) { + String fromContext = baseAttributes.get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE); + if (fromContext != null) { + return fromContext; + } + Throwable curr = e; + while (curr != null && curr.getCause() != curr) { + if (curr instanceof StorageException) { + int code = ((StorageException) curr).getCode(); + if (code > 0) { + return Integer.toString(code); + } + } + if (curr instanceof com.google.api.client.http.HttpResponseException) { + int code = ((com.google.api.client.http.HttpResponseException) curr).getStatusCode(); + if (code > 0) { + return Integer.toString(code); + } + } + curr = curr.getCause(); + } + return null; + } + + static @Nullable StorageMetricsContext from(@Nullable CallOptions callOptions) { + return callOptions != null ? callOptions.getOption(GRPC_CALL_OPTIONS_KEY) : null; + } + + static @Nullable StorageMetricsContext from(@Nullable GrpcCallContext context) { + if (context == null) { + return null; + } + StorageMetricsContext result = context.getOption(API_CALL_CONTEXT_KEY); + if (result != null) { + return result; + } + return from(context.getCallOptions()); + } + + static @Nullable StorageMetricsContext from(@Nullable ApiCallContext context) { + if (context == null) { + return null; + } + if (context instanceof GrpcCallContext) { + return from((GrpcCallContext) context); + } + return context.getOption(API_CALL_CONTEXT_KEY); + } + + static @Nullable StorageMetricsContext from(@Nullable Map properties) { + if (properties == null) { + return null; + } + Object obj = properties.get(HTTP_REQUEST_PROPERTY_KEY); + return (obj instanceof StorageMetricsContext) ? (StorageMetricsContext) obj : null; + } + + CallOptions attach(@Nullable CallOptions callOptions) { + CallOptions options = callOptions != null ? callOptions : CallOptions.DEFAULT; + return options.withOption(GRPC_CALL_OPTIONS_KEY, this); + } + + GrpcCallContext attach(@Nullable GrpcCallContext context) { + GrpcCallContext callContext = context != null ? context : GrpcCallContext.createDefault(); + return callContext + .withCallOptions(attach(callContext.getCallOptions())) + .withOption(API_CALL_CONTEXT_KEY, this); + } + + @Nullable ApiCallContext attach(@Nullable ApiCallContext context) { + if (context == null) { + return null; + } + if (context instanceof GrpcCallContext) { + return attach((GrpcCallContext) context); + } + return context.withOption(API_CALL_CONTEXT_KEY, this); + } + + @Nullable Map attach(@Nullable Map properties) { + if (properties != null) { + properties.put(HTTP_REQUEST_PROPERTY_KEY, this); + } + return properties; + } + + int incrementAttempts() { + return attemptCount.incrementAndGet(); + } + + int getAttemptCount() { + return attemptCount.get(); + } + + long addRequestBytes(long bytes) { + return requestBytes.addAndGet(bytes); + } + + long getRequestBytes() { + return requestBytes.get(); + } + + long addResponseBytes(long bytes) { + return responseBytes.addAndGet(bytes); + } + + long getResponseBytes() { + return responseBytes.get(); + } + + String getOperationName() { + return operationName; + } + + long getStartTimeNanos() { + return startTimeNanos; + } + + Context getOtelContext() { + return otelContext; + } + + double getElapsedSeconds() { + return getElapsedSeconds(System.nanoTime()); + } + + double getElapsedSeconds(long currentTimeNanos) { + return (currentTimeNanos - startTimeNanos) / 1_000_000_000.0; + } + + Attributes getBaseAttributes() { + return baseAttributes; + } + + AttributesBuilder attributesBuilder() { + return baseAttributes.toBuilder(); + } +} 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 new file mode 100644 index 000000000000..d096573b37ed --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageMetricsContextTest.java @@ -0,0 +1,612 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.storage; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; +import com.google.api.gax.grpc.GrpcCallContext; +import com.google.api.gax.grpc.GrpcStatusCode; +import com.google.api.gax.rpc.ApiCallContext; +import com.google.api.gax.rpc.ApiException; +import io.grpc.CallOptions; +import io.grpc.Status; +import io.grpc.StatusException; +import io.grpc.StatusRuntimeException; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.context.Context; +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.util.Collection; +import java.util.HashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.junit.Before; +import org.junit.Test; + +public final class StorageMetricsContextTest { + + private InMemoryMetricReader reader; + private SdkMeterProvider provider; + private StorageClientMetrics metrics; + + @Before + public void setUp() { + resetMetrics(); + } + + private void resetMetrics() { + reader = InMemoryMetricReader.create(); + provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + metrics = StorageClientMetrics.create(provider, true, false); + } + + @Test + public void create_initializesDefaultState() { + StorageMetricsContext context = StorageMetricsContext.create("GetObject"); + + assertThat(context.getOperationName()).isEqualTo("GetObject"); + assertThat(context.getStartTimeNanos()).isGreaterThan(0L); + assertThat(context.getElapsedSeconds()).isAtLeast(0.0); + assertThat(context.getAttemptCount()).isEqualTo(0); + assertThat(context.getRequestBytes()).isEqualTo(0L); + assertThat(context.getResponseBytes()).isEqualTo(0L); + assertThat(context.getBaseAttributes()).isEqualTo(Attributes.empty()); + assertThat(context.getOtelContext()).isSameInstanceAs(Context.current()); + } + + @Test + public void create_withOtelContext() { + Context customContext = Context.root(); + StorageMetricsContext context = + StorageMetricsContext.create("GetObject", Attributes.empty(), customContext); + assertThat(context.getOtelContext()).isSameInstanceAs(customContext); + } + + @Test + public void create_validatesNonNullArguments() { + assertThrows(NullPointerException.class, () -> StorageMetricsContext.create(null)); + assertThrows( + NullPointerException.class, () -> StorageMetricsContext.create(null, Attributes.empty())); + assertThrows(NullPointerException.class, () -> StorageMetricsContext.create("op", null)); + assertThrows( + NullPointerException.class, + () -> StorageMetricsContext.create("op", Attributes.empty(), null)); + assertThrows(NullPointerException.class, () -> StorageMetricsContext.createForMultiStep(null)); + assertThrows( + NullPointerException.class, + () -> StorageMetricsContext.createForMultiStep(null, Attributes.empty())); + assertThrows( + NullPointerException.class, () -> StorageMetricsContext.createForMultiStep("op", null)); + assertThrows( + NullPointerException.class, + () -> StorageMetricsContext.createForMultiStep("op", Attributes.empty(), null)); + } + + @Test + public void create_withBaseAttributes() { + Attributes attrs = + Attributes.of( + AttributeKey.stringKey("gcp.client.service"), + "storage", + AttributeKey.stringKey("gcp.client.version"), + "2.74.0"); + StorageMetricsContext context = StorageMetricsContext.create("WriteObject", attrs); + + assertThat(context.getOperationName()).isEqualTo("WriteObject"); + assertThat(context.getBaseAttributes()).isEqualTo(attrs); + assertThat(context.getBaseAttributes().toBuilder().build()).isEqualTo(attrs); + } + + @Test + public void stateMutations_accumulateValues() { + StorageMetricsContext context = StorageMetricsContext.create("WriteObject"); + + assertThat(context.incrementAttempts()).isEqualTo(1); + assertThat(context.incrementAttempts()).isEqualTo(2); + assertThat(context.getAttemptCount()).isEqualTo(2); + + assertThat(context.addRequestBytes(1024L)).isEqualTo(1024L); + assertThat(context.addRequestBytes(2048L)).isEqualTo(3072L); + assertThat(context.getRequestBytes()).isEqualTo(3072L); + + assertThat(context.addResponseBytes(512L)).isEqualTo(512L); + assertThat(context.addResponseBytes(512L)).isEqualTo(1024L); + assertThat(context.getResponseBytes()).isEqualTo(1024L); + } + + @Test + public void elapsedSeconds_computesCorrectly() { + long startTime = 1_000_000_000L; + StorageMetricsContext context = + StorageMetricsContext.create("GetObject", Attributes.empty(), startTime); + + long endTime = 2_500_000_000L; + double elapsed = context.getElapsedSeconds(endTime); + assertThat(elapsed).isEqualTo(1.5); + assertThat(context.getElapsedSeconds()).isAtLeast(0.0); + } + + @Test + public void transportAttachment_grpcCallOptions() { + StorageMetricsContext context = StorageMetricsContext.create("GetObject"); + CallOptions options = CallOptions.DEFAULT; + + CallOptions attachedOptions = context.attach(options); + assertThat(attachedOptions).isNotNull(); + + StorageMetricsContext extracted = StorageMetricsContext.from(attachedOptions); + assertThat(extracted).isSameInstanceAs(context); + + assertThat(StorageMetricsContext.from((CallOptions) null)).isNull(); + assertThat(StorageMetricsContext.from(CallOptions.DEFAULT)).isNull(); + } + + @Test + public void transportAttachment_grpcCallContext() { + StorageMetricsContext context = StorageMetricsContext.create("GetObject"); + GrpcCallContext callContext = GrpcCallContext.createDefault(); + + GrpcCallContext attachedContext = context.attach(callContext); + assertThat(attachedContext).isNotNull(); + + StorageMetricsContext extracted = StorageMetricsContext.from(attachedContext); + assertThat(extracted).isSameInstanceAs(context); + + assertThat(StorageMetricsContext.from((GrpcCallContext) null)).isNull(); + assertThat(StorageMetricsContext.from(GrpcCallContext.createDefault())).isNull(); + } + + @Test + public void transportAttachment_apiCallContext() { + StorageMetricsContext context = StorageMetricsContext.create("GetObject"); + ApiCallContext callContext = GrpcCallContext.createDefault(); + + ApiCallContext attachedContext = context.attach(callContext); + assertThat(attachedContext).isNotNull(); + + StorageMetricsContext extracted = StorageMetricsContext.from(attachedContext); + assertThat(extracted).isSameInstanceAs(context); + + assertThat(StorageMetricsContext.from((ApiCallContext) null)).isNull(); + } + + @Test + public void transportAttachment_apiCallContext_whenGrpcCallContext_attachesCallOptions() { + StorageMetricsContext context = StorageMetricsContext.create("GetObject"); + ApiCallContext callContext = GrpcCallContext.createDefault(); + + ApiCallContext attachedContext = context.attach(callContext); + assertThat(attachedContext).isNotNull(); + assertThat(attachedContext).isInstanceOf(GrpcCallContext.class); + + GrpcCallContext grpcCallContext = (GrpcCallContext) attachedContext; + assertThat(grpcCallContext.getOption(StorageMetricsContext.API_CALL_CONTEXT_KEY)) + .isSameInstanceAs(context); + assertThat( + grpcCallContext.getCallOptions().getOption(StorageMetricsContext.GRPC_CALL_OPTIONS_KEY)) + .isSameInstanceAs(context); + + StorageMetricsContext extracted = StorageMetricsContext.from(attachedContext); + assertThat(extracted).isSameInstanceAs(context); + + GrpcCallContext callOptionsOnlyContext = + GrpcCallContext.createDefault().withCallOptions(context.attach(CallOptions.DEFAULT)); + assertThat(StorageMetricsContext.from((ApiCallContext) callOptionsOnlyContext)) + .isSameInstanceAs(context); + } + + @Test + public void transportAttachment_propertiesMap() { + StorageMetricsContext context = StorageMetricsContext.create("GetObject"); + Map properties = new HashMap<>(); + + Map attachedMap = context.attach(properties); + assertThat(attachedMap).isSameInstanceAs(properties); + + StorageMetricsContext extracted = StorageMetricsContext.from(properties); + assertThat(extracted).isSameInstanceAs(context); + + assertThat(StorageMetricsContext.from((Map) null)).isNull(); + Map unattached = new HashMap<>(); + assertThat(StorageMetricsContext.from(unattached)).isNull(); + } + + @Test + public void multiStepContext_behavior() { + StorageMetricsContext context1 = StorageMetricsContext.createForMultiStep("WriteObject"); + assertThat(context1.shouldRecordOperationOnRetrierExit()).isFalse(); + assertThat(context1.getOperationName()).isEqualTo("WriteObject"); + assertThat(context1.getBaseAttributes()).isEqualTo(Attributes.empty()); + assertThat(context1.getOtelContext()).isSameInstanceAs(Context.current()); + + Attributes customAttrs = Attributes.of(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "grpc"); + StorageMetricsContext context2 = + StorageMetricsContext.createForMultiStep("ReadObject", customAttrs); + assertThat(context2.shouldRecordOperationOnRetrierExit()).isFalse(); + assertThat(context2.getOperationName()).isEqualTo("ReadObject"); + assertThat(context2.getBaseAttributes()).isEqualTo(customAttrs); + + Context customContext = Context.root(); + StorageMetricsContext context3 = + StorageMetricsContext.createForMultiStep("DeleteObject", customAttrs, customContext); + assertThat(context3.shouldRecordOperationOnRetrierExit()).isFalse(); + assertThat(context3.getOperationName()).isEqualTo("DeleteObject"); + assertThat(context3.getBaseAttributes()).isEqualTo(customAttrs); + assertThat(context3.getOtelContext()).isSameInstanceAs(customContext); + } + + @Test + public void recordOperation_isIdempotent() { + StorageMetricsContext context = StorageMetricsContext.createForMultiStep("WriteObject"); + context.recordOperation(metrics, null); + context.recordOperation(metrics, null); + + 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(LongPointData::getValue) + .sum(); + assertThat(opCount).isEqualTo(1L); + } + + @Test + public void recordAttemptSuccess_nullMetrics_noOp() { + StorageMetricsContext context = StorageMetricsContext.create("GetObject"); + context.recordAttemptSuccess(null); + } + + @Test + public void recordAttemptSuccess_whenAttemptsNull_noOp() { + SdkMeterProvider provider = SdkMeterProvider.builder().build(); + StorageClientMetrics metrics = StorageClientMetrics.create(provider, false, false); + StorageMetricsContext context = StorageMetricsContext.create("GetObject"); + context.recordAttemptSuccess(metrics); + } + + @Test + public void recordAttemptSuccess_defaultAttributes() { + StorageMetricsContext context = StorageMetricsContext.create("GetObject"); + context.recordAttemptSuccess(metrics); + + Collection collected = reader.collectAllMetrics(); + LongPointData point = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS); + assertThat(point.getValue()).isEqualTo(1L); + assertThat(point.getAttributes().get(StorageClientMetrics.KEY_ERROR_TYPE)) + .isEqualTo(StorageErrorTypeMapper.STATUS_OK); + assertThat(point.getAttributes().get(StorageClientMetrics.KEY_NETWORK_PATH)) + .isEqualTo(StorageClientMetrics.NETWORK_PATH_CLOUDPATH); + assertThat(point.getAttributes().get(StorageClientMetrics.KEY_GCP_STORAGE_OPERATION)) + .isEqualTo("GetObject"); + assertThat(point.getAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE)) + .isEqualTo(""); + assertThat(point.getAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) + .isEqualTo(""); + // Errors counter must not be recorded on success + assertThat(findMetric(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS)) + .isNull(); + } + + @Test + public void recordAttemptSuccess_grpcSystemName_setsRpcStatusCodeOk() { + Attributes base = + Attributes.of( + StorageClientMetrics.KEY_RPC_SYSTEM_NAME, + "grpc", + StorageClientMetrics.KEY_NETWORK_PATH, + StorageClientMetrics.NETWORK_PATH_DIRECTPATH); + StorageMetricsContext context = StorageMetricsContext.create("GetObject", base); + context.recordAttemptSuccess(metrics); + + Collection collected = reader.collectAllMetrics(); + LongPointData point = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS); + assertThat(point.getAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE)) + .isEqualTo("OK"); + assertThat(point.getAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) + .isEqualTo(""); + assertThat(point.getAttributes().get(StorageClientMetrics.KEY_NETWORK_PATH)) + .isEqualTo(StorageClientMetrics.NETWORK_PATH_DIRECTPATH); + } + + @Test + public void recordAttemptSuccess_httpSystemName_setsHttpStatusCode200() { + Attributes base = Attributes.of(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "http"); + StorageMetricsContext context = StorageMetricsContext.create("GetObject", base); + context.recordAttemptSuccess(metrics); + + Collection collected = reader.collectAllMetrics(); + LongPointData point = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS); + assertThat(point.getAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) + .isEqualTo("200"); + assertThat(point.getAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE)) + .isEqualTo(""); + } + + @Test + public void recordAttemptSuccess_overridesFromBaseAttributes() { + Attributes base = + Attributes.builder() + .put(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "grpc") + .put(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE, "ALREADY_EXISTS") + .put(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, "204") + .put( + StorageClientMetrics.KEY_NETWORK_PATH, StorageClientMetrics.NETWORK_PATH_DIRECTPATH) + .build(); + StorageMetricsContext context = StorageMetricsContext.create("GetObject", base); + context.recordAttemptSuccess(metrics); + + Collection collected = reader.collectAllMetrics(); + LongPointData point = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS); + assertThat(point.getAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE)) + .isEqualTo("ALREADY_EXISTS"); + assertThat(point.getAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) + .isEqualTo(""); + assertThat(point.getAttributes().get(StorageClientMetrics.KEY_NETWORK_PATH)) + .isEqualTo(StorageClientMetrics.NETWORK_PATH_DIRECTPATH); + } + + @Test + public void recordAttemptFailure_nullMetrics_noOp() { + StorageMetricsContext context = StorageMetricsContext.create("GetObject"); + context.recordAttemptFailure(null, new RuntimeException("boom")); + } + + @Test + public void recordAttemptFailure_whenInstrumentsNull_noOp() { + StorageClientMetrics disabledMetrics = StorageClientMetrics.create(provider, false, false); + StorageMetricsContext context = StorageMetricsContext.create("GetObject"); + context.recordAttemptFailure(disabledMetrics, new RuntimeException("err")); + } + + @Test + public void recordAttemptFailure_storageException() { + Attributes base = Attributes.of(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "http"); + StorageMetricsContext context = StorageMetricsContext.create("GetObject", base); + StorageException se = new StorageException(404, "Not Found"); + context.recordAttemptFailure(metrics, se); + + Collection collected = reader.collectAllMetrics(); + + // Verify attempts counter + LongPointData attemptPoint = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS); + assertThat(attemptPoint.getValue()).isEqualTo(1L); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_ERROR_TYPE)) + .isEqualTo("NOT_FOUND"); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) + .isEqualTo("404"); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE)) + .isEqualTo(""); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_NETWORK_PATH)) + .isEqualTo(StorageClientMetrics.NETWORK_PATH_CLOUDPATH); + + // Verify errors counter + LongPointData errorPoint = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS); + assertThat(errorPoint.getValue()).isEqualTo(1L); + assertThat(errorPoint.getAttributes().get(StorageClientMetrics.KEY_ERROR_TYPE)) + .isEqualTo("NOT_FOUND"); + assertThat(errorPoint.getAttributes().get(StorageClientMetrics.KEY_NETWORK_PATH)) + .isEqualTo(StorageClientMetrics.NETWORK_PATH_CLOUDPATH); + assertThat(errorPoint.getAttributes().get(StorageClientMetrics.KEY_GCP_STORAGE_OPERATION)) + .isEqualTo("GetObject"); + } + + @Test + public void recordAttemptFailure_statusRuntimeException() { + Attributes base = Attributes.of(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "grpc"); + StorageMetricsContext context = StorageMetricsContext.create("GetObject", base); + StatusRuntimeException sre = Status.NOT_FOUND.asRuntimeException(); + context.recordAttemptFailure(metrics, sre); + + Collection collected = reader.collectAllMetrics(); + LongPointData attemptPoint = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_ERROR_TYPE)) + .isEqualTo("NOT_FOUND"); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE)) + .isEqualTo("NOT_FOUND"); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) + .isEqualTo(""); + + LongPointData errorPoint = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS); + assertThat(errorPoint.getAttributes().get(StorageClientMetrics.KEY_ERROR_TYPE)) + .isEqualTo("NOT_FOUND"); + } + + @Test + public void recordAttemptFailure_statusException() { + Attributes base = Attributes.of(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "grpc"); + StorageMetricsContext context = StorageMetricsContext.create("GetObject", base); + StatusException se = Status.PERMISSION_DENIED.asException(); + context.recordAttemptFailure(metrics, se); + + Collection collected = reader.collectAllMetrics(); + LongPointData attemptPoint = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_ERROR_TYPE)) + .isEqualTo("PERMISSION_DENIED"); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE)) + .isEqualTo("PERMISSION_DENIED"); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) + .isEqualTo(""); + } + + @Test + public void recordAttemptFailure_apiException() { + Attributes base = Attributes.of(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "grpc"); + StorageMetricsContext context = StorageMetricsContext.create("GetObject", base); + ApiException apiException = + new ApiException( + new StatusRuntimeException(Status.ALREADY_EXISTS), + GrpcStatusCode.of(Status.Code.ALREADY_EXISTS), + false); + context.recordAttemptFailure(metrics, apiException); + + Collection collected = reader.collectAllMetrics(); + LongPointData attemptPoint = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_ERROR_TYPE)) + .isEqualTo("ALREADY_EXISTS"); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE)) + .isEqualTo("ALREADY_EXISTS"); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) + .isEqualTo(""); + } + + @Test + public void recordAttemptFailure_httpResponseException() { + Attributes base = Attributes.of(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "http"); + StorageMetricsContext context = StorageMetricsContext.create("GetObject", base); + HttpResponseException hre = + new HttpResponseException.Builder(429, "Too Many Requests", new HttpHeaders()).build(); + context.recordAttemptFailure(metrics, hre); + + Collection collected = reader.collectAllMetrics(); + LongPointData attemptPoint = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_ERROR_TYPE)) + .isEqualTo("RESOURCE_EXHAUSTED"); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) + .isEqualTo("429"); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE)) + .isEqualTo(""); + } + + @Test + public void recordAttemptFailure_nestedCause() { + Attributes grpcBase = Attributes.of(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "grpc"); + StorageMetricsContext grpcContext = StorageMetricsContext.create("GetObject", grpcBase); + Exception wrappedGrpc = + new RuntimeException(new IllegalStateException(Status.UNAVAILABLE.asRuntimeException())); + grpcContext.recordAttemptFailure(metrics, wrappedGrpc); + + Collection collected = reader.collectAllMetrics(); + LongPointData attemptPoint = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE)) + .isEqualTo("UNAVAILABLE"); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) + .isEqualTo(""); + + resetMetrics(); + Attributes httpBase = Attributes.of(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "http"); + StorageMetricsContext httpContext = StorageMetricsContext.create("GetObject", httpBase); + Exception wrappedHttp = new RuntimeException(new StorageException(503, "Service Unavailable")); + httpContext.recordAttemptFailure(metrics, wrappedHttp); + collected = reader.collectAllMetrics(); + attemptPoint = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) + .isEqualTo("503"); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE)) + .isEqualTo(""); + } + + @Test + public void recordAttemptFailure_selfCausingException_doesNotInfiniteLoop() { + Exception selfCausing = + new Exception("self") { + @Override + public synchronized Throwable getCause() { + return this; + } + }; + Attributes grpcBase = Attributes.of(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "grpc"); + StorageMetricsContext grpcContext = StorageMetricsContext.create("GetObject", grpcBase); + grpcContext.recordAttemptFailure(metrics, selfCausing); + + Attributes httpBase = Attributes.of(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "http"); + StorageMetricsContext httpContext = StorageMetricsContext.create("GetObject", httpBase); + httpContext.recordAttemptFailure(metrics, selfCausing); + } + + @Test + public void recordOperation_formatsRpcMethodAndAttributes() { + StorageMetricsContext context = StorageMetricsContext.create("GetObject"); + context.recordOperation(metrics, null); + + Collection collected = reader.collectAllMetrics(); + MetricData durationMetric = + findMetric(collected, StorageClientMetrics.METRIC_GCP_CLIENT_REQUEST_DURATION); + assertThat(durationMetric).isNotNull(); + assertThat(durationMetric.getHistogramData().getPoints()).hasSize(1); + HistogramPointData durationPoint = + durationMetric.getHistogramData().getPoints().iterator().next(); + assertThat(durationPoint.getAttributes().get(StorageClientMetrics.KEY_RPC_METHOD)) + .isEqualTo("google.storage.v2.Storage/GetObject"); + assertThat(durationPoint.getAttributes().get(StorageClientMetrics.KEY_GCP_STORAGE_OPERATION)) + .isEqualTo("GetObject"); + assertThat(durationPoint.getAttributes().get(StorageClientMetrics.KEY_ERROR_TYPE)) + .isEqualTo(StorageErrorTypeMapper.STATUS_OK); + } + + @Test + public void recordAttemptFailure_overridesFromBaseAttributes() { + Attributes base = + Attributes.builder() + .put(StorageClientMetrics.KEY_RPC_SYSTEM_NAME, "grpc") + .put(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE, "OVERRIDDEN_RPC") + .put(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE, "999") + .put( + StorageClientMetrics.KEY_NETWORK_PATH, StorageClientMetrics.NETWORK_PATH_DIRECTPATH) + .build(); + StorageMetricsContext context = StorageMetricsContext.create("GetObject", base); + context.recordAttemptFailure(metrics, new StorageException(404, "Not Found")); + + Collection collected = reader.collectAllMetrics(); + LongPointData attemptPoint = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ATTEMPTS); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_RPC_RESPONSE_STATUS_CODE)) + .isEqualTo("OVERRIDDEN_RPC"); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_HTTP_RESPONSE_STATUS_CODE)) + .isEqualTo(""); + assertThat(attemptPoint.getAttributes().get(StorageClientMetrics.KEY_NETWORK_PATH)) + .isEqualTo(StorageClientMetrics.NETWORK_PATH_DIRECTPATH); + + LongPointData errorPoint = + getSinglePoint(collected, StorageClientMetrics.METRIC_GCP_STORAGE_CLIENT_ERRORS); + assertThat(errorPoint.getAttributes().get(StorageClientMetrics.KEY_NETWORK_PATH)) + .isEqualTo(StorageClientMetrics.NETWORK_PATH_DIRECTPATH); + } + + private static @Nullable MetricData findMetric( + Collection metrics, String metricName) { + return metrics.stream().filter(m -> m.getName().equals(metricName)).findFirst().orElse(null); + } + + private static LongPointData getSinglePoint(Collection metrics, String metricName) { + MetricData metric = findMetric(metrics, metricName); + assertThat(metric).isNotNull(); + assertThat(metric.getLongSumData().getPoints()).hasSize(1); + return metric.getLongSumData().getPoints().iterator().next(); + } +}