If you
have read some of the .NET - related articles on exception handling (including
one of mine, "Documenting
Exceptional Developers") then you probably have a pretty good
idea that the rules of the game in .NET are twofold:
1) Unhandled exceptions (when they are thrown) are expensive
and burn up a lot of extra CPU cycles.
2) Do everything you can to prevent
exceptions from happening. Avoid using exceptions
to
handle
business
logic
conditions
in
your code.
Having said this, the next step is "what to do with
exceptions". All developers know that when an application is deployed,
it's almost
never perfect. Sooner or later, some user is going to find a way to break
your app, correct? (After all, isn't that what users are for!) Users
create inputs and other business use conditions that we developers could
never have anticipated, and. uh -- well, "bad things" happen.
More often that not,
users see that nasty looking red and yellow default ASP.NET error page
because we extra - cool developers have never even taken
the time to create a custom error page! What the heck good is a stack
trace to a user? Heck, they can't even use the app correctly - they just
caused an exception with it, right?
Of
course you can easily set up your web.config (and even IIS) to redirect
to a more friendly and Really Useful (a
la "Thomas the Tank Engine") error page using the <customErrors>
element in web.config. I encourage you to do this, and there is plenty
of good documentation on it. However, that is not what this article is
about, and so I don't cover the subject here. The problem with exceptions
for the developer usually still exists: often the error
page
doesn't
provide enough
information for us to thoroughly and easily nail down a problem in a
production web application. And so we are forced to spend much
more time than we should attempting to figure out "why" it
broke in production when it seems to work just fine in debug mode on
our local development
box. This doesn't have to be.
This article presents some concrete and "really
useful"
steps you can take (including a Registry trick with the Event Log viewer
that you won't find anywhere else) that will enable you to easily zero
- in on the exact error along with all the details right
down to the page and exact line of code where your "perfect app" well
-- BLEW UP!
Not only that, but you can easily wire up any of your
web apps to use this little framework simply by adding a few items to
the web.config and the global.asax
and dropping in a little 16K DLL into the /bin folder. The
Really Useful Exception Engine works fine with codebehind
as well as script-only ASP.NET apps.
First, understand that in ASP.NET, the Global Application_Error event
handler receives all
unhandled exceptions (including those you choose to
throw on your own). And while I'm ranting on the subject, the following
is NOT my idea of a legitimately "handled" exception:
Try
' your buggy spaghetti code here
Catch
End Try
-- I really hope, if you have
digested the message correctly, that the above VB.NET snippet doesn't
look familiar! If it does, go back and read (at the least) my other
article linked at the top of this article.
The GetLastError() method of the Server
object returns a reference to a generic HttpException wrapping the original
exception
that was passed from your ASP.NET page to the Application_Error event.
Gain access to the original exception by calling its GetBaseException() method.
This will provide the original exception instance, regardless of how
many layers have been added
to the exception stack. Once Application_Error has
completed, it automatically performs a redirect to your custom error
page that you can set up in web.config. See the MSDN documentation
on how to set this up if you want it.
NOTE: If you are re-throwing an exception
in your catch block after doing some business logic with it, you don't
want
to do this:
catch(Exception e){
// your stuff here
throw(e);
}
Instead, you want to do this:
catch(Exception e){
// your stuff here
throw;
}
If you use the first model, you may be throwing away the
Stack Trace information. In the first example above, you are not rethrowing
e, you are beginning a new exception flow using the same exception object
instance. The C# throw syntax is "throw expr", where expr is
an expression that evaluates to an exception object. It doesn't need
to be a new object, any Exception-derived object can be used, even if
it has been thrown a number of times before.
What's more important, and what
we'll be dealing with here, is the fact that you can invoke whatever
logging and additional exception - handling code you wish from within
Application_Error. You don't need to use fancy HttpModules. All you
need to do is set a "using" or "Imports" reference
to you ExceptionHandler class library in your Global.asax and call
your method(s).
In writing this ExceptionLogging class library, my
initial objectives were:
1) It should be simple to write and easy to understand.
2) It should be portable (e.g., easy to set up with a minimum of configuration
steps)
3) It should be extensible - that is, you can easily improve on or add
to it.
4) It's initial scope should be to effectively log exceptions and, notify
developers that an exception occurred.
5) It should be very easy to look in the event log and get details (e.g.
go to a web page report).
6) It should use a database, not text files. Who wants to look through
text files? Jeesh!
7) You should be able to easily deploy it in a production app, and only
"turn it on" when you need it.
8) ALL exceptions from ALL apps in the enterprise should be logged to
a central database so that admins and other responsible individuals can
access
this information easily.
As mentioned above, I do NOT like "log files". They
are hard to parse, difficult to find things in, and they can get big
really fast and start to slow down the whole works. They are also local
to a specific machine and thus somewhat counterproductive to a development
/ production environment where numbers of dev and admin types are required
by the overall business model to be able to work well together in a team
environment. Databases, on the other hand, provide fast access throughout
the enterprise, are easier to control vis-a-vis security, and the RDBMS
model makes it much easier
to sort, select and report on information. Additionally, scheduled jobs
can perform cleanup or needed replication. So, if you want to write exception
stuff to log files, you can choose to either modify my code, or roll
your own.
The overall simple concept of my exception reporting
engine is as follows:
1) We definitely want to log unhandled exceptions including
basic details to the local event log. If possible, we also want to
identify each exception with a custom ID so that we can create an HTTP
URL link to a custom report page that will query our detailed exception
information out of our database and present it in a nice web - page report,
accessible by anyone with the proper credentials, throughout the enterprise.
2) We want optional email notification to a short list of people who
should know about the problem and are capable of taking fast action to
fix it, preferably (again) with a link to the report page URL.
With this in mind, we need to look at a little and very
much undocumented Registry hack. If you've ever noticed, some of your
event log entries have an actual clickable hyperlink in them that takes
you to the "help and support engine" and if the exception has
a description there, you get to read up on it. Big deal, right? I have "Help
and support"
service turned off on all my boxes, cause all it does is take up extra
memory and resources. And besides, since we as professional developers
already know everything, why would we need Help and Support?
Now open up REGEDIT and look at:
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Event
Viewer
You'll see entries for:
MicrosoftRedirectionURL,
MicrosoftRedirectionProgram,and
MicrosoftRedirectionProgramCommandLineParameters
By simply changing
some of these values, you can tell the Event Viewer to stop using the
custom help and support executable and simply go to a URL of your choosing.
It will append the s% variable, which contains a whole bunch of querystring
information, onto the end of your custom URL, and if you have set a
custom EventId such as the Identity value of a newly inserted database
record, it will happily supply this on the querystring to your reporting
URL so that it can go find the newly inserted database record! (Don't
look for this trick in MSDN, you'll won't find anything beyond a vague
description of the Registry entries- I had to figure it out through
personal toil and pain). Of course what
this
means is
that your Event Log entries just became about 10 times more useful,
because now, if you think you found the right exception, all you need
to do is click on the hyperlink and you get the whole report in a nice
web page! And, as we'll see shortly, you can also turn on email notification
- which will provide much the same information, including the useful
hyperlink that zeroes in on the exact exception record from your database!
Oh, and don't worry
- I've included a handy registry script with the downloadable zipfile
below. All you need to do is double-click on it and the modifications
will
be
made for you automatically.
First, before we
delve into some code, lets take a quick review of what it takes to
configure an ASP.NET web application to use the engine:
1) web.config needs
to have an <appSettings> section with the following entries, which
should be pretty much self-explanatory:
</system.web>
<appSettings>
<add key="LogErrors" value="true" />
<add key="dbConnString" value="server=(local);database=WebAppLogs;User
id=sa;password=;" />
<add key="emailAddresses" value="you@yourdomain.com|friend@yourdomain.com" />
<add key="smtpServer" value="mail.yourdomain.com" />
<add key="fromEmail" value="yourEmailFrom@yourdomain.com" />
<add key="detailURL" value="http://yourappURL/exceptionlogger/report.aspx" />
</appSettings>
< /configuration>
-- That's it for
the web.config! Easy!
Next, we move to
your Global.asax:
using ExceptionHandler;
. . .
protected void Application_Error(Object
sender, EventArgs e)
{
ExceptionHandler.LogException exc = new ExceptionHandler.LogException();
exc.HandleException(Server.GetLastError().GetBaseException());
}
-- Pretty simple!
Aside from the Registry entries and the database setup (a sample SQL
script is provided) we are now 100% set up! Note that the "LogErrors"
key can be set to "false" -- and it's as if the exception engine didn't
even exist.
So now, lets cruise through my spaghetti
code and see what this thing really does, under the hood:
using System;
using System.Web;
using System.Diagnostics;
using System.Data;
using System.Data.SqlClient;
using System.Web.Mail;
namespace ExceptionHandler
{
public class LogException
{
public LogException( ) //ctor
{
}
public void HandleException( Exception ex)
{
HttpContext ctx = HttpContext.Current;
string strData=String.Empty;
int evtId = 0;
bool logIt =
Convert.ToBoolean(System.Configuration.ConfigurationSettings.AppSettings["logErrors"]);
if(logIt)
{
string dbConnString=
System.Configuration.ConfigurationSettings.AppSettings["dbConnString"].ToString();
string referer=String.Empty;
if(ctx.Request.ServerVariables["HTTP_REFERER"]!=null)
{
referer = ctx.Request.ServerVariables["HTTP_REFERER"].ToString();
}
string sForm =
(ctx.Request.Form !=null)?ctx.Request.Form.ToString():String.Empty;
string sQuery =
(ctx.Request.QueryString !=null)?ctx.Request.QueryString.ToString():String.Empty;
strData="\nSOURCE: " + ex.Source +
"\nMESSAGE: " +ex.Message +
"\nFORM: " + sForm +
"\nQUERYSTRING: " + sQuery +
"\nTARGETSITE: " + ex.TargetSite +
"\nSTACKTRACE: " + ex.StackTrace +
"\nREFERER: " +referer;
if(dbConnString.Length >0)
{
SqlCommand cmd = new SqlCommand();
cmd.CommandType=CommandType.StoredProcedure;
cmd.CommandText="usp_WebAppLogsInsert";
SqlConnection cn = new SqlConnection(dbConnString);
cmd.Connection=cn;
cn.Open();
try
{
cmd.Parameters.Add(new SqlParameter("@Source", ex.Source));
cmd.Parameters.Add(new SqlParameter("@Message",ex.Message)); cmd.Parameters.Add(new SqlParameter("@Form",sForm));
cmd.Parameters.Add(new SqlParameter("@QueryString",sQuery));
cmd.Parameters.Add(new SqlParameter("@TargetSite",ex.TargetSite.ToString()));
cmd.Parameters.Add(new SqlParameter("@StackTrace",ex.StackTrace.ToString()));
cmd.Parameters.Add(new SqlParameter("@Referer",referer));
SqlParameter outParm=new SqlParameter("@EventId",SqlDbType.Int);
outParm.Direction =ParameterDirection.Output;
cmd.Parameters.Add(outParm);
cmd.ExecuteNonQuery();
evtId =Convert.ToInt32(cmd.Parameters[7].Value);
cmd.Dispose();
cn.Close();
}
catch (Exception exc)
{
EventLog.WriteEntry (ex.Source, "Database Error From Exception Log!", EventLogEntryType.Error,65535);
}
finally
{
cmd.Dispose();
cn.Close();
}
try
{
EventLog.WriteEntry (ex.Source, strData,EventLogEntryType.Error,evtId);
}
catch(Exception exl)
{
throw;
}
}
}
string strEmails =System.Configuration.ConfigurationSettings.AppSettings["emailAddresses"].ToString();
if (strEmails.Length >0)
{
string[] emails = strEmails.Split(Convert.ToChar("|"));
MailMessage msg = new MailMessage();
msg.BodyFormat = MailFormat.Text;
msg.To = emails[0];
for (int i =1;i<emails.Length;i++)
msg.Cc=emails[i];
string fromEmail=
System.Configuration.ConfigurationSettings.AppSettings["fromEmail"].ToString(); msg.From=fromEmail;
msg.Subject = "Web application error!";
string detailURL=
System.Configuration.ConfigurationSettings.AppSettings["detailURL"].ToString();
msg.Body = strData + detailURL +"?EvtId="+ evtId.ToString();
SmtpMail.SmtpServer =
System.Configuration.ConfigurationSettings.AppSettings["smtpServer"].ToString();
try
{
SmtpMail.Send(msg);
}
catch (Exception excm )
{
throw;
}
}
else
{
return;
}
}
}
}
|
if you walk through the above code which is very "linear" in
nature, you should have no difficulty understanding what is going on.
Now here are
the steps to set up the sample app in the download below, so that you
can begin to use and / or customize it: The download zipfile contains
code that adds an additional "LogDateTime" field in the table to make
searching and sorting easier for any reporting pages you may wish to
create.
1) Unzip the files into a new folder under your wwwroot called "ExceptionLogger".
2) Go Into IIS Manager and set this to be a new Virtual Directory and
make it an IIS Application. (The actual ExceptionHandler class project
is in a subfolder).
3) In Windows Explorer double-click the EventLogEntries.reg Registry
script to make the Registry modifications described above (You can
export the existing keys first if you want so you can restore them). If
you want a custom IIS app for your reporting page(s), you'll need to
make those changes in the Registry script before you
execute it).
4) Open Up Sql Query Analyzer and connect to your SQL Server database.
Load the ExceptionLogger.sql script and execute it.
5) Make any custom modifications to the web.config that pertain to
your email and other settings. Make sure that your IIS SMTP Server
is running.
NOTE: If your app is running under the default "machine" account in
machine.config, that's the weak asp.net account, whch may not have write
permissions to the Event log. Either grant it the permission, change
the userName in the processModel section of machine.config to "system",
or run your code under impersonation of a stronger account with the correct
setting in your web.config.
Your Really Useful Exception Engine is now ready. If you request the
Webform1.aspx test page, you'll see that it attempts a division by zero
and creates
an unhandled
exception.
If
you bring up your Event Viewer and look in the Application log, you'll
see the event log entry for this. If you scroll to the bottom of the
entry, clcking on the help URL will bring up the Report.aspx page and
display your custom exception database record information! You should
also get an email with a link to the same page and info.
Your goal when you put your app through testing and QA should be to
turn on the Really Useful Exception Engine, and over an extended testing
period, see NO ENTRIES. Then, turn it off when you deploy your app into
production. If there is ever a user report of a "BTH" (Bad thing happened),
you can turn it back on temporarily to help diagnose the issue so it
can be fixed quickly.
Finally, in the interest of completeness, there is one issue you should
be aware of. The eventlog classes wrap the OS API and the eventID is
an integer with a maximum value of 65535. Once your table gets over
that number of rows, you will need to drop and recreate the LogItems
table, or at least delete the records and re-seed the EventId column
back to
"1". The most elegant way to do this is to test in the stored
procedure itself, and issue a TRUNCATE TABLE command which reseeds the
Primary Key back to start at "1" I've included code in the sproc that
will do this in the download below. Of
course,
only developers with Obsessive Compulsive Disorder are ever likely to
rack
up that many
exception records in their lifetimes, but - you never know, do ya'?
Download the code accompanying this article
| Peter Bromberg is a C# MVP, MCP, and .NET consultant who has worked in the banking and financial industry for 20 years. He has architected and developed web - based corporate distributed application solutions since 1995, and focuses exclusively on the .NET Platform. |
|