Module 09: Extensions & Plugins

The core collector we have built over Modules 01–08 handles page views, technographics, performance timing, web vitals, error tracking, session identity, and configuration — the fundamentals every site needs. But some sites need more: click tracking, scroll depth, form analytics, session recording, heatmaps. Instead of bloating the core, we build a plugin system that keeps the core small and lets you load extensions separately.

Demo Files

Run: Open test.html and interact: click elements, scroll down. Watch the status panel update in real time and check the console for tracked events.

Why a Plugin System?

The core collector (Modules 01–08) handles page views, technographics, timing, vitals, and errors — the fundamentals every site needs. But some sites need more: click tracking, scroll depth, form analytics, session recording, heatmaps. Each of these adds weight. A site that only needs scroll tracking should not have to download click-tracking code.

The solution is a plugin system: keep the core small, load extensions as separate scripts, and register them at runtime with collector.use(). This mirrors the architecture of real analytics tools — Google Tag Manager loads "tags" on demand, Segment loads "integrations" as plugins, and analytics libraries like Amplitude support extension modules.

Core Collector (~5KB) ┌──────────────────────┐ │ init / track / send │ │ timing / vitals │ │ errors / session │ ├──────────────────────┤ │ collector.use() │ └──┬──────┬──────┬─────┘ │ │ │ ┌──▼──┐┌──▼──┐┌──▼──┐ │click││scroll││form │ Extensions (~1-2KB each) │track││depth ││track│ Load only what you need └─────┘└─────┘└─────┘

This architecture delivers several benefits. First, smaller payloads — sites load only the extensions they need. Second, separation of concerns — each extension is a self-contained unit with its own event listeners and cleanup. Third, testability — you can test each extension in isolation. Fourth, third-party extensibility — other developers can write extensions without modifying the core.

The Plugin Interface

Every extension must conform to a simple contract — an object with a name string, an init function, and an optional destroy function:

// An extension must have:
const extension = {
  name: 'my-extension',           // Unique identifier
  init: function(collector) { },  // Called when use() is called
  destroy: function() { }         // Called for cleanup (optional)
};

The name property serves as a unique identifier — it prevents the same extension from being registered twice. The init function receives a reference to the collector's public API, which the extension uses to track events. The destroy function handles cleanup: removing event listeners, clearing timers, and releasing references. This is especially important in single-page applications (SPAs) where the page is never fully unloaded.

This is a deliberately simple interface. No class hierarchy, no dependency injection framework, no configuration schema. Just a plain object with three properties. The simplicity makes it easy to write extensions and easy to reason about what they do.

Adding use() to the Collector

The use() function is added inside the collector's IIFE. It maintains a registry of installed extensions and passes a limited API to each extension's init function:

// Inside the IIFE...
const extensions = {};

function use(extension) {
  if (!extension || !extension.name) {
    warn('Extension must have a name property');
    return;
  }
  if (extensions[extension.name]) {
    warn(`Extension "${extension.name}" already registered`);
    return;
  }

  extensions[extension.name] = extension;

  // Call init, passing the collector's public API
  if (typeof extension.init === 'function') {
    extension.init({
      track: track,
      set: set,
      getConfig: () => config,
      getSessionId: getSessionId
    });
  }

  log('Extension registered:', extension.name);
}

Notice the validation at the top: extensions must have a name, and duplicate registrations are rejected. This prevents subtle bugs where the same extension is loaded twice (perhaps from two different script tags) and ends up tracking events double.

The extension receives a limited APItrack() and set() but NOT send() directly. This is an intentional design choice. By funneling all extension data through track(), the core collector can apply its configuration uniformly: sampling rates, debug mode, batching, and endpoint routing all work the same whether the event came from the core or from an extension. If extensions could call send() directly, they could bypass sampling, ignore debug mode, or send data to the wrong endpoint.

The getConfig() function lets extensions read the current configuration (for example, to check if debug mode is on), and getSessionId() provides access to the session identifier without exposing the session management internals.

Building the Click Tracker Extension

The click tracker is our first real extension. It listens for click events anywhere on the page, extracts information about what was clicked, and sends it through collector.track(). Let us walk through ext-clicks.js:

const ClickTracker = {
  name: 'click-tracker',

  _handler: null,
  _debounceTimer: null,

  init: function(collector) {
    var self = this;
    let lastClick = 0;

    self._handler = function(event) {
      // Debounce: ignore clicks within 300ms of each other
      const now = Date.now();
      if (now - lastClick < 300) return;
      lastClick = now;

      const target = event.target;

      collector.track('click', {
        // What was clicked
        tagName: target.tagName,
        id: target.id || undefined,
        className: target.className || undefined,
        text: (target.textContent || '').substring(0, 100),
        // Where in the page
        x: event.clientX,
        y: event.clientY,
        // CSS selector path for identifying the element
        selector: self._getSelector(target)
      });
    };

    document.addEventListener('click', self._handler, true);
  },

  _getSelector: function(el) {
    const parts = [];
    while (el && el !== document.body) {
      let part = el.tagName.toLowerCase();
      if (el.id) {
        part += `#${el.id}`;
        parts.unshift(part);
        break;
      }
      if (el.className && typeof el.className === 'string') {
        part += `.${el.className.trim().split(/\s+/).join('.')}`;
      }
      parts.unshift(part);
      el = el.parentElement;
    }
    return parts.join(' > ');
  },

  destroy: function() {
    if (this._handler) {
      document.removeEventListener('click', this._handler, true);
    }
  }
};

Click Event Handling

The event listener is registered with true as the third argument, which means it uses the capture phase. This ensures the handler fires before any element-level click handlers that might call stopPropagation(). If we used the bubble phase (the default), a click on a button that stops propagation would never reach our tracker.

The debounce (300ms) prevents rapid double-clicks or programmatic click events from generating duplicate tracking data. In analytics, you want to know that the user clicked a button — you do not need to record both clicks of a double-click separately.

The data sent with each click event includes the element's tagName, id, className, and the first 100 characters of its text content. The text is truncated to prevent accidentally sending large amounts of page content to the analytics endpoint.

CSS Selector Path Generation

The _getSelector() method walks up the DOM from the clicked element, building a CSS selector path like div.container > ul.menu > li > a.nav-link. This path uniquely identifies the element's position in the page structure, which is essential for building heatmaps and click maps.

The walk stops at an element with an id attribute, because IDs are (or should be) unique within a page. So div#sidebar > ul > li is a complete path — there is no need to walk further up. If no ID is found, the path extends all the way to the document body.

Class names are included by joining them with dots: an element with class="btn btn-primary" becomes button.btn.btn-primary. This level of detail lets you analyze clicks at whatever granularity you need — all buttons, all primary buttons, or a specific primary button in a specific container.

Cleanup

The destroy() function removes the click event listener. This is critical for single-page applications. When a user navigates away from a "page" in an SPA, the old page's extensions should be destroyed so they stop tracking events on the new page. Without destroy(), you would accumulate event listeners with every navigation, eventually degrading performance.

Building the Scroll Depth Extension

Scroll depth tells you how far users read down the page. If 90% of visitors never scroll past the fold, your below-the-fold content is not working. If users consistently reach 75% but stop before the call-to-action at 90%, you know exactly where to optimize. Here is ext-scroll.js:

const ScrollTracker = {
  name: 'scroll-tracker',

  _collector: null,
  _maxDepth: 0,
  _reported: {},
  _rafId: null,
  _thresholds: [25, 50, 75, 100],

  init: function(collector) {
    var self = this;
    self._collector = collector;

    // Throttle scroll measurement with requestAnimationFrame
    let ticking = false;

    window.addEventListener('scroll', () => {
      if (!ticking) {
        ticking = true;
        requestAnimationFrame(() => {
          self._measure();
          ticking = false;
        });
      }
    });

    // Report final depth on page hide
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        self._reportFinal();
      }
    });
  },

  _measure: function() {
    const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
    const docHeight = Math.max(
      document.documentElement.scrollHeight,
      document.body.scrollHeight
    );
    const winHeight = window.innerHeight;
    const percent = Math.round((scrollTop + winHeight) / docHeight * 100);

    if (percent > this._maxDepth) {
      this._maxDepth = percent;
    }

    // Report threshold crossings
    for (const t of this._thresholds) {
      if (percent >= t && !this._reported[t]) {
        this._reported[t] = true;
        this._collector.track('scroll_depth', {
          threshold: t,
          maxDepth: this._maxDepth
        });
      }
    }
  },

  _reportFinal: function() {
    this._collector.track('scroll_final', {
      maxDepth: this._maxDepth
    });
  },

  destroy: function() {
    // In a real implementation, we'd remove event listeners
    // by storing references to the bound functions
  }
};

requestAnimationFrame Throttling

Scroll events fire dozens of times per second — potentially hundreds of times during a fast scroll. If we measured scroll depth on every scroll event, we would waste CPU cycles on redundant calculations and potentially cause jank (dropped frames). The requestAnimationFrame throttle limits measurement to once per animation frame, which is roughly 60 times per second (matching the display refresh rate). This is enough to catch all threshold crossings without wasting resources.

The ticking flag is the key mechanism: when a scroll event fires and ticking is false, we set it to true and schedule a measurement on the next frame. Any additional scroll events that fire before that frame are ignored because ticking is already true. After the measurement runs, ticking resets to false, ready for the next scroll event.

Scroll Depth Calculation

The formula (scrollTop + winHeight) / docHeight * 100 calculates what percentage of the total document height is currently visible or has been scrolled past. scrollTop is how far the page has scrolled from the top. winHeight is the height of the visible viewport. Together, scrollTop + winHeight represents the bottom edge of the visible area. Dividing by the total document height gives the fraction of the page that the user has "reached."

We use Math.max(document.documentElement.scrollHeight, document.body.scrollHeight) because different browsers report document height through different properties. Taking the maximum ensures we get the correct value across browsers.

Threshold Reporting

Instead of reporting every single scroll position (which would generate enormous amounts of data), the extension reports only when the user crosses predefined thresholds: 25%, 50%, 75%, and 100%. The _reported object ensures each threshold is reported only once — if a user scrolls to 60% and back to 40% and then to 80%, they cross the 50% threshold only once, and it is reported only once.

This threshold-based approach dramatically reduces data volume while preserving the insights you actually need. In aggregate, you can see what percentage of users reached each depth level, which is far more useful than raw scroll positions.

Final Depth on Page Hide

The visibilitychange listener sends one final scroll_final event when the user leaves the page, reporting the maximum depth they ever reached. This is the most important metric — it tells you the deepest point the user explored, regardless of whether they scrolled back up before leaving.

Using Extensions

With the plugin system in place, adding extensions to a page is straightforward. Load the scripts and call collector.use() for each one:

<script src="collector-v8.js"></script>
<script src="ext-clicks.js"></script>
<script src="ext-scroll.js"></script>
<script>
  collector.init({
    endpoint: '/collect',
    debug: true
  });
  collector.use(ClickTracker);
  collector.use(ScrollTracker);
</script>

Each extension is a separate script file that defines a global object (ClickTracker, ScrollTracker). The collector does not know about extensions until use() is called — there is no auto-discovery or magical loading. This explicitness is intentional: you can see exactly which extensions are active by reading the HTML source.

The order matters slightly: collector-v8.js must load first (it defines the collector global), and init() should be called before use() so that extensions receive a fully configured collector API. Extension scripts can load in any order relative to each other.

Extension Lifecycle

Extensions follow a predictable lifecycle from loading through active use to cleanup:

Phase What Happens
Load Extension scripts load, defining global objects (e.g., window.ClickTracker). No side effects — the extension is inert until registered.
use() collector.use(ext) stores the extension in the registry and calls ext.init(api), passing the collector's limited public API.
Active The extension listens for events (clicks, scrolls, etc.) and calls collector.track() to send data through the core pipeline.
Destroy ext.destroy() removes event listeners, clears timers, and releases references. Used for SPA cleanup when navigating between views.

The key insight is that extensions are inert when loaded. Loading ext-clicks.js does not start tracking clicks — it only defines the ClickTracker object. Tracking begins only when collector.use(ClickTracker) is called and init() sets up the event listeners. This separation means you can conditionally enable extensions based on configuration, user consent, or A/B test groups:

// Only enable click tracking for users in the "detailed" cohort
if (config.trackingLevel === 'detailed') {
  collector.use(ClickTracker);
}

// Only enable scroll tracking if user has consented
if (hasAnalyticsConsent()) {
  collector.use(ScrollTracker);
}

Cross-Reference

Cross-Reference: See analytics-overview.html Section 12 (User Behavior and Usability) for the theory behind click tracking and scroll analysis. The plugin pattern keeps the core collector small — sites that do not need click tracking never download that code. This is a practical application of the performance principle discussed in Module 05: every byte you send to the browser has a cost, so send only what you need.

Summary