Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8f397eb
Simple link tracking
djanelle-mit Mar 5, 2026
1de21a6
Matomo click helper function
djanelle-mit Mar 6, 2026
b330074
Removed manual tracking test
djanelle-mit Mar 6, 2026
afe58f3
Commenting click listener code
djanelle-mit Mar 6, 2026
42a85f2
Visibility tracking helper refactor plus initial test attribute
djanelle-mit Mar 6, 2026
7c9495f
Updated to make seen tracking work for Turbo:load
djanelle-mit Mar 6, 2026
769c5d0
Testing results found tracking
djanelle-mit Mar 6, 2026
d7d5880
Add helper function to get tab names and apply to no results tracking
djanelle-mit Mar 6, 2026
f169c56
Attempt to interpolate function names in attributes for helper functions
djanelle-mit Mar 6, 2026
1601c54
added child link tracking to -click attribute
djanelle-mit Mar 12, 2026
4255038
Experimenting with adding parent element click tracking to tabs
djanelle-mit Mar 12, 2026
1665023
Adding getElementText helper function and implementing on tabs
djanelle-mit Mar 12, 2026
b01d4b5
Testing sidebar link tracking and seen
djanelle-mit Mar 12, 2026
fd9045c
Experimenting with suggestion visibility tracking
djanelle-mit Mar 12, 2026
cb19cc9
Helper to find results page number
djanelle-mit Mar 12, 2026
b8d6f43
Updated -seen tracking to be truly when visible not when loaded in DOM
djanelle-mit Mar 12, 2026
986031b
Adding capture to force the getActiveTab name helper to fire before l…
djanelle-mit Mar 12, 2026
6625405
Testing intervention tracking
djanelle-mit Mar 12, 2026
8945b41
Experimenting with Content Tracking for Interventions
djanelle-mit Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/javascript/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import "@hotwired/turbo-rails"
import "controllers"
import "loading_spinner"
import "matomo_tracking"

// Show the progress bar after 200 milliseconds, not the default 500
Turbo.config.drive.progressBarDelay = 200;
236 changes: 236 additions & 0 deletions app/javascript/matomo_tracking.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// Matomo event tracking via data attributes.
//
// CLICK TRACKING
// Add `data-matomo-click="Category, Action, Name"` to any element to track
// clicks as Matomo events. The Name segment is optional.
//
// Examples:
// <a href="/file.pdf" data-matomo-click="Downloads, PDF Click, My Paper">Download</a>
// <button data-matomo-click="Search, Boolean Toggle">AND/OR</button>
//
// Event delegation on `document` means this works for elements loaded
// asynchronously (Turbo frames, content-loader, etc.) without re-binding.
//
// SEEN TRACKING
// Add `data-matomo-seen="Category, Action, Name"` to any element to fire a
// Matomo event when that element becomes visible in the viewport. The Name
// segment is optional. Each element fires at most once per page load.
// Works for elements present on initial page load and for elements injected
// later by Turbo frames or async content loaders.
//
// Examples:
// <div data-matomo-seen="Impressions, Result Card, Alma">...</div>
// <a data-matomo-seen="Promotions, Banner Shown">...</a>
//
// DYNAMIC VALUES ({{...}} interpolation)
// Wrap a helper name in double curly braces anywhere inside a segment to have
// it replaced with the return value of that function at tracking time. Helpers
// must be registered on `window.MatomoHelpers` (see bottom of this file).
// Multiple tokens in one segment are supported.
//
// Examples:
// <h2 data-matomo-seen="Search, Results Found, Tab: {{getActiveTabName}}">...</h2>
// <a data-matomo-click="Nav, {{getActiveTabName}} Link Click">...</a>

// ---------------------------------------------------------------------------
// Shared helper
// ---------------------------------------------------------------------------

// Parse a "Category, Action, Name" attribute string and push a trackEvent call
// to the Matomo queue. Name is optional; returns early if fewer than 2 parts.
// `context` is the DOM element that triggered the event; it is forwarded to
// every helper so functions like getElementText can reference it.
function pushMatomoEvent(raw, context) {

// Split on commas, trim whitespace from each part, drop any empty strings.
const parts = (raw || "").split(",").map((s) => s.trim()).filter(Boolean);
// Matomo requires at least a Category and an Action.
if (parts.length < 2) return;

// Resolve any {{functionName}} tokens by calling the matching helper.
// Each token is replaced in-place, so it can appear anywhere in a segment.
// The context element is passed as the first argument so helpers can
// inspect the element that triggered the event (e.g. getElementText).
const helpers = window.MatomoHelpers || {};
const resolved = parts.map((part) =>
part.replace(/\{\{(\w+)\}\}/g, (_, fnName) => {
const fn = helpers[fnName];
// Call the function if it exists; otherwise leave the token as-is.
return (typeof fn === "function") ? fn(context) : `{{${fnName}}}`;
})
);

// Destructure into named variables; `name` will be undefined if not provided.
const [category, action, name] = resolved;

// Ensure _paq exists even if the Matomo snippet hasn't loaded yet
// (e.g. in development). Matomo will replay queued calls once it initialises.
window._paq = window._paq || [];
const payload = ["trackEvent", category, action];
if (name) payload.push(name);
window._paq.push(payload);
}

// ---------------------------------------------------------------------------
// Click tracking
// ---------------------------------------------------------------------------

// Attach a single click listener to the entire document using the capture
// phase (third argument { capture: true }). Capture phase fires top-down
// before any bubble-phase listeners, which guarantees helpers like
// getActiveTabName() read pre-click DOM state before other listeners
// (e.g. loading_spinner.js's swapTabs) synchronously update it.
document.addEventListener("click", (event) => {
// Walk up the DOM from the clicked element to find the nearest ancestor
// (or the element itself) that has a data-matomo-click attribute.
const el = event.target.closest("[data-matomo-click]");
// If no such element exists in the ancestor chain, ignore this click.
if (!el) return;

// Only fire when the click originated from an interactive element (link,
// button, or form control). This allows data-matomo-click to be placed on
// a container and track only meaningful interactions within it, ignoring
// clicks on surrounding text, padding, or decorative children.
const interactive = event.target.closest("a, button, input, select, textarea");
if (!interactive) return;

// Confirm the interactive element is actually inside the tracked container
// (guards against the unlikely case where closest() finds an ancestor of el).
if (!el.contains(interactive) && el !== interactive) return;

// Pass the interactive element as context so helpers like getElementText
// can read the text of the specific link or button that was clicked.
pushMatomoEvent(el.dataset.matomoClick, interactive);
}, { capture: true });

// ---------------------------------------------------------------------------
// Seen tracking
// ---------------------------------------------------------------------------

// Track elements already registered with the viewport observer to avoid
// double-registration if the same node is added to the DOM more than once.
const seenRegistered = new WeakSet();

// Fire a Matomo event when an observed element intersects the viewport.
// Unobserve immediately so the event fires at most once per element.
const viewportObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
// Stop watching — we only want to fire once per element.
viewportObserver.unobserve(entry.target);
pushMatomoEvent(entry.target.dataset.matomoSeen, entry.target);
});
});

// Register a single element with the viewport observer if it carries
// data-matomo-seen and hasn't been registered yet.
function registerIfSeen(el) {
// Only process element nodes (not text nodes, comments, etc.).
if (el.nodeType !== Node.ELEMENT_NODE) return;
// Skip if already registered.
if (seenRegistered.has(el)) return;

// Register the element itself if it has the attribute.
if (el.dataset.matomoSeen) {
seenRegistered.add(el);
viewportObserver.observe(el);
}

// Also register any descendants — content loaders often inject a whole
// subtree at once, so walking deep ensures every marked element is caught.
el.querySelectorAll("[data-matomo-seen]").forEach((child) => {
if (seenRegistered.has(child)) return;
seenRegistered.add(child);
viewportObserver.observe(child);
});
}

// Register all elements already present in the DOM on initial page load.
document.querySelectorAll("[data-matomo-seen]").forEach((el) => {
seenRegistered.add(el);
viewportObserver.observe(el);
});

// Watch for any new nodes added to the DOM after initial load.
// MutationObserver fires synchronously after each DOM mutation, so it catches
// both Turbo frame renders and content-loader replacements immediately.
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// Each mutation record lists the nodes that were added in this batch.
mutation.addedNodes.forEach(registerIfSeen);
});
});

// Observe the entire document subtree so no async insertion is missed.
observer.observe(document.body, { childList: true, subtree: true });

// Turbo Drive navigation replaces document.body with a brand new element,
// which detaches the MutationObserver from the old body. Re-scan and
// re-attach on every turbo:load so full-page navigations are handled.
// (Turbo frame and content-loader updates are covered by the observer above
// because they mutate within the existing body rather than replacing it.)
document.addEventListener("turbo:load", () => {
// Register any seen elements that arrived with the navigation.
document.querySelectorAll("[data-matomo-seen]").forEach((el) => {
if (seenRegistered.has(el)) return;
seenRegistered.add(el);
viewportObserver.observe(el);
});

// Re-attach the MutationObserver to the new document.body instance.
observer.observe(document.body, { childList: true, subtree: true });
});


// ===========================================================================
// HELPER FUNCTIONS
// Custom JS to enhance the payload information we provide to Matomo.
// ===========================================================================

// ---------------------------------------------------------------------------
// Get the name of the active search results tab, if any.
// ---------------------------------------------------------------------------
function getActiveTabName() {
var tabs = document.querySelector('#tabs');
if (!tabs) {
return "None"; // #tabs not found
}

var activeAnchor = tabs.querySelector('a.active');
if (!activeAnchor) {
return "None"; // no active tab
}

return activeAnchor.textContent.trim();
}

// ---------------------------------------------------------------------------
// Get the visible text of the element that triggered the event.
// For click tracking this is the interactive element (link, button, etc.).
// For seen tracking this is the element carrying data-matomo-seen.
// Returns an empty string if no context element is available.
// ---------------------------------------------------------------------------
function getElementText(el) {
if (!el) return "";
return el.textContent.trim();
}

// ---------------------------------------------------------------------------
// Get the current results page number from the `page` URL parameter.
// Returns "1" when the parameter is absent (the first page has no page param).
// ---------------------------------------------------------------------------
function getCurrentResultsPage() {
const params = new URLSearchParams(window.location.search);
return params.get("page") || "1";
}

// ---------------------------------------------------------------------------
// Register helpers on window.MatomoHelpers so they can be referenced with the
// {{functionName}} syntax in data-matomo-seen and data-matomo-click attributes.
// Add new helpers here as needed.
// ---------------------------------------------------------------------------
window.MatomoHelpers = {
getActiveTabName,
getElementText,
getCurrentResultsPage,
};
2 changes: 1 addition & 1 deletion app/views/search/_results_callouts.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div class="callout-wrapper">
<div class="callout-wrapper" data-matomo-seen="Escape Hatches, Suggestions Test, Testing">
<% if ['cdi', 'alma', 'all'].include?(@active_tab) %>
<%= render partial: "results_callout_component", locals: {
fa_name: 'magnifying-glass',
Expand Down
6 changes: 3 additions & 3 deletions app/views/search/_results_sidebar.html.erb
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
<aside id="results-sidebar">
<%= render partial: "results_callouts" %>
<div class="core-sidebar-items">
<div class="core-sidebar-items" data-matomo-click="Escape Hatches, Sidebar Link Engaged, Link: {{getElementText}}">
<div>
<i class="fa-light fa-message"></i>
<div>
<h4>Were these results useful?</h4>
<p>Your feedback can help shape the quality and relevance of our search results.</p>
<a href="https://libraries.mit.edu/use-feedback">Send feedback</a>
<a data-matomo-seen="Escape Hatches, Sidebar Link Seen, Link: {{getElementText}}" href="https://libraries.mit.edu/use-feedback">Send feedback</a>
</div>
</div>
<div>
<i class="fa-light fa-user"></i>
<div>
<h4>Need help?</h4>
<p>Via chat, email, or consultations, we'll help you find and access what you need.</p>
<a href="https://libraries.mit.edu/ask/">Ask us</a>
<a data-matomo-seen="Escape Hatches, Sidebar Link Seen, Link: {{getElementText}}" href="https://libraries.mit.edu/ask/">Ask us</a>
</div>
</div>
</div>
Expand Down
3 changes: 1 addition & 2 deletions app/views/search/_source_tabs.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!-- Tab Navigation -->
<nav id="tabs" class="tab-navigation" aria-label="Result type navigation">
<nav id="tabs" class="tab-navigation" aria-label="Result type navigation" data-matomo-click="Navigation, Tabs Engaged, Current Tab: {{getActiveTabName}}; Navigated to: {{getElementText}}">
<ul class="primary">
<li><%= link_to_tab("All") %></li>

Expand All @@ -22,4 +22,3 @@
</nav>

<%= javascript_include_tag "source_tabs" %>

4 changes: 2 additions & 2 deletions app/views/search/results.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

<% elsif @results.present? && @errors.blank? %>

<h2 class="results-context"><%= results_summary(@pagination[:hits]) %></h2>
<h2 class="results-context" data-matomo-seen="Search, Results Found, Tab: {{getActiveTabName}}"><%= results_summary(@pagination[:hits]) %></h2>
<p class="results-context-description"><%= tab_description %></p>
<div id="results-layout-wrapper">
<main id="results">
Expand All @@ -41,7 +41,7 @@
<% elsif @errors.blank? %>

<div id="results-layout-wrapper">
<main id="results" class="no-results-container">
<main id="results" class="no-results-container" data-matomo-seen="Search, No Results Found, Tab: {{getActiveTabName}}">
<%= render partial: "no_results" %>

<%# Note `results_callouts` is also displayed in results and no errors condition above %>
Expand Down
4 changes: 2 additions & 2 deletions app/views/tacos/analyze.html.erb
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<% if @suggestions.present? && !Feature.enabled?(:geodata) %>
<div id="hint" aria-live="polite">
<div id="hint" aria-live="polite" data-matomo-seen="Interventions, Intervention Shown" data-matomo-click="Interventions, Intervention Engaged, Link: {{getElementText}}" data-track-content data-content-name="Intervention">
<aside class="mitlib-suggestion-panel">
<div class="panel-content">
<p class="panel-type">Suggested resource</p>
<h3><%= @suggestions['title'] %></h3>
<h3 data-content-piece="<%= @suggestions['title'] %>"><%= @suggestions['title'] %></h3>
<p>
<%= link_to(
@suggestions['url'], @suggestions['url']
Expand Down
1 change: 1 addition & 0 deletions config/importmap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

pin "application", preload: true
pin "loading_spinner", preload: true
pin "matomo_tracking", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
Expand Down
Loading