ASP.NET 5 comes with a new logging framework that integrates nicely with the dependency injection system. We can request
ILoggerinstances in components like our controllers and ASP.NET 5 will handle creating and injecting these logger instances for us. For more information on how the new logging framework basics, check out my post here.
With such rich logging features integrated and ready to use it can be tempting to start logging out all kinds of bits of information. In my experience it has been hard to add too much logging: when debugging an issue having more logs is better than having not enough.
Luckily the new logging framework allows us to create log entries at different levels (
Debug,
Verbose,
Information,
Warning,
Error, and
Critical). This means I can go wild logging all kinds of useful information throughout my application, then I can adjust the output logging level in my
Startup.csto tailor to my current needs. Perhaps when running in Development I’d want to see all kinds of verbose messages, but in Production I’d want to see only warnings, errors, and critical messages.
It Can Be Expensive
This is all great, but there’s an important aspect of all this logging to consider: constructing log messages can sometimes be an expensive operation. For example, I may want to output a debug log entry that shows detailed user information that I had to fetch from a database or other service. If I’m only making this extra call just to construct a Debug log message, it would be wasteful to make that call when I’m running in Production where I don’t even have Debug logs enabled. Lets look at a concrete example.
Lets say I have a
UserInfoServicethat will look up a user ID for me when I give it a username:
using System.Threading; namespace ConditionalLogging.Services { public class UserInfoService : IUserInfoService { public string GetUserId(string userName) { // Simulate some expensive operation Thread.Sleep(500); return "some-user-id"; } } }
In this example I’m using
Thread.Sleepto simulate an expensive operation like communicating with a database or service. Now lets say I wanted to log out a user ID in my
HomeControlleras a
Debuglevel log entry:
using Microsoft.AspNet.Mvc; using ConditionalLogging.Services; using Microsoft.Framework.Logging; namespace ConditionalLogging.Controllers { public class HomeController : Controller { [FromServices] public IUserInfoService UserInfoService { get; set; } [FromServices] public ILogger<HomeController> Logger { get; set; } public IActionResult Index() { var userName = ActionContext.HttpContext.User.Identity.Name; Logger.LogDebug("User with ID {userId} requested Index.", UserInfoService.GetUserId(userName)); return View(); } } }
This works fine when the log level is set to
Debug. What happens if I adjust the log level (in
Startup.cs) to be
Information? My
Indexaction will make the call to
GetUserId, which will take 500 ms, then the
ILoggerwill ignore the log entry that I spent so much time preparing.
This should illustrate the problem quite well. For this reason most logging frameworks include some functionality to check whether a given log level is enabled or not, allowing us to check before spending time computing an expensive log entry. ASP.NET 5 is no exception. Before writing to the logger, I can call the
IsEnabledmethod and check beforehand:
using Microsoft.AspNet.Mvc; using ConditionalLogging.Services; using Microsoft.Framework.Logging; namespace ConditionalLogging.Controllers { public class HomeController : Controller { [FromServices] public IUserInfoService UserInfoService { get; set; } [FromServices] public ILogger<HomeController> Logger { get; set; } public IActionResult Index() { var userName = ActionContext.HttpContext.User.Identity.Name; if (Logger.IsEnabled(LogLevel.Debug)) { Logger.LogDebug("User with ID {userId} requested Index.", UserInfoService.GetUserId(userName)); } return View(); } } }
Unit Testing This
I can even add some unit tests to validate this works as expected (and to protect against me accidentally removing the if check later on).
First, I created a
TestLoggerto use for unit testing my controller. Here’s what that looks like:
using Microsoft.Framework.Logging; using System; using System.Collections.Generic; namespace ConditionalLogging.Tests { public class TestLogger<T> : ILogger<T> { private LogLevel _logLevel; public IList<string> LogMessages { get; } = new List<string>(); public TestLogger(LogLevel logLevel) { _logLevel = logLevel; } public IDisposable BeginScopeImpl(object state) { return null; } public bool IsEnabled(LogLevel logLevel) { return logLevel >= _logLevel; } public void Log(LogLevel logLevel, int eventId, object state, Exception exception, Func<object, Exception, string> formatter) { var message = string.Empty; if (null != formatter) { message = formatter(state, exception); } else { message = LogFormatter.Formatter(state, exception); } LogMessages.Add(message); } } }
There’s two important parts here. First, I can pass in a
LogLevelto the constructor so that
IsEnabledcan return true or false depending on how I want to use it in my test. Second, I’m recording the log entries to a list that I can access in the unit test to validate the messages (if any) that were written to the log during the test.
Next, I needed to create a fake
IUserInfoServicethat simply records invocations for me. Like above, I’ll use this list of invocations in my unit test to validate whether the
GetUserIdmethod was called or not.
using ConditionalLogging.Services; using System.Collections.Generic; namespace ConditionalLogging.Tests { public class TestUserInfoService : IUserInfoService { public IList<string> GetUserInvocations { get; } = new List<string>(); public string GetUserId(string userName) { GetUserInvocations.Add(userName); return "test-user-id"; } } }
Now I’m ready to add my unit tests. First I added the xUnit framework dependencies to my project as described in my previous post on unit testing with xUnit. Next I added my first test to a new file called
Tests/Controllers/HomeControllerTest.cs:
[Fact] public void index_action_with_log_level_information_should_not_call_getuserid() { // Arrange var userInfoService = new TestUserInfoService(); var logger = new TestLogger<HomeController>(LogLevel.Information); var subject = new HomeController() { Logger = logger, UserInfoService = userInfoService, ActionContext = new ActionContext { HttpContext = new DefaultHttpContext() } }; // Act var result = subject.Index(); // Assert Assert.Empty(logger.LogMessages); Assert.Empty(userInfoService.GetUserInvocations); }
This one is pretty simple. I create a fake logger and set its log level to
LogLevel.Information. When I invoke the Index action on my controller, I expect no log messages to be emitted and no calls to
GetUserId.
Now I added a positive test case where the log level was set to
LogLevel.Debug:
[Fact] public void index_action_with_log_level_debug_should_call_getuserid() { // Arrange var userInfoService = new TestUserInfoService(); var logger = new TestLogger<HomeController>(LogLevel.Debug); var actionContext = new ActionContext { HttpContext = new DefaultHttpContext { User = new GenericPrincipal(new GenericIdentity("test-user"), null) } }; var subject = new HomeController() { Logger = logger, UserInfoService = userInfoService, ActionContext = actionContext }; // Act var result = subject.Index(); // Assert Assert.Equal("User with ID test-user-id requested Index.", logger.LogMessages.Single()); Assert.Equal("test-user", userInfoService.GetUserInvocations.Single()); }
This one is only slightly more involved: I had to set the user identity inside the
ActionContext(since it is being accessed in my controller and passed into the
IUserInfoService.GetUserIdmethod. Now I was able to assert that the correct log message was emitted and the
IUserInfoServicewas invoked the correct number of times with the correct arguments.
Done
We touched on two topics in this post: first we recognized that for some log entries that are expensive to calculate, it makes sense to check
ILogger.IsEnabledbeforehand so we don’t waste time and resources needlessly.
Next we looked at how we could unit test this to validate that our logic is working as expected and help prevent breaking our code in the future. Even ignoring logging, the techniques used for unit testing in this post are applicable to unit testing our ASP.NET 5 MVC 6 applications in other areas.