Table of contents
Introduction
Logging is one of the most useful services that every production website should have.
When errors occur on your website, you should be notified about them. Whilst you may think you have written perfect code and unit tested everything to the best of your ability, errors can and will still happen. The database server may go down, a third party website may be offline, the shared hosting environment of your website may suffer an outage, a previously undetected bug may occur, and the list goes on.
Having a great logging system in place allows you to stay on top of errors when they do happen.
In this article, we will integrate Log4Net, NLog, Elmah, and ASP.NET Health Monitoring into an ASP.NET MVC 2.0 Website, and provide a log reporting dashboard that will allow us to filter the messages by time period, log level, and log provider.
At first glance, you may ask why would you need to implement all four just to do logging in your website. And that's a fair question. The answer is that you don't.
ELMAH was chosen as it is focused on just one thing - logging and notification of unhandled exceptions, and it does this very well. It wasn't built for general logging purposes though - and that's where Log4Net and NLog come in.
Log4Net and NLog are equivalent for most purposes, so you would usually only need one of them in your project. Of course, you could also use Enterprise Services, another logging framework, or your own custom logger if you want.
What about Health Monitoring? Well, it can log a lot of information not covered by the other tools. Is it really necessary? That's up to you, but if you are in a shared hosting environment or have your website on servers not under your control, it may prove to be a useful debugging tool when trying to diagnose production problems.
Here is a quick look at the log reporting dashboard:
The sample project also adds charting and an RSS feed to the dashboard.
Background
Rob Conery has created a wonderful MVC starter project which you can find up on the CodePlex website. In his starter project, he included a logging interface and used NLog as his logger of choice.
This article expands on the logging found in Rob's starter project by including extra loggers for Log4Net, ELMAH, and Health Monitoring (the logger for NLog is kept as well, and has a couple of small additions) and by providing a UI to view and filter log messages by all or any of the log providers.
It is unlikely that you would ever use NLog and Log4Net in the one project as they both do a similar job, but if you are using third party assemblies that also use one of them, then you can quickly configure your website to use the same log provider in your web.config file.
Rob's MVC starter project contains a lot more than just logging, so be sure to check it out as well. (There is a link to it at the bottom of this article.)
Setting up and configuring ELMAH for MVC
ELMAH is an excellent tool to tracking unhandled exceptions in a project. I won't go into any detailed information about ELMAH as there is already a lot of information about it. If you are not familiar with it, please check out the links at the bottom of this article.
For a normal ASP.NET Web Forms project, there are two very easy steps to configure ELMAH:
- Download and add a reference to the ELMAH assembly
- Make changes to your web.config file
... and you are done! Easy!
However, for an ASP.NET MVC website, there are three additional steps that you need to do:
- Implement a custom exception attribute
- Implement a custom action invoker
- Implement a custom controller factory (optional, but highly recommended)
Custom error attribute
The following two code snippets were taken from this question on Stack Overflow:
public class HandleErrorWithELMAHAttribute : HandleErrorAttribute
{
public override void OnException(ExceptionContext context)
{
base.OnException(context);
var e = context.Exception;
if (!context.ExceptionHandled || RaiseErrorSignal(e) || IsFiltered(context)) return;
LogException(e);
}
private static bool RaiseErrorSignal(Exception e)
{
var context = HttpContext.Current;
if (context == null)
return false;
var signal = ErrorSignal.FromContext(context);
if (signal == null)
return false;
signal.Raise(e, context);
return true;
}
private static bool IsFiltered(ExceptionContext context)
{
var config = context.HttpContext.GetSection("elmah/errorFilter")
as ErrorFilterConfiguration;
if (config == null)
return false;
var testContext = new ErrorFilterModule.AssertionHelperContext(
context.Exception, HttpContext.Current);
return config.Assertion.Test(testContext);
}
private static void LogException(Exception e)
{
var context = HttpContext.Current;
ErrorLog.GetDefault(context).Log(new Error(e, context));
}
}
Custom action filter
public class ErrorHandlingActionInvoker : ControllerActionInvoker
{
private readonly IExceptionFilter filter;
public ErrorHandlingActionInvoker(IExceptionFilter filter)
{
if (filter == null)
{
throw new ArgumentNullException("filter");
}
this.filter = filter;
}
protected override FilterInfo GetFilters(
ControllerContext controllerContext,
ActionDescriptor actionDescriptor)
{
var filterInfo =
base.GetFilters(controllerContext,
actionDescriptor);
filterInfo.ExceptionFilters.Add(this.filter);
return filterInfo;
}
}
Custom controller factory
The following code snippet was taken from Rajan's blog article:
public class ErrorHandlingControllerFactory : DefaultControllerFactory
{
public override IController CreateController(
RequestContext requestContext,
string controllerName)
{
var controller =
base.CreateController(requestContext,
controllerName);
var c = controller as Controller;
if (c != null)
{
c.ActionInvoker =
new ErrorHandlingActionInvoker(
new HandleErrorWithELMAHAttribute());
}
return controller;
}
}
The last bit is to add the following to your application_start
event in your global.asax.cs file so that MVC knows to use the new custom controller factory:
ControllerBuilder.Current.SetControllerFactory(new ErrorHandlingControllerFactory());
Setting up and configuring Health Monitoring
Click through to the official documentation on Health Monitoring and follow the instructions to get Health Monitoring set up and running.
It's quite easy, so I won't duplicate any of it in this article. You can find out more by downloading the associated code, or view more detailed instructions on my blog series if you get stuck (see the link at the bottom)
ASP.NET Health Monitoring logs a lot of different types of messages, but there is no way to differentiate whether a message is just for information purposes or whether it is an error message that may need attention. So to address this issue, let's create a new table called "aspnet_WebEvent_ErrorCodes" and introduce a column called "Level" which will map each message event code to either "Info" or "Error".
The reason for doing this is so that we have a common "Level" attribute that we can use for all of our logging providers, and this will allow us to later on filter all messages by their log level. ELMAH, for example, will only be used to log unhandled exceptions so the log level for our ELMAH messages will always be "Error".
Here is the database script necessary to add the new table to our database:
IF EXISTS (SELECT * FROM sys.objects WHERE
object_id = OBJECT_ID(N'[dbo].[aspnet_WebEvent_ErrorCodes]') AND type in (N'U'))
DROP TABLE [dbo].[aspnet_WebEvent_ErrorCodes]
GO
IF EXISTS (SELECT * FROM sys.default_constraints WHERE
object_id = OBJECT_ID(N'[dbo].[DF_aspnet_WebEvent_ErrorCodes_Level]')
AND parent_object_id = OBJECT_ID(N'[dbo].[aspnet_WebEvent_ErrorCodes]'))
Begin
IF EXISTS (SELECT * FROM dbo.sysobjects WHERE
id = OBJECT_ID(N'[DF_aspnet_WebEvent_ErrorCodes_Level]') AND type = 'D')
BEGIN
ALTER TABLE [dbo].[aspnet_WebEvent_ErrorCodes]
DROP CONSTRAINT [DF_aspnet_WebEvent_ErrorCodes_Level]
END
End
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
IF NOT EXISTS (SELECT * FROM sys.objects WHERE
object_id = OBJECT_ID(N'[dbo].[aspnet_WebEvent_ErrorCodes]') AND type in (N'U'))
BEGIN
CREATE TABLE [dbo].[aspnet_WebEvent_ErrorCodes](
[Id] [int] IDENTITY(1,1) NOT NULL,
[Name] [nvarchar](255) COLLATE Latin1_General_CI_AS NOT NULL,
[EventCode] [int] NOT NULL,
[Level] [nvarchar](10) COLLATE Latin1_General_CI_AS NOT NULL,
CONSTRAINT [PK_aspnet_WebEvent_ErrorCodes] PRIMARY KEY CLUSTERED
(
[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF,
IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON)
)
END
GO
SET IDENTITY_INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ON
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (1, N'InvalidEventCode', -1, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (2, N'UndefinedEventCode/UndefinedEventDetailCode', 0, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (3, N'Not used', -9999, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (4, N'ApplicationCodeBase', 1000, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (5, N'ApplicationStart', 1001, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (6, N'ApplicationShutdown', 1002, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (7, N'ApplicationCompilationStart', 1003, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (8, N'ApplicationCompilationEnd', 1004, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (9, N'ApplicationHeartbeat', 1005, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (10, N'RequestCodeBase', 2000, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (11, N'RequestTransactionComplete', 2001, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (12, N'RequestTransactionAbort', 2002, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (13, N'ErrorCodeBase', 3000, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (14, N'RuntimeErrorRequestAbort', 3001, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (15, N'RuntimeErrorViewStateFailure', 3002, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (16, N'RuntimeErrorValidationFailure', 3003, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (17, N'RuntimeErrorPostTooLarge', 3004, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (18, N'RuntimeErrorUnhandledException', 3005, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (19, N'WebErrorParserError', 3006, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (20, N'WebErrorCompilationError', 3007, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (21, N'WebErrorConfigurationError', 3008, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (22, N'WebErrorOtherError', 3009, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (23, N'WebErrorPropertyDeserializationError', 3010, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (24, N'WebErrorObjectStateFormatterDeserializationError',
3011, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (25, N'AuditCodeBase', 4000, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (26, N'AuditFormsAuthenticationSuccess', 4001, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (27, N'AuditMembershipAuthenticationSuccess', 4002, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (28, N'AuditUrlAuthorizationSuccess', 4003, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (29, N'AuditFileAuthorizationSuccess', 4004, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (30, N'AuditFormsAuthenticationFailure', 4005, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (31, N'AuditMembershipAuthenticationFailure', 4006, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (32, N'AuditUrlAuthorizationFailure', 4007, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (33, N'AuditFileAuthorizationFailure', 4008, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (34, N'AuditInvalidViewStateFailure', 4009, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (35, N'AuditUnhandledSecurityException', 4010, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (36, N'AuditUnhandledAccessException', 4011, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (37, N'MiscCodeBase', 6000, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (38, N'WebEventProviderInformation', 6001, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (39, N'ApplicationDetailCodeBase', 50000, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (40, N'ApplicationShutdownUnknown', 50001, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (41, N'ApplicationShutdownHostingEnvironment', 50002, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (42, N'ApplicationShutdownChangeInGlobalAsax', 50003, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (43, N'ApplicationShutdownConfigurationChange', 50004, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (44, N'ApplicationShutdownUnloadAppDomainCalled', 50005, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (45, N'ApplicationShutdownChangeInSecurityPolicyFile',
50006, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (46, N'ApplicationShutdownBinDirChangeOrDirectoryRename',
50007, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (47, N'ApplicationShutdownBrowsersDirChangeOrDirectoryRename',
50008, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (48, N'ApplicationShutdownCodeDirChangeOrDirectoryRename',
50009, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (49, N'ApplicationShutdownResourcesDirChangeOrDirectoryRename',
50010, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (50, N'ApplicationShutdownIdleTimeout', 50011, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (51, N'ApplicationShutdownPhysicalApplicationPathChanged',
50012, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (52, N'ApplicationShutdownHttpRuntimeClose', 50013, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (53, N'ApplicationShutdownInitializationError', 50014, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (54, N'ApplicationShutdownMaxRecompilationsReached', 50015, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (55, N'StateServerConnectionError', 50016, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (56, N'AuditDetailCodeBase', 50200, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (57, N'InvalidTicketFailure', 50201, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (58, N'ExpiredTicketFailure', 50202, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (59, N'InvalidViewStateMac', 50203, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (60, N'InvalidViewState', 50204, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (61, N'WebEventDetailCodeBase', 50300, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (62, N'SqlProviderEventsDropped', 50301, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level])
VALUES (63, N'WebExtendedBase', 100000, N'Info')
SET IDENTITY_INSERT [dbo].[aspnet_WebEvent_ErrorCodes] OFF
IF Not EXISTS (SELECT * FROM sys.default_constraints WHERE
object_id = OBJECT_ID(N'[dbo].[DF_aspnet_WebEvent_ErrorCodes_Level]')
AND parent_object_id = OBJECT_ID(N'[dbo].[aspnet_WebEvent_ErrorCodes]'))
Begin
IF NOT EXISTS (SELECT * FROM dbo.sysobjects WHERE
id = OBJECT_ID(N'[DF_aspnet_WebEvent_ErrorCodes_Level]') AND type = 'D')
BEGIN
ALTER TABLE [dbo].[aspnet_WebEvent_ErrorCodes]
ADD CONSTRAINT [DF_aspnet_WebEvent_ErrorCodes_Level]
DEFAULT ('Info') FOR [Level]
END
End
GO
And here is the script to create a new view for our Health Monitoring events:
CREATE VIEW vw_aspnet_WebEvents_extended
AS
SELECT
webEvent.EventId
, webEvent.EventTimeUtc
, webEvent.EventTime
, webEvent.EventType
, webEvent.EventSequence
, webEvent.EventOccurrence
, webEvent.EventCode
, webEvent.EventDetailCode
, webEvent.Message
, webEvent.ApplicationPath
, webEvent.ApplicationVirtualPath
, webEvent.MachineName
, webEvent.RequestUrl
, webEvent.ExceptionType
, webEvent.Details
, webEventCodes.Level
FROM
dbo.aspnet_WebEvent_Events AS webEvent
INNER JOIN
dbo.aspnet_WebEvent_ErrorCodes AS webEventCodes ON
webEvent.EventCode = webEventCodes.EventCode
Setting up and configuring Log4Net
Log4Net is a popular logging framework. To find out more information about Log4Net, please visit the official Log4Net website.
Setting up Log4Net requires the following steps:
- Download Log4Net.
- Add a reference to Log4Net.
- Add a table in our database to store the Log4Net logs.
- Modify the web.config file for Log4Net.
- Implement
Log4NetLogger
that implements our ILogger
interface.
Here is the script to create a table to store the Log4Net messages:
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
SET ANSI_PADDING ON
GO
CREATE TABLE [dbo].[Log4Net_Error](
[Id] [int] IDENTITY(1,1) NOT NULL,
[Date] [datetime] NOT NULL,
[Thread] [varchar](255) NOT NULL,
[Level] [varchar](50) NOT NULL,
[Logger] [varchar](255) NOT NULL,
[Message] [varchar](4000) NOT NULL,
[Exception] [varchar](2000) NULL
) ON [PRIMARY]
GO
SET ANSI_PADDING OFF
GO
I won't include the web.config settings in this article for Log4Net as it is already a long article - you can see the required settings in the downloadable code for this article.
The last step is to create a logger class that implements the ILogger
interface, as used in Rob Connery's MVC starter website. Here is the code for that:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using log4net;
namespace MvcLoggingDemo.Services.Logging.Log4Net
{
public class Log4NetLogger : ILogger
{
private ILog _logger;
public Log4NetLogger()
{
_logger = LogManager.GetLogger(this.GetType());
}
public void Info(string message)
{
_logger.Info(message);
}
public void Warn(string message)
{
_logger.Warn(message);
}
public void Debug(string message)
{
_logger.Debug(message);
}
public void Error(string message)
{
_logger.Error(message);
}
public void Error(Exception x)
{
Error(LogUtility.BuildExceptionMessage(x));
}
public void Error(string message, Exception x)
{
_logger.Error(message, x);
}
public void Fatal(string message)
{
_logger.Fatal(message);
}
public void Fatal(Exception x)
{
Fatal(LogUtility.BuildExceptionMessage(x));
}
}
}
As you can see, it's pretty easy to add a logger into your website!
Setting up and configuring NLog
NLog is another popular logging framework. To find out more information about NLog, please visit the official NLog website.
Here are the steps required for NLog:
- Download NLog.
- Create a table in our database to store the NLog messages.
- Configure our NLog configuration file.
- Set up a logging interface for our website.
- Implement an NLog logger that uses our interface to log messages to the database table in step 2.
- Add some additional layout renders that we will need for NLog.
I'm going to skip over everything except the last item as the other items are all easy and similar to what we have done before for the other loggers.
However, please note that everything is covered in a lot more detail on my blog (see the link at the bottom of this article).
NLog layout renderers
Layout renderers are like template placeholders that you can use to output certain information to your NLog log file or database table.
For example, ${date} will output the date/time to your log. However, NLog uses the local date/time, and I needed a way to log the Universal Time to keep it consistent with the other log providers. I also wanted to record for NLog the same information that is recorded by ELMAH - namely, the Server variables and HTTP cookies information.
The solution to both of these problems was to create two new custom layout renderers for NLog - one for ${utc_date} and one for ${web_variables}.
Here is the code for the ${utc_date} layout renderer:
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Web;
using NLog;
using NLog.Config;
namespace MySampleApp.Services.Logging.NLog
{
[LayoutRenderer("utc_date")]
public class UtcDateRenderer : LayoutRenderer
{
public UtcDateRenderer()
{
this.Format = "G";
this.Culture = CultureInfo.InvariantCulture;
}
protected override int GetEstimatedBufferSize(LogEventInfo ev)
{
return 10;
}
public CultureInfo Culture { get; set; }
[DefaultParameter]
public string Format { get; set; }
protected override void Append(StringBuilder builder, LogEventInfo logEvent)
{
builder.Append(logEvent.TimeStamp.ToUniversalTime().ToString(
this.Format, this.Culture));
}
}
}
and here is the code for the ${web_variables} layout renderer:
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Web;
using System.Xml;
using NLog;
using NLog.Config;
namespace MySampleApp.Services.Logging.NLog
{
[LayoutRenderer("web_variables")]
public class WebVariablesRenderer : LayoutRenderer
{
public WebVariablesRenderer()
{
this.Format = "";
this.Culture = CultureInfo.InvariantCulture;
}
protected override int GetEstimatedBufferSize(LogEventInfo ev)
{
return 10000;
}
public CultureInfo Culture { get; set; }
[DefaultParameter]
public string Format { get; set; }
protected override void Append(StringBuilder builder, LogEventInfo logEvent)
{
StringBuilder sb = new StringBuilder();
XmlWriter writer = XmlWriter.Create(sb);
writer.WriteStartElement("error");
writer.WriteStartElement("serverVariables");
foreach (string key in
HttpContext.Current.Request.ServerVariables.AllKeys)
{
writer.WriteStartElement("item");
writer.WriteAttributeString("name", key);
writer.WriteStartElement("value");
writer.WriteAttributeString("string",
HttpContext.Current.Request.ServerVariables[key].ToString());
writer.WriteEndElement();
writer.WriteEndElement();
}
writer.WriteEndElement();
writer.WriteStartElement("cookies");
foreach (string key in HttpContext.Current.Request.Cookies.AllKeys)
{
writer.WriteStartElement("item");
writer.WriteAttributeString("name", key);
writer.WriteStartElement("value");
writer.WriteAttributeString("string",
HttpContext.Current.Request.Cookies[key].Value.ToString());
writer.WriteEndElement();
writer.WriteEndElement();
}
writer.WriteEndElement();
writer.WriteEndElement();
writer.Flush();
writer.Close();
string xml = sb.ToString();
builder.Append(xml);
}
}
}
The last step is to register your custom layout renderers in the global.asax.cs file in your application_start
event, like this:
LayoutRendererFactory.AddLayoutRenderer("utc_date",
typeof(MySampleApp.Services.Logging.NLog.UtcDateRenderer));
LayoutRendererFactory.AddLayoutRenderer("web_variables",
typeof(MySampleApp.Services.Logging.NLog.WebVariablesRenderer));
Creating the Model
At this point, we have ELMAH, NLog, Log4Net, and ASP.NET Health Monitoring set up and logging to their own tables in the database.
Here is the outline of what we need to do in our Data Layer:
- Use the Entity Designer to create our LINQ-To-Entity classes
- Create a common
LogEvent
class that we will use to store messages for all of our log providers
- Create an
ILogReportingRepository
interface
- Implement a
LogReportingRepository
for each log provider in our website (ELMAH, Log4Net, NLog, Health Monitoring)
- Create a
LogReportingFacade
class that will pull results out of one or all of the log repositories that we have installed
Here is the code for our LogEvent
class that will hold information about the log messages:
public class LogEvent
{
private string _Id = string.Empty;
public string Id
{
get
{
switch (IdType)
{
case "number":
return IdAsInteger.ToString();
case "guid":
return IdAsGuid.ToString();
default:
return _Id;
}
}
set
{
_Id = value;
}
}
internal Guid IdAsGuid { get; set; }
internal int IdAsInteger { get; set; }
internal string IdType { get; set; }
public DateTime LogDate { get; set; }
public string LoggerProviderName { get; set; }
public string Source { get; set; }
public string MachineName { get; set; }
public string Type { get; set; }
public string Level { get; set; }
public string Message { get; set; }
public string StackTrace { get; set; }
public string AllXml { get; set; }
}
The only trick with this class is that I had to use a few internal properties for 'ID
' in combination with an 'IdType
' property so that it could cater for Integer
, String
, and GUID
primary keys. At first, I attempted to use the 'Object
' type and use a single 'ID
' property, but I ran into problems when using the LINQ-to-Entity UNION
operator to join all of the IQueryable
results together (see the 'LogReportingFacade
' code below). If anyway knows of a better way to do this, please post it in the comments below.
Here is the code for the NLogRepository
class (the code for the other repositories is very similar):
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using MvcLoggingDemo.Models.Entities;
using MvcLoggingDemo.Services.Paging;
using MvcLoggingDemo.Helpers;
using System.Data.SqlClient;
namespace MvcLoggingDemo.Models.Repository
{
public class NLogRepository : ILogReportingRepository
{
MvcLoggingDemoContainer _context = null;
public NLogRepository()
{
_context = new MvcLoggingDemoContainer();
}
public NLogRepository(MvcLoggingDemoContainer context)
{
_context = context;
}
public IQueryable<LogEvent> GetByDateRangeAndType(int pageIndex,
int pageSize, DateTime start, DateTime end, string logLevel)
{
IQueryable<LogEvent> list = (from b in _context.NLog_Error
where b.time_stamp >= start && b.time_stamp <= end
&& (b.level == logLevel || logLevel == "All")
select new LogEvent { IdType = "number"
, Id = ""
, IdAsInteger = b.Id
, IdAsGuid = Guid.NewGuid()
, LoggerProviderName = "NLog"
, LogDate = b.time_stamp
, MachineName = b.host
, Message = b.message
, Type = b.type
, Level = b.level
, Source = b.source
, StackTrace = b.stacktrace });
return list;
}
public LogEvent GetById(string id)
{
int logEventId = Convert.ToInt32(id);
LogEvent logEvent = (from b in _context.NLog_Error
where b.Id == logEventId
select new LogEvent { IdType = "number"
, IdAsInteger = b.Id
, LoggerProviderName = "NLog"
, LogDate = b.time_stamp
, MachineName = b.host
, Message = b.message
, Type = b.type
, Level = b.level
, Source = b.source
, StackTrace = b.stacktrace
, AllXml = b.allxml })
.SingleOrDefault();
return logEvent;
}
public void ClearLog(DateTime start, DateTime end, string[] logLevels)
{
string logLevelList = "";
foreach (string logLevel in logLevels)
{
logLevelList += ",'" + logLevel + "'";
}
if (logLevelList.Length > 0)
{
logLevelList = logLevelList.Substring(1);
}
string commandText = "delete from NLog_Error WHERE time_stamp " +
">= @p0 and time_stamp <= @p1 and level in (@p2)";
SqlParameter paramStartDate = new SqlParameter { ParameterName = "p0",
Value = start.ToUniversalTime(), DbType = System.Data.DbType.DateTime };
SqlParameter paramEndDate = new SqlParameter { ParameterName = "p1",
Value = end.ToUniversalTime(), DbType = System.Data.DbType.DateTime };
SqlParameter paramLogLevelList =
new SqlParameter { ParameterName = "p2", Value = logLevelList };
_context.ExecuteStoreCommand(commandText, paramStartDate,
paramEndDate, paramLogLevelList);
}
}
}
and here is a snippet from the LogReportingFacade
class:
public IPagedList<LogEvent> GetByDateRangeAndType(int pageIndex, int pageSize,
DateTime start, DateTime end, string logProviderName, string logLevel)
{
IQueryable<LogEvent> list = null;
switch (logProviderName)
{
case "All":
foreach (string providerName in logProviders.Keys)
{
IQueryable<LogEvent> logList =
GetProvider(providerName).GetByDateRangeAndType(pageIndex,
pageSize, start, end, logLevel);
list = (list == null) ? logList : list.Union(logList);
}
break;
default:
list = GetProvider(logProviderName).GetByDateRangeAndType(
pageIndex, pageSize, start, end, logLevel);
break;
}
list = list.OrderByDescending(d => d.LogDate);
return new PagedList<LogEvent>(list, pageIndex, pageSize);
}
In the above method, if all log providers should be queried, then we loop through each log provider that we have configured and query it for the filtered log messages. and then we use the LINQ-to-Objects UNION
operator to join all of the queries together.
If, on the other hand, the calling client only wants to query for one of the log providers, then we simply instantiate that specific log provider and get the IQueryable
results for that one.
At the end of the method, we do our sorting and then our paging logic, and finally, return a list of the consolidated log messages that we can display on our view.
View Model
The LogEvent
class allows us to represent the log messages from any of our implemented log providers. However, on our dashboard page, we will need more information than just a list of LogEvent
s. We will need to keep track of the filter criteria and the fields used to keep track of our paging. To enable our views to get at this data, a class named LoggingIndexModel
was created that will hold all of the information that we need to display on the View. Here is the code for that:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using MvcLoggingDemo.Models;
using MvcLoggingDemo.Services.Paging;
namespace MvcLoggingDemo.ViewModels
{
public class LoggingIndexModel
{
public IPagedList<LogEvent> LogEvents { get; set; }
public string LoggerProviderName { get; set; }
public string LogLevel { get; set; }
public string Period { get; set; }
public int CurrentPageIndex { get; set; }
public int PageSize { get; set; }
public LoggingIndexModel()
{
CurrentPageIndex = 0;
PageSize = 20;
}
}
}
The LogEvents
property holds our paged list of log messages. LoggerProviderName
, LogLevel
, and Period
hold our filtering information. CurrentPageIndex
and PageSize
hold our paging information.
Creating the Controller
Here is the source code for the Index
action on our Logging Controller:
public ActionResult Index(string Period, string LoggerProviderName,
string LogLevel, int? page, int? PageSize)
{
string defaultPeriod = Session["Period"] == null ?
"Today" : Session["Period"].ToString();
string defaultLogType = Session["LoggerProviderName"] == null ?
"All" : Session["LoggerProviderName"].ToString();
string defaultLogLevel = Session["LogLevel"] == null ?
"Error" : Session["LogLevel"].ToString();
LoggingIndexModel model = new LoggingIndexModel();
model.Period = (Period == null) ? defaultPeriod : Period;
model.LoggerProviderName = (LoggerProviderName == null) ?
defaultLogType : LoggerProviderName;
model.LogLevel = (LogLevel == null) ? defaultLogLevel : LogLevel;
model.CurrentPageIndex = page.HasValue ? page.Value - 1 : 0;
model.PageSize = PageSize.HasValue ? PageSize.Value : 20;
TimePeriod timePeriod = TimePeriodHelper.GetUtcTimePeriod(model.Period);
model.LogEvents = loggingRepository.GetByDateRangeAndType(model.CurrentPageIndex,
model.PageSize, timePeriod.Start, timePeriod.End,
model.LoggerProviderName, model.LogLevel);
ViewData["Period"] = model.Period;
ViewData["LoggerProviderName"] = model.LoggerProviderName;
ViewData["LogLevel"] = model.LogLevel;
ViewData["PageSize"] = model.PageSize;
Session["Period"] = model.Period;
Session["LoggerProviderName"] = model.LoggerProviderName;
Session["LogLevel"] = model.LogLevel;
return View(model);
}
All of the code is self-explanatory, except that I've added a class called TimePeriod
, and a helper class TimePeriodHelper
that will take a string based period like "Today", "Last Week", "Last Month" etc., and return the start and end dates for that period.
Creating the View
This is the fun part where we put everything together and create our log reporting dashboard page.
At the top of our View, we will have the options to display the log messages by either a List, Chart, or RSS feed. Here is the code for that:
<div>
View :
<strong>List</strong>
| <%: Html.ActionLink("Chart", "Chart")%>
| <%: Html.ActionLink("RSS", "RssFeed",
new { LoggerProviderName = Model.LoggerProviderName,
Period = Model.Period, LogLevel = Model.LogLevel },
new { target = "_blank" })%>
</div>
As our index page will be the list or grid based view, we will need a way to filter the error messages to be displayed. Here is the HTML for the filter:
<div>
<div>
Logger : <%: Html.DropDownList("LoggerProviderName",
new SelectList(MvcLoggingDemo.Helpers.FormsHelper.LogProviderNames,
"Value", "Text"))%>
Level : <%: Html.DropDownList("LogLevel",
new SelectList(MvcLoggingDemo.Helpers.FormsHelper.LogLevels,
"Value", "Text"))%>
For : <%: Html.DropDownList("Period",
new SelectList(MvcLoggingDemo.Helpers.FormsHelper.CommonTimePeriods,
"Value", "Text"))%>
<input id="btnGo" name="btnGo" type="submit" value="Apply Filter" />
</div>
</div>
We also need a header for our grid that will display the number of messages found, and a way for the user to change the number of records displayed per page. Here is the code for the grid header:
<div>
<div>
<div>
<span style="float: left">
<%: string.Format("{0} records found. Page {1} of {2}",
Model.LogEvents.TotalItemCount, Model.LogEvents.PageNumber,
Model.LogEvents.PageCount)%>
</span>
<span style="float: right">
Show <%: Html.DropDownList("PageSize",
new SelectList(MvcLoggingDemo.Helpers.FormsHelper.PagingPageSizes, "Value", "Text"),
new { onchange = "document.getElementById('myform').submit()" })%> results per page
</span>
<div style="clear: both"></div>
</div>
</div>
<div>
<div>
<%= Html.Pager(ViewData.Model.LogEvents.PageSize,
ViewData.Model.LogEvents.PageNumber,
ViewData.Model.LogEvents.TotalItemCount,
new { LogType = ViewData["LogType"],
Period = ViewData["Period"],
PageSize = ViewData["PageSize"] })%>
</div>
</div>
</div>
<% } %>
The last part of the View just displays the log messages in a table, and you can see that in the downloadable code for this article.
The downloadable code also contains a charting View that uses the Google Visualization API and an RSS feed of the log messages.
Screenshots
The dashboard
Charting
Log message details
RSS feed
Using the code
The project in the downloadable code is self-contained, and uses its own database in the app_data folder. The first time you run the application, you will need to register yourself as a user. Once logged in, you should see a link to the logging dashboard appear in the main tabbed menu.
Further information
Here is a list of useful links for the various logging tools used in this article:
ELMAH
NLog
Log4Net
Health Monitoring
Other useful links
Conclusion
I hope this article helps those of you who are setting up ELMAH, Log4Net, NLog, or Health Monitoring on your MVC websites, and encourages those who don't currently have logging on their production websites to get a move on and get logging!