Capturing and sending error reports by email in C♯
A month or two ago I put together a simple automatic updater and showed you how to do the same. This time I'm going to walk you through how to write your own error reporting system. It will be able to catch any errors through a try...catch
block, ask the user whether they want to send an error report when an exception is thrown, and send the report to your email inbox.
Just like last time, this post is a starting point, not an ending point. It has a significant flaw and can be easily extended to, say, ask the user to write a short paragraph detailing what they were doing at the time of the crash, or add a proper gui, for example.
Please note that this tutorial requires that you have a server of some description to use to send the error reports to. If you want to get the system to send you an email too, you'll need a working mail server. Thankfully DigitalOcean provide free credit if you have the GitHub Student pack. This tutorial assumes that your mail server (or at least a relay one) is running on the same machine as your web server. While setting one up correctly can be a challenge, Lee Hutchinson over at Ars Technica has a great tutorial that's easy to follow.
To start with, we will need a suitable test program to work with whilst building this thing. Here's a good one riddled with holes that should throw more than a few exceptions:
using System;
using System.IO;
using System.Net;
using System.Text;
public class Program
{
public static readonly string Name = "Dividing program";
public static readonly string Version = "0.1";
public static string ProgramId
{
get { return string.Format("{0}/{1}", Name, Version); }
}
public static int Main(string[] args)
{
float a = 0, b = 0, c = 0;
Console.WriteLine(ProgramId);
Console.WriteLine("This program divides one number by another.");
Console.Write("Enter number 1: ");
a = float.Parse(Console.ReadLine());
Console.Write("Enter number 2: ");
b = float.Parse(Console.ReadLine());
c = a / b;
Console.WriteLine("Number 1 divided by number 2 is {0}.", c);
return 0;
}
There are a few redundant using
statements at the top there - we will get to utilizing them later on.
First things first - we need to capture all exceptions and build an error report:
try
{
// Insert your program here
}
catch(Exception error)
{
Console.Write("Collecting data - ");
MemoryStream dataStream = new MemoryStream();
StreamWriter dataIn = new StreamWriter(dataStream);
dataIn.WriteLine("***** Error Report *****");
dataIn.WriteLine(error.ToString());
dataIn.WriteLine();
dataIn.WriteLine("*** Details ***");
dataIn.WriteLine("a: {0}", a);
dataIn.WriteLine("b: {0}", b);
dataIn.WriteLine("c: {0}", c);
dataIn.Flush();
dataStream.Seek(0, SeekOrigin.Begin);
string errorReport = new StreamReader(dataStream).ReadToEnd();
Console.WriteLine("done");
}
If you were doing this for real, it might be a good idea to move all of your application logic it it's own class and have a call like application.Run()
instead of placing your code directly inside the try{ }
block. Anyway, the above will catch the exception, and build a simple error report. I'm including the values of a few variables I created too. You might want to set up your own mechanism for storing state data so that the error reporting system can access it, like a special static class or something.
Now that we have created an error report, we need to send it to the server to processing. Before we do this, though, we ought to ask the user if this is ok with them (it is their computer in all likeliness after all!). This is easy:
Console.WriteLine("An error has occurred!");
Console.Write("Would you like to report it? [Y/n] ");
bool sendReport = false;
while(true)
{
ConsoleKey key = Console.ReadKey().Key;
if (key == ConsoleKey.Y) {
sendReport = true;
break;
}
else if (key == ConsoleKey.N)
break;
}
Console.WriteLine();
if(!sendReport)
{
Console.WriteLine("No report has been sent.");
Console.WriteLine("Press any key to exit.");
Console.ReadKey(true);
return 1;
}
Since this program uses the console, I'm continuing that trend here. You will need to create your own GUI if you aren't creating a console app.
Now that's taken care of, we can go ahead and send the report to the server. Here's how I've done it:
Console.Write("Sending report - ");
HttpWebRequest reportSender = WebRequest.CreateHttp("https://starbeamrainbowlabs.com/reportSender.php");
reportSender.Method = "POST";
byte[] payload = Encoding.UTF8.GetBytes(errorReport);
reportSender.ContentType = "text/plain";
reportSender.ContentLength = payload.Length;
reportSender.UserAgent = ProgramId;
Stream requestStream = reportSender.GetRequestStream();
requestStream.Write(payload, 0, payload.Length);
requestStream.Close();
WebResponse reportResponse = reportSender.GetResponse();
Console.WriteLine("done");
Console.WriteLine("Server response: {0}", ((HttpWebResponse)reportResponse).StatusDescription);
Console.WriteLine("Press any key to exit.");
Console.ReadKey(true);
return 1;
That may look unfamiliar and complicated, so let's walk through it one step at a time.
To start with, I create a new HTTP web request and point it at an address on my server. You will use a slightly different address, but the basic principle is the same. As for what resides at that address - we will take a look at that later on.
Next I set request method to be POST so that I can send some data to the server, and set a few headers to help the server out in understanding our request. Then I prepare the error report for transport and push it down the web request's request stream.
After that I get the response from the server and tell the user that we have finished sending the error report to the server.
That pretty much completes the client side code. Here's the whole thing from start to finish:
using System;
using System.IO;
using System.Net;
using System.Text;
public class Program
{
public static readonly string Name = "Dividing program";
public static readonly string Version = "0.1";
public static string ProgramId
{
get { return string.Format("{0}/{1}", Name, Version); }
}
public static int Main(string[] args)
{
float a = 0, b = 0, c = 0;
try
{
Console.WriteLine(ProgramId);
Console.WriteLine("This program divides one number by another.");
Console.Write("Enter number 1: ");
a = float.Parse(Console.ReadLine());
Console.Write("Enter number 2: ");
b = float.Parse(Console.ReadLine());
c = a / b;
Console.WriteLine("Number 1 divided by number 2 is {0}.", c);
}
catch(Exception error)
{
Console.WriteLine("An error has occurred!");
Console.Write("Would you like to report it? [Y/n] ");
bool sendReport = false;
while(true)
{
ConsoleKey key = Console.ReadKey().Key;
if (key == ConsoleKey.Y) {
sendReport = true;
break;
}
else if (key == ConsoleKey.N)
break;
}
Console.WriteLine();
if(!sendReport)
{
Console.WriteLine("No report has been sent.");
Console.WriteLine("Press any key to exit.");
Console.ReadKey(true);
return 1;
}
Console.Write("Collecting data - ");
MemoryStream dataStream = new MemoryStream();
StreamWriter dataIn = new StreamWriter(dataStream);
dataIn.WriteLine("***** Error Report *****");
dataIn.WriteLine(error.ToString());
dataIn.WriteLine();
dataIn.WriteLine("*** Details ***");
dataIn.WriteLine("a: {0}", a);
dataIn.WriteLine("b: {0}", b);
dataIn.WriteLine("c: {0}", c);
dataIn.Flush();
dataStream.Seek(0, SeekOrigin.Begin);
string errorReport = new StreamReader(dataStream).ReadToEnd();
Console.WriteLine("done");
Console.Write("Sending report - ");
HttpWebRequest reportSender = WebRequest.CreateHttp("https://starbeamrainbowlabs.com/reportSender.php");
reportSender.Method = "POST";
byte[] payload = Encoding.UTF8.GetBytes(errorReport);
reportSender.ContentType = "text/plain";
reportSender.ContentLength = payload.Length;
reportSender.UserAgent = ProgramId;
Stream requestStream = reportSender.GetRequestStream();
requestStream.Write(payload, 0, payload.Length);
requestStream.Close();
WebResponse reportResponse = reportSender.GetResponse();
Console.WriteLine("done");
Console.WriteLine("Server response: {0}", ((HttpWebResponse)reportResponse).StatusDescription);
Console.WriteLine("Press any key to exit.");
Console.ReadKey(true);
return 1;
}
return 0;
}
}
(Pastebin, Raw)
Next up is the server side code. Since I'm familiar with it and it can be found on all popular web servers, I'm going to be using PHP here. You could write this in ASP.NET, too, but I'm not familiar with it, nor do I have the appropriate environment set up at the time of posting (though I certainly plan on looking into it).
The server code can be split up into 3 sections: the settings, receiving and extending the error report, and sending the error report on in an email. Part one is quite straightforward:
<?php
/// Settings ///
$settings = new stdClass();
$settings->fromAddress = "[email protected]";
$settings->toAddress = "[email protected]";
The above simply creates a new object and stores a few settings in it. I like to put settings at the top of small scripts like this because it both makes it easy to reconfigure them and allows for expansion later.
Next we need to receive the error report from the client:
// Get the error report from the client
$errorReport = file_get_contents("php://input");
PHP on a web server it smarter than you'd think and collects some useful information about the connected client, so we can collect a few interesting statistics and tag them onto the end of the error report like this:
// Add some extra information to it
$errorReport .= "\n*** Server Information ***\n";
$errorReport .= "Date / time reported: " . date("r") . "\n";
$errorReport .= "Reporting ip: " . $_SERVER['REMOTE_ADDR'] . "\n";
if(isset($_SERVER["HTTP_X_FORWARDED_FOR"]))
{
$errorReport .= "The error report was forwarded through a proxy.\n";
$errorReport .= "The proxy says that it forwarded the request from this address: " . $_SERVER['HTTP_X_FORWARDED_FOR'] . "\n\n";
}
if(isset($_SERVER["HTTP_USER_AGENT"]))
{
$errorReport .= "The reporting client identifies themselves as: " . $_SERVER["HTTP_USER_AGENT"] . ".\n";
}
I'm adding the date and time here too just because the client could potentially fake it (they could fake everything, but that's a story for another time). I'm also collecting the client's user agent string too. This is being set in the client code above to the name and version of the program running. This information could be useful if you attach multiple programs to the same error reporting script. You could modify the client code to include the current .NET version, too by utilising Environment.Version
.
Lastly, since the report has gotten this far, we really should do something with it. I decided I wanted to send it to myself in an email, but you could just as easily store it in a file using something like file_put_contents("bug_reports.txt", $errorReport, FILE_APPEND);
. Here's the code I came up with:
$emailHeaders = [
"From: $settings->fromAddress",
"Content-Type: text/plain",
"X-Mailer: PHP/" . phpversion()
];
$subject = "Error Report";
if(isset($_SERVER["HTTP_USER_AGENT"]))
$subject .= " from " . $_SERVER["HTTP_USER_AGENT"];
mail($settings->toAddress, $subject, $errorReport, implode("\r\n", $emailHeaders), "-t");
?>
That completes the server side code. Here's the completed script:
<?php
/// Settings ///
$settings = new stdClass();
$settings->fromAddress = "[email protected]";
$settings->toAddress = "[email protected]";
// Get the error report from the client
$errorReport = file_get_contents("php://input");
// Add some extra information to it
$errorReport .= "\n*** Server Information ***\n";
$errorReport .= "Date / time reported: " . date("r") . "\n";
$errorReport .= "Reporting ip: " . $_SERVER['REMOTE_ADDR'] . "\n";
if(isset($_SERVER["HTTP_X_FORWARDED_FOR"]))
{
$errorReport .= "The error report was forwarded through a proxy.\n";
$errorReport .= "The proxy says that it forwarded the request from this address: " . $_SERVER['HTTP_X_FORWARDED_FOR'] . "\n\n";
}
if(isset($_SERVER["HTTP_USER_AGENT"]))
{
$errorReport .= "The reporting client identifies themselves as: " . $_SERVER["HTTP_USER_AGENT"] . ".\n";
}
$emailHeaders = [
"From: $settings->fromAddress",
"Content-Type: text/plain",
"X-Mailer: PHP/" . phpversion()
];
$subject = "Error Report";
if(isset($_SERVER["HTTP_USER_AGENT"]))
$subject .= " from " . $_SERVER["HTTP_USER_AGENT"];
mail($settings->toAddress, $subject, $errorReport, implode("\r\n", $emailHeaders), "-t");
?>
(Pastebin, Raw)
The last job we need to do is to upload the PHP script to a PHP-enabled web server, and go back to the client and point it at the web address at which the PHP script is living.
If you have read this far, then you've done it! You should have by this point a simple working error reporting system. Here's an example error report email that I got whilst testing it:
***** Error Report *****
System.FormatException: Input string was not in a correct format.
at System.Number.ParseSingle (System.String value, NumberStyles options, System.Globalization.NumberFormatInfo numfmt) <0x7fe1c97de6c0 + 0x00158> in <filename unknown>:0
at System.Single.Parse (System.String s, NumberStyles style, System.Globalization.NumberFormatInfo info) <0x7fe1c9858690 + 0x00016> in <filename unknown>:0
at System.Single.Parse (System.String s) <0x7fe1c9858590 + 0x0001d> in <filename unknown>:0
at Program.Main (System.String[] args) <0x407d7d60 + 0x00180> in <filename unknown>:0
*** Details ***
a: 4
b: 0
c: 0
*** Server Information ***
Date / time reported: Mon, 11 Apr 2016 10:31:20 +0100
Reporting ip: 83.100.151.189
The reporting client identifies themselves as: Dividing program/0.1.
I mentioned at the beginning of this post that that this approach has a flaw. The main problem lies in the fact that the PHP script can be abused by a knowledgeable attacker to send you lots of spam. I can't think of any real way to properly solve this, but I'd suggest storing the PHP script at a long and complicated URL that can't be easily guessed. There are probably other flaws as well, but I can't think of any at the moment.
Found a mistake? Got an improvement? Please leave a comment below!