From 5c291e8786b8e976979ec2e26b13f0327333bb02 Mon Sep 17 00:00:00 2001 From: Deepankar Dixit <90280028+ddixit14@users.noreply.github.com> Date: Tue, 30 Jan 2024 17:43:02 +0000 Subject: [PATCH] feat: MetricsTracer implementation (#2421) * feat: Opentelemetry implementation --- .../google/api/gax/tracing/MetricsTracer.java | 175 +++++++++++- .../gax/tracing/MetricsTracerFactoryTest.java | 81 ++++++ .../api/gax/tracing/MetricsTracerTest.java | 261 ++++++++++++++++++ 3 files changed, 515 insertions(+), 2 deletions(-) create mode 100644 gax-java/gax/src/test/java/com/google/api/gax/tracing/MetricsTracerFactoryTest.java create mode 100644 gax-java/gax/src/test/java/com/google/api/gax/tracing/MetricsTracerTest.java diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/MetricsTracer.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/MetricsTracer.java index bf5dbdd046..45a8558599 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/tracing/MetricsTracer.java +++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/MetricsTracer.java @@ -32,6 +32,16 @@ import com.google.api.core.BetaApi; import com.google.api.core.InternalApi; +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.StatusCode; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Stopwatch; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; +import org.threeten.bp.Duration; /** * This class computes generic metrics that can be observed in the lifecycle of an RPC operation. @@ -42,12 +52,173 @@ @InternalApi public class MetricsTracer implements ApiTracer { - public MetricsTracer(MethodName methodName, MetricsRecorder metricsRecorder) {} + private static final String STATUS_ATTRIBUTE = "status"; + + private Stopwatch attemptTimer; + + private final Stopwatch operationTimer = Stopwatch.createStarted(); + + private final Map attributes = new HashMap<>(); + + private MetricsRecorder metricsRecorder; + + public MetricsTracer(MethodName methodName, MetricsRecorder metricsRecorder) { + this.attributes.put("method_name", methodName.toString()); + this.metricsRecorder = metricsRecorder; + } + + /** + * Signals that the overall operation has finished successfully. The tracer is now considered + * closed and should no longer be used. Successful operation adds "OK" value to the status + * attribute key. + */ + @Override + public void operationSucceeded() { + attributes.put(STATUS_ATTRIBUTE, StatusCode.Code.OK.toString()); + metricsRecorder.recordOperationLatency( + operationTimer.elapsed(TimeUnit.MILLISECONDS), attributes); + metricsRecorder.recordOperationCount(1, attributes); + } + + /** + * Signals that the operation was cancelled by the user. The tracer is now considered closed and + * should no longer be used. Cancelled operation adds "CANCELLED" value to the status attribute + * key. + */ + @Override + public void operationCancelled() { + attributes.put(STATUS_ATTRIBUTE, StatusCode.Code.CANCELLED.toString()); + metricsRecorder.recordOperationLatency( + operationTimer.elapsed(TimeUnit.MILLISECONDS), attributes); + metricsRecorder.recordOperationCount(1, attributes); + } + + /** + * Signals that the operation was cancelled by the user. The tracer is now considered closed and + * should no longer be used. Failed operation extracts the error from the throwable and adds it to + * the status attribute key. + */ + @Override + public void operationFailed(Throwable error) { + attributes.put(STATUS_ATTRIBUTE, extractStatus(error)); + metricsRecorder.recordOperationLatency( + operationTimer.elapsed(TimeUnit.MILLISECONDS), attributes); + metricsRecorder.recordOperationCount(1, attributes); + } + + /** + * Adds an annotation that an attempt is about to start with additional information from the + * request. In general this should occur at the very start of the operation. The attemptNumber is + * zero based. So the initial attempt will be 0. When the attempt starts, the attemptTimer starts + * the stopwatch. + * + * @param attemptNumber the zero based sequential attempt number. + * @param request request of this attempt. + */ + @Override + public void attemptStarted(Object request, int attemptNumber) { + attemptTimer = Stopwatch.createStarted(); + } + + /** + * Adds an annotation that the attempt succeeded. Successful attempt add "OK" value to the status + * attribute key. + */ + @Override + public void attemptSucceeded() { + + attributes.put(STATUS_ATTRIBUTE, StatusCode.Code.OK.toString()); + metricsRecorder.recordAttemptLatency(attemptTimer.elapsed(TimeUnit.MILLISECONDS), attributes); + metricsRecorder.recordAttemptCount(1, attributes); + } + + /** + * Add an annotation that the attempt was cancelled by the user. Cancelled attempt add "CANCELLED" + * to the status attribute key. + */ + @Override + public void attemptCancelled() { + + attributes.put(STATUS_ATTRIBUTE, StatusCode.Code.CANCELLED.toString()); + metricsRecorder.recordAttemptLatency(attemptTimer.elapsed(TimeUnit.MILLISECONDS), attributes); + metricsRecorder.recordAttemptCount(1, attributes); + } + + /** + * Adds an annotation that the attempt failed, but another attempt will be made after the delay. + * + * @param error the error that caused the attempt to fail. + * @param delay the amount of time to wait before the next attempt will start. + *

Failed attempt extracts the error from the throwable and adds it to the status attribute + * key. + */ + @Override + public void attemptFailed(Throwable error, Duration delay) { + + attributes.put(STATUS_ATTRIBUTE, extractStatus(error)); + metricsRecorder.recordAttemptLatency(attemptTimer.elapsed(TimeUnit.MILLISECONDS), attributes); + metricsRecorder.recordAttemptCount(1, attributes); + } + + /** + * Adds an annotation that the attempt failed and that no further attempts will be made because + * retry limits have been reached. This extracts the error from the throwable and adds it to the + * status attribute key. + * + * @param error the last error received before retries were exhausted. + */ + @Override + public void attemptFailedRetriesExhausted(Throwable error) { + + attributes.put(STATUS_ATTRIBUTE, extractStatus(error)); + metricsRecorder.recordAttemptLatency(attemptTimer.elapsed(TimeUnit.MILLISECONDS), attributes); + metricsRecorder.recordAttemptCount(1, attributes); + } + + /** + * Adds an annotation that the attempt failed and that no further attempts will be made because + * the last error was not retryable. This extracts the error from the throwable and adds it to the + * status attribute key. + * + * @param error the error that caused the final attempt to fail. + */ + @Override + public void attemptPermanentFailure(Throwable error) { + + attributes.put(STATUS_ATTRIBUTE, extractStatus(error)); + metricsRecorder.recordAttemptLatency(attemptTimer.elapsed(TimeUnit.MILLISECONDS), attributes); + metricsRecorder.recordAttemptCount(1, attributes); + } + + /** Function to extract the status of the error as a string */ + @VisibleForTesting + static String extractStatus(@Nullable Throwable error) { + final String statusString; + + if (error == null) { + return StatusCode.Code.OK.toString(); + } else if (error instanceof CancellationException) { + statusString = StatusCode.Code.CANCELLED.toString(); + } else if (error instanceof ApiException) { + statusString = ((ApiException) error).getStatusCode().getCode().toString(); + } else { + statusString = StatusCode.Code.UNKNOWN.toString(); + } + + return statusString; + } /** * Add attributes that will be attached to all metrics. This is expected to be called by * handwritten client teams to add additional attributes that are not supposed be collected by * Gax. */ - public void addAttributes(String key, String value) {}; + public void addAttributes(String key, String value) { + attributes.put(key, value); + }; + + @VisibleForTesting + Map getAttributes() { + return attributes; + } } diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/MetricsTracerFactoryTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/MetricsTracerFactoryTest.java new file mode 100644 index 0000000000..2c6a014658 --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/MetricsTracerFactoryTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2024 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.tracing; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.api.gax.tracing.ApiTracerFactory.OperationType; +import com.google.common.truth.Truth; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +public class MetricsTracerFactoryTest { + @Mock private MetricsRecorder metricsRecorder; + @Mock private ApiTracer parent; + private SpanName spanName; + private MetricsTracerFactory metricsTracerFactory; + + @Before + public void setUp() { + // Create an instance of MetricsTracerFactory with the mocked MetricsRecorder + metricsTracerFactory = new MetricsTracerFactory(metricsRecorder); + + spanName = mock(SpanName.class); + when(spanName.getClientName()).thenReturn("testService"); + when(spanName.getMethodName()).thenReturn("testMethod"); + } + + @Test + public void testNewTracer_notNull() { + // Call the newTracer method + ApiTracer apiTracer = metricsTracerFactory.newTracer(parent, spanName, OperationType.Unary); + + // Assert that the apiTracer created has expected type and not null + Truth.assertThat(apiTracer).isInstanceOf(MetricsTracer.class); + Truth.assertThat(apiTracer).isNotNull(); + } + + @Test + public void testNewTracer_HasCorrectParameters() { + + // Call the newTracer method + ApiTracer apiTracer = metricsTracerFactory.newTracer(parent, spanName, OperationType.Unary); + + // Assert that the apiTracer created has expected type and not null + Truth.assertThat(apiTracer).isInstanceOf(MetricsTracer.class); + Truth.assertThat(apiTracer).isNotNull(); + + MetricsTracer metricsTracer = (MetricsTracer) apiTracer; + Truth.assertThat(metricsTracer.getAttributes().get("method_name")) + .isEqualTo("testService.testMethod"); + } +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/MetricsTracerTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/MetricsTracerTest.java new file mode 100644 index 0000000000..7b6b76f181 --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/MetricsTracerTest.java @@ -0,0 +1,261 @@ +/* + * Copyright 2024 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.tracing; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.DeadlineExceededException; +import com.google.api.gax.rpc.NotFoundException; +import com.google.api.gax.rpc.StatusCode.Code; +import com.google.api.gax.rpc.testing.FakeStatusCode; +import com.google.common.collect.ImmutableMap; +import com.google.common.truth.Truth; +import java.util.Map; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.mockito.quality.Strictness; +import org.threeten.bp.Duration; + +@RunWith(JUnit4.class) +public class MetricsTracerTest { + // stricter way of testing for early detection of unused stubs and argument mismatches + @Rule + public final MockitoRule mockitoRule = MockitoJUnit.rule().strictness(Strictness.STRICT_STUBS); + + private MetricsTracer metricsTracer; + @Mock private MetricsRecorder metricsRecorder; + + @Before + public void setUp() { + metricsTracer = + new MetricsTracer(MethodName.of("fake_service", "fake_method"), metricsRecorder); + } + + @Test + public void testOperationSucceeded_recordsAttributes() { + + metricsTracer.operationSucceeded(); + + Map attributes = + ImmutableMap.of( + "status", "OK", + "method_name", "fake_service.fake_method"); + + verify(metricsRecorder).recordOperationCount(1, attributes); + verify(metricsRecorder).recordOperationLatency(anyDouble(), eq(attributes)); + + verifyNoMoreInteractions(metricsRecorder); + } + + @Test + public void testOperationFailed_recordsAttributes() { + + ApiException error0 = + new NotFoundException( + "invalid argument", null, new FakeStatusCode(Code.INVALID_ARGUMENT), false); + metricsTracer.operationFailed(error0); + + Map attributes = + ImmutableMap.of( + "status", "INVALID_ARGUMENT", + "method_name", "fake_service.fake_method"); + + verify(metricsRecorder).recordOperationCount(1, attributes); + verify(metricsRecorder).recordOperationLatency(anyDouble(), eq(attributes)); + + verifyNoMoreInteractions(metricsRecorder); + } + + @Test + public void testOperationCancelled_recordsAttributes() { + + metricsTracer.operationCancelled(); + + Map attributes = + ImmutableMap.of( + "status", "CANCELLED", + "method_name", "fake_service.fake_method"); + + verify(metricsRecorder).recordOperationCount(1, attributes); + verify(metricsRecorder).recordOperationLatency(anyDouble(), eq(attributes)); + + verifyNoMoreInteractions(metricsRecorder); + } + + @Test + public void testAttemptSucceeded_recordsAttributes() { + // initialize mock-request + Object mockSuccessfulRequest = new Object(); + + // Attempt #1 + metricsTracer.attemptStarted(mockSuccessfulRequest, 0); + metricsTracer.attemptSucceeded(); + + Map attributes = + ImmutableMap.of( + "status", "OK", + "method_name", "fake_service.fake_method"); + + verify(metricsRecorder).recordAttemptCount(1, attributes); + verify(metricsRecorder).recordAttemptLatency(anyDouble(), eq(attributes)); + + verifyNoMoreInteractions(metricsRecorder); + } + + @Test + public void testAttemptFailed_recordsAttributes() { + // initialize mock-request + Object mockFailedRequest = new Object(); + + // Attempt #1 + metricsTracer.attemptStarted(mockFailedRequest, 0); + ApiException error0 = + new NotFoundException( + "invalid argument", null, new FakeStatusCode(Code.INVALID_ARGUMENT), false); + metricsTracer.attemptFailed(error0, Duration.ofMillis(2)); + + Map attributes = + ImmutableMap.of( + "status", "INVALID_ARGUMENT", + "method_name", "fake_service.fake_method"); + + verify(metricsRecorder).recordAttemptCount(1, attributes); + verify(metricsRecorder).recordAttemptLatency(anyDouble(), eq(attributes)); + + verifyNoMoreInteractions(metricsRecorder); + } + + @Test + public void testAttemptCancelled_recordsAttributes() { + // initialize mock-request + Object mockCancelledRequest = new Object(); + // Attempt #1 + metricsTracer.attemptStarted(mockCancelledRequest, 0); + metricsTracer.attemptCancelled(); + + Map attributes = + ImmutableMap.of( + "status", "CANCELLED", + "method_name", "fake_service.fake_method"); + + verify(metricsRecorder).recordAttemptCount(1, attributes); + verify(metricsRecorder).recordAttemptLatency(anyDouble(), eq(attributes)); + + verifyNoMoreInteractions(metricsRecorder); + } + + @Test + public void testAttemptFailedRetriesExhausted_recordsAttributes() { + // initialize mock-request + Object mockRequest = new Object(); + // Attempt #1 + metricsTracer.attemptStarted(mockRequest, 0); + ApiException error0 = + new DeadlineExceededException( + "deadline exceeded", null, new FakeStatusCode(Code.DEADLINE_EXCEEDED), false); + metricsTracer.attemptFailedRetriesExhausted(error0); + + Map attributes = + ImmutableMap.of( + "status", "DEADLINE_EXCEEDED", + "method_name", "fake_service.fake_method"); + + verify(metricsRecorder).recordAttemptCount(1, attributes); + verify(metricsRecorder).recordAttemptLatency(anyDouble(), eq(attributes)); + + verifyNoMoreInteractions(metricsRecorder); + } + + @Test + public void testAttemptPermanentFailure_recordsAttributes() { + + // initialize mock-request + Object mockRequest = new Object(); + // Attempt #1 + metricsTracer.attemptStarted(mockRequest, 0); + ApiException error0 = + new NotFoundException("not found", null, new FakeStatusCode(Code.NOT_FOUND), false); + metricsTracer.attemptFailedRetriesExhausted(error0); + + Map attributes = + ImmutableMap.of( + "status", "NOT_FOUND", + "method_name", "fake_service.fake_method"); + + verify(metricsRecorder).recordAttemptCount(1, attributes); + verify(metricsRecorder).recordAttemptLatency(anyDouble(), eq(attributes)); + + verifyNoMoreInteractions(metricsRecorder); + } + + @Test + public void testAddAttributes_recordsAttributes() { + + metricsTracer.addAttributes("FakeTableId", "12345"); + Truth.assertThat(metricsTracer.getAttributes().get("FakeTableId").equals("12345")); + } + + @Test + public void testExtractStatus_errorConversion_apiExceptions() { + + ApiException error = + new ApiException("fake_error", null, new FakeStatusCode(Code.INVALID_ARGUMENT), false); + String errorCode = metricsTracer.extractStatus(error); + assertThat(errorCode).isEqualTo("INVALID_ARGUMENT"); + } + + @Test + public void testExtractStatus_errorConversion_noError() { + + // test "OK", which corresponds to a "null" error. + String successCode = metricsTracer.extractStatus(null); + assertThat(successCode).isEqualTo("OK"); + } + + @Test + public void testExtractStatus_errorConversion_unknownException() { + + // test "UNKNOWN" + Throwable unknownException = new RuntimeException(); + String errorCode2 = metricsTracer.extractStatus(unknownException); + assertThat(errorCode2).isEqualTo("UNKNOWN"); + } +}