Tracking page events in JSS

11 March 2021 • ~6 min read • by @jim
#sitecore#jss#react
Tracking page events in JSS

With Sitecore JSS, tracking custom page events requires a different approach.

Gone are the custom ASP.NET MVC controllers where you would previously trigger events, but it is relatively simple to achieve the same thing in the frontend with just a few lines of code.

Backend Setup

JSS ships with a tracking API, but the tracker service is disabled by default, so its first necessary to patch the Sitecore configuration to enable it, like so:

<configuration>
  <sitecore>
    <settings>
      <setting name="Sitecore.JSS.TrackerServiceEnabled" value="true" />
    </settings>
  </sitecore>
</configuration>

Basic Frontend Usage

Sitecore provide a client for the tracker service via the @sitecore-jss/sitecore-jss-tracking npm package. The code below shows a basic example of how it works, for more examples see the Styleguide sample component.

import { trackingApi } from '@sitecore-jss/sitecore-jss-tracking';
import { dataFetcher } from '../../dataFetcher';
import config from '../../temp/config';

const trackingApiOptions = {
  host: config.sitecoreApiHost,
  querystringParams: {
    sc_apikey: config.sitecoreApiKey,
  },
  fetcher: dataFetcher,
};

export const trackEvent = events => {
  trackingApi.trackEvent(events, trackingApiOptions)
    .then(() => console.log('Page event(s) pushed', events))
    .catch(error => console.error('Error pushing page event(s)', error));
};

// usage
trackEvent([{eventId: 'Download'}]);

At this stage, once built/deployed you should be able to see simple events being sent to the server when triggered.

Hint: the tracking API has a built in way to end the current session and flush the data to XConnect. Just go to /sitecore/api/jss/track/flush in your browser.

Keeping the frontend DRY

In the sample JSS React project, everything exists in one package so its straightforward to simply import the necessary config and dataFetcher.

In my latest project, we have a library of shared components and various JSS apps that use them, each with their own config. This can lead to having to pass the config into every component as props, a bit messy – so we've used a different approach: the React Context API.

/* /shared-components/src/tracking/TrackingProvider.jsx */
import React, { createContext, useContext } from 'react';
import PropTypes from 'prop-types';
import { trackingApi } from '@sitecore-jss/sitecore-jss-tracking';

export const TrackingContext = createContext();

export const TrackingProvider = ({ children, config, dataFetcher, debug = false }) => {
  const trackingApiOptions = {
    host: config.sitecoreApiHost,
    querystringParams: {
      sc_apikey: config.sitecoreApiKey,
    },
    fetcher: dataFetcher,
  };

  const trackEvent = events => {
    trackingApi.trackEvent(events, sitecoreApiOptions)
      .then(() => {
        if (debug) {
          console.log('Page event(s) pushed', events)
        }
      })
      .catch(error => console.error('Error pushing page event(s)', error));
  };

  return (
    <TrackingContext.Provider value={{ trackEvent: trackEvent }}>
      {children}
    </TrackingContext.Provider>
  );
};

TrackingProvider.propTypes = {
  children: PropTypes.oneOfType([
    PropTypes.node, 
    PropTypes.arrayOf(PropTypes.node)
  ]).isRequired,
  config: PropTypes.shape({
    sitecoreApiHost: PropTypes.string.isRequired,
    sitecoreApiKey: PropTypes.string.isRequired,
  }),
  dataFetcher: PropTypes.func.isRequired,
  debug: PropTypes.bool,
};

// Hook for use in components...
export const useTracking = () => {
  const { trackEvent = () => {} } = useContext(TrackingContext) || {};
  return { trackEvent };
};

/* /shared-components/src/tracking/index.js */
export * from './TrackingProvider';

We can then add this to our JSS apps in AppRoot and pass each app's individual config in:

/* /jss-app/src/AppRoot.js */

// snip... other imports

// Import our provider, the JSS config and dataFetcher.
import { TrackingProvider } from 'shared-components/core/tracking';
import config from './temp/config';
import { dataFetcher } from './dataFetcher';

class AppRoot extends React.Component {
  // snip... constructor / ssr stuff

  render() {
    // snip... props / route handler

    // Wrap the other providers, router handler, etc with our TrackingProvider.
    return (
      <TrackingProvider config={config} dataFetcher={dataFetcher}>
        {/* snip... ApolloProvider, etc */}
      </TrackingProvider>
    )
  }
}

And in each component we want to trigger events from:

/* /shared-components/src/example/Example.jsx */

import React from 'react';

// import the hook
import { useTracking } from '../tracking';

const Example = props => {
  // call the hook to get the configured tracking function.
  const { trackEvent } = useTracking();

  // call the tracker API.
  trackEvent([{eventId: 'Download'}]);
}

Now we've wrapped up the tracking function and it's config into an easy to access provider and hook.

Passing event data

So far we've triggered simple events but not passed any data along with them. For example, with the built-in the Search event you can pass the terms the user searched for.

This requires a small amount of customisation in the backend. As noted in the documentation, there is a pipeline trackEvent that is responsible for handling incoming tracking requests, and new processors can easily be added here to customise behaviour.

First, we need to define an object to deserialize our event data to:

using Newtonsoft.Json;
using Sitecore.JavaScriptServices.Tracker.Models;

namespace Foundation.Search.Model
{
    public class SearchEventInstance : EventInstance
    {
        [JsonProperty("terms")]
        public string Terms { get; set; }

        [JsonProperty("count")]
        public int Count { get; set; }
    }
}

And, then a pipeline processor to handle that event data:

using System.Linq;
using Foundation.Common.Extensions;
using Foundation.Search.Model;
using Sitecore.Analytics;
using Sitecore.Analytics.Tracking;
using Sitecore.JavaScriptServices.Tracker.Pipelines.TrackEvent;
using Sitecore.Marketing.Definitions.PageEvents;

namespace Foundation.Search.Pipelines.TrackEvent
{
    public class TrackSearchEvent :
        MarketingDefinitionBasedEventProcessor<SearchEventInstance, IPageEventDefinition>
    {
        /// <summary>
        /// Determines whether the incoming event can be handled by this processor.
        /// </summary>
        /// <param name="eventInstance">The event data</param>
        protected override bool IsValidEvent(SearchEventInstance eventInstance)
        {
            if (eventInstance.EventId.IsNullOrWhiteSpace())
                return false;

            return eventInstance.EventId.Is("Search") && 
                !eventInstance.Terms.IsNullOrWhiteSpace();
        }

        /// <summary>
        /// Resolve the right event definition for the event data.
        /// </summary>
        protected override IPageEventDefinition ResolveDefinition(
            SearchEventInstance eventInstance, TrackEventPipelineArgs args) 
        {
            return GetDefinition(Tracker.MarketingDefinitions.PageEvents,
                eventInstance.Count > 0
                    ? AnalyticsIds.SearchEvent.ToString()
                    : AnalyticsIds.NoSearchHitsFound.ToString());
        }

        /// <summary>
        /// Prevent tracking the same event twice.
        /// </summary>
        protected virtual bool ShouldTrack(
            Sitecore.Analytics.Data.PageEventData current,
            Sitecore.Analytics.Model.PageEventData previous)
        {
            if (previous == null)
                return true;

            return current.PageEventDefinitionId != previous.PageEventDefinitionId ||
                !current.Text.Is(previous.Text);
        }

        /// <summary>
        /// Register the event with the tracker.
        /// </summary>
        protected override void DoTrack(
            IPageContext pageContext, 
            IPageEventDefinition eventDefinition,
            SearchEventInstance eventInstance)
        {
            var @event = new Sitecore.Analytics.Data.PageEventData(
                eventDefinition.Alias, eventDefinition.Id) 
            {
                Text = eventInstance.Terms,
                Data = eventInstance.Terms,
                DataKey = eventInstance.Terms
            };

            var previous = pageContext.PageEvents.LastOrDefault();

            if (!ShouldTrack(@event, previous))
                return;

            pageContext.Register(@event);
        }
    }
}

This is patched into the trackEvent pipeline:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <pipelines>
    <group groupName="javaScriptServices">
      <pipelines>
        <trackEvent>
          <processor 
          	type="Foundation.Search.Pipelines.TrackEvent.TrackSearchEvent,Foundation.Search"
            patch:before="*[1]" />
        </trackEvent>
      </pipelines>
    </group>
  </pipelines>
</configuration>

And we update our frontend code to send the necessary details:

/* /shared-components/src/example/Example.jsx */

import React from 'react';

// import the hook
import { useTracking } from '../tracking';

const Example = props => {
  // call the hook to get the configured tracking function.
  const { trackEvent } = useTracking();

  // call the tracker API.
  trackEvent([{eventId: 'Search', terms: 'user terms here', count: 1}]);
}

These events and their data will now be reflected in Experience Analytics / Experience Profile screens.

Happy Tracking!