-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmatomo_tracking.js
More file actions
236 lines (206 loc) · 10.3 KB
/
matomo_tracking.js
File metadata and controls
236 lines (206 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
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,
};