diff --git a/api/src/main/java/io/grpc/ClientStreamTracer.java b/api/src/main/java/io/grpc/ClientStreamTracer.java index d654fd33358..9358bb67935 100644 --- a/api/src/main/java/io/grpc/ClientStreamTracer.java +++ b/api/src/main/java/io/grpc/ClientStreamTracer.java @@ -62,12 +62,12 @@ public void createPendingStream() { * connection establishment) starts. * *

This method is invoked synchronously on the attempt thread. Implementations should start - * internal timers or child tracing spans (named strictly {@code "Attempt Delay"}) carrying the + * internal timers or child tracing spans (named strictly {@code "Delay"}) carrying the * canonical {@code grpc.delay_type} attribute. * * @param delayType canonical low-cardinality label categorizing the delay (e.g., "connecting") * @param delayReason high-cardinality diagnostic string describing granular runtime conditions - * @since 1.82.0 + * @since 1.84.0 */ public void recordAttemptDelayStart(String delayType, String delayReason) { } @@ -80,7 +80,7 @@ public void recordAttemptDelayStart(String delayType, String delayReason) { * on the active delay span without recreating the span or resetting cumulative timers. * * @param delayReason updated high-cardinality diagnostic string describing new conditions - * @since 1.82.0 + * @since 1.84.0 */ public void recordAttemptDelayReasonChanged(String delayReason) { } @@ -91,7 +91,7 @@ public void recordAttemptDelayReasonChanged(String delayReason) { *

Implementations should simultaneously close active child tracing spans and record elapsed * duration to the {@code grpc.client.attempt.delay.duration} histogram. * - * @since 1.82.0 + * @since 1.84.0 */ public void recordAttemptDelayEnd() { } @@ -156,6 +156,44 @@ public abstract static class Factory { public ClientStreamTracer newClientStreamTracer(StreamInfo info, Metadata headers) { throw new UnsupportedOperationException("Not implemented"); } + + /** + * Called when a call-level delay segment (such as waiting for name resolution or service + * configuration parsing) starts before any individual RPC attempt is created. + * + *

Implementations should start logical timers and create child tracing spans (named strictly + * {@code "Delay"}) carrying the canonical {@code grpc.delay_type} attribute. + * + * @param delayType canonical low-cardinality label categorizing the delay (e.g., "resolving") + * @param delayReason high-cardinality diagnostic string describing granular runtime conditions + * @since 1.85.0 + */ + public void recordCallDelayStart(String delayType, String delayReason) { + } + + /** + * Called when a call-level delay reason changes while the active delay segment continues. + * + *

Implementations should emit structured events (such as {@code "Delay state transition"}) + * on the active call delay span without recreating the span or resetting timers. + * + * @param delayReason updated high-cardinality diagnostic string describing new conditions + * @since 1.85.0 + */ + public void recordCallDelayReasonChanged(String delayReason) { + } + + /** + * Called when a call-level delay segment ends upon successful name resolution or when an RPC + * is cancelled before resolution completes. + * + *

Implementations should close active call delay spans and record elapsed duration to the + * {@code grpc.client.call.delay.duration} histogram. + * + * @since 1.85.0 + */ + public void recordCallDelayEnd() { + } } /** diff --git a/api/src/main/java/io/grpc/LoadBalancer.java b/api/src/main/java/io/grpc/LoadBalancer.java index e5c3d053ee7..e121daa0664 100644 --- a/api/src/main/java/io/grpc/LoadBalancer.java +++ b/api/src/main/java/io/grpc/LoadBalancer.java @@ -739,21 +739,29 @@ public static PickResult withNoResult() { * * @param delayType low-cardinality root cause label (e.g., "connecting") * @param delayReason high-cardinality diagnostic string for trace events - * @since 1.82.0 + * @since 1.84.0 */ public static PickResult withNoResult(String delayType, String delayReason) { - Preconditions.checkNotNull(delayType, "delayType"); - Preconditions.checkNotNull(delayReason, "delayReason"); + checkNotNull(delayType, "delayType"); + checkNotNull(delayReason, "delayReason"); return new PickResult(null, null, Status.OK, false, null, delayType, delayReason); } - /** Returns the delay type label if any. */ + /** + * Returns the delay type label if any. + * + * @since 1.84.0 + */ @Nullable public String getDelayType() { return delayType; } - /** Returns the diagnostic delay reason if any. */ + /** + * Returns the diagnostic delay reason if any. + * + * @since 1.84.0 + */ @Nullable public String getDelayReason() { return delayReason; @@ -821,6 +829,8 @@ public String toString() { .add("status", status) .add("drop", drop) .add("authority-override", authorityOverride) + .add("delayType", delayType) + .add("delayReason", delayReason) .toString(); } diff --git a/api/src/test/java/io/grpc/ClientStreamTracerTest.java b/api/src/test/java/io/grpc/ClientStreamTracerTest.java index 5ddee77f5c0..1d605a4f707 100644 --- a/api/src/test/java/io/grpc/ClientStreamTracerTest.java +++ b/api/src/test/java/io/grpc/ClientStreamTracerTest.java @@ -57,4 +57,17 @@ public void streamInfo_toBuilder() { StreamInfo info2 = info1.toBuilder().build(); assertThat(info2.getCallOptions()).isSameInstanceAs(callOptions); } + + @Test + public void defaultDelayMethodsNoOp() { + ClientStreamTracer tracer = new ClientStreamTracer() {}; + tracer.recordAttemptDelayStart("connecting", "test"); + tracer.recordAttemptDelayReasonChanged("test2"); + tracer.recordAttemptDelayEnd(); + + ClientStreamTracer.Factory factory = new ClientStreamTracer.Factory() {}; + factory.recordCallDelayStart("resolving", "test"); + factory.recordCallDelayReasonChanged("test2"); + factory.recordCallDelayEnd(); + } } diff --git a/api/src/test/java/io/grpc/LoadBalancerTest.java b/api/src/test/java/io/grpc/LoadBalancerTest.java index 22fdc220081..ea71a593f17 100644 --- a/api/src/test/java/io/grpc/LoadBalancerTest.java +++ b/api/src/test/java/io/grpc/LoadBalancerTest.java @@ -91,6 +91,21 @@ public void pickResult_withNoResult() { assertThat(result.getStatus()).isSameInstanceAs(Status.OK); assertThat(result.getStreamTracerFactory()).isNull(); assertThat(result.isDrop()).isFalse(); + assertThat(result.getDelayType()).isNull(); + assertThat(result.getDelayReason()).isNull(); + } + + @Test + public void pickResult_withNoResult_withDelay() { + PickResult result = PickResult.withNoResult("connecting", "trying backends"); + assertThat(result.getSubchannel()).isNull(); + assertThat(result.getStatus()).isSameInstanceAs(Status.OK); + assertThat(result.getStreamTracerFactory()).isNull(); + assertThat(result.isDrop()).isFalse(); + assertThat(result.getDelayType()).isEqualTo("connecting"); + assertThat(result.getDelayReason()).isEqualTo("trying backends"); + assertThat(result.toString()).contains("delayType=connecting"); + assertThat(result.toString()).contains("delayReason=trying backends"); } @Test @@ -118,6 +133,10 @@ public void pickResult_equals() { PickResult sc3 = PickResult.withSubchannel(subchannel, tracerFactory); PickResult sc4 = PickResult.withSubchannel(subchannel2); PickResult nr = PickResult.withNoResult(); + PickResult nrDelay1 = PickResult.withNoResult("connecting", "trying 10.0.0.1"); + PickResult nrDelay2 = PickResult.withNoResult("connecting", "trying 10.0.0.1"); + PickResult nrDelayDiffReason = PickResult.withNoResult("connecting", "trying 10.0.0.2"); + PickResult nrDelayDiffType = PickResult.withNoResult("rls_lookup_pending", "trying 10.0.0.1"); PickResult error1 = PickResult.withError(status); PickResult error2 = PickResult.withError(status2); PickResult error3 = PickResult.withError(status2); @@ -132,6 +151,12 @@ public void pickResult_equals() { assertThat(sc1).isNotEqualTo(sc3); assertThat(sc1).isNotEqualTo(sc4); + assertThat(nr).isEqualTo(nrDelay1); + assertThat(nrDelay1).isEqualTo(nrDelay2); + assertThat(nrDelay1.hashCode()).isEqualTo(nrDelay2.hashCode()); + assertThat(nrDelay1).isEqualTo(nrDelayDiffReason); + assertThat(nrDelay1).isEqualTo(nrDelayDiffType); + assertThat(error1).isNotEqualTo(error2); assertThat(error2).isEqualTo(error3); diff --git a/core/src/main/java/io/grpc/internal/DelayedClientTransport.java b/core/src/main/java/io/grpc/internal/DelayedClientTransport.java index aa1d820a570..0cb57b025e4 100644 --- a/core/src/main/java/io/grpc/internal/DelayedClientTransport.java +++ b/core/src/main/java/io/grpc/internal/DelayedClientTransport.java @@ -405,6 +405,8 @@ private class PendingStream extends DelayedStream { @Nullable private String activeDelayType; @GuardedBy("this") @Nullable private String activeDelayReason; + @GuardedBy("this") + private boolean delayEnded; private PendingStream(PickSubchannelArgs args, ClientStreamTracer[] tracers, @Nullable String initialType, @Nullable String initialReason) { @@ -428,26 +430,27 @@ private PendingStream(PickSubchannelArgs args, ClientStreamTracer[] tracers, * structured transition event is appended to the active span without span re-creation. */ synchronized void updateDelay(@Nullable String newType, @Nullable String newReason) { - if (getRealStream() != null) { + if (getRealStream() != null || delayEnded) { return; } + // Delay type changed (e.g., from RLS lookup to connecting). End the previous delay. if (!Objects.equals(activeDelayType, newType)) { - // Delay type changed (e.g., from RLS lookup to connecting). End the previous delay. if (activeDelayType != null) { for (ClientStreamTracer tracer : tracers) { tracer.recordAttemptDelayEnd(); } } activeDelayType = newType; - activeDelayReason = null; + activeDelayReason = newReason; if (newType != null) { for (ClientStreamTracer tracer : tracers) { tracer.recordAttemptDelayStart(newType, newReason != null ? newReason : ""); } } } - if (newType != null && newReason != null && !Objects.equals(activeDelayReason, newReason)) { - // Delay type is unchanged, but the reason changed (e.g., priority failover). + // Delay type is unchanged, but the reason changed (e.g., priority failover). + else if (newType != null && newReason != null + && !Objects.equals(activeDelayReason, newReason)) { activeDelayReason = newReason; for (ClientStreamTracer tracer : tracers) { tracer.recordAttemptDelayReasonChanged(newReason); @@ -459,6 +462,10 @@ synchronized void updateDelay(@Nullable String newType, @Nullable String newReas * Ends active attempt delay segment telemetry upon stream creation or stream cancellation. */ synchronized void endDelay() { + if (delayEnded) { + return; + } + delayEnded = true; if (activeDelayType != null) { for (ClientStreamTracer tracer : tracers) { tracer.recordAttemptDelayEnd(); @@ -469,8 +476,9 @@ synchronized void endDelay() { } Runnable setStreamAndEndDelay(ClientStream stream) { + Runnable runnable = setStream(stream); endDelay(); - return setStream(stream); + return runnable; } /** Runnable may be null. */ diff --git a/core/src/main/java/io/grpc/internal/ManagedChannelImpl.java b/core/src/main/java/io/grpc/internal/ManagedChannelImpl.java index 00df05a0c00..b0f337f890a 100644 --- a/core/src/main/java/io/grpc/internal/ManagedChannelImpl.java +++ b/core/src/main/java/io/grpc/internal/ManagedChannelImpl.java @@ -918,6 +918,7 @@ public void run() { inUseStateAggregator.updateObjectInUse(pendingCallsInUseObject, true); } pendingCalls.add(pendingCall); + pendingCall.notifyQueuedForNameResolution(); } else { pendingCall.reprocess(); } @@ -997,6 +998,10 @@ private final class PendingCall extends DelayedClientCall method; final CallOptions callOptions; private final long callCreationTime; + @GuardedBy("this") + private boolean queuedForResolution; + @GuardedBy("this") + private boolean delayEnded; PendingCall(Context context, MethodDescriptor method, CallOptions callOptions) { super( @@ -1010,8 +1015,35 @@ private final class PendingCall extends DelayedClientCall realCall; Context previous = context.attach(); try { @@ -1037,6 +1069,7 @@ public void run() { @Override protected void callCancelled() { + endDelayIfNeeded(); super.callCancelled(); syncContext.execute(new PendingCallRemoval()); } diff --git a/core/src/test/java/io/grpc/internal/DelayedClientTransportTest.java b/core/src/test/java/io/grpc/internal/DelayedClientTransportTest.java index afcce806f2b..8d5e2074c8f 100644 --- a/core/src/test/java/io/grpc/internal/DelayedClientTransportTest.java +++ b/core/src/test/java/io/grpc/internal/DelayedClientTransportTest.java @@ -42,6 +42,7 @@ import io.grpc.IntegerMarshaller; import io.grpc.LoadBalancer.PickResult; import io.grpc.LoadBalancer.PickSubchannelArgs; +import io.grpc.LoadBalancer.Subchannel; import io.grpc.LoadBalancer.SubchannelPicker; import io.grpc.Metadata; import io.grpc.MethodDescriptor; @@ -876,8 +877,8 @@ public void streamDelayMetrics_channelFallback_subchannelStateMismatch() { FakeStreamTracer fakeTracer = new FakeStreamTracer(); ClientStreamTracer[] customTracers = new ClientStreamTracer[] { fakeTracer }; - io.grpc.LoadBalancer.Subchannel disconnectedSubchannel = - mock(io.grpc.LoadBalancer.Subchannel.class); + Subchannel disconnectedSubchannel = + mock(Subchannel.class); when(disconnectedSubchannel.getInternalSubchannel()) .thenReturn(newTransportProvider(null)); diff --git a/core/src/test/java/io/grpc/internal/ForwardingClientStreamTracerTest.java b/core/src/test/java/io/grpc/internal/ForwardingClientStreamTracerTest.java index 5eb5b49fa19..87b5b8f87b4 100644 --- a/core/src/test/java/io/grpc/internal/ForwardingClientStreamTracerTest.java +++ b/core/src/test/java/io/grpc/internal/ForwardingClientStreamTracerTest.java @@ -17,6 +17,7 @@ package io.grpc.internal; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import io.grpc.ClientStreamTracer; import io.grpc.ForwardingTestUtil; @@ -40,6 +41,19 @@ public void allMethodsForwarded() throws Exception { Collections.emptyList()); } + @Test + public void attemptDelayMethodsForwarded() { + TestClientStreamTracer tracer = new TestClientStreamTracer(); + tracer.recordAttemptDelayStart("connecting", "test"); + verify(mockDelegate).recordAttemptDelayStart("connecting", "test"); + + tracer.recordAttemptDelayReasonChanged("test2"); + verify(mockDelegate).recordAttemptDelayReasonChanged("test2"); + + tracer.recordAttemptDelayEnd(); + verify(mockDelegate).recordAttemptDelayEnd(); + } + private final class TestClientStreamTracer extends ForwardingClientStreamTracer { @Override protected ClientStreamTracer delegate() { diff --git a/core/src/test/java/io/grpc/internal/ManagedChannelImplTest.java b/core/src/test/java/io/grpc/internal/ManagedChannelImplTest.java index 42fb374a68b..569dd5651dc 100644 --- a/core/src/test/java/io/grpc/internal/ManagedChannelImplTest.java +++ b/core/src/test/java/io/grpc/internal/ManagedChannelImplTest.java @@ -40,6 +40,7 @@ import static org.junit.Assert.fail; import static org.mockito.AdditionalAnswers.delegatesTo; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.ArgumentMatchers.same; @@ -81,6 +82,7 @@ import io.grpc.ConnectivityState; import io.grpc.ConnectivityStateInfo; import io.grpc.Context; +import io.grpc.Deadline; import io.grpc.EquivalentAddressGroup; import io.grpc.InsecureChannelCredentials; import io.grpc.IntegerMarshaller; @@ -4823,4 +4825,210 @@ private static ManagedChannelServiceConfig createManagedChannelServiceConfig( return ManagedChannelServiceConfig .fromServiceConfig(rawServiceConfig, true, 3, 4, policySelection); } + + @Test + public void callDelay_normalDeferredResolution() { + FakeNameResolverFactory nsFactory = new FakeNameResolverFactory.Builder(expectedUri) + .setResolvedAtStart(false).build(); + channelBuilder.nameResolverFactory(nsFactory); + createChannel(); + + ClientStreamTracer.Factory mockTracerFactory = mock(ClientStreamTracer.Factory.class); + when(mockTracerFactory.newClientStreamTracer(any(StreamInfo.class), any(Metadata.class))) + .thenReturn(new ClientStreamTracer() {}); + CallOptions callOptions = CallOptions.DEFAULT.withStreamTracerFactory(mockTracerFactory); + ClientCall call = channel.newCall(method, callOptions); + call.start(mockCallListener, new Metadata()); + + verify(mockTracerFactory).recordCallDelayStart( + eq("resolving"), eq("waiting for name resolution or service config")); + verify(mockTracerFactory, never()).recordCallDelayEnd(); + + nsFactory.allResolved(); + + verify(mockTracerFactory).recordCallDelayEnd(); + executor.runDueTasks(); + } + + @Test + public void callDelay_immediateResolution() { + FakeNameResolverFactory nsFactory = new FakeNameResolverFactory.Builder(expectedUri) + .setResolvedAtStart(true).build(); + channelBuilder.nameResolverFactory(nsFactory); + createChannel(); + + ClientStreamTracer.Factory mockTracerFactory = mock(ClientStreamTracer.Factory.class); + when(mockTracerFactory.newClientStreamTracer(any(StreamInfo.class), any(Metadata.class))) + .thenReturn(new ClientStreamTracer() {}); + CallOptions callOptions = CallOptions.DEFAULT.withStreamTracerFactory(mockTracerFactory); + ClientCall call = channel.newCall(method, callOptions); + call.start(mockCallListener, new Metadata()); + + channel.syncContext.execute(new Runnable() { + @Override + public void run() {} + }); + + verify(mockTracerFactory, never()).recordCallDelayStart(anyString(), anyString()); + verify(mockTracerFactory, never()).recordCallDelayEnd(); + executor.runDueTasks(); + } + + @Test + public void callDelay_cancellationWhileQueued() { + FakeNameResolverFactory nsFactory = new FakeNameResolverFactory.Builder(expectedUri) + .setResolvedAtStart(false).build(); + channelBuilder.nameResolverFactory(nsFactory); + createChannel(); + + ClientStreamTracer.Factory mockTracerFactory = mock(ClientStreamTracer.Factory.class); + when(mockTracerFactory.newClientStreamTracer(any(StreamInfo.class), any(Metadata.class))) + .thenReturn(new ClientStreamTracer() {}); + CallOptions callOptions = CallOptions.DEFAULT.withStreamTracerFactory(mockTracerFactory); + ClientCall call = channel.newCall(method, callOptions); + call.start(mockCallListener, new Metadata()); + + verify(mockTracerFactory).recordCallDelayStart( + eq("resolving"), eq("waiting for name resolution or service config")); + verify(mockTracerFactory, never()).recordCallDelayEnd(); + + call.cancel("Cancelled while queued", null); + + verify(mockTracerFactory).recordCallDelayEnd(); + executor.runDueTasks(); + } + + @Test + public void callDelay_deadlineExpirationWhileQueued() { + FakeNameResolverFactory nsFactory = new FakeNameResolverFactory.Builder(expectedUri) + .setResolvedAtStart(false).build(); + channelBuilder.nameResolverFactory(nsFactory); + createChannel(); + + ClientStreamTracer.Factory mockTracerFactory = mock(ClientStreamTracer.Factory.class); + when(mockTracerFactory.newClientStreamTracer(any(StreamInfo.class), any(Metadata.class))) + .thenReturn(new ClientStreamTracer() {}); + CallOptions callOptions = CallOptions.DEFAULT + .withStreamTracerFactory(mockTracerFactory) + .withDeadline(Deadline.after(100, TimeUnit.MILLISECONDS, timer.getDeadlineTicker())); + ClientCall call = channel.newCall(method, callOptions); + call.start(mockCallListener, new Metadata()); + + verify(mockTracerFactory).recordCallDelayStart( + eq("resolving"), eq("waiting for name resolution or service config")); + verify(mockTracerFactory, never()).recordCallDelayEnd(); + + timer.forwardTime(101, TimeUnit.MILLISECONDS); + + verify(mockTracerFactory).recordCallDelayEnd(); + executor.runDueTasks(); + } + + @Test + public void callDelay_resolutionFailure() { + FakeNameResolverFactory nsFactory = new FakeNameResolverFactory.Builder(expectedUri) + .setResolvedAtStart(false) + .setError(Status.UNAVAILABLE.withDescription("Simulated resolver failure")) + .build(); + channelBuilder.nameResolverFactory(nsFactory); + createChannel(); + + ClientStreamTracer.Factory mockTracerFactory = mock(ClientStreamTracer.Factory.class); + when(mockTracerFactory.newClientStreamTracer(any(StreamInfo.class), any(Metadata.class))) + .thenReturn(new ClientStreamTracer() {}); + CallOptions callOptions = CallOptions.DEFAULT.withStreamTracerFactory(mockTracerFactory); + ClientCall call = channel.newCall(method, callOptions); + call.start(mockCallListener, new Metadata()); + + verify(mockTracerFactory).recordCallDelayStart( + eq("resolving"), eq("waiting for name resolution or service config")); + verify(mockTracerFactory, never()).recordCallDelayEnd(); + + nsFactory.allResolved(); + + verify(mockTracerFactory).recordCallDelayEnd(); + executor.runDueTasks(); + } + + @Test + public void callDelay_forcefulShutdown() { + FakeNameResolverFactory nsFactory = new FakeNameResolverFactory.Builder(expectedUri) + .setResolvedAtStart(false).build(); + channelBuilder.nameResolverFactory(nsFactory); + createChannel(); + + ClientStreamTracer.Factory mockTracerFactory = mock(ClientStreamTracer.Factory.class); + when(mockTracerFactory.newClientStreamTracer(any(StreamInfo.class), any(Metadata.class))) + .thenReturn(new ClientStreamTracer() {}); + CallOptions callOptions = CallOptions.DEFAULT.withStreamTracerFactory(mockTracerFactory); + ClientCall call = channel.newCall(method, callOptions); + call.start(mockCallListener, new Metadata()); + + verify(mockTracerFactory).recordCallDelayStart( + eq("resolving"), eq("waiting for name resolution or service config")); + verify(mockTracerFactory, never()).recordCallDelayEnd(); + + channel.shutdownNow(); + + verify(mockTracerFactory).recordCallDelayEnd(); + executor.runDueTasks(); + } + + @Test + public void callDelay_cancelledBeforeQueuedOnSyncContext() { + FakeNameResolverFactory nsFactory = new FakeNameResolverFactory.Builder(expectedUri) + .setResolvedAtStart(false).build(); + channelBuilder.nameResolverFactory(nsFactory); + createChannel(); + + ClientStreamTracer.Factory mockTracerFactory = mock(ClientStreamTracer.Factory.class); + when(mockTracerFactory.newClientStreamTracer(any(StreamInfo.class), any(Metadata.class))) + .thenReturn(new ClientStreamTracer() {}); + CallOptions callOptions = CallOptions.DEFAULT.withStreamTracerFactory(mockTracerFactory); + + channel.syncContext.execute(() -> { + ClientCall call = channel.newCall(method, callOptions); + call.cancel("Cancelled before syncContext drains", null); + }); + + verify(mockTracerFactory, never()).recordCallDelayStart(anyString(), anyString()); + verify(mockTracerFactory, never()).recordCallDelayEnd(); + executor.runDueTasks(); + } + + @Test + public void callDelay_callCancelledDuringTracerIteration_abortsLoop() { + FakeNameResolverFactory nsFactory = new FakeNameResolverFactory.Builder(expectedUri) + .setResolvedAtStart(false).build(); + channelBuilder.nameResolverFactory(nsFactory); + createChannel(); + + final AtomicReference> callRef = new AtomicReference<>(); + ClientStreamTracer.Factory tracer1 = new ClientStreamTracer.Factory() { + @Override + public ClientStreamTracer newClientStreamTracer(StreamInfo info, Metadata headers) { + return new ClientStreamTracer() {}; + } + + @Override + public void recordCallDelayStart(String delayType, String delayReason) { + callRef.get().cancel("Cancel inside first tracer start", null); + } + }; + ClientStreamTracer.Factory tracer2 = mock(ClientStreamTracer.Factory.class); + when(tracer2.newClientStreamTracer(any(StreamInfo.class), any(Metadata.class))) + .thenReturn(new ClientStreamTracer() {}); + + CallOptions callOptions = CallOptions.DEFAULT + .withStreamTracerFactory(tracer1) + .withStreamTracerFactory(tracer2); + + channel.syncContext.execute(() -> { + ClientCall call = channel.newCall(method, callOptions); + callRef.set(call); + }); + + verify(tracer2, never()).recordCallDelayStart(anyString(), anyString()); + executor.runDueTasks(); + } } diff --git a/opentelemetry/src/main/java/io/grpc/opentelemetry/GrpcOpenTelemetry.java b/opentelemetry/src/main/java/io/grpc/opentelemetry/GrpcOpenTelemetry.java index 1243e0fff59..c94b7811ccd 100644 --- a/opentelemetry/src/main/java/io/grpc/opentelemetry/GrpcOpenTelemetry.java +++ b/opentelemetry/src/main/java/io/grpc/opentelemetry/GrpcOpenTelemetry.java @@ -238,11 +238,25 @@ && isMetricEnabled("grpc.client.attempt.delay.duration", enableMetrics, disableD meter.histogramBuilder( "grpc.client.attempt.delay.duration") .setUnit("s") - .setDescription("Time taken before a client call attempt starts") + .setDescription( + "EXPERIMENTAL. Time an RPC attempt spent waiting for a load balancing pick" + + " or connection establishment.") .setExplicitBucketBoundariesAdvice(LATENCY_BUCKETS) .build()); } + if (isDelayObservabilityEnabled() + && isMetricEnabled("grpc.client.call.delay.duration", enableMetrics, disableDefault)) { + builder.clientCallDelayCounter( + meter.histogramBuilder( + "grpc.client.call.delay.duration") + .setUnit("s") + .setDescription( + "EXPERIMENTAL. Time an RPC spent waiting at the call level before an attempt was" + + " initiated, such as waiting for name resolution.") + .setExplicitBucketBoundariesAdvice(LATENCY_BUCKETS) + .build()); + } if (isMetricEnabled("grpc.client.attempt.sent_total_compressed_message_size", enableMetrics, disableDefault)) { builder.clientTotalSentCompressedMessageSizeCounter( @@ -364,7 +378,7 @@ && isMetricEnabled("grpc.client.attempt.delay.duration", enableMetrics, disableD * Checks whether experimental client attempt and call delay observability is globally enabled. * *

Guarded strictly by the {@code GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY} environment - * variable (defaults to {@code false}). When disabled, delay spans and + * variable or JVM system property (defaults to {@code false}). When disabled, delay spans and * duration histograms are suppressed to avoid runtime overhead. */ static boolean isDelayObservabilityEnabled() { diff --git a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java index 008a9754b29..0eec807e66d 100644 --- a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java +++ b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java @@ -105,6 +105,7 @@ final class OpenTelemetryMetricsModule { private final boolean localityEnabled; private final boolean backendServiceEnabled; private final boolean customLabelEnabled; + private final boolean delayObservabilityEnabled; private final ImmutableList plugins; @Nullable private final TargetFilter targetAttributeFilter; @@ -124,6 +125,7 @@ final class OpenTelemetryMetricsModule { this.localityEnabled = optionalLabels.contains(LOCALITY_KEY.getKey()); this.backendServiceEnabled = optionalLabels.contains(BACKEND_SERVICE_KEY.getKey()); this.customLabelEnabled = optionalLabels.contains(CUSTOM_LABEL_KEY.getKey()); + this.delayObservabilityEnabled = GrpcOpenTelemetry.isDelayObservabilityEnabled(); this.plugins = ImmutableList.copyOf(plugins); this.targetAttributeFilter = targetAttributeFilter; } @@ -205,8 +207,12 @@ private static final class ClientTracer extends ClientStreamTracer { volatile String backendService; long attemptNanos; Code statusCode; - @Nullable private volatile Stopwatch activeDelayStopwatch; - @Nullable private volatile String activeDelayType; + @GuardedBy("this") + @Nullable private Stopwatch activeDelayStopwatch; + @GuardedBy("this") + @Nullable private String activeDelayType; + @GuardedBy("this") + private boolean streamClosed; ClientTracer(CallAttemptsTracerFactory attemptsState, OpenTelemetryMetricsModule module, StreamInfo info, String target, String fullMethodName, @@ -227,14 +233,21 @@ public void streamCreated(io.grpc.Attributes transportAtts, Metadata headers) { @Override public void recordAttemptDelayStart(String delayType, String delayReason) { - if (!GrpcOpenTelemetry.isDelayObservabilityEnabled() - || (activeDelayStopwatch != null && Objects.equals(activeDelayType, delayType))) { - // Do not reset the stopwatch if the delay type is unchanged. + if (!module.delayObservabilityEnabled) { return; } - recordAttemptDelayEnd(); - activeDelayType = delayType; - activeDelayStopwatch = module.stopwatchSupplier.get().start(); + synchronized (this) { + if (streamClosed) { + return; + } + if (activeDelayStopwatch != null && Objects.equals(activeDelayType, delayType)) { + // Do not reset the stopwatch if the delay type is unchanged. + return; + } + recordAttemptDelayEnd(); + activeDelayType = delayType; + activeDelayStopwatch = module.stopwatchSupplier.get().start(); + } } @Override @@ -244,27 +257,26 @@ public void recordAttemptDelayReasonChanged(String delayReason) { @Override public void recordAttemptDelayEnd() { - Stopwatch delayStopwatch = activeDelayStopwatch; - String delayType = activeDelayType; - if (delayStopwatch != null && delayType != null) { - delayStopwatch.stop(); - long delayNanos = delayStopwatch.elapsed(TimeUnit.NANOSECONDS); - activeDelayStopwatch = null; - activeDelayType = null; - if (module.resource.clientAttemptDelayCounter() != null) { - AttributesBuilder builder = Attributes.builder() - .put(METHOD_KEY, fullMethodName) - .put(TARGET_KEY, target) - .put("grpc.delay_type", delayType); - if (module.customLabelEnabled) { - builder.put( - CUSTOM_LABEL_KEY, info.getCallOptions().getOption(Grpc.CALL_OPTION_CUSTOM_LABEL)); - } - for (OpenTelemetryPlugin.ClientStreamPlugin plugin : streamPlugins) { - plugin.addLabels(builder); + if (!module.delayObservabilityEnabled) { + return; + } + synchronized (this) { + Stopwatch delayStopwatch = activeDelayStopwatch; + String delayType = activeDelayType; + if (delayStopwatch != null && delayType != null) { + delayStopwatch.stop(); + long delayNanos = delayStopwatch.elapsed(TimeUnit.NANOSECONDS); + activeDelayStopwatch = null; + activeDelayType = null; + if (module.resource.clientAttemptDelayCounter() != null) { + AttributesBuilder builder = Attributes.builder() + .put(METHOD_KEY, fullMethodName) + .put(TARGET_KEY, target) + .put("grpc.delay_type", delayType); + addOptionalLabels(builder); + module.resource.clientAttemptDelayCounter() + .record(delayNanos * SECONDS_PER_NANO, builder.build(), attemptsState.otelContext); } - module.resource.clientAttemptDelayCounter() - .record(delayNanos * SECONDS_PER_NANO, builder.build(), attemptsState.otelContext); } } } @@ -315,7 +327,15 @@ public void inboundTrailers(Metadata trailers) { @Override public void streamClosed(Status status) { - recordAttemptDelayEnd(); + if (module.delayObservabilityEnabled) { + synchronized (this) { + if (streamClosed) { + return; + } + streamClosed = true; + recordAttemptDelayEnd(); + } + } stopwatch.stop(); attemptNanos = stopwatch.elapsed(TimeUnit.NANOSECONDS); Deadline deadline = info.getCallOptions().getDeadline(); @@ -333,32 +353,12 @@ public void streamClosed(Status status) { } void recordFinishedAttempt() { - AttributesBuilder builder = io.opentelemetry.api.common.Attributes.builder() + AttributesBuilder builder = Attributes.builder() .put(METHOD_KEY, fullMethodName) .put(TARGET_KEY, target) .put(STATUS_KEY, statusCode.toString()); - if (module.localityEnabled) { - String savedLocality = locality; - if (savedLocality == null) { - savedLocality = ""; - } - builder.put(LOCALITY_KEY, savedLocality); - } - if (module.backendServiceEnabled) { - String savedBackendService = backendService; - if (savedBackendService == null) { - savedBackendService = ""; - } - builder.put(BACKEND_SERVICE_KEY, savedBackendService); - } - if (module.customLabelEnabled) { - builder.put( - CUSTOM_LABEL_KEY, info.getCallOptions().getOption(Grpc.CALL_OPTION_CUSTOM_LABEL)); - } - for (OpenTelemetryPlugin.ClientStreamPlugin plugin : streamPlugins) { - plugin.addLabels(builder); - } - io.opentelemetry.api.common.Attributes attribute = builder.build(); + addOptionalLabels(builder); + Attributes attribute = builder.build(); if (module.resource.clientAttemptDurationCounter() != null ) { module.resource.clientAttemptDurationCounter() @@ -373,6 +373,22 @@ void recordFinishedAttempt() { .record(inboundWireSize, attribute, attemptsState.otelContext); } } + + private void addOptionalLabels(AttributesBuilder builder) { + if (module.localityEnabled) { + builder.put(LOCALITY_KEY, Objects.toString(locality, "")); + } + if (module.backendServiceEnabled) { + builder.put(BACKEND_SERVICE_KEY, Objects.toString(backendService, "")); + } + if (module.customLabelEnabled) { + builder.put( + CUSTOM_LABEL_KEY, info.getCallOptions().getOption(Grpc.CALL_OPTION_CUSTOM_LABEL)); + } + for (OpenTelemetryPlugin.ClientStreamPlugin plugin : streamPlugins) { + plugin.addLabels(builder); + } + } } @VisibleForTesting @@ -387,6 +403,11 @@ static final class CallAttemptsTracerFactory extends ClientStreamTracer.Factory private final List callPlugins; private final Context otelContext; private Status status; + @GuardedBy("lock") + @Nullable private Stopwatch activeCallDelayStopwatch; + @GuardedBy("lock") + @Nullable private String activeCallDelayType; + private final Attributes callLevelBaseAttributes; private long retryDelayNanos; private long callLatencyNanos; private final Object lock = new Object(); @@ -412,18 +433,18 @@ static final class CallAttemptsTracerFactory extends ClientStreamTracer.Factory this.attemptDelayStopwatch = module.stopwatchSupplier.get(); this.callStopWatch = module.stopwatchSupplier.get().start(); - AttributesBuilder builder = io.opentelemetry.api.common.Attributes.builder() + AttributesBuilder builder = Attributes.builder() .put(METHOD_KEY, fullMethodName) .put(TARGET_KEY, target); if (module.customLabelEnabled) { builder.put( CUSTOM_LABEL_KEY, callOptions.getOption(Grpc.CALL_OPTION_CUSTOM_LABEL)); } - io.opentelemetry.api.common.Attributes attribute = builder.build(); + this.callLevelBaseAttributes = builder.build(); - // Record here in case mewClientStreamTracer() would never be called. + // Record here in case newClientStreamTracer() would never be called. if (module.resource.clientAttemptCountCounter() != null) { - module.resource.clientAttemptCountCounter().add(1, attribute, otelContext); + module.resource.clientAttemptCountCounter().add(1, callLevelBaseAttributes, otelContext); } } @@ -504,6 +525,9 @@ void callEnded(Status status, CallOptions callOptions) { return; } callEnded = true; + if (module.delayObservabilityEnabled) { + recordCallDelayEnd(); + } if (activeStreams == 0 && !finishedCallToBeRecorded) { shouldRecordFinishedCall = true; finishedCallToBeRecorded = true; @@ -579,6 +603,54 @@ void recordFinishedCall(CallOptions callOptions) { ); } } + + @Override + public void recordCallDelayStart(String delayType, String delayReason) { + if (!module.delayObservabilityEnabled) { + return; + } + synchronized (lock) { + if (callEnded) { + return; + } + if (activeCallDelayStopwatch != null && Objects.equals(activeCallDelayType, delayType)) { + return; + } + recordCallDelayEnd(); + activeCallDelayType = delayType; + activeCallDelayStopwatch = module.stopwatchSupplier.get().start(); + } + } + + @Override + public void recordCallDelayReasonChanged(String delayReason) { + // Reason strings are high-cardinality diagnostics intended for tracing spans. + } + + @Override + public void recordCallDelayEnd() { + if (!module.delayObservabilityEnabled) { + return; + } + synchronized (lock) { + Stopwatch delayStopwatch = activeCallDelayStopwatch; + String delayType = activeCallDelayType; + if (delayStopwatch != null && delayType != null) { + delayStopwatch.stop(); + long delayNanos = delayStopwatch.elapsed(TimeUnit.NANOSECONDS); + activeCallDelayStopwatch = null; + activeCallDelayType = null; + if (module.resource.clientCallDelayCounter() != null) { + module.resource.clientCallDelayCounter().record( + delayNanos * SECONDS_PER_NANO, + callLevelBaseAttributes.toBuilder() + .put("grpc.delay_type", delayType) + .build(), + otelContext); + } + } + } + } } private static final class ServerTracer extends ServerStreamTracer diff --git a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsResource.java b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsResource.java index 085498d746e..2771b3eb9af 100644 --- a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsResource.java +++ b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsResource.java @@ -38,6 +38,9 @@ abstract class OpenTelemetryMetricsResource { @Nullable abstract DoubleHistogram clientAttemptDelayCounter(); + @Nullable + abstract DoubleHistogram clientCallDelayCounter(); + @Nullable abstract LongHistogram clientTotalSentCompressedMessageSizeCounter(); @@ -84,6 +87,9 @@ abstract static class Builder { abstract Builder clientAttemptDelayCounter(DoubleHistogram counter); + abstract Builder clientCallDelayCounter(DoubleHistogram counter); + + abstract Builder clientTotalSentCompressedMessageSizeCounter(LongHistogram counter); abstract Builder clientTotalReceivedCompressedMessageSizeCounter( diff --git a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryTracingModule.java b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryTracingModule.java index 32aab870f0f..a6b9e4825d4 100644 --- a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryTracingModule.java +++ b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryTracingModule.java @@ -22,7 +22,7 @@ import static io.grpc.opentelemetry.internal.OpenTelemetryConstants.BAGGAGE_KEY; import com.google.common.annotations.VisibleForTesting; -import io.grpc.Attributes; +import com.google.errorprone.annotations.concurrent.GuardedBy; import io.grpc.CallOptions; import io.grpc.Channel; import io.grpc.ClientCall; @@ -37,11 +37,13 @@ import io.grpc.ServerCallHandler; import io.grpc.ServerInterceptor; import io.grpc.ServerStreamTracer; +import io.grpc.Status; import io.grpc.internal.GrpcUtil; import io.grpc.opentelemetry.internal.OpenTelemetryConstants; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.baggage.Baggage; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.StatusCode; @@ -99,6 +101,7 @@ final class OpenTelemetryTracingModule { private final ServerInterceptor serverSpanPropagationInterceptor = new TracingServerSpanPropagationInterceptor(); private final ServerTracerFactory serverTracerFactory = new ServerTracerFactory(); + private final boolean delayObservabilityEnabled; OpenTelemetryTracingModule(OpenTelemetry openTelemetry) { this.otelTracer = checkNotNull(openTelemetry.getTracerProvider(), "tracerProvider") @@ -106,6 +109,7 @@ final class OpenTelemetryTracingModule { .setInstrumentationVersion(IMPLEMENTATION_VERSION) .build(); this.contextPropagators = checkNotNull(openTelemetry.getPropagators(), "contextPropagators"); + this.delayObservabilityEnabled = GrpcOpenTelemetry.isDelayObservabilityEnabled(); } @VisibleForTesting @@ -144,6 +148,10 @@ final class CallAttemptsTracerFactory extends ClientStreamTracer.Factory { volatile int callEnded; private final Span clientSpan; private final String fullMethodName; + @GuardedBy("this") + @Nullable private Span activeCallDelaySpan; + @GuardedBy("this") + @Nullable private String activeCallDelayType; CallAttemptsTracerFactory(Span clientSpan, MethodDescriptor method) { checkNotNull(method, "method"); @@ -168,6 +176,13 @@ public ClientStreamTracer newClientStreamTracer( return new ClientTracer(attemptSpan, clientSpan); } + private boolean isCallEnded() { + if (callEndedUpdater != null) { + return callEndedUpdater.get(this) != 0; + } + return callEnded != 0; + } + /** * Record a finished call and mark the current time as the end time. * @@ -185,8 +200,65 @@ void callEnded(io.grpc.Status status) { } callEnded = 1; } + if (delayObservabilityEnabled) { + recordCallDelayEnd(); + } endSpanWithStatus(clientSpan, status); } + + @Override + public void recordCallDelayStart(String delayType, String delayReason) { + if (!delayObservabilityEnabled || isCallEnded()) { + return; + } + synchronized (this) { + if (isCallEnded()) { + return; + } + if (activeCallDelaySpan != null && Objects.equals(activeCallDelayType, delayType)) { + recordCallDelayReasonChanged(delayReason); + return; + } + recordCallDelayEnd(); + activeCallDelayType = delayType; + activeCallDelaySpan = otelTracer.spanBuilder("Delay") + .setParent(Context.current().with(clientSpan)) + .setAttribute("grpc.delay_type", delayType) + .startSpan(); + activeCallDelaySpan.addEvent( + "Delay state transition", + Attributes.of(AttributeKey.stringKey("grpc.delay_reason"), delayReason)); + } + } + + @Override + public void recordCallDelayReasonChanged(String delayReason) { + if (!delayObservabilityEnabled || isCallEnded()) { + return; + } + synchronized (this) { + if (isCallEnded() || activeCallDelaySpan == null) { + return; + } + activeCallDelaySpan.addEvent( + "Delay state transition", + Attributes.of(AttributeKey.stringKey("grpc.delay_reason"), delayReason)); + } + } + + @Override + public void recordCallDelayEnd() { + if (!delayObservabilityEnabled) { + return; + } + synchronized (this) { + if (activeCallDelaySpan != null) { + activeCallDelaySpan.end(); + activeCallDelaySpan = null; + activeCallDelayType = null; + } + } + } } private final class ClientTracer extends ClientStreamTracer { @@ -194,8 +266,12 @@ private final class ClientTracer extends ClientStreamTracer { private final Span parentSpan; volatile int seqNo; boolean isPendingStream; - @Nullable private volatile Span activeDelaySpan; - @Nullable private volatile String activeDelayType; + @GuardedBy("this") + @Nullable private Span activeDelaySpan; + @GuardedBy("this") + @Nullable private String activeDelayType; + @GuardedBy("this") + private boolean streamClosed; ClientTracer(Span span, Span parentSpan) { this.span = checkNotNull(span, "span"); @@ -203,7 +279,7 @@ private final class ClientTracer extends ClientStreamTracer { } @Override - public void streamCreated(Attributes transportAtts, Metadata headers) { + public void streamCreated(io.grpc.Attributes transportAtts, Metadata headers) { recordAttemptDelayEnd(); contextPropagators.getTextMapPropagator().inject(Context.current().with(span), headers, metadataSetter); @@ -219,51 +295,59 @@ public void createPendingStream() { @Override public void recordAttemptDelayStart(String delayType, String delayReason) { - if (!GrpcOpenTelemetry.isDelayObservabilityEnabled()) { + if (!delayObservabilityEnabled) { return; } - if (activeDelaySpan != null && Objects.equals(activeDelayType, delayType)) { - // Do not recreate the span if the delay type is unchanged (e.g., priority failover). - recordAttemptDelayReasonChanged(delayReason); - return; + synchronized (this) { + if (streamClosed) { + return; + } + if (activeDelaySpan != null && Objects.equals(activeDelayType, delayType)) { + // Do not recreate the span if the delay type is unchanged (e.g., priority failover). + recordAttemptDelayReasonChanged(delayReason); + return; + } + // Close any previous delay segment before starting a new canonical segment. + recordAttemptDelayEnd(); + activeDelayType = delayType; + // All attempt queuing segments use the strict child span name "Delay". + activeDelaySpan = otelTracer.spanBuilder("Delay") + .setParent(Context.current().with(span)) + .setAttribute("grpc.delay_type", delayType) + .startSpan(); + activeDelaySpan.addEvent( + "Delay state transition", + Attributes.of(AttributeKey.stringKey("grpc.delay_reason"), delayReason)); } - // Close any previous delay segment before starting a new canonical segment. - recordAttemptDelayEnd(); - activeDelayType = delayType; - // All attempt queuing segments use the strict child span name "Attempt Delay". - Span delaySpan = otelTracer.spanBuilder("Attempt Delay") - .setParent(Context.current().with(span)) - .setAttribute("grpc.delay_type", delayType) - .startSpan(); - activeDelaySpan = delaySpan; - delaySpan.addEvent( - "Delay state transition", - io.opentelemetry.api.common.Attributes.of( - AttributeKey.stringKey("grpc.delay_type"), delayType, - AttributeKey.stringKey("grpc.delay_reason"), delayReason)); } @Override public void recordAttemptDelayReasonChanged(String delayReason) { - if (!GrpcOpenTelemetry.isDelayObservabilityEnabled() || activeDelaySpan == null) { + if (!delayObservabilityEnabled) { return; } - String type = activeDelayType; - activeDelaySpan.addEvent( - "Delay state transition", - io.opentelemetry.api.common.Attributes.of( - AttributeKey.stringKey("grpc.delay_type"), type != null ? type : "", - AttributeKey.stringKey("grpc.delay_reason"), delayReason)); + synchronized (this) { + if (streamClosed || activeDelaySpan == null) { + return; + } + activeDelaySpan.addEvent( + "Delay state transition", + Attributes.of(AttributeKey.stringKey("grpc.delay_reason"), delayReason)); + } } @Override public void recordAttemptDelayEnd() { - Span delaySpan = activeDelaySpan; - if (delaySpan != null) { - // End active child span upon pick completion or transport cancellation. - delaySpan.end(); - activeDelaySpan = null; - activeDelayType = null; + if (!delayObservabilityEnabled) { + return; + } + synchronized (this) { + if (activeDelaySpan != null) { + // End active child span upon pick completion or transport cancellation. + activeDelaySpan.end(); + activeDelaySpan = null; + activeDelayType = null; + } } } @@ -292,8 +376,14 @@ public void inboundUncompressedSize(long bytes) { } @Override - public void streamClosed(io.grpc.Status status) { - recordAttemptDelayEnd(); + public synchronized void streamClosed(Status status) { + if (streamClosed) { + return; + } + streamClosed = true; + if (delayObservabilityEnabled) { + recordAttemptDelayEnd(); + } endSpanWithStatus(span, status); } } @@ -511,7 +601,7 @@ public void onClose(io.grpc.Status status, Metadata trailers) { // 'message-size' = 7854) ----| private void recordOutboundMessageSentEvent(Span span, int seqNo, long optionalWireSize, long optionalUncompressedSize) { - AttributesBuilder attributesBuilder = io.opentelemetry.api.common.Attributes.builder(); + AttributesBuilder attributesBuilder = Attributes.builder(); attributesBuilder.put("sequence-number", seqNo); if (optionalUncompressedSize != -1) { attributesBuilder.put("message-size", optionalUncompressedSize); @@ -523,14 +613,14 @@ private void recordOutboundMessageSentEvent(Span span, } private void recordInboundCompressedMessage(Span span, int seqNo, long optionalWireSize) { - AttributesBuilder attributesBuilder = io.opentelemetry.api.common.Attributes.builder(); + AttributesBuilder attributesBuilder = Attributes.builder(); attributesBuilder.put("sequence-number", seqNo); attributesBuilder.put("message-size-compressed", optionalWireSize); span.addEvent("Inbound compressed message", attributesBuilder.build()); } private void recordInboundMessageSize(Span span, int seqNo, long bytes) { - AttributesBuilder attributesBuilder = io.opentelemetry.api.common.Attributes.builder(); + AttributesBuilder attributesBuilder = Attributes.builder(); attributesBuilder.put("sequence-number", seqNo); attributesBuilder.put("message-size", bytes); span.addEvent("Inbound message", attributesBuilder.build()); diff --git a/opentelemetry/src/test/java/io/grpc/opentelemetry/GrpcOpenTelemetryTest.java b/opentelemetry/src/test/java/io/grpc/opentelemetry/GrpcOpenTelemetryTest.java index 77eadf9ebbb..9a4fb87e58e 100644 --- a/opentelemetry/src/test/java/io/grpc/opentelemetry/GrpcOpenTelemetryTest.java +++ b/opentelemetry/src/test/java/io/grpc/opentelemetry/GrpcOpenTelemetryTest.java @@ -17,6 +17,9 @@ package io.grpc.opentelemetry; import static com.google.common.truth.Truth.assertThat; +import static io.grpc.ClientStreamTracer.NAME_RESOLUTION_DELAYED; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.emptyList; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -24,27 +27,82 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.ByteStreams; +import io.grpc.CallOptions; +import io.grpc.ClientCall; import io.grpc.ClientInterceptor; +import io.grpc.ClientStreamTracer; import io.grpc.ForwardingChannelBuilder2; +import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; import io.grpc.MetricSink; import io.grpc.ServerBuilder; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerServiceDefinition; +import io.grpc.Status; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.internal.FakeClock; import io.grpc.internal.GrpcUtil; import io.grpc.opentelemetry.GrpcOpenTelemetry.TargetFilter; +import io.grpc.testing.GrpcCleanupRule; import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; 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 io.opentelemetry.sdk.testing.junit4.OpenTelemetryRule; import io.opentelemetry.sdk.trace.SdkTracerProvider; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @RunWith(JUnit4.class) public class GrpcOpenTelemetryTest { + @Rule + public final OpenTelemetryRule openTelemetryRule = OpenTelemetryRule.create(); + @Rule + public final GrpcCleanupRule grpcCleanupRule = new GrpcCleanupRule(); + + private static final MethodDescriptor.Marshaller MARSHALLER = + new MethodDescriptor.Marshaller() { + @Override + public InputStream stream(String value) { + return new ByteArrayInputStream(value.getBytes(UTF_8)); + } + + @Override + public String parse(InputStream stream) { + try { + return new String(ByteStreams.toByteArray(stream), UTF_8); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } + }; + + private final MethodDescriptor method = + MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setRequestMarshaller(MARSHALLER) + .setResponseMarshaller(MARSHALLER) + .setFullMethodName("test.service/method") + .build(); + private final InMemoryMetricReader inMemoryMetricReader = InMemoryMetricReader.create(); private final SdkMeterProvider meterProvider = SdkMeterProvider.builder().registerMetricReader(inMemoryMetricReader).build(); @@ -55,11 +113,13 @@ public class GrpcOpenTelemetryTest { @Before public void setup() { originalEnableOtelTracing = GrpcOpenTelemetry.ENABLE_OTEL_TRACING; + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); } @After public void tearDown() { GrpcOpenTelemetry.ENABLE_OTEL_TRACING = originalEnableOtelTracing; + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); } @Test @@ -179,6 +239,308 @@ public void configureChannelBuilder_registersMetricSink() { assertThat(testBuilder.interceptorFactory).isNotNull(); } + @Test + public void nameResolutionDelay_endToEndClientServerSimulation() throws Exception { + String serverName = InProcessServerBuilder.generateName(); + ServerServiceDefinition serviceDef = ServerServiceDefinition.builder("test.service") + .addMethod(method, new ServerCallHandler() { + @Override + public ServerCall.Listener startCall( + ServerCall call, Metadata headers) { + call.sendHeaders(new Metadata()); + call.sendMessage("response_payload"); + call.close(Status.OK, new Metadata()); + return new ServerCall.Listener() {}; + } + }) + .build(); + + grpcCleanupRule.register( + InProcessServerBuilder.forName(serverName).directExecutor().addService(serviceDef).build() + .start()); + + OpenTelemetrySdk sdk = (OpenTelemetrySdk) openTelemetryRule.getOpenTelemetry(); + GrpcOpenTelemetry grpcOpenTelemetry = GrpcOpenTelemetry.newBuilder() + .sdk(sdk) + .enableMetrics(Arrays.asList( + "grpc.client.attempt.delay.duration", + "grpc.client.call.delay.duration", + "grpc.client.attempt.started")) + .enableTracing(true) + .addOptionalLabel("grpc.delay_type") + .build(); + + ManagedChannelBuilder channelBuilder = InProcessChannelBuilder.forName(serverName) + .directExecutor(); + grpcOpenTelemetry.configureChannelBuilder(channelBuilder); + ManagedChannel channel = grpcCleanupRule.register(channelBuilder.build()); + + // Simulate Name Resolution delay on call options and stream tracer + CallOptions callOptions = CallOptions.DEFAULT.withOption( + NAME_RESOLUTION_DELAYED, TimeUnit.MILLISECONDS.toNanos(120)); + + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments( + sdk.getMeterProvider().get("grpc-java"), + ImmutableMap.of( + "grpc.client.attempt.delay.duration", true, + "grpc.client.call.delay.duration", true), + false); + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + new FakeClock().getStopwatchSupplier(), resource, emptyList(), emptyList()); + OpenTelemetryMetricsModule.CallAttemptsTracerFactory factory = + new OpenTelemetryMetricsModule.CallAttemptsTracerFactory( + module, "target:///", callOptions, method.getFullMethodName(), + emptyList(), io.opentelemetry.context.Context.root()); + ClientStreamTracer delayTracer = factory.newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().setCallOptions(callOptions).build(), + new Metadata()); + delayTracer.recordAttemptDelayStart("connecting", "DNS server unreachable temporarily"); + delayTracer.recordAttemptDelayEnd(); + factory.recordCallDelayStart("resolving", "DNS resolution pending"); + factory.recordCallDelayEnd(); + + final CountDownLatch latch = new CountDownLatch(1); + ClientCall call = channel.newCall(method, callOptions); + call.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + latch.countDown(); + } + }, new Metadata()); + call.sendMessage("request_payload"); + call.halfClose(); + call.request(1); + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + + io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions + .assertThat(openTelemetryRule.getMetrics()) + .anySatisfy( + metric -> io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions + .assertThat(metric) + .hasName("grpc.client.attempt.delay.duration") + .hasDescription( + "EXPERIMENTAL. Time an RPC attempt spent waiting for a load balancing pick" + + " or connection establishment.") + .hasUnit("s") + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> { + point.hasAttribute( + AttributeKey.stringKey("grpc.target"), "target:///"); + point.hasAttribute( + AttributeKey.stringKey("grpc.method"), method.getFullMethodName()); + point.hasAttribute( + AttributeKey.stringKey("grpc.delay_type"), "connecting"); + }))); + io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions + .assertThat(openTelemetryRule.getMetrics()) + .anySatisfy( + metric -> io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions + .assertThat(metric) + .hasName("grpc.client.call.delay.duration") + .hasDescription( + "EXPERIMENTAL. Time an RPC spent waiting at the call level before an attempt" + + " was initiated, such as waiting for name resolution.") + .hasUnit("s") + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> { + point.hasAttribute( + AttributeKey.stringKey("grpc.target"), "target:///"); + point.hasAttribute( + AttributeKey.stringKey("grpc.method"), method.getFullMethodName()); + point.hasAttribute( + AttributeKey.stringKey("grpc.delay_type"), "resolving"); + }))); + } + + @Test + public void delayObservability_flagEnabled_notOptedIn_instrumentsNullAndNoMetrics() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + OpenTelemetrySdk sdk = (OpenTelemetrySdk) openTelemetryRule.getOpenTelemetry(); + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments( + sdk.getMeterProvider().get("grpc-java"), + ImmutableMap.of(), + false); + + assertThat(resource.clientAttemptDelayCounter()).isNull(); + assertThat(resource.clientCallDelayCounter()).isNull(); + + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + new FakeClock().getStopwatchSupplier(), resource, emptyList(), emptyList()); + OpenTelemetryMetricsModule.CallAttemptsTracerFactory factory = + new OpenTelemetryMetricsModule.CallAttemptsTracerFactory( + module, "target:///", CallOptions.DEFAULT, method.getFullMethodName(), + emptyList(), io.opentelemetry.context.Context.root()); + + // Verify call delay methods execute cleanly when counters are null + factory.recordCallDelayStart("resolving", "resolving name"); + factory.recordCallDelayEnd(); + factory.callEnded(Status.OK, CallOptions.DEFAULT); + + // Verify attempt delay methods execute cleanly when counters are null + ClientStreamTracer delayTracer = factory.newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().setCallOptions(CallOptions.DEFAULT).build(), + new Metadata()); + delayTracer.recordAttemptDelayStart("connecting", "connecting to backend"); + delayTracer.recordAttemptDelayReasonChanged("still connecting"); + delayTracer.recordAttemptDelayEnd(); + delayTracer.streamClosed(Status.OK); + + for (MetricData m : openTelemetryRule.getMetrics()) { + assertThat(m.getName()).isNotIn( + ImmutableList.of( + "grpc.client.attempt.delay.duration", "grpc.client.call.delay.duration")); + } + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void lbPolicyDelay_endToEndClientServerSimulation() throws Exception { + String serverName = InProcessServerBuilder.generateName(); + ServerServiceDefinition serviceDef = ServerServiceDefinition.builder("test.service") + .addMethod(method, new ServerCallHandler() { + @Override + public ServerCall.Listener startCall( + ServerCall call, Metadata headers) { + call.sendHeaders(new Metadata()); + call.sendMessage("response_payload"); + call.close(Status.OK, new Metadata()); + return new ServerCall.Listener() {}; + } + }) + .build(); + + grpcCleanupRule.register( + InProcessServerBuilder.forName(serverName).directExecutor().addService(serviceDef).build() + .start()); + + OpenTelemetrySdk sdk = (OpenTelemetrySdk) openTelemetryRule.getOpenTelemetry(); + GrpcOpenTelemetry grpcOpenTelemetry = GrpcOpenTelemetry.newBuilder() + .sdk(sdk) + .enableMetrics(Arrays.asList( + "grpc.client.attempt.delay.duration", + "grpc.client.call.delay.duration", + "grpc.client.attempt.started")) + .enableTracing(true) + .addOptionalLabel("grpc.delay_type") + .build(); + + ManagedChannelBuilder channelBuilder = InProcessChannelBuilder.forName(serverName) + .directExecutor(); + grpcOpenTelemetry.configureChannelBuilder(channelBuilder); + ManagedChannel channel = grpcCleanupRule.register(channelBuilder.build()); + + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments( + sdk.getMeterProvider().get("grpc-java"), + ImmutableMap.of("grpc.client.attempt.delay.duration", true), + false); + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + new FakeClock().getStopwatchSupplier(), resource, emptyList(), emptyList()); + OpenTelemetryMetricsModule.CallAttemptsTracerFactory factory = + new OpenTelemetryMetricsModule.CallAttemptsTracerFactory( + module, "target:///", CallOptions.DEFAULT, method.getFullMethodName(), + emptyList(), io.opentelemetry.context.Context.root()); + ClientStreamTracer tracer = factory.newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().setCallOptions(CallOptions.DEFAULT).build(), + new Metadata()); + tracer.recordAttemptDelayStart("rls_lookup_pending", "Route Lookup Service query pending"); + tracer.recordAttemptDelayEnd(); + + final CountDownLatch latch = new CountDownLatch(1); + ClientCall call = channel.newCall(method, CallOptions.DEFAULT); + call.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + latch.countDown(); + } + }, new Metadata()); + call.sendMessage("request_payload"); + call.halfClose(); + call.request(1); + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + + io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions + .assertThat(openTelemetryRule.getMetrics()) + .anySatisfy( + metric -> io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions + .assertThat(metric) + .hasName("grpc.client.attempt.delay.duration") + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> { + point.hasAttribute( + AttributeKey.stringKey("grpc.delay_type"), "rls_lookup_pending"); + }))); + } + + @Test + public void baselineNoDelay_endToEndClientServerSimulation() throws Exception { + String serverName = InProcessServerBuilder.generateName(); + ServerServiceDefinition serviceDef = ServerServiceDefinition.builder("test.service") + .addMethod(method, new ServerCallHandler() { + @Override + public ServerCall.Listener startCall( + ServerCall call, Metadata headers) { + call.sendHeaders(new Metadata()); + call.sendMessage("response_payload"); + call.close(Status.OK, new Metadata()); + return new ServerCall.Listener() {}; + } + }) + .build(); + + grpcCleanupRule.register( + InProcessServerBuilder.forName(serverName).directExecutor().addService(serviceDef).build() + .start()); + + OpenTelemetrySdk sdk = (OpenTelemetrySdk) openTelemetryRule.getOpenTelemetry(); + GrpcOpenTelemetry grpcOpenTelemetry = GrpcOpenTelemetry.newBuilder() + .sdk(sdk) + .enableMetrics(Arrays.asList( + "grpc.client.attempt.delay.duration", + "grpc.client.call.delay.duration", + "grpc.client.attempt.started")) + .enableTracing(true) + .build(); + + ManagedChannelBuilder channelBuilder = InProcessChannelBuilder.forName(serverName) + .directExecutor(); + grpcOpenTelemetry.configureChannelBuilder(channelBuilder); + ManagedChannel channel = grpcCleanupRule.register(channelBuilder.build()); + + final CountDownLatch latch = new CountDownLatch(1); + ClientCall call = channel.newCall(method, CallOptions.DEFAULT); + call.start(new ClientCall.Listener() { + @Override + public void onClose(Status status, Metadata trailers) { + latch.countDown(); + } + }, new Metadata()); + call.sendMessage("request_payload"); + call.halfClose(); + call.request(1); + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + + boolean hasAttemptDelay = false; + boolean hasCallDelay = false; + for (MetricData m : openTelemetryRule.getMetrics()) { + if ("grpc.client.attempt.delay.duration".equals(m.getName()) + && !m.getHistogramData().getPoints().isEmpty()) { + hasAttemptDelay = true; + } + if ("grpc.client.call.delay.duration".equals(m.getName()) + && !m.getHistogramData().getPoints().isEmpty()) { + hasCallDelay = true; + } + } + assertThat(hasAttemptDelay).isFalse(); + assertThat(hasCallDelay).isFalse(); + } + private static class TestChannelBuilder extends ForwardingChannelBuilder2 { Object interceptorFactory; MetricSink metricSink; diff --git a/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java b/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java index bd613888f94..9646aa3f035 100644 --- a/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java +++ b/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java @@ -30,6 +30,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import com.google.common.collect.ImmutableMap; @@ -45,6 +46,9 @@ import io.grpc.Grpc; import io.grpc.KnownLength; import io.grpc.LoadBalancer; +import io.grpc.LoadBalancer.PickResult; +import io.grpc.LoadBalancer.PickSubchannelArgs; +import io.grpc.LoadBalancer.SubchannelPicker; import io.grpc.LoadBalancerProvider; import io.grpc.LoadBalancerRegistry; import io.grpc.ManagedChannel; @@ -1663,6 +1667,179 @@ public void clientAttemptDelayDuration_recorded() { }))); } + + @Test + public void clientCallDelayDuration_recorded() { + Map enabledMetrics = ImmutableMap.of( + "grpc.client.call.delay.duration", true + ); + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments( + testMeter, enabledMetrics, disableDefaultMetrics); + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), resource, emptyList(), emptyList()); + CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory( + module, "target:///", STREAM_INFO.getCallOptions(), method.getFullMethodName(), + emptyList(), Context.root()); + + callAttemptsTracerFactory.recordCallDelayStart("resolving", "dns resolution pending"); + fakeClock.forwardTime(500, TimeUnit.MILLISECONDS); + callAttemptsTracerFactory.recordCallDelayEnd(); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> assertThat(metric) + .hasName("grpc.client.call.delay.duration") + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> { + point.hasSum(0.5); + point.hasAttribute( + AttributeKey.stringKey("grpc.delay_type"), "resolving"); + }))); + } + + @Test + public void clientCallDelayDuration_defensiveCleanupOnAbruptCallEnded() { + Map enabledMetrics = ImmutableMap.of( + "grpc.client.call.delay.duration", true + ); + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments( + testMeter, enabledMetrics, disableDefaultMetrics); + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), resource, emptyList(), emptyList()); + CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory( + module, "target:///", STREAM_INFO.getCallOptions(), method.getFullMethodName(), + emptyList(), Context.root()); + + callAttemptsTracerFactory.recordCallDelayStart("resolving", "dns resolution pending"); + fakeClock.forwardTime(500, TimeUnit.MILLISECONDS); + // Call ends abruptly without prior recordCallDelayEnd() + callAttemptsTracerFactory.callEnded( + Status.CANCELLED.withDescription("abrupt cancellation"), STREAM_INFO.getCallOptions()); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> assertThat(metric) + .hasName("grpc.client.call.delay.duration") + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> { + point.hasSum(0.5); + point.hasAttribute(METHOD_KEY, method.getFullMethodName()); + point.hasAttribute(TARGET_KEY, "target:///"); + point.hasAttribute( + AttributeKey.stringKey("grpc.delay_type"), "resolving"); + }))); + + // Ensure subsequent calls to recordCallDelayEnd are safe no-ops + callAttemptsTracerFactory.recordCallDelayEnd(); + // Ensure subsequent calls to recordCallDelayStart are rejected after callEnded + callAttemptsTracerFactory.recordCallDelayStart("resolving", "late start attempt"); + fakeClock.forwardTime(200, TimeUnit.MILLISECONDS); + callAttemptsTracerFactory.recordCallDelayEnd(); + + // Metric sum remains 0.5, no additional recordings + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> assertThat(metric) + .hasName("grpc.client.call.delay.duration") + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> point.hasSum(0.5)))); + } + + @Test + public void clientCallDelayDuration_endToEnd_nameResolutionDelay() throws Exception { + final CountDownLatch resolutionLatch = new CountDownLatch(1); + final AtomicReference capturedListener = new AtomicReference<>(); + + NameResolverProvider slowResolverProvider = new NameResolverProvider() { + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return 5; + } + + @Override + public String getDefaultScheme() { + return "slowresmetric"; + } + + @Override + public Collection> getProducedSocketAddressTypes() { + return Collections.singleton(InProcessSocketAddress.class); + } + + @Override + public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + return new NameResolver() { + @Override + public String getServiceAuthority() { + return "slowresmetric"; + } + + @Override + public void start(Listener2 listener) { + capturedListener.set(listener); + resolutionLatch.countDown(); + } + + @Override + public void shutdown() {} + }; + } + }; + NameResolverRegistry.getDefaultRegistry().register(slowResolverProvider); + + GrpcOpenTelemetry grpcOpenTelemetry = GrpcOpenTelemetry.newBuilder() + .sdk(openTelemetryTesting.getOpenTelemetry()) + .enableMetrics(Collections.singleton("grpc.client.call.delay.duration")) + .build(); + + InProcessChannelBuilder channelBuilder = + InProcessChannelBuilder.forTarget("slowresmetric:///test-metric-service") + .defaultLoadBalancingPolicy("pick_first"); + grpcOpenTelemetry.configureChannelBuilder(channelBuilder); + ManagedChannel channel = channelBuilder.build(); + try { + ClientCall call = channel.newCall(method, CallOptions.DEFAULT); + call.start(new ClientCall.Listener() {}, new Metadata()); + call.request(1); + + resolutionLatch.await(5, TimeUnit.SECONDS); + + // Complete name resolution + capturedListener.get().onResult(NameResolver.ResolutionResult.newBuilder() + .setAddressesOrError(StatusOr.fromValue(Collections.singletonList( + new EquivalentAddressGroup(new InProcessSocketAddress("test-slow-metric"))))) + .build()); + + call.cancel("End test", null); + } finally { + channel.shutdownNow(); + channel.awaitTermination(5, TimeUnit.SECONDS); + NameResolverRegistry.getDefaultRegistry().deregister(slowResolverProvider); + } + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> assertThat(metric) + .hasName("grpc.client.call.delay.duration") + .hasHistogramSatisfying( + histogram -> histogram.hasPointsSatisfying( + point -> { + point.hasAttribute(METHOD_KEY, method.getFullMethodName()); + point.hasAttribute( + AttributeKey.stringKey("grpc.delay_type"), "resolving"); + }))); + } + @Test public void clientAttemptDelayDuration_endToEnd_inProcessTransport() throws Exception { final CountDownLatch latch = new CountDownLatch(1); @@ -2289,6 +2466,174 @@ public void serverMetrics_recordsBaggage() { "baggage-val-1", capturedBaggage.getEntryValue("baggage-key-1")); } + @Test + public void clientMetrics_nameResolutionFailure_zeroAttempts() { + String target = "target:///"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); + OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + + fakeClock.forwardTime(50, TimeUnit.MILLISECONDS); + callAttemptsTracerFactory.callEnded(Status.UNAVAILABLE, CALL_OPTIONS); + + io.opentelemetry.api.common.Attributes clientAttributes = + io.opentelemetry.api.common.Attributes.of( + TARGET_KEY, target, + METHOD_KEY, method.getFullMethodName(), + STATUS_KEY, Code.UNAVAILABLE.toString()); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> + assertThat(metric) + .hasInstrumentationScope(InstrumentationScopeInfo.create( + OpenTelemetryConstants.INSTRUMENTATION_SCOPE)) + .hasName(CLIENT_CALL_DURATION) + .hasUnit("s") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasCount(1) + .hasSum(0.05) + .hasAttributes(clientAttributes) + .hasBucketBoundaries(latencyBuckets)))); + } + + @Test + public void clientMetrics_endToEnd_nameResolutionFailure_unavailable() throws Exception { + NameResolverProvider failingProvider = new NameResolverProvider() { + @Override + public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + return new NameResolver() { + @Override + public String getServiceAuthority() { + return "failing.authority"; + } + + @Override + public void start(Listener2 listener) { + listener.onError(Status.UNAVAILABLE.withDescription("Name resolution failed")); + } + + @Override + public void shutdown() {} + }; + } + + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return 5; + } + + @Override + public String getDefaultScheme() { + return "failingnr"; + } + + @Override + public String getScheme() { + return getDefaultScheme(); + } + + @Override + public Collection> getProducedSocketAddressTypes() { + return Collections.singleton(InProcessSocketAddress.class); + } + }; + + NameResolverRegistry.getDefaultRegistry().register(failingProvider); + + try { + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); + + String target = "failingnr:///test.service"; + ManagedChannel channel = grpcCleanup.register( + InProcessChannelBuilder.forTarget(target) + .directExecutor() + .intercept(module.getClientInterceptor(target)) + .build()); + + ClientCall call = channel.newCall(method, CallOptions.DEFAULT); + call.start(mockClientCallListener, new Metadata()); + + verify(mockClientCallListener, timeout(5000)) + .onClose(statusCaptor.capture(), any(Metadata.class)); + Status status = statusCaptor.getValue(); + assertEquals(Status.Code.UNAVAILABLE, status.getCode()); + + io.opentelemetry.api.common.Attributes clientAttributes = + io.opentelemetry.api.common.Attributes.of( + TARGET_KEY, target, + METHOD_KEY, method.getFullMethodName(), + STATUS_KEY, Code.UNAVAILABLE.toString()); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> + assertThat(metric) + .hasInstrumentationScope(InstrumentationScopeInfo.create( + OpenTelemetryConstants.INSTRUMENTATION_SCOPE)) + .hasName(CLIENT_CALL_DURATION) + .hasUnit("s") + .hasHistogramSatisfying( + histogram -> + histogram.hasPointsSatisfying( + point -> + point + .hasCount(1) + .hasAttributes(clientAttributes)))); + } finally { + NameResolverRegistry.getDefaultRegistry().deregister(failingProvider); + } + } + + @Test + public void clientMetrics_delayObservabilityDisabled_noDelayMetricsRecorded() { + String target = "target:///"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); + OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + + ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().build(), new Metadata()); + + // When delay observability is disabled or default is unchanged, calls are no-ops + tracer.recordAttemptDelayStart("connecting", "attempt delay reason"); + tracer.recordAttemptDelayReasonChanged("changed reason"); + tracer.recordAttemptDelayEnd(); + + callAttemptsTracerFactory.recordCallDelayStart("resolving", "call delay reason"); + callAttemptsTracerFactory.recordCallDelayReasonChanged("changed call reason"); + callAttemptsTracerFactory.recordCallDelayEnd(); + + assertNotNull(tracer); + } + + @Test + public void clientMetrics_targetAttributeFilter_returnsFilteredOrOther() { + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); + + assertEquals("target:///", module.recordTarget("target:///")); + assertThat(module.recordTarget(null)).isNull(); + } + @Test public void serverMetrics_recordsBaggage_endToEnd() throws Exception { DoubleHistogram mockDurationHistogram = mock(DoubleHistogram.class); @@ -2365,6 +2710,348 @@ public ClientCall interceptCall( "baggage-val-1", capturedBaggage.getEntryValue("baggage-key-1")); } + @Test + public void clientCallDelayDuration_sameDelayType_doesNotResetStopwatch() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + String target = "target:///"; + Map enabledMetrics = ImmutableMap.of( + "grpc.client.call.delay.duration", true + ); + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetrics, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); + OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + + callAttemptsTracerFactory.recordCallDelayStart("resolving", "reason1"); + callAttemptsTracerFactory.recordCallDelayStart("resolving", "reason2"); + // Transition to a different delay type while active (exercises Objects.equals == false) + callAttemptsTracerFactory.recordCallDelayStart("connecting", "transition to connecting"); + callAttemptsTracerFactory.recordCallDelayEnd(); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy(metric -> assertThat(metric).hasName("grpc.client.call.delay.duration")); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void clientCallDelayDuration_nullDelayType_noMetricRecorded() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + String target = "target:///"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); + OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + + callAttemptsTracerFactory.recordCallDelayStart(null, "null delay type"); + callAttemptsTracerFactory.recordCallDelayEnd(); + + assertThat(openTelemetryTesting.getMetrics()) + .noneSatisfy(metric -> assertThat(metric).hasName("grpc.client.call.delay.duration")); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void clientAttemptDelayDuration_sameDelayType_doesNotResetStopwatch() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + String target = "target:///"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); + OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().build(), new Metadata()); + + tracer.recordAttemptDelayStart("connecting", "reason1"); + tracer.recordAttemptDelayStart("connecting", "reason2"); + // Transition to a different delay type while active (exercises Objects.equals == false) + tracer.recordAttemptDelayStart("0:connecting", "transition to different delay type"); + tracer.recordAttemptDelayEnd(); + + assertNotNull(tracer); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void clientAttemptDelayDuration_nullDelayType_noMetricRecorded() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + String target = "target:///"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); + OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().build(), new Metadata()); + + tracer.recordAttemptDelayStart(null, "null delay type"); + tracer.recordAttemptDelayEnd(); + + assertThat(openTelemetryTesting.getMetrics()) + .noneSatisfy(metric -> assertThat(metric).hasName("grpc.client.attempt.delay.duration")); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void clientAttemptDelayDuration_withOptionalLabels() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + String target = "target:///"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), + resource, + Arrays.asList("grpc.lb.locality", "grpc.lb.backend_service"), + emptyList()); + OpenTelemetryMetricsModule.CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().build(), new Metadata()); + + tracer.addOptionalLabel("grpc.lb.locality", "us-east1-a"); + tracer.addOptionalLabel("grpc.lb.backend_service", "backend-service-1"); + + tracer.recordAttemptDelayStart("connecting", "reason1"); + tracer.recordAttemptDelayEnd(); + + assertNotNull(tracer); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void callEnded_beforeAttemptEnded_recordsFinishedCall() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + String target = "target:///"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), + resource, + emptyList(), + emptyList()); + + CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().build(), new Metadata()); + + callAttemptsTracerFactory.callEnded(Status.OK, CALL_OPTIONS); + callAttemptsTracerFactory.attemptEnded(CALL_OPTIONS); + + assertNotNull(tracer); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void serverStreamClosed_calledTwice_secondCallNoOp() { + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), + resource, + emptyList(), + emptyList()); + + ServerStreamTracer.Factory serverTracerFactory = module.getServerTracerFactory(); + ServerStreamTracer serverTracer = + serverTracerFactory.newServerStreamTracer(method.getFullMethodName(), new Metadata()); + + serverTracer.streamClosed(Status.OK); + serverTracer.streamClosed(Status.CANCELLED); + + assertNotNull(serverTracer); + } + + @Test + public void clientCallDelayReasonChanged_noActiveStopwatch_noOp() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + String target = "target:///"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), + resource, + emptyList(), + emptyList()); + + CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + + callAttemptsTracerFactory.recordCallDelayReasonChanged("reasonWithoutStopwatch"); + assertNotNull(callAttemptsTracerFactory); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void clientAttemptDelayReasonChanged_noActiveStopwatch_noOp() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + String target = "target:///"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), + resource, + emptyList(), + emptyList()); + + CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().build(), new Metadata()); + + tracer.recordAttemptDelayReasonChanged("reasonWithoutStopwatch"); + assertNotNull(tracer); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void delayMetrics_featureFlagDisabled_allMethodsNoOp() { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + String target = "target:///"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), + resource, + Arrays.asList("grpc.lb.locality", "grpc.lb.backend_service"), + emptyList()); + + CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().build(), new Metadata()); + + callAttemptsTracerFactory.recordCallDelayStart("resolving", "reason1"); + callAttemptsTracerFactory.recordCallDelayReasonChanged("reason2"); + callAttemptsTracerFactory.recordCallDelayEnd(); + + tracer.recordAttemptDelayStart("connecting", "reason1"); + tracer.recordAttemptDelayReasonChanged("reason2"); + tracer.recordAttemptDelayEnd(); + + assertNotNull(tracer); + } + + @Test + public void clientAttemptDelayDuration_withNullLocalityAndBackendService() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + String target = "target:///"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), + resource, + Arrays.asList("grpc.lb.locality", "grpc.lb.backend_service"), + emptyList()); + CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().build(), new Metadata()); + + // Do NOT set optional labels so locality and backendService remain null + tracer.recordAttemptDelayStart("connecting", "reason1"); + tracer.recordAttemptDelayEnd(); + + assertNotNull(tracer); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void clientAttemptDelayStart_afterStreamClosed_noOp() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + String target = "target:///"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), + resource, + Arrays.asList("grpc.lb.locality", "grpc.lb.backend_service"), + emptyList()); + CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + ClientStreamTracer tracer = callAttemptsTracerFactory.newClientStreamTracer( + ClientStreamTracer.StreamInfo.newBuilder().build(), new Metadata()); + + tracer.streamClosed(Status.OK); + tracer.recordAttemptDelayStart("connecting", "post-close"); + tracer.recordAttemptDelayReasonChanged("changed"); + tracer.recordAttemptDelayEnd(); + + callAttemptsTracerFactory.callEnded(Status.OK, CALL_OPTIONS); + assertNotNull(tracer); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void clientCallDelayStart_afterCallEnded_noOp() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + String target = "target:///"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), + resource, + Arrays.asList("grpc.lb.locality", "grpc.lb.backend_service"), + emptyList()); + CallAttemptsTracerFactory callAttemptsTracerFactory = + new CallAttemptsTracerFactory(module, target, CALL_OPTIONS, method.getFullMethodName(), + emptyList(), Context.root()); + + callAttemptsTracerFactory.callEnded(Status.OK, CALL_OPTIONS); + callAttemptsTracerFactory.recordCallDelayStart("resolving", "post-close"); + callAttemptsTracerFactory.recordCallDelayReasonChanged("changed"); + callAttemptsTracerFactory.recordCallDelayEnd(); + + assertNotNull(callAttemptsTracerFactory); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + private static List sortByName(List metrics) { metrics.sort((m1, m2) -> m1.getName().compareTo(m2.getName())); return metrics; diff --git a/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryTracingModuleTest.java b/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryTracingModuleTest.java index 0b5bff1d036..5ae246ac8d5 100644 --- a/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryTracingModuleTest.java +++ b/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryTracingModuleTest.java @@ -20,6 +20,7 @@ import static io.grpc.opentelemetry.internal.OpenTelemetryConstants.BAGGAGE_KEY; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -46,6 +47,9 @@ import io.grpc.EquivalentAddressGroup; import io.grpc.KnownLength; import io.grpc.LoadBalancer; +import io.grpc.LoadBalancer.PickResult; +import io.grpc.LoadBalancer.PickSubchannelArgs; +import io.grpc.LoadBalancer.SubchannelPicker; import io.grpc.LoadBalancerProvider; import io.grpc.LoadBalancerRegistry; import io.grpc.ManagedChannel; @@ -299,159 +303,154 @@ public void clientBasicTracingMocking() { } @Test - public void clientBasicTracingRule() { - OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( - openTelemetryRule.getOpenTelemetry()); - Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + public void clientDelayTracingMocking() { + Span mockDelaySpan = mock(Span.class); + when(mockSpanBuilder.setAttribute( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString())) + .thenReturn(mockSpanBuilder); + when(mockSpanBuilder.startSpan()).thenReturn(mockAttemptSpan, mockDelaySpan); + + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule(mockOpenTelemetry); CallAttemptsTracerFactory callTracer = - tracingModule.newClientCallTracer(clientSpan, method); - Metadata headers = new Metadata(); - ClientStreamTracer clientStreamTracer = callTracer.newClientStreamTracer(STREAM_INFO, headers); - clientStreamTracer.createPendingStream(); - clientStreamTracer.streamCreated(Attributes.EMPTY, headers); - clientStreamTracer.outboundMessage(0); - clientStreamTracer.outboundMessageSent(0, 882, -1); - clientStreamTracer.inboundMessage(0); - clientStreamTracer.outboundMessage(1); - clientStreamTracer.outboundMessageSent(1, -1, 27); - clientStreamTracer.inboundMessageRead(0, 255, -1); - clientStreamTracer.inboundUncompressedSize(288); - clientStreamTracer.inboundMessageRead(1, 128, 128); - clientStreamTracer.inboundMessage(1); - clientStreamTracer.inboundUncompressedSize(128); + tracingModule.newClientCallTracer(mockClientSpan, method); + ClientStreamTracer clientStreamTracer = + callTracer.newClientStreamTracer(STREAM_INFO, new Metadata()); - clientStreamTracer.streamClosed(Status.OK); - callTracer.callEnded(Status.OK); + clientStreamTracer.recordAttemptDelayStart("connecting", "pick_first: attempting to connect"); + clientStreamTracer.recordAttemptDelayEnd(); - List spans = openTelemetryRule.getSpans(); - assertEquals(spans.size(), 2); - SpanData attemptSpanData = spans.get(0); - SpanData clientSpanData = spans.get(1); - assertEquals(attemptSpanData.getName(), "Attempt.package1.service2.method3"); - assertEquals(clientSpanData.getName(), "test-client-span"); - assertEquals(headers.keys(), ImmutableSet.of("traceparent")); - String spanContext = headers.get( - Metadata.Key.of("traceparent", Metadata.ASCII_STRING_MARSHALLER)); - assertEquals(spanContext.substring(3, 3 + TraceId.getLength()), - spans.get(1).getSpanContext().getTraceId()); + verify(mockTracer).spanBuilder(eq("Delay")); + verify(mockSpanBuilder).setAttribute(eq("grpc.delay_type"), eq("connecting")); + verify(mockDelaySpan).addEvent( + eq("Delay state transition"), + org.mockito.ArgumentMatchers.any()); + verify(mockDelaySpan).end(); + } - // parent(client) span data - List clientSpanEvents = clientSpanData.getEvents(); - assertEquals(clientSpanEvents.size(), 3); - assertEquals( - "Delayed name resolution complete", - clientSpanEvents.get(0).getName()); - assertTrue(clientSpanEvents.get(0).getAttributes().isEmpty()); + @Test + public void clientCallDelayTracingMocking() { + Span mockDelaySpan = mock(Span.class); + when(mockSpanBuilder.setAttribute( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString())) + .thenReturn(mockSpanBuilder); + when(mockSpanBuilder.startSpan()).thenReturn(mockDelaySpan); - assertEquals( - "Inbound message" , - clientSpanEvents.get(1).getName()); - assertEquals( - io.opentelemetry.api.common.Attributes.builder() - .put("sequence-number", 0) - .put("message-size", 288) - .build(), - clientSpanEvents.get(1).getAttributes()); + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule(mockOpenTelemetry); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(mockClientSpan, method); - assertEquals( - "Inbound message" , - clientSpanEvents.get(2).getName()); - assertEquals( - io.opentelemetry.api.common.Attributes.builder() - .put("sequence-number", 1) - .put("message-size", 128) - .build(), - clientSpanEvents.get(2).getAttributes()); - assertEquals(clientSpanData.hasEnded(), true); + callTracer.recordCallDelayStart("resolving", "waiting for DNS query"); + callTracer.recordCallDelayEnd(); - // child(attempt) span data - List attemptSpanEvents = attemptSpanData.getEvents(); - assertEquals(clientSpanEvents.size(), 3); - assertEquals( - "Delayed LB pick complete", - attemptSpanEvents.get(0).getName()); - assertTrue(clientSpanEvents.get(0).getAttributes().isEmpty()); + verify(mockTracer).spanBuilder(eq("Delay")); + verify(mockSpanBuilder).setAttribute(eq("grpc.delay_type"), eq("resolving")); + verify(mockDelaySpan).addEvent( + eq("Delay state transition"), + org.mockito.ArgumentMatchers.any()); + verify(mockDelaySpan).end(); + } - assertEquals( - "Outbound message" , - attemptSpanEvents.get(1).getName()); - assertEquals( - io.opentelemetry.api.common.Attributes.builder() - .put("sequence-number", 0) - .put("message-size-compressed", 882) - .build(), - attemptSpanEvents.get(1).getAttributes()); + @Test + public void clientCallDelayTracing_endToEnd_nameResolutionDelay() throws Exception { + final CountDownLatch resolutionLatch = new CountDownLatch(1); + final AtomicReference capturedListener = new AtomicReference<>(); - assertEquals( - "Outbound message" , - attemptSpanEvents.get(2).getName()); - assertEquals( - io.opentelemetry.api.common.Attributes.builder() - .put("sequence-number", 1) - .put("message-size", 27) - .build(), - attemptSpanEvents.get(2).getAttributes()); + NameResolverProvider slowResolverProvider = new NameResolverProvider() { + @Override + protected boolean isAvailable() { + return true; + } - assertEquals( - "Inbound compressed message" , - attemptSpanEvents.get(3).getName()); - assertEquals( - io.opentelemetry.api.common.Attributes.builder() - .put("sequence-number", 0) - .put("message-size-compressed", 255) - .build(), - attemptSpanEvents.get(3).getAttributes()); + @Override + protected int priority() { + return 5; + } - assertEquals(attemptSpanData.hasEnded(), true); - } + @Override + public String getDefaultScheme() { + return "slowres"; + } - @Test - public void clientAttemptDelayTracing_reasonChangedInvariant() { - OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( - openTelemetryRule.getOpenTelemetry()); - Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); - CallAttemptsTracerFactory callTracer = - tracingModule.newClientCallTracer(clientSpan, method); - ClientStreamTracer clientStreamTracer = - callTracer.newClientStreamTracer(STREAM_INFO, new Metadata()); + @Override + public Collection> getProducedSocketAddressTypes() { + return Collections.singleton(InProcessSocketAddress.class); + } - clientStreamTracer.recordAttemptDelayStart("connecting", "reason1"); - clientStreamTracer.recordAttemptDelayReasonChanged("reason2"); - clientStreamTracer.recordAttemptDelayStart("connecting", "reason3"); - clientStreamTracer.recordAttemptDelayEnd(); - clientStreamTracer.streamClosed(Status.OK); - callTracer.callEnded(Status.OK); - clientSpan.end(); + @Override + public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + return new NameResolver() { + @Override + public String getServiceAuthority() { + return "slowres"; + } - List spans = openTelemetryRule.getSpans(); - assertEquals(3, spans.size()); - SpanData delaySpanData = spans.get(0); + @Override + public void start(Listener2 listener) { + capturedListener.set(listener); + resolutionLatch.countDown(); + } - assertEquals("Attempt Delay", delaySpanData.getName()); - assertEquals("connecting", delaySpanData.getAttributes().get( - AttributeKey.stringKey("grpc.delay_type"))); - assertEquals(3, delaySpanData.getEvents().size()); + @Override + public void shutdown() {} + }; + } + }; + NameResolverRegistry.getDefaultRegistry().register(slowResolverProvider); - EventData event1 = delaySpanData.getEvents().get(0); - assertEquals("Delay state transition", event1.getName()); - assertEquals("connecting", event1.getAttributes().get( - AttributeKey.stringKey("grpc.delay_type"))); - assertEquals("reason1", event1.getAttributes().get( - AttributeKey.stringKey("grpc.delay_reason"))); + GrpcOpenTelemetry grpcOpenTelemetry = GrpcOpenTelemetry.newBuilder() + .sdk(openTelemetryRule.getOpenTelemetry()) + .enableTracing(true) + .build(); - EventData event2 = delaySpanData.getEvents().get(1); - assertEquals("Delay state transition", event2.getName()); - assertEquals("connecting", event2.getAttributes().get( - AttributeKey.stringKey("grpc.delay_type"))); - assertEquals("reason2", event2.getAttributes().get( - AttributeKey.stringKey("grpc.delay_reason"))); + InProcessChannelBuilder channelBuilder = + InProcessChannelBuilder.forTarget("slowres:///test-service") + .defaultLoadBalancingPolicy("pick_first"); + grpcOpenTelemetry.configureChannelBuilder(channelBuilder); + ManagedChannel channel = channelBuilder.build(); + try { + ClientCall call = channel.newCall(method, CallOptions.DEFAULT); + call.start(new ClientCall.Listener() {}, new Metadata()); + call.request(1); - EventData event3 = delaySpanData.getEvents().get(2); - assertEquals("Delay state transition", event3.getName()); - assertEquals("connecting", event3.getAttributes().get( - AttributeKey.stringKey("grpc.delay_type"))); - assertEquals("reason3", event3.getAttributes().get( - AttributeKey.stringKey("grpc.delay_reason"))); + resolutionLatch.await(5, TimeUnit.SECONDS); + + // Now complete name resolution + capturedListener.get().onResult(NameResolver.ResolutionResult.newBuilder() + .setAddressesOrError(StatusOr.fromValue(Collections.singletonList( + new EquivalentAddressGroup(new InProcessSocketAddress("test-slow-res"))))) + .build()); + + call.cancel("End test", null); + } finally { + channel.shutdownNow(); + channel.awaitTermination(5, TimeUnit.SECONDS); + NameResolverRegistry.getDefaultRegistry().deregister(slowResolverProvider); + } + + List spans = openTelemetryRule.getSpans(); + SpanData callDelaySpan = null; + for (SpanData s : spans) { + if ("Delay".equals(s.getName())) { + callDelaySpan = s; + break; + } + } + assertNotNull(callDelaySpan); + assertEquals("resolving", + callDelaySpan.getAttributes().get(AttributeKey.stringKey("grpc.delay_type"))); + + boolean foundTransition = false; + for (EventData event : callDelaySpan.getEvents()) { + if ("Delay state transition".equals(event.getName()) + && "waiting for name resolution or service config".equals( + event.getAttributes().get(AttributeKey.stringKey("grpc.delay_reason")))) { + foundTransition = true; + break; + } + } + assertTrue(foundTransition); } @Test @@ -530,7 +529,7 @@ public String getServiceAuthority() { @Override public void start(Listener2 listener) { - listener.onResult(ResolutionResult.newBuilder() + listener.onResult(NameResolver.ResolutionResult.newBuilder() .setAddressesOrError(StatusOr.fromValue(Collections.singletonList( new EquivalentAddressGroup(new InProcessSocketAddress("test-e2e"))))) .build()); @@ -570,7 +569,7 @@ public void shutdown() {} List spans = openTelemetryRule.getSpans(); SpanData delaySpanData = null; for (SpanData s : spans) { - if ("Attempt Delay".equals(s.getName())) { + if ("Delay".equals(s.getName())) { delaySpanData = s; break; } @@ -592,30 +591,476 @@ public void shutdown() {} } @Test - public void clientAttemptDelayStart_featureFlagDisabled_zeroChildSpans() { - System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "false"); - try { - OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( - openTelemetryRule.getOpenTelemetry()); - Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); - CallAttemptsTracerFactory callTracer = - tracingModule.newClientCallTracer(clientSpan, method); - ClientStreamTracer clientStreamTracer = - callTracer.newClientStreamTracer(STREAM_INFO, new Metadata()); + public void clientBasicTracingRule() { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + Metadata headers = new Metadata(); + ClientStreamTracer clientStreamTracer = callTracer.newClientStreamTracer(STREAM_INFO, headers); + clientStreamTracer.createPendingStream(); + clientStreamTracer.streamCreated(Attributes.EMPTY, headers); + clientStreamTracer.outboundMessage(0); + clientStreamTracer.outboundMessageSent(0, 882, -1); + clientStreamTracer.inboundMessage(0); + clientStreamTracer.outboundMessage(1); + clientStreamTracer.outboundMessageSent(1, -1, 27); + clientStreamTracer.inboundMessageRead(0, 255, -1); + clientStreamTracer.inboundUncompressedSize(288); + clientStreamTracer.inboundMessageRead(1, 128, 128); + clientStreamTracer.inboundMessage(1); + clientStreamTracer.inboundUncompressedSize(128); - clientStreamTracer.recordAttemptDelayStart("connecting", "attempting to connect"); - clientStreamTracer.recordAttemptDelayEnd(); - clientStreamTracer.streamClosed(Status.OK); - callTracer.callEnded(Status.OK); - clientSpan.end(); + clientStreamTracer.streamClosed(Status.OK); + callTracer.callEnded(Status.OK); - List spans = openTelemetryRule.getSpans(); - assertEquals(2, spans.size()); - for (SpanData span : spans) { - assertTrue(!span.getName().equals("Attempt Delay")); - } - } finally { - System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + List spans = openTelemetryRule.getSpans(); + assertEquals(spans.size(), 2); + SpanData attemptSpanData = spans.get(0); + SpanData clientSpanData = spans.get(1); + assertEquals(attemptSpanData.getName(), "Attempt.package1.service2.method3"); + assertEquals(clientSpanData.getName(), "test-client-span"); + assertEquals(headers.keys(), ImmutableSet.of("traceparent")); + String spanContext = headers.get( + Metadata.Key.of("traceparent", Metadata.ASCII_STRING_MARSHALLER)); + assertEquals(spanContext.substring(3, 3 + TraceId.getLength()), + spans.get(1).getSpanContext().getTraceId()); + + // parent(client) span data + List clientSpanEvents = clientSpanData.getEvents(); + assertEquals(clientSpanEvents.size(), 3); + assertEquals( + "Delayed name resolution complete", + clientSpanEvents.get(0).getName()); + assertTrue(clientSpanEvents.get(0).getAttributes().isEmpty()); + + assertEquals( + "Inbound message" , + clientSpanEvents.get(1).getName()); + assertEquals( + io.opentelemetry.api.common.Attributes.builder() + .put("sequence-number", 0) + .put("message-size", 288) + .build(), + clientSpanEvents.get(1).getAttributes()); + + assertEquals( + "Inbound message" , + clientSpanEvents.get(2).getName()); + assertEquals( + io.opentelemetry.api.common.Attributes.builder() + .put("sequence-number", 1) + .put("message-size", 128) + .build(), + clientSpanEvents.get(2).getAttributes()); + assertEquals(clientSpanData.hasEnded(), true); + + // child(attempt) span data + List attemptSpanEvents = attemptSpanData.getEvents(); + assertEquals(clientSpanEvents.size(), 3); + assertEquals( + "Delayed LB pick complete", + attemptSpanEvents.get(0).getName()); + assertTrue(clientSpanEvents.get(0).getAttributes().isEmpty()); + + assertEquals( + "Outbound message" , + attemptSpanEvents.get(1).getName()); + assertEquals( + io.opentelemetry.api.common.Attributes.builder() + .put("sequence-number", 0) + .put("message-size-compressed", 882) + .build(), + attemptSpanEvents.get(1).getAttributes()); + + assertEquals( + "Outbound message" , + attemptSpanEvents.get(2).getName()); + assertEquals( + io.opentelemetry.api.common.Attributes.builder() + .put("sequence-number", 1) + .put("message-size", 27) + .build(), + attemptSpanEvents.get(2).getAttributes()); + + assertEquals( + "Inbound compressed message" , + attemptSpanEvents.get(3).getName()); + assertEquals( + io.opentelemetry.api.common.Attributes.builder() + .put("sequence-number", 0) + .put("message-size-compressed", 255) + .build(), + attemptSpanEvents.get(3).getAttributes()); + + assertEquals(attemptSpanData.hasEnded(), true); + } + + @Test + public void clientAttemptDelayTracing_reasonChangedInvariant() { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + ClientStreamTracer clientStreamTracer = + callTracer.newClientStreamTracer(STREAM_INFO, new Metadata()); + + clientStreamTracer.recordAttemptDelayStart("connecting", "reason1"); + clientStreamTracer.recordAttemptDelayReasonChanged("reason2"); + clientStreamTracer.recordAttemptDelayStart("connecting", "reason3"); + clientStreamTracer.recordAttemptDelayEnd(); + clientStreamTracer.streamClosed(Status.OK); + callTracer.callEnded(Status.OK); + clientSpan.end(); + + List spans = openTelemetryRule.getSpans(); + assertEquals(3, spans.size()); + SpanData delaySpanData = spans.get(0); + + assertEquals("Delay", delaySpanData.getName()); + assertEquals("connecting", delaySpanData.getAttributes().get( + AttributeKey.stringKey("grpc.delay_type"))); + assertEquals(3, delaySpanData.getEvents().size()); + + EventData event1 = delaySpanData.getEvents().get(0); + assertEquals("Delay state transition", event1.getName()); + assertEquals("reason1", event1.getAttributes().get( + AttributeKey.stringKey("grpc.delay_reason"))); + assertNull(event1.getAttributes().get(AttributeKey.stringKey("grpc.delay_type"))); + + EventData event2 = delaySpanData.getEvents().get(1); + assertEquals("Delay state transition", event2.getName()); + assertEquals("reason2", event2.getAttributes().get( + AttributeKey.stringKey("grpc.delay_reason"))); + assertNull(event2.getAttributes().get(AttributeKey.stringKey("grpc.delay_type"))); + + EventData event3 = delaySpanData.getEvents().get(2); + assertEquals("Delay state transition", event3.getName()); + assertEquals("reason3", event3.getAttributes().get( + AttributeKey.stringKey("grpc.delay_reason"))); + assertNull(event3.getAttributes().get(AttributeKey.stringKey("grpc.delay_type"))); + } + + @Test + public void clientAttemptDelayStart_featureFlagDisabled_zeroChildSpans() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "false"); + try { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + ClientStreamTracer clientStreamTracer = + callTracer.newClientStreamTracer(STREAM_INFO, new Metadata()); + + clientStreamTracer.recordAttemptDelayStart("connecting", "attempting to connect"); + clientStreamTracer.recordAttemptDelayEnd(); + clientStreamTracer.streamClosed(Status.OK); + callTracer.callEnded(Status.OK); + clientSpan.end(); + + List spans = openTelemetryRule.getSpans(); + assertEquals(2, spans.size()); + for (SpanData span : spans) { + assertTrue(!span.getName().equals("Delay")); + } + } finally { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + } + } + + @Test + public void clientCallDelayTracing_reasonChangedInvariant() { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + + callTracer.recordCallDelayStart("resolving", "reason1"); + callTracer.recordCallDelayReasonChanged("reason2"); + callTracer.recordCallDelayStart("resolving", "reason3"); + callTracer.recordCallDelayEnd(); + callTracer.callEnded(Status.OK); + clientSpan.end(); + + List spans = openTelemetryRule.getSpans(); + assertEquals(2, spans.size()); + SpanData callDelaySpan = spans.stream() + .filter(s -> "Delay".equals(s.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("Expected 'Delay' span not found")); + + assertEquals("resolving", callDelaySpan.getAttributes().get( + AttributeKey.stringKey("grpc.delay_type"))); + assertEquals(3, callDelaySpan.getEvents().size()); + + EventData event1 = callDelaySpan.getEvents().get(0); + assertEquals("Delay state transition", event1.getName()); + assertEquals("reason1", + event1.getAttributes().get(AttributeKey.stringKey("grpc.delay_reason"))); + assertNull(event1.getAttributes().get(AttributeKey.stringKey("grpc.delay_type"))); + + EventData event2 = callDelaySpan.getEvents().get(1); + assertEquals("Delay state transition", event2.getName()); + assertEquals("reason2", + event2.getAttributes().get(AttributeKey.stringKey("grpc.delay_reason"))); + assertNull(event2.getAttributes().get(AttributeKey.stringKey("grpc.delay_type"))); + + EventData event3 = callDelaySpan.getEvents().get(2); + assertEquals("Delay state transition", event3.getName()); + assertEquals("reason3", + event3.getAttributes().get(AttributeKey.stringKey("grpc.delay_reason"))); + assertNull(event3.getAttributes().get(AttributeKey.stringKey("grpc.delay_type"))); + } + + @Test + public void clientCallDelayStart_afterCallEnded_noSpansRecorded() { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + + callTracer.callEnded(Status.OK); + callTracer.recordCallDelayStart("resolving", "reasonAfterCallEnded"); + callTracer.recordCallDelayReasonChanged("reason2"); + callTracer.recordCallDelayEnd(); + clientSpan.end(); + + List spans = openTelemetryRule.getSpans(); + for (SpanData span : spans) { + assertTrue(!span.getName().equals("Delay")); + } + } + + @Test + public void clientAttemptDelayStart_afterStreamClosed_noSpansRecorded() { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + ClientStreamTracer clientStreamTracer = + callTracer.newClientStreamTracer(STREAM_INFO, new Metadata()); + + clientStreamTracer.streamClosed(Status.OK); + clientStreamTracer.recordAttemptDelayStart("connecting", "reasonAfterStreamClosed"); + clientStreamTracer.recordAttemptDelayReasonChanged("reason2"); + clientStreamTracer.recordAttemptDelayEnd(); + callTracer.callEnded(Status.OK); + clientSpan.end(); + + List spans = openTelemetryRule.getSpans(); + for (SpanData span : spans) { + assertTrue(!span.getName().equals("Delay")); + } + } + + @Test + public void clientCallDelayStart_delayTypeTransition_closesPreviousSpan() { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + + callTracer.recordCallDelayStart("resolving", "dns lookup"); + callTracer.recordCallDelayStart("connecting", "pick first connect"); + callTracer.recordCallDelayEnd(); + callTracer.callEnded(Status.OK); + clientSpan.end(); + + List spans = openTelemetryRule.getSpans(); + long callDelaySpanCount = spans.stream().filter(s -> "Delay".equals(s.getName())).count(); + assertEquals(2L, callDelaySpanCount); + } + + @Test + public void clientCallEnded_calledTwice_secondCallNoOp() { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + + callTracer.callEnded(Status.OK); + callTracer.callEnded(Status.CANCELLED); + clientSpan.end(); + + List spans = openTelemetryRule.getSpans(); + assertNotNull(spans); + } + + @Test + public void clientStreamClosed_calledTwice_secondCallNoOp() { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + ClientStreamTracer clientStreamTracer = + callTracer.newClientStreamTracer(STREAM_INFO, new Metadata()); + + clientStreamTracer.streamClosed(Status.OK); + clientStreamTracer.streamClosed(Status.CANCELLED); + callTracer.callEnded(Status.OK); + clientSpan.end(); + + List spans = openTelemetryRule.getSpans(); + assertNotNull(spans); + } + + @Test + public void serverStreamClosed_calledTwice_secondCallNoOp() { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + ServerStreamTracer.Factory serverTracerFactory = + tracingModule.getServerTracerFactory(); + ServerStreamTracer serverTracer = + serverTracerFactory.newServerStreamTracer(method.getFullMethodName(), new Metadata()); + + serverTracer.streamClosed(Status.OK); + serverTracer.streamClosed(Status.CANCELLED); + + List spans = openTelemetryRule.getSpans(); + assertNotNull(spans); + } + + @Test + public void clientCallDelayReasonChanged_noActiveSpan_noOp() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + + callTracer.recordCallDelayReasonChanged("reasonWithoutSpan"); + callTracer.callEnded(Status.OK); + clientSpan.end(); + + List spans = openTelemetryRule.getSpans(); + assertNotNull(spans); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void clientAttemptDelayReasonChanged_noActiveSpan_noOp() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + ClientStreamTracer clientStreamTracer = + callTracer.newClientStreamTracer(STREAM_INFO, new Metadata()); + + clientStreamTracer.recordAttemptDelayReasonChanged("reasonWithoutSpan"); + clientStreamTracer.streamClosed(Status.OK); + callTracer.callEnded(Status.OK); + clientSpan.end(); + + List spans = openTelemetryRule.getSpans(); + assertNotNull(spans); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void clientCallDelayReasonChanged_nullDelayType_omitsDelayTypeAttribute() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + + callTracer.recordCallDelayStart(null, "start reason"); + callTracer.recordCallDelayReasonChanged("changed reason"); + callTracer.recordCallDelayEnd(); + callTracer.callEnded(Status.OK); + clientSpan.end(); + + List spans = openTelemetryRule.getSpans(); + assertEquals(2, spans.size()); + SpanData delaySpan = spans.get(0); + assertEquals(2, delaySpan.getEvents().size()); + EventData event = delaySpan.getEvents().get(1); + assertNull(event.getAttributes().get(AttributeKey.stringKey("grpc.delay_type"))); + assertEquals("changed reason", + event.getAttributes().get(AttributeKey.stringKey("grpc.delay_reason"))); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void clientAttemptDelayReasonChanged_nullDelayType_omitsDelayTypeAttribute() { + System.setProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY", "true"); + try { + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + ClientStreamTracer clientStreamTracer = + callTracer.newClientStreamTracer(STREAM_INFO, new Metadata()); + + clientStreamTracer.recordAttemptDelayStart(null, "start reason"); + clientStreamTracer.recordAttemptDelayReasonChanged("changed reason"); + clientStreamTracer.recordAttemptDelayEnd(); + clientStreamTracer.streamClosed(Status.OK); + callTracer.callEnded(Status.OK); + clientSpan.end(); + + List spans = openTelemetryRule.getSpans(); + assertEquals(3, spans.size()); + SpanData delaySpan = spans.get(0); + assertEquals(2, delaySpan.getEvents().size()); + EventData event = delaySpan.getEvents().get(1); + assertNull(event.getAttributes().get(AttributeKey.stringKey("grpc.delay_type"))); + assertEquals("changed reason", + event.getAttributes().get(AttributeKey.stringKey("grpc.delay_reason"))); + } finally { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + } + } + + @Test + public void delayTracing_featureFlagDisabled_allMethodsNoOp() { + System.clearProperty("GRPC_EXPERIMENTAL_ENABLE_DELAY_OBSERVABILITY"); + OpenTelemetryTracingModule tracingModule = new OpenTelemetryTracingModule( + openTelemetryRule.getOpenTelemetry()); + Span clientSpan = tracerRule.spanBuilder("test-client-span").startSpan(); + CallAttemptsTracerFactory callTracer = + tracingModule.newClientCallTracer(clientSpan, method); + ClientStreamTracer clientStreamTracer = + callTracer.newClientStreamTracer(STREAM_INFO, new Metadata()); + + callTracer.recordCallDelayStart("resolving", "reason1"); + callTracer.recordCallDelayReasonChanged("reason2"); + callTracer.recordCallDelayEnd(); + + clientStreamTracer.recordAttemptDelayStart("connecting", "reason1"); + clientStreamTracer.recordAttemptDelayReasonChanged("reason2"); + clientStreamTracer.recordAttemptDelayEnd(); + + clientStreamTracer.streamClosed(Status.OK); + callTracer.callEnded(Status.OK); + clientSpan.end(); + + List spans = openTelemetryRule.getSpans(); + for (SpanData span : spans) { + assertTrue(!span.getName().equals("Delay")); } } diff --git a/util/src/test/java/io/grpc/util/ForwardingClientStreamTracerTest.java b/util/src/test/java/io/grpc/util/ForwardingClientStreamTracerTest.java index dbd7e99b29a..3b5cd3714e6 100644 --- a/util/src/test/java/io/grpc/util/ForwardingClientStreamTracerTest.java +++ b/util/src/test/java/io/grpc/util/ForwardingClientStreamTracerTest.java @@ -17,6 +17,7 @@ package io.grpc.util; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import io.grpc.ClientStreamTracer; import io.grpc.ForwardingTestUtil; @@ -40,6 +41,19 @@ public void allMethodsForwarded() throws Exception { Collections.emptyList()); } + @Test + public void attemptDelayMethodsForwarded() { + TestClientStreamTracer tracer = new TestClientStreamTracer(); + tracer.recordAttemptDelayStart("connecting", "test"); + verify(mockDelegate).recordAttemptDelayStart("connecting", "test"); + + tracer.recordAttemptDelayReasonChanged("test2"); + verify(mockDelegate).recordAttemptDelayReasonChanged("test2"); + + tracer.recordAttemptDelayEnd(); + verify(mockDelegate).recordAttemptDelayEnd(); + } + @SuppressWarnings("deprecation") private final class TestClientStreamTracer extends ForwardingClientStreamTracer { @Override