Elmah in the Enterprise: a 3 tier solution

Elmah is a fantastic error logging solution for ASP.NET, but it has one “feature” that can prevent its adoption in the enterprise – errors can be stored on the local drive or to a database, neither of which are working for my employer.

Error details should not be stored on the web server and the web server cannot be given access to the data server.

So here’s my solution, a WebApi service for the application tier. The code makes use of RestSharp despite the lack of support. The trick in this code is serializing the Error class using the BinaryFormatter and sending it over the wire as a byte array.

The Repository:

This implementation is for Sql Server but the Repository can be easily changed to use any of the other providers.

This could be enhanced to read the Elmah config section of the web.config file.

public class ElmahRepository
{
    private readonly string logConnectionString;
    private readonly string elmahApplicationName;

    public ElmahRepository(
        string LogConnectionString, 
        string ElmahApplicationNameAppSetting)
    {
        this.logConnectionString = LogConnectionString;
        this.elmahApplicationName = ElmahApplicationNameAppSetting;
    }

    public string Log(Error error)
    {
        var logger = error.ApplicationName;
        return this.Logger.Log(error);
    }

    private SqlErrorLog Logger
    {
        get
        {
            var logger = new SqlErrorLog(this.logConnectionString);
            logger.ApplicationName = this.elmahApplicationName;
            return logger;
        }
    }
}

And an integration test:

public class ElmahRepositoryTests
{
    [Fact]
    public void Log_Always_Succeeds()
    {
        var exception = new InvalidOperationException("Log_Always_Succeeds");
        var error = new Error(exception);
        var repository = this.RepositoryFactory();

        var response = repository.Log(error);
        Guid result;
        Assert.True(Guid.TryParse(response, out result));
        Assert.NotEqual(
            Guid.Parse("{00000000-0000-0000-0000-000000000000}"),
            result);
    }

    private ElmahRepository RepositoryFactory()
    {
        return new ElmahRepository(
            "server=.;database=<>;user id=<>;password=<>;",
            "Test");
    }
}

The Controller:

Note: you have to use PUT with RestSharp to get the response.

[RoutePrefix("Log")]
public class LogController : ApiController
{
    private readonly ElmahRepository elmahRepository;

    public LogController(
        ElmahRepository elmahRepository)
    {
        this.elmahRepository = elmahRepository;
    }

    [Route("Add")]
    [HttpPut]
    public string Add([FromBody]byte[] data)
    {
        return this.elmahRepository.Log(ElmahHelpers.ToError(data));
    }
}

And its integration tests

The use of NtlmAuthenticator is not mandatory.

public class LogControllerTests
{
    [Fact]
    public void Add_Always_Succeeds()
    {
        var error = new Error(
            new InvalidOperationException("Add_Always_Succeeds"));
        var controller = this.ControllerFactory();

        var response = controller.Add(ElmahHelpers.ToByteArray(error));
        Guid result;

        Assert.True(Guid.TryParse(response, out result));
        Assert.NotEqual(
            Guid.Parse("{00000000-0000-0000-0000-000000000000}"),
            result);
    }

    [Fact]
    public void Add_MadeAsARestRequest_Succeeds()
    {
        var error = new Error(
            new InvalidOperationException("Add_MadeAsARestRequest_Succeeds"));
        var client = this.RestClientFactory();
        var request = new RestRequest("Log/Add");
        request.RequestFormat = DataFormat.Json;
        request.AddBody(ElmahHelpers.ToByteArray(error));

        var response = client.Put(request).Content.Replace(@"""", "");

        Guid result;
        Assert.True(Guid.TryParse(response, out result));
        Assert.NotEqual(
            Guid.Parse("{00000000-0000-0000-0000-000000000000}"),
            result);
    }

    private RestClient RestClientFactory()
    {
        var client = new RestClient("http://<server>/<path>/");
        client.Authenticator = new NtlmAuthenticator();

        return client;
    }

    private LogController ControllerFactory()
    {
        var controller = new LogController(
            this.ElmahRepositoryFactory());

        var context = new HttpControllerContext
        {
            RequestContext = new HttpRequestContext
            {
                Principal = new GenericPrincipal(
                    System.Security.Principal.WindowsIdentity.GetCurrent(),
                    null)
            }
        };

        controller.ControllerContext = context;
        return controller;
    }

    private ElmahRepository ElmahRepositoryFactory()
    {
        return new ElmahRepository(
            "server=.;database=<>;user id=<>;password=<>;",
            "Test");
    }
}

The BinaryFormatter helpers:

public static class ElmahHelpers
{
    public static byte[] ToByteArray(Error error)
    {
        var bf = new BinaryFormatter();
        using (var ms = new MemoryStream())
        {
            bf.Serialize(ms, error);
            return ms.ToArray();
        }
    }

    public static Error ToError(byte[] data)
    {
        using (var memStream = new MemoryStream())
        {
            var binForm = new BinaryFormatter();
            memStream.Write(data, 0, data.Length);
            memStream.Seek(0, SeekOrigin.Begin);
            var obj = (Error)binForm.Deserialize(memStream);
            return obj;
        }
    }
}

The implementation of ErrorLog that calls the WebApi controller:

I have simply chosen to throw an UnauthorizedAccessException if any attempt is made to read the errors.

public class RestErrorLog : ErrorLog
{
    private readonly string connectionString;

    public RestErrorLog(IDictionary config)
    {
        if (config == null)
        {
            throw new ArgumentNullException("config");
        }

        this.connectionString = (string)config["connectionString"] ?? string.Empty;

        if (this.connectionString.Length == 0)
        {
            throw new Elmah.ApplicationException("Connection string is missing for the Rest error log.");
        }
    }

    public override string Name
    {
        get
        {
            return "Rest Error Log.";
        }
    }

    public override string Log(Error error)
    {
        var client = new RestClient(this.connectionString);
        client.Authenticator = new NtlmAuthenticator();
        var request = new RestRequest("Log/Add");
        request.RequestFormat = DataFormat.Json;
        request.AddBody(ToByteArray(error));

        var result = client.Put(request).Content.Replace(@"""", "");

        return result;
    }

    public override int GetErrors(int pageIndex, int pageSize, System.Collections.IList errorEntryList)
    {
        throw new UnauthorizedAccessException(
            string.Format("RestErrorLog.GetErrors({0}, {1})", pageIndex, pageSize));
    }

    public override ErrorLogEntry GetError(string id)
    {
        throw new UnauthorizedAccessException(
            string.Format("RestErrorLog.GetError({0})", id));
    }

    private static byte[] ToByteArray(Error error)
    {
        var bf = new BinaryFormatter();
        using (var ms = new MemoryStream())
        {
            bf.Serialize(ms, error);
            return ms.ToArray();
        }
    }
}

And finally the entry in web.config to wire it all together:

Note that applicationName should match the value injected into the Repository.

<elmah xmlns="http://Elmah.Configuration">
  <errorLog 
    type="<namespace>.RestErrorLog, <assembly>" 
    applicationName="Test" 
    connectionString="http://<server>/<path>/"></errorLog>
</elmah>

Woohoo! Elmah in the Enterprise :-)

Leave a Reply