viernes, 23 de agosto de 2013

ELMAH en ASP.NET MVC, paso a paso

Registrar los errores de tu aplicación con ELMAH es un buen invento. Puedes guardarlos en una base de datos, enviarlos por correo, filtrar sólo a cierto tipo de excepciones, puedes suscribirte a una fuente RSS y ver los errores en tu lector de noticias preferido, etc. Lo cierto es que para mí es un must-have en casi cualquier proyecto de ASP.NET. Sin embargo, siempre que arranco un proyecto y agrego ELMAH, después me toca configurarlo y recordar el porqué de esos pequeños detalles que hacen que ELMAH funcione como yo quiero.

En este post lo que voy a contar es como yo configuro ELMAH en un proyecto de ASP.NET MVC, cubriendo las necesidades más usuales en cuanto al registro de errores: filtrado, envío por correo, etc.

Como ya sabes, cuando creas un proyecto de ASP.NET MVC, la plantilla incorpora código para agregar un filtro global a todos los controladores a través del atributo HandleErrorAttribute. Esto pasa en la clase FilterConfig que está en el directorio App_Start, y cuyo método RegisterGlobalFilters es llamado desde el evento Application_Start del global.asax.

filters.Add(new HandleErrorAttribute());

Este atributo se encarga de devolver al cliente una vista de error con información relativa al mismo. Este atributo no registra el error en ningún medio persistente. Eso nos gusta porque sólo tiene una responsabilidad y la hace muy bien. No se pega con ELMAH porque hace algo distinto a ELMAH. HandleError gestiona si debe o no mostrar una vista de error al usuario, por el contrario, lo que hará ELMAH será registrar el error en una base de datos y enviar un correo electrónico. Si se muestra o no al usuario una vista de error nada tiene que ver con ELMAH.

Lo que si debe interesarnos de la pareja HandleError y ELMAH, es que ELMAH considera que un error debe ser registrado sino ha sido manejado previamente. ¿Y quién decide si el error ha sido o no manejado? Pues HandleError estableciendo a true o false la propiedad ExceptionHandled.

Para que HandleError marque como manejado el error tienen que cumplirse algunos requisitos como por ejemplo no ser una acción hija (ChildAction), estar activado CustomErrors, etc. La verdad es que con todas las herramientas gratis que hay de decompilación (¿Se dice así?), es fácil ver el código de HandleErrorAttribute y su método OnException. Yo utilizo JustDecompile de Telerik, pero hay otras y además siempre nos quedará explorar directamente el código fuente de ASP.NET MVC, que es open-source… comento… http://aspnetwebstack.codeplex.com/SourceControl/latest#src/System.Web.Mvc/HandleErrorAttribute.cs

En cualquier caso, a propósito de la gestión de errores en ASP.NET MVC, te recomiendo la lectura del post de José María Aguilar Control de errores en acciones ASP.NET MVC donde se explica perfectamente al atributo HandleError mucho más en detalle.

El único aporte que hago al anterior post es que es necesario que la vista de error tenga cierto tamaño en bytes para que IE la muestre. Es raro, lo sé, pero es lo que hay. Míralo aquí http://stackoverflow.com/a/9540840. Yo directamente en mi vista de error agrego siempre al final la siguiente instrucción para curarme en salud:

@(new String(' ', 1000))

Llegados a este punto y sabiendo qué hace y qué no hace HandleErrorAttribute, llega la hora de comenzar a integrar ELMAH en nuestra aplicación.

Lo primero es descargar el paquete de Nuget. Como suelo trabajar con Sql Server, siempre descargo el mismo paquete:

clip_image002

Este paquete puede ser agregado también a proyectos de librerías de clases. En ese caso, simplemente agregará la referencia a Elmah.dll. Yo suele hacerlo para el ensamblado Models porque así puedo utilizar el registro manual de errores también desde este ensamblado, luego veremos cómo.

La configuración manual que hay que realizar a continuación es la siguiente:

  • Completar con información válida la cadena de conexión “elmah-sqlserver” o cambiar el atributo connectionStringName de la sección elmah/errorLog a una cadena de conexión que ya tuviéramos registrada previamente.
  • Ejecutar el código T-SQL que hay en el fichero App_Readme/Elmah.SqlServer.sql. Este código da un warning si no estamos en una base de datos con compatibilidad con Sql Server 2000, pero finalmente se ejecuta con éxito y yo nunca he tenido problemas con ninguna versión superior de Sql Server.

Yo por mi parte y como me gusta recibir al correo los errores de mi aplicación, agrego también la sección elmah/errorMail al web.config raíz.

<errorMail from="tucorreo@tudominio.com" to="tucorreo@tudominio.com" subject="Error" priority="Low" async="true" smtpPort="25" smtpServer="mail.tudominio.com" useSsl="false" userName="tucorreo@tudominio.com" password="tucontraseña" noYsod="false" />


Respecto a la seguridad, por defecto sólo se puede acceder a elmah.axd desde una petición local. Como queremos poder acceder desde fuera a esta pantalla para ver los errores, hay que establecer a true el valor de allowRemoteAccess. https://code.google.com/p/elmah/wiki/SecuringErrorLogPages

<security allowRemoteAccess="true" />

Como ahora hemos abierto al mundo la página elmah.axd, debemos estar seguros de que sólo usuarios con un cierto rol puede acceder a la misma. Esto lo haremos con ayuda de la autorización basada en roles de ASP.NET sólo dejaremos acceder a elmah.axd a los roles Admin.

<authorization>

  <allow roles="admin" />

  <deny users="*" />

</authorization>

Esto hay que meterlo dentro de location path="elmah.axd", elemento que ya nos agregó el paquete de Nuget. De todas formas, no siempre es una buena práctica exponer elmah.axd al exterior, al respecto puedes leer este magnífico post de Luis Ruiz Pavón que ilustra qué fácil es robar la sesión de un usuario y acceder de forma fraudulenta a elmah.axd.

Hasta aquí era la parte fácil de configuración de ELMAH, ahora toca escribir código.

En nuestro ensamblado Models voy a crear una clase de excepción personalizada.

using System;

 

namespace Models

{

    public class ModelException : Exception

    {

        public ModelException()

        {

        }

 

        public ModelException(string message)

            : base(message)

        {

        }

 

        public ModelException(string message, Exception inner)

            : base(message, inner)

        {

        }

    }

}


Esta clase será utilizada después para excluirla a través del filtrado de ELMAH, pero lo dicho, después lo veremos.

En el ensamblado de Models agrego la clase ElmahUtilities que nos servirá para filtrar qué errores se deben o no registrar con ELMAH. Yo me inclino por el filtrado por código, pero también se puede filtrar a través del web.config con la sección elmah/errorFilter. De todas formas, aquí se explica cómo filtrar vía configuración https://code.google.com/p/elmah/wiki/ErrorFiltering

using System;

using System.Web;

 

namespace Models

{

    public static class ElmahUtilities

    {

        public static bool Dismiss(Exception e)

        {

            if (e.GetBaseException() is HttpException)

            {

                var httpCode = ((HttpException)e.GetBaseException()).GetHttpCode();

                if (httpCode == 404)

                {

                    return true;

                }

            }

            if (e.GetBaseException() is ModelException)

            {

                return true;

            }

            return false;

        }

    }

}

Si te estás preguntado cómo es posible que hayamos agregado una referencia a System.Web en el ensamblado Models… Ya sé que no se debe, que el modelo tiene que ser agnóstico de la capa de presentación… pero la realidad es que si lo hago podré después utilizar ELMAH también desde el modelo para registrar errores de forma manual. De todas formas, sino te gusta esto también se podría crear un ensamblado CrossCutting o similar donde poner esta funcionalidad (aunque tendrías que retocar ligeramente el código aquí expuesto, pero vamos… nada que tú no sepas hacer).

Volviendo a la clase ElmahUtilities, lo que hace el método Dismiss es devolver true, lo que descartará y no registrará la excepción, cuando se cumpla alguna de las siguientes condiciones:

  • Es una excepción de tipo Http y el error es 404.
  • Es una excepción del tipo ModelException (la que creamos personalizada en un paso anterior).

Lógicamente, eres libre de implementar la lógica de filtrado que creas oportuna. Yo por ejemplo considero las excepciones del tipo ModelException de negocio y no quiero llenar mi log de errores de este tipo. Otro ejemplo que suele escribir a veces es no registrar nada si estoy en depuración:

if (HttpContext.Current != null && HttpContext.Current.IsDebuggingEnabled)

{

    return true;

}

Ahora crearemos un nuevo atributo llamado HandleErrorWithElmahAttribute que sustituirá al atributo HandleError.

using System;

using System.Web;

using System.Web.Mvc;
using Models;

using Elmah;

 

namespace MvcApplication1

{

    public class HandleErrorWithElmahAttribute : HandleErrorAttribute

    {       

        public override void OnException(ExceptionContext context)

        {

            var e = context.Exception;

            var httpContext = context.HttpContext;

            if (e is ModelException

                && httpContext.IsCustomErrorEnabled

                && httpContext.Request.IsAjaxRequest())

            {

                var response = httpContext.Response;

                response.StatusCode = (int)HttpStatusCode.InternalServerError;

                response.ContentType = "text/plain";

                response.Write(e.Message);

                context.ExceptionHandled = true;

                return;

            }

            base.OnException(context);

            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)

        {

            if (ElmahUtilities.Dismiss(e))

            {

                return;

            }

            var context = HttpContext.Current;

            ErrorLog.GetDefault(context).Log(new Error(e, context));

        }

 

    }

}

Este código es casi el mismo que el del post Logging in MVC Part 1- Elmah. La verdad es que ese post es sólo la primera parte de una serie de post sobre logging en MVC que están geniales, te recomiendo su lectura.

También hay que dar el cambiazo de HandleError por HandleErrorWithElmahAttribute en el fichero FilterConfig.cs.

public static void RegisterGlobalFilters(GlobalFilterCollection filters)

{

    filters.Add(new HandleErrorWithElmahAttribute());

}

Por último hay que agregar manejadores para los eventos ErrorLog_Filtering y ErrorMail_Filtering en el fichero global.asax

        private void ErrorLog_Filtering(object sender, ExceptionFilterEventArgs e)

        {

            if (ElmahUtilities.Dismiss(e.Exception))

            {

                e.Dismiss();

            }

        }

 

        private void ErrorMail_Filtering(object sender, ExceptionFilterEventArgs e)

        {

            if (ElmahUtilities.Dismiss(e.Exception))

            {

                e.Dismiss();

            }

        }

Después de haber tirado todo este código ELMAH ya estará funcionando, pero ¿Qué es importante comentar al respecto de este código?

Lo primero es recordar que ELMAH considera que tiene registrar un error sólo si no ha sido manejado. Si no hubiéramos creado nuestro propio atributo para sustituir HandleError, en producción y con CustomErrors igual a On, HandleError hubiera manejado los errores (ExceptionHandled a true) y ELMAH no registraría ningún error. El resultado sería que el usuario tendría una bonita vista de error y nosotros como desarrolladores no tendríamos nada… mal asunto. Siendo así, ahora se entiende el porqué del nuevo atributo HandleErrorWithElmah.

Lo más importante que hay que entender de HandleErrorWithElmah es la lógica detrás del método OnException.

        public override void OnException(ExceptionContext context)

        {

            var e = context.Exception;

            var httpContext = context.HttpContext;

            if (e is ModelException

                && httpContext.IsCustomErrorEnabled

                && httpContext.Request.IsAjaxRequest())

            {

                var response = httpContext.Response;

                response.StatusCode = (int)HttpStatusCode.InternalServerError;

                response.ContentType = "text/plain";

                response.Write(e.Message);

                context.ExceptionHandled = true;

                return;

            }

            base.OnException(context);

            if (!context.ExceptionHandled

                || RaiseErrorSignal(e)

                || IsFiltered(context))

            {

                return;

            }

            LogException(e);

        }

Lo primero es controlar un caso especial cuando el tipo de la excepción es ModelException, está activados los errores personalizados y además la petición es Ajax. En este caso, no quiero devolver la página de error personalizada porque al ser una excepción de negocio quiero mostrar el mensaje al usuario y quiero ser devolviendo un código de error 500 para poder interceptarlo en el método fail de una promise de jQuery.

Después se llama a la implementación de clase base, es decir, HandleError. Aquí lo queremos es olvidarnos de vistas de error, de información asociada, etc. Eso es un trabajo indigno para ELMAH, que lo haga otro! :)

Después se hace el siguiente razonamiento:

  • Si la excepción ha sido manejada por HandleError, se va a intentar registrar manualmente el error con el método RaiseErrorSignal. Recuerda que si ha sido manejada ELMAH no la registrará automáticamente, así que hay que llamar al método RaiseErrorSignal para hacerlo manualmente.
    • Si por algún motivo RaiseErrorSignal no pudiera registrar el error, primero se comprueba si el error debe ser registrado según el filtro por configuración (elmah/errorFilter). Si no hay sección o no es filtrado, terminaremos registrando el error de forma más manual si cabe, con el método LogException.
  • Si la excepción no ha sido manejada, simplemente dejaremos fluir el asunto y se registrará automáticamente por su cauce normal, que es llamando a los eventos de global.asax.

¿Y dónde entran los eventos de filtrado del global.asax?

Pues a ellos se llegará siempre y cuando no registre el error con LogException. Es decir, LogException no lanza los eventos ErrorLog_Filtering y ErrorMail_Filtering. Sin embargo, el método RaiseErrorSignal o un error no manejado por HandleError, sí llama a esos eventos. Es por esto que se llama manualmente a ElmahUtilities.Dismiss desde LogException (sino lo hiciéramos no podríamos filtrar la excepción).

A propósito de LogException también cabe mencionar que no enviará el error por correo electrónico.

¿Y puedo registrar errores manualmente desde cualquier otro punto de mi aplicación?

Pues se puede, yo utilizo una clase como la siguiente en el ensamblado Models.

using System;

using System.Web;

using Elmah;

 

namespace Models

{

    public static class ErrorLogger

    {

        public static string ApplicationName { get; set; }

 

        public static void Log(Exception ex)

        {

            if (HttpContext.Current != null)

            {

                var errorSignal = ErrorSignal.FromCurrentContext();

                errorSignal.Raise(ex);

            }

            else

            {

                if (ElmahUtilities.Dismiss(ex))

                {

                    return;

                }

                var errorLog = ErrorLog.GetDefault(null);

                errorLog.ApplicationName = ApplicationName;

                errorLog.Log(new Error(ex));

            }

        }

    }

}


En el método Log se explorarán 2 posibles vías:

  • Si hay contexto Http, se utilizará ErrorSignal que lanzará los eventos de global.asax con normalidad.
  • Si no hay contexto (y al igual que sucedía con el método LogException de HandleErrorWithElmah) guardaremos el error pero no lanzará eventos del global.asax ni se enviará correo.

Además, podrás ver que hay una propiedad pública en ErrorLogger llamada ApplicationName. Es un caso extraño. Si no hay contexto (luego el error lo registraremos de forma manual, no con ErrorSignal), la propiedad ApplicationName está vacía. Esto no impide que el error se registre, pero cuando después naveguemos a elmah.axd los errores sin valor en el campo ApplicationName no aparecerán en esa página. Yo por eso dejo preparado ErrorLogger con una propiedad ApplicationName para que pueda establecerse desde fuera si es preciso.

Sólo espero que te sirve el post para poner en marcha ELMAH en ASP.NET MVC e invitarte a que comentes en el blog cualquier cosa que pueda ser de ayuda para todos.

Un saludo!

No hay comentarios:

Publicar un comentario