Stand your own crash server

How to deploy your own server to receive real time crash data.

I highly recommend trying a crash reporting services, like Sentry or Raygun. You will discover a lot of errors that you have never discovered during testing.

What if your organization feels uneasy trusting crash data (and source) to a 3rd party? Luckily, you can stand your own Sentry server, because it is an open source project, hosted at github.com/getsentry/sentry. But for purely client-side browser crash reporting it has a huge drawback: it only allows sending exceptions via GET transport. There are open issues and even a pull request but no resolution yet.

The main drawback of using GET requests is that all information must be encoded in the URL, that has limits (most importantly, IE limits the length of the URL to about 1-2 KB). Thus all my extra information about the state of the program when the crash happened might be trimmed.

On the other hand, Raygun uses POST to send the exceptions by default and it works beautifully. Yet, Raygun is not open source, and we cannot just deploy and host it ourselves.

In this blog post I will show how stand your own simple crash server. The code is hosted at bahmutov/error-receiver

  • Runs on Nodejs
  • Compatible with Raygun clients, like raygun4js
  • Only exposes a single end point that receives the crash data via POST
  • Checks the API key against environment variable to prevent unauthorized submissions

Raygun4js client

First, I investigated the raygun4js client library that catches the JavaScript exceptions in the browser and sends the data to the server. It is MIT licensed (nice!) and seems to be well-maintained and under active development. I created a sample page, trying to see what it takes to send the error to our own server. I am using the unminified Raygun client straight from the CDN for this demo.

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
<!DOCTYPE html>
<html>
<head>
<script>
// raygun snippet from https://github.com/MindscapeHQ/raygun4js
!function(a,b,c,d,e,f,g,h){a.RaygunObject=e,a[e]=a[e]||function(){
(a[e].o=a[e].o||[]).push(arguments)},f=b.createElement(c),g=b.getElementsByTagName(c)[0],
f.async=1,f.src=d,g.parentNode.insertBefore(f,g),h=a.onerror,a.onerror=function(b,c,d,f,g){
h&&h(b,c,d,f,g),g||(g=new Error(b)),a[e].q=a[e].q||[],a[e].q.push({
e:g})}}(window,document,"script","//cdn.raygun.io/raygun4js/raygun.js","rg4js");
</script>
<script>
// configure Raygun4js client
rg4js('apiKey', 'demo-api-key');
rg4js('enableCrashReporting', true);
rg4js('options', {
apiUrl: '//localhost:3004/crash'
});
</script>
</head>
<body>
<script>
throw new Error('This is wrong');
</script>
</body>
</html>

I hosted the page using http-server running on port 3004

The error was sent by the rg4js to the server

Request URL:http://localhost:3004/crash/entries?apikey=demo-api-key
Request Method:POST

The important request headers

Content-Length:1104
Content-Type:text/plain;charset=UTF-8
Cookie:raygun4js-userid=63db206a-e9c4-13df-25b0-a8c091f041f5

The error object has the following data

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
{
"OccurredOn":"2015-11-13T19:11:59.380Z",
"Details":{
"Error":{
"ClassName":"Error",
"Message":"This is wrong",
"StackTrace":[{
"LineNumber":38,
"ColumnNumber":11,
"ClassName":"line 38, column 11",
"FileName":"http://localhost:3004/",
"MethodName":"at "
}]
},
"Environment":{
"UtcOffset":-5,
"Browser-Width":1256,
"Browser-Height":370,
"Screen-Width":2560,
"Screen-Height":1440,
"Color-Depth":24,
"Browser":"Mozilla",
"Browser-Name":"Netscape",
"Browser-Version":"5.0 (Macintosh; ...",
"Platform":"MacIntel"
},
"Client":{
"Name":"raygun-js",
"Version":"2.0.3"
},
"UserCustomData":{
"handler":"From Raygun4JS snippet global error handler"
},
"Tags":[],
"Request":{
"Url":"http://localhost:3004/",
"QueryString":{},
"Headers":{
"User-Agent":"Mozilla/5.0 ...",
"Referer":"",
"Host":"localhost"
}
},
"Version":"Not supplied",
"User":{
"Identifier":"63db206a-e9c4-13df-25b0-a8c091f041f5",
"IsAnonymous":true,
"UUID":"63db206a-e9c4-13df-25b0-a8c091f041f5"
}
}
}

I highly recommend to provide tags, version and (if available) user information with each error, for now these fields are sent empty.

Server implementation

I decided to use the simplest HTTP server implementation. The first implementation is below. To see the latest code, look at the server source file.

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
var config = require('./src/config');
var pkg = require('./package.json');
var http = require('http');
var url = require('url');

var allowedApiKey = config.get('apiKey');
console.log('allowed api key "%s"', allowedApiKey);

// handle data encoded in json or text body
var bodyParser = require('body-parser');
var jsonParser = bodyParser.json();
var textParser = bodyParser.text();

function respondToInvalid(res) {
res.writeHead(400, {'Content-Type': 'text/plain'});
res.end('Invalid request\n');
}

function isValid(req, parsed) {
return req.method === 'POST' &&
parsed &&
parsed.query &&
parsed.query.apiKey === allowedApiKey;
}

function saveCrashReport(req) {
console.log(req.body);
}

function writeResponse(res) {
res.writeHead(200, {
'Content-Type': 'text/plain',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'Content-Type'
});
res.end('Hello there\n');
}

http.createServer(function (req, res) {
var parsed = url.parse(req.url, true);
console.log('%s - %s query', req.method, parsed.href, parsed.query);
if (!isValid(req, parsed)) {
return respondToInvalid(res);
}
jsonParser(req, res, function () {
textParser(req, res, function () {
saveCrashReport(req);
writeResponse(res);
});
});
}).listen(config.get('PORT'), '127.0.0.1');
console.log('%s running on port %d', pkg.name, config.get('PORT'));

To test the server from the command line, I used httpie tool.

$ http POST localhost:3004/foo?apiKey=demo-api-key key=value another=value
HTTP/1.1 200 OK
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Methods: POST
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Type: text/plain
Date: Fri, 13 Nov 2015 20:52:50 GMT
Transfer-Encoding: chunked
Hello there

The server parsed the query and the payload parameters

POST - /foo?apiKey=demo-api-key query { apiKey: 'demo-api-key' }
{ key: 'value', another: 'value' }

Any other request gets 4xx response

$ http localhost:3004
HTTP/1.1 400 Bad Request
Connection: keep-alive
Content-Type: text/plain
Date: Fri, 13 Nov 2015 20:17:28 GMT
Transfer-Encoding: chunked
Invalid request

To test from the actual page, I opened the above HTML (saved in test-page/index.html) and the server received the correct data

1
2
3
4
5
6
7
8
allowed api key "demo-api-key" at end point "/crash/entries
error-receiver running on port 3004
POST - /crash/entries?apikey=demo-api-key query { apikey: 'demo-api-key' }
{"OccurredOn":"2015-11-13T21:40:38.796Z",
"Details":{
"Error":{
"ClassName":"Error",
"Message":"This is wrong","StackTrace":[{"LineNumber":38 ...

Future work

I just showed the error receiver, and even this code is just the beginning. There is plenty of features that can be implemented to intelligently group and process exceptions. For example, one can open GitHub issues or attach comments to existing ones using my github-issue-filer module. One should also support a dynamic list of API keys to check. Each project should get its own API key to prevent error collisions.

Follow me at @bahmutov and github.com/bahmutov to get updates on this project.