diff --git a/src/Persistence/MySql/Wolverine.MySql/AssemblyAttributes.cs b/src/Persistence/MySql/Wolverine.MySql/AssemblyAttributes.cs index 225257b24..7c0f548e9 100644 --- a/src/Persistence/MySql/Wolverine.MySql/AssemblyAttributes.cs +++ b/src/Persistence/MySql/Wolverine.MySql/AssemblyAttributes.cs @@ -1,3 +1,6 @@ using System.Runtime.CompilerServices; +using Wolverine.Attributes; + +[assembly: ExcludeFromServiceCapabilities] [assembly: InternalsVisibleTo("MySqlTests")] diff --git a/src/Persistence/Oracle/Wolverine.Oracle/AssemblyAttributes.cs b/src/Persistence/Oracle/Wolverine.Oracle/AssemblyAttributes.cs index 4a0e5faa3..0ca6857d9 100644 --- a/src/Persistence/Oracle/Wolverine.Oracle/AssemblyAttributes.cs +++ b/src/Persistence/Oracle/Wolverine.Oracle/AssemblyAttributes.cs @@ -1,3 +1,6 @@ using System.Runtime.CompilerServices; +using Wolverine.Attributes; + +[assembly: ExcludeFromServiceCapabilities] [assembly: InternalsVisibleTo("OracleTests")] diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/AssemblyAttributes.cs b/src/Persistence/Wolverine.EntityFrameworkCore/AssemblyAttributes.cs index e30dfe826..838bb5005 100644 --- a/src/Persistence/Wolverine.EntityFrameworkCore/AssemblyAttributes.cs +++ b/src/Persistence/Wolverine.EntityFrameworkCore/AssemblyAttributes.cs @@ -1,4 +1,7 @@ using System.Runtime.CompilerServices; +using Wolverine.Attributes; + +[assembly: ExcludeFromServiceCapabilities] [assembly: InternalsVisibleTo("PersistenceTests")] [assembly: InternalsVisibleTo("EfCoreTests")] diff --git a/src/Persistence/Wolverine.Marten/AssemblyAttributes.cs b/src/Persistence/Wolverine.Marten/AssemblyAttributes.cs index 2b47af08c..d2a647aac 100644 --- a/src/Persistence/Wolverine.Marten/AssemblyAttributes.cs +++ b/src/Persistence/Wolverine.Marten/AssemblyAttributes.cs @@ -1,4 +1,7 @@ using System.Runtime.CompilerServices; +using Wolverine.Attributes; + +[assembly: ExcludeFromServiceCapabilities] [assembly: InternalsVisibleTo("PersistenceTests")] [assembly: InternalsVisibleTo("MartenTests")] diff --git a/src/Persistence/Wolverine.Polecat/AssemblyAttributes.cs b/src/Persistence/Wolverine.Polecat/AssemblyAttributes.cs index 90b6af158..9af68fa74 100644 --- a/src/Persistence/Wolverine.Polecat/AssemblyAttributes.cs +++ b/src/Persistence/Wolverine.Polecat/AssemblyAttributes.cs @@ -1,4 +1,7 @@ using System.Runtime.CompilerServices; +using Wolverine.Attributes; + +[assembly: ExcludeFromServiceCapabilities] [assembly: InternalsVisibleTo("PolecatTests")] [assembly: InternalsVisibleTo("Wolverine.Http.Polecat")] diff --git a/src/Persistence/Wolverine.Postgresql/AssemblyAttributes.cs b/src/Persistence/Wolverine.Postgresql/AssemblyAttributes.cs index 2d7dc747b..6bba0fa92 100644 --- a/src/Persistence/Wolverine.Postgresql/AssemblyAttributes.cs +++ b/src/Persistence/Wolverine.Postgresql/AssemblyAttributes.cs @@ -1,4 +1,7 @@ using System.Runtime.CompilerServices; +using Wolverine.Attributes; + +[assembly: ExcludeFromServiceCapabilities] [assembly: InternalsVisibleTo("Wolverine.Marten")] [assembly: InternalsVisibleTo("PersistenceTests")] diff --git a/src/Persistence/Wolverine.RDBMS/AssemblyAttributes.cs b/src/Persistence/Wolverine.RDBMS/AssemblyAttributes.cs index a67b2dbe9..e719eeda4 100644 --- a/src/Persistence/Wolverine.RDBMS/AssemblyAttributes.cs +++ b/src/Persistence/Wolverine.RDBMS/AssemblyAttributes.cs @@ -1,4 +1,7 @@ using System.Runtime.CompilerServices; +using Wolverine.Attributes; + +[assembly: ExcludeFromServiceCapabilities] [assembly: InternalsVisibleTo("PersistenceTests")] [assembly: InternalsVisibleTo("SqlServerTests")] diff --git a/src/Persistence/Wolverine.SqlServer/AssemblyAttributes.cs b/src/Persistence/Wolverine.SqlServer/AssemblyAttributes.cs index b39a3407e..748aaec26 100644 --- a/src/Persistence/Wolverine.SqlServer/AssemblyAttributes.cs +++ b/src/Persistence/Wolverine.SqlServer/AssemblyAttributes.cs @@ -1,4 +1,7 @@ using System.Runtime.CompilerServices; +using Wolverine.Attributes; + +[assembly: ExcludeFromServiceCapabilities] [assembly: InternalsVisibleTo("PersistenceTests")] [assembly: InternalsVisibleTo("SqlServerTests")] \ No newline at end of file diff --git a/src/Persistence/Wolverine.Sqlite/AssemblyAttributes.cs b/src/Persistence/Wolverine.Sqlite/AssemblyAttributes.cs index 2c949e3fa..d8077b385 100644 --- a/src/Persistence/Wolverine.Sqlite/AssemblyAttributes.cs +++ b/src/Persistence/Wolverine.Sqlite/AssemblyAttributes.cs @@ -1,3 +1,6 @@ using System.Runtime.CompilerServices; +using Wolverine.Attributes; + +[assembly: ExcludeFromServiceCapabilities] [assembly: InternalsVisibleTo("SqliteTests")] diff --git a/src/Testing/CoreTests/Acceptance/system_message_type_filtering.cs b/src/Testing/CoreTests/Acceptance/system_message_type_filtering.cs new file mode 100644 index 000000000..55838c2a3 --- /dev/null +++ b/src/Testing/CoreTests/Acceptance/system_message_type_filtering.cs @@ -0,0 +1,152 @@ +using Microsoft.Extensions.Hosting; +using Wolverine.Configuration; +using Wolverine.Configuration.Capabilities; +using Wolverine.ErrorHandling; +using Wolverine.Logging; +using Wolverine.Runtime; +using Wolverine.Runtime.Agents; +using Wolverine.Runtime.Metrics; +using Wolverine.Runtime.Routing; +using Wolverine.Tracking; +using Wolverine.Transports; +using Xunit; + +namespace CoreTests.Acceptance; + +/// +/// GH-2520: framework-internal message types must not leak into ServiceCapabilities +/// or be reported via IWolverineObserver hooks (MessageRouted / MessageCausedBy). +/// +public class system_message_type_filtering +{ + [Fact] + public void IsSystemMessageType_recognizes_IInternalMessage() + { + typeof(SampleInternalMessage).IsSystemMessageType().ShouldBeTrue(); + } + + [Fact] + public void IsSystemMessageType_recognizes_IAgentCommand() + { + typeof(SampleAgentCommand).IsSystemMessageType().ShouldBeTrue(); + } + + [Fact] + public void IsSystemMessageType_recognizes_INotToBeRouted() + { + typeof(SampleNotToBeRouted).IsSystemMessageType().ShouldBeTrue(); + } + + [Fact] + public void IsSystemMessageType_returns_false_for_normal_user_messages() + { + typeof(NormalUserMessage).IsSystemMessageType().ShouldBeFalse(); + } + + [Fact] + public void IsSystemMessageType_returns_false_for_null() + { + ((Type?)null).IsSystemMessageType().ShouldBeFalse(); + } + + [Fact] + public async Task service_capabilities_excludes_system_message_types() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery(); + opts.Discovery.IncludeType(); + }) + .StartAsync(); + + var capabilities = await ServiceCapabilities.ReadFrom(host.GetRuntime(), null, CancellationToken.None); + + // Sanity: the normal user message should appear + capabilities.Messages.ShouldContain(m => m.Type.FullName == typeof(NormalUserMessage).FullName); + + // The three system-marked types must NOT appear + capabilities.Messages.ShouldNotContain(m => m.Type.FullName == typeof(SampleInternalMessage).FullName); + capabilities.Messages.ShouldNotContain(m => m.Type.FullName == typeof(SampleAgentCommand).FullName); + capabilities.Messages.ShouldNotContain(m => m.Type.FullName == typeof(SampleNotToBeRouted).FullName); + } + + [Fact] + public async Task observer_does_not_receive_message_routed_for_system_types() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery(); + opts.Discovery.IncludeType(); + }) + .StartAsync(); + + var runtime = host.GetRuntime(); + + // Swap in a recording observer after startup so we capture exactly the + // post-startup MessageRouted calls we care about. + var observer = new RecordingObserver(); + runtime.Observer = observer; + + // Force routing for each type so the observer hook is exercised + runtime.RoutingFor(typeof(NormalUserMessage)); + runtime.RoutingFor(typeof(SampleInternalMessage)); + runtime.RoutingFor(typeof(SampleAgentCommand)); + runtime.RoutingFor(typeof(SampleNotToBeRouted)); + + // Normal type should be reported, system types should be filtered out + observer.RoutedTypes.ShouldContain(typeof(NormalUserMessage)); + observer.RoutedTypes.ShouldNotContain(typeof(SampleInternalMessage)); + observer.RoutedTypes.ShouldNotContain(typeof(SampleAgentCommand)); + observer.RoutedTypes.ShouldNotContain(typeof(SampleNotToBeRouted)); + } +} + +// Fixtures live in the test (user) assembly to prove the per-type filter works +// regardless of which assembly declares the type. +public record NormalUserMessage(string Name); +public record SampleInternalMessage(string Note) : IInternalMessage; +public record SampleNotToBeRouted(string Note) : INotToBeRouted; + +public class SampleAgentCommand : IAgentCommand +{ + public Task ExecuteAsync(IWolverineRuntime runtime, CancellationToken cancellationToken) + => Task.FromResult(AgentCommands.Empty); +} + +public class SystemFilteringHandler +{ + public void Handle(NormalUserMessage msg) { } + public void Handle(SampleInternalMessage msg) { } + public void Handle(SampleNotToBeRouted msg) { } + public void Handle(SampleAgentCommand cmd) { } +} + +internal class RecordingObserver : IWolverineObserver +{ + public List RoutedTypes { get; } = new(); + + public void MessageRouted(Type messageType, IMessageRouter router) + { + RoutedTypes.Add(messageType); + } + + // All other members are no-ops (interface uses default implementations) + public Task AssumedLeadership() => Task.CompletedTask; + public Task NodeStarted() => Task.CompletedTask; + public Task NodeStopped() => Task.CompletedTask; + public Task AgentStarted(Uri agentUri) => Task.CompletedTask; + public Task AgentStopped(Uri agentUri) => Task.CompletedTask; + public Task AssignmentsChanged(AssignmentGrid grid, AgentCommands commands) => Task.CompletedTask; + public Task StaleNodes(IReadOnlyList staleNodes) => Task.CompletedTask; + public Task RuntimeIsFullyStarted() => Task.CompletedTask; + public void EndpointAdded(Endpoint endpoint) { } + public Task BackPressureTriggered(Endpoint endpoint, IListeningAgent agent) => Task.CompletedTask; + public Task BackPressureLifted(Endpoint endpoint) => Task.CompletedTask; + public Task ListenerLatched(Endpoint endpoint) => Task.CompletedTask; + public Task CircuitBreakerTripped(Endpoint endpoint, CircuitBreakerOptions options) => Task.CompletedTask; + public Task CircuitBreakerReset(Endpoint endpoint) => Task.CompletedTask; + public void PersistedCounts(Uri storeUri, PersistedCounts counts) { } + public void MessageHandlingMetricsExported(MessageHandlingMetrics metrics) { } +} diff --git a/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs b/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs index ada108cc2..273df6922 100644 --- a/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs +++ b/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs @@ -2,7 +2,6 @@ using System.Text.Json.Serialization; using JasperFx.Core.Reflection; using JasperFx.Descriptors; -using Wolverine.Attributes; using JasperFx.Events; using JasperFx.Events.Descriptors; using Microsoft.Extensions.DependencyInjection; @@ -107,7 +106,7 @@ private static void readMessageTypes(IWolverineRuntime runtime, ServiceCapabilit var messageTypes = runtime.Options.Discovery.FindAllMessages(runtime.Options.HandlerGraph); foreach (var messageType in messageTypes.OrderBy(x => x.FullNameInCode())) { - if (messageType.Assembly.HasAttribute()) continue; + if (messageType.IsSystemMessageType()) continue; capabilities.Messages.Add(new MessageDescriptor(messageType, runtime)); } } diff --git a/src/Wolverine/Configuration/Capabilities/SystemMessageTypeExtensions.cs b/src/Wolverine/Configuration/Capabilities/SystemMessageTypeExtensions.cs new file mode 100644 index 000000000..1abe3ac23 --- /dev/null +++ b/src/Wolverine/Configuration/Capabilities/SystemMessageTypeExtensions.cs @@ -0,0 +1,41 @@ +using JasperFx.Core.Reflection; +using Wolverine.Attributes; +using Wolverine.Runtime; +using Wolverine.Runtime.Agents; + +namespace Wolverine.Configuration.Capabilities; + +/// +/// Centralized predicate for deciding whether a given message type is a system / +/// framework-internal type that should be excluded from observability surfaces: +/// , IWolverineObserver.MessageRouted, and +/// IWolverineObserver.MessageCausedBy. +/// +internal static class SystemMessageTypeExtensions +{ + /// + /// Returns true if the supplied message type should be hidden from observability. + /// Catches: + /// + /// Types implementing (preferred — fastest) + /// Types implementing (Wolverine internal commands) + /// Types implementing (covers , + /// ICritterWatchMessage, acknowledgements, etc.) + /// Types declared in an assembly marked with + /// + /// + public static bool IsSystemMessageType(this Type? messageType) + { + if (messageType is null) return false; + + // Marker-interface checks first — these are constant-time runtime type tests. + if (messageType.CanBeCastTo()) return true; + if (messageType.CanBeCastTo()) return true; + if (messageType.CanBeCastTo()) return true; + + // Assembly-level opt-out — slower, falls through to reflection but cached by the runtime. + if (messageType.Assembly.HasAttribute()) return true; + + return false; + } +} diff --git a/src/Wolverine/Runtime/Handlers/MessageHandler.cs b/src/Wolverine/Runtime/Handlers/MessageHandler.cs index 9b971238a..66698f32a 100644 --- a/src/Wolverine/Runtime/Handlers/MessageHandler.cs +++ b/src/Wolverine/Runtime/Handlers/MessageHandler.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using JasperFx.Core.Reflection; using Microsoft.Extensions.Logging; +using Wolverine.Configuration.Capabilities; using Wolverine.Runtime.Agents; namespace Wolverine.Runtime.Handlers; @@ -59,13 +60,26 @@ public void RecordCauseAndEffect(MessageContext context, IWolverineObserver obse { if (!context.Runtime.Options.EnableMessageCausationTracking) return; + // Skip the entire causation report when the incoming message itself is a + // framework-internal type (IAgentCommand, INotToBeRouted, IInternalMessage, etc.). + // See GH-2520. + if (MessageType.IsSystemMessageType()) return; + var incomingType = MessageType.FullName ?? MessageType.Name; var handlerType = GetType().FullName ?? GetType().Name; var endpointUri = Chain?.Endpoints?.FirstOrDefault()?.Uri?.ToString(); foreach (var envelope in context.Outstanding) { - var outgoingType = envelope.Message?.GetType().FullName; + var outgoingMessage = envelope.Message; + if (outgoingMessage is null) continue; + + // Per-instance check uses fast pattern match over runtime type-tests for + // the marker interfaces; falls through to the helper for assembly attrs. + var outgoingMessageType = outgoingMessage.GetType(); + if (outgoingMessageType.IsSystemMessageType()) continue; + + var outgoingType = outgoingMessageType.FullName; if (string.IsNullOrEmpty(outgoingType)) continue; var key = $"{incomingType}->{outgoingType}@{handlerType}"; diff --git a/src/Wolverine/Runtime/IInternalMessage.cs b/src/Wolverine/Runtime/IInternalMessage.cs new file mode 100644 index 000000000..75fc4e322 --- /dev/null +++ b/src/Wolverine/Runtime/IInternalMessage.cs @@ -0,0 +1,15 @@ +namespace Wolverine.Runtime; + +/// +/// Marker interface for message types that are internal to Wolverine or to extension +/// frameworks (such as CritterWatch). Types implementing this interface are excluded +/// from and +/// from IWolverineObserver notifications such as MessageRouted and +/// MessageCausedBy. +/// +/// Use this for system commands and infrastructure messages that should not appear in +/// observability tooling alongside user-defined messages. Per-instance filtering uses +/// a single type-test (x is IInternalMessage), which is cheaper than reflection +/// or attribute lookups. +/// +public interface IInternalMessage; diff --git a/src/Wolverine/Runtime/WolverineRuntime.Routing.cs b/src/Wolverine/Runtime/WolverineRuntime.Routing.cs index 1a07520ba..856933287 100644 --- a/src/Wolverine/Runtime/WolverineRuntime.Routing.cs +++ b/src/Wolverine/Runtime/WolverineRuntime.Routing.cs @@ -2,6 +2,7 @@ using JasperFx.Core; using JasperFx.Core.Reflection; using Wolverine.Configuration; +using Wolverine.Configuration.Capabilities; using Wolverine.Runtime.Agents; using Wolverine.Runtime.Routing; using Wolverine.Transports; @@ -155,7 +156,13 @@ public IMessageRouter RoutingFor(Type messageType) ? typeof(MessageRouter<>).CloseAndBuildAs(this, routes, messageType) : typeof(EmptyMessageRouter<>).CloseAndBuildAs(this, messageType); - Observer.MessageRouted(messageType, router); + // Skip framework-internal types (IAgentCommand, INotToBeRouted, IInternalMessage, + // and types from assemblies marked [ExcludeFromServiceCapabilities]) so they + // never reach observers like CritterWatch. See GH-2520. + if (!messageType.IsSystemMessageType()) + { + Observer.MessageRouted(messageType, router); + } _messageTypeRouting = _messageTypeRouting.AddOrUpdate(messageType, router);