A user encounters a JavaScript error. You’ll never guess what happens next!!

David Gilbertson
9 min readDec 10, 2017

One of the hardest things a developer must do is release their bug-riddled code out into the world, knowing they may never hear from those little critters ever again.

The savvy developer, then, should have some sort of mechanism so that those bugs can call home and tell you about their adventures in user-land.

Maybe your bug reporting setup is just this:

I apologise if you were eating while you read that.

Now just ask your friendly network engineer for a spreadsheet with all the 404s starting with /errors. They’ll love that.

But that ‘code’ probably isn’t going to help you track down where the error occurred. You’ll want to go one better and send a message containing the file and line number:

That’s borderline legitimate code, but like a attractive grizzly’s skeleton, it’s still pretty bare bones.

If the error is related to some specific data then you won’t have much luck replicating it.

Wouldn’t it be neat if you had a full report of the user’s activity so you could retrace their steps. Something like this:

The interesting parts being that the user navigated to a product details page (step 4) and clicked the buy button (step 5, the final step).

Straight away I can guess that there’s probably something iffy with the data for that particular product, so I can go to the URL and hit the button labelled “Buy this for $”.

Sure enough, when I do this, I get the error; this particular product had no price, so calling toLocaleString threw an error. Classic rookie mistake.

But … what if the flow was more complex? Maybe the user was on one of many tabs not reflected in the URL, or the error occurred when validating data from a form.

Following a URL and clicking a button isn’t going to cut it.

What I really want is to be able to play back all of the user’s steps up until the point of the error. Ideally just by clicking a crimson “Next step” button again and again.

This is how it would look in my imagination if I didn’t have aphantasia:

Presenting: Peeping DOM

(The error showing on the screen and the file opening in my editor are courtesy of the Create React App setup, nothing clever that I’m doing.)

Side note: I actually made this as an experiment to be able to carry out ‘unmoderated usability testing’. I wrote all the code that tracked the user actions and played them back, and then asked a clever chap called John what he thought. He said it was a stupid idea, but that the code might be useful for replicating errors. So, that’s what this blog post is about now. Thanks John.

The setup

The code is all here if you’d rather read that than my words. I’ll show simplified versions of the functions below and link to the full versions as I go.

I have a module record.js that contains a handful of functions for capturing different user interactions. These are all captured in a journey object that is sent to a server somewhere if an error is encountered.

At the entry point to my app, I’m going to kick things off by calling startRecording(), a function that looks a lot like this:

And when an error occurs, I’m going to send that journey object off. I’ll do this when I start my app:

With sendErrorReport (in the same module that journey is defined) looking like so:

If someone can ELI5 why doing JSON.stringify(err) doesn’t give me the body of the error that would be tops.

So far this is not super useful. But we now have the framework to start capturing more interactions.

If you have a state-based app (that is, the DOM is rendered based entirely on some central state) then your life is going to be easy (and dare I say you’re less likely to have bugs in the first place). When trying to replicate an error, you can simply re-create the state and you’ll probably be able to recreate the error.

If your app is a little bit retro and has things hiding and showing based directly on users clicking on buttons, things are going to be more difficult. You’ll need to replay click and focus events, and I guess each key press too. Not sure what you’re going to do about users pasting data. Best of luck to ya.

I’m both lazy and selfish, so I’m only going to address the scenario of a site using React and Redux.

There’s a few things I want to capture:

  • All dispatched actions (so I can ‘play back’ the store state)
  • URL changes (so I can update the URL)
  • Clicks (so I can see with my eyes the buttons and links the user was clicking on)
  • Scrolling (so I can see what the user was seeing on the page)

Intercepting Redux actions

This starts with the totally-easy-to-comprehend = () => next => action => {. You can go read the docs if you’re interested in what that’s all about. I’d rather spend my mental bandwidth on more important things, like practising my facial expression for when people are singing happy birthday to me.

All you really need to know is that this will push Redux ‘actions’ into that journey object as they happen.

I then reference this middleware when creating my store using Redux’s applyMiddleware function.

Recording URL changes

The point at which you capture a URL change will depend on how you do your routing.

React Router doesn’t make it easy to detect URL changes so you’re going to want to do something like this or maybe something like this. (I wish I could just define an onRouteChange handler with React Router. Surely things like logging virtual page views to Google Analytics is a pretty common thing.)

Anyhoo, I prefer to roll-my-own routing for most sites because it takes, like, 17 minutes to write the code and is significantly faster.

To capture a URL change I have the following function that I call whenever the URL is changed.

I call this in two places. At the same point where I do history.push() to update the URL, and also in a popstate event, which will fire if the user hits the back button:

Capturing user interactions

This is the most ‘intrusive’ of the capturing mechanisms because you need to interweave it throughout your app, so I personally would not bother doing this unless I was getting errors that I thought I couldn’t replicate without knowing what the user had clicked on.

Nevertheless, it was an interesting problem, so I shall document it here. When working with React, I always have a <Link> and a <Button> component, so centralising this is fairly simple. Let’s take a look at a <Link>:

The two lines relevant to this blog post are data-interaction-id={props.interactionId} and captureInteraction(e);.

When it comes time to play back a session, I want to be able to highlight the thing being clicked. For which I will need a selector of some sort. I could insist that elements to be tracked have an id, but for a reason I now forget, I decided that something specific to Peeping DOM (I still wince) was more appropriate.

Here’s the captureInteraction function:

The real one checks that it will be able to find the element again when playing back.

As with all the others, I gather a bit of data then journey.steps.push.

Scrolling

The last thing I want to do is keep track of where a user is on the screen. If they scroll to the bottom of the page and start filling out a form, playing that back without scrolling isn’t much use.

I will batch all sequential scroll events into a single event. I’ll debounce for performance (using Lodash because setting and clearing timeouts in a loop confuses me).

The real version excludes contiguous scroll events.

startScrollCapturing() is called when the app first starts.

Some ideas I didn’t build

  • You might want to capture keystrokes like escape and tab and enter if that’s going to be helpful.
  • You will also want to capture screen resizes (if replaying scroll position is important).
  • Rather than capturing scroll position, you could instead call scrollIntoView() on an element at playback time when highlighting it.
  • You might want to take a copy of localStorage and cookies if they affect the behaviour of your site.
  • Lastly, users tend to get a bit complainey about people intercepting and saving everything they type, particularly credit card numbers, passwords and the like. So it’s important that they don’t know you’re doing this. Wink.

Edit: contrary to what a few commenters have said, the methods described in this post bring no additional security or privacy concerns. If you’re currently collecting sensitive user data, then whatever requirements you have for the collection and storage of that data must be applied to data collected when you submit an error report. If you wouldn’t auto-save a form without asking, then don’t auto-send an error report without asking. If you must expressly require a user to tick a tick box before submitting a form with personal information, then you must expressly have that user tick the same tickbox before sending an error report. There’s nothing fundamentally different between sending a payload to /signup or /errors — as long as you treat the data in the appropriate and legal manner in both cases.

You might think we’re done now, but that’s all just to record the steps. The sexy part is being able to play those steps back…

Playing back a user’s steps

There’s two parts to this:

  1. The interface that I open to investigate errors (by replaying the user’s steps)
  2. Putting some code in my site that allows it to be controlled by external forces

The playback interface

This page will display my site in an iFrame and the me ‘drive’ the site by replaying the steps from a user’s journey.

The page will load the details of a session that resulted in an error, and send each step ‘down’ into the site to change its state.

When I open this page I see some basic, ugly UI, and the site loaded in an iFrame (wrapped in an iPad image for a bit of pizazz).

Here’s the same gif from further up the page.

When I click “Next step” it’s going to send a message to the iFrame using iFrame.contentWindow.postMessage(nextStep, '*'). There’s one exception to this: URL changes. In that case it just updates the src of the iFrame. This is essentially a full page refresh for the app, so might not work depending on how you carry state between your pages.

If you’re not familiar, postMessage is a method on the window object designed to allow communication between different windows (in this case, the main page window and the window in the iFrame).

That’s about all the cleverness that this playback page has.

Allowing external control of the site

The logic for allowing playback is in playback.js.

In the entry point to my app I call a function to start listening to for messages, I pass in the store so I can call dispatch on it later.

I only do this in development mode.

Here it is in its proper context

That function looks something like this.

The full version

For Redux actions it just dispatches them to the store — nothing more to it. How great is that?

Scrolling does exactly what you might think it does. This is where having the correct screen width is important (and the setup as you see it in this repo will be incorrect if the user changes screen width/rotates their device, but I think calling scrollIntoView() is probably smarter anyway).

The highlightElement() function just adds an outline to the element. At it’s heart that’s just this:

The full version

That’s all there is to it.

Debrief

So, is this useful in the real world? I guess it depends on how many errors you get, and how much trouble you have replicating them.

Maybe just capturing URL and store changes will get you far enough to track down an error.

Or maybe the recording logic is useful to you, but the playback interface not so much. If you’re seeing errors that are only happening in, say, Safari 9 on iOS, the playback page is of no use because you won’t see the error in question.

As always, the litmus test (for me) is if I plan to incorporate this experiment into the site that I’m currently working on, and the answer is no.

Anyhoo, as an experiment this was kinda fun, I learnt a few things, and I still hold hope that it might be useful one day as an el cheapo unmoderated user testing tool.

If you find this useful, have some more ideas, or would like to be mean for no real reason, hit me up in the comments.

Farewell.

--

--