R&D

Product & Analytics Engineering

Implementing Attention Minutes, Part 1

In February, Upworthy announced Attention Minutes — a new standard for measuring user engagement on websites. Attention Minutes provide a more accurate and useful indicator of user experience than traditional metrics like pageviews and unique visitors.

In that announcement, we promised we’d share the details of our implementation with the public. We’ll do so here, in a series of posts on the Upworthy R&D Blog.

These posts will start with a high-level overview of what signals we take into account when determining whether a page has a user’s attention. Then, they’ll describe the JavaScript code we use to measure those signals and send them to our data systems for processing and analysis.

It’s worth mentioning that this series will only cover the method we use to record Attention Minutes in the browser. This technique is useless without additional systems to process, store, and analyze the (potentially very large) quantities of data generated by code like this. If that sounds daunting, you may want to consider utilizing a third-party analytics tool such as Chartbeat, whose “Engaged Time” is very similar to Attention Minutes. Other third-party analytics tools may also be adding attention tracking in the near future.

What do we mean by “attention”?

We consider the page to have the user’s attention if

  1. some activity is happening on the page. For our purposes this means:
    1. the page currently has focus
    2. and the user has interacted with the page within a certain timeout (more on what counts as “interaction” later)
  2. or a video is playing on the page

Attention Minutes, then, are the total number of minutes that a user has spent on a page where the above conditions have held true. Next, we’ll take a deeper look at how to test for each of these conditions.

Introduction to Bacon.js & event streams

For our implementation, we’re going to be using a JavaScript library called Bacon.js. For the unfamiliar, Bacon is an FRP (functional reactive programming)-style JavaScript library that allows us to merge, filter, and transform events without descending into event listener/callback hell.

Whereas traditional approaches involve reacting to discrete events in the context of a callback, Bacon.js lets us work with the set of all events of a given type that might occur on a given element as a single event “stream.” If event callbacks are machines waiting at the end of event-carrying conveyor belts, Bacon.js could be thought of as a tool for manipulating the conveyor belts themselves.

While this blog post assumes no previous experience with Bacon.js, it only provides a cursory overview of its functionality. For more information on the differences between reactive and event-driven programming, and when/why an FRP-style approach can be beneficial, here are some blog posts we recommend:

  1. Flowdock, “Bacon.js Makes Functional Reactive Programming Sizzle”
  2. Josh Marinacci, “Functional Reactive Programming with Bacon.js”

Now on to the actual implementation! While in our examples we are assuming that jQuery is also on the page, jQuery is not a dependency for using Bacon.js.

Listening to events and reacting to events using Bacon.js

Bacon.js adds the asEventStream method to jQuery. asEventStream returns a Bacon.js stream of all the events of a given type that occur on a given element. For example, to create an event stream that contains all of the “focus” events on the $(window) element, we could do something like this:

1
  var allFocusEvents = $(window).asEventStream('focus');

We can act on events that pass through a given event stream with the onValue function. onValue takes as its argument a separate function that is then called any time a value passes through the given stream. For example, if we wanted to log each event that passes through allFocusEvents, we could write:

1
2
3
4
5
  function logIt(x) {
    console.log(x);
  }

  allFocusEvents.onValue(logIt);

This is analogous to .on in jQuery, but as you will see, streams provide us with much more powerful tools for working with events.

In addition to firing functions in direct response to events, we can use Bacon’s map method to transform events as they arrive, producing a new stream of events in a different format.

map takes as its argument a transformation function and returns a new stream containing the result of applying the transformation function to each event passing through the source stream. For example, if you wanted to log the y-offset every time a user stopped scrolling on the page, it could look something like this:

1
2
3
4
5
6
7
8
9
10
11
12
  var allScrollEvents = $(window).asEventStream('scroll');

  function getYOffset() {
    return window.pageYOffset;
  }

  function logIt(x) {
    console.log(x);
  }

  var transformedEventStream = allScrollEvents.map(getYOffset);
  transformedEventStream.onValue(logIt);

In addition to accepting a transformation function, map can also accept a non-function value for all incoming events to be transformed into. For example, .map(x) has the same semantics as .map(function() { return x; }.

Bacon.js also provides the notion of a “property.” A property is like a stream, except it has a “current value.” The current value of a property is, by default, the last event to pass through the stream. We can retrieve a property for any given stream by calling toProperty on the stream. toProperty optionally takes an initial value.

For example, we could use a property in conjunction with the filter and merge methods to model whether or not the enter key is currently pressed.

1
2
3
4
5
6
7
8
9
10
11
12
13
  var allKeyUps = $(document).asEventStream('keyup');
  var allKeyDowns = $(document).asEventStream('keydown');

  function isEnter(e) {
    return e.keyCode === 32;
  }

  var enterUps = allKeyUps.filter(isEnter);
  var enterDowns = allKeyDowns.filter(isEnter);

  var enterPressed = enterDowns.map(true)
      .merge(enterUps.map(false))
      .toProperty(false);

Starting to put it all together

Now that we have some of the basics of Bacon.js under our belts, let’s turn to putting together some Bacon properties that can represent “attention” based on the conditions we outlined above.

Does the page currently have focus?

We think of the page as 1. having focus when the page loads, 2. losing focus if the window receives a blur event, and 3. regaining focus if the window receives a focus event.

This can be implemented like so:

1
2
3
4
5
6
7
8
9
  var focuses = $(window).asEventStream('focus');
  var blurs = $(window).asEventStream('blur');

  var focusesAsTrues = focuses.map(true);
  var blursAsFalses = blurs.map(false);

  var focusesAndBlurs = focusesAsTrues.merge(blursAsFalses);

  var isFocused = focusesAndBlurs.toProperty(true);

If we take all “focus” events as true and all “blur” events as false, we can merge those two streams to create the new stream isFocused.

We can then use the toProperty method to capture the value of the isFocused event stream. Note that we are supplying it with a default value of true because we consider the page to have focus on page load.

This can be more succinctly represented like so:

1
2
3
4
5
6
  var focuses = $(window).asEventStream('focus');
  var blurs = $(window).asEventStream('blur');

  var isFocused = focuses.map(true)
      .merge(blurs.map(false))
      .toProperty(true);

We’ve tried to keep this example as succinct as possible, but it’s worth noting that one could achieve better results by utilizing the Page Visibility API. Support is somewhat limited as of this writing, but if you’re interested in going that route, Mozilla has published an excellent tutorial.

Has the user interacted with the page recently?

Let’s now implement a property, recentlyActive, that represents whether the user has engaged with the page within a certain timeout. For the purposes of this post, that means the events focus, click, scroll, mousemove, touchstart, touchend, touchcancel, touchleave, and touchmove.

Let’s first build a stream that contains all of these events. For that, we’ll need to utilize Bacon.mergeAll. mergeAll takes its arguments and merges them together into a stream containing all the elements from each:

1
2
3
4
5
6
7
8
9
10
11
  var signsOfLife = Bacon.mergeAll(
    $(window).asEventStream('focus'),
    $(window).asEventStream('click'),
    $(window).asEventStream('scroll'),
    $(window).asEventStream('mousemove'),
    $(window).asEventStream('touchstart'),
    $(window).asEventStream('touchend'),
    $(window).asEventStream('touchcancel'),
    $(window).asEventStream('touchleave'),
    $(window).asEventStream('touchmove')
  );

Using this stream we now want to build a property that represents whether or not an event has passed through signsOfLife within a certain timeout. For that, we’re going to need three more utility functions from Bacon.js: delay, once, and flatMapLatest.

stream.delay(ms) creates a new stream that contains all the elements of the original stream, only delayed by ms milliseconds.

Bacon.once(x) creates a new stream that emits x once and then ends.

To achieve the timeout behavior we want, let’s first write a function that creates a stream that will emit true, then after a certain timeout, emit false. We’ll call it decayingStream:

1
2
3
4
5
  var TIMEOUT = 5000;

  function decayingStream() {
    return Bacon.once(true).merge(Bacon.once(false).delay(TIMEOUT));
  }

To put this all together, we’re going to need one more utility function, flatMapLatest. For each element in the source stream, stream.flatMapLatest(f) creates a new stream using f (which must be a function and always returns a stream of its own). The stream returned by flatMapLatest, then, emits any value emitted by the most recent stream generated by the function f.

Returning to our conveyor belt metaphor, you can think of flatMapLatest as transforming incoming values into their own conveyor belts using f. As each new conveyor belt is created, it pushes out the previous one and begins sending its own values down to the next part of the factory.

This probably seems a bit abstract, so let’s look at an example. Here’s what it would look like to use flatMapLatest to figure out if a user has been recently active:

1
2
3
  var recentlyActive = signsOfLife
      .flatMapLatest(decayingStream)
      .toProperty(true);

As each user-generated interaction event passes through the stream signsOfLife, a new decaying stream is generated. Think of this decaying stream as providing a timeout, which we set earlier to 5000 ms.

The decaying stream flips recentlyActive to true, and then, given enough time, back to false. If another “sign of life” occurs within the timeout, however, the original decaying stream will be replaced with a new one that has a fresh timeout, and the recentlyActive property will remain true for at least another 5000 ms.

Does the page have the user’s attention?

Assume for a moment that we have a property representing whether a video is playing on the page. Let’s call that videoIsPlaying. We could implement a property, hasAttention that represents whether the user is currently active (or what we call paying attention) that would look something like this:

1
  var hasAttention = (recentlyActive.and(isFocused)).or(videoIsPlaying);

In future posts, we’ll describe the implementation of videoIsPlaying, as well as how to use the hasAttention property to actually track Attention Minutes. For a sneak preview, here’s a simplified code sample that demonstrates the technique.

Thanks

Many thanks to @oldwestaction, @ljharb, @sstrickl, @littlelazer, and @tones for their help editing this post.