Error Reporting in a Plugin  General

Error Reporting in a Plugin

Error reporting enables users of your plugin to report exceptions to you. You implement extension point errorHandler to integrate your plugin with the IDE's dialog to report fatal errors. This article teaches the fundamentals, explains how to avoid mistakes with the exception handling and demonstrates how to integrate with a Sentry backend.

A story

Back in 2017 I implemented error reporting for a client.
Exceptions were sent to Rollbar and could be fixed quickly.
My client and I were happy with this integration.

But after a while ago rather odd exceptions popped up. They were similar to this:

1
2
3
4
com.intellij.diagnostic.IdeaReportingEvent$TextBasedThrowable
    at com.intellij.diagnostic.IdeaReportingEvent.<init> (IdeaReportingEvent.java:18)
    at com.intellij.diagnostic.IdeErrorsDialog.reportMessage (IdeErrorsDialog.java:608)
    at com.intellij.diagnostic.IdeErrorsDialog.doOKAction (IdeErrorsDialog.java:373)

Here’s what I thought:

Why is IntelliJ reporting this?
It’s clearly some internal IntelliJ stuff. Probably, this doOkAction() method is buggy.
It really does not come from this plugin!

Errors of this kind did not stop, though. But my own plugins were still receiving stack traces as usual. So, I assumed that all of this is not my fault and that I couldn’t fix this in the plugin.

Recently, I implemented error reporting for the upcoming BashSupport Pro. I decided to use a self-hosted Sentry installation for this, because I wanted to be in control of the data.
And guess what? The exceptions looked broken, just like the one above.

This article will explain what error reporting in a plugin can do for you. You’ll also learn how to implement it for your own plugin and how to avoid my mistake.

Why you should implement error reporting

The notorious blinking icon of IntelliJ
Have you ever noticed this little red, blinking exclamation mark in the lower–right corner of your IDE’s main window? Well, that’s telling you that a fatal error occurred. If you’re lucky, then your own plugin is not at fault here. But often it is the culprit, isn’t it?

When you click on this red icon, then IntelliJ displays a dialog to report the exception.

How IntelliJ reports exceptions
How IntelliJ reports exceptions

Exceptions, which come from JetBrains’s code, are reported to their own servers.
If the exception comes from the code of a 3rd–party plugin, then the error report is handled by that plugin. This is only possible if you implement the extension point for this.

If you don’t do that, then users still open the dialog, but can’t send the report because the “Submit” button is disabled. That’s confusing and frustrating.

If you care about your users and the quality of your plugin, then it’s a good idea to implement this. And it’s an easy, automated way to receive error reports with all the data you need.

Extension point “errorHandler”

The extension point we need is called com.intellij.errorHandler.

Prerequisites

You need a few things before you can get to the code.

A way to receive the reports
You have multiple options here:
  • An easy solution is to upload a cgi–script somewhere and to send a HTTP POST request. That’s what I’m doing with the open–source BashSupport plugin. It’s simple and easy to handle for small plugins or single developers.
  • The client, whom I mentioned in the introduction, is using Rollbar. Rollbar is okay, but rather expensive if you need more than the basics.
  • For BashSupport Pro I’m hosting Sentry on my own server. With Sentry you can either use their cloud-hosting solution or host the same software yourself. This way you get the complete set of features and still are in control of the data. You’ll need a hosting solution which supports Docker, though.
  • Or you could do something completely different. For example, you could just open github.com in the browser or show a message to explain what the user should do.
A privacy statement (optional)
Users might want to know what’s send. You can provide your own message for the error reporting dialog.
User identification (optional)
The dialog provides optional UI to let the user provide identification, e.g. an email address or a login.

Implementation

This extension isn’t that complicated to implement. At first, I’ll describe how this works. You can find sample code to integrate with Sentry at the end.

Declare your error handler like this in your plugin.xml:

1
2
3
<extensions defaultExtensionNs="com.intellij">
    <errorHandler implementation="plugin.SentryErrorReporter"/>
</extensions>

You have to create a subclass of com.intellij.openapi.diagnostic.ErrorReportSubmitter.
Implement at least these two methods:

  1. public String getReportActionText().
    Use it to customize the label of the dialog’s submit button.
  2. public boolean submit(...).
    This method does all the hard work. The parameters provide the errors to report, an optional note of the user and a callback to tell IntelliJ when the report was send.

How to implement ErrorReportSubmitter.submit(...)

Here’s the full signature of the method:

1
2
3
4
5
6
7
8
class YourErrorSubmitter extends ErrorReportSubmitter {
    public boolean submit(@NotNull IdeaLoggingEvent[] events,
            @Nullable String additionalInfo,
            @NotNull Component parentComponent,
            @NotNull Consumer<SubmittedReportInfo> consumer) {
        // your code here
    }
}

The method itself has a boolean return type. This is to tell the IDE if the report can be send at all. If you can’t send the report, then return false and you’re done.
Otherwise, return true and send the report asynchronously — that’s important.

events
This is the list of exceptions, which should be send.
additionalInfo
This is an optional message by the user.
parentComponent
This might be useful if you want to show UI, e.g. a message box. Ignore this if you’re not interacting with the user.
consumer
The callback. Call consumer.consume(…) when the report has been send successfully or failed to send. The argument to this method specifies the type of result.

Implementing an asynchronous operation might seem difficult at first. But IntelliJ already provides a bunch of abstractions to handle the most common cases.
IntelliJ’s own, internal error reporter implements this with a Task.Backgroundable.

We’ll do this in a very similar manner:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class YourErrorSubmitter extends ErrorReportSubmitter {
    public boolean submit(@NotNull IdeaLoggingEvent[] events,
            @Nullable String additionalInfo,
            @NotNull Component parentComponent,
            @NotNull Consumer<SubmittedReportInfo> consumer) {
        
        DataManager mgr = DataManager.getInstance();
        DataContext context = mgr.getDataContext(parentComponent);
        Project project = CommonDataKeys.PROJECT.getData(context);

        // make use of IntelliJ's background tasks
        new Task.Backgroundable(project, "Sending Error Report") {
            @Override
            public void run(@NotNull ProgressIndicator indicator) {
                // fixme: send your error report here
                //        this is executed in a background thread 

                ApplicationManager.getApplication().invokeLater(() -> {                    
                    // fixme: tell IntelliJ about the result
                    SubmittedReportInfo status = /*...*/; 
                    consumer.consume(status);
                });
            }
        }.queue(); // <-- don't miss the queue() call here!
        return true;
    }
}

Of course, the logic to send the data to your server isn’t there yet. But we’ll get to that, soon.

Extracting the data to send

Let’s have another look at the dialog and the signature of the submit() method.

1
2
3
4
5
6
7
8
class YourErrorSubmitter extends ErrorReportSubmitter {
    public boolean submit(@NotNull IdeaLoggingEvent[] events,
            @Nullable String additionalInfo,
            @NotNull Component parentComponent,
            @NotNull Consumer<SubmittedReportInfo> consumer) {
        // your code here
    }
}

Most of the elements of this dialog can be customized by your implementation. From top to bottom:

User message
Here, a user may provide some more details. This message is provided by the parameter String additionalInfo.
Attachments
A list of files, which should be send alongside the report.
The first one is always the exception itself. Sometimes, IntelliJ adds more items to this list, e.g. the currently edited file. Only user–approved attachments are send. These attachments are made available by com.intellij.diagnostic.IdeaReportingEvent, which is a subclass of IdeaLoggingEvent. Logging events are provided by the parameter events.
User identification (optional, hidden by default)
This allows to identify the user. Override String getReporterAccount() and void changeReporterAccount(Component parentComponent) if you want to support this.
This was only recently added to the SDK, versions 2019.3 and later come with this feature.
Privacy policy (optional, hidden by default)
Implement public String getPrivacyNoticeText() if you’d like to show this. Basic HTML tags are allowed
Versions 2018.3 and later offer this feature.
Submit button
Implement method String getReportActionText() to customize the label of this button.

IdeaLoggingEvent

Do you still remember the first parameter of the submit() method? It’s IdeaLoggingEvent[] events. We’ll now take a closer look at these events.

As far as I can tell, IntelliJ always passes a single event of type IdeaReportingEvent. IdeaReportingEvent wraps the exception and the list of attachments. But this implementation may change at any time. So we’ll handle more than one event and won’t assume that it’s always a IdeaReportingEvent.

Use event.getThrowableText() to get the complete stack trace as a string.
Method event.getThrowable().printStackTrace(…) provides the same value as event.getThrowableText().

But, please, never use the return value of event.getThrowable() for anything else. For example, don’t use the result of event.getThrowable().getStackTrace().

Here’s why: the event is a IdeaReportingEvent, and the implementation of IdeaReportingEvent is a bit special. It creates a new exception of type TextBasedThrowable to wrap the original exception’s stack trace string. But its getStackTrace() is still returning the stack trace where this wrapper was instantiated. If you use it, then you’ll get the wrong stack trace.

If you need the original exception, then retrieve it like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class YourErrorSubmitter extends ErrorReportSubmitter {
    public boolean submit(@NotNull IdeaLoggingEvent[] events,
            @Nullable String additionalInfo,
            @NotNull Component parentComponent,
            @NotNull Consumer<SubmittedReportInfo> consumer) {
        // not npe-safe, just for demonstration
        Throwable original =
            ((AbstractMessage)events[0].getData()).getThrowable();
    }
}

This is what happened for the plugin of my client:

  1. I implemented the error reporter in 2017. The exception returned by event.getThrowable() was passed on to the Rollbar library. Everything worked nicely.
    The Rollbar library used event.getThrowable().getStackTrace() to get all the frames of the stack. So far, so good.
  2. Now — in 2018 — this commit refactored the error reporting. TextBasedThrowable was introduced. Apparently, this change was made to allow user–editable stack traces.
  3. Now, event.getThrowable().getStackTrace() now returns where TextBasedThrowable was created and not were the original exception occurred.
  4. The error reporting is now messed up. Error reporting of my own plugins worked, because I was just using event.getThrowableText() and not the throwable itself.

Recommendations

  • Use event.getThrowableText() whenever possible. Try to use the user–editable stack trace text.
  • Use ((AbstractMessage)event.getData()).getThrowable() if you need the original exception with stack trace intact. This is probably the only solution for libraries, which need a Throwable. And – of course – check getData() for null when you’re using its return value.

As far as I know, this isn’t documented in the public API and this certainly is not an official guideline. Things may break in the future if you access the original throwable as shown above. But — to my knowledge — there’s no other way if you need it.

How to implement error reporting with Sentry

Here we’ll discuss in short how an implementation with Sentry could look like.
There’s complete sample code on github.com for your reference. Here, we’re only discussing the general approach.

  1. Declare a dependency on the Sentry client library to make it available in your project. Add this to your build.gradle file:
1
2
3
4
5
6
7
8
9
dependencies {
    compile('io.sentry:sentry:1.7.27') {
        // exclude the slf4j transitive dependency
        // IntelliJ already bundles it. 
        // You'll get classloader errors
        // if this is bundled with your plugin.
        exclude group: 'org.slf4j'
    }
}
  1. Setup the sentry client. This is usually only needed once. You need a “DSN” for this. Sentry provides it in the settings of your project. SentryDemo is a simple implementation.
  2. Create a new event with the properties, which you want to send together with the stack trace (sample code).
  3. Send the event to the Sentry server: sentryClient.sendEvent(…).
  4. Call consumer.consume(…) to tell the IDE about the result. You need to do this in the dispatcher thread. It’s usually something like this:
1
2
3
4
5
6
7
ApplicationManager.getApplication().invokeLater(() -> {
    consumer.consume(
        new SubmittedReportInfo(
            SubmittedReportInfo.SubmissionStatus.NEW_ISSUE
        )
    );       
});

How this is displayed by Sentry

Here’s how such an exceptions shows up in Sentry. The tags ide.build and release are especially useful to debug these issues later. The automatic grouping of events is also very helpful.

Code of the Sentry error handler

This is just a copy of the code on GitHub.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
package dev.ja.samples.errorReporting;

import com.intellij.diagnostic.AbstractMessage;
import com.intellij.diagnostic.IdeaReportingEvent;
import com.intellij.ide.DataManager;
import com.intellij.ide.plugins.IdeaPluginDescriptor;
import com.intellij.idea.IdeaLogger;
import com.intellij.openapi.actionSystem.CommonDataKeys;
import com.intellij.openapi.actionSystem.DataContext;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.diagnostic.ErrorReportSubmitter;
import com.intellij.openapi.diagnostic.IdeaLoggingEvent;
import com.intellij.openapi.diagnostic.SubmittedReportInfo;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.progress.Task;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.Messages;
import com.intellij.util.Consumer;
import io.sentry.SentryClient;
import io.sentry.event.Event;
import io.sentry.event.EventBuilder;
import io.sentry.event.interfaces.ExceptionInterface;
import io.sentry.event.interfaces.SentryException;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.awt.*;
import java.util.ArrayDeque;
import java.util.Deque;

/**
 * This is a sample implementation how to report exceptions
 * to a <a href="https://sentry.io/welcome/">Sentry</a> endpoint.
 *
 * @author jansorg
 */
public class SentryErrorReporter extends ErrorReportSubmitter {
    @Nullable
    @Override
    public String getPrivacyNoticeText() {
        return "Hereby you agree to <a href=\"https://www.example.com\">this privacy statement</a>";
    }

    @Nullable
    @Override
    public String getReporterAccount() {
        return "user-id";
    }

    @Override
    public void changeReporterAccount(@NotNull Component parentComponent) {
        // change it
    }

    @NotNull
    @Override
    public String getReportActionText() {
        return "Report to Author";
    }

    /**
     * Here comes the main implementation of your error reporter.
     * See the definition of the super method for more specific comments.
     *
     * @param events          The list of events to process. IntelliJ seems to always send just one event.
     * @param additionalInfo  Optional, user-provided notes
     * @param parentComponent
     * @param consumer
     * @return
     */
    @Override
    public boolean submit(@NotNull IdeaLoggingEvent[] events,
                          @Nullable String additionalInfo,
                          @NotNull Component parentComponent,
                          @NotNull Consumer<SubmittedReportInfo> consumer) {

        DataContext context = DataManager.getInstance().getDataContext(parentComponent);
        Project project = CommonDataKeys.PROJECT.getData(context);

        new Task.Backgroundable(project, "Sending Error Report") {
            @Override
            public void run(@NotNull ProgressIndicator indicator) {
                EventBuilder event = new EventBuilder();
                event.withLevel(Event.Level.ERROR);
                if (getPluginDescriptor() instanceof IdeaPluginDescriptor) {
                    event.withRelease(((IdeaPluginDescriptor) getPluginDescriptor()).getVersion());
                }
                // set server name to empty to avoid tracking personal data
                event.withServerName("");

                // now, attach all exceptions to the message
                Deque<SentryException> errors = new ArrayDeque<>(events.length);
                for (IdeaLoggingEvent ideaEvent : events) {
                    // this is the tricky part
                    // ideaEvent.throwable is a com.intellij.diagnostic.IdeaReportingEvent.TextBasedThrowable
                    // This is a wrapper and is only providing the original stack trace via 'printStackTrace(...)',
                    // but not via 'getStackTrace()'.
                    //
                    // Sentry accesses Throwable.getStackTrace(),
                    // So, we workaround this by retrieving the original exception from the data property
                    if (ideaEvent instanceof IdeaReportingEvent && ideaEvent.getData() instanceof AbstractMessage) {
                        Throwable ex = ((AbstractMessage) ideaEvent.getData()).getThrowable();
                        errors.add(new SentryException(ex, ex.getStackTrace()));
                    } else {
                        // ignoring this ideaEvent, you might not want to do this
                    }
                }
                event.withSentryInterface(new ExceptionInterface(errors));
                // might be useful to debug the exception
                event.withExtra("last_action", IdeaLogger.ourLastActionId);

                // by default, Sentry is sending async in a background thread
                SentryClient sentry = SentryDemo.getSentryClient();
                sentry.sendEvent(event);

                ApplicationManager.getApplication().invokeLater(() -> {
                    // we're a bit lazy here.
                    // Alternatively, we could add a listener to the sentry client
                    // to be notified if the message was successfully send
                    Messages.showInfoMessage(parentComponent, "Thank you for submitting your report!", "Error Report");
                    consumer.consume(new SubmittedReportInfo(SubmittedReportInfo.SubmissionStatus.NEW_ISSUE));
                });
            }
        }.queue();
        return true;
    }
}