Changing NLog minLevel LogLevel at runtime in EPiServer

We moved from log4net to NLog in my current client’s EPiServer 8.5 website last year (see Using NLog with EPiServer and log4net for more information). Some time ago we received a request about being able to alter the log level at runtime, without editing configuration files on the servers. Reasonable enough; we already had read access to the generated logs, but no way of temporarily changing the level without involving our hosting company.

The NLog minLevel LogLevel configuration in EPiServer

Below is a sample of what a NLog.config configuration file may look like. What the client wanted to be able to change was the minLevel attributes of the logger rules (lines 13 and 16).

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd">
  <targets>
    <target name="AllFile"
            fileName="${basedir}/../Logs/All.${shortdate}.log"
            layout="[${longdate}][${uppercase:${level}}][${logger}] ${message} ${exception:format=ToString,StackTrace}" />
    <target name="MyWebFile"
            fileName="${basedir}/../Logs/MyWeb.${shortdate}.log"
            layout="[${longdate}][${uppercase:${level}}][${logger}] ${message} ${exception:format=ToString,StackTrace}" />
  </targets>
  <rules>
    <logger name="*"
            minlevel="Warn"
            writeTo="AllFile" />
    <logger name="MyWeb.*"
            minLevel="Warn"
            writeTo="MyWebFile" />
  </rules>
</nlog>

This really should not be a problem, so I wrote a small POC to show how. Let’s start by creating two ordinary EPiServer properties on, let’s say, our StartPage page type.

StartPage.cs

[Display(
  Name = "NLog: AllFile LogLevel",
  Description = "Log level for the rule that catches everything",
  GroupName = Constants.ContentTypes.Tabs.HiddenAdmin,
  Order = 150)]
[RegularExpression(@"^(Fatal|Error|Warn|Info|Debug|Trace|Off)$", ErrorMessage = "Must be valid log level (Fatal|Error|Warn|Info|Debug|Trace|Off).")]
public virtual string NLogAllFileLogLevel { get; set; }

[Display(
  Name = "NLog: MyWebFile LogLevel",
  Description = "Log level for the rule that catches MyWeb.*",
  GroupName = Constants.ContentTypes.Tabs.HiddenAdmin,
  Order = 160)]
[RegularExpression(@"^(Fatal|Error|Warn|Info|Debug|Trace|Off)$", ErrorMessage = "Must be valid log level (Fatal|Error|Warn|Info|Debug|Trace|Off).")]
public virtual string NLogMyWebFileLogLevel { get; set; }

As you can see I added a simple Regex validation attribute on the property allowing only valid log levels to be set; I guess you could make it more or less complex, but this is more than enough for my POC. Note that the GroupName is a custom tab called HiddenAdmin, if you’re interested you can read more about alternate ways of managing your EPiServer tabs in this article.

How to change NLog log level at runtime using EPiServer properties

As a little spoiler of what we are about to do, here is a small service interface that we will implement. The first method will insert our existing EPiServer property values in the NLog configuration object, and the event handler will take care of updating the config when we change it.

INLogConfigurationService.cs

public interface INLogConfigurationService
{
  void RefreshWithConfigFromEPiServer();
  void StartPagePublished_TryUpdateNLogRules(object sender, ContentEventArgs e);
}

So let’s start with the first half of the interface, and implement the RefreshWithConfigFromEPiServer method. As you can see we are using the constructor to inject dependencies; in this case EPiServer’s IContentLoader, which we will need in order to access the property values.

NLogConfigurationService.cs

using System;
using MyWeb.Core.Models.Pages;
using EPiServer;
using EPiServer.Core;
using NLog;
using NLog.Config;

namespace MyWeb.Core.Infrastructure.Logging
{
  public class NLogConfigurationService : INLogConfigurationService
  {
    private readonly IContentLoader _contentLoader;

    public NLogConfigurationService(IContentLoader contentLoader)
    {
      if (contentLoader == null) throw new ArgumentNullException("contentLoader");
      _contentLoader = contentLoader;
    }

I will probably refactor and tidy a few things up before implementing this in the client’s solution, but here is the main line of thought. We create a new LoggingConfiguration object (22), which is the kind of object that NLog uses to store it’s configuration. To this object we then add the existing targets from the XML configuration file (lines 23-26, scroll up if you don’t remember them); this is done through NLog’s existing configuration object stored in LogManager.Configuration (NLog is already initialized with all the config from the XML file at this point).

Since our RegEx will only allow us to enter valid LogLevel string values into our EPiServer properties, we can use NLog’s build in LogLevel.FromString method to retrieve the appropriate log levels. New rules are created for both the AllFile and the MyWebFile targets, and added to the new config object’s LoggingRules collection.

    public void RefreshWithConfigFromEPiServer()
    {
      var epiConfig = new LoggingConfiguration();
      foreach (var target in LogManager.Configuration.AllTargets)
      {
        epiConfig.AddTarget(target.Name, target);
      }
      var page = _contentLoader.Get<StartPage>(ContentReference.StartPage);

      var allFileLevel = LogLevel.FromString(page.NLogAllFileLogLevel);
      var ruleAll = new LoggingRule("*", allFileLevel, epiConfig.FindTargetByName("AllFile"));
      epiConfig.LoggingRules.Add(ruleAll);

      var myWebFileLevel = LogLevel.FromString(page.NLogMyWebFileLogLevel);
      var ruleMyWeb = new LoggingRule("MyWeb.*", myWebFileLevel, epiConfig.FindTargetByName("MyWebFile"));
      epiConfig.LoggingRules.Add(ruleMyWeb);

      LogManager.Configuration = epiConfig;
    }

The new configuration object is activated by assigning it to LogManager.Configuration (line 37). This means that we can now remove the entire rules section from our NLog.config transformations leaving only the targets in place in the XML.

The event handler code is rather simple. We only want to try refreshing the config if someone is publishing the StartPage.

    public void StartPagePublished_TryUpdateNLogRules(object sender, ContentEventArgs e)
    {
      var page = e.Content as StartPage;
      if (page == null)
      {
        return;
      }
      RefreshWithConfigFromEPiServer();
    }
  }
}

Making EPiServer update NLog log level on page publish

To hook up the handler to the proper EPiServer event, I created a simple initializable module. All that it’s actually doing is refreshing the NLog configuration object with the proper rules (16) at start up, and attaching the handler to the event (17).

NLogConfigurationInitializer.cs

[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class NLogConfigurationInitializer : IInitializableModule
{
  public void Initialize(InitializationEngine context)
  {
    var configService = ServiceLocator.Current.GetInstance<INLogConfigurationService>();
    var contentEvents = ServiceLocator.Current.GetInstance<IContentEvents>();

    configService.RefreshWithConfigFromEPiServer();
    contentEvents.PublishedContent += configService.StartPagePublished_TryUpdateNLogRules;
  }

  public void Uninitialize(InitializationEngine context)
  {
    var configService = ServiceLocator.Current.GetInstance<INLogConfigurationService>();
    var contentEvents = ServiceLocator.Current.GetInstance<IContentEvents>();

    contentEvents.PublishedContent -= configService.StartPagePublished_TryUpdateNLogRules;
  }
}

Note: If you do not add the ModuleDependency for the type EPiServer.Web.InitializationModule but rather just a dependency to initialize StructureMap, the IContentLoader (in the NLogConfigurationService) will feed you a StartPage with all properties set to null etc.