4
\$\begingroup\$

I'm experimenting with an alternative abstraction layer for logging that is build on top of the Reactive Extensions. This allows me to easily add features I need to my layer and map logs to any other logger I want (not shown). Filtering and buffering etc is also very easy because Rx already implements all that.


The story begins with the LoggerFactory. I use this to create the rootLogger which all other loggers use to publish their logs. In addition it can hold a collection of IComputables that are properties which are automatically added to each Log. Listeners can subscribe to a factory to listen for logs.

public interface ILoggerFactory
{
    ILogger CreateLogger(string name);
}

public class LoggerFactory : IObservable<Log>
{
    private readonly ConcurrentDictionary<SoftString, ILogger> _cache = new ConcurrentDictionary<SoftString, ILogger>();

    private readonly Subject<Log> _rootLogger = new Subject<Log>();

    public List<IComputable> AutoProperties { get; set; } = new List<IComputable>();

    public IDisposable Subscribe(IObserver<Log> listener)
    {
        return _rootLogger.Subscribe(listener);
    }

    public ILogger CreateLogger(SoftString name)
    {
        return _cache.GetOrAdd(name, n =>
        {
            return new Logger(name, _rootLogger, AutoProperties);
        });
    }
}

Next comes the Logger. Upon creation it receives the rootLogger and the automatic-properties form a factory. The Log method renders the log and sends it to all listeners via the rootLogger.

public interface ILogger
{
    SoftString Name { get; }

    ILogger Log(Log logEntry);
}

public class Logger : ILogger
{
    private readonly SoftString _name;

    private readonly IObserver<Log> _rootLogger;

    private readonly IEnumerable<IComputable> _autoProperties;

    public Logger(SoftString name, IObserver<Log> rootLogger, IEnumerable<IComputable> autoProperties)
    {
        _name = name;
        _rootLogger = rootLogger;
        _autoProperties = autoProperties;
    }

    public SoftString Name => _name;

    public ILogger Log(Log log)
    {
        _rootLogger.OnNext(log.Render(_autoProperties));
        return this;
    }
}

public enum LogLevel
{
    Trace,
    Debug,
    Information,
    Warning,
    Error,
    Fatal
}

Helper methods for logging are provided via extensions to the ILogger:

public static class LoggerExtensions
{
    public static ILogger Trace(this ILogger logger, string message, Func<Log, Log> createLog = null)
    {
        return logger.Log(CreateLog(logger, LogLevel.Trace, message, null, createLog));
    }

    public static ILogger Debug(this ILogger logger, string message, Func<Log, Log> createLog = null)
    {
        return logger.Log(CreateLog(logger, LogLevel.Debug, message, null, createLog));
    }

    public static ILogger Warning(this ILogger logger, string message, Func<Log, Log> createLog = null)
    {
        return logger.Log(CreateLog(logger, LogLevel.Warning, message, null, createLog));
    }

    public static ILogger Information(this ILogger logger, string message, Func<Log, Log> createLog = null)
    {
        return logger.Log(CreateLog(logger, LogLevel.Information, message, null, createLog));
    }

    public static ILogger Error(this ILogger logger, string message, Exception exception, Func<Log, Log> createLog = null)
    {
        return logger.Log(CreateLog(logger, LogLevel.Error, message, exception, createLog));
    }

    public static ILogger Fatal(this ILogger logger, string message, Exception exception, Func<Log, Log> createLog = null)
    {
        return logger.Log(CreateLog(logger, LogLevel.Fatal, message, exception, createLog));
    }

    private static Log CreateLog(ILogger logger, LogLevel logLevel, string message, Exception exception, Func<Log, Log> customLog)
    {
        return (customLog ?? new Func<Log, Log>(l => l))(Log.Create(logger.Name, logLevel, message, exception));
    }

    public static LogScope BeginScope(this ILogger logger, object obj)
    {
        return LogScope.Push(DictionaryFactory.CreateFromProjection(obj));
    }
}

The Log class itself is a Dictionary<> and for creation requires a few basic parameters that a log at leat must have.

public class Log : Dictionary<SoftString, object>
{
    internal Log() { }

    private Log(SoftString category, LogLevel logLevel, string message, Exception exception)
    {
        this[LogProperties.Category] = category;
        this[LogProperties.LogLevel] = logLevel;
        this[LogProperties.Message] = message;

        if (exception != null)
        {
            this[LogProperties.Exception] = exception;
        }
    }

    public static Log Create(SoftString category, LogLevel logLevel, string message, Exception exception)
    {
        return new Log(category, logLevel, message, exception);
    }
}

The LogExtensions provide more helper methods like the Render method that executes all computables and concatenates various dictionaries to build the final log from scopes and automatic-properties.

public static class LogExtensions
{
    public static Log Render(this Log log, IEnumerable<IComputable> autoProperties)
    {
        var result = new Log();

        var properties = log
            .Concat(autoProperties.ToDictionary(p => p.Name, p => (object)p))
            .Concat(LogScope.Current.Flatten().SelectMany(x => x));

        foreach (var item in properties)
        {
            if (item.Value is IComputable computable)
            {
                result[item.Key] = computable.Compute(log);
            }
            else
            {
                result[item.Key] = item.Value;
            }
        }

        return result;
    }

    public static bool TryGetValue<T>(this Log log, SoftString name, out T value)
    {
        if (log.TryGetValue(name, out var obj) && obj is T result)
        {
            value = result;
            return true;
        }

        value = default(T);
        return false;
    }
}

The names of most common properies are defined in a static class.

public static class LogProperties
{
    public static readonly SoftString Category = nameof(Category);
    public static readonly SoftString LogLevel = nameof(LogLevel);
    public static readonly SoftString Message = nameof(Message);
    public static readonly SoftString Exception = nameof(Exception);
    public static readonly SoftString Elapsed = nameof(Elapsed);
    public static readonly SoftString CallerMemberName = nameof(CallerMemberName);
    public static readonly SoftString CallerLineNumber = nameof(CallerLineNumber);
    public static readonly SoftString CallerFilePath = nameof(CallerFilePath);
}

The idea of scope is supported by LogScope class. I admit it is partialy based on the ASP.NET Core implementation for ConsoleLogScope but under the hood it's quite different. My sope is a dictionary and it can provide default properties like the Elapsed one.

public class LogScope : Dictionary<SoftString, object>, IDisposable
{
    private static AsyncLocal<LogScope> _current = new AsyncLocal<LogScope>();

    private LogScope(int depth, IDictionary<SoftString, object> state) : base(state)
    {
        Depth = depth;
        this[LogProperties.Elapsed] = new Elapsed();
    }

    public LogScope Parent { get; private set; }

    public int Depth { get; }

    public string Name => $"{nameof(LogScope)}{Depth}";

    public static LogScope Current
    {
        get => _current.Value;
        set => _current.Value = value;
    }

    public static LogScope Push(IDictionary<SoftString, object> state)
    {
        return Current = new LogScope(Current?.Depth + 1 ?? 0, state)
        {
            Parent = Current
        };
    }

    public void Dispose()
    {
        Current = Current.Parent;
    }
}

Extensions for the LogScope help me to work with it when rendering logs or creating scopes by either flattening it or adding caller-info to it.

public static class LogScopeExtensions
{
    public static IEnumerable<LogScope> Flatten(this LogScope scope)
    {
        var current = scope;
        while (current != null)
        {
            yield return current;
            current = current.Parent;
        }
    }

    public static IDisposable WithCallerInfo(
        this LogScope scope,
        [CallerMemberName] string callerMemberName = null,
        [CallerLineNumber] int callerLineNumber = 0,
        [CallerFilePath] string callerFilePath = null
    )
    {
        scope.Add(LogProperties.CallerMemberName, callerMemberName);
        scope.Add(LogProperties.CallerLineNumber, callerLineNumber);
        scope.Add(LogProperties.CallerFilePath, callerFilePath);

        return scope;
    }
}

Scope dictionaries are created from projections and are implemented in a DictionaryFactory.

internal static class DictionaryFactory
{
    public static IDictionary<SoftString, object> CreateFromProjection(object obj)
    {
        return
            obj?.GetType()
                .GetProperties(BindingFlags.Public | BindingFlags.Instance)
                .ToDictionary(p => SoftString.Create(p.Name), p => p.GetValue(obj))
            ?? new Dictionary<SoftString, object>();
    }
}

Computable properties are based on the IComputable interface with a single method that requries the current log.

public interface IComputable : IEquatable<IComputable>
{
    [AutoEqualityProperty]
    SoftString Name { get; }

    object Compute(Log logEntry);
}

public abstract class Computable : IComputable
{
    protected Computable(SoftString name) => Name = name;

    protected Computable() => Name = GetType().Name;

    public SoftString Name { get; }

    public abstract object Compute(Log logEntry);

    public bool Equals(IComputable other) => AutoEquality<IComputable>.Comparer.Equals(this, other);

    public override bool Equals(object obj) => Equals(obj as IComputable);

    public override int GetHashCode() => AutoEquality<IComputable>.Comparer.GetHashCode(this);
}

public class UtcNow : Computable
{
    public override object Compute(Log log) => DateTime.UtcNow;
}

public class Elapsed : Computable
{
    public override object Compute(Log log)
    {
        return log.TryGetValue<Stopwatch>(LogProperties.Elapsed, out var stopwatch) ? stopwatch.Elapsed : TimeSpan.Zero;
    }
}

public class Lambda : Computable
{
    private readonly Func<Log, object> _compute;

    public Lambda(SoftString name, Func<Log, object> compute) : base(name)
    {
        _compute = compute ?? throw new ArgumentNullException(nameof(compute));
    }

    public override object Compute(Log log) => _compute(log);
}

Example

To use the logger I first need to create a factory. Optionally I can specify automatic-properties.

var loggerFactory = new LoggerFactory
{
    AutoProperties =
    {
        new UtcNow()
    }
};

Then I need to subscribe some listeners with optional filtering.

loggerFactory
    //.Where(l => (LogLevel)l["LogLevel"] == LogLevel.Debug)
    .Subscribe(ConsoleObserver.Create());

Now I'm ready to log.

var logger = loggerFactory.CreateLogger("Test");

using (var scope = logger.BeginScope(new { Transaction = 123 }).WithCallerInfo())
{
    logger.Information("Hallo!");
    logger.Debug("This is a debug message.");

    try
    {           
        throw new DivideByZeroException();
    }
    catch (Exception ex)
    {
        logger.Error("Division failed!", ex);
    }
}

An observer can be as simple as that:

public static class ConsoleObserver
{
    public static IObserver<Log> Create() => Observer.Create<Log>(log => Console.WriteLine(log));
}

One of the output of this simple program is:

Key               Value
---               -----
Category          Test 
LogLevel          Information 
Message           Hallo! 
UtcNow            25.10.2017 17:09:17 
Transaction       123 
Elapsed           00:00:00 
CallerMemberName  Main 
CallerLineNumber  55 
CallerFilePath    C:\...\query_fnuete.cs 

This implementation is a working proof-of-concept and it lacks null checks everywhere. I'll be adding them after implementing any improvements later.

Do you think this code can still be improved? Or is there anything terribly wrong with it?


*SoftString is my own custom type that represents a trimmed and case-insensitive string.

\$\endgroup\$
2
\$\begingroup\$

The only issue I see is the Subject class implements IDisposable and should be disposed of. Which would mean your ILoggerFactory now needs to be IDisposable.

(Taken from MS documentation)

Disposing of a subject releases all resources used by the current instance of the Subject class and unsubscribe all observers

If all your loggers call Dispose then you might be able to get away with not disposing the Subject but it's good practice to dispose of the Subject.

\$\endgroup\$
  • \$\begingroup\$ Ah, good catch. Disposing the logger-factory would then unsubscribe all loggers! ;-) \$\endgroup\$ – t3chb0t Oct 27 '17 at 14:34

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.