Improve Logger With Destructuring of ExpandoObject (#6877)

* Initial implementation of log activity + base sink

* Refactor logging implementation: replace `Elsa.ProcessLogging` with a new modular `Elsa.Logging` framework, introducing support for configurable log sinks, enhanced logging extensibility, and updated dependencies in consuming projects.

* Enhance logging framework: introduce custom `NullableBoolConverter` and update JSON serialization/deserialization logic for log sink handling.

* Update description for `Log` activity input: clarify target sinks configuration

* Set default value of `SinkNames` input in `Log` activity to non-nullable collection

* Set `DisplayName` for `Sinks` input in `Log` activity

* Refactor logging framework: update `ILogSink` and `ILogSinkRouter` to support arguments and attributes, enhance `Log` activity to use updated interfaces, and add default category handling.

* Refactor logging framework: simplify argument handling in `ILogSink` and `ILogSinkRouter`, update `Log` activity inputs, and improve message formatting in `MelLogSink`.

* Update logging framework to simplify log sink creation, enhance category filtering, and refactor `ILogSink`/`ILogSinkRouter` interface methods.

* Introduce modular logging framework enhancements: add `Console` and `Serilog` logging features, refactor `ILogSink` framework, and update projects to align with a modular architecture.

* Refactor logging framework: introduce `AddCategoryFilters` extension, replace `DefaultCategory` handling with enhanced category filters, and update sink creation logic for consistency.

* Refactor logging framework: rename `SinkOptions` to `LogSinkOptions`, standardize naming across log sink types, and update configuration and sink factory logic for consistency.

* Enhance logging framework: add `ConfigureDefaults` methods, update `ILogSinkCatalog` to use `IServiceScopeFactory`, and improve logging configuration handling and defaults setup.

* Introduce asynchronous log entry processing: add `ILogEntryQueue`, `LogEntryBackgroundWorker`, and related models to enable queue-based logging and background processing. Update `Log` activity to enqueue log entries for processing.

* Add unit and integration tests for `Elsa.Logging.Core` library, refactor logger setup in `Elsa.Server.Web`, enhance logging configuration, and standardize `Directory.Packages.props` file.

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add documentation comments to logging framework classes, interfaces, methods, and factories to enhance code readability and maintainability. Remove unused `CustomPurpleConsoleFormatter` class and `logs` folder from server project.

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Make `LogEntryInstruction` and `LogEntryQueue` classes public and simplify return statement in `LogSinkCatalog.ListAsync` method.

* Standardize terminology in `ILogSink` interface and `LoggerSink` implementation: rename `properties` to `attributes`. Update project files and solution structure to reflect integration test additions.

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update documentation comments in `LoggingFeature` and `LogEntryInstruction` to clarify functionality and improve precision.

* Add README for `Elsa.Logging` module with configuration examples, usage details, and extension guidance.

* Add `Dictionary` UI hint to `InputUIHints` and update `Attributes` in `Log` activity to use it.

* Update `Log` activity default category to "Process", add integration tests for logging, and enhance null safety in `ConfigurationLogSinkProvider`.

* Remove `UseLoggingFramework` middleware from `Program.cs` to streamline workflow initialization.

* Refactor `LoggerSink` to simplify logging logic and remove unused `FormatMessage` method. Enhance `Log` activity argument handling by introducing JSON parsing for string inputs.

* Refactor logging tests to improve consistency and update variable initialization in `WorkflowExecutionContext` for cleaner syntax.

* Replace mocked logger dependencies in tests with `TestLogger` and `TestLoggerFactory` for improved readability and maintainability.

* Add `JsonDestructuringConsoleFormatter` for structured JSON logging and update logging configuration to support new formatter.

* Refactor JSON converters: replace `NullableBoolConverter` with `NullableBooleanConverter` and `BooleanConverter` for improved readability and consistency.

* Add error handling and logging to `LogEntryBackgroundWorker`

Introduce exception handling with logging in `LogEntryBackgroundWorker` to capture and log errors during log entry processing. Added `ILogger` dependency for structured error reporting.

---------

Co-authored-by: lucas.hipolito <lukhipolito@yahoo.com.br>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2025-08-28 08:53:15 +02:00 committed by GitHub
parent 8645493b04
commit ca85fe0f6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 309 additions and 122 deletions

View file

@ -25,7 +25,11 @@
},
"DatabaseProvider": "Sqlite",
"LoggingFramework": {
"Defaults": ["Console", "FilePretty", "FileJson"],
"Defaults": [
"Console",
"FilePretty",
"FileJson"
],
"Sinks": [
{
"Type": "Console",
@ -42,6 +46,17 @@
"DisableColors": true
}
},
{
"Type": "Console",
"Name": "Datadog",
"Options": {
"MinLevel": "Information",
"Formatter": "json",
"TimestampFormat": "O",
"UseUtcTimestamp": true,
"DisableColors": true
}
},
{
"Type": "Console",
"Name": "ConsoleSystemd",

View file

@ -0,0 +1,29 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elsa.Common.Converters;
public class BooleanConverter : JsonConverter<bool>
{
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.True:
return true;
case JsonTokenType.False:
return false;
case JsonTokenType.String:
var value = reader.GetString();
if (bool.TryParse(value, out var b))
return b;
break;
}
throw new JsonException($"Cannot convert {reader.TokenType} to bool");
}
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
{
writer.WriteBooleanValue(value);
}
}

View file

@ -0,0 +1,34 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elsa.Common.Converters;
public class NullableBooleanConverter : JsonConverter<bool?>
{
public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.True:
return true;
case JsonTokenType.False:
return false;
case JsonTokenType.String:
var value = reader.GetString();
if (bool.TryParse(value, out var b))
return b;
break;
case JsonTokenType.Null:
return null;
}
throw new JsonException($"Cannot convert {reader.TokenType} to bool?");
}
public override void Write(Utf8JsonWriter writer, bool? value, JsonSerializerOptions options)
{
if (value.HasValue)
writer.WriteBooleanValue(value.Value);
else
writer.WriteNullValue();
}
}

View file

@ -1,7 +1,9 @@
using Elsa.Logging.Contracts;
using Elsa.Logging.Extensions;
using Elsa.Logging.Sinks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console;
namespace Elsa.Logging.Console;
@ -20,13 +22,19 @@ public sealed class ConsoleLogSinkFactory : ILogSinkFactory<ConsoleLogSinkOption
/// <inheritdoc/>
public string Type => "Console";
/// <inheritdoc/>
public ILogSink Create(string name, ConsoleLogSinkOptions options)
{
var factory = LoggerFactory.Create(builder =>
{
builder.ClearProviders();
builder.AddCategoryFilters(options);
builder.Services.Configure<ConsoleFormatterOptions>(cfo =>
{
if (options.TimestampFormat is not null) cfo.TimestampFormat = options.TimestampFormat;
if (options.IncludeScopes is not null) cfo.IncludeScopes = options.IncludeScopes.Value;
if (options.UseUtcTimestamp is not null) cfo.UseUtcTimestamp = options.UseUtcTimestamp.Value;
});
var min = options.MinLevel ?? LogLevel.Information;
@ -49,14 +57,16 @@ public sealed class ConsoleLogSinkFactory : ILogSinkFactory<ConsoleLogSinkOption
if (options.IncludeScopes is not null) o.IncludeScopes = options.IncludeScopes.Value;
});
break;
default:
case "json":
builder.AddConsoleFormatter<JsonDestructuringConsoleFormatter, ConsoleFormatterOptions>();
builder.AddConsole(o =>
{
if (options.TimestampFormat is not null) o.TimestampFormat = options.TimestampFormat;
if (options.DisableColors is not null) o.DisableColors = options.DisableColors.Value;
if (options.IncludeScopes is not null) o.IncludeScopes = options.IncludeScopes.Value;
o.FormatterName = JsonDestructuringConsoleFormatter.FormatterName;
});
break;
default:
builder.AddConsole();
break;
}
builder.SetMinimumLevel(min);

View file

@ -15,7 +15,7 @@ public sealed record ConsoleLogSinkOptions : LogSinkOptions
// "Default" | "Simple" | "Systemd"
public string Formatter { get; init; } = "Default";
public string? TimestampFormat { get; init; }
public bool? IncludeScopes { get; init; }
public bool? IncludeScopes { get; init; } = true;
// Default console
public bool? DisableColors { get; init; }
@ -24,4 +24,5 @@ public sealed record ConsoleLogSinkOptions : LogSinkOptions
public LoggerColorBehavior? ColorBehavior { get; init; }
public bool? SingleLine { get; init; }
public bool? UseUtcTimestamp { get; init; }
public bool JsonIndented { get; set; } = true;
}

View file

@ -0,0 +1,84 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Console;
namespace Elsa.Logging.Console;
/// <summary>
/// A custom console formatter that formats log entries as JSON with destructured data.
/// </summary>
/// <remarks>
/// This formatter outputs log information in a JSON format, including details such as log level, category, message,
/// state, and scopes. The formatter is designed to provide a structured and easily parseable log output for improved log analysis.
/// </remarks>
public sealed class JsonDestructuringConsoleFormatter() : ConsoleFormatter(FormatterName)
{
public const string FormatterName = "json-destructuring";
public override void Write<TState>(in LogEntry<TState> logEntry, IExternalScopeProvider? scopes, TextWriter writer)
{
using var stream = new MemoryStream();
using var json = new Utf8JsonWriter(stream, new()
{
Indented = false
});
json.WriteStartObject();
json.WriteString("LogLevel", logEntry.LogLevel.ToString());
json.WriteString("Category", logEntry.Category);
json.WriteString("Message", logEntry.Formatter?.Invoke(logEntry.State, logEntry.Exception));
// STATE (TState might be IReadOnlyList<KeyValuePair<string,object?>>)
if (logEntry.State is IEnumerable<KeyValuePair<string, object?>> kvs)
{
json.WriteStartObject("State");
foreach (var kv in kvs)
{
if (kv.Key == "{OriginalFormat}")
{
json.WriteString(kv.Key, kv.Value?.ToString());
continue;
}
json.WritePropertyName(kv.Key);
JsonSerializer.Serialize(json, kv.Value);
}
json.WriteEndObject();
}
// SCOPES
if (scopes is not null)
{
json.WriteStartArray("Scopes");
scopes.ForEachScope((scope, state) =>
{
switch (scope)
{
case IEnumerable<KeyValuePair<string, object?>> scopeKvs:
state.WriteStartObject();
foreach (var kv in scopeKvs)
{
state.WritePropertyName(kv.Key);
JsonSerializer.Serialize(state, kv.Value);
}
state.WriteEndObject();
break;
default:
JsonSerializer.Serialize(state, scope);
break;
}
}, json);
json.WriteEndArray();
}
json.WriteEndObject();
json.Flush();
// Write the JSON string to the TextWriter
writer.Write(Encoding.UTF8.GetString(stream.ToArray()));
writer.WriteLine();
}
}

View file

@ -2,6 +2,7 @@ using Elsa.Logging.Contracts;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Elsa.Logging.HostedServices;
@ -10,21 +11,28 @@ namespace Elsa.Logging.HostedServices;
/// and routing them to appropriate log sinks.
/// </summary>
[UsedImplicitly]
public class LogEntryBackgroundWorker(ILogEntryQueue queue, IServiceScopeFactory scopeFactory) : BackgroundService
public class LogEntryBackgroundWorker(ILogEntryQueue queue, IServiceScopeFactory scopeFactory, ILogger<LogEntryBackgroundWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var instruction in queue.DequeueAsync().WithCancellation(stoppingToken))
{
using var scope = scopeFactory.CreateScope();
var router = scope.ServiceProvider.GetRequiredService<ILogSinkRouter>();
await router.WriteAsync(
instruction.SinkNames,
instruction.Category,
instruction.Level,
instruction.Message,
instruction.Arguments,
instruction.Attributes, stoppingToken);
try
{
using var scope = scopeFactory.CreateScope();
var router = scope.ServiceProvider.GetRequiredService<ILogSinkRouter>();
await router.WriteAsync(
instruction.SinkNames,
instruction.Category,
instruction.Level,
instruction.Message,
instruction.Arguments,
instruction.Attributes, stoppingToken);
}
catch (Exception e)
{
logger.LogError(e, "An error occurred while processing a log entry {@LogEntry}", instruction);
}
}
}
}

View file

@ -1,9 +1,9 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Common.Converters;
using Elsa.Extensions;
using Elsa.Logging.Contracts;
using Elsa.Logging.Models;
using Elsa.Logging.Serialization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
@ -21,7 +21,8 @@ public class ConfigurationLogSinkProvider : ILogSinkProvider
PropertyNameCaseInsensitive = true,
Converters =
{
new NullableBoolConverter(),
new NullableBooleanConverter(),
new BooleanConverter(),
new JsonStringEnumConverter()
}
};

View file

@ -1,44 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elsa.Logging.Serialization;
/// <summary>
/// A custom JSON converter for nullable boolean values.
/// Provides functionality to serialize and deserialize nullable boolean values
/// in JSON, including support for the string representation of boolean values
/// ("true", "false") and handling of null cases.
/// </summary>
public class NullableBoolConverter : JsonConverter<bool?>
{
public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
// Handle real boolean
case JsonTokenType.True:
return true;
case JsonTokenType.False:
return false;
// Handle string "true"/"false"
case JsonTokenType.String:
{
var value = reader.GetString();
if (bool.TryParse(value, out var b))
return b;
break;
}
}
// Handle null
return reader.TokenType == JsonTokenType.Null ? null : throw new JsonException($"Cannot convert {reader.TokenType} to bool?");
}
public override void Write(Utf8JsonWriter writer, bool? value, JsonSerializerOptions options)
{
if (value.HasValue)
writer.WriteBooleanValue(value.Value);
else
writer.WriteNullValue();
}
}

View file

@ -14,42 +14,13 @@ public sealed class LoggerSink(string name, ILoggerFactory factory) : ILogSink
/// <inheritdoc/>
public ValueTask WriteAsync(string name, LogLevel level, string message, object? arguments, IDictionary<string, object?>? attributes = null, CancellationToken cancellationToken = default)
{
var logger = factory.CreateLogger(name);
var l = factory.CreateLogger(name);
if (!logger.IsEnabled(level))
if (!l.IsEnabled(level))
return ValueTask.CompletedTask;
using var scope = attributes is null ? null : logger.BeginScope(attributes);
logger.Log(level, 0, arguments, null, (state, ex) => FormatMessage(message, state));
using var scope = attributes is null ? null : l.BeginScope(attributes);
l.Log(level, 0, null, message, arguments);
return ValueTask.CompletedTask;
}
private static string FormatMessage(string message, object? state)
{
// If the state is an array, use string.Format.
if (state is object[] { Length: > 0 } args)
return string.Format(message, args);
// Otherwise, use string interpolation. No need to use StringBuilder here, since the message is not expected to be long.
var formattedMessage = message;
// If the state is a dictionary, use string interpolation.
if (state is IDictionary<string, object?> dict)
{
foreach (var kvp in dict)
formattedMessage = formattedMessage.Replace($"{{{kvp.Key}}}", kvp.Value?.ToString());
}
// Otherwise, use reflection to find properties on the state object.
else if (state is not null)
{
var props = state.GetType().GetProperties();
foreach (var prop in props)
{
var value = prop.GetValue(state);
formattedMessage = formattedMessage.Replace($"{{{prop.Name}}}", value?.ToString());
}
}
return formattedMessage;
}
}

View file

@ -1,4 +1,6 @@
using System.Dynamic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Extensions;
using Elsa.Logging.Contracts;
@ -90,6 +92,13 @@ public class Log : CodeActivity
var message = Message.Get(context);
var level = Level.Get(context);
var arguments = Arguments.GetOrDefault(context);
if (arguments is string argumentString)
{
// Could be JSON created from e.g., Liquid template. If so, parse it into an ExpandoObject.
arguments = TryParseJson(argumentString);
}
var attributes = Attributes.GetOrDefault(context) ?? new Dictionary<string, object?>();
var sinkNames = SinkNames.GetOrDefault(context) ?? new List<string>();
var category = Category.GetOrDefault(context);
@ -112,4 +121,16 @@ public class Log : CodeActivity
};
await queue.EnqueueAsync(instruction);
}
private object TryParseJson(string json)
{
try
{
return JsonSerializer.Deserialize<ExpandoObject>(json) ?? new ExpandoObject();
}
catch
{
return json;
}
}
}

View file

@ -551,7 +551,7 @@ public partial class WorkflowExecutionContext : IExecutionContext
var now = SystemClock.UtcNow;
var id = IdentityGenerator.GenerateId();
var activityExecutionContext = new ActivityExecutionContext(id, this, parentContext, activity, activityDescriptor, now, tag, SystemClock, CancellationToken);
var variablesToDeclare = options?.Variables ?? Array.Empty<Variable>();
var variablesToDeclare = options?.Variables ?? [];
var variableContainer = new[]
{
activityExecutionContext.ActivityNode
@ -614,7 +614,7 @@ public partial class WorkflowExecutionContext : IExecutionContext
{
// Filter out completed activity execution contexts, except for the root Workflow activity context, which stores workflow-level variables.
// This will currently break scripts accessing activity output directly, but there's a workaround for that via variable capturing.
// We may ultimately restore direct output access, but in a different way.
// We may ultimately restore direct output access, but differently.
return ActivityExecutionContexts.Where(x => !x.IsCompleted || x.ParentActivityExecutionContext == null);
}

View file

@ -0,0 +1,22 @@
using Microsoft.Extensions.Logging;
namespace Elsa.Logging.Core.IntegrationTests.Helpers;
class TestLogger : ILogger
{
public List<(LogLevel level, EventId eventId, object state, Exception? exception, Delegate formatter)> Calls { get; } = new();
public bool IsEnabledCalled { get; private set; }
public bool IsEnabled(LogLevel logLevel)
{
IsEnabledCalled = true;
return true;
}
public IDisposable BeginScope<TState>(TState state) => null!;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
Calls.Add((logLevel, eventId, state!, exception, formatter));
}
}

View file

@ -0,0 +1,10 @@
using Microsoft.Extensions.Logging;
namespace Elsa.Logging.Core.IntegrationTests.Helpers;
class TestLoggerFactory(ILogger logger) : ILoggerFactory
{
public ILogger CreateLogger(string categoryName) => logger;
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
}

View file

@ -5,6 +5,7 @@ using Elsa.Logging.Contracts;
using Elsa.Logging.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Elsa.Logging.Core.IntegrationTests.Helpers;
using Moq;
namespace Elsa.Logging.Core.IntegrationTests;
@ -14,11 +15,9 @@ public class LogSinkRouterTests
[Fact]
public async Task LogEntryInstruction_ShouldFlowThroughQueueAndRouterToSink()
{
var loggerFactoryMock = new Mock<ILoggerFactory>();
var loggerMock = new Mock<ILogger>();
loggerFactoryMock.Setup(f => f.CreateLogger(It.IsAny<string>())).Returns(loggerMock.Object);
loggerMock.Setup(l => l.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var sink = new LoggerSink("TestSink", loggerFactoryMock.Object);
var testLogger = new TestLogger();
var loggerFactory = new TestLoggerFactory(testLogger);
var sink = new LoggerSink("TestSink", loggerFactory);
var catalogMock = new Mock<ILogSinkCatalog>();
catalogMock.Setup(c => c.ListAsync(CancellationToken.None)).ReturnsAsync(new List<ILogSink>
{
@ -38,7 +37,7 @@ public class LogSinkRouterTests
Level = LogLevel.Information,
Message = "Test message"
};
await queue.EnqueueAsync(instruction);
await foreach (var dequeued in queue.DequeueAsync())
{
@ -46,11 +45,10 @@ public class LogSinkRouterTests
break;
}
loggerMock.Verify(l => l.Log(
LogLevel.Information,
0,
null,
null,
It.IsAny<Func<object, Exception, string>>()!), Times.Once);
// Assert that Log was called once with expected parameters
Assert.Single(testLogger.Calls);
var call = testLogger.Calls[0];
Assert.Equal(LogLevel.Information, call.level);
Assert.Null(call.exception);
}
}

View file

@ -0,0 +1,22 @@
using Microsoft.Extensions.Logging;
namespace Elsa.Logging.Core.UnitTests.Helpers;
class TestLogger : ILogger
{
public List<(LogLevel level, EventId eventId, object state, Exception? exception, Delegate formatter)> Calls { get; } = new();
public bool IsEnabledCalled { get; private set; }
public bool IsEnabled(LogLevel logLevel)
{
IsEnabledCalled = true;
return true;
}
public IDisposable BeginScope<TState>(TState state) => null!;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
Calls.Add((logLevel, eventId, state!, exception, formatter));
}
}

View file

@ -0,0 +1,10 @@
using Microsoft.Extensions.Logging;
namespace Elsa.Logging.Core.UnitTests.Helpers;
class TestLoggerFactory(ILogger logger) : ILoggerFactory
{
public ILogger CreateLogger(string categoryName) => logger;
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
}

View file

@ -1,6 +1,6 @@
using Elsa.Logging.Core.UnitTests.Helpers;
using Elsa.Logging.Sinks;
using Microsoft.Extensions.Logging;
using Moq;
namespace Elsa.Logging.Core.UnitTests;
@ -9,17 +9,12 @@ public class LoggerSinkTests
[Fact]
public async Task WriteAsync_ShouldLogMessage()
{
var loggerFactoryMock = new Mock<ILoggerFactory>();
var loggerMock = new Mock<ILogger>();
loggerFactoryMock.Setup(f => f.CreateLogger(It.IsAny<string>())).Returns(loggerMock.Object);
loggerMock.Setup(l => l.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var sink = new LoggerSink("TestLogger", loggerFactoryMock.Object);
var testLogger = new TestLogger();
var loggerFactory = new TestLoggerFactory(testLogger);
var sink = new LoggerSink("TestLogger", loggerFactory);
await sink.WriteAsync("TestLogger", LogLevel.Information, "Test message", null, null);
loggerMock.Verify(l => l.Log(
LogLevel.Information,
0,
null,
null,
It.IsAny<Func<object, Exception, string>>()), Times.Once);
Assert.Single(testLogger.Calls);
var call = testLogger.Calls[0];
Assert.Equal(LogLevel.Information, call.level);
}
}