docbrown/app/javascript/packs/feedEvents.js
PJ 3f7d1fbc1a
Create feed impression and click events (#20043)
* getting started: model setup

* basic controller action & spec

* somewhat working click events

* pick up on impressions

* there was an attempt at batching without duplicates

* (better) bulk upsert service

* fix specs?

* touch up feed events JS

* end-to-end specs??

* workers wip

* fix failing user delete spec
2023-09-11 13:32:36 +01:00

181 lines
6.1 KiB
JavaScript

const MAX_BATCH_SIZE = 20; // Maybe adjust?
const AUTOSEND_PERIOD = 5 * 1000;
const VISIBLE_THRESHOLD = 0.25;
const tracker = {
queue: [],
processInterval: null,
observer: new IntersectionObserver(trackFeedImpressions, {
root: null,
rootMargin: '0px',
threshold: VISIBLE_THRESHOLD,
}),
beaconEnabled: true,
nextFeedPosition: null,
};
window.observeFeedElements = observeFeedElements;
/**
* Sets up the feed events tracker.
* Called every time posts are inserted into the feed.
*/
export function observeFeedElements() {
const feedContainer = document.getElementById('index-container');
// Default container for Preact-rendered home feed
const feedItemsRoot = document.getElementById('rendered-article-feed');
if (!(feedContainer && feedItemsRoot)) return;
const { feedCategoryClick, feedCategoryImpression, feedContextType } =
feedContainer.dataset;
// Reset all relevant state
tracker.categoryClick = feedCategoryClick;
tracker.categoryImpression = feedCategoryImpression;
tracker.contextType = feedContextType;
tracker.processInterval ||= setInterval(submitEventsBatch, AUTOSEND_PERIOD);
tracker.observer.disconnect();
tracker.nextFeedPosition = 1;
findAndTrackFeedItems(feedItemsRoot);
ensureQueueIsClearedBeforeExit();
}
/**
* Given how often it may be called, and the need to assign the correct positions
* in the feed, we take a more efficient approach to finding feed items than
* querying the entire DOM.
* This manual recursion (and a good chunk of `useListNavigation.js` would be
* unnecessary if `initScrolling.js` is updated to *not* create a waterfall of elements.
* @param {HTMLElement} root The (current) element with feed items as children.
*/
function findAndTrackFeedItems(root) {
Array.from(root.children).forEach((/** @type HTMLElement */ element) => {
if (element.classList.contains('paged-stories')) {
// This was inserted by `initScrolling, and will contain feed items within.
findAndTrackFeedItems(element);
} else if (element.dataset?.feedContentId) {
element.dataset.feedPosition = tracker.nextFeedPosition;
element.addEventListener('click', trackFeedClickListener, true);
tracker.observer.observe(element);
tracker.nextFeedPosition += 1;
}
});
}
/**
* Attempts to send any pending queued events before state is lost - e.g. when
* navigating to a different page, or (on mobile) switching to a different app
* (which can eventually cause the browser tab to be discarded in the background
* at the operating system's discretion).
* Both the unload event and the page visibility API are used as each one covers
* some gaps that the other does not.
*/
function ensureQueueIsClearedBeforeExit() {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState == 'hidden') submitEventsBatch();
});
window.addEventListener('beforeunload', submitEventsBatch);
}
/**
* Collects feed impressions, counted as at least a quarter of the article card
* coming into view. This is typically enough to at least see the title and/or
* a significant portion of the cover image.
* @param {IntersectionObserverEntry[]} entries
*/
function trackFeedImpressions(entries) {
entries.forEach((entry) => {
// At least a quarter of the card is in view; not quite enough to read the
// title for many articles, but it'll do
if (entry.isIntersecting && entry.intersectionRatio >= VISIBLE_THRESHOLD) {
queueMicrotask(() => {
const post = entry.target;
if (!post.dataset.impressionRecorded) {
queueEvent(post, tracker.categoryImpression);
post.dataset.impressionRecorded = true;
}
});
}
});
}
/**
* Sends click events to the server immediately along with any currently-batched
* events.
* These may not necessarily be clicks that open the article (e.g. the user may
* have clicked on the author's profile image).
* TODO: Only track link-opening clicks instead?
* @param {MouseEvent} event
*/
function trackFeedClickListener(event) {
const post = event.currentTarget;
if (!post.dataset.clickRecorded) {
queueEvent(post, tracker.categoryClick);
post.dataset.clickRecorded = true;
submitEventsBatch();
}
}
function queueEvent(post, category) {
const { feedContentId, feedPosition } = post.dataset;
tracker.queue.push({
article_id: feedContentId,
article_position: feedPosition,
category,
context_type: tracker.contextType,
});
if (tracker.queue.length >= MAX_BATCH_SIZE) {
submitEventsBatch();
}
}
/**
* Sends a batch of feed events to the server.
* Note: requests made with `navigator.sendBeacon` have greater guarantees to
* actually complete than regular fetch requests with the `keepalive` property
* set. However, the former is a bit tedious to implement and is often
* inadvertently blocked by users (e.g. via extensions like uBlock). So a fallback
* to `fetch` is included to cover that.
*/
function submitEventsBatch() {
if (tracker.queue.length === 0) return;
const tokenMeta = document.querySelector("meta[name='csrf-token']");
const authenticity_token = tokenMeta?.getAttribute('content');
if (tracker.beaconEnabled) {
// The Beacon API doesn't actually let you set headers, so we set the content
// type and CSRF token within the body itself (the browser will recognise the
// former, and Rails will recognise the latter)
const data = new Blob(
[JSON.stringify({ authenticity_token, feed_events: tracker.queue })],
{ type: 'application/json' },
);
// `sendBeacon` returns true if sending a beacon worked, and false otherwise.
tracker.beaconEnabled = navigator.sendBeacon('/feed_events', data);
} else {
fallbackRequest(authenticity_token);
}
tracker.queue = [];
}
function fallbackRequest(authenticity_token) {
window
.fetch('/feed_events', {
method: 'POST',
headers: {
'X-CSRF-Token': authenticity_token,
'Content-Type': 'application/json',
},
body: JSON.stringify({ feed_events: tracker.queue }),
credentials: 'same-origin',
keepalive: true,
})
.catch((error) => console.error(error));
}