Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -3474,6 +3474,14 @@ namespace Akka.Event
{
public LoggerMailbox(Akka.Actor.IActorRef owner, Akka.Actor.ActorSystem system) { }
}
public sealed class LoggerSetup : Akka.Actor.Setup.Setup
{
public Akka.Event.ILogMessageFormatter? Formatter { get; }
public System.Collections.Generic.IReadOnlyList<Akka.Actor.Props> Loggers { get; }
public static Akka.Event.LoggerSetup Create(params Akka.Actor.Props[] loggers) { }
public static Akka.Event.LoggerSetup Create(System.Collections.Generic.IEnumerable<Akka.Actor.Props> loggers) { }
public static Akka.Event.LoggerSetup Create(Akka.Event.ILogMessageFormatter formatter, params Akka.Actor.Props[] loggers) { }
}
public static class Logging
{
public static System.Type ClassFor(this Akka.Event.LogLevel logLevel) { }
Expand Down
173 changes: 173 additions & 0 deletions src/core/Akka.Tests/Loggers/LoggerSetupSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//-----------------------------------------------------------------------
// <copyright file="LoggerSetupSpec.cs" company="Akka.NET Project">
// Copyright (C) 2009-2025 Lightbend Inc. <http://www.lightbend.com>
// Copyright (C) 2013-2025 .NET Foundation <https://github.com/akkadotnet/akka.net>
// </copyright>
//-----------------------------------------------------------------------

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Akka.Actor;
using Akka.Actor.Setup;
using Akka.Configuration;
using Akka.Dispatch;
using Akka.Event;
using Akka.TestKit;
using Akka.TestKit.Xunit;
using Akka.Tests.Util;
using FluentAssertions;
using Xunit;

namespace Akka.Tests.Loggers;

/// <summary>
/// A logger actor generic over a marker type, so each spec's own marker (e.g. <c>LoggerA</c> vs
/// <c>LoggerB</c>) makes a distinct closed generic type with its own instance counter and forwarding
/// probe - lets a spec name the "same" logger through both <c>akka.loggers</c> and a
/// <see cref="LoggerSetup"/> and check it started once.
/// </summary>
public sealed class CapturingLogger<TMarker> : ActorBase, IRequiresMessageQueue<ILoggerMessageQueueSemantics>
{
private static int _instances;
public static int Instances => _instances;
public static IActorRef? Probe { get; set; }

public CapturingLogger() => Interlocked.Increment(ref _instances);

protected override bool Receive(object message)
{
switch (message)
{
case InitializeLogger:
Sender.Tell(new LoggerInitialized());
return true;
case LogEvent logEvent:
Probe?.Tell(logEvent);
return true;
default:
return false;
}
}
}

/// <summary>
/// A minimal <see cref="ILogMessageFormatter"/> stand-in for a third-party formatter (e.g.
/// Akka.Logger.Serilog's <c>SerilogLogMessageFormatter</c>) - wraps the formatted text so a spec can
/// tell it apart from <see cref="DefaultLogMessageFormatter"/>'s output.
/// </summary>
public sealed class MarkerLogMessageFormatter : ILogMessageFormatter
{
public string Format(string format, params object[] args) => $"<<{string.Format(format, args)}>>";

public string Format(string format, IEnumerable<object> args) => Format(format, args.ToArray());
}

public class AdditiveLoggerSetupSpec : AkkaSpec
{
private sealed class LoggerA { }
private sealed class LoggerB { }

private static readonly string LoggerATypeName =
$"{typeof(CapturingLogger<LoggerA>).FullName}, {typeof(CapturingLogger<LoggerA>).Assembly.GetName().Name}";

private static ActorSystemSetup CreateSetup()
{
var config = ConfigurationFactory.ParseString($@"akka.loggers = [""{LoggerATypeName}""]");
var loggerSetup = LoggerSetup.Create(
Props.Create<CapturingLogger<LoggerB>>(),
Props.Create<CapturingLogger<LoggerA>>());
return ActorSystemSetup.Create(BootstrapSetup.Create().WithConfig(config), loggerSetup);
}

public AdditiveLoggerSetupSpec(ITestOutputHelper output)
: base(CreateSetup(), output: output)
{
}

[Fact(DisplayName = "Should_StartLoggerOnce_When_TheSameTypeIsInHoconAndLoggerSetup")]
public async Task Should_StartLoggerOnce_When_TheSameTypeIsInHoconAndLoggerSetup()
{
var probeB = CreateTestProbe();
CapturingLogger<LoggerB>.Probe = probeB;

Logging.GetLogger(Sys, "AdditiveSource").Warning("Hello");

// B only exists via the LoggerSetup - proves the setup is additive to akka.loggers, not a
// replacement for it.
await probeB.ExpectMsgAsync<Warning>(TimeSpan.FromSeconds(5));

// A is named by both akka.loggers and the LoggerSetup - only the LoggerSetup instance starts.
CapturingLogger<LoggerA>.Instances.Should().Be(1);
}
}

[Collection(DynamicTypeLoadingCollection.Name)]
public class DynamicTypeLoadingOffLoggerSetupSpec
{
private sealed class OffMarker { }

[Fact(DisplayName = "Should_StartLoggerAndUseItsFormatter_When_DynamicTypeLoadingIsDisabled")]
public async Task Should_StartLoggerAndUseItsFormatter_When_DynamicTypeLoadingIsDisabled()
{
var formatter = new MarkerLogMessageFormatter();
var loggerSetup = LoggerSetup.Create(formatter, Props.Create<CapturingLogger<OffMarker>>());
ActorSystem? sys = null;

// Only the system start needs to run with the switch off - a type-based LoggerSetup
// registration and its formatter need no Type.GetType, so both start fine without dynamic
// type loading. The TestKit's own probe plumbing resolves its dispatcher by reflection and
// isn't part of what this spec is checking, so the switch is back on before that runs.
await AkkaFeaturesSpec.WithDynamicTypeLoading(false, () =>
{
sys = ActorSystem.Create("DynamicTypeLoadingOffLoggerSetupSpec", ActorSystemSetup.Create(loggerSetup));
return Task.CompletedTask;
});

try
{
sys!.Settings.LogFormatter.Should().BeSameAs(formatter);

var probe = new TestProbe(sys, new XunitAssertions());
CapturingLogger<OffMarker>.Probe = probe;

Logging.GetLogger(sys, "OffSource").Warning("NoReflectionNeeded {0}", "here");

var warning = await probe.ExpectMsgAsync<Warning>(TimeSpan.FromSeconds(5));
warning.Message.ToString().Should().Be("<<NoReflectionNeeded here>>");
}
finally
{
if (sys != null)
await sys.Terminate();
}
}
}

public class LoggerSetupFormatterPrecedenceSpec : AkkaSpec
{
private static readonly MarkerLogMessageFormatter SetupFormatter = new();

private static ActorSystemSetup CreateSetup()
{
// A built-in, resolvable value, so a reversed precedence would still fail the assertion below
// with a *different* formatter instance rather than an exception.
var config = ConfigurationFactory.ParseString(
@"akka.logger-formatter = ""Akka.Event.SemanticLogMessageFormatter""");
return ActorSystemSetup.Create(BootstrapSetup.Create().WithConfig(config), LoggerSetup.Create(SetupFormatter));
}

public LoggerSetupFormatterPrecedenceSpec(ITestOutputHelper output)
: base(CreateSetup(), output: output)
{
}

[Fact(DisplayName = "Should_PreferLoggerSetupFormatter_When_AkkaLoggerFormatterIsAlsoSet")]
public void Should_PreferLoggerSetupFormatter_When_AkkaLoggerFormatterIsAlsoSet()
{
Sys.Settings.LogFormatter.Should().BeSameAs(SetupFormatter);
}
}
40 changes: 26 additions & 14 deletions src/core/Akka/Actor/Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,24 +188,36 @@ public Settings(ActorSystem system, Config config, ActorSystemSetup setup)
LoggerStartTimeout = Config.GetTimeSpan("akka.logger-startup-timeout", null);
LoggerAsyncStart = Config.GetBoolean("akka.logger-async-start", false);

var loggerFormatterName = Config.GetString("akka.logger-formatter", null);
if (string.IsNullOrWhiteSpace(loggerFormatterName))
// A LoggerSetup formatter is the AOT-safe escape hatch for a third-party formatter (e.g.
// Akka.Logger.Serilog's SerilogLogMessageFormatter) that HOCON can no longer resolve by type
// name with dynamic type loading off; it wins over akka.logger-formatter when present.
var loggerSetupFormatter = Setup.Get<LoggerSetup>().Select(s => s.Formatter).GetOrElse(null);
if (loggerSetupFormatter is not null)
{
LogFormatter = DefaultLogMessageFormatter.Instance;
}
else if (TypeExtensions.ToBuiltInAkkaTypeName(loggerFormatterName) is { } builtInLogFormatterName &&
BuiltInLogMessageFormatters.TryGetValue(builtInLogFormatterName, out var logFormatterFactory))
{
LogFormatter = logFormatterFactory();
}
else if (AkkaFeatures.IsDynamicTypeLoadingSupported)
{
LogFormatter = CreateLogMessageFormatter(loggerFormatterName);
LogFormatter = loggerSetupFormatter;
}
else
{
throw new ConfigurationException(AkkaFeatures.NotBuiltIn(
"akka.logger-formatter", loggerFormatterName, "one of the built-in log message formatters"));
var loggerFormatterName = Config.GetString("akka.logger-formatter", null);
if (string.IsNullOrWhiteSpace(loggerFormatterName))
{
LogFormatter = DefaultLogMessageFormatter.Instance;
}
else if (TypeExtensions.ToBuiltInAkkaTypeName(loggerFormatterName) is { } builtInLogFormatterName &&
BuiltInLogMessageFormatters.TryGetValue(builtInLogFormatterName, out var logFormatterFactory))
{
LogFormatter = logFormatterFactory();
}
else if (AkkaFeatures.IsDynamicTypeLoadingSupported)
{
LogFormatter = CreateLogMessageFormatter(loggerFormatterName);
}
else
{
throw new ConfigurationException(AkkaFeatures.NotBuiltIn(
"akka.logger-formatter", loggerFormatterName,
"one of the built-in log message formatters, or a LoggerSetup formatter"));
}
}

//handled
Expand Down
101 changes: 101 additions & 0 deletions src/core/Akka/Event/LoggerSetup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//-----------------------------------------------------------------------
// <copyright file="LoggerSetup.cs" company="Akka.NET Project">
// Copyright (C) 2009-2025 Lightbend Inc. <http://www.lightbend.com>
// Copyright (C) 2013-2025 .NET Foundation <https://github.com/akkadotnet/akka.net>
// </copyright>
//-----------------------------------------------------------------------

#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Akka.Actor;
using Akka.Actor.Setup;

namespace Akka.Event;

/// <summary>
/// Loggers to start with the <see cref="ActorSystem"/>, the programmatic equivalent of
/// <c>akka.loggers</c> that needs no type names - the AOT-safe alternative, since HOCON-configured
/// loggers are resolved with <see cref="Type.GetType(string)"/>. Each entry is the <see cref="Props"/>
/// for an actor that handles <see cref="InitializeLogger"/> the way <see cref="LoggingBus"/> expects
/// any logger to (see <see cref="LoggingBus.StartDefaultLoggers"/>); build one with
/// <see cref="Akka.Actor.Props.Create{TActor}(object[])"/> against your logger's own type.
/// </summary>
/// <remarks>
/// <para>
/// A <see cref="LoggerSetup"/> is additive to <c>akka.loggers</c>, not a replacement for it: both
/// start, and a logger type named by both starts once. The default <c>akka.loggers = [DefaultLogger]</c>
/// keeps printing to standard output alongside the loggers registered here unless you also set
/// <c>akka.loggers = []</c>.
/// </para>
/// <para>
/// <see cref="Formatter"/> is the same kind of AOT-safe escape hatch for <c>akka.logger-formatter</c> -
/// useful for a third-party formatter, such as Akka.Logger.Serilog's <c>SerilogLogMessageFormatter</c>,
/// that HOCON can no longer resolve by type name with dynamic type loading off.
/// </para>
/// <para>
/// Like any <see cref="Setup"/>, a second <see cref="LoggerSetup"/> passed to
/// <see cref="ActorSystemSetup.And{T}"/> replaces the first - only one <see cref="LoggerSetup"/> exists
/// per system.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// var setup = ActorSystemSetup.Create(LoggerSetup.Create(Props.Create&lt;MyCustomLogger&gt;()));
/// var system = ActorSystem.Create("MySystem", setup);
/// </code>
/// </example>
public sealed class LoggerSetup : Setup
{
private LoggerSetup(IList<Props> loggers, ILogMessageFormatter? formatter = null)
{
Loggers = new ReadOnlyCollection<Props>(loggers);
Formatter = formatter;
}

/// <summary>
/// The logger <see cref="Props"/> to start when the <see cref="ActorSystem"/> starts.
/// </summary>
public IReadOnlyList<Props> Loggers { get; }

/// <summary>
/// The log message formatter to use in place of <c>akka.logger-formatter</c>, or <c>null</c> to keep
/// resolving it from HOCON.
/// </summary>
public ILogMessageFormatter? Formatter { get; }

private static LoggerSetup CreateInternal(Props[] loggers, ILogMessageFormatter? formatter)
{
if (loggers.Any(p => p is null))
throw new ArgumentException("Logger Props must not be null.", nameof(loggers));
return new LoggerSetup(loggers, formatter);
}

/// <summary>
/// Creates a <see cref="LoggerSetup"/> that starts the given loggers.
/// </summary>
/// <param name="loggers">The logger <see cref="Props"/> to start when the <see cref="ActorSystem"/> starts.</param>
/// <exception cref="ArgumentException">An entry is <c>null</c>.</exception>
public static LoggerSetup Create(IEnumerable<Props> loggers)
=> CreateInternal(loggers.ToArray(), null);

/// <summary>
/// Creates a <see cref="LoggerSetup"/> that starts the given loggers.
/// </summary>
/// <param name="loggers">The logger <see cref="Props"/> to start when the <see cref="ActorSystem"/> starts.</param>
/// <exception cref="ArgumentException">An entry is <c>null</c>.</exception>
public static LoggerSetup Create(params Props[] loggers)
=> CreateInternal(loggers, null);

/// <summary>
/// Creates a <see cref="LoggerSetup"/> that starts the given loggers and uses
/// <paramref name="formatter"/> in place of <c>akka.logger-formatter</c>.
/// </summary>
/// <param name="formatter">The log message formatter to use instead of HOCON's <c>akka.logger-formatter</c>.</param>
/// <param name="loggers">The logger <see cref="Props"/> to start when the <see cref="ActorSystem"/> starts.</param>
/// <exception cref="ArgumentException">An entry is <c>null</c>.</exception>
public static LoggerSetup Create(ILogMessageFormatter formatter, params Props[] loggers)
=> CreateInternal(loggers, formatter);
}
Loading
Loading