From 658039901a414103ab64695a2c5b49af08a55497 Mon Sep 17 00:00:00 2001
From: Jake Lee Kennedy <jake.kennedy@guardian.co.uk>
Date: Fri, 17 May 2024 10:48:32 +0100
Subject: [PATCH] use fetch to send commercial metrics (#1382)

---
 .changeset/lovely-zoos-wink.md           |   5 +
 src/core/send-commercial-metrics.spec.ts | 190 ++++++++++++++---------
 src/core/send-commercial-metrics.ts      |  11 +-
 3 files changed, 129 insertions(+), 77 deletions(-)
 create mode 100644 .changeset/lovely-zoos-wink.md

diff --git a/.changeset/lovely-zoos-wink.md b/.changeset/lovely-zoos-wink.md
new file mode 100644
index 000000000..11521fee2
--- /dev/null
+++ b/.changeset/lovely-zoos-wink.md
@@ -0,0 +1,5 @@
+---
+'@guardian/commercial': minor
+---
+
+Use fetch instead of sendBeacon for commercial metrics
diff --git a/src/core/send-commercial-metrics.spec.ts b/src/core/send-commercial-metrics.spec.ts
index f5191c03b..161cda385 100644
--- a/src/core/send-commercial-metrics.spec.ts
+++ b/src/core/send-commercial-metrics.spec.ts
@@ -47,6 +47,14 @@ const defaultMetrics = {
 	],
 };
 
+const expectedOptions = {
+	method: 'POST',
+	body: JSON.stringify(defaultMetrics),
+	keepalive: true,
+	cache: 'no-store',
+	mode: 'no-cors',
+};
+
 const tcfv2AllConsent: ConsentState = {
 	tcfv2: {
 		consents: {
@@ -124,7 +132,7 @@ afterEach(() => {
 });
 
 describe('send commercial metrics', () => {
-	Object.defineProperty(navigator, 'sendBeacon', {
+	Object.defineProperty(window, 'fetch', {
 		configurable: true,
 		enumerable: true,
 		value: jest.fn(),
@@ -147,8 +155,8 @@ describe('send commercial metrics', () => {
 		setVisibility('hidden');
 		global.dispatchEvent(new Event('visibilitychange'));
 
-		expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([
-			[Endpoints.PROD, JSON.stringify(defaultMetrics)],
+		expect((window.fetch as jest.Mock).mock.calls).toEqual([
+			[Endpoints.PROD, expectedOptions],
 		]);
 	});
 
@@ -166,7 +174,7 @@ describe('send commercial metrics', () => {
 		setVisibility('visible');
 		global.dispatchEvent(new Event('visibilitychange'));
 
-		expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([]);
+		expect((window.fetch as jest.Mock).mock.calls).toEqual([]);
 	});
 
 	it('does not send metrics when user is not in sampling group', async () => {
@@ -183,7 +191,7 @@ describe('send commercial metrics', () => {
 		setVisibility('hidden');
 		global.dispatchEvent(new Event('visibilitychange'));
 
-		expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([]);
+		expect((window.fetch as jest.Mock).mock.calls).toEqual([]);
 	});
 
 	it('does not send metrics when consent does not include purpose 8', async () => {
@@ -200,7 +208,7 @@ describe('send commercial metrics', () => {
 		setVisibility('hidden');
 		global.dispatchEvent(new Event('visibilitychange'));
 
-		expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([]);
+		expect((window.fetch as jest.Mock).mock.calls).toEqual([]);
 	});
 
 	it('sends metrics when non-TCFv2 user (i.e. USA or Australia) consents', async () => {
@@ -217,8 +225,8 @@ describe('send commercial metrics', () => {
 		setVisibility('hidden');
 		global.dispatchEvent(new Event('visibilitychange'));
 
-		expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([
-			[Endpoints.PROD, JSON.stringify(defaultMetrics)],
+		expect((window.fetch as jest.Mock).mock.calls).toEqual([
+			[Endpoints.PROD, expectedOptions],
 		]);
 	});
 
@@ -236,8 +244,8 @@ describe('send commercial metrics', () => {
 		setVisibility('hidden');
 		global.dispatchEvent(new Event('visibilitychange'));
 
-		expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([
-			[Endpoints.PROD, JSON.stringify(defaultMetrics)],
+		expect((window.fetch as jest.Mock).mock.calls).toEqual([
+			[Endpoints.PROD, expectedOptions],
 		]);
 	});
 
@@ -280,8 +288,8 @@ describe('send commercial metrics', () => {
 			setVisibility('hidden');
 			global.dispatchEvent(new Event('visibilitychange'));
 
-			expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([
-				[Endpoints.PROD, JSON.stringify(defaultMetrics)],
+			expect((window.fetch as jest.Mock).mock.calls).toEqual([
+				[Endpoints.PROD, expectedOptions],
 			]);
 		});
 
@@ -300,7 +308,7 @@ describe('send commercial metrics', () => {
 			setVisibility('hidden');
 			global.dispatchEvent(new Event('visibilitychange'));
 
-			expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([]);
+			expect((window.fetch as jest.Mock).mock.calls).toEqual([]);
 		});
 
 		it('expects to be initialised before calling bypassCoreWebVitalsSampling', async () => {
@@ -333,16 +341,22 @@ describe('send commercial metrics', () => {
 			setVisibility('hidden');
 			global.dispatchEvent(new Event('visibilitychange'));
 
-			expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([
+			expect((window.fetch as jest.Mock).mock.calls).toEqual([
 				[
 					Endpoints.CODE,
-					JSON.stringify({
-						...defaultMetrics,
-						properties: [
-							{ name: 'isDev', value: 'testurl.theguardian.com' },
-							{ name: 'adBlockerInUse', value: 'false' },
-						],
-					}),
+					{
+						...expectedOptions,
+						body: JSON.stringify({
+							...defaultMetrics,
+							properties: [
+								{
+									name: 'isDev',
+									value: 'testurl.theguardian.com',
+								},
+								{ name: 'adBlockerInUse', value: 'false' },
+							],
+						}),
+					},
 				],
 			]);
 		});
@@ -368,17 +382,20 @@ describe('send commercial metrics', () => {
 			setVisibility('hidden');
 			global.dispatchEvent(new Event('visibilitychange'));
 
-			expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([
+			expect((window.fetch as jest.Mock).mock.calls).toEqual([
 				[
 					Endpoints.PROD,
-					JSON.stringify({
-						...defaultMetrics,
-						properties: [
-							{ name: 'downlink', value: '1' },
-							{ name: 'effectiveType', value: '4g' },
-							{ name: 'adBlockerInUse', value: 'false' },
-						],
-					}),
+					{
+						...expectedOptions,
+						body: JSON.stringify({
+							...defaultMetrics,
+							properties: [
+								{ name: 'downlink', value: '1' },
+								{ name: 'effectiveType', value: '4g' },
+								{ name: 'adBlockerInUse', value: 'false' },
+							],
+						}),
+					},
 				],
 			]);
 		});
@@ -404,18 +421,24 @@ describe('send commercial metrics', () => {
 			setVisibility('hidden');
 			global.dispatchEvent(new Event('pagehide'));
 
-			expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([
+			expect((window.fetch as jest.Mock).mock.calls).toEqual([
 				[
 					Endpoints.CODE,
-					JSON.stringify({
-						...defaultMetrics,
-						properties: [
-							{ name: 'downlink', value: '1' },
-							{ name: 'effectiveType', value: '4g' },
-							{ name: 'isDev', value: 'testurl.theguardian.com' },
-							{ name: 'adBlockerInUse', value: 'false' },
-						],
-					}),
+					{
+						...expectedOptions,
+						body: JSON.stringify({
+							...defaultMetrics,
+							properties: [
+								{ name: 'downlink', value: '1' },
+								{ name: 'effectiveType', value: '4g' },
+								{
+									name: 'isDev',
+									value: 'testurl.theguardian.com',
+								},
+								{ name: 'adBlockerInUse', value: 'false' },
+							],
+						}),
+					},
 				],
 			]);
 		});
@@ -467,17 +490,23 @@ describe('send commercial metrics', () => {
 			setVisibility('hidden');
 			global.dispatchEvent(new Event('pagehide'));
 
-			expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([
+			expect((window.fetch as jest.Mock).mock.calls).toEqual([
 				[
 					Endpoints.CODE,
-					JSON.stringify({
-						...defaultMetrics,
-						properties: [
-							{ name: 'downlink', value: '1' },
-							{ name: 'effectiveType', value: '4g' },
-							{ name: 'isDev', value: 'testurl.theguardian.com' },
-						],
-					}),
+					{
+						...expectedOptions,
+						body: JSON.stringify({
+							...defaultMetrics,
+							properties: [
+								{ name: 'downlink', value: '1' },
+								{ name: 'effectiveType', value: '4g' },
+								{
+									name: 'isDev',
+									value: 'testurl.theguardian.com',
+								},
+							],
+						}),
+					},
 				],
 			]);
 		});
@@ -498,17 +527,23 @@ describe('send commercial metrics', () => {
 			setVisibility('hidden');
 			global.dispatchEvent(new Event('pagehide'));
 
-			expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([
+			expect((window.fetch as jest.Mock).mock.calls).toEqual([
 				[
 					Endpoints.CODE,
-					JSON.stringify({
-						...defaultMetrics,
-						properties: [
-							{ name: 'adSlotsInline', value: '5' },
-							{ name: 'adSlotsTotal', value: '10' },
-							{ name: 'isDev', value: 'testurl.theguardian.com' },
-						],
-					}),
+					{
+						...expectedOptions,
+						body: JSON.stringify({
+							...defaultMetrics,
+							properties: [
+								{ name: 'adSlotsInline', value: '5' },
+								{ name: 'adSlotsTotal', value: '10' },
+								{
+									name: 'isDev',
+									value: 'testurl.theguardian.com',
+								},
+							],
+						}),
+					},
 				],
 			]);
 		});
@@ -531,13 +566,16 @@ describe('send commercial metrics', () => {
 			setVisibility('hidden');
 			global.dispatchEvent(new Event('pagehide'));
 
-			expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([
+			expect((window.fetch as jest.Mock).mock.calls).toEqual([
 				[
 					Endpoints.PROD,
-					JSON.stringify({
-						...defaultMetrics,
-						metrics: [{ name: 'offlineCount', value: 3 }],
-					}),
+					{
+						...expectedOptions,
+						body: JSON.stringify({
+							...defaultMetrics,
+							metrics: [{ name: 'offlineCount', value: 3 }],
+						}),
+					},
 				],
 			]);
 		});
@@ -558,13 +596,16 @@ describe('send commercial metrics', () => {
 			setVisibility('hidden');
 			global.dispatchEvent(new Event('pagehide'));
 
-			expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([
+			expect((window.fetch as jest.Mock).mock.calls).toEqual([
 				[
 					Endpoints.PROD,
-					JSON.stringify({
-						...defaultMetrics,
-						metrics: [{ name: 'offlineCount', value: 0 }],
-					}),
+					{
+						...expectedOptions,
+						body: JSON.stringify({
+							...defaultMetrics,
+							metrics: [{ name: 'offlineCount', value: 0 }],
+						}),
+					},
 				],
 			]);
 		});
@@ -585,13 +626,16 @@ describe('send commercial metrics', () => {
 			setVisibility('hidden');
 			global.dispatchEvent(new Event('pagehide'));
 
-			expect((navigator.sendBeacon as jest.Mock).mock.calls).toEqual([
+			expect((window.fetch as jest.Mock).mock.calls).toEqual([
 				[
 					Endpoints.PROD,
-					JSON.stringify({
-						...defaultMetrics,
-						metrics: [],
-					}),
+					{
+						...expectedOptions,
+						body: JSON.stringify({
+							...defaultMetrics,
+							metrics: [],
+						}),
+					},
 				],
 			]);
 		});
diff --git a/src/core/send-commercial-metrics.ts b/src/core/send-commercial-metrics.ts
index 93c16f6d9..b93337178 100644
--- a/src/core/send-commercial-metrics.ts
+++ b/src/core/send-commercial-metrics.ts
@@ -115,10 +115,13 @@ function sendMetrics() {
 		commercialMetricsPayload,
 	);
 
-	return navigator.sendBeacon(
-		endpoint,
-		JSON.stringify(commercialMetricsPayload),
-	);
+	void fetch(endpoint, {
+		method: 'POST',
+		body: JSON.stringify(commercialMetricsPayload),
+		keepalive: true,
+		cache: 'no-store',
+		mode: 'no-cors',
+	});
 }
 
 type ArrayMetric = [key: string, value: string | number];