npm package: @lonelyplanet/lp-analytics
Description: A JS library providing a standard interface for event tracking for analytics services across LP's frontend codebases.
Latest version: 2.2.6

# lp-analytics

👉 A JS library providing _a standard interface for event tracking_ for analytics services across our frontend codebases, and _a set of tools for implementing said interface_ across a variety of JS environments.

## FAQ

### Where is the implementation spec?

[Here](https://docs.google.com/presentation/d/113Tnh3irk3GGUHiq98b3tpaxuiCBsV1v8IjUSIuPqRE/edit#slide=id.g47033556c8_5_35)

### How do I use it?

Install from npm:

```bash
npm install --save @lonelyplanet/lp-analytics
```

...aaand from that point it depends on what sort of JS environment you're working in.

See the Very Broad Overview for details, under subheading "that's still pretty abstract", where three representative setups are examined.

### How do I run tests?

> `npm run test`

### How do I publish to NPM?

Make a PR into develop without worrying about versioning/etc. Get it reviewed & merge it.

Then use `standard-version` to update the version/changelog & push to develop.

(per https://www.npmjs.com/package/standard-version, with some tweaks):

1. when you land commits on your master branch, select the Squash and Merge option.
> - _JQ note: it's optional to merge develop into master prior to publishing._
2. add a title and body that follows the [Conventional Commits Specification](https://www.conventionalcommits.org/en/v1.0.0-beta.2/#specification).
3. when you're ready to release to npm:
4. git checkout `develop`; git pull origin `develop`
5. run `npm run preversion` (this tests, then builds), then `npm run version:patch`, `npm run version:minor`, or `npm run version:major`.
> - 👉 [semvar cheatsheet](https://devhints.io/semver)
6. ensure that the version has been changed as desired in `package.json` and `package-lock.json`, and the changelog is as you expect.
7. run `npm run push`. Now the NPM package should be updated.
8. PR the changes generated by the release from `develop` to `master`

```
standard-version does the following:

1. bumps the version in package.json/bower.json (based on your commit history)
2. uses conventional-changelog to update CHANGELOG.md
3. commits package.json (et al.) and CHANGELOG.md
4. tags a new release
```

### How do I update the static scripts?

`npm run update_static`

### For that matter, what _are_ the static scripts?

You'll notice the presence of two scripts in `/src/scripts`: `initializeDataLayer/index.ts` and `initializeTrackingContainers/index.ts`.

These handle setting up the tracking framework and including `gtm.js` on the page; really they aren't doing _too_ much, just making sure that `window.lp.analytics.dataLayer` and `window.lp.analytics.track` are where we expect them to be, running GTM's snippet, etc.

If you use any of React/ES6 tooling, you won't have to worry about including these since
you'll be making use of the same methods it invokes _directly_, which will handle all of the same setup.

But if you're in a legacy codebase and need to include your tracking code as script tags directly in a html/haml/etc. file, you won't be able to import those methods and thus you'll need to include these already-webpacked-babelified-etc. script tags as static assets.

The actual links you'll use are:
- https://assets.staticlp.com/lp-analytics/initialize-data-layer.js
- https://assets.staticlp.com/lp-analytics/initialize-tracking-containers.js

(See the Very Broad Overview's examination of `destinations-next` for details)

When updates are made that would affect either of these scripts, you'll want to update the static assets as well.

Do so via npm script:

`npm run update_static`

This will build `/dist` and then upload the scripts, so it's all you'll need to do.

📝 Note 📝 that if multi-factor authentication is enabled for your AWS account (it probably is), you'll need to authenticate from the CLI for the script to work.

One avenue for doing so is to install `aws-mfa` as detailed here: `https://github.com/broamski/aws-mfa`.

## A Very Broad Overview Of What (And Why) This Is

### data layer?  🧐

We need to be able to draw inferences about how users interact with our sites in order to guide our digital strategy. Why devote time & resources to a page noone visits? etc.

We could keep track of all that data ourselves, but there are many third-party analytics services that can do a lot of the heavy lifting for us.

A standard frontend interface for integrating with these services is the `dataLayer`, a JavaScript array that exists somewhere within the user's web browser's JS environment.

When things happen on the page that we're interested in tracking (a page loaded, a button clicked, etc.), we push a JavaScript object into the `dataLayer` array. That object is a data payload. Who clicked what? etc.

And the third party services take it from there. Not that our work necessarily _ends_ there, but anything else would be done via the third-party service, not our JS.

### that sounds simple why did you make this library 🧐

Well, we have a lot of discrete codebases that have evolved independently over a long time. Our codebases

1. Use a variety of JS setups
2. Implement(-ed) a variety of third-party analytics services
3. Do not necessarily use consistent payload shapes for the same event occurring in different codebases
4. Do not necessarily use consistent keys for the key/value pairs that those payloads contain

At some point shortly before your author's hiring at LP, our organization decided to devote resources to improving the integrity of our analytics data. Evidently our decision-makers have not been comfortable making decisions based on our existing analytics implementations.

This led to a partnership with [Analytics Pros](https://www.analyticspros.com/).

The first collaboration between AP & LP yielded an analytics implementation for landing & marketing page events. More on that in a sec**

The second collaboration called for a complete overhaul of our existing analytics implementations, everywhere. Your author has been working on this.

AP favors a setup that pushes data to Google Tag Manager (GTM), and then uses GTM to push events to Google Analytics (GA) based on that data.

** It called for event payloads that were unknown to the author during the majority of the work that went on during the _second_ collaboration. Late into the process, the specifications for the _first_ collaboration were added into the spec for the _second_ (labeled as "moved from old spec"), which is why their implementations may only be present in select codebases. They may also not have their interfaces/types/enums/etc. present in `lp-analytics` at all yet-- please add them as needed (you might be asked to track an event defined in that first collaboration in another codebase, for example).

### in conclusion 🤓

- `lp-analytics` (this library) sets up _a standard interface for event tracking_ (to address points 3 & 4 above), and provides _a set of tools for implementing said interface across a variety of JS environments_ (to address point 1).

- The specific impetus for its creation has been to implement the GTM/GA-based tracking setup that AP has prescribed for us (as the second LP/AP collaboration).

- As such, it currently only provides for that implementation, but is extensible should the need arise to add other tracking providers.

## Further Into The Weeds 🌱

Broadly speaking, here's how the setup works:

- We have a `dataLayer` array
- Analytics event payloads get pushed there
- GTM receives that data
- GTM pushes events to GA (per its configuration, which we need to set up; that setup is not currently explored here)
- GA makes reports from data from the events
- LP makes informed decisions

Let's get a bit more concrete. _How_ do we implement all that?

- First, note that where `dataLayer` _is_ relative to the `window` object is _mostly_ straightforward, but please don't interact with/push to it directly, lest you run afoul of a workaround that was needed for codebases that implement `rizzo`'s head JS. Long story short, there might not _be_ a `.push` method where you expect one to be.

- Instead, we'll 🚨 (**STEP 1**) run some setup that'll make for a standardized, abstracted interface.

- Next, take a look at [AP's implementation spec](https://docs.google.com/presentation/d/113Tnh3irk3GGUHiq98b3tpaxuiCBsV1v8IjUSIuPqRE/edit#slide=id.g47033556c8_5_35), page 10 ("Core Data Layer").

- The spec describes a payload (henceforth called `dataLayer-initialized`) that we'll want to have placed in the data layer on _each page_ as the page loads.

- So 🚨 (**STEP 2**) _track_ (ie, append to `dataLayer`) the `dataLayer-initialized` payload next.

- Finally, 🚨 (**STEP 3**), we need to _initialize GTM_. This just involves running their snippet-- that snippet will append a script tag for `gtm.js` to the page, which does a bunch of stuff that we won't worry about here. Just know that it has to be done in the `<head>` tag, and after the `dataLayer-initialized` payload has been appended to the `dataLayer`.

- That's the end of the complicated stuff.

- Step 4 is to set up tracking in reaction to client-side user activity (button clicks, etc.). We'll go over this separately (_todo_).

##### that's still pretty abstract 🤓

You're right, it's hard to get too specific without looking at a particular implementation since it really depends on the JS setup of the codebase we're looking at.

So let's do that.

🌎 `destinations-next` 🌎

`application_detail.html.hbs` (annotated)

```hbs
{{!-- From Destinations/app/templates/layouts/partials/doctype --}}
{{> "layouts/partials/doctype" }}
<head>
  <script src="https://assets.staticlp.com/lp-analytics/initialize-data-layer.js"></script> 🚨 (**STEP 1**)
  {{> "layouts/partials/data_layer_detail"}}
  {{> "layouts/partials/data_layer_init_track_detail" }} 🚨 (**STEP 2**)
  {{> "layouts/partials/lp_js_details" }}
  <script src="https://assets.staticlp.com/lp-analytics/initialize-tracking-containers.js"></script> 🚨 (**STEP 3**)
  {{> "layouts/partials/meta_detail" }}
<!-- ...etc -->
```

__Step 1__: There is some setup required to get our `dataLayer` situation situated. _If you're in a React or ES6 JS environment_, you won't actually need to do this as a discrete step, because the methods you import will handle it all for you upon being invoked.

We don't use ES modules here in `destinations-next`, though, so we'll instead run a script that'll do what we need and has been webpacked and minified for us ahead of time.

See the `/src/scripts` directory? Everything in there winds up being processed & made available in this manner. Should the need arise, the scripts can be updated to reflect code changes with the command `npm run update_static`.

Anyway, end result of `initialize-data-layer.js` is that we're ready to push events to the dataLayer by means of the `window.lp.analytics.track` method (note that this does not directly interface with the `dataLayer` or use a `.push` method).

__Step 2__: Let's look at the handlebars partial that is being included by this line:

🌎 `destinations-next` 🌎

`data_layer_init_track_detail.hbs`

```hbs
<script>
  (function() {
    window.lp.analytics.track({
      adblock: undefined,
      applicationName: "{{application_name}}",
      articleName: "{{article_name}}",
      articleType: "{{article_type}}",
      atlasId: "{{atlas_id}}",
      campaignPageName: undefined,
      contentCountry: "{{content_country}}" || undefined,
      contentContinent: "{{content_continent}}" || undefined,
      contentCity: "{{content_city}}" || undefined,
      contentNeighborhood: undefined,
      contentRegion: "{{content_region}}" || undefined,
      contentType: undefined,
      destinationDirectory: undefined,
      destinationSubNav: undefined,
      dispatchVariant: undefined,
      event: "{{event_name}}",
      forumCategory: undefined,
      forumContinent: undefined,
      forumCountry: undefined,
      forumPostTitle: undefined,
      hasPhoto: {{has_photo}},
      hasVideo: {{has_video}},
      loggedIn: undefined,
      poiAttributes: undefined,
      poiName: undefined,
      poiType: undefined,
      poiVenueType: undefined,
      siteSection: "{{site_section}}",
      userId: undefined,
    });
  })();
</script>
```

Note that all possible key/value pairs for `dataLayer-initialized` are present-- If anything is unknown or irrelevant to the page being tracked, `undefined` is used to fill in the value. This is by design; it's part of AP's spec.

🎵 _Note_ 🎵 : leave `userId`, `dispatchVariant` and `loggedIn` undefined. We have some machinations in place that'll handle those for us.

If you define any variables prior to calling `window.lp.analytics.track`, be sure to wrap everything up in an IIFE so as to avoid polluting global scope (this code _doesn't_ define any, but probably used to, hence the IIFE).

By the way, you probably noticed `{{> "layouts/partials/data_layer_detail"}}` right above `{{> "layouts/partials/data_layer_init_track_detail" }}` in `application_detail.html.hbs`. We haven't addressed that yet, but it sure smells like part of our analytics stew, right?

Yep, that's the legacy pageload payload. I've left that code alone for the time being, since

#### 🗣 removing the legacy pageload payload (if present), _or changing its index within the `dataLayer`_ may break existing gtm-based ad code.

That ad code may be hardcoded to look for specific (legacy) key/value pairs _on the object at index zero of the dataLayer_, which it expects to be the legacy payload.

This is why the new `dataLayer-initialized` payloads are being tracked _after_ any existing pageload payloads, if present.

We'll need to continue efforts to audit & update ad code prior to cleaning the old payloads out.

__Step 3__: This, like step 2, loads a webpacked & minified script for us since we don't have access to ES modules.

`initialize-tracking-containers.js` does three things:

- Make a remote call to `dotcom-connect` and await the user's auth details (if logged in). Upon hearing back, it will

- Add data we got from `dotcom-connect` to the `dataLayer-initialized` payload that we tracked in (**step 2**). Don't worry about what _index_ it occupies within the dataLayer-- our machinery here will find the appropriate payload.

- Run GTM's snippet code that kicks everything into motion.

That wraps it up.

Obviously this might _look_ pretty different depending on your codebase, but for a non-React/ES6 setup, these are the notes you'll want to hit.

##### Philosophical Note 🗿

I shied away from adding any of the steps above to `rizzo`, `rizzo-next`, etc.

The data being tracked by `dataLayer-initialized` comes from the codebase itself, of course, and its timing relative to the other steps described above is pretty rigidly prescribed.

As such, I figured it made sense to keep it all together in the codebase where the data lives.

And given how many codebases require this setup (a lot), I figure that verbosity that is predictable and easy to find is better than brevity that may not be.

Anyway, let's move on to a React/ES6 setup.

🎯 `dotcom-pois` 🎯

`list.jsx` (annotated)

```jsx
// ...etc
import {
  analytics,
  ApplicationNames,
  createDataLayerScript,
  DataLayerInitializer,
  DestinationDirectories,
  DestinationSubNavs,
  SiteSections,
} from "@lonelyplanet/lp-analytics";
// ...etc

export default class ListComponent extends React.Component {
  // ...etc
  render() {
    // ...etc
    return (
      <div className="PageContainer">
        <DataLayerInitializer
          script={createDataLayerScript({
            [analytics.applicationName]: ApplicationNames.dotcomPois,
            // ...etc
            [analytics.destinationDirectory]: DestinationDirectories.poiList,
            // ...etc
            [analytics.siteSection]: SiteSections.destinations,
          }, 1, asyncUserStatusWrapper)}
          helmet
        /> 🚨 (**STEPS 1, 2, 3**)
        // ...etc
    );
  }
}
```
__Step 1__: As noted earlier, when using ES6 modules, (**step 2**) doesn't require any action on your part-- you're going to import and directly use the methods from which `initialize-data-layer.js` was generated in the first place.

__Step 2__: Pretty good amount to unpack here.

`<DataLayerInitializer>` is essentially just a wrapper around `<script dangerouslySetInnerHTML={{ __html: ...[script content] }} />`, except that it also allows you to pass in the boolean prop `helmet` (as is present here).

`helmet` does what you'd think-- `react-helmet` will move the resultant script up into the `<head>` tag of the document if present. The script will stay wherever it is if not. `react-helmet` is not a peer dependency; `lp-analytics` has its own version.

We generate our argument to the `script` prop via `createDataLayerScript` (imported from `lp-analytics`).

The first argument it accepts is the payload to be tracked.

Note that `createDataLayerScript` will accept whatever you have and add in every other key with `undefined` for its value. You don't need to manually include every single key/value pair as you would for the non-ES6 setup. You'll still wind up with a complete `dataLayer-initialized` payload.

📓 _Note_ 📓: leave `userId`, `dispatchVariant` and `loggedIn` out. Just don't worry about them at all. We have some machinations in place that'll handle those for us.

The second argument to `createDataLayerScript` is the index at which the payload should be inserted.

You may recall from above that we have some GTM-based ad code that could break (depends on what ad code is on the page in question) if we remove or change the index of the legacy pageload payload. This is how we're getting around the problem in a React/ES6-based setup: tell the script we're generating to put the _new_ payload at a specific index.

The argument defaults to 0 if not included.

_Finally_, note the variables `analytics`, `ApplicationNames`, `DestinationDirectories` and `SiteSections` here.

`analytics` is an object full of constants for all the key names that'll be present on payloads we track via `lp-analytics`.

`ApplicationNames`, `DestinationDirectories` and `SiteSections` are TS `enums`. In practice, they work like `analytics` above (as a collection of constants), but have a more specific use within TypeScript in that they define acceptable values that may be provided for a certain key. i.e., every possible value that could be used for the `analytics.applicationName` key should be contained within `ApplicationNames`. Naturally, you'll only use these for value sets that are finite and countable (e.g. user registrations sources-- facebook, twitter, etc.), not infinite (e.g. user IDs).

This may seem like a chore-- you'll probably need to add to these enums as you develop. But I think it's worth doing in that it promotes consistency across codebases and also allows `lp-analytics` to serve as a set of living documentation for what values, payload shapes, etc. we're actually using in practice.

Even if you aren't coding in an ES6-based JS environment and can't _use_ the enums, for example, you can peruse `lp-analytics`'s `enums.ts` file and see what values are being used for a particular key, or by checking `interfaces.ts` you can glean what key/value pairs are present for a certain payload type.

__Step 3__: The _third_ argument to `createDataLayerScript` allows you to specify a wrapper into which the produced script will be placed. You will, in all cases, want to import & pass in `asyncUserStatusWrapper`**. It wasn't originally meant to be used in all cases like this, but, well, that changed a day or two ago, and now it is.

** _Update_: `makeAsyncUserStatusWrapper` will be the preferred means of implementing this functionality going forward, instead of using `asyncUserStatusWrapper` directly. It allows you to pass in an argument for the url you want it to use for `dotcom-connect`; this will enable use of QA/staging/production urls as the case may be. Leaving the argument blank (or using `asyncUserStatusWrapper` directly) will result in the default value, which is production. It returns a function that can be used in place of `asyncUserStatusWrapper`, e.g.

```javascript
createDataLayerScript({ ...payload }, 1, makeAsyncUserStatusWrapper("http://connect.qa.lonelyplanet.com"));
```

Anywhoo, it will implement the same functionality vis-a-vis `dotcom-connect` as is built into `initialize-tracking-containers.js`-- you'll get `userId`, `loggedIn` and `dispatchVariant`, these will be added to your `dataLayer-initialized` payload for you, and then the tracking container(s) will be initialized (i.e., the GTM snippet will be executed).

Now I must confess that `dotcom-pois` is one of the simpler React setups you'll see. More commonly, you'll find `<DataLayerInitializationData>` components littered throughout a React codebase. For example,

🎬 `dotcom-video` 🎬

`app/assets/app.jsx`

```jsx
// ...etc
<DataLayerInitializationData
  data={{ [analytics.applicationName]: ApplicationNames.dotcomVideo }}
  indexForEntirePayload={1}
  scriptWrapperForEntirePayload={asyncUserStatusWrapper}
/>
// ...etc
```

That's one of several. The idea behind `DataLayerInitializationData` is that you add an instance wherever you happen to have some data that should be added to the `dataLayer-initialized` payload.

Then, by means of `react-side-effect`, they are composed into a single payload that gets fed to `createDataLayerScript`, just like _we_ did in the previous example, when we provided a single payload to `createDataLayerScript` from the outset.

You then feed that script to `DataLayerInitializer` (if still in React-land), or otherwise toss it into a `<script>` tag through whatever means are at your disposal (the example here uses handlebars):

🎬 `dotcom-video` 🎬

`pov_controller.js`

```js
// ...etc
locals.dataLayer = DataLayerInitializationData.rewind();
// ...etc
```

🎬 `dotcom-video` 🎬

`meta.hbs`

```hbs
<!-- ...etc -->
<script>{{{dataLayer}}}</script>
<!-- ...etc -->
```

This way you don't have to know _everything_ about the payload in just one spot in the code.

Note that since there is no single place to specify the index or script wrapper, this setup allows you to set the index or script wrapper for the entire payload on any single instance. I've been adding those props to every instance so as to be totally unambiguous about how the payload will wind up upon being composed.

My thoughts: this setup has merits but is quite finicky. Some instances don't get picked up by `react-side-effect` and it can be time-consuming to figure out where/why, and you've got to be very diligent about calling `rewind` after each `renderToString`, or else incorrect data could be appended to your page (`copilot` used to have a problem with this when publishing all pages at once).

My recommendation is to favor the simpler setup found in `dotcom-pois` where possible.