Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WSTEAM1-1514: Add click tracking at top level #12360

Open
wants to merge 11 commits into
base: latest
Choose a base branch
from
12 changes: 8 additions & 4 deletions src/app/components/ATIAnalytics/canonical/sendBeaconLite.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
const sendBeaconLite = (atiPageViewUrlString: string) => `
var xhr = new XMLHttpRequest();
xhr.open("GET", "${atiPageViewUrlString}", true);
xhr.withCredentials = true;
xhr.send();
function sendBeaconLite (atiPageViewUrlString) {
var xhr = new XMLHttpRequest();
xhr.open("GET", atiPageViewUrlString, true);
xhr.withCredentials = true;
xhr.send();
}

sendBeaconLite("${atiPageViewUrlString}");
`;

export default sendBeaconLite;
11 changes: 9 additions & 2 deletions src/app/components/MostRead/Canonical/Item/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/** @jsx jsx */
import React, { PropsWithChildren } from 'react';
import React, { PropsWithChildren, useContext } from 'react';
import { jsx } from '@emotion/react';
import useClickTrackerHandler from '#hooks/useClickTrackerHandler';
import useClickTrackerHandler, {
LITE_TRACKER_PARAM,
useConstructLiteSiteUrl,
} from '#hooks/useClickTrackerHandler';
import styles from './index.styles';
import {
mostReadListGridProps,
Expand All @@ -15,6 +18,7 @@
} from '../../types';
import { Direction } from '../../../../models/types/global';
import Grid from '../../../../legacy/components/Grid';
import { RequestContext } from '#app/contexts/RequestContext';

Check failure on line 21 in src/app/components/MostRead/Canonical/Item/index.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

`#app/contexts/RequestContext` import should occur before import of `./index.styles`

export const getParentColumns = (columnLayout: ColumnLayout) => {
return columnLayout !== 'oneColumn'
Expand Down Expand Up @@ -46,14 +50,17 @@
size,
eventTrackingData,
}: PropsWithChildren<MostReadLinkProps>) => {
const { isLite } = useContext(RequestContext);
const clickTrackerHandler = useClickTrackerHandler(eventTrackingData);
const liteUrl = useConstructLiteSiteUrl(eventTrackingData);

return (
<div css={getItemCss({ dir, size })} dir={dir}>
<a
css={[styles.link, size === 'default' && styles.defaultLink]}
href={href}
onClick={clickTrackerHandler}
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved
{...(isLite && { [LITE_TRACKER_PARAM]: liteUrl })}
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved
>
{title}
</a>
Expand Down
33 changes: 33 additions & 0 deletions src/app/hooks/useClickTrackerHandler/index.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable no-console */
import { useContext, useCallback, useState } from 'react';

import { buildATIEventTrackUrl } from '#app/components/ATIAnalytics/atiUrl';
import { EventTrackingContext } from '../../contexts/EventTrackingContext';
import useTrackingToggle from '../useTrackingToggle';
import OPTIMIZELY_CONFIG from '../../lib/config/optimizely';
Expand All @@ -9,6 +10,7 @@ import { ServiceContext } from '../../contexts/ServiceContext';
import { isValidClick } from './clickTypes';

const EVENT_TYPE = 'click';
export const LITE_TRACKER_PARAM = 'data-ati-tracking';
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved

const useClickTrackerHandler = (props = {}) => {
const preventNavigation = props?.preventNavigation;
Expand Down Expand Up @@ -136,4 +138,35 @@ const useClickTrackerHandler = (props = {}) => {
);
};

export const useConstructLiteSiteUrl = (props = {}) => {
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved
const eventTrackingContext = useContext(EventTrackingContext);

const componentName = props?.componentName;
const url = props?.url;
const advertiserID = props?.advertiserID;
const format = props?.format;
const detailedPlacement = props?.detailedPlacement;
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved

const { pageIdentifier, platform, producerId, statsDestination } =
eventTrackingContext;

const campaignID = props?.campaignID || eventTrackingContext?.campaignID;

const atiClickTrackingUrl = buildATIEventTrackUrl({
pageIdentifier,
producerId,
platform,
statsDestination,
componentName,
campaignID,
format,
type: EVENT_TYPE,
advertiserID,
url,
detailedPlacement,
});

return atiClickTrackingUrl;
};

export default useClickTrackerHandler;
21 changes: 20 additions & 1 deletion src/app/hooks/useClickTrackerHandler/index.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
import * as serviceContextModule from '../../contexts/ServiceContext';

import pidginData from './fixtureData/tori-51745682.json';
import useClickTrackerHandler from '.';
import useClickTrackerHandler, { useConstructLiteSiteUrl } from '.';

const trackingToggleSpy = jest.spyOn(trackingToggle, 'default');

Expand Down Expand Up @@ -588,3 +588,22 @@ describe('Error handling', () => {
expect(global.fetch).not.toHaveBeenCalled();
});
});

describe('Lite Site - Click tracking', () => {
it('Returns a valid ati tracking url given the input props', () => {
const { result } = renderHook(
() =>
useConstructLiteSiteUrl({
...defaultProps,
campaignID: 'custom-campaign',
}),
{
wrapper,
},
);

expect(result.current).toContain(
'atc=PUB-[custom-campaign]-[brand]-[]-[CHD=promo::2]-[]-[]-[]-[]&type=AT',
);
});
});
6 changes: 6 additions & 0 deletions src/server/Document/Renderers/LiteRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable react/no-danger */
import React, { ReactElement, PropsWithChildren } from 'react';
import trackingScript from '#src/server/utilities/clickTrackingScript/clickTracking';
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved
import { BaseRendererProps } from './types';

interface Props extends BaseRendererProps {
Expand All @@ -24,6 +25,11 @@ export default function LitePageRenderer({
{helmetLinkTags}
{helmetScriptTags}
<style dangerouslySetInnerHTML={{ __html: styles }} />
<script
dangerouslySetInnerHTML={{
__html: `(${trackingScript.toString()})()`,
}}
/>
</head>
<body>{bodyContent}</body>
</html>
Expand Down
92 changes: 92 additions & 0 deletions src/server/utilities/clickTrackingScript/clickTracking.js
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
export default function trackingScript() {
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved
window.addEventListener('load', () => {
document.addEventListener('click', event => {
// eslint-disable-next-line no-undef
if (event.target.tagName === 'A') {
event.stopPropagation();
event.preventDefault();

const atiURL = event.target.getAttribute('data-ati-tracking');

if (atiURL == null) {
return;
}

const nextPageUrl = event.currentTarget.href;

const {
screen: { width, height, colorDepth, pixelDepth },
innerWidth,
innerHeight,
} = window;
const now = new Date();
const hours = now.getHours();
const mins = now.getMinutes();
const secs = now.getSeconds();

// COOKIE SETTINGS
const cookieName = 'atuserid';
const expires = 397; // expires in 13 months
const cookiesForPage = `; ${document.cookie}`;
const atUserIdCookie = cookiesForPage.split(`; ${cookieName}=`);
let atUserIdValue = null;

if (atUserIdCookie.length === 2) {
const cookieInfo = atUserIdCookie.pop().split(';').shift();

if (cookieInfo) {
const decodedCookie = decodeURI(cookieInfo);
const user = JSON.parse(decodedCookie);
atUserIdValue = user.val;
}
}

if (!atUserIdValue && crypto.randomUUID) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to worry about Opera Mini + Lite, or does this work? Or is the point of Opera Mini that all users have the same ID, so it would be impossible for us to detect unique users?

atUserIdValue = crypto.randomUUID();
}

const stringifiedCookieValue = JSON.stringify({ val: atUserIdValue });
if (atUserIdValue) {
document.cookie = `${cookieName}=${stringifiedCookieValue}; path=/; max-age=${expires};`;
}

const rValue = [
width || 0,
height || 0,
colorDepth || 0,
pixelDepth || 0,
].join('x');

const reValue = [innerWidth || 0, innerHeight || 0].join('x');

const hlValue = [hours, mins, secs].join('x');

let clientSideAtiURL = atiURL
.concat('&', 'r=', rValue)
.concat('&', 're=', reValue)
.concat('&', 'hl=', hlValue);

if (navigator.language) {
clientSideAtiURL = clientSideAtiURL.concat(
'&',
'lng=',
navigator.language,
);
}

if (atUserIdValue) {
clientSideAtiURL = clientSideAtiURL.concat(
'&',
'idclient=',
atUserIdValue,
);
}

// eslint-disable-next-line no-undef -- This is provided in a helmet script
sendBeaconLite(clientSideAtiURL);

window.location.assign(nextPageUrl);
}
});
});
}
100 changes: 100 additions & 0 deletions src/server/utilities/clickTrackingScript/clickTracking.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import trackingScript from './clickTracking';

const dispatchClick = targetElement => {
document.body.appendChild(targetElement);
const event = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
});
targetElement.dispatchEvent(event);
};

describe('Click tracking script', () => {
const randomUUIDMock = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers().setSystemTime(new Date(1731515402000));

trackingScript();

document.cookie =
Fixed Show fixed Hide fixed
'atuserid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';

delete window.location;
window.location = {
assign: jest.fn(),
};
window.sendBeaconLite = jest.fn();

Object.defineProperty(global, 'crypto', {
value: {
randomUUID: randomUUIDMock,
},
});

window.dispatchEvent(new Event('load'));
});

it('Sets a new cookie if there is no atuserid cookie on the user browser', () => {
const anchorElement = document.createElement('a');
anchorElement.setAttribute(
'data-ati-tracking',
'https://logws1363.ati-host.net/?',
);

randomUUIDMock.mockReturnValueOnce('randomUniqueId');
dispatchClick(anchorElement);

expect(document.cookie).toBe('atuserid={"val":"randomUniqueId"}');
});

it('Reuses the atuserid cookie if there is a atuserid cookie on the user browser', () => {
const anchorElement = document.createElement('a');
anchorElement.setAttribute(
'data-ati-tracking',
'https://logws1363.ati-host.net/?',
);

document.cookie = 'atuserid={"val":"oldCookieId"}';
Fixed Show fixed Hide fixed
randomUUIDMock.mockReturnValueOnce('newCookieId');
dispatchClick(anchorElement);

const callParam = window.sendBeaconLite.mock.calls[0][0];

expect(callParam).toContain('idclient=oldCookieId');
});

it('Calls sendBeaconLite() with the correct url', () => {
const anchorElement = document.createElement('a');
anchorElement.setAttribute(
'data-ati-tracking',
'https://logws1363.ati-host.net/?',
);

window.screen = { width: 100, height: 400, colorDepth: 24, pixelDepth: 24 };
window.innerWidth = 4060;
window.innerHeight = 1080;
Object.defineProperty(navigator, 'language', {
get() {
return 'en-GB';
},
});

dispatchClick(anchorElement);

const callParam = window.sendBeaconLite.mock.calls[0][0];

expect(callParam).toContain(
'r=0x0x24x24&re=4060x1080&hl=16x30x2&lng=en-GB',
);
shayneahchoon marked this conversation as resolved.
Show resolved Hide resolved
});

it('Does not call sendBeacon if the event has no data-ati-tracking parameter', () => {
const anchorElement = document.createElement('a');

dispatchClick(anchorElement);

expect(window.sendBeaconLite).toHaveBeenCalledTimes(0);
});
});
Loading