Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
64 changes: 64 additions & 0 deletions src/javascript/Logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,70 @@ describe('Logger', () => {
expect(event!.get('ep.page_path')).toBe('/custom');
});

it('refreshes the dl and dt params via refreshPageParams()', async () => {
const originalUrl = location.href;
const originalTitle = document.title;

try {
const logger = new Logger();
await logger.event('event_1');

const firstBeacon = lastBeacon();
expect(firstBeacon.pageParams.get('dl')).toBe(originalUrl);

// Simulate an SPA navigation.
history.pushState({}, '', '/spa-page/');
document.title = 'SPA Page — Site Name';

await logger.refreshPageParams();
await logger.event('event_2');

const beacon = lastBeacon();
expect(new URL(beacon.pageParams.get('dl')!).pathname).toBe('/spa-page/');
expect(beacon.pageParams.get('dt')).toBe('SPA Page');

// Events logged after the refresh start a new beacon, and the beacon
// containing the pre-navigation events is left scheduled (not
// aborted), so those events keep the params from when they were
// logged.
expect(beacon.pageParams.get('_s')).toBe('2');
expect(beacon.events.map((e) => e.get('en'))).toEqual(['event_2']);
expect(firstBeacon.init.signal!.aborted).toBe(false);
} finally {
history.replaceState({}, '', originalUrl);
document.title = originalTitle;
}
});

it('keeps in-flight events in the pre-refresh beacon group', async () => {
const logger = new Logger();

// Add a presend dependency that doesn't resolve until later, so
// events are held in-flight (not yet queued) when the refresh occurs.
let resolveDependency!: () => void;
logger.awaitBeforeSending(
new Promise<void>((r) => (resolveDependency = r)),
);

const event1Done = logger.event('event_1');
const refreshDone = logger.refreshPageParams();
const event2Done = logger.event('event_2');

resolveDependency();
await Promise.all([event1Done, refreshDone, event2Done]);

// event_1 was logged before the refresh, so it must remain in the
// first beacon group, which must not be aborted.
const [, firstInit] = fetchLaterMock.mock.calls.at(0)!;
expect(String(firstInit!.body)).toContain('en=event_1');
expect(firstInit!.signal!.aborted).toBe(false);

// event_2 was logged after the refresh, so it starts a new beacon.
const beacon = lastBeacon();
expect(beacon.pageParams.get('_s')).toBe('2');
expect(beacon.events.map((e) => e.get('en'))).toEqual(['event_2']);
});

it('batches queued events, aborting the superseded beacon', async () => {
const logger = new Logger();
await logger.event('event_1');
Expand Down
35 changes: 34 additions & 1 deletion src/javascript/Logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class Logger {

this._pageParams = {
dl: location.href,
dt: document.title.replace(/\s+—.*$/, ''),
dt: getPageTitle(),
de: document.characterSet,
ul: navigator.language.toLowerCase(),
vp: `${innerWidth}x${innerHeight}`,
Expand Down Expand Up @@ -168,6 +168,32 @@ export class Logger {
Object.assign(this._eventParams, params);
}

/**
* Updates the page params that can change after an SPA navigation
* (document location and title) to reflect the current page.
*/
async refreshPageParams() {
const dl = location.href;
const dt = getPageTitle();

// Wait for any in-flight events to be queued first. Since `event()`
// awaits these same dependencies before queuing, all events logged
// before this method was called are queued before it continues.
await Promise.all(this._presendDependencies);

// If any events are queued, start a new beacon and leave the pending
// one scheduled (not aborted), so already-logged events are sent with
// the page params that were current when they were logged.
if (this._eventQueue.size > 0) {
this._sendCount++;
this._eventQueue.clear();
delete this._fetchLaterResult;
delete this._fetchLaterController;
}
this._pageParams.dl = dl;
this._pageParams.dt = dt;
}

/**
* Logs an event.
*/
Expand Down Expand Up @@ -404,6 +430,13 @@ function toQueryString(params: Params): string {
.join('&');
}

/**
* Gets the document title with the site name suffix removed.
*/
function getPageTitle(): string {
return document.title.replace(/\s+—.*$/, '');
}

/**
* Gets the referrer of the page.
*/
Expand Down
8 changes: 8 additions & 0 deletions src/javascript/content-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ const executeContainerScripts = () => {
*/
const trackPageview = async (url: URL) => {
log.set({page_path: url.pathname});

// Update the page-level `dl` and `dt` params to reflect the new URL and
// title. This runs after `loadPage()`, so `document.title` has already
// been updated by the partial's container script. Events logged before
// this point (e.g. `route_transition`) remain in a beacon that keeps
// the pre-navigation `dl`/`dt`.
await log.refreshPageParams();

log.event('page_view', {
navigation_type: 'route_change',
visibility_state: document.visibilityState,
Expand Down
24 changes: 22 additions & 2 deletions test/e2e/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,8 @@ describe('log', function () {

await browser.waitUntil(() => {
return beaconsContain({
// After an SPA navigation, `dl` should reflect the new URL.
'dl': new RegExp(`${articles[0]?.path}$`),
'en': 'page_view',
'ep.page_path': articles[0]?.path || '',
'ep.original_page_path': '/',
Expand Down Expand Up @@ -344,19 +346,37 @@ describe('log', function () {
return title.includes(pages[1]?.title || '');
});

// Each SPA navigation finalizes the pending beacon (so its events
// keep the pre-navigation page params) and its events are not re-sent
// in later beacons, so wait for each navigation's beacon to be
// received before navigating again.
await browser.waitUntil(() => {
return beaconsContain({
'en': 'page_view',
'ep.page_path': pages[1]?.path || '',
'ep.navigation_type': 'route_change',
});
});

// Click 'back' to the home page

await clearBeacons();
await browser.back();
// await browser.pause(1000);
await browser.waitUntil(async () => {
const title = await browser.getTitle();
return title.includes(pages[0]?.title || '');
});

await browser.waitUntil(() => {
return beaconsContain({
'en': 'page_view',
'ep.page_path': pages[0]?.path || '',
'ep.navigation_type': 'route_change',
});
});

// Click 'forward' to the articles page

await clearBeacons();
await browser.forward();
// await browser.pause(1000);
await browser.waitUntil(async () => {
Expand Down