Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 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
4a124b4
Updated to listen for content tracking attributes loaded async
djanelle-mit Mar 17, 2026
a6c601d
removing unnecessary tracking from experiments
djanelle-mit Mar 18, 2026
97ed976
Adding global alert partial from theme to introduce matomo tracking
djanelle-mit Mar 18, 2026
8a4aca8
Batch of tracking on various elements
djanelle-mit Mar 18, 2026
f357887
Removing sidebar event tracking and adding result link click event
djanelle-mit Mar 18, 2026
a0f15e0
Experimenting with content tracking on Sidebar and Suggestions
djanelle-mit Mar 18, 2026
f79edd2
Fix markup bug in pagination
djanelle-mit Mar 18, 2026
964c4e9
Fixed syntax of tracking from comma to semicolon
djanelle-mit Mar 18, 2026
38d6b41
Added denominator to header link tracking event
djanelle-mit Mar 18, 2026
a9e5c4b
Add event tracking to sidebar on top of content tracking
djanelle-mit Mar 18, 2026
b32ea8a
Moved sidebar event tracking and added to callouts
djanelle-mit Mar 18, 2026
8aabb24
using correct helper in pagination
djanelle-mit Mar 19, 2026
e8b5d52
testing browzine link event tracking
djanelle-mit Mar 19, 2026
64150c4
browzine tracking event category changed
djanelle-mit Mar 19, 2026
ab7b572
Libkey link tracking
djanelle-mit Mar 19, 2026
16fceaf
fixed type in footer event tracking
djanelle-mit Mar 19, 2026
5c6fac4
Adding result count event tracking
djanelle-mit Mar 23, 2026
9e5073a
Content and event tracking for results
djanelle-mit Mar 25, 2026
099e87e
Browzine content tracking
djanelle-mit Mar 25, 2026
903dcf7
Renaming escape hatch events and adding search performed event
djanelle-mit Mar 25, 2026
c977136
Added content tracking to result title links
djanelle-mit Mar 25, 2026
24acc28
Fixing syntax error in title content tracking
djanelle-mit Mar 25, 2026
1fe9f30
Added content tracking to Availability LInk
djanelle-mit Mar 25, 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
5 changes: 3 additions & 2 deletions app/helpers/search_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def format_highlight_label(field_name)

def link_to_result(result)
if result[:source_link].present?
link_to(result[:title], result[:source_link])
link_to(result[:title], result[:source_link], data: { content_piece: 'Result Title' })
else
result[:title]
end
Expand All @@ -45,7 +45,8 @@ def link_to_tab(target, label = nil)
end

def view_record(record_id)
link_to 'View full record', record_path(id: record_id), class: 'button button-primary'
link_to 'View full record', record_path(id: record_id), class: 'button button-primary',
data: { content_piece: 'View Full Record' }
end

# 'Coverage' and 'issued' seem to be the most prevalent types; 'coverage' is typically formatted as
Expand Down
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;
262 changes: 262 additions & 0 deletions app/javascript/matomo_tracking.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
// 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.
// ---------------------------------------------------------------------------
// Matomo native content tracking
// ---------------------------------------------------------------------------

// Matomo's built-in content tracking (data-track-content / data-content-name /
// data-content-piece) only scans the DOM at page load. For content injected
// asynchronously (e.g. by the content-loader Stimulus controller), we must
// manually notify Matomo by calling trackContentImpressionsWithinNode on the
// newly-added node.
function trackContentImpressionsIfPresent(el) {
if (el.nodeType !== Node.ELEMENT_NODE) return;
// Check the element itself or any descendant for data-track-content.
const hasContent =
el.hasAttribute("data-track-content") ||
el.querySelector("[data-track-content]") !== null;
if (!hasContent) return;

window._paq = window._paq || [];
// Ask Matomo to scan the subtree for content impressions.
window._paq.push(["trackContentImpressionsWithinNode", 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((node) => {
registerIfSeen(node);
trackContentImpressionsIfPresent(node);
});
});
});

// 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,
};
9 changes: 9 additions & 0 deletions app/views/layouts/_global_alert.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<% if ENV['GLOBAL_ALERT'] %>
<div class="wrap-notices info layout-band">
<div class="wrap-notice">
<div class="alert alert-global" data-matomo-click="Banner, Link Engaged, Link: {{getElementText}}">
<h1 class="title"><%= sanitize(ENV['GLOBAL_ALERT']) %></h1>
</div>
</div>
</div>
<% end %>
6 changes: 3 additions & 3 deletions app/views/layouts/_site_header.html.erb
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<div class="institute-bar">
<div class="institute-bar" data-matomo-seen="Navigation, Header Links Seen" data-matomo-click="Navigation, Header Links Engaged, Link: {{getElementText}}">
<div class="wrapper">
<a class="link-logo-mit" href="https://www.mit.edu"><span class="sr">MIT Logo</span>
<a class="link-logo-mit" href="https://www.mit.edu" ><span class="sr">MIT Logo</span>
<img src="https://cdn.libraries.mit.edu/files/branding/local/mit_logo_std_rgb_white.svg" height="24" alt="MIT logo" >
</a>
</div>
</div>
<div class="libraries-header">
<div class="wrapper">
<header class="navigation-bar">
<header class="navigation-bar" data-matomo-click="Navigation, Header Links Engaged, Link: {{getElementText}}">
<h1>
<a href="https://libraries.mit.edu/" class="logo-mit-lib">
<span class="sr">MIT Libraries Homepage</span>
Expand Down
2 changes: 1 addition & 1 deletion app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
</div>
</div>

<footer>
<footer data-matomo-seen="Navigation, Footer Links Seen" data-matomo-click="Navigation, Footer Links Engaged, Link: {{getElementText}}">
<%= render partial: "layouts/libraries_footer" %>
<%= render partial: "layouts/institute_footer" %>
</footer>
Expand Down
2 changes: 1 addition & 1 deletion app/views/search/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<input id="tab-to-target" type="hidden" name="tab" value="<%= @active_tab %>">
<button type="submit" class="btn button-primary">Search</button>
</div>
<div class="search-actions">
<div class="search-actions" data-matomo-click="Search, Advanced Search Engaged">
<a href="https://libraries.mit.edu/search-advanced">Advanced search</a>
</div>
</form>
Expand Down
2 changes: 1 addition & 1 deletion app/views/search/_pagination.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<% return if @pagination.nil? %>
<div id="pagination">
<nav class="pagination-container" aria-label="Pagination">
<nav class="pagination-container" aria-label="Pagination" data-matomo-seen="Navigation, Pagination Seen, Tab: {{getActiveTabName}}" data-matomo-click="Navigation, Pagination Engaged, Link: {{getElementText}}; Current Page: {{getCurrentResultsPage}}">
<div class="previous">
<%= prev_url(@enhanced_query) %>
</div>
Expand Down
20 changes: 11 additions & 9 deletions app/views/search/_result.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<li class="result use" data-record-id="<%=result[:identifier]%>">
<li class="result use" data-record-id="<%= result[:identifier] %>" data-matomo-seen="Results, Result Seen, Tab: {{getActiveTabName}}" data-content-track data-content-name="Result">
<div class="result-content">
<% if multi_source_tab? && result[:eyebrow].present? %>
<p class="eyebrow"><%= result[:eyebrow] %></p>
Expand All @@ -12,20 +12,22 @@

<p class="pub-info">
<span><%= result[:content_type] %></span>

<% if result[:date_range].present? %>
<span><%= result[:date_range] %></span>
<% else %>
<span>

<span>
<% if result[:date_range].present? %>
<%= result[:date_range] %>
<% else %>

<% result[:dates]&.each do |date| %>
<%= date['value'] if date['kind'] == 'Publication date' %>
<% end %>
</span>
<% end %>

<% end %>
</span>
</p>

<%# unclear why TIMDEX is using contributors and Primo is using creators. Should we normalize this? %>
<% if result[:contributors].present? %>
<% if result[:contributors].present? %>
<span class="sr">Contributors: </span>
<ul class="list-inline truncate-list contributors">
<%= render partial: 'shared/contributors', locals: { contributors: result[:contributors] } %>
Expand Down
Loading
Loading