diff --git a/docs/articles/utilities/logging.md b/docs/articles/utilities/logging.md index 1b06cafaa05..9a0df095d77 100644 --- a/docs/articles/utilities/logging.md +++ b/docs/articles/utilities/logging.md @@ -194,3 +194,42 @@ In your log, expect to see a line such as: `[DEBUG]... received handled message hello from akka://test/deadLetters` This logging can be toggled by configuring `akka.actor.debug.receive`. + +## Filtering Log Messages + +Since v1.5.21, Akka.NET supports for filtering log messages based on the `LogSource` or the content of a log message. + +The goal of this feature is to allow users to run Akka.NET at more verbose logging settings (i.e. `LogLevel.Debug`) while not getting completely flooded with unhelpful noise from the Akka.NET logging system. You can use the [`LogFilterBuilder`](xref:Akka.Event.LogFilterBuilder) to exclude messages don't need while still keeping ones that you do. + +### Configuring Log Filtering + +[!code-csharp[Create LoggerSetup](../../../src/core/Akka.Tests/Loggers/LogFilterEvaluatorSpecs.cs?name=CreateLoggerSetup)] + +We create a [`LogFilterBuilder`](xref:Akka.Event.LogFilterBuilder) prior to starting the `ActorSystem` and provide it with rules for which logs _should be excluded_ from any of Akka.NET's logged output - this uses the [`ActorSystemSetup`](xref:Akka.Actor.Setup.ActorSystemSetup) class functionality that Akka.NET supports for programmatic `ActorSystem` configuration: + +[!code-csharp[Create ActorSystemSetup](../../../src/core/Akka.Tests/Loggers/LogFilterEvaluatorSpecs.cs?name=ActorSystemSetup)] + +From there, we can create our `ActorSystem` with these rules enabled: + +```csharp +ActorSystemSetup completeSetup = CustomLoggerSetup(); + +// start the ActorSystem with the LogFilterBuilder rules enabled +ActorSystem mySystem = ActorSystem.Create("MySys", completeSetup); +``` + +### Log Filtering Rules + +There are two built-in types of log filtering rules: + +* `ExcludeSource___` - filters logs based on the `LogSource`; this type of filtering is _very_ resource efficient because it doesn't require the log message to be expanded in order for filtering to work. +* `ExcludeMessage___` - filters logs based on the content of the message. More resource-intensive as it does require log messages to be fully expanded prior to filtering. + +> [!NOTE] +> For an Akka.NET log to be excluded from the output logs, only one filter rule has to return a `LogFilterDecision.Drop`. + +However, if that's not sufficient for your purposes we also support defining custom rules via the `LogFilterBase` class: + +[!code-csharp[LogFilterBase](../../../src/core/Akka/Event/LogFilter.cs?name=LogFilterBase)] + +You can filter log messages based on any of the accessibly properties, and for performance reasons any `LogFilterBase` that looks at `LogFilterType.Content` will be passed in the fully expanded log message as a `string?` via the optional `expandedMessage` property. This is done in order to avoid allocating the log message every time for each possible rule that might be evaluated. diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt index 895f7aa1add..b59d0eb7c22 100644 --- a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt @@ -1725,6 +1725,7 @@ namespace Akka.Actor public int LogDeadLetters { get; } public bool LogDeadLettersDuringShutdown { get; } public System.TimeSpan LogDeadLettersSuspendDuration { get; } + public Akka.Event.LogFilterEvaluator LogFilter { get; } public Akka.Event.ILogMessageFormatter LogFormatter { get; } public string LogLevel { get; } public bool LogSerializerOverrideOnStart { get; } @@ -3373,6 +3374,13 @@ namespace Akka.Event public static bool Subscribe(this Akka.Event.EventStream eventStream, Akka.Actor.IActorRef subscriber) { } public static bool Unsubscribe(this Akka.Event.EventStream eventStream, Akka.Actor.IActorRef subscriber) { } } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public sealed class ExactMatchLogSourceFilter : Akka.Event.LogFilterBase + { + public ExactMatchLogSourceFilter(string source, System.StringComparison comparison = 5) { } + public override Akka.Event.LogFilterType FilterType { get; } + public override Akka.Event.LogFilterDecision ShouldKeepMessage(Akka.Event.LogEvent content, [System.Runtime.CompilerServices.NullableAttribute(2)] string expandedMessage = null) { } + } public interface IDeadLetterSuppression { } public interface ILogMessageFormatter { @@ -3414,6 +3422,53 @@ namespace Akka.Event public abstract Akka.Event.LogLevel LogLevel(); public override string ToString() { } } + public abstract class LogFilterBase : Akka.Actor.INoSerializationVerificationNeeded, Akka.Event.IDeadLetterSuppression + { + protected LogFilterBase() { } + public abstract Akka.Event.LogFilterType FilterType { get; } + public abstract Akka.Event.LogFilterDecision ShouldKeepMessage(Akka.Event.LogEvent content, [System.Runtime.CompilerServices.NullableAttribute(2)] string expandedMessage = null); + } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public sealed class LogFilterBuilder + { + public LogFilterBuilder() { } + public Akka.Event.LogFilterBuilder Add(Akka.Event.LogFilterBase filter) { } + public Akka.Event.LogFilterBuilder AddRange(System.Collections.Generic.IEnumerable filters) { } + public Akka.Event.LogFilterSetup Build() { } + public Akka.Event.LogFilterBuilder ExcludeMessageContaining(string messagePart) { } + public Akka.Event.LogFilterBuilder ExcludeMessageRegex(System.Text.RegularExpressions.Regex regex) { } + public Akka.Event.LogFilterBuilder ExcludeSourceContaining(string sourcePart) { } + public Akka.Event.LogFilterBuilder ExcludeSourceEndingWith(string sourceEnd) { } + public Akka.Event.LogFilterBuilder ExcludeSourceExactly(string source, System.StringComparison comparison = 5) { } + public Akka.Event.LogFilterBuilder ExcludeSourceRegex(System.Text.RegularExpressions.Regex regex) { } + public Akka.Event.LogFilterBuilder ExcludeSourceStartingWith(string sourceStart) { } + } + public enum LogFilterDecision + { + Keep = 0, + Drop = 1, + NoDecision = 2, + } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public class LogFilterEvaluator + { + public static readonly Akka.Event.LogFilterEvaluator NoFilters; + public LogFilterEvaluator(Akka.Event.LogFilterBase[] filters) { } + public bool EvaluatesLogSourcesOnly { get; } + public virtual bool ShouldTryKeepMessage(Akka.Event.LogEvent evt, out string expandedLogMessage) { } + } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public sealed class LogFilterSetup : Akka.Actor.Setup.Setup + { + public LogFilterSetup(Akka.Event.LogFilterBase[] filters) { } + public Akka.Event.LogFilterBase[] Filters { get; } + public Akka.Event.LogFilterEvaluator CreateEvaluator() { } + } + public enum LogFilterType + { + Source = 0, + Content = 1, + } public enum LogLevel { DebugLevel = 0, @@ -3571,6 +3626,7 @@ namespace Akka.Event public abstract class MinimalLogger : Akka.Actor.MinimalActorRef { protected MinimalLogger() { } + public Akka.Event.LogFilterEvaluator Filter { get; } public virtual Akka.Actor.ActorPath Path { get; } public virtual Akka.Actor.IActorRefProvider Provider { get; } protected abstract void Log(object message); @@ -3588,6 +3644,20 @@ namespace Akka.Event public void Log(Akka.Event.LogLevel logLevel, System.Exception cause, string format) { } public void Log(Akka.Event.LogLevel logLevel, System.Exception cause, Akka.Event.LogMessage message) { } } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public sealed class RegexLogMessageFilter : Akka.Event.LogFilterBase + { + public RegexLogMessageFilter(System.Text.RegularExpressions.Regex messageRegex) { } + public override Akka.Event.LogFilterType FilterType { get; } + public override Akka.Event.LogFilterDecision ShouldKeepMessage(Akka.Event.LogEvent content, [System.Runtime.CompilerServices.NullableAttribute(2)] string expandedMessage = null) { } + } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public sealed class RegexLogSourceFilter : Akka.Event.LogFilterBase + { + public RegexLogSourceFilter(System.Text.RegularExpressions.Regex sourceRegex) { } + public override Akka.Event.LogFilterType FilterType { get; } + public override Akka.Event.LogFilterDecision ShouldKeepMessage(Akka.Event.LogEvent content, [System.Runtime.CompilerServices.NullableAttribute(2)] string expandedMessage = null) { } + } public class StandardOutLogger : Akka.Event.MinimalLogger { public StandardOutLogger() { } diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.Net.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.Net.verified.txt index 00655827382..a5b2de54885 100644 --- a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.Net.verified.txt +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.Net.verified.txt @@ -1723,6 +1723,7 @@ namespace Akka.Actor public int LogDeadLetters { get; } public bool LogDeadLettersDuringShutdown { get; } public System.TimeSpan LogDeadLettersSuspendDuration { get; } + public Akka.Event.LogFilterEvaluator LogFilter { get; } public Akka.Event.ILogMessageFormatter LogFormatter { get; } public string LogLevel { get; } public bool LogSerializerOverrideOnStart { get; } @@ -3365,6 +3366,13 @@ namespace Akka.Event public static bool Subscribe(this Akka.Event.EventStream eventStream, Akka.Actor.IActorRef subscriber) { } public static bool Unsubscribe(this Akka.Event.EventStream eventStream, Akka.Actor.IActorRef subscriber) { } } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public sealed class ExactMatchLogSourceFilter : Akka.Event.LogFilterBase + { + public ExactMatchLogSourceFilter(string source, System.StringComparison comparison = 5) { } + public override Akka.Event.LogFilterType FilterType { get; } + public override Akka.Event.LogFilterDecision ShouldKeepMessage(Akka.Event.LogEvent content, [System.Runtime.CompilerServices.NullableAttribute(2)] string expandedMessage = null) { } + } public interface IDeadLetterSuppression { } public interface ILogMessageFormatter { @@ -3406,6 +3414,53 @@ namespace Akka.Event public abstract Akka.Event.LogLevel LogLevel(); public override string ToString() { } } + public abstract class LogFilterBase : Akka.Actor.INoSerializationVerificationNeeded, Akka.Event.IDeadLetterSuppression + { + protected LogFilterBase() { } + public abstract Akka.Event.LogFilterType FilterType { get; } + public abstract Akka.Event.LogFilterDecision ShouldKeepMessage(Akka.Event.LogEvent content, [System.Runtime.CompilerServices.NullableAttribute(2)] string expandedMessage = null); + } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public sealed class LogFilterBuilder + { + public LogFilterBuilder() { } + public Akka.Event.LogFilterBuilder Add(Akka.Event.LogFilterBase filter) { } + public Akka.Event.LogFilterBuilder AddRange(System.Collections.Generic.IEnumerable filters) { } + public Akka.Event.LogFilterSetup Build() { } + public Akka.Event.LogFilterBuilder ExcludeMessageContaining(string messagePart) { } + public Akka.Event.LogFilterBuilder ExcludeMessageRegex(System.Text.RegularExpressions.Regex regex) { } + public Akka.Event.LogFilterBuilder ExcludeSourceContaining(string sourcePart) { } + public Akka.Event.LogFilterBuilder ExcludeSourceEndingWith(string sourceEnd) { } + public Akka.Event.LogFilterBuilder ExcludeSourceExactly(string source, System.StringComparison comparison = 5) { } + public Akka.Event.LogFilterBuilder ExcludeSourceRegex(System.Text.RegularExpressions.Regex regex) { } + public Akka.Event.LogFilterBuilder ExcludeSourceStartingWith(string sourceStart) { } + } + public enum LogFilterDecision + { + Keep = 0, + Drop = 1, + NoDecision = 2, + } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public class LogFilterEvaluator + { + public static readonly Akka.Event.LogFilterEvaluator NoFilters; + public LogFilterEvaluator(Akka.Event.LogFilterBase[] filters) { } + public bool EvaluatesLogSourcesOnly { get; } + public virtual bool ShouldTryKeepMessage(Akka.Event.LogEvent evt, out string expandedLogMessage) { } + } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public sealed class LogFilterSetup : Akka.Actor.Setup.Setup + { + public LogFilterSetup(Akka.Event.LogFilterBase[] filters) { } + public Akka.Event.LogFilterBase[] Filters { get; } + public Akka.Event.LogFilterEvaluator CreateEvaluator() { } + } + public enum LogFilterType + { + Source = 0, + Content = 1, + } public enum LogLevel { DebugLevel = 0, @@ -3561,6 +3616,7 @@ namespace Akka.Event public abstract class MinimalLogger : Akka.Actor.MinimalActorRef { protected MinimalLogger() { } + public Akka.Event.LogFilterEvaluator Filter { get; } public virtual Akka.Actor.ActorPath Path { get; } public virtual Akka.Actor.IActorRefProvider Provider { get; } protected abstract void Log(object message); @@ -3578,6 +3634,20 @@ namespace Akka.Event public void Log(Akka.Event.LogLevel logLevel, System.Exception cause, string format) { } public void Log(Akka.Event.LogLevel logLevel, System.Exception cause, Akka.Event.LogMessage message) { } } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public sealed class RegexLogMessageFilter : Akka.Event.LogFilterBase + { + public RegexLogMessageFilter(System.Text.RegularExpressions.Regex messageRegex) { } + public override Akka.Event.LogFilterType FilterType { get; } + public override Akka.Event.LogFilterDecision ShouldKeepMessage(Akka.Event.LogEvent content, [System.Runtime.CompilerServices.NullableAttribute(2)] string expandedMessage = null) { } + } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public sealed class RegexLogSourceFilter : Akka.Event.LogFilterBase + { + public RegexLogSourceFilter(System.Text.RegularExpressions.Regex sourceRegex) { } + public override Akka.Event.LogFilterType FilterType { get; } + public override Akka.Event.LogFilterDecision ShouldKeepMessage(Akka.Event.LogEvent content, [System.Runtime.CompilerServices.NullableAttribute(2)] string expandedMessage = null) { } + } public class StandardOutLogger : Akka.Event.MinimalLogger { public StandardOutLogger() { } diff --git a/src/core/Akka.Tests/Loggers/LogFilterEvaluatorSpecs.cs b/src/core/Akka.Tests/Loggers/LogFilterEvaluatorSpecs.cs new file mode 100644 index 00000000000..41b4f8d2be7 --- /dev/null +++ b/src/core/Akka.Tests/Loggers/LogFilterEvaluatorSpecs.cs @@ -0,0 +1,230 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2024 Lightbend Inc. +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FluentAssertions; +using Akka.Actor; +using Akka.Actor.Setup; +using Akka.Configuration; +using Akka.Event; +using Akka.TestKit; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Tests.Loggers; + +/// +/// Goal of these specs are mostly to make sure our default s are working as expected +/// +// ReSharper disable once ClassNeverInstantiated.Global +public class LogFilterEvaluatorSpecs +{ + public class LogFilterSetupSpecs : AkkaSpec + { + // + public static Setup LoggerSetup() + { + + var builder = new LogFilterBuilder(); + builder.ExcludeSourceContaining("Akka.Tests") + .ExcludeMessageContaining("foo-bar"); + return builder.Build(); + } + // + + // + public static ActorSystemSetup CustomLoggerSetup() + { + var hocon = @$"akka.stdout-logger-class = ""{typeof(CustomLogger).AssemblyQualifiedName}"""; + var bootstrapSetup = BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(hocon)); + return ActorSystemSetup.Create(bootstrapSetup, LoggerSetup()); + } + // + + // create a custom MinimalLogger that subclasses StandardOutLogger + public class CustomLogger : StandardOutLogger + { + protected override void Log(object message) + { + if (message is LogEvent e) + { + if (Filter.ShouldTryKeepMessage(e, out _)) + { + _events.Add(e); + } + } + + } + + private readonly List _events = new(); + public IReadOnlyList Events => _events; + } + + public LogFilterSetupSpecs(ITestOutputHelper output) : base(CustomLoggerSetup(), + output: output) + { + _logger = (CustomLogger)Sys.Settings.StdoutLogger; + } + + private readonly CustomLogger _logger; + + [Fact] + public async Task LogFilterEnd2EndSpec() + { + // subscribe to warning level log events + Sys.EventStream.Subscribe(TestActor, typeof(Warning)); + + // produce three warning messages - that hits the source filter, another that hits the message filter, and a third that hits neither + var loggingAdapter1 = Logging.GetLogger(Sys, "Akka.Tests.Test1"); + var loggingAdapter2 = Logging.GetLogger(Sys, "Akka.Util.Test2"); + + // should be filtered out based on Source + loggingAdapter1.Warning("test"); + + // should be filtered out based on message content + loggingAdapter2.Warning("foo-bar"); + + // should be allowed through + loggingAdapter2.Warning("baz"); + + // expect only the last message to be received + ReceiveN(3); + + // check that the last message was the one that was allowed through + await AwaitAssertAsync(() => _logger.Events.Count.Should().Be(1)); + var msg = _logger.Events[0]; + msg.Message.Should().Be("baz"); + msg.LogSource.Should().StartWith("Akka.Util.Test2"); + } + } + + public class LogSourceCases + { + public static readonly TheoryData LogSourceContainsCases = new() + { + { + // exact match (text, not case) + new Debug("Akka.Tests", typeof(IActorRef), "TEST"), false + }, + { + // test with stuff after the match + new Debug("Akka.Tests.Test2", typeof(IActorRef), "TEST"), false + }, + { + // test with stuff before the match + new Debug("LOL.Akka.Tests", typeof(IActorRef), "TEST"), false + }, + { new Debug("Akka.Util", typeof(IActorRef), "TEST"), true } + }; + + [Theory] + [MemberData(nameof(LogSourceContainsCases))] + public void ShouldFilterByLogSourceContains(LogEvent e, bool expected) + { + var ruleBuilder = new LogFilterBuilder().ExcludeSourceContaining("Akka.Tests"); + var evaluator = ruleBuilder.Build().CreateEvaluator(); + + var keepMessage = evaluator.ShouldTryKeepMessage(e, out _); + + Assert.Equal(expected, keepMessage); + } + + // add a test case for LogSource starts with + public static readonly TheoryData LogSourceStartsWithCases = new() + { + { + // exact match + new Debug("Akka.Tests", typeof(IActorRef), "TEST"), false + }, + { + // test with stuff after the match + new Debug("Akka.Tests.Test2", typeof(IActorRef), "TEST"), false + }, + { + // test with stuff before the match + new Debug("LOL.Akka.Tests", typeof(IActorRef), "TEST"), true + }, + { new Debug("Akka.Util", typeof(IActorRef), "TEST"), true } + }; + + [Theory] + [MemberData(nameof(LogSourceStartsWithCases))] + public void ShouldFilterByLogSourceStartsWith(LogEvent e, bool expected) + { + var ruleBuilder = new LogFilterBuilder().ExcludeSourceStartingWith("Akka.Tests"); + var evaluator = ruleBuilder.Build().CreateEvaluator(); + + var keepMessage = evaluator.ShouldTryKeepMessage(e, out _); + + Assert.Equal(expected, keepMessage); + } + + // add a test case for LogSource ends with + public static readonly TheoryData LogSourceEndsWithCases = new() + { + { + // exact match + new Debug("Akka.Tests", typeof(IActorRef), "TEST"), false + }, + { + // test with stuff after the match + new Debug("Akka.Tests.Test2", typeof(IActorRef), "TEST"), true + }, + { + // test with stuff before the match + new Debug("LOL.Akka.Tests", typeof(IActorRef), "TEST"), false + }, + { new Debug("Akka.Util", typeof(IActorRef), "TEST"), true } + }; + + [Theory] + [MemberData(nameof(LogSourceEndsWithCases))] + public void ShouldFilterByLogSourceEndsWith(LogEvent e, bool expected) + { + var ruleBuilder = new LogFilterBuilder().ExcludeSourceEndingWith("Akka.Tests"); + var evaluator = ruleBuilder.Build().CreateEvaluator(); + + var keepMessage = evaluator.ShouldTryKeepMessage(e, out _); + + Assert.Equal(expected, keepMessage); + } + } + + public class LogMessageCases + { + public static readonly TheoryData LogMessageContainsCases = new() + { + { + // exact match + new Debug("Akka.Tests", typeof(IActorRef), "TEST"), false + }, + { + // test with stuff after the match + new Debug("Akka.Tests", typeof(IActorRef), "TEST2"), false + }, + { + // test with stuff before the match + new Debug("Akka.Tests", typeof(IActorRef), "LOLTEST"), false + }, + { new Debug("Akka.Tests", typeof(IActorRef), "LOL"), true } + }; + + [Theory] + [MemberData(nameof(LogMessageContainsCases))] + public void ShouldFilterByLogMessageContains(LogEvent e, bool expected) + { + var ruleBuilder = new LogFilterBuilder().ExcludeMessageContaining("TEST"); + var evaluator = ruleBuilder.Build().CreateEvaluator(); + + var keepMessage = evaluator.ShouldTryKeepMessage(e, out _); + + Assert.Equal(expected, keepMessage); + } + } +} \ No newline at end of file diff --git a/src/core/Akka.Tests/Loggers/LoggerSpec.cs b/src/core/Akka.Tests/Loggers/LoggerSpec.cs index ddb07a8f4a3..d44ab43048c 100644 --- a/src/core/Akka.Tests/Loggers/LoggerSpec.cs +++ b/src/core/Akka.Tests/Loggers/LoggerSpec.cs @@ -127,7 +127,7 @@ public async Task StandardOutLogger_WithBadFormattingMustNotThrow() public void StandardOutLogger_PrintLogEvent_WithBadLogFormattingMustNotThrow(LogEvent @event) { var obj = new object(); - obj.Invoking(_ => StandardOutLogger.PrintLogEvent(@event)).Should().NotThrow(); + obj.Invoking(_ => StandardOutLogger.PrintLogEvent(@event, LogFilterEvaluator.NoFilters)).Should().NotThrow(); } public static IEnumerable LogEventFactory() diff --git a/src/core/Akka/Actor/Settings.cs b/src/core/Akka/Actor/Settings.cs index 62a4d75a689..26f13f3f334 100644 --- a/src/core/Akka/Actor/Settings.cs +++ b/src/core/Akka/Actor/Settings.cs @@ -111,6 +111,18 @@ public Settings(ActorSystem system, Config config, ActorSystemSetup setup) LogLevel = Config.GetString("akka.loglevel", null); StdoutLogLevel = Config.GetString("akka.stdout-loglevel", null); + + // FILTER MUST ALWAYS BE LOADED BEFORE STANDARD OUT LOGGER + // check to see if we have a LogFilterSetup in the ActorSystemSetup + var logFilterSetup = Setup.Get(); + if (logFilterSetup.HasValue) + { + LogFilter = logFilterSetup.Value.CreateEvaluator(); + } + else + { + LogFilter = LogFilterEvaluator.NoFilters; + } var stdoutClassName = Config.GetString("akka.stdout-logger-class", null); if (string.IsNullOrWhiteSpace(stdoutClassName)) @@ -136,6 +148,9 @@ public Settings(ActorSystem system, Config config, ActorSystemSetup setup) } } + // set the filter + StdoutLogger!.Filter = LogFilter; + Loggers = Config.GetStringList("akka.loggers", new string[] { }); LoggersDispatcher = Config.GetString("akka.loggers-dispatcher", null); LoggerStartTimeout = Config.GetTimeSpan("akka.logger-startup-timeout", null); @@ -361,6 +376,14 @@ public Settings(ActorSystem system, Config config, ActorSystemSetup setup) /// Can be overridden on individual `Context.GetLogger()` calls. /// public ILogMessageFormatter LogFormatter { get; } + + /// + /// Used to filter log messages based on the log source and message content. + /// + /// + /// Not enabled by default and may not be supported in all third party logging implementations. + /// + public LogFilterEvaluator LogFilter { get; } /// /// Gets a value indicating whether [log serializer override on start]. diff --git a/src/core/Akka/Configuration/akka.conf b/src/core/Akka/Configuration/akka.conf index 358f85e38a8..1a11eb86a71 100644 --- a/src/core/Akka/Configuration/akka.conf +++ b/src/core/Akka/Configuration/akka.conf @@ -49,7 +49,8 @@ akka { # Fully qualified class name (FQCN) of the very basic logger used on startup and shutdown. # You can substitute the logger by supplying an FQCN to a custom class that implements Akka.Event.MinimumLogger - stdout-logger-class = "Akka.Event.StandardOutLogger" + # Set to null by default so the default logger is used. + stdout-logger-class = "" # Log the complete configuration at INFO level when the actor system is started. # This is useful when you are uncertain of what configuration is used. diff --git a/src/core/Akka/Event/LogFilter.cs b/src/core/Akka/Event/LogFilter.cs new file mode 100644 index 00000000000..69111e5aadc --- /dev/null +++ b/src/core/Akka/Event/LogFilter.cs @@ -0,0 +1,321 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2024 Lightbend Inc. +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Akka.Actor; +using Akka.Actor.Setup; + +namespace Akka.Event; + +/* + * NOTE: We do not do caching here - the number of log sources can be very large and + * can change rapidly over the course of an application's lifecycle. + * + * This is out of band processing anyway, not on the fast past - exclude the logs + * entirely if you care about performance here. This is for debugging and diagnostics. + */ + +public enum LogFilterType +{ + /// + /// Filter log messages based on their source + /// + Source, + + /// + /// Filter log messages based on their content: message, exception, etc. + /// + /// + /// This is the slowest filter type, as it requires fully expanding the log message. + /// + Content +} + +public enum LogFilterDecision +{ + Keep, + Drop, + + /// + /// If we're asked to evaluate a filter and we don't have enough information to make a decision. + /// + /// For instance: a stage gets asked to evaluate a message body. + /// + NoDecision +} + +// +/// +/// Base class for all log filters +/// +/// +/// Worth noting: these run inside the Logging actors, so they're out of band +/// from any high performance workloads already. +/// +/// In addition to this - all log filters will only run if the log level is enabled. +/// +/// i.e. if we're at INFO level and the filter is set to a lower level, i.e. filtering DEBUG +/// logs, the filter won't even run. +/// +public abstract class LogFilterBase : INoSerializationVerificationNeeded, IDeadLetterSuppression +{ + /// + /// Which part of the log message this filter is evaluating? + /// + /// + /// This actually has a performance implication - if we're filtering on the source, which + /// is already fully "expanded" into its final string representation, we can try to fail fast + /// on that without any additional allocations. + /// + /// If we're filtering on the message, we have to fully expand the log message first which + /// involves allocations. Users on really tight performance budgets should be aware of this. + /// + public abstract LogFilterType FilterType { get; } + + /// + /// Fast path designed to avoid allocating strings if we're filtering on the message content. + /// + /// The part of the message to evaluate. + /// Usually the fully expanded message content. + /// The fully expanded message, optional. + public abstract LogFilterDecision ShouldKeepMessage(LogEvent content, string? expandedMessage = null); +} +// + +/// +/// Uses a regular expression to filter log messages based on their source. +/// +public sealed class RegexLogSourceFilter : LogFilterBase +{ + private readonly Regex _sourceRegex; + + public RegexLogSourceFilter(Regex sourceRegex) + { + _sourceRegex = sourceRegex; + } + + public override LogFilterType FilterType => LogFilterType.Source; + + public override LogFilterDecision ShouldKeepMessage(LogEvent content, string? expandedMessage = null) + { + return _sourceRegex.IsMatch(content.LogSource) ? LogFilterDecision.Drop : LogFilterDecision.Keep; + } +} + +public sealed class ExactMatchLogSourceFilter : LogFilterBase +{ + private readonly string _source; + private readonly StringComparison _comparison; + + public ExactMatchLogSourceFilter(string source, StringComparison comparison = StringComparison.OrdinalIgnoreCase) + { + _source = source; + _comparison = comparison; + } + + public override LogFilterType FilterType => LogFilterType.Source; + + public override LogFilterDecision ShouldKeepMessage(LogEvent content, + string? expandedMessage = null) + { + return content.LogSource == _source ? LogFilterDecision.Drop : LogFilterDecision.Keep; + } +} + +public sealed class RegexLogMessageFilter : LogFilterBase +{ + private readonly Regex _messageRegex; + + public RegexLogMessageFilter(Regex messageRegex) + { + _messageRegex = messageRegex; + } + + public override LogFilterType FilterType => LogFilterType.Content; + + public override LogFilterDecision ShouldKeepMessage(LogEvent content, + string? expandedMessage = null) + { + if(expandedMessage is not null) + return _messageRegex.IsMatch(expandedMessage ?? string.Empty) + ? LogFilterDecision.Drop + : LogFilterDecision.Keep; + + return LogFilterDecision.NoDecision; + } +} + +/// +/// Runs inside the logging actor and evaluates if a log message should be kept. +/// +public class LogFilterEvaluator +{ + public static readonly LogFilterEvaluator NoFilters = EmptyLogFilterEvaluator.Instance; + + private readonly LogFilterBase[] _filters; + + + /// + /// "Fast path" indicator - if this is true, we only evaluate log sources and not the message content. + /// + public bool EvaluatesLogSourcesOnly { get; } + + public LogFilterEvaluator(LogFilterBase[] filters) + { + _filters = filters; + EvaluatesLogSourcesOnly = filters.All(x => x.FilterType == LogFilterType.Source); + } + + public virtual bool ShouldTryKeepMessage(LogEvent evt, out string expandedLogMessage) + { + expandedLogMessage = string.Empty; + + // fast and slow paths available here + if (EvaluatesLogSourcesOnly) + { + foreach (var filter in _filters) + { + // saves on allocations in negative cases, where we can avoid expanding the message + if (filter.ShouldKeepMessage(evt) == LogFilterDecision.Drop) + return false; + } + } + else + { + // allocate the message just once + var nullCheck = evt.Message.ToString(); + + if (nullCheck == null) + return false; // no message to filter + + expandedLogMessage = nullCheck; + + foreach (var filter in _filters) + { + if (filter.ShouldKeepMessage(evt, expandedLogMessage) == LogFilterDecision.Drop) + return false; + } + } + + // expand the message if we haven't already + // NOTE: might result in duplicate allocations in third party logging libraries. They'll have to adjust their + // code accordingly after this feature ships. + expandedLogMessage = (string.IsNullOrEmpty(expandedLogMessage) ? evt.Message.ToString() : expandedLogMessage)!; + return true; + } + + /// + /// INTERNAL API - used to prevent unnecessary iterations when no filters are present + /// + private class EmptyLogFilterEvaluator : LogFilterEvaluator + { + public static readonly EmptyLogFilterEvaluator Instance = new(); + + private EmptyLogFilterEvaluator() : base(Array.Empty()) + { + } + + public override bool ShouldTryKeepMessage(LogEvent evt, out string expandedLogMessage) + { + expandedLogMessage = evt.Message.ToString()!; + return true; + } + } +} + +/// +/// Used to specify filters that can be used to curtail noise from sources in the Akka.NET log stream. +/// +public sealed class LogFilterSetup : Setup +{ + public LogFilterBase[] Filters { get; } + + public LogFilterEvaluator CreateEvaluator() => new(Filters); + + public LogFilterSetup(LogFilterBase[] filters) + { + Filters = filters; + } +} + +/// +/// Can be used to build a set of log filters to be used in conjunction with the . +/// +public sealed class LogFilterBuilder +{ + private readonly List _filters = new(); + + public LogFilterBuilder ExcludeSourceExactly(string source, + StringComparison comparison = StringComparison.OrdinalIgnoreCase) + { + _filters.Add(new ExactMatchLogSourceFilter(source, comparison)); + return this; + } + + public LogFilterBuilder ExcludeSourceStartingWith(string sourceStart) + { + _filters.Add(new RegexLogSourceFilter(new Regex($"^{Regex.Escape(sourceStart)}", RegexOptions.Compiled))); + return this; + } + + public LogFilterBuilder ExcludeSourceContaining(string sourcePart) + { + _filters.Add(new RegexLogSourceFilter(new Regex(Regex.Escape(sourcePart), RegexOptions.Compiled))); + return this; + } + + public LogFilterBuilder ExcludeSourceEndingWith(string sourceEnd) + { + _filters.Add(new RegexLogSourceFilter(new Regex($"{Regex.Escape(sourceEnd)}$", RegexOptions.Compiled))); + return this; + } + + /// + /// Performance boost: use your own pre-compiled Regex instance to filter log sources. + /// + public LogFilterBuilder ExcludeSourceRegex(Regex regex) + { + _filters.Add(new RegexLogSourceFilter(regex)); + return this; + } + + /// + /// Performance boost: use your own pre-compiled Regex instance to filter log messages. + /// + public LogFilterBuilder ExcludeMessageRegex(Regex regex) + { + _filters.Add(new RegexLogMessageFilter(regex)); + return this; + } + + public LogFilterBuilder ExcludeMessageContaining(string messagePart) + { + _filters.Add(new RegexLogMessageFilter(new Regex(Regex.Escape(messagePart), RegexOptions.Compiled))); + return this; + } + + public LogFilterBuilder Add(LogFilterBase filter) + { + _filters.Add(filter); + return this; + } + + public LogFilterBuilder AddRange(IEnumerable filters) + { + _filters.AddRange(filters); + return this; + } + + public LogFilterSetup Build() + { + return new LogFilterSetup(_filters.ToArray()); + } +} \ No newline at end of file diff --git a/src/core/Akka/Event/LoggingBus.cs b/src/core/Akka/Event/LoggingBus.cs index 8f19da3c46d..89acbb165eb 100644 --- a/src/core/Akka/Event/LoggingBus.cs +++ b/src/core/Akka/Event/LoggingBus.cs @@ -218,7 +218,7 @@ private void RemoveLogger(IActorRef logger) // Task ran to completion successfully var response = t.Result; - if (!(response is LoggerInitialized)) + if (response is not LoggerInitialized) { // Malformed logger, logger did not send a proper ack. Publish(new Error(null, loggingBusName, GetType(), diff --git a/src/core/Akka/Event/StandardOutLogger.cs b/src/core/Akka/Event/StandardOutLogger.cs index 343411e1724..40077b15455 100644 --- a/src/core/Akka/Event/StandardOutLogger.cs +++ b/src/core/Akka/Event/StandardOutLogger.cs @@ -9,12 +9,13 @@ using Akka.Actor; using Akka.Util; using System.Text; -using System.Threading; namespace Akka.Event { public abstract class MinimalLogger : MinimalActorRef { + public LogFilterEvaluator Filter { get; internal set; } = LogFilterEvaluator.NoFilters; + /// /// N/A /// @@ -55,7 +56,6 @@ protected sealed override void TellInternal(object message, IActorRef sender) /// public class StandardOutLogger : MinimalLogger { - /// /// Initializes the class. /// @@ -80,7 +80,7 @@ protected override void Log(object message) switch (message) { case LogEvent logEvent: - PrintLogEvent(logEvent); + PrintLogEvent(logEvent, Filter); break; default: @@ -118,10 +118,15 @@ protected override void Log(object message) /// Prints a specified event to the console. /// /// The event to print - internal static void PrintLogEvent(LogEvent logEvent) + /// + internal static void PrintLogEvent(LogEvent logEvent, LogFilterEvaluator filter) { try { + // short circuit if we're not going to print this message + if (!filter.ShouldTryKeepMessage(logEvent, out var expandedLogMessage)) + return; + ConsoleColor? color = null; if (UseColors) @@ -144,7 +149,7 @@ internal static void PrintLogEvent(LogEvent logEvent) } } - StandardOutWriter.WriteLine(logEvent.ToString(), color); + StandardOutWriter.WriteLine(expandedLogMessage, color); } catch (FormatException ex) {