core,opentelemetry: Fix server metric labels on early close (#12774)
This addresses the server-side OpenTelemetry metric labeling bug from
#12117 where a generated method can be recorded as `grpc.method="other"`
if `streamClosed()` happens before `serverCallStarted()`.
### What changed
- add an internal `StatsTraceContext.ServerCallMethodListener` hook so
tracers can consume an already-resolved primary-registry
`MethodDescriptor`
- resolve the immutable internal primary registry on the transport path
and seed method classification before the async `MethodLookup` path runs
- keep fallback registry lookup on the existing async path
- update the OpenTelemetry server tracer to use the early-resolved
method classification for close metrics
### Why this shape
- avoids tracer-side `HandlerRegistry` lookup
- uses only the immutable internal primary registry for early
transport-path lookup
- keeps fallback registry lookup on the existing async path
### Tests
- primary generated method: early close preserves the generated method
name
- primary non-generated method: early close still records `other`
- fallback generated method: fallback lookup remains on the existing
async path and does not introduce early transport-path classification
- tracer-level regression: `serverCallMethodResolved()` +
`streamClosed()` records the generated method name without waiting for
`serverCallStarted()`
### Notes
- `ServerCallMethodListener` is an internal hook that carries the
resolved `MethodDescriptor`; tracers consume the resolved result instead
of performing registry lookup themselves
- `ServerImpl` uses `InternalHandlerRegistry` explicitly for the primary
registry to make it clear that the early transport- path lookup is
limited to the immutable internal primary registry
- this PR intentionally does not widen transport-path lookup to the
fallback registry
Ref #12117
diff --git a/core/src/main/java/io/grpc/internal/ServerImpl.java b/core/src/main/java/io/grpc/internal/ServerImpl.java
index d469fdb..d9f64c2 100644
--- a/core/src/main/java/io/grpc/internal/ServerImpl.java
+++ b/core/src/main/java/io/grpc/internal/ServerImpl.java
@@ -99,7 +99,7 @@
private final ObjectPool<? extends Executor> executorPool;
/** Executor for application processing. Safe to read after {@link #start()}. */
private Executor executor;
- private final HandlerRegistry registry;
+ private final InternalHandlerRegistry registry;
private final HandlerRegistry fallbackRegistry;
private final List<ServerTransportFilter> transportFilters;
// This is iterated on a per-call basis. Use an array instead of a Collection to avoid iterator
@@ -498,8 +498,12 @@
final StatsTraceContext statsTraceCtx = Preconditions.checkNotNull(
stream.statsTraceContext(), "statsTraceCtx not present from stream");
+ final ServerMethodDefinition<?, ?> primaryMethod = registry.lookupMethod(methodName, null);
final Context.CancellableContext context = createContext(headers, statsTraceCtx);
+ if (primaryMethod != null) {
+ statsTraceCtx.serverCallMethodResolved(primaryMethod.getMethodDescriptor());
+ }
final Link link = PerfMark.linkOut();
@@ -536,7 +540,7 @@
ServerMethodDefinition<?, ?> wrapMethod;
ServerCallParameters<?, ?> callParams;
try {
- ServerMethodDefinition<?, ?> method = registry.lookupMethod(methodName);
+ ServerMethodDefinition<?, ?> method = primaryMethod;
if (method == null) {
method = fallbackRegistry.lookupMethod(methodName, stream.getAuthority());
}
diff --git a/core/src/main/java/io/grpc/internal/StatsTraceContext.java b/core/src/main/java/io/grpc/internal/StatsTraceContext.java
index 650f0b9..007aefc 100644
--- a/core/src/main/java/io/grpc/internal/StatsTraceContext.java
+++ b/core/src/main/java/io/grpc/internal/StatsTraceContext.java
@@ -23,6 +23,7 @@
import io.grpc.ClientStreamTracer;
import io.grpc.Context;
import io.grpc.Metadata;
+import io.grpc.MethodDescriptor;
import io.grpc.ServerStreamTracer;
import io.grpc.ServerStreamTracer.ServerCallInfo;
import io.grpc.Status;
@@ -38,6 +39,14 @@
*/
@ThreadSafe
public final class StatsTraceContext {
+ /**
+ * Internal hook for server tracers that can use the resolved method descriptor before
+ * {@link ServerStreamTracer#serverCallStarted(ServerCallInfo)} runs.
+ */
+ public interface ServerCallMethodListener {
+ void serverCallMethodResolved(MethodDescriptor<?, ?> method);
+ }
+
public static final StatsTraceContext NOOP = new StatsTraceContext(new StreamTracer[0]);
private final StreamTracer[] tracers;
@@ -145,6 +154,20 @@
}
/**
+ * Notifies server tracers that a primary-registry method descriptor was resolved before
+ * {@link ServerStreamTracer#serverCallStarted(ServerCallInfo)}.
+ *
+ * <p>Called from {@link io.grpc.internal.ServerImpl}.
+ */
+ public void serverCallMethodResolved(MethodDescriptor<?, ?> method) {
+ for (StreamTracer tracer : tracers) {
+ if (tracer instanceof ServerCallMethodListener) {
+ ((ServerCallMethodListener) tracer).serverCallMethodResolved(method);
+ }
+ }
+ }
+
+ /**
* See {@link StreamTracer#streamClosed}. This may be called multiple times, and only the first
* value will be taken.
*
diff --git a/core/src/test/java/io/grpc/internal/ServerImplTest.java b/core/src/test/java/io/grpc/internal/ServerImplTest.java
index 3405cb9..91969dd 100644
--- a/core/src/test/java/io/grpc/internal/ServerImplTest.java
+++ b/core/src/test/java/io/grpc/internal/ServerImplTest.java
@@ -129,6 +129,10 @@
.setRequestMarshaller(STRING_MARSHALLER)
.setResponseMarshaller(INTEGER_MARSHALLER)
.build();
+ private static final MethodDescriptor<String, Integer> GENERATED_METHOD =
+ METHOD.toBuilder()
+ .setSampledToLocalTracing(true)
+ .build();
private static final Context.Key<String> SERVER_ONLY = Context.key("serverOnly");
private static final Context.Key<String> SERVER_TRACER_ADDED_KEY = Context.key("tracer-added");
private static final Context.CancellableContext SERVER_CONTEXT =
@@ -142,6 +146,60 @@
};
private static final String AUTHORITY = "some_authority";
+ private static final class MethodNameCapturingTracer extends ServerStreamTracer
+ implements StatsTraceContext.ServerCallMethodListener {
+ @Nullable private ServerCallInfo<?, ?> serverCallInfo;
+ @Nullable private String recordedMethodName;
+ @Nullable private String resolvedMethodName;
+ private boolean streamClosed;
+
+ @Override
+ public synchronized void serverCallMethodResolved(MethodDescriptor<?, ?> method) {
+ resolvedMethodName =
+ recordMethodName(method.isSampledToLocalTracing(), method.getFullMethodName());
+ }
+
+ @Override
+ public synchronized void streamClosed(Status status) {
+ streamClosed = true;
+ if (serverCallInfo != null) {
+ recordedMethodName =
+ recordMethodName(
+ serverCallInfo.getMethodDescriptor().isSampledToLocalTracing(),
+ serverCallInfo.getMethodDescriptor().getFullMethodName());
+ } else if (resolvedMethodName != null) {
+ recordedMethodName = resolvedMethodName;
+ } else {
+ recordedMethodName = "other";
+ }
+ }
+
+ @Override
+ public synchronized void serverCallStarted(ServerCallInfo<?, ?> callInfo) {
+ serverCallInfo = callInfo;
+ if (streamClosed) {
+ recordedMethodName =
+ recordMethodName(
+ callInfo.getMethodDescriptor().isSampledToLocalTracing(),
+ callInfo.getMethodDescriptor().getFullMethodName());
+ }
+ }
+
+ @Nullable
+ synchronized ServerCallInfo<?, ?> getServerCallInfo() {
+ return serverCallInfo;
+ }
+
+ @Nullable
+ synchronized String getRecordedMethodName() {
+ return recordedMethodName;
+ }
+
+ private static String recordMethodName(boolean generatedMethod, String fullMethodName) {
+ return generatedMethod ? fullMethodName : "other";
+ }
+ }
+
@Rule public final MockitoRule mocks = MockitoJUnit.rule();
@BeforeClass
@@ -462,6 +520,172 @@
assertEquals(Status.Code.UNIMPLEMENTED, statusCaptor.getValue().getCode());
}
+ @Test
+ public void primaryRegistryGeneratedMethod_streamClosedBeforeStart_preservesMethodName()
+ throws Exception {
+ MethodNameCapturingTracer methodNameTracer = new MethodNameCapturingTracer();
+ streamTracerFactories =
+ Collections.singletonList(
+ new ServerStreamTracer.Factory() {
+ @Override
+ public ServerStreamTracer newServerStreamTracer(
+ String fullMethodName, Metadata headers) {
+ return methodNameTracer;
+ }
+ });
+ builder.addService(
+ ServerServiceDefinition.builder(new ServiceDescriptor("Waiter", GENERATED_METHOD))
+ .addMethod(
+ GENERATED_METHOD,
+ new ServerCallHandler<String, Integer>() {
+ @Override
+ public ServerCall.Listener<String> startCall(
+ ServerCall<String, Integer> call, Metadata headers) {
+ return callListener;
+ }
+ })
+ .build());
+
+ createAndStartServer();
+ ServerTransportListener transportListener
+ = transportServer.registerNewServerTransport(new SimpleServerTransport());
+ transportListener.transportReady(Attributes.EMPTY);
+ Metadata requestHeaders = new Metadata();
+ StatsTraceContext statsTraceCtx =
+ StatsTraceContext.newServerContext(
+ streamTracerFactories, GENERATED_METHOD.getFullMethodName(), requestHeaders);
+ when(stream.getAttributes()).thenReturn(Attributes.EMPTY);
+ when(stream.statsTraceContext()).thenReturn(statsTraceCtx);
+
+ transportListener.streamCreated(stream, GENERATED_METHOD.getFullMethodName(), requestHeaders);
+ verify(stream).setListener(isA(ServerStreamListener.class));
+ verify(stream, atLeast(1)).statsTraceContext();
+
+ statsTraceCtx.streamClosed(Status.CANCELLED);
+ assertNull(methodNameTracer.getServerCallInfo());
+ assertEquals(
+ GENERATED_METHOD.getFullMethodName(),
+ methodNameTracer.getRecordedMethodName());
+
+ assertEquals(1, executor.runDueTasks());
+
+ assertNotNull(methodNameTracer.getServerCallInfo());
+ assertSame(GENERATED_METHOD, methodNameTracer.getServerCallInfo().getMethodDescriptor());
+ assertEquals(
+ GENERATED_METHOD.getFullMethodName(),
+ methodNameTracer.getRecordedMethodName());
+ verify(fallbackRegistry, never()).lookupMethod(anyString(), any());
+ }
+
+ @Test
+ public void primaryRegistryNonGeneratedMethod_streamClosedBeforeStart_recordsOther()
+ throws Exception {
+ MethodNameCapturingTracer methodNameTracer = new MethodNameCapturingTracer();
+ streamTracerFactories =
+ Collections.singletonList(
+ new ServerStreamTracer.Factory() {
+ @Override
+ public ServerStreamTracer newServerStreamTracer(
+ String fullMethodName, Metadata headers) {
+ return methodNameTracer;
+ }
+ });
+ builder.addService(
+ ServerServiceDefinition.builder(new ServiceDescriptor("Waiter", METHOD))
+ .addMethod(
+ METHOD,
+ new ServerCallHandler<String, Integer>() {
+ @Override
+ public ServerCall.Listener<String> startCall(
+ ServerCall<String, Integer> call, Metadata headers) {
+ return callListener;
+ }
+ })
+ .build());
+
+ createAndStartServer();
+ ServerTransportListener transportListener
+ = transportServer.registerNewServerTransport(new SimpleServerTransport());
+ transportListener.transportReady(Attributes.EMPTY);
+ Metadata requestHeaders = new Metadata();
+ StatsTraceContext statsTraceCtx =
+ StatsTraceContext.newServerContext(
+ streamTracerFactories, METHOD.getFullMethodName(), requestHeaders);
+ when(stream.getAttributes()).thenReturn(Attributes.EMPTY);
+ when(stream.statsTraceContext()).thenReturn(statsTraceCtx);
+
+ transportListener.streamCreated(stream, METHOD.getFullMethodName(), requestHeaders);
+ verify(stream).setListener(isA(ServerStreamListener.class));
+ verify(stream, atLeast(1)).statsTraceContext();
+
+ statsTraceCtx.streamClosed(Status.CANCELLED);
+ assertNull(methodNameTracer.getServerCallInfo());
+ assertEquals("other", methodNameTracer.getRecordedMethodName());
+
+ assertEquals(1, executor.runDueTasks());
+
+ assertNotNull(methodNameTracer.getServerCallInfo());
+ assertSame(METHOD, methodNameTracer.getServerCallInfo().getMethodDescriptor());
+ assertEquals("other", methodNameTracer.getRecordedMethodName());
+ verify(fallbackRegistry, never()).lookupMethod(anyString(), any());
+ }
+
+ @Test
+ public void fallbackRegistryGeneratedMethod_streamClosedBeforeStart_resolvesOnAsyncLookup()
+ throws Exception {
+ MethodNameCapturingTracer methodNameTracer = new MethodNameCapturingTracer();
+ streamTracerFactories =
+ Collections.singletonList(
+ new ServerStreamTracer.Factory() {
+ @Override
+ public ServerStreamTracer newServerStreamTracer(
+ String fullMethodName, Metadata headers) {
+ return methodNameTracer;
+ }
+ });
+ mutableFallbackRegistry.addService(
+ ServerServiceDefinition.builder(new ServiceDescriptor("Waiter", GENERATED_METHOD))
+ .addMethod(
+ GENERATED_METHOD,
+ new ServerCallHandler<String, Integer>() {
+ @Override
+ public ServerCall.Listener<String> startCall(
+ ServerCall<String, Integer> call, Metadata headers) {
+ return callListener;
+ }
+ })
+ .build());
+
+ createAndStartServer();
+ ServerTransportListener transportListener
+ = transportServer.registerNewServerTransport(new SimpleServerTransport());
+ transportListener.transportReady(Attributes.EMPTY);
+ Metadata requestHeaders = new Metadata();
+ StatsTraceContext statsTraceCtx =
+ StatsTraceContext.newServerContext(
+ streamTracerFactories, GENERATED_METHOD.getFullMethodName(), requestHeaders);
+ when(stream.getAttributes()).thenReturn(Attributes.EMPTY);
+ when(stream.statsTraceContext()).thenReturn(statsTraceCtx);
+
+ transportListener.streamCreated(stream, GENERATED_METHOD.getFullMethodName(), requestHeaders);
+ verify(stream).setListener(isA(ServerStreamListener.class));
+ verify(stream, atLeast(1)).statsTraceContext();
+
+ statsTraceCtx.streamClosed(Status.CANCELLED);
+ assertNull(methodNameTracer.getServerCallInfo());
+ assertEquals("other", methodNameTracer.getRecordedMethodName());
+ verify(fallbackRegistry, never()).lookupMethod(anyString(), any());
+
+ assertEquals(1, executor.runDueTasks());
+
+ assertNotNull(methodNameTracer.getServerCallInfo());
+ assertSame(GENERATED_METHOD, methodNameTracer.getServerCallInfo().getMethodDescriptor());
+ assertEquals(
+ GENERATED_METHOD.getFullMethodName(),
+ methodNameTracer.getRecordedMethodName());
+ verify(fallbackRegistry).lookupMethod(GENERATED_METHOD.getFullMethodName(), AUTHORITY);
+ }
+
@Test
public void executorSupplierSameExecutorBasic() throws Exception {
diff --git a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java
index c9e623b..f783b94 100644
--- a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java
+++ b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java
@@ -47,6 +47,7 @@
import io.grpc.Status;
import io.grpc.Status.Code;
import io.grpc.StreamTracer;
+import io.grpc.internal.StatsTraceContext.ServerCallMethodListener;
import io.grpc.opentelemetry.GrpcOpenTelemetry.TargetFilter;
import io.opentelemetry.api.baggage.Baggage;
import io.opentelemetry.api.common.AttributesBuilder;
@@ -526,7 +527,8 @@
}
}
- private static final class ServerTracer extends ServerStreamTracer {
+ private static final class ServerTracer extends ServerStreamTracer
+ implements ServerCallMethodListener {
@Nullable private static final AtomicIntegerFieldUpdater<ServerTracer> streamClosedUpdater;
@Nullable private static final AtomicLongFieldUpdater<ServerTracer> outboundWireSizeUpdater;
@Nullable private static final AtomicLongFieldUpdater<ServerTracer> inboundWireSizeUpdater;
@@ -588,6 +590,11 @@
}
@Override
+ public void serverCallMethodResolved(MethodDescriptor<?, ?> method) {
+ isGeneratedMethod = method.isSampledToLocalTracing();
+ }
+
+ @Override
public void serverCallStarted(ServerCallInfo<?, ?> callInfo) {
// Only record method name as an attribute if isSampledToLocalTracing is set to true,
// which is true for all generated methods. Otherwise, programmatically
@@ -644,9 +651,24 @@
}
stopwatch.stop();
long elapsedTimeNanos = stopwatch.elapsed(TimeUnit.NANOSECONDS);
- AttributesBuilder builder = io.opentelemetry.api.common.Attributes.builder()
- .put(METHOD_KEY, recordMethodName(fullMethodName, isGeneratedMethod))
- .put(STATUS_KEY, status.getCode().toString());
+ recordClosedStream(
+ status,
+ elapsedTimeNanos,
+ outboundWireSize,
+ inboundWireSize,
+ isGeneratedMethod);
+ }
+
+ private void recordClosedStream(
+ Status status,
+ long elapsedTimeNanos,
+ long closedOutboundWireSize,
+ long closedInboundWireSize,
+ boolean generatedMethod) {
+ AttributesBuilder builder =
+ io.opentelemetry.api.common.Attributes.builder()
+ .put(METHOD_KEY, recordMethodName(fullMethodName, generatedMethod))
+ .put(STATUS_KEY, status.getCode().toString());
for (OpenTelemetryPlugin.ServerStreamPlugin plugin : streamPlugins) {
plugin.addLabels(builder);
}
@@ -658,11 +680,11 @@
}
if (module.resource.serverTotalSentCompressedMessageSizeCounter() != null) {
module.resource.serverTotalSentCompressedMessageSizeCounter()
- .record(outboundWireSize, attributes, otelContext);
+ .record(closedOutboundWireSize, attributes, otelContext);
}
if (module.resource.serverTotalReceivedCompressedMessageSizeCounter() != null) {
module.resource.serverTotalReceivedCompressedMessageSizeCounter()
- .record(inboundWireSize, attributes, otelContext);
+ .record(closedInboundWireSize, attributes, otelContext);
}
}
}
@@ -744,4 +766,3 @@
}
}
}
-
diff --git a/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java b/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java
index 14139b8..7c9db87 100644
--- a/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java
+++ b/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java
@@ -57,6 +57,7 @@
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.internal.FakeClock;
+import io.grpc.internal.StatsTraceContext.ServerCallMethodListener;
import io.grpc.opentelemetry.GrpcOpenTelemetry.TargetFilter;
import io.grpc.opentelemetry.OpenTelemetryMetricsModule.CallAttemptsTracerFactory;
import io.grpc.opentelemetry.internal.OpenTelemetryConstants;
@@ -1734,6 +1735,129 @@
}
+ @Test
+ public void serverMetrics_methodResolvedBeforeStreamClosed_generatedMethodRecordsName() {
+ OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter,
+ enabledMetricsMap, disableDefaultMetrics);
+ OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource);
+ ServerStreamTracer.Factory tracerFactory = module.getServerTracerFactory();
+ ServerStreamTracer tracer =
+ tracerFactory.newServerStreamTracer(method.getFullMethodName(), new Metadata());
+
+ ((ServerCallMethodListener) tracer).serverCallMethodResolved(method);
+ fakeClock.forwardTime(10, MILLISECONDS);
+ tracer.streamClosed(Status.CANCELLED);
+
+ io.opentelemetry.api.common.Attributes serverAttributes =
+ io.opentelemetry.api.common.Attributes.of(
+ METHOD_KEY, method.getFullMethodName(),
+ STATUS_KEY, Code.CANCELLED.toString());
+
+ assertThat(openTelemetryTesting.getMetrics())
+ .anySatisfy(
+ metric ->
+ assertThat(metric)
+ .hasName(SERVER_CALL_DURATION)
+ .hasUnit("s")
+ .hasHistogramSatisfying(
+ histogram ->
+ histogram.hasPointsSatisfying(
+ point ->
+ point
+ .hasCount(1)
+ .hasSum(0.01)
+ .hasAttributes(serverAttributes))));
+ }
+
+ @Test
+ public void serverMetrics_methodResolvedBeforeStreamClosed_nonGeneratedMethodRecordsOther() {
+ MethodDescriptor<String, String> nonGeneratedMethod =
+ method.toBuilder().setSampledToLocalTracing(false).build();
+ OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter,
+ enabledMetricsMap, disableDefaultMetrics);
+ OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource);
+ ServerStreamTracer.Factory tracerFactory = module.getServerTracerFactory();
+ ServerStreamTracer tracer =
+ tracerFactory.newServerStreamTracer(nonGeneratedMethod.getFullMethodName(), new Metadata());
+
+ ((ServerCallMethodListener) tracer).serverCallMethodResolved(nonGeneratedMethod);
+ fakeClock.forwardTime(10, MILLISECONDS);
+ tracer.streamClosed(Status.CANCELLED);
+
+ io.opentelemetry.api.common.Attributes serverAttributes =
+ io.opentelemetry.api.common.Attributes.of(
+ METHOD_KEY, "other",
+ STATUS_KEY, Code.CANCELLED.toString());
+
+ assertThat(openTelemetryTesting.getMetrics())
+ .anySatisfy(
+ metric ->
+ assertThat(metric)
+ .hasName(SERVER_CALL_DURATION)
+ .hasUnit("s")
+ .hasHistogramSatisfying(
+ histogram ->
+ histogram.hasPointsSatisfying(
+ point ->
+ point
+ .hasCount(1)
+ .hasSum(0.01)
+ .hasAttributes(serverAttributes))));
+ }
+
+ @Test
+ public void serverMetrics_serverCallStarted_nonGeneratedMethodRecordsOther() {
+ MethodDescriptor<String, String> nonGeneratedMethod =
+ method.toBuilder().setSampledToLocalTracing(false).build();
+ OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter,
+ enabledMetricsMap, disableDefaultMetrics);
+ OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource);
+ ServerStreamTracer.Factory tracerFactory = module.getServerTracerFactory();
+ ServerStreamTracer tracer =
+ tracerFactory.newServerStreamTracer(nonGeneratedMethod.getFullMethodName(), new Metadata());
+ tracer.serverCallStarted(
+ new CallInfo<>(nonGeneratedMethod, Attributes.EMPTY, null));
+
+ io.opentelemetry.api.common.Attributes startedAttributes =
+ io.opentelemetry.api.common.Attributes.of(METHOD_KEY, "other");
+
+ assertThat(openTelemetryTesting.getMetrics())
+ .anySatisfy(
+ metric ->
+ assertThat(metric)
+ .hasName(SERVER_CALL_COUNT)
+ .hasUnit("{call}")
+ .hasLongSumSatisfying(
+ longSum ->
+ longSum.hasPointsSatisfying(
+ point ->
+ point
+ .hasAttributes(startedAttributes)
+ .hasValue(1))));
+
+ fakeClock.forwardTime(10, MILLISECONDS);
+ tracer.streamClosed(Status.CANCELLED);
+
+ io.opentelemetry.api.common.Attributes closedAttributes =
+ io.opentelemetry.api.common.Attributes.of(
+ METHOD_KEY, "other",
+ STATUS_KEY, Code.CANCELLED.toString());
+
+ assertThat(openTelemetryTesting.getMetrics())
+ .anySatisfy(
+ metric ->
+ assertThat(metric)
+ .hasName(SERVER_CALL_DURATION)
+ .hasUnit("s")
+ .hasHistogramSatisfying(
+ histogram ->
+ histogram.hasPointsSatisfying(
+ point ->
+ point
+ .hasCount(1)
+ .hasSum(0.01)
+ .hasAttributes(closedAttributes))));
+ }
@Test
public void targetAttributeFilter_notSet_usesOriginalTarget() {