A Simple Logger for .net Core

I recently started looking at writing new development in .net Core 2.2 instead of my standard MVC5. This meant rewriting my class libraries as they are not compatible without adding in a dependency on the .net Framework.

.net Core provides a logging framework out of the box and its very snazzy but all I want from a logger is for it to log the error and the location to a text file. This isn’t even supported in the default ILogger implementations in .net Core. I am a great believer in logging being as simple as possible as I don’t want to have errors when logging my errors.

I decided to adapt my old logging code to .net core and also take advantage of Caller Information Attributes as I was previously using Reflection and walking up the stack to find where the error occurred.

First thing we need is an Interface so we can easily inject the logger. I used to call my interface ILogger but if I do that in .net Core it will be confusing so I am now going to use ISimpleLogger (because it’s a simple logger).

public interface ISimpleLogger
    {
        void LogError(string message, string username = null, int eventID = 0, [CallerMemberName] string methodName = "", [CallerFilePath] string callerName = "");

        void LogInformation(string message, string username = null, int eventID = 0, [CallerMemberName] string methodName = "", [CallerFilePath] string callerName = "");

        void LogWarning(string message, string username = null, int eventID = 0, [CallerMemberName] string methodName = "", [CallerFilePath] string callerName = "");

        void LogDebug(string message, string username = null, int eventID = 0, [CallerMemberName] string methodName = "", [CallerFilePath] string callerName = "");

        /// <summary>
        /// The location of the current log
        /// </summary>
        string LogLocation { get; }
    }

If we look at LogError we can see that i am passing:

  • message – the text of the error. As much or as little as I want.
  • username – optional but I like to know who the error belongs to.
  • eventID – optional. Used for tracing which is needed with Azure error logging. I know it’s a violation of a SOLID principal. Can’t remember which one.
  • methodName – automatically populated with the calling method’s name.
  • callerName – automatically populated with the file path to the calling class. Alas “Caller Information Attributes” do not include class name so this is the next best thing.

The only additional thing we need is some way to tell the logger what level of logging to apply. I do this with an enumeration called LogLevelEnum.

public enum LogLevelEnum : int
{
	/// <summary>
	/// Don't Log anything
	/// </summary>
	None = 0,

	/// <summary>
	/// Exceptions/Errors
	/// </summary>
	Error = 2,

	/// <summary>
	/// Warnings
	/// </summary>
	Warning = 4 | Error,

	/// <summary>
	/// Information
	/// </summary>
	Information = 8 | Warning | Error,

	/// <summary>
	/// Debug
	/// </summary>
	Debug = 16 | Information | Warning | Error
}

This Enum uses values which happen to work as binary flags so they can be OR’d.

Now we are ready to create a concrete class. As the whole point of the exercise is to log to a simple file, we will start with SimpleFileLogger.

This is a simple logger but I want it to be able to create it’s log folder if it doesn’t exist, be threadsafe and to accept a filename or use Today’s date.

First we create a new class called SimpleFileLogger and implement the ISimpleLogger interface.

public class SimpleFileLogger : ISimpleLogger

We then add two constructors, one that takes a LogLevelEnum and a folder path and one that also takes a log filename.

public class SimpleFileLogger : ISimpleLogger
{
	private string _directoryPath;
	private LogLevelEnum _loggingLevel;
	private bool _generateFileName = true;

	public SimpleFileLogger(LogLevelEnum logLevel, string directoryPath)
	{
		_loggingLevel = logLevel;
		_directoryPath = directoryPath;

		if (!Directory.Exists(_directoryPath))
		{
			// Try to create the directory.
			Directory.CreateDirectory(_directoryPath);
		}
	}

	public SimpleFileLogger(LogLevelEnum logLevel, string directoryPath, string fileName) : this(logLevel, directoryPath)
	{
		_logFileName = fileName;
		_generateFileName = false;
	}
}

The interface methods are all very similar so all they will do is pass the correct logging level over to a private log function that actually writes the file.

[DebuggerStepThrough]
public void LogDebug(string message, string username = null, int eventID = 0, [CallerMemberName] string methodName = "", [CallerFilePath] string callerName = "")
{
	log(LogLevelEnum.Debug, message, methodName, callerName, username, eventID);
}

[DebuggerStepThrough]
public void LogError(string message, string username = null, int eventID = 0, [CallerMemberName] string methodName = "", [CallerFilePath] string callerName = "")
{
	log(LogLevelEnum.Error, message, methodName, callerName, username, eventID);
}

[DebuggerStepThrough]
public void LogInformation(string message, string username = null, int eventID = 0, [CallerMemberName] string methodName = "", [CallerFilePath] string callerName = "")
{
	log(LogLevelEnum.Information, message, methodName, callerName, username, eventID);
}

[DebuggerStepThrough]
public void LogWarning(string message, string username = null, int eventID = 0, [CallerMemberName] string methodName = "", [CallerFilePath] string callerName = "")
{
	log(LogLevelEnum.Warning, message, methodName, callerName, username, eventID);
}

Note the [DebuggerStepThrough] attribute on each method. This makes the debugger automatically step over the methods so you don’t constantly end up in them when debugging.

The LogLocation method is handy because if the filename is auto-generated you might not know what it is called (The filename generation code should probably be moved to a separate function as it is used more than once).

public string LogLocation
{
	get
	{
		if (_generateFileName)
		{
			return Path.Combine(_directoryPath, String.Format("Log-{0}.txt", DateTime.Now.ToString("yyyy-MM-dd")));
		}
		else
		{
			return Path.Combine(_directoryPath, _logFileName);
		}
	}
}

Now it is time to do the log method where the actual code lives. Note I’ve included the instance variables needed for the method so it is easier to understand.

private string _directoryPath;
private ReaderWriterLockSlim _fileLock = new ReaderWriterLockSlim();
private LogLevelEnum _loggingLevel;
private string _logFileName;
private readonly string _delimiter = "|";
private bool _generateFileName = true;

[DebuggerStepThrough]
private void log(LogLevelEnum logLevel, string message, string methodName, string callerName, string username = null, int eventID = 0)
{
	// If loglevel is not configured high enough return
	if ((_loggingLevel & logLevel) != logLevel)
	{
		return;
	}

	//Assume the class name is the filename
	string className = Path.GetFileNameWithoutExtension(callerName);
	callerName = className + "." + methodName;

	if (_generateFileName)
	{
		//Generate a filename
		_logFileName = string.Format("Log-{0}.txt", DateTime.Now.ToString("yyyy-MM-dd"));
	}

	_fileLock.EnterWriteLock();
	StreamWriter logWriter = new StreamWriter(Path.Combine(_directoryPath, _logFileName), true);
	try
	{
		//Write to file: datetime, method, user,logLevel, message
		logWriter.WriteLine(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + _delimiter + callerName + _delimiter + (username ?? "") + _delimiter + logLevel.ToString() + _delimiter + eventID + _delimiter + message);
	}
	finally
	{
		logWriter.Close();
		_fileLock.ExitWriteLock();
	}
}

The first line of code uses a bit-wise AND to determine if the FileSystemLogger’s log level is higher than the level of the entry being logged. If it isn’t then nothing happens.

Next we need to work out which class is calling the logger. It is very annoying that the Caller Information Attributes don’t include this but we can determine it by looking at the file path passed by CallerName. As long as you follow best practice and don’t put multiple classes in the same file then this works. Even if you don’t it should provide enough information to find your error.

Next we generate a filename if one isn’t specified. Note this is done every time the logger is called as the log might go over midnight and we would want the filename to change if it did.

The log entry is then written in a thread-safe manner. If you don’t like using the ReaderWriterLockSlim then feel free to do it in another way.

public static void Main(string[] args)
{
	ISimpleLogger logger = new SimpleFileLogger(LogLevelEnum.Error, "C:\\Logs\\");
	logger.LogError("hello");
}

produces:

2019-01-16 09:30:49|Program.Main||Error|0|hello

I like to use the | symbol as it is never used in my code. Feel free to add a constructor argument to change this to , or \t .

I also made an Email logger that puts my log entry into an email and sends it over. Another clever thing you can do is to make a MultiLogger that inherits from the same interface but contains other ISimpleLoggers that log different levels of error.

public class SimpleMultiLogger : ISimpleLogger
{
	private List<ISimpleLogger> _loggers;

	public SimpleMultiLogger(List<ISimpleLogger> loggers)
	{
		_loggers = loggers;
	}

	public string LogLocation => throw new NotImplementedException();

	public void LogDebug(string message, string username = null, int eventID = 0, [CallerMemberName] string methodName = "", [CallerFilePath] string callerName = "")
	{
		foreach (ISimpleLogger logger in _loggers)
		{
			logger.LogDebug(message, username, eventID, methodName, callerName);
		}
	}

	public void LogError(string message, string username = null, int eventID = 0, [CallerMemberName] string methodName = "", [CallerFilePath] string callerName = "")
	{
		foreach (ISimpleLogger logger in _loggers)
		{
			logger.LogError(message, username, eventID, methodName, callerName);
		}
	}

	public void LogInformation(string message, string username = null, int eventID = 0, [CallerMemberName] string methodName = "", [CallerFilePath] string callerName = "")
	{
		foreach (ISimpleLogger logger in _loggers)
		{
			logger.LogInformation(message, username, eventID, methodName, callerName);
		}
	}

	public void LogWarning(string message, string username = null, int eventID = 0, [CallerMemberName] string methodName = "", [CallerFilePath] string callerName = "")
	{
		foreach (ISimpleLogger logger in _loggers)
		{
			logger.LogWarning(message, username, eventID, methodName, callerName);
		}
	}
}

If I want to create a logger that logs everything to a file but also emails me if an error occurs I can do:

ISimpleLogger fileLogger = new SimpleFileLogger(LogLevelEnum.Debug, "C:\\Logs\\");
ISimpleLogger emailLogger = new SimpleEmailLogger(LogLevelEnum.Error, "An error occurred", "errors@greedycoder.co.uk", "greedy@greedycoder.co.uk", "mailserver");
ISimpleLogger multiLogger = new SimpleMultiLogger(new List<ISimpleLogger>() { fileLogger, emailLogger });

multiLogger.LogError("Email me this error");

Hope this all makes sense. It’s always a pain trying to deconstruct code you have already written so it makes sense to someone else.

Leave a Reply

Your email address will not be published. Required fields are marked *