Thursday, July 21, 2011

Using Elmah with Azure Table Storage

Article Summary
This article will explain how to extend Elmah to log to and view errors from Windows Azure Table Storage. 

Introduction
Elmah is an exception handling and logging tool that plugs into ASP.NET and ASP.NET MVC applications. When an error is thrown, Elmah grabs all the information including the stack trace, server variables, and query string. Then this information is entered into the data container of your choice. When you want to review these exceptions, the tool provides a web interface to display the errors.

clip_image002

Your Windows Azure Usage
This article assumes you already have a Windows Azure account and know how to manage data in Azure Table Storage. I use either the Visual Studio Server Explorer or the Azure Storage Explorer (codeplex) to look at the tables. A longer list of Storage Viewer applications is referenced at the bottom of this article.

clip_image004
Visual Studio Server Explorer

clip_image006
Azure Storage Explorer

Steps to Connect Elmah to Windows Azure Table Storage
The Elmah download provides extensions that allow you to store your errors in various containers, including SQL Server and XML files. However, it doesn’t currently allow you to store your errors in Windows Azure Table Storage. Conveniently you can extend the Elmah framework and program your own storage containers. This article post will show you how to extend Elmah for Windows Azure Table Storage.

In order to get the Elmah code base to work with Windows Azure Table Storage, several steps have to be completed:
  1. Download the Elmah framework to get the Elmah.dll file.
  2. Create a class library project implementing Elmah for Windows Azure Table Storage. We will show you how in the next section.
  3. Reference the newly created class library into an existing ASP.NET or ASP.NET MVC application along with the Elmah framework class libraries.
  4. Configure web.config to use the Elmah library.
  5. Verify/Test class library works by throwing error then view it in the Elmah Web tool.
This article will focus on the last three. After you download Elmah, move to the next step.

Create a New Class Library Project
In your solution, add a new class library project. This project will produce the library that your web application will reference. When the new library is finally built, you will reference it in your web.config.

Make sure to add Elmah.dll and Microsoft.WindowsAzure.StorageClient.dll as references to this new class library project.

We have to create two classes to make Elmah work. The first is the entity (model) which is translated into columns in the Windows Azure Table. The second class is the Error Log extension which Elmah instantiates . The Error log extension needs three methods at the very least: Log() (add error to table), GetError() (select error from table), GetErrors() (select all errors of pagesize at pageindex). I used LINQ as the expression engine in this sample application to access Windows Azure Table Storage

My project manages the Azure Table connection in the constructor.

Create an entity for the Elmah data model
ElmahEntity.cs

namespace ElmahAzureTableStorage
{
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    using Microsoft.WindowsAzure.StorageClient;

    /// <summary>
    /// Descriptions of the Entity Written To Windows Azure Table Storage
    /// </summary>
    public class ErrorEntity : TableServiceEntity
    {
        [System.Obsolete("Provided For Serialization From Windows Azure Do No Call Directly")]
        public ErrorEntity()
        {
        }

        /// <summary>
        /// Initialize a new instance of the ErrorEntity class. 
        /// </summary>
        /// <param name="timeUtc"></param>
        /// <param name="Id"></param>
        public ErrorEntity(DateTime timeUtc, Guid Id)
            : base(ErrorEntity.GetParitionKey(timeUtc), ErrorEntity.GetRowKey(Id))
        {
            this.TimeUtc = timeUtc;
            this.Id = Id;
        }

        /// <summary>
        /// Given a DateTime Return a Parition Key
        /// </summary>
        /// <param name="time"></param>
        /// <returns></returns>
        public static string GetParitionKey(DateTime time)
        {
            return time.ToString("yyyyMMddHH");
        }

        /// <summary>
        /// Given a Error Identifier Return A Parition Key
        /// </summary>
        /// <param name="id"></param>
        /// <returns></returns>
        public static string GetRowKey(Guid id)
        {
            return id.ToString().Replace("-", "").ToLower();
        }

        /// <summary>
        /// Unique Error Identifier
        /// </summary>
        public Guid Id { get; set; }

        /// <summary>
        /// Get or set
        /// </summary>
        public string HostName { get; set; }

        /// <summary>
        /// Get or set
        /// </summary>
        public string Type { get; set; }

        /// <summary>
        /// Get or set
        /// </summary>
        public string Source { get; set; }

        /// <summary>
        /// Get or set
        /// </summary>
        public string Message { get; set; }

        /// <summary>
        /// Get or set
        /// </summary>
        public string User { get; set; }

        /// <summary>
        /// Get or set
        /// </summary>
        public DateTime TimeUtc { get; set; }

        /// <summary>
        /// Get or set
        /// </summary>
        public int StatusCode { get; set; }

        /// <summary>
        /// Get or set
        /// </summary>
        public string ErrorXml { get; set; }
    }
}




Create an Elmah Error Log Extension
This is the code that will eventually be called when an error is thrown in your app.

The private read-only string “tablename” contains the Azure table name (“Elmah”) and the constructor reads the web.config of the web site project (not the class library project) for the Azure Table Storage connection string.

The web page to display the captured errors returns in reverse chronology on purpose. There is a performance hit for returning in reverse chronology order because we need to fetch all the rows from Windows Azure Table Storage in order to sort them but for my purposes, I want to see the newest errors first.

ElmahProvider.cs
namespace ElmahAzureTableStorage
{
    using System;
    using System.Collections;
    using System.Collections.Generic;
    using System.Collections.Specialized;
    using System.Data.Services.Client;
    using System.Text;
    using System.Linq;
    using Microsoft.WindowsAzure.StorageClient;
    using Elmah;
    using Microsoft.WindowsAzure;
    using System.Text.RegularExpressions;
    using System.Xml;

    public class WindowsAzureErrorLogs : ErrorLog
    {
        /// <summary>
        /// Table Name To Use In Windows Azure Storage
        /// </summary>
        private readonly string tableName = "Elmah";

        /// <summary>
        /// Cloud Table Client To Use When Accessing Windows Azure Storage
        /// </summary>
        private readonly CloudTableClient cloudTableClient;

        /// <summary>
        /// Initialize a new instance of the WindowsAzureErrorLogs class.
        /// </summary>
        /// <param name="config"></param>
        public WindowsAzureErrorLogs(IDictionary config)
        {
            if (!(config["connectionString"] is string))
            {
                throw new Elmah.ApplicationException("Connection string is missing for the Windows Azure error log.");
            }

            if (string.IsNullOrWhiteSpace((string)config["connectionString"]))
            {
                throw new Elmah.ApplicationException("Connection string is missing for the Windows Azure error log.");
            }

            CloudStorageAccount cloudStorageAccount = CloudStorageAccount.Parse((string)config["connectionString"]);
            this.cloudTableClient = cloudStorageAccount.CreateCloudTableClient();

            this.cloudTableClient.CreateTableIfNotExist(this.tableName);
        }


        /// <summary>
        /// 
        /// </summary>
        /// <param name="error"></param>
        /// <returns></returns>
        public override string Log(Error error)
        {
            ErrorEntity entity = new ErrorEntity(error.Time, Guid.NewGuid())
            {
                HostName = error.HostName,
                Type = error.Type,
                ErrorXml = ErrorXml.EncodeString(error),
                Message = error.Message,
                StatusCode = error.StatusCode,
                User = error.User,
                Source = error.Source
            };

            TableServiceContext tableServiceContext = this.cloudTableClient.GetDataServiceContext();
            tableServiceContext.AddObject(this.tableName, entity);
            tableServiceContext.SaveChanges();

            return entity.Id.ToString();
        }

        /// <summary>
        /// Get a Error From Windows Azure Storage
        /// </summary>
        /// <param name="id">Error Identifier (Guid)</param>
        /// <returns>Error Fetched (or Null If Not Found)</returns>
        public override ErrorLogEntry GetError(string id)
        {
            TableServiceContext tableServiceContext = this.cloudTableClient.GetDataServiceContext();

            var query = from entity in tableServiceContext.CreateQuery<ErrorEntity>(this.tableName).AsTableServiceQuery()
                        where ErrorEntity.GetRowKey(Guid.Parse(id)) == entity.RowKey
                        select entity;

            ErrorEntity errorEntity = query.FirstOrDefault();
            if (errorEntity == null)
            {
                return null;
            }

            return new ErrorLogEntry(this, id, ErrorXml.DecodeString(errorEntity.ErrorXml));
        }

        /// <summary>
        /// Get A Page Of Errors From Windows Azure Storage
        /// </summary>
        /// <param name="pageIndex">Page Index</param>
        /// <param name="pageSize">Size Of Page To Return</param>
        /// <param name="errorEntryList">List of Errors Returned</param>
        /// <returns>Total Count of Errors</returns>
        public override int GetErrors(int pageIndex, int pageSize, System.Collections.IList errorEntryList)
        {
            if (pageIndex < 0)
                throw new ArgumentOutOfRangeException("pageIndex", pageIndex, null);

            if (pageSize < 0)
                throw new ArgumentOutOfRangeException("pageSize", pageSize, null);

            TableServiceContext tableServiceContext = this.cloudTableClient.GetDataServiceContext();

            // WWB: Server Side Call To Get All Data
            ErrorEntity[] serverSideQuery = tableServiceContext.CreateQuery<ErrorEntity>(this.tableName).AsTableServiceQuery().Execute().ToArray();

            // WWB: Sorted in Reverse Order So Oldest are First
            var sorted = serverSideQuery.OrderByDescending(entity => entity.TimeUtc);

            // WWB: Trim To Just a Page From The End
            ErrorEntity[] page = sorted.Skip(pageIndex * pageSize).Take(pageSize).ToArray();

            // WWB: Convert To ErrorLogEntry classes From Windows Azure Table Entities
            IEnumerable<ErrorLogEntry> errorLogEntries = page.Select(errorEntity => new ErrorLogEntry(this, errorEntity.Id.ToString(), ErrorXml.DecodeString(errorEntity.ErrorXml)));

            // WWB: Stuff them into the class we were passed
            foreach (var errorLogEntry in errorLogEntries)
            {
                errorEntryList.Add(errorLogEntry);
            };

            return serverSideQuery.Length;
        }
    }
}


Changes to Web.Config
Make sure to alter the <errorlog> to contain the correct endpoints, account name, and account key.

Notice that the only reference to this new Elmah Azure table storage library is in <elmah><errorlog>. The rest of the references to elmah are to the elmah library itself.

Elmah captures the errors then hooks up to the new elmah Azure table storage library in order to insert the error into the Azure table. This is true for either inserting into the table or reading information out for the error log web page.


<!-- Begin: Add for Elmah (child of <configuration>-->
  <configSections>
    <sectionGroup name="elmah">
      <section name="security" requirePermission="false" type="Elmah.SecuritySectionHandler, Elmah" />
      <section name="errorLog" requirePermission="false" type="Elmah.ErrorLogSectionHandler, Elmah" />
      <section name="errorMail" requirePermission="false" type="Elmah.ErrorMailSectionHandler, Elmah" />
      <section name="errorFilter" requirePermission="false" type="Elmah.ErrorFilterSectionHandler, Elmah" />
    </sectionGroup>
  </configSections>
  <elmah>
    <security allowRemoteAccess="yes" />
    <errorLog type="ElmahAzureTableStorage.WindowsAzureErrorLogs, ElmahAzureTableStorage" 
              connectionString="DefaultEndpointsProtocol=https;AccountName=YOURACCOUNTNAME;AccountKey=YOURACCOUNTKEY" />
  </elmah>
  <!-- Begin: End for Elmah –>
<!-- Begin: Add for Elmah (child of <system.web>) -->
    <httpHandlers>
      <add verb="POST,GET,HEAD" path="elmah.axd" type="Elmah.ErrorLogPageFactory, Elmah" />
    </httpHandlers>
    <httpModules>
      <add name="ErrorLog" type="Elmah.ErrorLogModule, Elmah"/>
    </httpModules>
    <!-- Begin: End for Elmah -->
<!-- Begin: Add for Elmah (child of <system.webServer>)-->
    <handlers>
      <add name="Elmah" verb="POST,GET,HEAD" path="elmah.axd" type="Elmah.ErrorLogPageFactory, Elmah" />
    </handlers>
    <modules runAllManagedModulesForAllRequests="true" >
      <add name="ErrorLog" type="Elmah.ErrorLogModule, Elmah" />
    </modules>
    <!-- Begin: End for Elmah -->

Build and Test

At this point, the Elmah Windows Azure Table Storage extension should be hooked up and the brand new web site should build and display.
clip_image008

The easiest test is a 404/File Not Found. At the URL bar, enter a short, fake web page such as http://localhost/ERRORPAGE. Since there is no page or controller for this, it should return an error page. Since no other extensions or handlers have been added to the web site, the generic error displays:

clip_image010

The point of displaying this is to show that Elmah doesn’t fix your display after an unhandled exception. You must do that yourself. Start with this article for gracefully responding to unhandled exceptions.

In order to see what Elmah and the Azure Table Storage Elmah extension did, Check the log to see what happened by going to your web site [http://localhost/elmah.axd].

clip_image012


What did Elmah do?
Elmah intercepted the unhandled exception, regardless of type or where it was thrown, and captured the relevant information for you, the developer, to review. Elmah captured the error but didn’t handle it at all – you must do that.

There are plenty of articles out there to make Elmah your own: MVC-ish, capture client-side errors, etc.

Additional Reading Material
· Elmah Codeplex Project & Wiki Page
· Windows Azure Storage Team Blog
· How to get most out of Windows Azure Tables
· Windows Azure Storage Explorers
· Using HTTP Modules and Handlers to Create Pluggable ASP.NET Components


The Downloadable Sample Project
The sample app at the bottom of the article was built using Visual Studio 2010 Professional and Elmah v 1.2.13605.0. Make sure you unblock the following dlls in the ElmahToAzureTableStorage project directory: Elmah.dll & Microsoft.WindowsAzure.StorageClient.dll.

Sample App
ElmahToAzureTableStorage.zip

Sample App on MSDN
This sample app is also hosted on MSDN: http://code.msdn.microsoft.com/windowsazure/Elmah-Exception-Handling-c6077265

3 comments:

  1. If I want to use Windows Azure just for SQL Server what parts do I need to buy? Not sure if I need the "Compute Instance" still?

    ReplyDelete
  2. Updated for Windows Azure Storage Client 2.0 - http://aidangarnish.net/post/Logging-Elmah-Exceptions-To-Azure-Storage.aspx

    ReplyDelete
  3. // WWB: Server Side Call To Get All Data
    ErrorEntity[] serverSideQuery = tableServiceContext.CreateQuery(this.tableName).AsTableServiceQuery().Execute().ToArray();

    Is this normal if I have a large database?

    ReplyDelete