Thursday, December 10, 2009

How to improve your application : a crash reporter to improve stability !

What is a crash reporter :
One condition for success, when shipping an application is that it should be as stable as possible !
And to achieve this, you have to test as deeply as possible your application. But :
1) If you are alone, it is a time consuming task,
2) It's still easy to miss some bugs, some special cases.

On the other hand, if you have a lot of users, the application will be launched a lot of times, a lot of special cases will be experimented by users, so they will suffer from unexpected crashes.

So we have to collect informations on the crashes that occurs on the end user device !
This is what a crash reporter is doing : whenever your application crash (and don't be over confident, it WILL happen ), it sends you informations on the conditions of the crash...
Don't get me wrong : the end users should not be your testers. If your application is full of bugs, they won't use it anymore, and you're just loosing your time ! But if you estimate your application is safe enough, and that you can't find any more bugs, publish your application with a crash reporter : you'll see all the 'other' bugs. The ones that result from situation you didn't imagine, and that really happen on your customer mobiles !

So what is in the crash reporter :
A crash is made of two different parts : the first one that collects information, and the second one that sends this information to you.

Note that there are already some packaged solutions for this, like this one :
http://code.google.com/p/android-remote-stacktrace/

But I preferred to create my own one (is it a good solution ? )

What to report ?

For each report, I chose to report as much information as possible. That includes :

  • The callstack
  • The information about the system that android provides me ( like the device type, the OS version, the application version, ...)
  • The avalaible memory ( memory is one of the most common issue in small mobile world ).


How to report ?
Most of the crash reporters I saw or heard about are using a Http connection silently opended to send the information to a Php wbe server that will store the issues in a database.

But I prefered to simply use mail :
Pro :

  • Simple to implement,
  • No server side code to make / test / debug,
  • gmail will automatic dealed with cases where there is no internet connection, 
  • Let the customer know that his problem is taken care of,
  • Let the customer know exactly what informations he sends you,
  • Involve the customer in the quality of your program ( if he didn't send a report, he can hardly complain the application is still bugged, so he 'should' be more tolerant).


Cons

  • All customer won't send the report !
  • Some customer could fake it ? That would be strange, but we definitively live in a strange world... So better be prepared to it !!
  • If you have a lot of bugs / a lot of customers, you can finally have a lot of mail to treat !
  • A Web/database solution is way easier to use for creating analysing tools ( like counting the occurences of each bugs, linking it to a version, etc ... )


So as you can see, each solution has some advantages. I would say if your application is big, go for a web/database solution.
In my case, I knew ( actually I hoped ) I hadn't a lot of bugs, so I chose the mail solution BECAUSE IT IS WAY SIMPLER TO DO !!

So here is the code :





public class ErrorReporter implements Thread.UncaughtExceptionHandler
{
String VersionName;
String PackageName;
String FilePath;
String PhoneModel;
String AndroidVersion;
String Board;
String Brand;
// String CPU_ABI;
String Device;
String Display;
String FingerPrint;
String Host;
String ID;
String Manufacturer;
String Model;
String Product;
String Tags;
long Time;
String Type;
String User;

private Thread.UncaughtExceptionHandler PreviousHandler;
private static ErrorReporter S_mInstance;
private Context CurContext;

public void Init( Context context )
{
PreviousHandler = Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler( this );
RecoltInformations( context );
CurContext = context;
}

public long getAvailableInternalMemorySize() {
        File path = Environment.getDataDirectory();
        StatFs stat = new StatFs(path.getPath());
        long blockSize = stat.getBlockSize();
        long availableBlocks = stat.getAvailableBlocks();
        return availableBlocks * blockSize;
    }
    
    public long getTotalInternalMemorySize() {
        File path = Environment.getDataDirectory();
        StatFs stat = new StatFs(path.getPath());
        long blockSize = stat.getBlockSize();
        long totalBlocks = stat.getBlockCount();
        return totalBlocks * blockSize;
    }

void RecoltInformations( Context context )
{
PackageManager pm = context.getPackageManager();
        try
        {
         PackageInfo pi;
            // Version
            pi = pm.getPackageInfo(context.getPackageName(), 0);
            VersionName = pi.versionName;
            // Package name
            PackageName = pi.packageName;
            // Files dir for storing the stack traces
            FilePath = context.getFilesDir().getAbsolutePath();
            // Device model
            PhoneModel = android.os.Build.MODEL;
            // Android version
            AndroidVersion = android.os.Build.VERSION.RELEASE;
          
            Board = android.os.Build.BOARD;
            Brand  = android.os.Build.BRAND;
            //CPU_ABI = android.os.Build.;
            Device  = android.os.Build.DEVICE;
            Display = android.os.Build.DISPLAY;
            FingerPrint = android.os.Build.FINGERPRINT;
         Host = android.os.Build.HOST;
         ID = android.os.Build.ID;
         //Manufacturer = android.os.Build.;
         Model = android.os.Build.MODEL;
         Product = android.os.Build.PRODUCT;
         Tags = android.os.Build.TAGS;
         Time = android.os.Build.TIME;
         Type = android.os.Build.TYPE;
         User = android.os.Build.USER;
          
        }
        catch (NameNotFoundException e)
        {
                e.printStackTrace();
        }
}

public String CreateInformationString()
{
String ReturnVal = "";
ReturnVal += "Version : " + VersionName;
ReturnVal += "\n";
ReturnVal += "Package : " + PackageName;
ReturnVal += "\n";
ReturnVal += "FilePath : " + FilePath;
ReturnVal += "\n";
ReturnVal += "Phone Model" + PhoneModel;
ReturnVal += "\n";
ReturnVal += "Android Version : " + AndroidVersion;
ReturnVal += "\n";
ReturnVal += "Board : " + Board;
ReturnVal += "\n";
ReturnVal += "Brand : " + Brand;
ReturnVal += "\n";
ReturnVal += "Device : " + Device;
ReturnVal += "\n";
ReturnVal += "Display : " + Display;
ReturnVal += "\n";
ReturnVal += "Finger Print : " + FingerPrint;
ReturnVal += "\n";
ReturnVal += "Host : " + Host;
ReturnVal += "\n";
ReturnVal += "ID : " + ID;
ReturnVal += "\n";
ReturnVal += "Model : " + Model;
ReturnVal += "\n";
ReturnVal += "Product : " + Product;
ReturnVal += "\n";
ReturnVal += "Tags : " + Tags;
ReturnVal += "\n";
ReturnVal += "Time : " + Time;
ReturnVal += "\n";
ReturnVal += "Type : " + Type;
ReturnVal += "\n";
ReturnVal += "User : " + User;
ReturnVal += "\n";
ReturnVal += "Total Internal memory : " + getTotalInternalMemorySize();
ReturnVal += "\n";
ReturnVal += "Available Internal memory : " + getAvailableInternalMemorySize();
ReturnVal += "\n";

return ReturnVal;
}

public void uncaughtException(Thread t, Throwable e)
{
String Report = "";
Date CurDate = new Date();
Report += "Error Report collected on : " + CurDate.toString();
Report += "\n";
Report += "\n";
Report += "Informations :";
Report += "\n";
Report += "==============";
Report += "\n";
Report += "\n";
Report += CreateInformationString();

Report += "\n\n";
Report += "Stack : \n";
Report += "======= \n";
final Writer result = new StringWriter();
final PrintWriter printWriter = new PrintWriter(result);
e.printStackTrace(printWriter);
String stacktrace = result.toString();
Report += stacktrace;

Report += "\n";
Report += "Cause : \n";
Report += "======= \n";

// If the exception was thrown in a background thread inside
// AsyncTask, then the actual exception can be found with getCause
Throwable cause = e.getCause();
while (cause != null)
{
cause.printStackTrace( printWriter );
Report += result.toString();
cause = cause.getCause();
}
printWriter.close();
Report += "****  End of current Report ***";
SaveAsFile(Report);
//SendErrorMail( Report );
PreviousHandler.uncaughtException(t, e);
}

static ErrorReporter getInstance()
{
if ( S_mInstance == null )
S_mInstance = new ErrorReporter();
return S_mInstance;
}
private void SendErrorMail( Context _context, String ErrorContent )
{
Intent sendIntent = new Intent(Intent.ACTION_SEND);
String subject = _context.getResources().getString( R.string.CrashReport_MailSubject );
String body = _context.getResources().getString( R.string.CrashReport_MailBody ) +
"\n\n"+
ErrorContent+
"\n\n";
sendIntent.putExtra(Intent.EXTRA_EMAIL,
new String[] {"postmaster@alocaly.com"});
sendIntent.putExtra(Intent.EXTRA_TEXT, body);
sendIntent.putExtra(Intent.EXTRA_SUBJECT, subject);
sendIntent.setType("message/rfc822");
_context.startActivity( Intent.createChooser(sendIntent, "Title:") );
}
private void SaveAsFile( String ErrorContent )
{
try
{
Random generator = new Random();
int random = generator.nextInt(99999);
String FileName = "stack-" + random + ".stacktrace";
FileOutputStream trace = CurContext.openFileOutput( FileName, Context.MODE_PRIVATE);
trace.write(ErrorContent.getBytes());
trace.close();
}
catch(IOException ioe) {
// ...
}
}

private String[] GetErrorFileList()
{
File dir = new File( FilePath + "/");
        // Try to create the files folder if it doesn't exist
        dir.mkdir();
        // Filter for ".stacktrace" files
        FilenameFilter filter = new FilenameFilter() {
                public boolean accept(File dir, String name) {
                        return name.endsWith(".stacktrace");
                }
        };
        return dir.list(filter);
}
private boolean bIsThereAnyErrorFile()
{
return GetErrorFileList().length > 0;
}
public void CheckErrorAndSendMail(Context _context )
{
try
{
if ( bIsThereAnyErrorFile() )
{
String WholeErrorText = "";
String[] ErrorFileList = GetErrorFileList();
int curIndex = 0;
// We limit the number of crash reports to send ( in order not to be too slow )
final int MaxSendMail = 5;
for ( String curString : ErrorFileList )
{
if ( curIndex++ <= MaxSendMail )
{
WholeErrorText+="New Trace collected :\n";
WholeErrorText+="=====================\n ";
String filePath = FilePath + "/" + curString;
BufferedReader input =  new BufferedReader(new FileReader(filePath));
String line;
while (( line = input.readLine()) != null)
{
WholeErrorText += line + "\n";
}
input.close();
}

// DELETE FILES !!!!
File curFile = new File( FilePath + "/" + curString );
curFile.delete();
}
SendErrorMail( _context , WholeErrorText );
}
}
catch( Exception e )
{
e.printStackTrace();
}
}
}



How does it work ?
This error reporter is initialized from the application object.
Then, in the first activity displayed  (my main menu ), I call the CheckErrorAndSendMail function, that checks if an error occured during a previous launch of the game, and in this case, creates the mail and calls a mail intent so the customer can send it ( or dismiss it )...

On init, the error reporter collects some general informations about the phone, the OS version, the application version. Then it registers the error reporter as the current default exception handler. Then the error reporter uncaughtException method will be called on any crash.

This method then creates a string with the general information collected, and information on the actual state of memory ( memory is a very sensible subject on mobiles ). It then adds the different information on the callstacks, packs all of this in a new file, and calls the previous exception handler ( Android need to know a crash happened !!! )
Why don't we send the mail right now ?
Because a crashed application won't let you send a mail.


Results 
What were the results on my games ( WordProspector and "Chasseur de mots" ) ?
The first day the version with the crash reporter was shipped, I received no mails. Either my crash reporter was not working, or my application was completely bugless ??

But a few days later, I start receiving 2 / 3 mails every days.
I could immediatly identify a simple bug ( a quote in the player name would make the game crash ).
Then most of the other reports were from a memory leak issue.

I would never have found those bugs without this crash reporter.
It is a real time saver, and a must if you want to give your customer the best experience you can !
If you don't already have it on your shipped application, you really shoud think about it !!


EDIT :
Note that I've updated my crash reporter !
Have a look here :
http://androidblogger.blogspot.com/2010/03/crash-reporter-for-android-slight.html

17 comments:

Tim said...

This is really great! I made two changes for myself...

I added a string value for CrashReport_EmailAddress, and modified SendErrorMail with:

String email = _context.getResources().getString(R.string.CrashReport_EmailAddress);

sendIntent.putExtra(Intent.EXTRA_EMAIL,
new String[] {email});


I made bIsThereAnyErrorFile() public...

This allows you to easily ask the user first if they want to send the error, instead of blindly throwing them into an email with no prompt about why they are sending it, with the following code at the start of your app:

[code]

final ErrorReporter reporter = ErrorReporter.getInstance();
reporter.Init(this);
if (reporter.bIsThereAnyErrorFile()) {
new AlertDialog.Builder(this)
.setTitle("Send Error Log?")
.setMessage("A previous crash was reported. Would you like to send" +
" the developer the error log to fix this issue in the future?")
.setPositiveButton("OK", new OnClickListener() {

@Override
public void onClick(DialogInterface dialog, int which) {
reporter.CheckErrorAndSendMail(MyActivity.this);
}})
.setNegativeButton("Cancel", new OnClickListener(){

@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}})
.show();

}

[/code]

Oleg Mazurashu said...

Hi. It is very useful information. Thanks a lot.

Gyuri said...

Thanks for sharing, can be quite useful.

In my apps, I'm using Flurry analytics, which has built-in reporting of uncaught exceptions.

AndroidBlogger said...

@Oleg : Thanks ! It's nice if it can be useful !

@Tim : Actually, I didn't see the need for a specific Xml entry for the email address, but you're right : it's a good thing if I want it as independant from me as possible !

As for your message box thing, I think it would be better to integrate it in the Error Reporter, in the CheckErrorAndSendMail method, so that your activity just call a method, and don't do anything else !

Actually, when I send the mail, I put an header before the real logging part where I tell the user that sending the mail would help me fix the bug, and that I won't use the information for any other thing.
In my mind, it is just as your message box, but with one less step.
That makes me think I should add that the user can cancel the mail if he don't want to send it...
Hum...
Thanks for your feedback !

@Gyuri : I didn't know that Flurry was adding a crash report ! This is nice !
Perhaps I should try it, as it looks good !
Thanks for this input !

Mads said...

Not bad - I stumbled upon this post, when looking for info on the sliding drawer widget and Google pointed me to an older post of yours.

I wrote the library you refer to (Android Remote Stack Trace). Maybe I should include an option to let the user send an email instead? Might be nice..

AndroidBlogger said...

@Mads :
First: Nice work on your error reporter !
Actually I first tried your library, and for some reason, I couldn't get my own php script to deal with the error...
I really didn't want to debug some php at that point, so that basically what motivated me to use a mail approach : it is so simple !
Now if you already have a solid Http/php version working, I don't think you need a mail version.
The best benefit I see on my version compared to yours is the information that I collect on top of the call stack.
Mainly the app version, and the available memory are really precious information.
You definitively should add them !

ruhalmi said...

Great one saved me couple of hours ;)
Thanks a lot.

MrPants said...

Just wanted to say thanks for this - I've used this on my first app and it has proven invaluable

AndroidBlogger said...

Thanks for your comments !
Please note that I've improved the code here :

http://androidblogger.blogspot.com/2010/03/crash-reporter-for-android-slight.html

Anonymous said...

You should never create a method or a variable that starts with a capital letter. In the Java coding standards, only Classes start with a capital letter...

I was reading through the code and was wondering where this class was:
RecoltInformations(context);
and what it did. Then I found out it was a method...

Many of your methods and variables start with capitals - this is a really really bad habit to be in, and one you really need to get out of, especially if you are going to publish your code or work with any other java programmers.

AndroidBlogger said...

@ Anonymous : You are completely right, this naming convention I'm using sucks, because it's part of my own code, and part of code I reuse from samples / internet.

The thing is I can't switch to the Java convention : I'm during the day a C/C++ developer, with some other conventions, that I'm using here by habits.

I prefer to have horrible convention in my hobby project, and stay coherent in my real job...

But, in essence, I do agree with you :)

PanosJee said...

You can also create a service I created. You can find it at BugSense.

It's free and very easy to install. We would love to hear your feedback

AndroidBlogger said...

@PanosJee :

Definitively interesting !
I found your report really nice and pro-looking !

Note that I'm now using ACRA (see here), that is a huge improvement on what I exposed here.

I think there are some features in ACRA that you may be missing ( like collecting some other data to check, or sending a report even when there is no real bug, just an anormal situation that I know how to handle ).

But I definitively found your service interesting, and I may try it ( I just don't understand your business model :) )

AndroidBlogger said...

I've seen in G+ that you have integrated Acra so BugSense could be the Acra backend !!

Really nice !

AndroidBlogger said...

@PanosJee :

I just made a small try with bugsense integrated with ACRA :

* Setup is uber simple !!
* The reports are really nice

but :
* the data coming from Acra and those exposed with BugSense are different. So for instance, the Wifi On parameter is wrong.
* More important IMHO, I don't have access anymore to the custom additional data, and I found them really helpful in ACRA to help me debug !!

So really nice job, but I would still like just a little more work on the ACRA integration to be fully happy...

AndroidBlogger said...

@PanosJee :

My bad, the custom data ARE Present !!!
So I guess there is nothing more to prevent me to use it...

I officially adopt BugSense !!!

Sorry for the mistake...

Sergey Bratuhin said...

The perfect solution. I would recommend you to use StringBuilder instead of connecting lines.