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() {