snowplow/snowplow-javascript-tracker

View on GitHub
plugins/browser-plugin-link-click-tracking/src/index.ts

Summary

Maintainability
B
6 hrs
Test Coverage
/*
 * Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice, this
 *    list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 *
 * 3. Neither the name of the copyright holder nor the names of its
 *    contributors may be used to endorse or promote products derived from
 *    this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

import {
  getHostName,
  getCssClasses,
  addEventListener,
  getFilterByClass,
  FilterCriterion,
  BrowserPlugin,
  BrowserTracker,
  dispatchToTrackersInCollection,
} from '@snowplow/browser-tracker-core';
import {
  resolveDynamicContext,
  DynamicContext,
  buildLinkClick,
  CommonEventProperties,
  LinkClickEvent,
} from '@snowplow/tracker-core';

interface LinkClickConfiguration {
  linkTrackingFilter?: (element: HTMLElement) => boolean;
  // Whether pseudo clicks are tracked
  linkTrackingPseudoClicks?: boolean | null | undefined;
  // Whether to track the  innerHTML of clicked links
  linkTrackingContent?: boolean | null | undefined;
  // The context attached to link click events
  linkTrackingContext?: DynamicContext | null | undefined;
  lastButton?: number | null;
  lastTarget?: EventTarget | null;
}

const _trackers: Record<string, BrowserTracker> = {};
const _configuration: Record<string, LinkClickConfiguration> = {};

/**
 * Link click tracking
 *
 * Will automatically tracking link clicks once enabled with 'enableLinkClickTracking'
 * or you can manually track link clicks with 'trackLinkClick'
 */
export function LinkClickTrackingPlugin(): BrowserPlugin {
  return {
    activateBrowserPlugin: (tracker: BrowserTracker) => {
      _trackers[tracker.id] = tracker;
    },
  };
}

/** The configuration for automatic link click tracking */
export interface LinkClickTrackingConfiguration {
  /** The filter options for the link click tracking */
  options?: FilterCriterion<HTMLElement> | null;
  /**
   * Captures middle click events in browsers that don't generate standard click
   * events for middle click actions
   */
  pseudoClicks?: boolean | null;
  /** Whether the content of the links should be tracked */
  trackContent?: boolean | null;
  /** The dyanmic context which will be evaluated for each link click event */
  context?: DynamicContext | null;
}

/**
 * Enable link click tracking
 *
 * @remarks
 * The default behaviour is to use actual click events. However, some browsers
 * (e.g., Firefox, Opera, and Konqueror) don't generate click events for the middle mouse button.
 *
 * To capture more "clicks", the pseudo click-handler uses mousedown + mouseup events.
 * This is not industry standard and is vulnerable to false positives (e.g., drag events).
 */
export function enableLinkClickTracking(
  configuration: LinkClickTrackingConfiguration = {},
  trackers: Array<string> = Object.keys(_trackers)
) {
  trackers.forEach((id) => {
    if (_trackers[id]) {
      if (_trackers[id].sharedState.hasLoaded) {
        // the load event has already fired, add the click listeners now
        configureLinkClickTracking(configuration, id);
        addClickListeners(id);
      } else {
        // defer until page has loaded
        _trackers[id].sharedState.registeredOnLoadHandlers.push(function () {
          configureLinkClickTracking(configuration, id);
          addClickListeners(id);
        });
      }
    }
  });
}

/**
 * Add click event listeners to links which have been added to the page since the
 * last time enableLinkClickTracking or refreshLinkClickTracking was used
 *
 * @param trackers - The tracker identifiers which the have their link click state refreshed
 */
export function refreshLinkClickTracking(trackers: Array<string> = Object.keys(_trackers)) {
  trackers.forEach((id) => {
    if (_trackers[id]) {
      if (_trackers[id].sharedState.hasLoaded) {
        addClickListeners(id);
      } else {
        _trackers[id].sharedState.registeredOnLoadHandlers.push(function () {
          addClickListeners(id);
        });
      }
    }
  });
}

/**
 * Manually log a click
 *
 * @param event - The event information
 * @param trackers - The tracker identifiers which the event will be sent to
 */
export function trackLinkClick(
  event: LinkClickEvent & CommonEventProperties,
  trackers: Array<string> = Object.keys(_trackers)
) {
  dispatchToTrackersInCollection(trackers, _trackers, (t) => {
    t.core.track(buildLinkClick(event), event.context, event.timestamp);
  });
}

/*
 * Process clicks
 */
function processClick(tracker: BrowserTracker, sourceElement: Element, context?: DynamicContext | null) {
  let parentElement, tag, elementId, elementClasses, elementTarget, elementContent;

  while (
    (parentElement = sourceElement.parentElement) !== null &&
    parentElement != null &&
    (tag = sourceElement.tagName.toUpperCase()) !== 'A' &&
    tag !== 'AREA'
  ) {
    sourceElement = parentElement;
  }

  const anchorElement = <HTMLAnchorElement>sourceElement;
  if (anchorElement.href != null) {
    // browsers, such as Safari, don't downcase hostname and href
    var originalSourceHostName = anchorElement.hostname || getHostName(anchorElement.href),
      sourceHostName = originalSourceHostName.toLowerCase(),
      sourceHref = anchorElement.href.replace(originalSourceHostName, sourceHostName),
      scriptProtocol = new RegExp('^(javascript|vbscript|jscript|mocha|livescript|ecmascript):', 'i');

    // Ignore script pseudo-protocol links
    if (!scriptProtocol.test(sourceHref)) {
      elementId = anchorElement.id;
      elementClasses = getCssClasses(anchorElement);
      elementTarget = anchorElement.target;
      elementContent = _configuration[tracker.id].linkTrackingContent ? anchorElement.innerHTML : undefined;

      // decodeUrl %xx
      sourceHref = unescape(sourceHref);
      tracker.core.track(
        buildLinkClick({
          targetUrl: sourceHref,
          elementId,
          elementClasses,
          elementTarget,
          elementContent,
        }),
        resolveDynamicContext(context, sourceElement)
      );
    }
  }
}

/*
 * Return function to handle click event
 */
function getClickHandler(tracker: string, context?: DynamicContext | null): EventListenerOrEventListenerObject {
  return function (evt: Event) {
    var button, target;

    evt = evt || window.event;
    button = (evt as MouseEvent).which || (evt as MouseEvent).button;
    target = evt.target || evt.srcElement;

    // Using evt.type (added in IE4), we avoid defining separate handlers for mouseup and mousedown.
    if (evt.type === 'click') {
      if (target) {
        processClick(_trackers[tracker], target as Element, context);
      }
    } else if (evt.type === 'mousedown') {
      if ((button === 1 || button === 2) && target) {
        _configuration[tracker].lastButton = button;
        _configuration[tracker].lastTarget = target;
      } else {
        _configuration[tracker].lastButton = _configuration[tracker].lastTarget = null;
      }
    } else if (evt.type === 'mouseup') {
      if (button === _configuration[tracker].lastButton && target === _configuration[tracker].lastTarget) {
        processClick(_trackers[tracker], target as Element, context);
      }
      _configuration[tracker].lastButton = _configuration[tracker].lastTarget = null;
    }
  };
}

/*
 * Add click listener to a DOM element
 */
function addClickListener(tracker: string, element: HTMLAnchorElement | HTMLAreaElement) {
  if (_configuration[tracker].linkTrackingPseudoClicks) {
    // for simplicity and performance, we ignore drag events
    addEventListener(element, 'mouseup', getClickHandler(tracker, _configuration[tracker].linkTrackingContext), false);
    addEventListener(
      element,
      'mousedown',
      getClickHandler(tracker, _configuration[tracker].linkTrackingContext),
      false
    );
  } else {
    addEventListener(element, 'click', getClickHandler(tracker, _configuration[tracker].linkTrackingContext), false);
  }
}

/*
 * Configures link click tracking: how to filter which links will be tracked,
 * whether to use pseudo click tracking, and what context to attach to link_click events
 */
function configureLinkClickTracking(
  { options, pseudoClicks, trackContent, context }: LinkClickTrackingConfiguration = {},
  tracker: string
) {
  _configuration[tracker] = {
    linkTrackingContent: trackContent,
    linkTrackingContext: context,
    linkTrackingPseudoClicks: pseudoClicks,
    linkTrackingFilter: getFilterByClass(options),
  };
}

/*
 * Add click handlers to anchor and AREA elements, except those to be ignored
 */
function addClickListeners(trackerId: string) {
  var linkElements = document.links,
    i;

  for (i = 0; i < linkElements.length; i++) {
    // Add a listener to link elements which pass the filter and aren't already tracked
    if (_configuration[trackerId].linkTrackingFilter?.(linkElements[i]) && !(linkElements[i] as any)[trackerId]) {
      addClickListener(trackerId, linkElements[i]);
      (linkElements[i] as any)[trackerId] = true;
    }
  }
}