diff --git a/src/DSoftStudio.Mediator.OpenTelemetry/DSoftStudio.Mediator.OpenTelemetry.csproj b/src/DSoftStudio.Mediator.OpenTelemetry/DSoftStudio.Mediator.OpenTelemetry.csproj index df20a5e..b655ad1 100644 --- a/src/DSoftStudio.Mediator.OpenTelemetry/DSoftStudio.Mediator.OpenTelemetry.csproj +++ b/src/DSoftStudio.Mediator.OpenTelemetry/DSoftStudio.Mediator.OpenTelemetry.csproj @@ -9,7 +9,12 @@ DSoftStudio.Mediator.OpenTelemetry - 1.1.0-rc.3 + + 1.1.0-rc.4 DSoftStudio DSoftStudio diff --git a/src/DSoftStudio.Mediator.OpenTelemetry/InstrumentedNotificationPublisher.cs b/src/DSoftStudio.Mediator.OpenTelemetry/InstrumentedNotificationPublisher.cs index 43021d7..f13c878 100644 --- a/src/DSoftStudio.Mediator.OpenTelemetry/InstrumentedNotificationPublisher.cs +++ b/src/DSoftStudio.Mediator.OpenTelemetry/InstrumentedNotificationPublisher.cs @@ -123,8 +123,16 @@ private sealed class InstrumentedHandler(INotificationHandler $"{type.Name} handle"); using var activity = Source.StartActivity(spanName, ActivityKind.Internal); @@ -139,7 +147,7 @@ public async Task Handle(TNotification notification, CancellationToken cancellat { activity.SetTag("mediator.request.kind", MediatorNotificationMetadata.RequestKind); activity.SetTag("mediator.request.type", MediatorNotificationMetadata.RequestType); - activity.SetTag("mediator.handler.type", inner.GetType().FullName); + activity.SetTag("mediator.handler.type", handlerType.FullName); } try @@ -158,5 +166,12 @@ public async Task Handle(TNotification notification, CancellationToken cancellat throw; } } + + // Mirrors MediatorDispatchTracingObserver.ResolveHandlerType / MediatorStreamTracingBehavior: + // a transparent decorator (e.g. the live-profiler's per-handler wrapper) exposes the real handler + // through IPipelineHandlerTypeAccessor; a terminal handler does not implement it and reports its own + // runtime type. + private static Type ResolveHandlerType(INotificationHandler handler) + => handler is IPipelineHandlerTypeAccessor accessor ? accessor.HandlerType : handler.GetType(); } } diff --git a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/NotificationPublisherTests.cs b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/NotificationPublisherTests.cs index b550b83..7951552 100644 --- a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/NotificationPublisherTests.cs +++ b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/NotificationPublisherTests.cs @@ -108,6 +108,46 @@ public async Task Handler_child_span_names_use_handler_type_name() childNames.ShouldContain("TestNotificationHandler2 handle"); } + [Fact] + public async Task Handler_span_unwraps_transparent_wrapper_to_real_handler_type() + { + // Repro for the live-profiler + bridge coexistence bug: when the profiler wraps each notification + // handler in a per-handler timing decorator (TimedNotificationHandler, which implements + // IPipelineHandlerTypeAccessor), the bridge must read the REAL handler type via that seam — NOT the + // decorator's compiler-mangled type — so the notification fan-out shows the concrete subscriber + // (SendConfirmationEmail/UpdateInventory), not <...>__TimedNotificationHandler. + using var collector = new ActivityCollector(); + var options = new MediatorInstrumentationOptions(); + var inner = new SequentialNotificationPublisher(); + var publisher = new InstrumentedNotificationPublisher(inner, options, _metrics.Metrics); + + var handlers = new INotificationHandler[] + { + new TracingTransparentNotificationHandler(new TestNotificationHandler1()), + new TracingTransparentNotificationHandler(new TestNotificationHandler2()) // file-scoped wrapper below + }; + + await publisher.Publish(handlers, new TestNotification("hi"), TestContext.Current.CancellationToken); + + var childSpans = collector.Activities + .Where(a => a.DisplayName.EndsWith(" handle")) + .ToList(); + childSpans.Count.ShouldBe(2); + + // Span NAME must use the real handler's type name, not the wrapper's mangled name. + var childNames = childSpans.Select(a => a.DisplayName).ToList(); + childNames.ShouldContain("TestNotificationHandler1 handle"); + childNames.ShouldContain("TestNotificationHandler2 handle"); + + // mediator.handler.type must be the REAL handler's FullName (what the IDE fan-out joins on). + var handlerTypes = childSpans + .Select(a => (string)a.GetTagItem("mediator.handler.type")!) + .ToList(); + handlerTypes.ShouldContain(typeof(TestNotificationHandler1).FullName!); + handlerTypes.ShouldContain(typeof(TestNotificationHandler2).FullName!); + handlerTypes.ShouldNotContain(t => t.Contains("TracingTransparentNotificationHandler", StringComparison.Ordinal)); + } + [Fact] public async Task Error_in_handler_sets_error_status_on_parent_and_child() { @@ -224,3 +264,25 @@ public async Task EnrichActivity_callback_on_notification() parentSpan.GetTagItem("custom.value")!.ShouldBe("enriched"); } } + +/// +/// A per-handler notification decorator that is TRANSPARENT to tracing: it wraps an inner handler +/// (for timing/profiling) yet exposes the real handler type via , +/// exactly like the Enterprise live-profiler's generated per-handler wrapper (TimedNotificationHandler<T>). +/// +/// Declared file so the OSS DependencyInjectionGenerator skips it during handler discovery — an +/// open-generic INotificationHandler<TNotification> shape would otherwise be registered with an +/// unbound type parameter. This is the SAME reason the real profiler wrapper is file-scoped. +/// +/// +file sealed class TracingTransparentNotificationHandler(INotificationHandler inner) + : INotificationHandler, IPipelineHandlerTypeAccessor + where TNotification : INotification +{ + // Same recursive walk to the terminal handler as BehaviorHandlerAdapter / StreamPipelineChainHandler. + public Type HandlerType + => inner is IPipelineHandlerTypeAccessor a ? a.HandlerType : inner.GetType(); + + public Task Handle(TNotification notification, CancellationToken cancellationToken) + => inner.Handle(notification, cancellationToken); +}