Passive Analytics Collection - Methods

This post is a continuation of my previous post introducing a strategy for passive UI metrics collection. In that post, I highlighted why metrics collection is hard, how I see metrics collection commonly done in the wild, and outlined a strategy I think is superior from both a technical and business perspective. In this post I will demonstrate this strategy with a live demo and summarize the types of UI metrics commonly helpful when understanding how people use your software.

If you want to head over to the live demo link on github you can skip reading the rest of this blog post. Most user interfaces should track four categories of metrics: page performance, clicks, errors, and page views. Capturing these our types of metrics gives good coverage of what users are viewing, how quick the application is for them, what they are interacting with, and general errors they are experiencing.

Page performance metrics are critical to knowing how fast your pages are loading for users. The browser performance paint timing API emits a few timing metrics to help you understand how quickly pages are loading for users. Specifically, I find that paint and resource metrics are helpful to describe the range of load times experienced by users. These two metrics are easily captured using a PerformanceObserver and whitelisting the metrics you care about.

private track(): void {
    if (!window.PerformanceObserver) return;

    const observer = new PerformanceObserver((list) => {
        list.getEntries().forEach((entry) => {
            const event: IEvent = {
                tracker: this.getTrackerName(),
                type: entry.entryType,
                name: entry.name,
                value: '',
                detail: entry.toJSON()
            };

            if (entry.entryType === 'paint') {
                event.value = entry.startTime;
            } else if (entry.entryType.match(/^(resource|longtask)$/)) {
                event.value = entry.duration;
            }

            this._record(event);
        });
    });
    observer.observe({ entryTypes: this._config.entryTypeNames });
}

What your users are clicking on (or not clicking on) is critical to understanding the value of a feature. For example, what percent of our users click on the create form button? How many users are sorting the table by a specific column? Collecting these metrics passively instead of instrumenting every click metric will keep your code clean and ensure all future feature clicks get captured. As highlighted in the previous post, recording all clicks is a relatively simple task. The challenge is in the naming and ensuring consistency of that naming across your application and across time. The metrics demo concatenates ID attributes of clicked elements with ID attributes of parent elements to create a globally unique name for each element.

private trackExecute(clickTarget: Element): void {
    const name = buildLeafNodeIdentifier(clickTarget, this._config.idAttribute);
    const value = buildConcatenatedIdentifier(clickTarget, this._config.idAttribute);
    const event: IEvent = {
        tracker: this.getTrackerName(),
        type: 'click',
        name,
        value,
        detail: {
            className: clickTarget.className,
            id: clickTarget.getAttribute(this._config.idAttribute),
            tagName: clickTarget.tagName,
            textContent: clickTarget.textContent
        }
    };
    this._record(event);
}

/* local variable for addEventListener and removeEventListener */
_trackExecute = (event: Event): void => this.trackExecute(event.target as Element);

private track(): void {
    document.querySelector('body').addEventListener('click', this._trackExecute);
}

Also important is knowing which pages your users are visiting. Tracking page views is simple with the history API. By piggy backing on the pushState and replaceState methods we can capture each page view.

private track(): void {
    const originalPushState = window.history.pushState;
    window.history.pushState = (...args) => {
        originalPushState.apply(window.history, args);
        this.trackExecute();
    };

    const originalReplaceState = window.history.replaceState;
    window.history.replaceState = (...args) => {
        originalReplaceState.apply(window.history, args);
        this.trackExecute();
    }

    window.addEventListener('popstate', () => this.trackExecute());

    this.trackExecute();
}

Finally, reporting errors are critical to fixing negative user experiences in our applications. It seems that no matter how much automated and manual testing I do, there are always unique and novel errors that pop up in my software. Sometimes these errors occur by oversight in logic on my part, other times due to myriad esoteric reasons. Regardless, capturing and reporting errors is simple. The demo link listens to the window onerror method to report an error metric when encountered.

private track(): void {
    window.onerror = (...args) => {
        this.trackExecute(...args);
    };
}