MailSharp / MailSharp.MailClient / Services / Logging / LiteDbLoggerProvider.cs
Code · 72 lines · 3195 bytes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72using MailSharp.MailClient.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace MailSharp.MailClient.Services.Logging;

// Custom ILogger sink that persists every log entry to LiteDB (see LogStore) so logs survive an
// app restart and are visible/filterable on the Maintenance page - unlike the console provider,
// which is lost the moment the process isn't attached to an interactive terminal (e.g. running as
// a Windows Service). Registered alongside the console provider, not instead of it; both receive
// the same events and both respect the standard Logging:LogLevel configuration, since this provider
// does no filtering of its own beyond what ILoggerFactory already applies.
public class LiteDbLoggerProvider(ILogStore logStore) : ILoggerProvider
{
	// LoggerExternalScopeProvider is the framework's own IExternalScopeProvider implementation
	// (AsyncLocal-backed) - owning one here rather than sharing the host's means every
	// logger.BeginScope() call made anywhere in the app is captured for this sink specifically,
	// without depending on ISupportExternalScopeProvider wiring from the logging builder.
	private readonly IExternalScopeProvider _scopeProvider = new LoggerExternalScopeProvider();

	public ILogger CreateLogger(string categoryName) => new LiteDbLogger(categoryName, logStore, _scopeProvider);

	public void Dispose() { }
}

public class LiteDbLogger(string categoryName, ILogStore logStore, IExternalScopeProvider scopeProvider) : ILogger
{
	public IDisposable? BeginScope<TState>(TState state) where TState : notnull =>
		scopeProvider.Push(state);

	public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;

	public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
	{
		if (!IsEnabled(logLevel)) return;

		var scopeData = new Dictionary<string, string>();
		scopeProvider.ForEachScope((scope, dict) => Flatten(scope, dict), scopeData);

		int? accountId = scopeData.TryGetValue("accountId", out var accountIdText) && int.TryParse(accountIdText, out var id) ? id : null;

		logStore.Add(new LogEntry
		{
			Timestamp = DateTime.UtcNow,
			Level = logLevel,
			Category = categoryName,
			Message = formatter(state, exception),
			Exception = exception?.ToString(),
			EventId = eventId.Id == 0 ? null : eventId.Id,
			AccountId = accountId,
			ScopeData = scopeData
		});
	}

	// BeginScope() is most commonly called with an IEnumerable<KeyValuePair<string,object>> (an
	// anonymous object or Dictionary, as passed throughout ImapService/SmtpService/the API
	// controllers) - flatten that shape; anything else just contributes its ToString() as a single
	// unnamed entry so scopes built some other way still show up rather than being silently dropped.
	private static void Flatten(object? scope, Dictionary<string, string> into)
	{
		if (scope is IEnumerable<KeyValuePair<string, object?>> pairs)
		{
			foreach (var kvp in pairs)
			{
				if (kvp.Value != null) into[kvp.Key] = kvp.Value.ToString() ?? "";
			}
			return;
		}

		if (scope != null) into[$"scope{into.Count}"] = scope.ToString() ?? "";
	}
}