305 lines
12 KiB
TypeScript

import Service from '@ember/service';
import { inject as service } from '@ember/service';
import Metrics, { IAdapterOptions } from 'ember-metrics';
import CurrentUser from '@datahub/shared/services/current-user';
import { ITrackingConfig } from '@datahub/shared/types/configurator/tracking';
import { ITrackSiteSearchParams } from '@datahub/shared/types/tracking/search';
import { getPiwikActivityQueue } from '@datahub/shared/utils/tracking/piwik';
import { scheduleOnce } from '@ember/runloop';
import RouterService from '@ember/routing/router-service';
import { alias } from '@ember/object/computed';
import Transition from '@ember/routing/-private/transition';
import { resolveDynamicRouteName } from '@datahub/utils/routes/routing';
import { mapOfRouteNamesToResolver } from '@datahub/data-models/utils/entity-route-name-resolver';
import { searchRouteName } from '@datahub/shared/constants/tracking/site-search-tracking';
import RouteInfo from '@ember/routing/-private/route-info';
import {
IBaseTrackingEvent,
IBaseTrackingGoal,
ICustomEventData,
IPageViewEventTrackingParams
} from '@datahub/shared/types/tracking/event-tracking';
import { PageType, TrackingEventCategory } from '@datahub/shared/constants/tracking/event-tracking';
import FoxieService from '@datahub/shared/services/foxie';
import { UserFunctionType } from '@datahub/shared/constants/foxie/user-function-type';
import { v4 as uuid } from 'ember-uuid';
import { ISessionInfo } from '@datahub/shared/types/tracking/session/session-tracking';
import { DateTime, Interval } from 'luxon';
/**
* Defines the base and full api for the analytics / tracking module in DataHub
* @export
* @class UnifiedTracking
*/
export default class UnifiedTracking extends Service {
/**
* Time, in minutes, that a session will last without activity before timing out
*/
private static SESSION_TRACKING_TIMEOUT = 15;
/**
* Key to access tracking session information from our local storage
*/
private static SESSION_TRACKING_STORAGE_KEY = 'datahub-tracking-session';
/**
* References the Ember Metrics addon service, which serves as a proxy to analytics services for
* metrics collection within the application
*/
@service
metrics!: Metrics;
/**
* Injection of the foxie service to trigger events from all tracked items as well, saves some effort in firing foxie
* events
*/
@service
foxie!: FoxieService;
/**
* Injected reference to the shared CurrentUser service, user here to inform the analytics service of the currently logged in
* user
*/
@service
currentUser!: CurrentUser;
/**
* Injects a reference to the router service, used to handle application routing concerns such as event handler binding
*/
@service
router!: RouterService;
/**
* The current user's username
*/
@alias('currentUser.entity.username')
userName?: string;
init(): void {
super.init();
// On init ensure that page view transitions are captured by the metrics services
this.trackPageViewOnRouteChange();
}
/**
* If tracking is enabled, activates the adapters for the applicable analytics services
* @param {ITrackingConfig} tracking a configuration object with properties for enabling tracking or specifying behavior
*/
setupTrackers(tracking: ITrackingConfig): Array<IAdapterOptions> {
const adapterOptions: Array<IAdapterOptions> = [];
if (tracking.isEnabled) {
const metrics = this.metrics;
const { trackers } = tracking;
const { piwikSiteId, piwikUrl } = trackers.piwik;
const trackingAdaptersToActivate: Array<IAdapterOptions> = [
...adapterOptions,
{
name: 'Piwik',
environments: ['all'],
config: {
piwikUrl,
siteId: piwikSiteId
}
}
];
if (piwikSiteId && piwikUrl) {
// Only activate adapters if the above two parameters are defined, otherwise ember metrics will throw an error
metrics.activateAdapters(trackingAdaptersToActivate);
}
return trackingAdaptersToActivate;
}
return adapterOptions;
}
/**
* Identifies and sets the currently logged in user to be tracked on the activated analytics services
* @param {ITrackingConfig} tracking a configuration object with properties for enabling tracking or specifying behavior
*/
setCurrentUser(tracking: ITrackingConfig): void {
const { currentUser, metrics } = this;
// Check if tracking is enabled prior to tracking the current user
// Passes an anonymous function to track the currently logged in user using the `current-user` service CurrentUser
tracking.isEnabled && currentUser.trackCurrentUser((userId: string): void => metrics.identify({ userId }));
}
/**
* This tracks the search event when a user successfully requests a search query
* @param {ITrackSiteSearchParams} { keyword, entity, searchCount } parameters for the search operation performed by the user
*/
trackSiteSearch({ keyword, entity, searchCount }: ITrackSiteSearchParams): void {
getPiwikActivityQueue().push(['trackSiteSearch', keyword, entity, searchCount]);
}
/**
* Tracks application events that are not site search events or page view. These are typically custom events that occur as
* a user interacts with the app
* @template T types that match the IBaseTrackingEvent or a specialized extension
* @template O optional type of attributes that may be used in constructing the tracking event
* @param {T} eventDetails properties to be used in building the tracking event
* @param {O} [options] optional attributes that may be used in constructing the tracking event
*/
trackEvent<T extends IBaseTrackingEvent, O extends ICustomEventData>(eventDetails: T, options?: O): void {
this.metrics.trackEvent(this.buildEvent(eventDetails, options));
this.foxie.launchUFO({
functionType:
eventDetails.category === TrackingEventCategory.ControlInteraction
? UserFunctionType.Interaction
: UserFunctionType.Tracking,
functionTarget: eventDetails.action,
functionContext: (eventDetails.name || '') + (eventDetails.value || '')
});
}
/**
* Constructs the resolved event options to be passed into the ember-metrics service
* @template T
* @param {IBaseTrackingEvent} baseEventAttrs the attributes to be used in constructing the base event
* @param {T} [_options] optional attributes that may be used in constructing the tracking event
*/
buildEvent<T extends ICustomEventData>(baseEventAttrs: IBaseTrackingEvent, _options?: T): IBaseTrackingEvent {
const { category, action, name, value } = baseEventAttrs;
const resolvedOptions = Object.assign({}, { category, action }, !!name && { name }, !!value && { value });
return resolvedOptions;
}
/**
* Track when a goal is met by adding the goal identifier to the activity queue
* @param {IBaseTrackingGoal} goal the goal to be tracked
*/
trackGoal(goal: IBaseTrackingGoal): void {
getPiwikActivityQueue().push(['trackGoal', goal.name]);
}
/**
* Tracks impressions for all rendered DOM content
* This is scheduled in the afterRender queue to ensure that tracking services can accurately identify content blocks
* that have been tagged with data-track-content data attributes. This methodology is currently specific to Piwik tracking
*/
trackContentImpressions(): void {
const trackOnPaq = (): number => getPiwikActivityQueue().push(['trackAllContentImpressions']);
scheduleOnce('afterRender', null, trackOnPaq);
}
/**
* Binds the handler to track page views on route change
*/
trackPageViewOnRouteChange(): void {
// Bind to the routeDidChange event to track global successful route transitions and track page view on the metrics service
this.router.on('routeDidChange', ({ to }: Transition): void => {
const { router, metrics } = this;
// Fetch the URL from the router and store it as an identifier
// Note: We need to append the hash '#' here as this isn't included in our tracking adapter but we need this hash
// to provide accurate information about the page since the Ember app uses it
const page: string = '/#' + router.currentURL;
// fallback to page value if a resolution cannot be determined, e.g when to / from is null
const title: string = resolveDynamicRouteName(mapOfRouteNamesToResolver, to) || page || '';
// Determine if the currentURL belongs to that of a searchPage
const isSearchRoute: boolean =
title.includes(searchRouteName) ||
Boolean(to && to.find(({ name }: RouteInfo): boolean => name === searchRouteName));
// Default the pageType to that of only `Router` since we fetch the URL directly from Ember Router.
const pageType: PageType = PageType.Router;
// Fetch current user's identifier from the currentUser service
const { userName = '' } = this;
// Track a page view event only on page's / route's that are not search
// and there is a page to track
if (!isSearchRoute && page) {
const pageViewEventParams: IPageViewEventTrackingParams = {
page,
title,
pageType,
userName
};
metrics.trackPage(pageViewEventParams);
this.foxie.launchUFO({
functionType: UserFunctionType.Navigation,
functionTarget: page,
functionContext: title
});
}
getPiwikActivityQueue().push(['enableHeartBeatTimer']);
});
}
/**
* Gets the currently stored session information
*/
private _getSessionInfo(): ISessionInfo | void {
const sessionInfo: string | null = localStorage.getItem(UnifiedTracking.SESSION_TRACKING_STORAGE_KEY);
if (sessionInfo) {
return JSON.parse(sessionInfo);
}
}
/**
* Creates and sets a new session information in order to establish for tracking purposes that a new session has
* started. We're setting our own unique session id here to have more control than relying on the play session
* information. Since this is only for tracking purposes, we don't necessarily need to be tied to auth for any reason
*/
createSessionInfo(): void {
const sessionId = uuid().toString();
const sessionInformation: ISessionInfo = {
sessionId,
timeOfLastEvent: Date.now()
};
localStorage.setItem(UnifiedTracking.SESSION_TRACKING_STORAGE_KEY, JSON.stringify(sessionInformation));
}
/**
* Updates the tracking session information with updated time of last event and any other useful measures
*/
updateSessionInfo(): void {
const currentTime = Date.now();
const sessionInfo = this._getSessionInfo();
if (sessionInfo) {
localStorage.setItem(
UnifiedTracking.SESSION_TRACKING_STORAGE_KEY,
JSON.stringify({ ...sessionInfo, timeOfLastEvent: currentTime })
);
}
}
/**
* Gets the current session information, or creates and returns a new one under conditions such as:
* 1. the session information does not exist
* 2. the session information has expired from inactivity of greater than SESSION_TRACKING_TIMEOUT
*/
getOrCreateSessionInfo(): ISessionInfo {
let sessionInfo = this._getSessionInfo();
if (!sessionInfo) {
this.createSessionInfo();
sessionInfo = this._getSessionInfo();
}
sessionInfo = sessionInfo as ISessionInfo;
const currentTime = DateTime.fromMillis(Date.now());
const timeOfLastEvent = DateTime.fromMillis(sessionInfo.timeOfLastEvent);
const timeBetweenEvents = Interval.fromDateTimes(timeOfLastEvent, currentTime);
if (timeBetweenEvents.length('minutes') > UnifiedTracking.SESSION_TRACKING_TIMEOUT) {
this.createSessionInfo();
sessionInfo = this._getSessionInfo();
}
return sessionInfo as ISessionInfo;
}
}
declare module '@ember/service' {
// eslint-disable-next-line @typescript-eslint/interface-name-prefix
interface Registry {
'unified-tracking': UnifiedTracking;
}
}