Realtime Error Reporting and Logs in JavaScript
It's not enough to have a central log and error logging system: good logs are characterized by realtime instant updates. There is a good reason for this, of course, as you never know what your customers are going through when using your product because you're not standing behind their back guiding them. Therefore, we need a way to be notified when they meet an unexpected behavior.
The most common ways to achieve realtime error reporting are Monitoring and Notifications. Both approaches are fine as long as they are used in the right situations. What is interesting about these approaches is that they share one common feature, which is providing log information immediately it is generated, to all connected clients.
This article explains a practical approach of using Pusher, a realtime Pub/Sub service, to monitor errors (especially uncaught ones) when they are thrown.
We will create a basic browser app that simulates a real life app throwing an error. We will throw this errors intentionally by the click of a button or two. The plan is, when this error is thrown, a global error event handler is setup which its handler will send as a request to a server via XHR.
The server is then going to use Pusher triggers to emit events that an error has occurred so all connected clients can subscribe to the event. In our case, we just have one connected client which displays each error as cards in realtime.
This leads us to categorizing our app into:
- Client: This is the customer facing app that has the tendency to throw errors. In our case, we will just be generating deliberate errors with the click of a button.
- Server: The client just generates errors and notifies the server. The server is then responsible for logging the errors and displaying them on all connected clients using Pusher events.
Client Errors
The first thing we need to work on is the client which contains the error-throwing buttons. Basically, we will create a few buttons that have event listeners attached to them. Here is basic markup to actualize our idea:
<!-- index.html -->
<div class="main">
<div class="container">
<h1>We love errors!</h1>
<h4>...and promise to throw some in REALTIME</h4>
<div class="button-group">
<button>Throw</button>
<button>some more...</button>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.16.2/axios.js"></script>
<script src="app.js"></script>
For simplicity’s sake, the head tag is not included. It just imports a basic CSS file which you can find in the Codepen demo at the end of the article. Below is a screenshot of the outcome:
The most important functions are the two buttons. When each of the buttons is clicked, an error is thrown with a different error message.
Axios, the CDN external JS file, is a utility library for making HTTP requests. We need this to send HTTP request when an error occurs. app.js
is our custom JavaScript file which we will address its content now.
Attaching Events
We need to attach events to both the buttons and the window
. Before doing so, let's create a constructor function for the app:
// app.js
(function() {
// Constructor function
function App (){
// Grab all events
this.buttons = document.querySelectorAll('button');
}
}())
Now, create a method that attaches an event listener to both buttons:
// ...
App.prototype.attachEvents = function() {
// Button Events
this.buttons.forEach((button, index) => {
button.addEventListener('click', () => {
if(index === 0) {
// yet to be created
// but throws error
this.throwDefault()
} else {
// yet to be created
// but throws another error
this.throwSomeMore()
}
})
})
// Window error events
}
// ...
We already know what the button events do, but why do we need to attach an event to the window
object? Browsers allow you to catch errors globally using the error
event. Therefore, whenever an error occurs in your app, this event will be triggered. This error event can be attached to the window
object:
App.prototype.attachEvents = function() {
// Button events
// Window error event
window.addEventListener('error', e => {
// when an error occurs,
// send the error information
// to our yet to be created server
this.sendPayloadToServer({
lineno: e.lineno,
colno: e.colno,
filename: e.filename,
message: e.error.message,
stack: e.error.stack
})
})
}
Throwing Errors
throwDefault
and throwSomeMore
are the methods called when the buttons are clicked. Let's create them and use them to throw errors:
App.prototype.throwDefault = function() {
throw new Error('An error occurred...')
}
App.prototype.throwSomeMore = function() {
throw new Error('...some more errors have occurred')
}
The console shows the errors thrown:
Send Errors to Server
Remember, when errors occur, they are handled by the error
event on window
. The event handler sends these errors as payload to the server using sendPayloadToServer
. Let's see what the function looks like:
App.prototype.sendPayloadToServer = function(e) {
// send error to server endpoint
axios.post('http://localhost:5000/report/error', e)
.then(data => {
console.log(data);
})
}
The error payload is sent to the /report/error
endpoint which we will create while talking about the server. axios is used to make this request by calling the post
method which takes the URL and the payload and returns a promise.
Provision a Server
Right now, we have a client sending errors to a server that doesn't exist. Guess it's time we do something about that.
Since we're speaking JavaScript, let's provision a server with Node using the Express Generator. To be able to do that, we need to install the generator globally, before generating an app:
# Install express generator
# globally
npm install -g express-generator
# Generate an app called
# error-server with ejs as view template
express --view=ejs error-server
The following image shows the output of generating a new express app:
What Express does is beyond the scope of this article; it's a routing framework for Node and you can learn more about it on the website.
Enabling CORS
Forgetting CORS issues is easy. We are dealing with two different apps that are running from different domains. Hence, we need to let the server know about that by allowing CORS. This can be done from the entry file, app.js
, right before importing the routes:
// ./app.js
//...
// CORS
app.all('/*', function(req, res, next) {
// CORS headers
res.header("Access-Control-Allow-Origin", "*"); // restrict it to the required domain
res.header('Access-Control-Allow-Methods', 'GET,POST');
// Set custom headers for CORS
res.header('Access-Control-Allow-Headers', 'Content-type,Accept,X-Access-Token,X-Key');
});
// Routes must come after
// enabling CORS
var index = require('./routes/index');
var report = require('./routes/report');
app.use('/', index);
app.use('/report/error', report);
It's important that you place the CORS middleware before the routes.
Creating Error Handler Route
The /routes/error
route shown above is yet to be created but its handler is being imported. This is the route which our client app sends a post request to. Let's create it:
// ./routes/report.js
const express = require('express');
const router = express.Router();
router.post('/', (req, res, next) => {
// Emit a realtime pusher event
res.send(req.body);
});
Right now it does nothing more than sending us back what we sent to it. We would rather it triggers a Pusher event.
Pusher Events
Pusher is known for the Pub/Sub (Event) pattern it introduces to building realtime solutions. This pattern is easy to work with because developers (even beginners) are used to writing events.
The event publisher is the source of event and payload, while the subscriber is the consumer of the event and payload. An example will explain this better:
// ./routes/report.js
// Import the Pusher
// JS SDK
const Pusher = require('pusher');
// Configure with the
// constructor function
const pusher = new Pusher({
appId: 'APP-ID',
key: 'APP-KEY',
secret: 'APP-SECRET',
cluster: 'CLUSTER',
encrypted: true
});
/* Handle error by emitting realtime events */
router.post('/', (req, res, next) => {
// emit an 'error' event
// via 'reports' channel,
// with the request body as payload
pusher.trigger('reports', 'error', req.body);
res.send(req.body);
});
module.exports = router;
For the above to work, you need to install the Pusher SDK:
npm install --save pusher
The code samples show how to configure Pusher using the Pusher
constructor function. The function is passed a config object with the credentials you receive when you create a Pusher App. Feel free to take the steps in the Appendix section at the bottom of this article to setup a Pusher account/app, if you don’t have one already.
The configured app is used in the route to trigger an event when the route is hit.
The next question is, where do we listen to these events and act accordingly? To answer that, we need to create an admin dashboard on the server app that listens for these events and acts on them.
Listing Errors
We have generated errors intentionally. We are reporting them to the server, and the server is acting on it. How do we know when the errors come in?
Note there is one route, /
, which we have not attended to. This route just renders an ejs
template view:
// ./routes/index.js
var express = require('express');
var router = express.Router();
/* GET home page. */
router.get('/', function(req, res, next) {
res.render('index');
});
module.exports = router;
The page rendered can be used to display a list of errors in realtime. This is what the simplified HTML looks like:
<!-- ./views/index.ejs -->
<div class="container">
<h1 class="text-center">Error Log</h1>
<div class="error-cards row">
<div class="col-md-4 card-template">
<div class="error-card">
<h4>Error message: something went wrong acd</h4>
<p>Stack Trace: Lorem ipsum dolor sit amet,...</p>
<div class="error-details">
<h4><a href="">file.js</a></h4>
<p>21:11</p>
</div>
</div>
</div>
</div>
</div>
<script src="https://js.pusher.com/4.0/pusher.min.js"></script>
<script src="/javascripts/app.js"></script>
The template contains a card widget which is hidden by default using CSS. The display property is applied using the card-template
class:
/* ./public/stylesheets/style.css */
.card-template {
display: none
}
The plan is, when the payload come in, we clone the template, remove the card-template
class, update the text contents with the payload values, and append to the HTML.
We also import the Pusher SDK because we need to listen to the error
event.
app.js
file is responsible for this:
// ./public/javascripts/app.js
(function(){
function App () {
// card template and cards parent
this.cardTemplate = document.querySelector('.card-template');
this.errorCards = document.querySelector('.error-cards');
}
// creates a card by cloning card template
// and updates the card with data
// from pusher subscription
App.prototype.updateCard = function(data) {
// clone template
const card = this.cardTemplate.cloneNode(true);
// update card contents and attributes
card.classList.remove('card-template');
card.querySelector('.error-card > h4').textContent = data.message;
card.querySelector('.error-card > p').textContent = data.stack;
card.querySelector('.error-details > h4 > a').textContent = data.filename;
card.querySelector('.error-details > h4 > a').setAttribute('href', data.filename)
card.querySelector('.error-details > p').textContent = `${data.lineno}:${data.colno}`;
// append to parent
this.errorCards.appendChild(card);
}
// sets up the app
App.prototype.boot = function() {
// allow pusher to log to your console
// DO THIS ONLY IN DEV
Pusher.logToConsole = true;
// configure pusher with app key
const pusher = new Pusher('<APP-KEY>', {
cluster: 'CLUSTER’,
encrypted: true
});
// subscribe to 'reports' channel
const channel = pusher.subscribe('reports');
// bind to error events to receive payload
channel.bind('error', (data) => {
console.log('-- pusher --', data)
// update cards
this.updateCard(data);
});
}
var app = new App();
app.boot();
}())
The constructor function, App
, is created with reference to the card template and the card’s parent (where we will attach real cards).
When the app is loaded, we first configure Pusher with the app key. The instance is used to subscribe to the reports
channel and bind to the error
event. The bind
method takes a callback which is where the UI is updated with updateCard
.
This is a screenshot of the listing screen:
...and a GIF showing the realtime updates
Conclusion
You have learned a better way to handle errors. Now you can give your customers the support they deserve, by responding promptly to the errors they encounter while using your apps.
Feel free to access any of the resources:
Original published at Pusher's blog.
Appendix: Setup a Pusher Account/App
- Sign up for a free Pusher account
- Create a new app by selecting Apps on the sidebar and clicking Create New button on the bottom of the sidebar:
- Configure an app by providing basic information requested in the form presented. You can also choose the environment you intend to integrate Pusher for a better setup experience:
- You can retrieve your keys from the App Keys tab
Awesome article christian.