Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JSUI-2467 Populate GTM datalayer with Coveo UA event data #1072

Merged
Merged
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
92435c8
Add coveoUAEventReady event
fbeaudoincoveo Apr 19, 2019
a360eca
Trigger coveoUAEventReady on custom and click events
fbeaudoincoveo Apr 19, 2019
50fc4b2
Trigger coveoUAEventReady on each search event
fbeaudoincoveo Apr 19, 2019
70eb85a
Add options for naming and populating GTM dataLayer
fbeaudoincoveo Apr 19, 2019
5ac65bd
- Refactor event / args names
fbeaudoincoveo Apr 23, 2019
a643ea7
Refactor event / args names
fbeaudoincoveo Apr 23, 2019
87f3970
- Refactor event / args names
fbeaudoincoveo Apr 23, 2019
145c635
- Rename event / args
fbeaudoincoveo Apr 23, 2019
3965f6f
- Make isGtmScriptPresent less restrictive
fbeaudoincoveo Apr 23, 2019
9ba847b
Document new options
fbeaudoincoveo Apr 23, 2019
1fcf4cd
Add basic documentation for new analytics events
fbeaudoincoveo Apr 23, 2019
ca7fe1b
Small documentation fix
fbeaudoincoveo Apr 23, 2019
bd19262
Add unit tests for analytics ready event
fbeaudoincoveo Apr 24, 2019
923c123
- Improve automatic GTM container snippet detection behavior
fbeaudoincoveo Apr 25, 2019
32c2afa
Remove unwanted s;
fbeaudoincoveo Apr 25, 2019
1208722
Improve regexes
fbeaudoincoveo Apr 25, 2019
e7cd32e
Fix match.length check
fbeaudoincoveo Apr 25, 2019
a210439
Fix logging error
fbeaudoincoveo Apr 25, 2019
874d175
Further fix match test
fbeaudoincoveo Apr 25, 2019
2c2bdfa
Further fix logging
fbeaudoincoveo Apr 25, 2019
648d9c3
- Revert back to a simpler GTM data layer push implementation (no aut…
fbeaudoincoveo Apr 26, 2019
98853e1
Write UTs for GTM data layer push feature
fbeaudoincoveo Apr 26, 2019
a640592
Test logger being called rather than error being thrown
fbeaudoincoveo Apr 30, 2019
730d5aa
- Rename `pushToGtmDataLayer` option
fbeaudoincoveo Apr 30, 2019
26dbeb1
- Test public method instead of triggering event
fbeaudoincoveo Apr 30, 2019
9fa0f49
Merge branch 'master' into JSUI-2467-populate-gtm-datalayer-with-cove…
fbeaudoincoveo May 8, 2019
fdfbd86
Merge branch 'master' into JSUI-2467-populate-gtm-datalayer-with-cove…
fbeaudoincoveo May 10, 2019
File filter...
Filter file types
Jump to…
Jump to file or symbol
Failed to load files and symbols.

Always

Just for now

@@ -21,6 +21,14 @@ export interface IAnalyticsCustomEventArgs {
customEvent: IAPICustomEvent;
}

/**
* The object that all [`analyticsEventReady`]{@link AnalyticsEvents.analyticsEventReady} handlers receive as an argument.
*/
export interface IAnalyticsEventArgs {
event: 'CoveoCustomEvent' | 'CoveoClickEvent' | 'CoveoSearchEvent';
coveoAnalyticsEventData: IAPISearchEvent | IAPIDocumentViewEvent | IAPICustomEvent;
}

/**
* The `IChangeAnalyticsCustomDataEventArgs` interface describes the object that all
* [`changeAnalyticsCustomData`]{@link AnalyticsEvents.changeAnalyticsCustomData} event handlers receive as an argument.
@@ -168,6 +176,13 @@ export class AnalyticsEvents {
public static documentViewEvent = 'analyticsDocumentViewEvent';
public static customEvent = 'analyticsCustomEvent';

/**
* Triggered when any event (i.e., `search`, `click`, or `custom`) is about to be logged.
*
* All `analyticsEventReady` event handlers receive an [`AnalyticsEventArgs`]{@link IAnalyticsEventArgs} object as an argument.
*/
public static analyticsEventReady = 'analyticsEventReady';

/**
* Triggered whenever an analytics event is about to be logged.
*
@@ -22,6 +22,7 @@ import { exportGlobally } from '../../GlobalExports';
import { PendingSearchEvent } from './PendingSearchEvent';
import { PendingSearchAsYouTypeSearchEvent } from './PendingSearchAsYouTypeSearchEvent';
import { AccessToken } from '../../rest/AccessToken';
import { AnalyticsEvents, IAnalyticsEventArgs } from '../../events/AnalyticsEvents';

export interface IAnalyticsOptions {
user?: string;
@@ -34,6 +35,8 @@ export interface IAnalyticsOptions {
splitTestRunVersion?: string;
sendToCloud?: boolean;
organization?: string;
autoPushToGtmDataLayer?: boolean;
gtmDataLayerName?: string;
renewAccessToken?: () => Promise<string>;
}

@@ -158,7 +161,28 @@ export class Analytics extends Component {
* Default value is `undefined`, and the value of this parameter will fallback to the organization used for the
* search endpoint.
*/
organization: ComponentOptions.buildStringOption()
organization: ComponentOptions.buildStringOption(),

/**
* Whether to automatically attempt to push Coveo usage analytics events to the Google Tag Manager [data layer](https://developers.google.com/tag-manager/devguide#datalayer).
*
* See also [`gtmDataLayerName`]{@link Analytics.options.gtmDataLayerName}.
*
* **Default:** `false`
*/
autoPushToGtmDataLayer: ComponentOptions.buildBooleanOption({ defaultValue: false }),

/**
* The name of the Google Tag Manager data layer initialized in the page.
*
* See also [`autoPushToGtmDataLayer`]{@link Analytics.options.autoPushToGtmDataLayer}.
*
* **Note:**
* Setting this option is only useful if the [GTM data layer was renamed](https://developers.google.com/tag-manager/devguide#renaming) in the page.
*
* **Default:** `dataLayer`
*/
gtmDataLayerName: ComponentOptions.buildStringOption({ defaultValue: 'dataLayer' })
};

/**
@@ -200,6 +224,10 @@ export class Analytics extends Component {
this.bind.onRootElement(QueryEvents.buildingQuery, (data: IBuildingQueryEventArgs) => this.handleBuildingQuery(data));
this.bind.onRootElement(QueryEvents.queryError, (data: IQueryErrorEventArgs) => this.handleQueryError(data));

if (this.options.autoPushToGtmDataLayer && this.isGtmDataLayerInitialized) {
this.bind.onRootElement(AnalyticsEvents.analyticsEventReady, (data: IAnalyticsEventArgs) => this.pushToGtmDataLayer(data));
}

// Analytics component is a bit special: It can be higher in the dom tree than the search interface
// Need to resolve down to find the componentOptionsModel if we need to.
if (!this.componentOptionsModel) {
@@ -343,6 +371,25 @@ export class Analytics extends Component {
this.client.setOriginContext(originContext);
}

/**
* Attempts to push data representing a single Coveo usage analytics event to the Google Tag Manager data layer.
*
* **Note:**
* If the [`autoPushToGtmDataLayer`]{@link Analytics.options.autoPushToGtmDataLayer} option is set to `true` and the GTM data layer has been properly initialized in the page, this method is called automatically whenever an event is about to be logged to the Coveo Cloud usage analytics service.
*
* See also the [`gtmDataLayerName`]{@link Analytics.options.gtmDataLayerName} option.
*
* @param data The data to push.
*/
public pushToGtmDataLayer(data: IAnalyticsEventArgs) {
const dataLayerName = this.options.gtmDataLayerName;
try {
(<any>window)[dataLayerName].push(data);
} catch (error) {
this.logger.error(`Unexpected error when pushing to Google Tag Manager data layer '${dataLayerName}': '${error}'.`);
}
}

protected initializeAnalyticsEndpoint(): AnalyticsEndpoint {
return new AnalyticsEndpoint({
accessToken: this.accessToken,
@@ -463,6 +510,18 @@ export class Analytics extends Component {
);
}

private get isGtmDataLayerInitialized(): boolean {
const dataLayerName = this.options.gtmDataLayerName;
if (!dataLayerName) {
return false;
}
if (!(<any>window)[dataLayerName]) {
This conversation was marked as resolved by fbeaudoincoveo

This comment has been minimized.

Copy link
@olamothe

olamothe Apr 24, 2019

Member

You could declare

const win = window as any

[... rest of the function ...]

to prevent the repetion

this.logger.warn(`Cannot automatically push to Google Tag Manager data layer: '${dataLayerName}' is undefined.`);
return false;
}
return true;
}

public static create(element: HTMLElement, options: IAnalyticsOptions, bindings: IComponentBindings): IAnalyticsClient {
let selector = Component.computeSelectorForType(Analytics.ID);
let found: HTMLElement[] = [];
@@ -11,7 +11,8 @@ import { ITopQueries } from '../../rest/TopQueries';
import {
IChangeableAnalyticsMetaObject,
IChangeableAnalyticsDataObject,
IChangeAnalyticsCustomDataEventArgs
IChangeAnalyticsCustomDataEventArgs,
IAnalyticsEventArgs
} from '../../events/AnalyticsEvents';
import { Defer } from '../../misc/Defer';
import { $$ } from '../../utils/Dom';
@@ -171,11 +172,16 @@ export class LiveAnalyticsClient implements IAnalyticsClient {
metaObject: IChangeableAnalyticsMetaObject,
element?: HTMLElement
): Promise<IAPIAnalyticsEventResponse> {
var customEvent = this.buildCustomEvent(actionCause, metaObject, element);
const customEvent = this.buildCustomEvent(actionCause, metaObject, element);
this.triggerChangeAnalyticsCustomData('CustomEvent', metaObject, customEvent);
this.checkToSendAnyPendingSearchAsYouType(actionCause);
const convertedCustomEvent = APIAnalyticsBuilder.convertCustomEventToAPI(customEvent);
$$(this.rootElement).trigger(AnalyticsEvents.customEvent, <IAnalyticsCustomEventArgs>{
customEvent: APIAnalyticsBuilder.convertCustomEventToAPI(customEvent)
customEvent: convertedCustomEvent
});
$$(this.rootElement).trigger(AnalyticsEvents.analyticsEventReady, <IAnalyticsEventArgs>{
event: 'CoveoCustomEvent',
coveoAnalyticsEventData: convertedCustomEvent
});
return this.sendToCloud ? this.endpoint.sendCustomEvent(customEvent) : Promise.resolve(null);
}
@@ -242,8 +248,13 @@ export class LiveAnalyticsClient implements IAnalyticsClient {
Assert.isNonEmptyString(event.sourceName);
Assert.isNumber(event.documentPosition);

const convertedDocumentViewEvent = APIAnalyticsBuilder.convertDocumentViewToAPI(event);
$$(this.rootElement).trigger(AnalyticsEvents.documentViewEvent, {
documentViewEvent: APIAnalyticsBuilder.convertDocumentViewToAPI(event)
documentViewEvent: convertedDocumentViewEvent
});
$$(this.rootElement).trigger(AnalyticsEvents.analyticsEventReady, <IAnalyticsEventArgs>{
event: 'CoveoClickEvent',
coveoAnalyticsEventData: convertedDocumentViewEvent
});
return this.sendToCloud ? this.endpoint.sendDocumentViewEvent(event) : Promise.resolve(null);
}
@@ -10,7 +10,7 @@ import { Component } from '../Base/Component';
import { QueryController } from '../../controllers/QueryController';
import { Defer } from '../../misc/Defer';
import { APIAnalyticsBuilder } from '../../rest/APIAnalyticsBuilder';
import { IAnalyticsSearchEventsArgs, AnalyticsEvents } from '../../events/AnalyticsEvents';
import { IAnalyticsSearchEventsArgs, IAnalyticsEventArgs, AnalyticsEvents } from '../../events/AnalyticsEvents';
import { analyticsActionCauseList } from '../Analytics/AnalyticsActionListMeta';
import { QueryStateModel } from '../../models/QueryStateModel';
import * as _ from 'underscore';
@@ -122,6 +122,14 @@ export class PendingSearchEvent {
$$(this.root).trigger(AnalyticsEvents.searchEvent, <IAnalyticsSearchEventsArgs>{
searchEvents: apiSearchEvents
});
if (apiSearchEvents.length) {
apiSearchEvents.forEach(searchEvent => {
$$(this.root).trigger(AnalyticsEvents.analyticsEventReady, <IAnalyticsEventArgs>{
event: 'CoveoSearchEvent',
coveoAnalyticsEventData: searchEvent
});
});
}
});
}
}
@@ -7,6 +7,7 @@ import { analyticsActionCauseList } from '../../src/ui/Analytics/AnalyticsAction
import { NoopAnalyticsClient } from '../../src/ui/Analytics/NoopAnalyticsClient';
import { LiveAnalyticsClient } from '../../src/ui/Analytics/LiveAnalyticsClient';
import { MultiAnalyticsClient } from '../../src/ui/Analytics/MultiAnalyticsClient';
import { AnalyticsEvents, $$ } from '../../src/Core';

export function AnalyticsTest() {
describe('Analytics', () => {
@@ -159,6 +160,108 @@ export function AnalyticsTest() {
});
});

describe('with data layer in page', () => {
let test: Mock.IBasicComponentSetup<Analytics>;
let defaultDataLayerName = 'dataLayer';
let customDataLayerName = 'myDataLayer';
let data = {
event: 'CoveoCustomEvent',
coveoAnalyticsEventData: {
language: 'en',
device: 'test device',
searchInterface: 'test interface',
searchHub: 'test searchHub',
responseTime: 100,
actionType: 'testActionType',
actionCause: 'testActionCause',
customMetadatas: { testKey: 'testValue' }
}
};
beforeEach(() => {
(<any>window)[defaultDataLayerName] = [];
(<any>window)[customDataLayerName] = [];
});

afterEach(() => {
delete (<any>window)[customDataLayerName];
delete (<any>window)[defaultDataLayerName];
});

it('should not automatically attempt to push to data layer if autoPushToGtmDataLayer is false', () => {
test = Mock.basicComponentSetup<Analytics>(Analytics);
spyOn(test.cmp, 'pushToGtmDataLayer');
$$(test.env.root).trigger(AnalyticsEvents.analyticsEventReady, data);
expect(test.cmp.pushToGtmDataLayer).not.toHaveBeenCalled();
});

it('should not automatically attempt to push to data layer if autoPushToGtmDataLayer is true and gtmDataLayerName is the empty string', () => {
test = Mock.optionsComponentSetup<Analytics, IAnalyticsOptions>(Analytics, {
autoPushToGtmDataLayer: true,
gtmDataLayerName: ''
});
spyOn(test.cmp, 'pushToGtmDataLayer');
$$(test.env.root).trigger(AnalyticsEvents.analyticsEventReady, data);
expect(test.cmp.pushToGtmDataLayer).not.toHaveBeenCalled();
});

it('should not automatically attempt to push to data layer if autoPushToGtmDataLayer is true and data layer is undefined', () => {
test = Mock.optionsComponentSetup<Analytics, IAnalyticsOptions>(Analytics, {
autoPushToGtmDataLayer: true,
gtmDataLayerName: 'myImaginaryDataLayer'
});
spyOn(test.cmp, 'pushToGtmDataLayer');
$$(test.env.root).trigger(AnalyticsEvents.analyticsEventReady, data);
expect(test.cmp.pushToGtmDataLayer).not.toHaveBeenCalled();
});

it('should automatically attempt to push to data layer if autoPushToGtmDataLayer is true and gtmDataLayerName is unspecified', () => {
test = Mock.optionsComponentSetup<Analytics, IAnalyticsOptions>(Analytics, {
autoPushToGtmDataLayer: true
});
spyOn(test.cmp, 'pushToGtmDataLayer');
$$(test.env.root).trigger(AnalyticsEvents.analyticsEventReady, data);
expect(test.cmp.pushToGtmDataLayer).toHaveBeenCalledWith(data);
});

it('should automatically attempt to push to data layer if autoPushToGtmDataLayer is true and gtmDataLayerName is specified', () => {
test = Mock.optionsComponentSetup<Analytics, IAnalyticsOptions>(Analytics, {
autoPushToGtmDataLayer: true,
gtmDataLayerName: customDataLayerName
});
spyOn(test.cmp, 'pushToGtmDataLayer');
$$(test.env.root).trigger(AnalyticsEvents.analyticsEventReady, data);
expect(test.cmp.pushToGtmDataLayer).toHaveBeenCalledWith(data);
});

it('can push to valid default gtmDataLayerName, even if autoPushToGtmDataLayer is false', () => {
test = Mock.optionsComponentSetup<Analytics, IAnalyticsOptions>(Analytics, {
autoPushToGtmDataLayer: false
});
test.cmp.pushToGtmDataLayer.call(test.cmp, data);
expect((<any>window)[defaultDataLayerName][0]).toBe(data);
});

it('can push to valid specified gtmDataLayerName, even if autoPushToGtmDataLayer is false', () => {
test = Mock.optionsComponentSetup<Analytics, IAnalyticsOptions>(Analytics, {
autoPushToGtmDataLayer: false,
gtmDataLayerName: customDataLayerName
});
test.cmp.pushToGtmDataLayer.call(test.cmp, data);
expect((<any>window)[customDataLayerName][0]).toBe(data);
});

it('should catch error when pushing to invalid data layer', () => {
test = Mock.optionsComponentSetup<Analytics, IAnalyticsOptions>(Analytics, {
autoPushToGtmDataLayer: false,
gtmDataLayerName: 'myImaginaryDataLayer'
});
test.cmp.pushToGtmDataLayer.call(test.cmp, data);
expect(() => {
test.cmp.pushToGtmDataLayer.call(test.cmp, data);
}).not.toThrow();
});
});

describe('exposes options', () => {
let test: Mock.IBasicComponentSetup<Analytics>;

@@ -384,6 +384,19 @@ export function LiveAnalyticsClientTest() {
expect(spy).toHaveBeenCalled();
});

it('should trigger an analytics ready event on document view', function() {
var spy = jasmine.createSpy('spy');
$$(env.root).on(AnalyticsEvents.analyticsEventReady, spy);
client.logClickEvent<IAnalyticsNoMeta>(
analyticsActionCauseList.documentOpen,
{},
FakeResults.createFakeResult('foo'),
document.createElement('div')
);
Defer.flush();
expect(spy).toHaveBeenCalled();
});

it('should trigger an analytics event on search event', function(done) {
var spy = jasmine.createSpy('spy');
$$(env.root).on(AnalyticsEvents.searchEvent, spy);
@@ -402,6 +415,24 @@ export function LiveAnalyticsClientTest() {
});
});

it('should trigger an analytics ready event on search event', function(done) {
var spy = jasmine.createSpy('spy');
$$(env.root).on(AnalyticsEvents.analyticsEventReady, spy);
client.logSearchEvent<IAnalyticsNoMeta>(analyticsActionCauseList.searchboxSubmit, {});
Simulate.query(env, {
query: {
q: 'the query 1'
},
promise: new Promise((resolve, reject) => {
resolve(FakeResults.createFakeResults(3));
})
});
_.defer(function() {
expect(spy).toHaveBeenCalled();
done();
});
});

it('should trigger an analytics event on custom event', function() {
var spy = jasmine.createSpy('spy');
$$(env.root).on(AnalyticsEvents.customEvent, spy);
@@ -410,6 +441,14 @@ export function LiveAnalyticsClientTest() {
expect(spy).toHaveBeenCalled();
});

it('should trigger an analytics ready event on custom event', function() {
var spy = jasmine.createSpy('spy');
$$(env.root).on(AnalyticsEvents.analyticsEventReady, spy);
client.logCustomEvent<IAnalyticsNoMeta>(analyticsActionCauseList.documentOpen, {}, document.createElement('div'));
Defer.flush();
expect(spy).toHaveBeenCalled();
});

it('should trigger change analytics metadata event', function() {
var spy = jasmine.createSpy('spy');
$$(env.root).on(AnalyticsEvents.changeAnalyticsCustomData, spy);
ProTip! Use n and p to navigate between commits in a pull request.
You can’t perform that action at this time.