attributes = new HashMap<>();
+
+ /**
+ * Creates the following instruments for the following metrics:
+ *
+ *
+ * - Client Request Duration: Histogram
+ *
+ *
+ * @param metricsRecorder OpenTelemetry
+ */
+ GoldenSignalsMetricsTracer(GoldenSignalsMetricsRecorder metricsRecorder) {
+ this.clientRequestTimer = Stopwatch.createStarted();
+ this.metricsRecorder = metricsRecorder;
+ }
+
+ @VisibleForTesting
+ GoldenSignalsMetricsTracer(GoldenSignalsMetricsRecorder metricsRecorder, Ticker ticker) {
+ this.clientRequestTimer = Stopwatch.createStarted(ticker);
+ this.metricsRecorder = metricsRecorder;
+ }
+
+ /**
+ * The concept of "operation" and "client request" are the same. They both represent the total
+ * time taken for a logical client request, including any retries, backoff, and
+ * pre/post-processing
+ */
+ @Override
+ public void operationSucceeded() {
+ attributes.put(RPC_RESPONSE_STATUS_ATTRIBUTE, StatusCode.Code.OK.toString());
+ metricsRecorder.recordOperationLatency(
+ clientRequestTimer.elapsed(TimeUnit.NANOSECONDS) / 1_000_000_000.0, attributes);
+ }
+
+ @Override
+ public void operationCancelled() {
+ attributes.put(RPC_RESPONSE_STATUS_ATTRIBUTE, StatusCode.Code.CANCELLED.toString());
+ metricsRecorder.recordOperationLatency(
+ clientRequestTimer.elapsed(TimeUnit.NANOSECONDS) / 1_000_000_000.0, attributes);
+ }
+
+ @Override
+ public void operationFailed(Throwable error) {
+ attributes.put(RPC_RESPONSE_STATUS_ATTRIBUTE, ObservabilityUtils.extractStatus(error));
+ metricsRecorder.recordOperationLatency(
+ clientRequestTimer.elapsed(TimeUnit.NANOSECONDS) / 1_000_000_000.0, attributes);
+ }
+}
diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/GoldenSignalsMetricsTracerFactory.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/GoldenSignalsMetricsTracerFactory.java
new file mode 100644
index 0000000000..02aa9a1ff4
--- /dev/null
+++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/GoldenSignalsMetricsTracerFactory.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2026 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 com.google.api.core.BetaApi;
+import com.google.api.core.InternalApi;
+import io.opentelemetry.api.OpenTelemetry;
+
+/**
+ * A {@link ApiTracerFactory} to build instances of {@link GoldenSignalsMetricsTracer}.
+ *
+ * This class is expected to be initialized once during client initialization.
+ */
+@BetaApi
+@InternalApi
+public class GoldenSignalsMetricsTracerFactory implements ApiTracerFactory {
+
+ private ApiTracerContext apiTracerContext;
+ private final OpenTelemetry openTelemetry;
+ private GoldenSignalsMetricsRecorder metricsRecorder;
+
+ public GoldenSignalsMetricsTracerFactory(OpenTelemetry openTelemetry) {
+ this.openTelemetry = openTelemetry;
+ }
+
+ @Override
+ public ApiTracer newTracer(ApiTracer parent, SpanName spanName, OperationType operationType) {
+ if (metricsRecorder == null) {
+ // This should never happen, in case it happens, create a no-op api tracer to not block
+ // regular requests.
+ return new BaseApiTracer();
+ }
+ return new GoldenSignalsMetricsTracer(metricsRecorder);
+ }
+
+ @Override
+ public ApiTracerFactory withContext(ApiTracerContext context) {
+ this.apiTracerContext = context;
+ this.metricsRecorder =
+ new GoldenSignalsMetricsRecorder(
+ openTelemetry, apiTracerContext.libraryMetadata().artifactName());
+ return this;
+ }
+}
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 16553dd118..e9ad908c21 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
@@ -35,16 +35,13 @@
import com.google.api.core.BetaApi;
import com.google.api.core.InternalApi;
import com.google.api.core.ObsoleteApi;
-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 java.util.concurrent.atomic.AtomicBoolean;
-import javax.annotation.Nullable;
/**
* This class computes generic metrics that can be observed in the lifecycle of an RPC operation.
@@ -123,7 +120,7 @@ public void operationFailed(Throwable error) {
if (operationFinished.getAndSet(true)) {
throw new IllegalStateException(OPERATION_FINISHED_STATUS_MESSAGE);
}
- attributes.put(STATUS_ATTRIBUTE, extractStatus(error));
+ attributes.put(STATUS_ATTRIBUTE, ObservabilityUtils.extractStatus(error));
metricsRecorder.recordOperationLatency(
operationTimer.elapsed(TimeUnit.MILLISECONDS), attributes);
metricsRecorder.recordOperationCount(1, attributes);
@@ -175,7 +172,7 @@ public void attemptCancelled() {
*/
@Override
public void attemptFailedDuration(Throwable error, java.time.Duration delay) {
- attributes.put(STATUS_ATTRIBUTE, extractStatus(error));
+ attributes.put(STATUS_ATTRIBUTE, ObservabilityUtils.extractStatus(error));
metricsRecorder.recordAttemptLatency(attemptTimer.elapsed(TimeUnit.MILLISECONDS), attributes);
metricsRecorder.recordAttemptCount(1, attributes);
}
@@ -199,7 +196,7 @@ public void attemptFailed(Throwable error, org.threeten.bp.Duration delay) {
*/
@Override
public void attemptFailedRetriesExhausted(Throwable error) {
- attributes.put(STATUS_ATTRIBUTE, extractStatus(error));
+ attributes.put(STATUS_ATTRIBUTE, ObservabilityUtils.extractStatus(error));
metricsRecorder.recordAttemptLatency(attemptTimer.elapsed(TimeUnit.MILLISECONDS), attributes);
metricsRecorder.recordAttemptCount(1, attributes);
}
@@ -213,29 +210,11 @@ public void attemptFailedRetriesExhausted(Throwable error) {
*/
@Override
public void attemptPermanentFailure(Throwable error) {
- attributes.put(STATUS_ATTRIBUTE, extractStatus(error));
+ attributes.put(STATUS_ATTRIBUTE, ObservabilityUtils.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
diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java
index ad55e20204..8f331d8094 100644
--- a/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java
+++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityAttributes.java
@@ -58,4 +58,10 @@ public class ObservabilityAttributes {
/** The RPC system name, e.g. 'grpc' or 'http'. */
public static final String RPC_SYSTEM_NAME_ATTRIBUTE = "rpc.system.name";
+
+ /**
+ * The error codes of the request. The value will be the string representation of the canonical
+ * gRPC status code (e.g., "OK", "INTERNAL").
+ */
+ public static final String RPC_RESPONSE_STATUS_ATTRIBUTE = "rpc.response.status_code";
}
diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityUtils.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityUtils.java
new file mode 100644
index 0000000000..142a11ff11
--- /dev/null
+++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/ObservabilityUtils.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2026 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 com.google.api.gax.rpc.ApiException;
+import com.google.api.gax.rpc.StatusCode;
+import com.google.common.base.Preconditions;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.common.AttributesBuilder;
+import java.util.Map;
+import java.util.concurrent.CancellationException;
+import javax.annotation.Nullable;
+
+class ObservabilityUtils {
+
+ /** Function to extract the status of the error as a string */
+ 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;
+ }
+
+ static Attributes toOtelAttributes(Map attributes) {
+ Preconditions.checkNotNull(attributes, "Attributes map cannot be null");
+ AttributesBuilder attributesBuilder = Attributes.builder();
+ attributes.forEach(attributesBuilder::put);
+ return attributesBuilder.build();
+ }
+}
diff --git a/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryMetricsRecorder.java b/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryMetricsRecorder.java
index a029f42fde..3ccd6a6e7d 100644
--- a/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryMetricsRecorder.java
+++ b/gax-java/gax/src/main/java/com/google/api/gax/tracing/OpenTelemetryMetricsRecorder.java
@@ -33,11 +33,7 @@
import com.google.api.core.BetaApi;
import com.google.api.core.InternalApi;
import com.google.api.gax.core.GaxProperties;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Preconditions;
import io.opentelemetry.api.OpenTelemetry;
-import io.opentelemetry.api.common.Attributes;
-import io.opentelemetry.api.common.AttributesBuilder;
import io.opentelemetry.api.metrics.DoubleHistogram;
import io.opentelemetry.api.metrics.LongCounter;
import io.opentelemetry.api.metrics.Meter;
@@ -116,7 +112,7 @@ public OpenTelemetryMetricsRecorder(OpenTelemetry openTelemetry, String serviceN
*/
@Override
public void recordAttemptLatency(double attemptLatency, Map attributes) {
- attemptLatencyRecorder.record(attemptLatency, toOtelAttributes(attributes));
+ attemptLatencyRecorder.record(attemptLatency, ObservabilityUtils.toOtelAttributes(attributes));
}
/**
@@ -129,7 +125,7 @@ public void recordAttemptLatency(double attemptLatency, Map attr
*/
@Override
public void recordAttemptCount(long count, Map attributes) {
- attemptCountRecorder.add(count, toOtelAttributes(attributes));
+ attemptCountRecorder.add(count, ObservabilityUtils.toOtelAttributes(attributes));
}
/**
@@ -141,7 +137,8 @@ public void recordAttemptCount(long count, Map attributes) {
*/
@Override
public void recordOperationLatency(double operationLatency, Map attributes) {
- operationLatencyRecorder.record(operationLatency, toOtelAttributes(attributes));
+ operationLatencyRecorder.record(
+ operationLatency, ObservabilityUtils.toOtelAttributes(attributes));
}
/**
@@ -154,14 +151,6 @@ public void recordOperationLatency(double operationLatency, Map
*/
@Override
public void recordOperationCount(long count, Map attributes) {
- operationCountRecorder.add(count, toOtelAttributes(attributes));
- }
-
- @VisibleForTesting
- Attributes toOtelAttributes(Map attributes) {
- Preconditions.checkNotNull(attributes, "Attributes map cannot be null");
- AttributesBuilder attributesBuilder = Attributes.builder();
- attributes.forEach(attributesBuilder::put);
- return attributesBuilder.build();
+ operationCountRecorder.add(count, ObservabilityUtils.toOtelAttributes(attributes));
}
}
diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/GoldenSignalsMetricsRecorderTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/GoldenSignalsMetricsRecorderTest.java
new file mode 100644
index 0000000000..3476e64a72
--- /dev/null
+++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/GoldenSignalsMetricsRecorderTest.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2026 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.api.gax.tracing.GoldenSignalsMetricsRecorder.BOUNDARIES;
+import static com.google.api.gax.tracing.GoldenSignalsMetricsRecorder.CLIENT_REQUEST_DURATION_METRIC_DESCRIPTION;
+import static com.google.api.gax.tracing.GoldenSignalsMetricsRecorder.CLIENT_REQUEST_DURATION_METRIC_NAME;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.metrics.SdkMeterProvider;
+import io.opentelemetry.sdk.metrics.data.MetricData;
+import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader;
+import java.util.Collection;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class GoldenSignalsMetricsRecorderTest {
+ private static final String ARTIFACT_NAME = "test-library";
+ private static final String ATTRIBUTE_1 = "attribute_1";
+ private static final String VALUE_1 = "value_1";
+
+ private InMemoryMetricReader metricReader;
+
+ private GoldenSignalsMetricsRecorder recorder;
+
+ @BeforeEach
+ void setUp() {
+ metricReader = InMemoryMetricReader.create();
+ SdkMeterProvider meterProvider =
+ SdkMeterProvider.builder().registerMetricReader(metricReader).build();
+ OpenTelemetry openTelemetry =
+ OpenTelemetrySdk.builder().setMeterProvider(meterProvider).build();
+ recorder = new GoldenSignalsMetricsRecorder(openTelemetry, ARTIFACT_NAME);
+ }
+
+ @Test
+ void recordOperationLatency_shouldRecordMeterInfo() {
+ recorder.recordOperationLatency(0.012, ImmutableMap.of(ATTRIBUTE_1, VALUE_1));
+
+ Collection metrics = metricReader.collectAllMetrics();
+ assertThat(metrics).hasSize(1);
+ MetricData metricData = metrics.iterator().next();
+
+ assertThat(metricData.getName()).isEqualTo(CLIENT_REQUEST_DURATION_METRIC_NAME);
+ assertThat(metricData.getDescription()).isEqualTo(CLIENT_REQUEST_DURATION_METRIC_DESCRIPTION);
+ assertThat(metricData.getUnit()).isEqualTo("s");
+ assertThat(metricData.getInstrumentationScopeInfo().getName()).isEqualTo(ARTIFACT_NAME);
+ }
+
+ @Test
+ void recordOperationLatency_shouldRecordWithBoundaries() {
+ recorder.recordOperationLatency(0.012, ImmutableMap.of(ATTRIBUTE_1, VALUE_1));
+
+ Collection metrics = metricReader.collectAllMetrics();
+ assertThat(metrics).hasSize(1);
+ MetricData metricData = metrics.iterator().next();
+
+ assertThat(metricData.getHistogramData().getPoints().iterator().next().getBoundaries())
+ .isEqualTo(BOUNDARIES);
+ }
+
+ @Test
+ void recordOperationLatency_shouldRecordMetrics() {
+ recorder.recordOperationLatency(0.012, ImmutableMap.of(ATTRIBUTE_1, VALUE_1));
+
+ Collection metrics = metricReader.collectAllMetrics();
+ assertThat(metrics).hasSize(1);
+ MetricData metricData = metrics.iterator().next();
+
+ assertThat(metricData.getHistogramData().getPoints()).hasSize(1);
+ assertThat(metricData.getHistogramData().getPoints().iterator().next().getMax()).isNonZero();
+ }
+
+ @Test
+ void recordOperationLatency_shouldRecordMetricAttributes() {
+ recorder.recordOperationLatency(0.012, ImmutableMap.of(ATTRIBUTE_1, VALUE_1));
+
+ Collection metrics = metricReader.collectAllMetrics();
+ assertThat(metrics).hasSize(1);
+ MetricData metricData = metrics.iterator().next();
+
+ assertThat(metricData.getHistogramData().getPoints().iterator().next().getAttributes())
+ .isEqualTo(Attributes.of(AttributeKey.stringKey(ATTRIBUTE_1), VALUE_1));
+ }
+}
diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/GoldenSignalsMetricsTracerFactoryTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/GoldenSignalsMetricsTracerFactoryTest.java
new file mode 100644
index 0000000000..8a39ff27f1
--- /dev/null
+++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/GoldenSignalsMetricsTracerFactoryTest.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2026 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.Mockito.mock;
+
+import io.opentelemetry.api.OpenTelemetry;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class GoldenSignalsMetricsTracerFactoryTest {
+
+ private GoldenSignalsMetricsTracerFactory tracerFactory;
+
+ @BeforeEach
+ void setUp() {
+ tracerFactory = new GoldenSignalsMetricsTracerFactory(OpenTelemetry.noop());
+ }
+
+ @Test
+ void newTracer_createsTracer_successfully() {
+ tracerFactory.withContext(ApiTracerContext.empty());
+ ApiTracer actual =
+ tracerFactory.newTracer(
+ mock(ApiTracer.class), mock(SpanName.class), ApiTracerFactory.OperationType.Unary);
+ assertThat(actual).isInstanceOf(GoldenSignalsMetricsTracer.class);
+ }
+
+ @Test
+ void newTracer_createsBaseTracer_ifMetricsRecorderIsNull() {
+ ApiTracer actual =
+ tracerFactory.newTracer(
+ mock(ApiTracer.class), mock(SpanName.class), ApiTracerFactory.OperationType.Unary);
+ assertThat(actual).isInstanceOf(BaseApiTracer.class);
+ }
+}
diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/GoldenSignalsMetricsTracerTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/GoldenSignalsMetricsTracerTest.java
new file mode 100644
index 0000000000..cfd9588abb
--- /dev/null
+++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/GoldenSignalsMetricsTracerTest.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2026 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.api.gax.tracing.ObservabilityAttributes.RPC_RESPONSE_STATUS_ATTRIBUTE;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.api.gax.rpc.ApiException;
+import com.google.api.gax.rpc.StatusCode;
+import com.google.api.gax.rpc.testing.FakeStatusCode;
+import com.google.common.testing.FakeTicker;
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.metrics.SdkMeterProvider;
+import io.opentelemetry.sdk.metrics.data.MetricData;
+import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader;
+import java.util.Collection;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class GoldenSignalsMetricsTracerTest {
+ private static final String ARTIFACT_NAME = "test-library";
+ public static final int TEST_REQUEST_DURATION_NANO = 2345698;
+ public static final double EXPECTED_REQUEST_DURATION_SECOND = 2345698 / 1_000_000_000.0;
+
+ private InMemoryMetricReader metricReader;
+
+ private GoldenSignalsMetricsTracer tracer;
+
+ private FakeTicker ticker;
+
+ @BeforeEach
+ void setUp() {
+ metricReader = InMemoryMetricReader.create();
+ SdkMeterProvider meterProvider =
+ SdkMeterProvider.builder().registerMetricReader(metricReader).build();
+ OpenTelemetry openTelemetry =
+ OpenTelemetrySdk.builder().setMeterProvider(meterProvider).build();
+ ticker = new FakeTicker();
+ tracer =
+ new GoldenSignalsMetricsTracer(
+ new GoldenSignalsMetricsRecorder(openTelemetry, ARTIFACT_NAME), ticker);
+ }
+
+ @Test
+ void operationSucceeded_shouldRecordsDuration() {
+ ticker.advance(TEST_REQUEST_DURATION_NANO);
+ tracer.operationSucceeded();
+
+ Collection metrics = metricReader.collectAllMetrics();
+ assertThat(metrics).hasSize(1);
+ MetricData metricData = metrics.iterator().next();
+
+ assertThat(metricData.getHistogramData().getPoints()).hasSize(1);
+ assertThat(metricData.getHistogramData().getPoints().iterator().next().getMax())
+ .isEqualTo(EXPECTED_REQUEST_DURATION_SECOND);
+ }
+
+ @Test
+ void operationSucceeded_shouldRecordsOKStatus() {
+ tracer.operationSucceeded();
+
+ Collection metrics = metricReader.collectAllMetrics();
+ assertThat(metrics).hasSize(1);
+ MetricData metricData = metrics.iterator().next();
+
+ assertThat(metricData.getHistogramData().getPoints()).hasSize(1);
+ assertThat(metricData.getHistogramData().getPoints().iterator().next().getAttributes())
+ .isEqualTo(
+ Attributes.of(
+ AttributeKey.stringKey(RPC_RESPONSE_STATUS_ATTRIBUTE),
+ StatusCode.Code.OK.toString()));
+ }
+
+ @Test
+ void operationCancelled_shouldRecordsDuration() {
+ ticker.advance(TEST_REQUEST_DURATION_NANO);
+ tracer.operationCancelled();
+
+ Collection metrics = metricReader.collectAllMetrics();
+ assertThat(metrics).hasSize(1);
+ MetricData metricData = metrics.iterator().next();
+
+ assertThat(metricData.getHistogramData().getPoints()).hasSize(1);
+ assertThat(metricData.getHistogramData().getPoints().iterator().next().getMax())
+ .isEqualTo(EXPECTED_REQUEST_DURATION_SECOND);
+ }
+
+ @Test
+ void operationCancelled_shouldRecordsOKStatus() {
+ tracer.operationCancelled();
+
+ Collection metrics = metricReader.collectAllMetrics();
+ assertThat(metrics).hasSize(1);
+ MetricData metricData = metrics.iterator().next();
+
+ assertThat(metricData.getHistogramData().getPoints()).hasSize(1);
+ assertThat(metricData.getHistogramData().getPoints().iterator().next().getAttributes())
+ .isEqualTo(
+ Attributes.of(
+ AttributeKey.stringKey(RPC_RESPONSE_STATUS_ATTRIBUTE),
+ StatusCode.Code.CANCELLED.toString()));
+ }
+
+ @Test
+ void operationFailed_shouldRecordsDuration() {
+ ticker.advance(TEST_REQUEST_DURATION_NANO);
+ ApiException error =
+ new ApiException("test error", null, new FakeStatusCode(StatusCode.Code.INTERNAL), false);
+ tracer.operationFailed(error);
+
+ Collection metrics = metricReader.collectAllMetrics();
+ assertThat(metrics).hasSize(1);
+ MetricData metricData = metrics.iterator().next();
+
+ assertThat(metricData.getHistogramData().getPoints()).hasSize(1);
+ assertThat(metricData.getHistogramData().getPoints().iterator().next().getMax())
+ .isEqualTo(EXPECTED_REQUEST_DURATION_SECOND);
+ }
+
+ @Test
+ void operationFailed_shouldRecordsOKStatus() {
+ ApiException error =
+ new ApiException("test error", null, new FakeStatusCode(StatusCode.Code.INTERNAL), false);
+ tracer.operationFailed(error);
+
+ Collection metrics = metricReader.collectAllMetrics();
+ assertThat(metrics).hasSize(1);
+ MetricData metricData = metrics.iterator().next();
+
+ assertThat(metricData.getHistogramData().getPoints()).hasSize(1);
+ assertThat(metricData.getHistogramData().getPoints().iterator().next().getAttributes())
+ .isEqualTo(
+ Attributes.of(
+ AttributeKey.stringKey(RPC_RESPONSE_STATUS_ATTRIBUTE),
+ StatusCode.Code.INTERNAL.toString()));
+ }
+}
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
index 1864eba7e1..b16820c624 100644
--- 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
@@ -238,27 +238,4 @@ void testAddAttributes_recordsAttributesWithMap() {
assertThat(metricsTracer.getAttributes().get("FakeTableId")).isEqualTo("12345");
assertThat(metricsTracer.getAttributes().get("FakeInstanceId")).isEqualTo("67890");
}
-
- @Test
- 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(Code.INVALID_ARGUMENT.toString());
- }
-
- @Test
- void testExtractStatus_errorConversion_noError() {
- // test "OK", which corresponds to a "null" error.
- String successCode = metricsTracer.extractStatus(null);
- assertThat(successCode).isEqualTo(Code.OK.toString());
- }
-
- @Test
- void testExtractStatus_errorConversion_unknownException() {
- // test "UNKNOWN"
- Throwable unknownException = new RuntimeException();
- String errorCode2 = metricsTracer.extractStatus(unknownException);
- assertThat(errorCode2).isEqualTo(Code.UNKNOWN.toString());
- }
}
diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/ObservabilityUtilsTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/ObservabilityUtilsTest.java
new file mode 100644
index 0000000000..9d9a07686c
--- /dev/null
+++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/ObservabilityUtilsTest.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2026 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.junit.jupiter.api.Assertions.assertThrows;
+
+import com.google.api.gax.rpc.ApiException;
+import com.google.api.gax.rpc.StatusCode;
+import com.google.api.gax.rpc.testing.FakeStatusCode;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.truth.Truth;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class ObservabilityUtilsTest {
+ @Test
+ void testExtractStatus_errorConversion_apiExceptions() {
+ ApiException error =
+ new ApiException(
+ "fake_error", null, new FakeStatusCode(StatusCode.Code.INVALID_ARGUMENT), false);
+ String errorCode = ObservabilityUtils.extractStatus(error);
+ assertThat(errorCode).isEqualTo(StatusCode.Code.INVALID_ARGUMENT.toString());
+ }
+
+ @Test
+ void testExtractStatus_errorConversion_noError() {
+ // test "OK", which corresponds to a "null" error.
+ String successCode = ObservabilityUtils.extractStatus(null);
+ assertThat(successCode).isEqualTo(StatusCode.Code.OK.toString());
+ }
+
+ @Test
+ void testExtractStatus_errorConversion_unknownException() {
+ // test "UNKNOWN"
+ Throwable unknownException = new RuntimeException();
+ String errorCode2 = ObservabilityUtils.extractStatus(unknownException);
+ assertThat(errorCode2).isEqualTo(StatusCode.Code.UNKNOWN.toString());
+ }
+
+ @Test
+ void testToOtelAttributes_correctConversion() {
+ String attribute1 = "attribute_1";
+ String attribute2 = "attribute_2";
+ String attribute1Value = "Today is a good day";
+ String attribute2Value = "Does not matter";
+ Map attributes =
+ ImmutableMap.of(attribute1, attribute1Value, attribute2, attribute2Value);
+
+ Attributes otelAttributes = ObservabilityUtils.toOtelAttributes(attributes);
+
+ Truth.assertThat(otelAttributes.get(AttributeKey.stringKey(attribute1)))
+ .isEqualTo(attribute1Value);
+ Truth.assertThat(otelAttributes.get(AttributeKey.stringKey(attribute2)))
+ .isEqualTo(attribute2Value);
+ }
+
+ @Test
+ void testToOtelAttributes_nullInput() {
+ Throwable thrown =
+ assertThrows(NullPointerException.class, () -> ObservabilityUtils.toOtelAttributes(null));
+ Truth.assertThat(thrown).hasMessageThat().contains("Attributes map cannot be null");
+ }
+}
diff --git a/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryMetricsRecorderTest.java b/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryMetricsRecorderTest.java
index 3eea5955a4..7a49105a6c 100644
--- a/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryMetricsRecorderTest.java
+++ b/gax-java/gax/src/test/java/com/google/api/gax/tracing/OpenTelemetryMetricsRecorderTest.java
@@ -29,15 +29,12 @@
*/
package com.google.api.gax.tracing;
-import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import com.google.common.collect.ImmutableMap;
-import com.google.common.truth.Truth;
import com.google.rpc.Code;
import io.opentelemetry.api.OpenTelemetry;
-import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.DoubleHistogram;
import io.opentelemetry.api.metrics.DoubleHistogramBuilder;
@@ -104,7 +101,7 @@ private Map getAttributes(Code statusCode) {
void testAttemptCountRecorder_recordsAttributes() {
Map attributes = getAttributes(Code.OK);
- Attributes otelAttributes = otelMetricsRecorder.toOtelAttributes(attributes);
+ Attributes otelAttributes = ObservabilityUtils.toOtelAttributes(attributes);
otelMetricsRecorder.recordAttemptCount(1, attributes);
verify(attemptCountRecorder).add(1, otelAttributes);
@@ -115,7 +112,7 @@ void testAttemptCountRecorder_recordsAttributes() {
void testAttemptLatencyRecorder_recordsAttributes() {
Map attributes = getAttributes(Code.NOT_FOUND);
- Attributes otelAttributes = otelMetricsRecorder.toOtelAttributes(attributes);
+ Attributes otelAttributes = ObservabilityUtils.toOtelAttributes(attributes);
otelMetricsRecorder.recordAttemptLatency(1.1, attributes);
verify(attemptLatencyRecorder).record(1.1, otelAttributes);
@@ -126,7 +123,7 @@ void testAttemptLatencyRecorder_recordsAttributes() {
void testOperationCountRecorder_recordsAttributes() {
Map attributes = getAttributes(Code.OK);
- Attributes otelAttributes = otelMetricsRecorder.toOtelAttributes(attributes);
+ Attributes otelAttributes = ObservabilityUtils.toOtelAttributes(attributes);
otelMetricsRecorder.recordOperationCount(1, attributes);
verify(operationCountRecorder).add(1, otelAttributes);
@@ -137,34 +134,13 @@ void testOperationCountRecorder_recordsAttributes() {
void testOperationLatencyRecorder_recordsAttributes() {
Map attributes = getAttributes(Code.INVALID_ARGUMENT);
- Attributes otelAttributes = otelMetricsRecorder.toOtelAttributes(attributes);
+ Attributes otelAttributes = ObservabilityUtils.toOtelAttributes(attributes);
otelMetricsRecorder.recordOperationLatency(1.7, attributes);
verify(operationLatencyRecorder).record(1.7, otelAttributes);
verifyNoMoreInteractions(operationLatencyRecorder);
}
- @Test
- void testToOtelAttributes_correctConversion() {
- Map attributes = getAttributes(Code.OK);
-
- Attributes otelAttributes = otelMetricsRecorder.toOtelAttributes(attributes);
-
- Truth.assertThat(otelAttributes.get(AttributeKey.stringKey("status")))
- .isEqualTo(Code.OK.toString());
- Truth.assertThat(otelAttributes.get(AttributeKey.stringKey("method_name")))
- .isEqualTo(DEFAULT_METHOD_NAME);
- Truth.assertThat(otelAttributes.get(AttributeKey.stringKey("language")))
- .isEqualTo(MetricsTracer.DEFAULT_LANGUAGE);
- }
-
- @Test
- void testToOtelAttributes_nullInput() {
- Throwable thrown =
- assertThrows(NullPointerException.class, () -> otelMetricsRecorder.toOtelAttributes(null));
- Truth.assertThat(thrown).hasMessageThat().contains("Attributes map cannot be null");
- }
-
private void setupAttemptCountRecorder() {
// Configure chained mocking for AttemptCountRecorder
Mockito.when(meter.counterBuilder(ATTEMPT_COUNT)).thenReturn(attemptCountRecorderBuilder);