Skip to content

Commit 8ea4dfd

Browse files
authored
Merge pull request #377 from MITLibraries/use-404-matomo-code
USE-404: Implements Matomo Analytics in USE UI
2 parents 7983550 + 63c4568 commit 8ea4dfd

20 files changed

+346
-47
lines changed

app/helpers/search_helper.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def format_highlight_label(field_name)
1919

2020
def link_to_result(result)
2121
if result[:source_link].present?
22-
link_to(result[:title], result[:source_link])
22+
link_to(result[:title], result[:source_link], data: { content_piece: 'Result Title' })
2323
else
2424
result[:title]
2525
end
@@ -47,7 +47,8 @@ def link_to_tab(target, label = nil)
4747
end
4848

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

5354
# 'Coverage' and 'issued' seem to be the most prevalent types; 'coverage' is typically formatted as

app/javascript/application.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import "@hotwired/turbo-rails"
33
import "controllers"
44
import "loading_spinner"
5+
import "matomo_tracking"
56

67
// Show the progress bar after 200 milliseconds, not the default 500
78
Turbo.config.drive.progressBarDelay = 200;

app/javascript/matomo_tracking.js

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
// BACKGROUND
2+
// This file implements helper functions that make it very straigtforward to track Matomo events in code.
3+
// These functions use matomo's _paq.push() API to send events directly to Matomo.
4+
// This does NOT use Tag Manager, but works in core matomo or Tag Manager environments.
5+
//
6+
// CLICK TRACKING
7+
// Add `data-matomo-click="Category, Action, Name"` to any element to track
8+
// clicks as Matomo events. The Name segment is optional. We have a convention
9+
// of using semicolons inside Name to divide multiple values.
10+
//
11+
// This tracking can be placed directly on an interactive element.
12+
// Tracking also works if placed on a container. The script will look
13+
// for any interactive elements inside the container.
14+
//
15+
// Interactive elements are defined as elements: a, button, input, select, textarea
16+
//
17+
// Examples:
18+
// <a href="/file.pdf" data-matomo-click="Downloads, PDF Click, My Paper">Download</a>
19+
// <button data-matomo-click="Search, Boolean Toggle">AND/OR</button>
20+
//
21+
// Event delegation on `document` means this works for elements loaded
22+
// asynchronously (Turbo frames, content-loader, etc.) without re-binding.
23+
//
24+
// SEEN TRACKING
25+
// Add `data-matomo-seen="Category, Action, Name"` to any element to fire a
26+
// Matomo event when that element becomes visible in the viewport. The Name
27+
// segment is optional. We have a convention of using semicolons inside Name
28+
// to divide multiple values. Each element fires at most once per page load.
29+
// Works for elements present on initial page load and for elements injected
30+
// later by Turbo frames or async content loaders.
31+
//
32+
// Examples:
33+
// <div data-matomo-seen="Impressions, Result Card, Alma">...</div>
34+
// <a data-matomo-seen="Promotions, Banner Shown">...</a>
35+
//
36+
// DYNAMIC VALUES
37+
// Wrap a helper name in double curly braces anywhere inside a segment to have
38+
// it replaced with the return value of that function at tracking time. Helpers
39+
// must be registered on `window.MatomoHelpers` (see bottom of this file).
40+
// Multiple tokens in one segment are supported.
41+
//
42+
// Convention is to only use these in the "Name" segment to provide more context.
43+
// Avoid using inside Category or Action to improve the hierarchy of Matomo dashboards.
44+
//
45+
// Examples:
46+
// <h2 data-matomo-seen="Search, Results Found, Tab: {{getActiveTabName}}">...</h2>
47+
// <a data-matomo-click="Nav, Link Click, Link: {{getElementText}}">...</a>
48+
49+
// ---------------------------------------------------------------------------
50+
// Shared helper
51+
// ---------------------------------------------------------------------------
52+
53+
// Parse a "Category, Action, Name" attribute string and push a trackEvent call
54+
// to the Matomo queue. Name is optional; returns early if fewer than 2 parts.
55+
// `context` is the DOM element that triggered the event; it is forwarded to
56+
// every helper so functions like getElementText can reference it.
57+
function pushMatomoEvent(raw, context) {
58+
59+
// Split on commas, trim whitespace from each part, drop any empty strings.
60+
const parts = (raw || "").split(",").map((s) => s.trim()).filter(Boolean);
61+
// Matomo requires at least a Category and an Action.
62+
if (parts.length < 2) return;
63+
64+
// Resolve any {{functionName}} tokens by calling the matching helper.
65+
// Each token is replaced in-place, so it can appear anywhere in a segment.
66+
// The context element is passed as the first argument so helpers can
67+
// inspect the element that triggered the event (e.g. getElementText).
68+
const helpers = window.MatomoHelpers || {};
69+
const resolved = parts.map((part) =>
70+
part.replace(/\{\{(\w+)\}\}/g, (_, fnName) => {
71+
const fn = helpers[fnName];
72+
// Call the function if it exists; otherwise leave the token as-is.
73+
return (typeof fn === "function") ? fn(context) : `{{${fnName}}}`;
74+
})
75+
);
76+
77+
// Destructure into named variables; `name` will be undefined if not provided.
78+
const [category, action, name] = resolved;
79+
80+
// Ensure _paq exists even if the Matomo snippet hasn't loaded yet
81+
// (e.g. in development). Matomo will replay queued calls once it initialises.
82+
window._paq = window._paq || [];
83+
const payload = ["trackEvent", category, action];
84+
if (name) payload.push(name);
85+
window._paq.push(payload);
86+
}
87+
88+
// ---------------------------------------------------------------------------
89+
// Click tracking
90+
// ---------------------------------------------------------------------------
91+
92+
// Attach a single click listener to the entire document using the capture
93+
// phase (third argument { capture: true }). Capture phase fires top-down
94+
// before any bubble-phase listeners, which guarantees helpers like
95+
// getActiveTabName() read pre-click DOM state before other listeners
96+
// (e.g. loading_spinner.js's swapTabs) synchronously update it.
97+
document.addEventListener("click", (event) => {
98+
// Walk up the DOM from the clicked element to find the nearest ancestor
99+
// (or the element itself) that has a data-matomo-click attribute.
100+
const el = event.target.closest("[data-matomo-click]");
101+
// If no such element exists in the ancestor chain, ignore this click.
102+
if (!el) return;
103+
104+
// Only fire when the click originated from an interactive element (link,
105+
// button, or form control). This allows data-matomo-click to be placed on
106+
// a container and track only meaningful interactions within it, ignoring
107+
// clicks on surrounding text, padding, or decorative children.
108+
const interactive = event.target.closest("a, button, input, select, textarea");
109+
if (!interactive) return;
110+
111+
// Confirm the interactive element is actually inside the tracked container
112+
// (guards against the unlikely case where closest() finds an ancestor of el).
113+
if (!el.contains(interactive) && el !== interactive) return;
114+
115+
// Pass the interactive element as context so helpers like getElementText
116+
// can read the text of the specific link or button that was clicked.
117+
pushMatomoEvent(el.dataset.matomoClick, interactive);
118+
}, { capture: true });
119+
120+
// ---------------------------------------------------------------------------
121+
// Seen tracking
122+
// ---------------------------------------------------------------------------
123+
124+
// Track elements already registered with the viewport observer to avoid
125+
// double-registration if the same node is added to the DOM more than once.
126+
const seenRegistered = new WeakSet();
127+
128+
// Fire a Matomo event when an observed element intersects the viewport.
129+
// Unobserve immediately so the event fires at most once per element.
130+
const viewportObserver = new IntersectionObserver((entries) => {
131+
entries.forEach((entry) => {
132+
if (!entry.isIntersecting) return;
133+
// Stop watching — we only want to fire once per element.
134+
viewportObserver.unobserve(entry.target);
135+
pushMatomoEvent(entry.target.dataset.matomoSeen, entry.target);
136+
});
137+
});
138+
139+
// Register a single element with the viewport observer if it carries
140+
// data-matomo-seen and hasn't been registered yet.
141+
function registerIfSeen(el) {
142+
// Only process element nodes (not text nodes, comments, etc.).
143+
if (el.nodeType !== Node.ELEMENT_NODE) return;
144+
// Skip if already registered.
145+
if (seenRegistered.has(el)) return;
146+
147+
// Register the element itself if it has the attribute.
148+
if (el.dataset.matomoSeen) {
149+
seenRegistered.add(el);
150+
viewportObserver.observe(el);
151+
}
152+
153+
// Also register any descendants — content loaders often inject a whole
154+
// subtree at once, so walking deep ensures every marked element is caught.
155+
el.querySelectorAll("[data-matomo-seen]").forEach((child) => {
156+
if (seenRegistered.has(child)) return;
157+
seenRegistered.add(child);
158+
viewportObserver.observe(child);
159+
});
160+
}
161+
162+
// Register all elements already present in the DOM on initial page load.
163+
document.querySelectorAll("[data-matomo-seen]").forEach((el) => {
164+
seenRegistered.add(el);
165+
viewportObserver.observe(el);
166+
});
167+
168+
// ---------------------------------------------------------------------------
169+
// Matomo native content tracking
170+
// ---------------------------------------------------------------------------
171+
172+
// Core Matomo includes "Content Tracking"
173+
// You can use these attributes to track content impressions and interactions.
174+
// See documentation for details (https://matomo.org/faq/how-to/how-do-i-markup-content-for-content-tracking/)
175+
// This file only ensures that these attributes are processed for asynchronously-inserted content.
176+
//
177+
// Attributes for content tracking (native to Matomo):
178+
// * data-track-content - Defines a content block to track
179+
// * data-content-name - Gives a name for the content block (appears in Matomo Dashboard)
180+
// * data-content-piece - Names a piece of content to track interactions on (appears in Matomo Dashboard)
181+
// * data-content-target - Specifies the target of the content interaction (appears in Matomo Dashboard)
182+
183+
// Matomo's built-in content tracking (data-track-content / data-content-name /
184+
// data-content-piece) only scans the DOM at page load. For content injected
185+
// asynchronously (e.g. by the content-loader Stimulus controller), we must
186+
// manually notify Matomo by calling trackContentImpressionsWithinNode on the
187+
// newly-added node.
188+
function trackContentImpressionsIfPresent(el) {
189+
if (el.nodeType !== Node.ELEMENT_NODE) return;
190+
// Check the element itself or any descendant for data-track-content.
191+
const hasContent =
192+
el.hasAttribute("data-track-content") ||
193+
el.querySelector("[data-track-content]") !== null;
194+
if (!hasContent) return;
195+
196+
window._paq = window._paq || [];
197+
// Ask Matomo to scan the subtree for content impressions.
198+
window._paq.push(["trackContentImpressionsWithinNode", el]);
199+
}
200+
201+
// Watch for any new nodes added to the DOM after initial load.
202+
// MutationObserver fires synchronously after each DOM mutation, so it catches
203+
// both Turbo frame renders and content-loader replacements immediately.
204+
const observer = new MutationObserver((mutations) => {
205+
mutations.forEach((mutation) => {
206+
// Each mutation record lists the nodes that were added in this batch.
207+
mutation.addedNodes.forEach((node) => {
208+
registerIfSeen(node);
209+
trackContentImpressionsIfPresent(node);
210+
});
211+
});
212+
});
213+
214+
// Observe the entire document subtree so no async insertion is missed.
215+
observer.observe(document.body, { childList: true, subtree: true });
216+
217+
// Turbo Drive navigation replaces document.body with a brand new element,
218+
// which detaches the MutationObserver from the old body. Re-scan and
219+
// re-attach on every turbo:load so full-page navigations are handled.
220+
// (Turbo frame and content-loader updates are covered by the observer above
221+
// because they mutate within the existing body rather than replacing it.)
222+
document.addEventListener("turbo:load", () => {
223+
// Register any seen elements that arrived with the navigation.
224+
document.querySelectorAll("[data-matomo-seen]").forEach((el) => {
225+
if (seenRegistered.has(el)) return;
226+
seenRegistered.add(el);
227+
viewportObserver.observe(el);
228+
});
229+
230+
// Re-attach the MutationObserver to the new document.body instance.
231+
observer.observe(document.body, { childList: true, subtree: true });
232+
});
233+
234+
235+
// ===========================================================================
236+
// HELPER FUNCTIONS
237+
// Custom JS to enhance the payload information we provide to Matomo.
238+
// ===========================================================================
239+
240+
// ---------------------------------------------------------------------------
241+
// Get the name of the active search results tab, if any.
242+
// ---------------------------------------------------------------------------
243+
function getActiveTabName() {
244+
var tabs = document.querySelector('#tabs');
245+
if (!tabs) {
246+
return "None"; // #tabs not found
247+
}
248+
249+
var activeAnchor = tabs.querySelector('a.active');
250+
if (!activeAnchor) {
251+
return "None"; // no active tab
252+
}
253+
254+
return activeAnchor.textContent.trim();
255+
}
256+
257+
// ---------------------------------------------------------------------------
258+
// Get the visible text of the element that triggered the event.
259+
// For click tracking this is the interactive element (link, button, etc.).
260+
// For seen tracking this is the element carrying data-matomo-seen.
261+
// Returns an empty string if no context element is available.
262+
// ---------------------------------------------------------------------------
263+
function getElementText(el) {
264+
if (!el) return "";
265+
return el.textContent.trim();
266+
}
267+
268+
// ---------------------------------------------------------------------------
269+
// Get the current results page number from the `page` URL parameter.
270+
// Returns "1" when the parameter is absent (the first page has no page param).
271+
// ---------------------------------------------------------------------------
272+
function getCurrentResultsPage() {
273+
const params = new URLSearchParams(window.location.search);
274+
return params.get("page") || "1";
275+
}
276+
277+
// ---------------------------------------------------------------------------
278+
// Register helpers on window.MatomoHelpers so they can be referenced with the
279+
// {{functionName}} syntax in data-matomo-seen and data-matomo-click attributes.
280+
// Add new helpers here as needed.
281+
// ---------------------------------------------------------------------------
282+
window.MatomoHelpers = {
283+
getActiveTabName,
284+
getElementText,
285+
getCurrentResultsPage,
286+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<% if ENV['GLOBAL_ALERT'] %>
2+
<div class="wrap-notices info layout-band">
3+
<div class="wrap-notice">
4+
<div class="alert alert-global" data-matomo-click="Banner, Link Engaged, Link: {{getElementText}}">
5+
<h1 class="title"><%= sanitize(ENV['GLOBAL_ALERT']) %></h1>
6+
</div>
7+
</div>
8+
</div>
9+
<% end %>

app/views/layouts/_site_header.html.erb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
<div class="institute-bar">
1+
<div class="institute-bar" data-matomo-seen="Navigation, Header Links Seen" data-matomo-click="Navigation, Header Links Engaged, Link: {{getElementText}}">
22
<div class="wrapper">
3-
<a class="link-logo-mit" href="https://www.mit.edu"><span class="sr">MIT Logo</span>
3+
<a class="link-logo-mit" href="https://www.mit.edu" ><span class="sr">MIT Logo</span>
44
<img src="https://cdn.libraries.mit.edu/files/branding/local/mit_logo_std_rgb_white.svg" height="24" alt="MIT logo" >
55
</a>
66
</div>
77
</div>
88
<div class="libraries-header">
99
<div class="wrapper">
10-
<header class="navigation-bar">
10+
<header class="navigation-bar" data-matomo-click="Navigation, Header Links Engaged, Link: {{getElementText}}">
1111
<h1>
1212
<a href="https://libraries.mit.edu/" class="logo-mit-lib">
1313
<span class="sr">MIT Libraries Homepage</span>

app/views/layouts/application.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
</div>
2323
</div>
2424

25-
<footer>
25+
<footer data-matomo-seen="Navigation, Footer Links Seen" data-matomo-click="Navigation, Footer Links Engaged, Link: {{getElementText}}">
2626
<%= render partial: "layouts/libraries_footer" %>
2727
<%= render partial: "layouts/institute_footer" %>
2828
</footer>

app/views/search/_form.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<input id="tab-to-target" type="hidden" name="tab" value="<%= @active_tab %>">
99
<button type="submit" class="btn button-primary">Search</button>
1010
</div>
11-
<div class="search-actions">
11+
<div class="search-actions" data-matomo-click="Search, Advanced Search Engaged">
1212
<a href="https://libraries.mit.edu/search-advanced">Advanced search</a>
1313
</div>
1414
</form>

app/views/search/_pagination.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<% return if @pagination.nil? %>
22
<div id="pagination">
3-
<nav class="pagination-container" aria-label="Pagination">
3+
<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}}">
44
<div class="previous">
55
<%= prev_url(@enhanced_query) %>
66
</div>

0 commit comments

Comments
 (0)