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 8 commits into
base: latest
Choose a base branch
from
Open
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
12 changes: 8 additions & 4 deletions src/app/components/ATIAnalytics/canonical/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,14 @@ describe('Canonical ATI Analytics', () => {

expect(helmet.scriptTags).toHaveLength(1);
expect(helmet.scriptTags[0].innerHTML).toEqual(`
var xhr = new XMLHttpRequest();
xhr.open("GET", "${expectedUrl}", true);
xhr.withCredentials = true;
xhr.send();
function sendBeaconLite (atiPageViewUrlString) {
var xhr = new XMLHttpRequest();
xhr.open("GET", atiPageViewUrlString, true);
xhr.withCredentials = true;
xhr.send();
}

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

Expand Down
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;
20 changes: 16 additions & 4 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,
useConstructLiteSiteATIEventTrackUrl,
} 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,22 @@
size,
eventTrackingData,
}: PropsWithChildren<MostReadLinkProps>) => {
const clickTrackerHandler = useClickTrackerHandler(eventTrackingData);
const { isLite } = useContext(RequestContext);

const clickTrackerHandler = isLite
? {
[LITE_TRACKER_PARAM]: useConstructLiteSiteATIEventTrackUrl(eventTrackingData),

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

View workflow job for this annotation

GitHub Actions / build (18.x)

Insert `⏎·········`

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

View workflow job for this annotation

GitHub Actions / build (18.x)

React Hook "useConstructLiteSiteATIEventTrackUrl" is called conditionally. React Hooks must be called in the exact same order in every component render
}
: {
onClick: useClickTrackerHandler(eventTrackingData),

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

View workflow job for this annotation

GitHub Actions / build (18.x)

React Hook "useClickTrackerHandler" is called conditionally. React Hooks must be called in the exact same order in every component render
};
Comment on lines +53 to +61
Copy link
Contributor

Choose a reason for hiding this comment

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

Not for the spike, but I would like to see this extracted into a utility - perhaps somewhere adjacent to, or within the useClickTrackerHandler - which we can reuse for all other components we would like to add click tracking to.


return (
<div css={getItemCss({ dir, size })} dir={dir}>
<a
css={[styles.link, size === 'default' && styles.defaultLink]}
href={href}
onClick={clickTrackerHandler}
{...clickTrackerHandler}
>
{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';
Copy link
Contributor

@karinathomasbbc karinathomasbbc Feb 3, 2025

Choose a reason for hiding this comment

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

nitpick: Naming things is 🧑‍🔬 (ignore this for the spike - perhaps when we productionise it, we can rename)

Suggested change
export const LITE_TRACKER_PARAM = 'data-ati-tracking';
export const LITE_ATI_TRACKING = 'data-lite-ati-tracking';


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

export const useConstructLiteSiteATIEventTrackUrl = (props = {}) => {
const eventTrackingContext = useContext(EventTrackingContext);

const componentName = props?.componentName;
const url = props?.url;
const advertiserID = props?.advertiserID;
const format = props?.format;
const detailedPlacement = props?.detailedPlacement;
Comment on lines +144 to +148
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we use destructuring here?


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;
23 changes: 22 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,9 @@ import {
import * as serviceContextModule from '../../contexts/ServiceContext';

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

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

Expand Down Expand Up @@ -588,3 +590,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(
() =>
useConstructLiteSiteATIEventTrackUrl({
...defaultProps,
campaignID: 'custom-campaign',
}),
{
wrapper,
},
);

expect(result.current).toContain(
'atc=PUB-[custom-campaign]-[brand]-[]-[CHD=promo::2]-[]-[]-[]-[]&type=AT',
);
});
});
1 change: 1 addition & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ declare global {
bootstrap: () => void;
cmd: { push: () => void };
};
sendBeaconLite: (url: string, data?: BodyInit | null) => boolean;
}
}

Expand Down
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/liteATIClickTracking';
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
71 changes: 71 additions & 0 deletions src/server/Document/__snapshots__/component.test.jsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,77 @@ exports[`Document Component should render LITE version correctly 1`] = `
<style>
.css-7prgni-StyledLink{display:inline-block;}
</style>
<script>
(function trackingScript() {
Copy link
Contributor

Choose a reason for hiding this comment

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

I wasn't expecting this, but was there an existing test which already snapshots the Document component?

window.addEventListener('load', function () {
document.addEventListener('click', function (event) {
var targetElement = event.target;
// eslint-disable-next-line no-undef
if ((targetElement === null || targetElement === void 0 ? void 0 : targetElement.tagName) === 'A') {
event.stopPropagation();
event.preventDefault();
var atiURL = targetElement.getAttribute('data-ati-tracking');
if (atiURL == null) {
return;
}
var currentAnchorElement = event.currentTarget;
var nextPageUrl = currentAnchorElement === null || currentAnchorElement === void 0 ? void 0 : currentAnchorElement.href;
var _window = window,
_window$screen = _window.screen,
width = _window$screen.width,
height = _window$screen.height,
colorDepth = _window$screen.colorDepth,
pixelDepth = _window$screen.pixelDepth,
innerWidth = _window.innerWidth,
innerHeight = _window.innerHeight;
var now = new Date();
var hours = now.getHours();
var mins = now.getMinutes();
var secs = now.getSeconds();

// COOKIE SETTINGS
var cookieName = 'atuserid';
var expires = 397; // expires in 13 months
var cookiesForPage = "; ".concat(document.cookie);
var atUserIdCookie = cookiesForPage.split("; ".concat(cookieName, "="));
var atUserIdValue = null;
if (atUserIdCookie.length === 2) {
var _atUserIdCookie$pop;
var cookieInfo = (_atUserIdCookie$pop = atUserIdCookie.pop()) === null || _atUserIdCookie$pop === void 0 ? void 0 : _atUserIdCookie$pop.split(';').shift();
if (cookieInfo) {
var decodedCookie = decodeURI(cookieInfo);
var user = JSON.parse(decodedCookie);
atUserIdValue = user.val;
}
}
if (!atUserIdValue && crypto.randomUUID) {
atUserIdValue = crypto.randomUUID();
}
var stringifiedCookieValue = JSON.stringify({
val: atUserIdValue
});
if (atUserIdValue) {
document.cookie = "".concat(cookieName, "=").concat(stringifiedCookieValue, "; path=/; max-age=").concat(expires, ";");
}
var rValue = [width || 0, height || 0, colorDepth || 0, pixelDepth || 0].join('x');
var reValue = [innerWidth || 0, innerHeight || 0].join('x');
var hlValue = [hours, mins, secs].join('x');
var 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
window.sendBeaconLite(clientSideAtiURL);
window.location.assign(nextPageUrl);
}
});
});
})()
</script>
</head>
<body>
<div>
Expand Down
118 changes: 118 additions & 0 deletions src/server/utilities/liteATIClickTracking/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import trackingScript from '.';

const dispatchClick = (targetElement: HTMLElement) => {
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));
Copy link
Contributor

Choose a reason for hiding this comment

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

nitpick: Could we use the ISO Date, so that we don't need to calculate what date/time this represents?

Suggested change
jest.useFakeTimers().setSystemTime(new Date(1731515402000));
jest.useFakeTimers().setSystemTime(new Date("2024-11-13T16:30:02.000Z"));


trackingScript();

document.cookie =

Check warning

Code scanning / CodeQL

Clear text transmission of sensitive cookie Medium

Sensitive cookie sent without enforcing SSL encryption.

Copilot Autofix AI 1 day ago

To fix the problem, we need to ensure that the cookie is transmitted using SSL by setting the secure attribute on the cookie. This can be done by modifying the document.cookie assignment to include the secure attribute. This change should be made in the beforeEach block where the cookie is being set.

Suggested changeset 1
src/server/utilities/liteATIClickTracking/index.test.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/server/utilities/liteATIClickTracking/index.test.ts b/src/server/utilities/liteATIClickTracking/index.test.ts
--- a/src/server/utilities/liteATIClickTracking/index.test.ts
+++ b/src/server/utilities/liteATIClickTracking/index.test.ts
@@ -21,3 +21,3 @@
     document.cookie =
-      'atuserid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
+      'atuserid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; secure;';
 
@@ -53,3 +53,3 @@
 
-    expect(document.cookie).toBe('atuserid={"val":"randomUniqueId"}');
+    expect(document.cookie).toBe('atuserid={"val":"randomUniqueId"}; secure');
   });
EOF
@@ -21,3 +21,3 @@
document.cookie =
'atuserid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
'atuserid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; secure;';

@@ -53,3 +53,3 @@

expect(document.cookie).toBe('atuserid={"val":"randomUniqueId"}');
expect(document.cookie).toBe('atuserid={"val":"randomUniqueId"}; secure');
});
Copilot is powered by AI and may make mistakes. Always verify output.
Positive Feedback
Negative Feedback

Provide additional feedback

Please help us improve GitHub Copilot by sharing more details about this comment.

Please select one or more of the options
'atuserid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we use a more realistic expiry date here?

Suggested change
'atuserid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
'atuserid=; expires=Wed, 01 Jan 2025 00:00:00 UTC; path=/;';


// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Comment on lines +24 to +25
Copy link
Contributor

Choose a reason for hiding this comment

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

This can be replaced with

Suggested change
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// @ts-expect-error Explain why here

delete window.location;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.location = {
assign: jest.fn(),
};

window.sendBeaconLite = jest.fn();

Object.defineProperty(global, 'crypto', {
value: {
randomUUID: randomUUIDMock,
},
});
Comment on lines +35 to +39
Copy link
Contributor

Choose a reason for hiding this comment

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

Not for spike but we should probably move this to the src/testHelpers/jest-setup.js file so that we don't have to keep mocking it in other places


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"}';

Check warning

Code scanning / CodeQL

Clear text transmission of sensitive cookie Medium

Sensitive cookie sent without enforcing SSL encryption.

Copilot Autofix AI 1 day ago

To fix the problem, we need to ensure that the cookie is transmitted using SSL by setting the secure attribute on the cookie. This can be done by appending ; secure to the cookie string when setting it. This change should be made in the test file where the cookie is being set.

Suggested changeset 1
src/server/utilities/liteATIClickTracking/index.test.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/server/utilities/liteATIClickTracking/index.test.ts b/src/server/utilities/liteATIClickTracking/index.test.ts
--- a/src/server/utilities/liteATIClickTracking/index.test.ts
+++ b/src/server/utilities/liteATIClickTracking/index.test.ts
@@ -53,3 +53,3 @@
 
-    expect(document.cookie).toBe('atuserid={"val":"randomUniqueId"}');
+    expect(document.cookie).toBe('atuserid={"val":"randomUniqueId"}; secure');
   });
@@ -63,3 +63,3 @@
 
-    document.cookie = 'atuserid={"val":"oldCookieId"}';
+    document.cookie = 'atuserid={"val":"oldCookieId"}; secure';
     randomUUIDMock.mockReturnValueOnce('newCookieId');
EOF
@@ -53,3 +53,3 @@

expect(document.cookie).toBe('atuserid={"val":"randomUniqueId"}');
expect(document.cookie).toBe('atuserid={"val":"randomUniqueId"}; secure');
});
@@ -63,3 +63,3 @@

document.cookie = 'atuserid={"val":"oldCookieId"}';
document.cookie = 'atuserid={"val":"oldCookieId"}; secure';
randomUUIDMock.mockReturnValueOnce('newCookieId');
Copilot is powered by AI and may make mistakes. Always verify output.
Positive Feedback
Negative Feedback

Provide additional feedback

Please help us improve GitHub Copilot by sharing more details about this comment.

Please select one or more of the options
randomUUIDMock.mockReturnValueOnce('newCookieId');
dispatchClick(anchorElement);

const callParam = (window.sendBeaconLite as jest.Mock).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,
availWidth: 400,
availHeight: 100,
orientation: 'landscape' as unknown as ScreenOrientation,
};
window.innerWidth = 4060;
window.innerHeight = 1080;
Object.defineProperty(navigator, 'language', {
get() {
return 'en-GB';
},
});

dispatchClick(anchorElement);

const callParam = (window.sendBeaconLite as jest.Mock).mock.calls[0][0];

const parsedATIParams = Object.fromEntries(new URLSearchParams(callParam));

expect(parsedATIParams).toMatchObject({
hl: '16x30x2',
lng: 'en-GB',
r: '0x0x24x24',
re: '4060x1080',
});
});

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
Loading