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);
+}