Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Log Reporting Dashboard for ASP.NET MVC

0.00/5 (No votes)
23 Aug 2010 4  
Log reporting dashboard for Log4Net, NLog, ELMAH, and ASP.NET Health Monitoring.

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:

Logging - 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:

//From http://stackoverflow.com/questions/766610/
public class HandleErrorWithELMAHAttribute : HandleErrorAttribute
{
    public override void OnException(ExceptionContext context)
    {
        base.OnException(context);

        var e = context.Exception;
        if (!context.ExceptionHandled   // if unhandled, will be logged anyhow
                || RaiseErrorSignal(e)      // prefer signaling, if possible
                || IsFiltered(context))     // filtered?
            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

/// <summary>
/// This class allows an Exception filter to be injected when an MVC action is invoked
/// </summary>
public class ErrorHandlingActionInvoker : ControllerActionInvoker
{
    private readonly IExceptionFilter filter;

    /// <summary>
    /// Constructor
    /// </summary>
    /// <param name="filter">The exception filter to inject</param>
    public ErrorHandlingActionInvoker(IExceptionFilter filter)
    {
        if (filter == null)
        {
            throw new ArgumentNullException("filter");
        }

        this.filter = filter;
    }

    /// <summary>
    /// This methods returns all of the normal filters used
    /// PLUS it appends our custom filter to the end of the list 
    /// </summary>
    /// <param name="controllerContext">The context of the controller</param>
    /// <param name="actionDescriptor">The action descriptor</param>
    /// <returns>All of the action filters</returns>
    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:

/// <summary>
/// This custom controller factory injects a custom attribute 
/// on every action that is invoked by the controller
/// </summary>
public class ErrorHandlingControllerFactory : DefaultControllerFactory
{
    /// <summary>
    /// Injects a custom attribute 
    /// on every action that is invoked by the controller
    /// </summary>
    /// <param name="requestContext">The request context</param>
    /// <param name="controllerName">The name of the controller</param>
    /// <returns>An instance of a controller</returns>
    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:

// Setup our custom controller factory so that the [HandleErrorWithElmah] attribute
// is automatically injected into all of the controllers
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:

/****** Object:  Table [dbo].[aspnet_WebEvent_ErrorCodes]
        Script Date: 07/29/2010 09:56:45 ******/
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
/****** Object:  Default [DF_aspnet_WebEvent_ErrorCodes_Level]
        Script Date: 07/29/2010 09:56:45 ******/
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
/****** Object:  Table [dbo].[aspnet_WebEvent_ErrorCodes]
        Script Date: 07/29/2010 09:56:45 ******/
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
/****** Object:  Default [DF_aspnet_WebEvent_ErrorCodes_Level]
        Script Date: 07/29/2010 09:56:45 ******/
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:

  1. Download Log4Net.
  2. Add a reference to Log4Net.
  3. Add a table in our database to store the Log4Net logs.
  4. Modify the web.config file for Log4Net.
  5. 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:

  1. Download NLog.
  2. Create a table in our database to store the NLog messages.
  3. Configure our NLog configuration file.
  4. Set up a logging interface for our website.
  5. Implement an NLog logger that uses our interface to log messages to the database table in step 2.
  6. 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
{
    ///
    /// Initializes a new instance of the  class.
    ///
    public UtcDateRenderer()
    {
        this.Format = "G";
        this.Culture = CultureInfo.InvariantCulture;
    }

    protected override int GetEstimatedBufferSize(LogEventInfo ev)
    {
        // Dates can be 6, 8, 10 bytes so let's go with 10
        return 10;
    }

    ///
    /// Gets or sets the culture used for rendering.
    ///
    ///
    public CultureInfo Culture { get; set; }

    ///
    /// Gets or sets the date format. Can be any 
    /// argument accepted by DateTime.ToString(format).
    ///
    ///
    [DefaultParameter]
    public string Format { get; set; }

    ///
    /// Renders the current date and appends it to the specified .
    ///
    /// <param name="builder">The  to append the rendered data to.
    /// <param name="logEvent">Logging event.
    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
{
    ///
    /// Initializes a new instance of the  class.
    ///
    public WebVariablesRenderer()
    {
        this.Format = "";
        this.Culture = CultureInfo.InvariantCulture;
    }

    protected override int GetEstimatedBufferSize(LogEventInfo ev)
    {
        // This will be XML of an unknown size
        return 10000;
    }

    ///
    /// Gets or sets the culture used for rendering.
    ///
    ///
    public CultureInfo Culture { get; set; }

    ///
    /// Gets or sets the date format. Can be any
    /// argument accepted by DateTime.ToString(format).
    ///
    ///
    [DefaultParameter]
    public string Format { get; set; }

    ///
    /// Renders the current date and appends it to the specified .
    ///
    /// <param name="builder">The  to append the rendered data to.
    /// <param name="logEvent">Logging event.
    protected override void Append(StringBuilder builder, LogEventInfo logEvent)
    {
        StringBuilder sb = new StringBuilder();
        XmlWriter writer = XmlWriter.Create(sb);

        writer.WriteStartElement("error");

        // -----------------------------------------
        // Server Variables
        // -----------------------------------------
        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();

        // -----------------------------------------
        // Cookies
        // -----------------------------------------
        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:

// Register custom NLog Layout renderers
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:

  1. Use the Entity Designer to create our LINQ-To-Entity classes
  2. Create a common LogEvent class that we will use to store messages for all of our log providers
  3. Create an ILogReportingRepository interface
  4. Implement a LogReportingRepository for each log provider in our website (ELMAH, Log4Net, NLog, Health Monitoring)
  5. 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:

/// <summary>
/// This represents a generic log message that can store log information about
/// any logger implemented. Eg: Log4Net, NLog, Health Monitoring, Elmah
/// </summary>
public class LogEvent
{
    private string _Id = string.Empty;

    /// <summary>
    /// String representation of the event log id
    /// </summary>
    public string Id 
    {
        get
        {
            switch (IdType)
            {
                case "number":
                    return IdAsInteger.ToString();

                case "guid":
                    return IdAsGuid.ToString();

                default:
                    return _Id;
            }
        }
        set
        {
            _Id = value;
        }
    }

    /// <summary>
    /// Stores the Id of the log event as a GUID 
    /// </summary>
    internal Guid IdAsGuid { get; set; }

    /// <summary>
    /// Stores the Id of the log event as an integer
    /// </summary>
    internal int IdAsInteger { get; set; }

    /// <summary>
    /// Stores the base type of the id 
    /// Valid values are : number, guid, string
    /// </summary>
    internal string IdType { get; set; }

    /// <summary>
    /// The date of the log event
    /// </summary>
    public DateTime LogDate { get; set; }

    /// <summary>
    /// The name of the log provider
    /// Example values are NLog, Log4Net, Elmah, Health Monitoring
    /// </summary>
    public string LoggerProviderName { get; set; }

    /// <summary>
    /// Information about where the error occurred
    /// </summary>
    public string Source { get; set; }

    /// <summary>
    /// The machine where the error occured
    /// </summary>
    public string MachineName { get; set; }

    /// <summary>
    /// The Type name of the class that logged the error
    /// </summary>
    public string Type { get; set; }

    /// <summary>
    /// The level of the message logged
    /// Valid values are : Debug, Info, Warning, Error, Fatal
    /// </summary>
    public string Level { get; set; }

    /// <summary>
    /// The message that was logged
    /// </summary>
    public string Message { get; set; }                

    /// <summary>
    /// If the message was from an error this value
    /// will contain details of the stack trace. 
    /// Otherwise it will be empty
    /// </summary>
    public string StackTrace { get; set; }

    /// <summary>
    /// If the message was from an error this value will
    /// contain details of the HTTP Server variables and Cookies. 
    /// Otherwise it will be empty
    /// </summary>
    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
{
/// <summary>
/// This class extracts information that NLog stores so that we can report on it
/// </summary>
public class NLogRepository : ILogReportingRepository
{
    MvcLoggingDemoContainer _context = null;

    /// <summary>
    /// Default Constructor uses the default Entity Container
    /// </summary>
    public NLogRepository()
    {
        _context = new MvcLoggingDemoContainer();
    }

    /// <summary>
    /// Overloaded constructor that can take an EntityContainer
    /// as a parameter so that it can be mocked out by our tests
    /// </summary>
    /// <param name="context">The Entity context</param>
    public NLogRepository(MvcLoggingDemoContainer context)
    {
        _context = context;
    }

    /// <summary>
    /// Gets a filtered list of log events
    /// </summary>
    /// <param name="pageIndex">0 based page index</param>
    /// <param name="pageSize">max number of records to return</param>
    /// <param name="start">start date</param>
    /// <param name="end">end date</param>
    /// <param name="logLevel">The level of the log messages</param>
    /// <returns>A filtered list of log events</returns>
    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;
    }

    /// <summary>
    /// Returns a single Log event
    /// </summary>
    /// <param name="id">Id of the log event as a string</param>
    /// <returns>A single Log event</returns>
    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;
    }

    /// <summary>
    /// Clears log messages between a date range and for specified log levels
    /// </summary>
    /// <param name="start">start date</param>
    /// <param name="end">end date</param>
    /// <param name="logLevels">string array of log levels</param>
    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 LogEvents. 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)
{
    // Set up our default values
    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();

    // Set up our view model
    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);            

    // Grab the data from the database
    model.LogEvents = loggingRepository.GetByDateRangeAndType(model.CurrentPageIndex, 
                      model.PageSize, timePeriod.Start, timePeriod.End, 
                      model.LoggerProviderName, model.LogLevel);

    // Put this into the ViewModel so our Pager can get at these values
    ViewData["Period"] = model.Period;
    ViewData["LoggerProviderName"] = model.LoggerProviderName;
    ViewData["LogLevel"] = model.LogLevel;
    ViewData["PageSize"] = model.PageSize;

    // Put the info into the Session so that when we browse away
    // from the page and come back that the last settings are rememberd and used.
    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

Logging - Dashboard

Charting

Logging - Charting

Log message details

Logging - Message Details

RSS feed

Logging - 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!

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here