UNPKG

coveo-search-ui

Version:

Coveo JavaScript Search Framework

465 lines (414 loc) • 17.6 kB
import { IAnalyticsClient } from './AnalyticsClient'; import { DeviceUtils } from '../../utils/DeviceUtils'; import { PendingSearchEvent } from './PendingSearchEvent'; import { PendingSearchAsYouTypeSearchEvent } from './PendingSearchAsYouTypeSearchEvent'; import { AnalyticsEndpoint } from '../../rest/AnalyticsEndpoint'; import { Assert } from '../../misc/Assert'; import { Logger } from '../../misc/Logger'; import { IAnalyticsActionCause, analyticsActionCauseList } from './AnalyticsActionListMeta'; import { IQueryResult } from '../../rest/QueryResult'; import { ITopQueries } from '../../rest/TopQueries'; import { IChangeableAnalyticsMetaObject, IChangeableAnalyticsDataObject, IChangeAnalyticsCustomDataEventArgs, IAnalyticsEventArgs } from '../../events/AnalyticsEvents'; import { Defer } from '../../misc/Defer'; import { $$ } from '../../utils/Dom'; import { AnalyticsEvents, IAnalyticsCustomEventArgs } from '../../events/AnalyticsEvents'; import { APIAnalyticsBuilder } from '../../rest/APIAnalyticsBuilder'; import { IAnalyticsEvent } from '../../rest/AnalyticsEvent'; import { IAPIAnalyticsEventResponse } from '../../rest/APIAnalyticsEventResponse'; import { ISearchEvent } from '../../rest/SearchEvent'; import { IClickEvent } from '../../rest/ClickEvent'; import { ICustomEvent } from '../../rest/CustomEvent'; import { QueryStateModel } from '../../models/QueryStateModel'; import { Component } from '../Base/Component'; import { version } from '../../misc/Version'; import { QueryUtils } from '../../utils/QueryUtils'; import * as _ from 'underscore'; import { IComponentBindings } from '../Base/ComponentBindings'; import { AnalyticsInformation } from './AnalyticsInformation'; import { QueryController } from '../../ControllersModules'; export class LiveAnalyticsClient implements IAnalyticsClient { public isContextual: boolean = false; public originContext: string = 'Search'; private language = <string>String['locale']; private device = DeviceUtils.getDeviceName(); private mobile = DeviceUtils.isMobileDevice(); private pendingSearchEvent: PendingSearchEvent; private pendingSearchAsYouTypeSearchEvent: PendingSearchAsYouTypeSearchEvent; private logger: Logger; constructor( public endpoint: AnalyticsEndpoint, public rootElement: HTMLElement, public userId: string, public userDisplayName: string, public anonymous: boolean, public splitTestRunName: string, public splitTestRunVersion: string, public originLevel1: string, public sendToCloud: boolean, public bindings: IComponentBindings ) { Assert.exists(endpoint); Assert.exists(rootElement); Assert.isNonEmptyString(this.language); Assert.isNonEmptyString(this.device); Assert.isNonEmptyString(this.originLevel1); this.logger = new Logger(this); } public isActivated(): boolean { return true; } public getCurrentVisitId(): string { return this.endpoint.getCurrentVisitId(); } public getCurrentVisitIdPromise(): Promise<string> { return this.endpoint.getCurrentVisitIdPromise(); } public getCurrentEventCause() { if (this.pendingSearchEvent != null) { return this.pendingSearchEvent.getEventCause(); } if (this.pendingSearchAsYouTypeSearchEvent != null) { return this.pendingSearchAsYouTypeSearchEvent.getEventCause(); } return null; } public getCurrentEventMeta() { if (this.pendingSearchEvent != null) { return this.pendingSearchEvent.getEventMeta(); } if (this.pendingSearchAsYouTypeSearchEvent != null) { return this.pendingSearchAsYouTypeSearchEvent.getEventMeta(); } return null; } public logSearchEvent<TMeta>(actionCause: IAnalyticsActionCause, meta: TMeta) { var metaObject = this.buildMetaObject(meta); this.pushSearchEvent(actionCause, metaObject); } public logSearchAsYouType<TMeta>(actionCause: IAnalyticsActionCause, meta?: TMeta) { var metaObject = this.buildMetaObject(meta); this.pushSearchAsYouTypeEvent(actionCause, metaObject); } public logClickEvent<TMeta>( actionCause: IAnalyticsActionCause, meta: TMeta, result: IQueryResult, element: HTMLElement ): Promise<IAPIAnalyticsEventResponse> { let metaObject = this.buildMetaObject(meta, result); return this.pushClickEvent(actionCause, metaObject, result, element); } public logCustomEvent<TMeta>( actionCause: IAnalyticsActionCause, meta: TMeta, element: HTMLElement, result?: IQueryResult ): Promise<IAPIAnalyticsEventResponse> { const metaObject = this.buildMetaObject(meta, result); return this.pushCustomEvent(actionCause, metaObject, element); } public getTopQueries(params: ITopQueries): Promise<string[]> { return this.endpoint.getTopQueries(params); } public sendAllPendingEvents() { if (this.pendingSearchAsYouTypeSearchEvent) { this.pendingSearchAsYouTypeSearchEvent.sendRightNow(); } } public cancelAllPendingEvents() { if (this.pendingSearchAsYouTypeSearchEvent) { this.pendingSearchAsYouTypeSearchEvent.cancel(); this.pendingSearchAsYouTypeSearchEvent = null; } if (this.pendingSearchEvent) { this.pendingSearchEvent.cancel(); this.pendingSearchEvent = null; } } public getPendingSearchEvent(): PendingSearchEvent { if (this.pendingSearchEvent) { return this.pendingSearchEvent; } else if (this.pendingSearchAsYouTypeSearchEvent) { return this.pendingSearchAsYouTypeSearchEvent; } return null; } public warnAboutSearchEvent() { if (_.isUndefined(this.pendingSearchEvent) && _.isUndefined(this.pendingSearchAsYouTypeSearchEvent)) { this.logger.warn( 'A search was triggered, but no analytics event was logged. If you wish to have consistent analytics data, consider logging a search event using the methods provided by the framework', 'https://docs.coveo.com/en/2726/#logging-your-own-search-events' ); if (window['console'] && console.trace) { console.trace(); } } } public setOriginContext(originContext: string) { this.originContext = originContext; } public getOriginContext() { return this.originContext; } public getUserDisplayName(): string { return this.userDisplayName; } private pushCustomEvent( actionCause: IAnalyticsActionCause, metaObject: IChangeableAnalyticsMetaObject, element?: HTMLElement ): Promise<IAPIAnalyticsEventResponse> { const customEvent = this.buildCustomEvent(actionCause, metaObject, element); this.triggerChangeAnalyticsCustomData('CustomEvent', metaObject, customEvent); this.checkToSendAnyPendingSearchAsYouType(actionCause); $$(this.rootElement).trigger(AnalyticsEvents.customEvent, <IAnalyticsCustomEventArgs>{ customEvent: APIAnalyticsBuilder.convertCustomEventToAPI(customEvent) }); $$(this.rootElement).trigger(AnalyticsEvents.analyticsEventReady, <IAnalyticsEventArgs>{ event: 'CoveoCustomEvent', coveoAnalyticsEventData: customEvent }); return this.sendToCloud ? this.endpoint.sendCustomEvent(customEvent) : Promise.resolve(null); } private pushSearchEvent(actionCause: IAnalyticsActionCause, metaObject: IChangeableAnalyticsMetaObject) { Assert.exists(actionCause); if (this.pendingSearchEvent && this.pendingSearchEvent.getEventCause() !== actionCause.name) { this.pendingSearchEvent.stopRecording(); this.pendingSearchEvent = null; } this.checkToSendAnyPendingSearchAsYouType(actionCause); if (!this.pendingSearchEvent) { var searchEvent = this.buildSearchEvent(actionCause, metaObject); this.triggerChangeAnalyticsCustomData('SearchEvent', metaObject, searchEvent); var pendingSearchEvent = (this.pendingSearchEvent = new PendingSearchEvent( this.rootElement, this.endpoint, searchEvent, this.sendToCloud )); Defer.defer(() => { // At this point all `duringQuery` events should have been fired, so we can forget // about the pending search event. It will finish processing automatically when // all the deferred that were caught terminate. this.pendingSearchEvent = undefined; pendingSearchEvent.stopRecording(); }); } } private checkToSendAnyPendingSearchAsYouType(actionCause: IAnalyticsActionCause) { if (this.eventIsNotRelatedToSearchbox(actionCause.name)) { this.sendAllPendingEvents(); } else { this.cancelAnyPendingSearchAsYouTypeEvent(); } } private pushSearchAsYouTypeEvent(actionCause: IAnalyticsActionCause, metaObject: IChangeableAnalyticsMetaObject) { this.cancelAnyPendingSearchAsYouTypeEvent(); var searchEvent = this.buildSearchEvent(actionCause, metaObject); this.triggerChangeAnalyticsCustomData('SearchEvent', metaObject, searchEvent); this.pendingSearchAsYouTypeSearchEvent = new PendingSearchAsYouTypeSearchEvent( this.rootElement, this.endpoint, searchEvent, this.sendToCloud ); } private pushClickEvent( actionCause: IAnalyticsActionCause, metaObject: IChangeableAnalyticsMetaObject, result: IQueryResult, element: HTMLElement ): Promise<IAPIAnalyticsEventResponse> { var event = this.buildClickEvent(actionCause, metaObject, result, element); this.checkToSendAnyPendingSearchAsYouType(actionCause); this.triggerChangeAnalyticsCustomData('ClickEvent', metaObject, event, { resultData: result }); Assert.isNonEmptyString(event.searchQueryUid); Assert.isNonEmptyString(event.collectionName); Assert.isNonEmptyString(event.sourceName); Assert.isNumber(event.documentPosition); $$(this.rootElement).trigger(AnalyticsEvents.documentViewEvent, { documentViewEvent: APIAnalyticsBuilder.convertDocumentViewToAPI(event) }); $$(this.rootElement).trigger(AnalyticsEvents.analyticsEventReady, <IAnalyticsEventArgs>{ event: 'CoveoClickEvent', coveoAnalyticsEventData: event }); return this.sendToCloud ? this.endpoint.sendDocumentViewEvent(event) : Promise.resolve(null); } private buildAnalyticsEvent(actionCause: IAnalyticsActionCause, metaObject: IChangeableAnalyticsMetaObject): IAnalyticsEvent { return { searchQueryUid: undefined, actionCause: actionCause.name, actionType: actionCause.type, username: this.userId, userDisplayName: this.userDisplayName, anonymous: this.anonymous, device: this.device, mobile: this.mobile, language: this.language, responseTime: undefined, originLevel1: this.originLevel1, originLevel2: this.getOriginLevel2(this.rootElement), originLevel3: document.referrer, originContext: this.originContext, customData: _.keys(metaObject).length > 0 ? metaObject : undefined, userAgent: navigator.userAgent, clientId: new AnalyticsInformation().clientId }; } private buildSearchEvent(actionCause: IAnalyticsActionCause, metaObject: IChangeableAnalyticsMetaObject): ISearchEvent { return this.merge<ISearchEvent>(this.buildAnalyticsEvent(actionCause, metaObject), { searchQueryUid: undefined, pipeline: undefined, splitTestRunName: this.splitTestRunName, splitTestRunVersion: this.splitTestRunVersion, queryText: undefined, advancedQuery: undefined, results: undefined, resultsPerPage: undefined, pageNumber: undefined, didYouMean: undefined, facets: undefined, contextual: this.isContextual }); } private buildClickEvent( actionCause: IAnalyticsActionCause, metaObject: IChangeableAnalyticsMetaObject, result: IQueryResult, element: HTMLElement ): IClickEvent { return this.merge<IClickEvent>(this.buildAnalyticsEvent(actionCause, metaObject), { searchQueryUid: result.queryUid, queryPipeline: result.pipeline, splitTestRunName: this.splitTestRunName || result.splitTestRun, splitTestRunVersion: this.splitTestRunVersion || (result.splitTestRun != undefined ? result.pipeline : undefined), documentUri: result.uri, documentUriHash: QueryUtils.getUriHash(result), documentUrl: result.clickUri, documentTitle: result.title, documentCategory: QueryUtils.getObjectType(result), originLevel2: this.getOriginLevel2(element), collectionName: QueryUtils.getCollection(result), sourceName: QueryUtils.getSource(result), documentPosition: result.index + 1, responseTime: 0, viewMethod: actionCause.name, rankingModifier: result.rankingModifier }); } private buildCustomEvent( actionCause: IAnalyticsActionCause, metaObject: IChangeableAnalyticsMetaObject, element: HTMLElement ): ICustomEvent { return this.merge<ICustomEvent>(this.buildAnalyticsEvent(actionCause, metaObject), { lastSearchQueryUid: this.getLastSearchQueryUid(), eventType: actionCause.type, eventValue: actionCause.name, originLevel2: this.getOriginLevel2(element), responseTime: 0 }); } protected getOriginLevel2(element: HTMLElement): string { return this.resolveActiveTabFromElement(element) || 'default'; } private getLastSearchQueryUid() { const queryController = Component.resolveBinding(Component.resolveRoot(this.rootElement), QueryController) as QueryController; if (!queryController) { return; } const lastResults = queryController.getLastResults(); if (!lastResults) { return; } return lastResults.searchUid; } private buildMetaObject<TMeta>(meta: TMeta, result?: IQueryResult): IChangeableAnalyticsMetaObject { let modifiedMeta: IChangeableAnalyticsMetaObject = _.extend({}, meta); modifiedMeta['JSUIVersion'] = version.lib + ';' + version.product; const contentIDsAreAlreadySet = modifiedMeta['contentIDKey'] && modifiedMeta['contentIDValue']; if (!contentIDsAreAlreadySet && result) { let uniqueId = QueryUtils.getPermanentId(result); modifiedMeta['contentIDKey'] = uniqueId.fieldUsed; modifiedMeta['contentIDValue'] = uniqueId.fieldValue; } return modifiedMeta; } private cancelAnyPendingSearchAsYouTypeEvent() { if (this.pendingSearchAsYouTypeSearchEvent) { this.pendingSearchAsYouTypeSearchEvent.cancel(); this.pendingSearchAsYouTypeSearchEvent = undefined; } } private resolveActiveTabFromElement(element: HTMLElement): string { Assert.exists(element); var queryStateModel = this.resolveQueryStateModel(element); return queryStateModel && <string>queryStateModel.get(QueryStateModel.attributesEnum.t); } private resolveQueryStateModel(rootElement: HTMLElement): QueryStateModel { return <QueryStateModel>Component.resolveBinding(rootElement, QueryStateModel); } private eventIsNotRelatedToSearchbox(event: string) { return event !== analyticsActionCauseList.searchboxSubmit.name && event !== analyticsActionCauseList.searchboxClear.name; } private triggerChangeAnalyticsCustomData(type: string, metaObject: IChangeableAnalyticsMetaObject, event: IAnalyticsEvent, data?: any) { // This is for backward compatibility. Before the analytics were using either numbered // metas in `metaDataAsNumber` of later on named metas in `metaDataAsString`. Thus we still // provide those properties in a deprecated way. Below we are moving any data that put // in them to the root. (<any>metaObject)['metaDataAsString'] = {}; (<any>metaObject)['metaDataAsNumber'] = {}; var changeableAnalyticsDataObject: IChangeableAnalyticsDataObject = { language: event.language, originLevel1: event.originLevel1, originLevel2: event.originLevel2, originLevel3: event.originLevel3, metaObject: metaObject }; var args: IChangeAnalyticsCustomDataEventArgs = _.extend( {}, { type: type, actionType: event.actionType, actionCause: event.actionCause }, changeableAnalyticsDataObject, data ); $$(this.rootElement).trigger(AnalyticsEvents.changeAnalyticsCustomData, args); event.language = args.language; event.originLevel1 = args.originLevel1; event.originLevel2 = args.originLevel2; event.originLevel3 = args.originLevel3; event.customData = metaObject; // This is for backward compatibility. Before the analytics were using either numbered // metas in `metaDataAsNumber` of later on named metas in `metaDataAsString`. We are now putting // them all at the root, and if I encounter the older properties I move them to the top // level after issuing a warning. var metaDataAsString = event.customData['metaDataAsString']; if (_.keys(metaDataAsString).length > 0) { this.logger.warn( "Using deprecated 'metaDataAsString' key to log custom analytics data. Custom meta should now be put at the root of the object." ); _.extend(event.customData, metaDataAsString); } delete event.customData['metaDataAsString']; var metaDataAsNumber = event.customData['metaDataAsNumber']; if (_.keys(metaDataAsNumber).length > 0) { this.logger.warn( "Using deprecated 'metaDataAsNumber' key to log custom analytics data. Custom meta should now be put at the root of the object." ); _.extend(event.customData, metaDataAsNumber); } delete event.customData['metaDataAsNumber']; } private merge<T extends IAnalyticsEvent>(first: IAnalyticsEvent, second: any): T { return _.extend({}, first, second); } }