@aws-amplify/analytics
Version:
Analytics category of aws-amplify
234 lines (195 loc) • 6.78 kB
text/typescript
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { ConsoleLogger } from '@aws-amplify/core';
import { isBrowser } from '@aws-amplify/core/internals/utils';
import {
PageViewTrackingOptions,
TrackerEventRecorder,
TrackerInterface,
} from '../types/trackers';
const logger = new ConsoleLogger('PageViewTracker');
const DEFAULT_EVENT_NAME = 'pageView';
const DEFAULT_APP_TYPE = 'singlePage';
const DEFAULT_URL_PROVIDER = () => {
return window.location.origin + window.location.pathname;
};
const PREV_URL_STORAGE_KEY = 'aws-amplify-analytics-prevUrl';
/**
* Custom event broadcast by the shared history patch (see below) whenever
* `history.pushState` / `history.replaceState` is invoked. Every active
* `PageViewTracker` subscribes to it, so a single global patch can drive any
* number of concurrent tracker instances.
*/
const SPA_NAVIGATION_EVENT = 'amplify-analytics-spa-navigation';
/**
* Reference-counted, process-global patch of the History API.
*
* `PageViewTracker` instances previously each installed their own revocable
* proxy on `window.history.pushState` / `replaceState`. That is not safe once
* more than one instance can exist at a time (e.g. `pageView` auto-track enabled
* on both Pinpoint and Kinesis): the proxies stacked (each instance captured the
* previous instance's proxy as its "original"), so only the last-installed
* instance actually received SPA navigations, and disabling providers in the
* wrong order could leave a revoked proxy installed — throwing on the next
* `pushState`.
*
* Instead the History API is patched exactly once (guarded by a reference
* count). The patch simply forwards to the native implementation and broadcasts
* {@link SPA_NAVIGATION_EVENT}; each tracker instance listens for that event.
* The patch is removed and the native methods restored only when the last
* instance tears down, so any number of trackers can be enabled / disabled in
* any order safely.
*/
let historyPatchRefCount = 0;
let nativePushState: History['pushState'] | undefined;
let nativeReplaceState: History['replaceState'] | undefined;
const installHistoryPatch = () => {
historyPatchRefCount += 1;
// The patch is shared across all instances; only install it once.
if (historyPatchRefCount > 1) {
return;
}
nativePushState = window.history.pushState;
nativeReplaceState = window.history.replaceState;
const dispatchNavigation = () => {
window.dispatchEvent(new Event(SPA_NAVIGATION_EVENT));
};
window.history.pushState = function pushState(
...args: Parameters<History['pushState']>
) {
nativePushState!.apply(this, args);
dispatchNavigation();
};
window.history.replaceState = function replaceState(
...args: Parameters<History['replaceState']>
) {
nativeReplaceState!.apply(this, args);
dispatchNavigation();
};
};
const uninstallHistoryPatch = () => {
if (historyPatchRefCount === 0) {
return;
}
historyPatchRefCount -= 1;
// Keep the patch installed while other instances still rely on it.
if (historyPatchRefCount > 0) {
return;
}
if (nativePushState) {
window.history.pushState = nativePushState;
nativePushState = undefined;
}
if (nativeReplaceState) {
window.history.replaceState = nativeReplaceState;
nativeReplaceState = undefined;
}
};
export class PageViewTracker implements TrackerInterface {
private trackerActive: boolean;
private options: PageViewTrackingOptions;
private eventRecorder: TrackerEventRecorder;
/**
* `sessionStorage` key used to persist the last-recorded URL. It is
* namespaced per provider so that concurrent trackers (e.g. Pinpoint +
* Kinesis) each keep their own "previous URL" and every provider records the
* page view, rather than the first tracker clobbering the shared key and the
* rest silently skipping the event.
*/
private readonly storageKey: string;
// SPA tracking helpers
private spaTrackingActive: boolean;
constructor(
eventRecorder: TrackerEventRecorder,
options?: PageViewTrackingOptions,
namespace?: string,
) {
this.options = {};
this.trackerActive = true;
this.eventRecorder = eventRecorder;
this.spaTrackingActive = false;
this.storageKey = namespace
? `${PREV_URL_STORAGE_KEY}-${namespace}`
: PREV_URL_STORAGE_KEY;
this.handleLocationChange = this.handleLocationChange.bind(this);
this.configure(eventRecorder, options);
}
public configure(
eventRecorder: TrackerEventRecorder,
options?: PageViewTrackingOptions,
) {
this.eventRecorder = eventRecorder;
// Clean up any existing listeners
this.cleanup();
// Apply defaults
this.options = {
appType: options?.appType ?? DEFAULT_APP_TYPE,
attributes: options?.attributes ?? undefined,
eventName: options?.eventName ?? DEFAULT_EVENT_NAME,
urlProvider: options?.urlProvider ?? DEFAULT_URL_PROVIDER,
};
// Configure SPA or MPA page view tracking
if (isBrowser()) {
if (this.options.appType === 'singlePage') {
this.setupSPATracking();
} else {
this.setupMPATracking();
}
this.trackerActive = true;
}
}
public cleanup() {
// No-op if document listener is not active
if (!this.trackerActive) {
return;
}
// Clean up SPA page view listeners
if (this.spaTrackingActive) {
window.removeEventListener(
SPA_NAVIGATION_EVENT,
this.handleLocationChange,
);
window.removeEventListener('popstate', this.handleLocationChange);
uninstallHistoryPatch();
this.spaTrackingActive = false;
}
}
private setupSPATracking() {
if (!this.spaTrackingActive) {
// Subscribe to the shared history patch (installed once, ref-counted)
// plus native `popstate`. Both funnel through `handleLocationChange`.
installHistoryPatch();
window.addEventListener(SPA_NAVIGATION_EVENT, this.handleLocationChange);
window.addEventListener('popstate', this.handleLocationChange);
sessionStorage.removeItem(this.storageKey);
this.spaTrackingActive = true;
}
}
private setupMPATracking() {
this.handleLocationChange();
}
private handleLocationChange() {
const currentUrl = this.options.urlProvider!();
const eventName = this.options.eventName || DEFAULT_EVENT_NAME;
if (this.urlHasChanged()) {
sessionStorage.setItem(this.storageKey, currentUrl);
// Assemble attribute list
const attributes = Object.assign(
{
url: currentUrl,
},
this.options.attributes,
);
logger.debug('Recording automatically tracked page view event', {
eventName,
attributes,
});
this.eventRecorder(eventName, attributes);
}
}
private urlHasChanged() {
const prevUrl = sessionStorage.getItem(this.storageKey);
const currUrl = this.options.urlProvider!();
return currUrl !== prevUrl;
}
}