From cb6c207dc2c99569fe0697619d809c00f78059c5 Mon Sep 17 00:00:00 2001
From: Rowan Manning <138944+rowanmanning@users.noreply.github.com>
Date: Tue, 25 Mar 2025 15:41:14 +0000
Subject: [PATCH] feat: add a new client-metrics-web package
This adds a lightweight wrapper around the AWS CloudWatch RUM client,
ensuring that we're consistent and can send metrics from other libraries
that we maintain.
This has been through a long design process, mostly outlined here:
https://financialtimes.atlassian.net/wiki/x/aoCJCQI
Release-as: 0.1.0
---
.release-please-manifest.json | 1 +
package-lock.json | 464 ++++++++++++++-
packages/client-metrics-web/.npmignore | 3 +
packages/client-metrics-web/README.md | 359 ++++++++++++
packages/client-metrics-web/lib/index.js | 218 +++++++
packages/client-metrics-web/package.json | 21 +
.../test/unit/lib/index.spec.js | 552 ++++++++++++++++++
packages/client-metrics-web/types/index.d.ts | 26 +
release-please-config.json | 1 +
9 files changed, 1644 insertions(+), 1 deletion(-)
create mode 100644 packages/client-metrics-web/.npmignore
create mode 100644 packages/client-metrics-web/README.md
create mode 100644 packages/client-metrics-web/lib/index.js
create mode 100644 packages/client-metrics-web/package.json
create mode 100644 packages/client-metrics-web/test/unit/lib/index.spec.js
create mode 100644 packages/client-metrics-web/types/index.d.ts
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 20cfc149..5cb9a831 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,5 +1,6 @@
{
"packages/app-info": "4.1.0",
+ "packages/client-metrics-web": "0.0.0",
"packages/crash-handler": "5.0.2",
"packages/errors": "4.0.0",
"packages/eslint-config": "4.0.0",
diff --git a/package-lock.json b/package-lock.json
index 551722b5..9641ca73 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -57,6 +57,373 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@aws-crypto/crc32": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz",
+ "integrity": "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^3.0.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^1.11.1"
+ }
+ },
+ "node_modules/@aws-crypto/crc32/node_modules/@aws-crypto/util": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz",
+ "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.222.0",
+ "@aws-sdk/util-utf8-browser": "^3.0.0",
+ "tslib": "^1.11.1"
+ }
+ },
+ "node_modules/@aws-crypto/crc32/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "license": "0BSD"
+ },
+ "node_modules/@aws-crypto/sha256-js": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-2.0.2.tgz",
+ "integrity": "sha512-iXLdKH19qPmIC73fVCrHWCSYjN/sxaAvZ3jNNyw6FclmHyjLKg0f69WlC9KTnyElxCR5MO9SKaG00VwlJwyAkQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^2.0.2",
+ "@aws-sdk/types": "^3.110.0",
+ "tslib": "^1.11.1"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-js/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "license": "0BSD"
+ },
+ "node_modules/@aws-crypto/util": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-2.0.2.tgz",
+ "integrity": "sha512-Lgu5v/0e/BcrZ5m/IWqzPUf3UYFTy/PpeED+uc9SWUR1iZQL8XXbGQg10UfllwwBryO3hFF5dizK+78aoXC1eA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.110.0",
+ "@aws-sdk/util-utf8-browser": "^3.0.0",
+ "tslib": "^1.11.1"
+ }
+ },
+ "node_modules/@aws-crypto/util/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "license": "0BSD"
+ },
+ "node_modules/@aws-sdk/eventstream-codec": {
+ "version": "3.370.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-codec/-/eventstream-codec-3.370.0.tgz",
+ "integrity": "sha512-PiaDMum7TNsIE3DGECSsNYwibBIPN2/e13BJbTwi6KgVx8BV2mYA3kQkaUDiy++tEpzN81Nh5OPTFVb7bvgYYg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/crc32": "3.0.0",
+ "@aws-sdk/types": "3.370.0",
+ "@aws-sdk/util-hex-encoding": "3.310.0",
+ "tslib": "^2.5.0"
+ }
+ },
+ "node_modules/@aws-sdk/eventstream-codec/node_modules/@aws-sdk/types": {
+ "version": "3.370.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.370.0.tgz",
+ "integrity": "sha512-8PGMKklSkRKjunFhzM2y5Jm0H2TBu7YRNISdYzXLUHKSP9zlMEYagseKVdmox0zKHf1LXVNuSlUV2b6SRrieCQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^1.1.0",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/eventstream-codec/node_modules/@smithy/types": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.2.0.tgz",
+ "integrity": "sha512-z1r00TvBqF3dh4aHhya7nz1HhvCg4TRmw51fjMrh5do3h+ngSstt/yKlNbHeb9QxJmFbmN8KEVSWgb1bRvfEoA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/fetch-http-handler": {
+ "version": "3.370.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.370.0.tgz",
+ "integrity": "sha512-3I77fcSWyy2A1VoOst/QEnoYsqI5QumXYF/cV4rg7/emON8wdzeqv5eYlRshtnC8jImTJidMHg79yeQST5cU6g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/protocol-http": "3.370.0",
+ "@aws-sdk/querystring-builder": "3.370.0",
+ "@aws-sdk/types": "3.370.0",
+ "@aws-sdk/util-base64": "3.310.0",
+ "tslib": "^2.5.0"
+ }
+ },
+ "node_modules/@aws-sdk/fetch-http-handler/node_modules/@aws-sdk/types": {
+ "version": "3.370.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.370.0.tgz",
+ "integrity": "sha512-8PGMKklSkRKjunFhzM2y5Jm0H2TBu7YRNISdYzXLUHKSP9zlMEYagseKVdmox0zKHf1LXVNuSlUV2b6SRrieCQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^1.1.0",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/fetch-http-handler/node_modules/@smithy/types": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.2.0.tgz",
+ "integrity": "sha512-z1r00TvBqF3dh4aHhya7nz1HhvCg4TRmw51fjMrh5do3h+ngSstt/yKlNbHeb9QxJmFbmN8KEVSWgb1bRvfEoA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/is-array-buffer": {
+ "version": "3.310.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/is-array-buffer/-/is-array-buffer-3.310.0.tgz",
+ "integrity": "sha512-urnbcCR+h9NWUnmOtet/s4ghvzsidFmspfhYaHAmSRdy9yDjdjBJMFjjsn85A1ODUktztm+cVncXjQ38WCMjMQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/protocol-http": {
+ "version": "3.370.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.370.0.tgz",
+ "integrity": "sha512-MfZCgSsVmir+4kJps7xT0awOPNi+swBpcVp9ZtAP7POduUVV6zVLurMNLXsppKsErggssD5E9HUgQFs5w06U4Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "3.370.0",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/protocol-http/node_modules/@aws-sdk/types": {
+ "version": "3.370.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.370.0.tgz",
+ "integrity": "sha512-8PGMKklSkRKjunFhzM2y5Jm0H2TBu7YRNISdYzXLUHKSP9zlMEYagseKVdmox0zKHf1LXVNuSlUV2b6SRrieCQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^1.1.0",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/protocol-http/node_modules/@smithy/types": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.2.0.tgz",
+ "integrity": "sha512-z1r00TvBqF3dh4aHhya7nz1HhvCg4TRmw51fjMrh5do3h+ngSstt/yKlNbHeb9QxJmFbmN8KEVSWgb1bRvfEoA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/querystring-builder": {
+ "version": "3.370.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.370.0.tgz",
+ "integrity": "sha512-yrDWn3AtXArHWXh9NATcf+aaF6SPBxgroSIHYKKDA7B0UlSEpCOroz7anj0Lvewwo1D3hLlXcJlBSGVtWI0Xyg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "3.370.0",
+ "@aws-sdk/util-uri-escape": "3.310.0",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/querystring-builder/node_modules/@aws-sdk/types": {
+ "version": "3.370.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.370.0.tgz",
+ "integrity": "sha512-8PGMKklSkRKjunFhzM2y5Jm0H2TBu7YRNISdYzXLUHKSP9zlMEYagseKVdmox0zKHf1LXVNuSlUV2b6SRrieCQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^1.1.0",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/querystring-builder/node_modules/@smithy/types": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.2.0.tgz",
+ "integrity": "sha512-z1r00TvBqF3dh4aHhya7nz1HhvCg4TRmw51fjMrh5do3h+ngSstt/yKlNbHeb9QxJmFbmN8KEVSWgb1bRvfEoA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4": {
+ "version": "3.370.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.370.0.tgz",
+ "integrity": "sha512-Mh++NJiXoBxMzz4d8GQPNB37nqjS1gsVwjKoSAWFE67sjgsjb8D5JWRCm9CinqPoXi2iN57+1DcQalTDKQGc0A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/eventstream-codec": "3.370.0",
+ "@aws-sdk/is-array-buffer": "3.310.0",
+ "@aws-sdk/types": "3.370.0",
+ "@aws-sdk/util-hex-encoding": "3.310.0",
+ "@aws-sdk/util-middleware": "3.370.0",
+ "@aws-sdk/util-uri-escape": "3.310.0",
+ "@aws-sdk/util-utf8": "3.310.0",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4/node_modules/@aws-sdk/types": {
+ "version": "3.370.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.370.0.tgz",
+ "integrity": "sha512-8PGMKklSkRKjunFhzM2y5Jm0H2TBu7YRNISdYzXLUHKSP9zlMEYagseKVdmox0zKHf1LXVNuSlUV2b6SRrieCQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^1.1.0",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4/node_modules/@smithy/types": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.2.0.tgz",
+ "integrity": "sha512-z1r00TvBqF3dh4aHhya7nz1HhvCg4TRmw51fjMrh5do3h+ngSstt/yKlNbHeb9QxJmFbmN8KEVSWgb1bRvfEoA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/types": {
+ "version": "3.775.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.775.0.tgz",
+ "integrity": "sha512-ZoGKwa4C9fC9Av6bdfqcW6Ix5ot05F/S4VxWR2nHuMv7hzfmAjTOcUiWT7UR4hM/U0whf84VhDtXN/DWAk52KA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-base64": {
+ "version": "3.310.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-base64/-/util-base64-3.310.0.tgz",
+ "integrity": "sha512-v3+HBKQvqgdzcbL+pFswlx5HQsd9L6ZTlyPVL2LS9nNXnCcR3XgGz9jRskikRUuUvUXtkSG1J88GAOnJ/apTPg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/util-buffer-from": "3.310.0",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-buffer-from": {
+ "version": "3.310.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-buffer-from/-/util-buffer-from-3.310.0.tgz",
+ "integrity": "sha512-i6LVeXFtGih5Zs8enLrt+ExXY92QV25jtEnTKHsmlFqFAuL3VBeod6boeMXkN2p9lbSVVQ1sAOOYZOHYbYkntw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/is-array-buffer": "3.310.0",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-hex-encoding": {
+ "version": "3.310.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.310.0.tgz",
+ "integrity": "sha512-sVN7mcCCDSJ67pI1ZMtk84SKGqyix6/0A1Ab163YKn+lFBQRMKexleZzpYzNGxYzmQS6VanP/cfU7NiLQOaSfA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-middleware": {
+ "version": "3.370.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-middleware/-/util-middleware-3.370.0.tgz",
+ "integrity": "sha512-Jvs9FZHaQznWGLkRel3PFEP93I1n0Kp6356zxYHk3LIOmjpzoob3R+v96mzyN+dZrnhPdPubYS41qbU2F9lROg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-uri-escape": {
+ "version": "3.310.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.310.0.tgz",
+ "integrity": "sha512-drzt+aB2qo2LgtDoiy/3sVG8w63cgLkqFIa2NFlGpUgHFWTXkqtbgf4L5QdjRGKWhmZsnqkbtL7vkSWEcYDJ4Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-utf8": {
+ "version": "3.310.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8/-/util-utf8-3.310.0.tgz",
+ "integrity": "sha512-DnLfFT8uCO22uOJc0pt0DsSNau1GTisngBCDw8jQuWT5CqogMJu4b/uXmwEqfj8B3GX6Xsz8zOd6JpRlPftQoA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/util-buffer-from": "3.310.0",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-utf8-browser": {
+ "version": "3.259.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz",
+ "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.3.1"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@@ -524,6 +891,18 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
+ "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
+ "license": "MIT",
+ "dependencies": {
+ "regenerator-runtime": "^0.14.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
@@ -1035,6 +1414,10 @@
"resolved": "packages/app-info",
"link": true
},
+ "node_modules/@dotcom-reliability-kit/client-metrics-web": {
+ "resolved": "packages/client-metrics-web",
+ "link": true
+ },
"node_modules/@dotcom-reliability-kit/crash-handler": {
"resolved": "packages/crash-handler",
"link": true
@@ -4000,6 +4383,18 @@
"@sinonjs/commons": "^3.0.0"
}
},
+ "node_modules/@smithy/types": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.2.0.tgz",
+ "integrity": "sha512-7eMk09zQKCO+E/ivsjQv+fDlOupcFUCSC/L2YUPgwhvowVGWbPQHjEFcmjt7QQ4ra5lyowS92SV53Zc6XD4+fg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
@@ -4639,6 +5034,25 @@
"node": ">=8.0.0"
}
},
+ "node_modules/aws-rum-web": {
+ "version": "1.21.0",
+ "resolved": "https://registry.npmjs.org/aws-rum-web/-/aws-rum-web-1.21.0.tgz",
+ "integrity": "sha512-7hRpB7JNb9j4dlytUJbSSm9N4R8fWBa3GLJfhhvm6Vtl3OjpZhiVejEiLVZNUI5qmbqQZuNppJ4lKiDUzud9HA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-js": "^2.0.2",
+ "@aws-sdk/fetch-http-handler": "^3.36.0",
+ "@aws-sdk/protocol-http": "^3.36.0",
+ "@aws-sdk/querystring-builder": "^3.36.0",
+ "@aws-sdk/signature-v4": "^3.36.0",
+ "@aws-sdk/util-hex-encoding": "^3.36.0",
+ "@babel/runtime": "^7.16.0",
+ "shimmer": "^1.2.1",
+ "ua-parser-js": "^1.0.33",
+ "uuid": "^9.0.0",
+ "web-vitals": "^3.0.2"
+ }
+ },
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -10281,6 +10695,12 @@
"node": ">=8"
}
},
+ "node_modules/regenerator-runtime": {
+ "version": "0.14.1",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
+ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
+ "license": "MIT"
+ },
"node_modules/release-please": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/release-please/-/release-please-17.0.0.tgz",
@@ -11528,7 +11948,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "devOptional": true,
"license": "0BSD"
},
"node_modules/type-detect": {
@@ -11579,6 +11998,32 @@
"node": ">=14.17"
}
},
+ "node_modules/ua-parser-js": {
+ "version": "1.0.40",
+ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz",
+ "integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ua-parser-js"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/faisalman"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/faisalman"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "ua-parser-js": "script/cli.js"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/uglify-js": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
@@ -11802,6 +12247,12 @@
"makeerror": "1.0.12"
}
},
+ "node_modules/web-vitals": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.2.tgz",
+ "integrity": "sha512-c0rhqNcHXRkY/ogGDJQxZ9Im9D19hDihbzSQJrsioex+KnFgmMzBiy57Z1EjkhX/+OjyBpclDCzz2ITtjokFmg==",
+ "license": "Apache-2.0"
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -12013,6 +12464,17 @@
"node": "20.x || 22.x"
}
},
+ "packages/client-metrics-web": {
+ "name": "@dotcom-reliability-kit/client-metrics-web",
+ "version": "0.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "aws-rum-web": "^1.21.0"
+ },
+ "engines": {
+ "node": "20.x || 22.x"
+ }
+ },
"packages/crash-handler": {
"name": "@dotcom-reliability-kit/crash-handler",
"version": "5.0.2",
diff --git a/packages/client-metrics-web/.npmignore b/packages/client-metrics-web/.npmignore
new file mode 100644
index 00000000..d4b2e9ba
--- /dev/null
+++ b/packages/client-metrics-web/.npmignore
@@ -0,0 +1,3 @@
+CHANGELOG.md
+docs
+test
\ No newline at end of file
diff --git a/packages/client-metrics-web/README.md b/packages/client-metrics-web/README.md
new file mode 100644
index 00000000..33c754c1
--- /dev/null
+++ b/packages/client-metrics-web/README.md
@@ -0,0 +1,359 @@
+
+# @dotcom-reliability-kit/client-metrics-web
+
+A client for sending operational metrics events to [AWS CloudWatch RUM](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-RUM.html) from the web. This module is part of [FT.com Reliability Kit](https://github.com/Financial-Times/dotcom-reliability-kit#readme).
+
+> [!WARNING]
+> This Reliability Kit module is intended for use in client-side JavaScript. Importing it into a Node.js app will result in errors being thrown.
+
+> [!IMPORTANT]
+> Remember that this library is intended for sending _operational_ metrics - metrics that help us understand whether your system is operating as expected. For analytics data you should still be using Spoor (or another solution common to your team).
+
+> [!CAUTION]
+> This Reliability Kit module is considered experimental, we're rolling out to systems in a controlled manner and may introduce breaking changes. Please speak to the Reliability team if you have a use-case.
+
+* [Usage (systems)](#usage-systems)
+ * [`MetricsClient`](#metricsclient)
+ * [`client.recordEvent()`](#clientrecordevent)
+ * [`client.recordError()`](#clientrecorderror)
+ * [`client.enable()`](#clientenable)
+ * [`client.disable()`](#clientdisable)
+ * [`client.isEnabled`](#clientisenabled)
+ * [`client.isAvailable`](#clientisavailable)
+ * [Event-based API](#event-based-api)
+ * [Error handling](#error-handling)
+ * [Configuration options](#configuration-options)
+ * [`options.allowedHostnamePattern`](#optionsallowedhostnamepattern)
+ * [`options.awsAppMonitorId`](#optionsawsappmonitorid)
+ * [`options.awsAppMonitorRegion`](#optionsawsappmonitorregion)
+ * [`options.awsIdentityPoolId`](#optionsawsidentitypoolid)
+ * [`options.samplePercentage`](#optionssamplepercentage)
+ * [`options.systemCode`](#optionssystemcode)
+ * [`options.systemVersion`](#optionssystemversion)
+* [Usage (shared libraries)](#usage-shared-libraries)
+* [Usage (infrastructure)](#usage-infrastructure)
+ * [Customer Products Client Metrics](#customer-products-client-metrics)
+ * [Running your own infrastructure](#running-your-own-infrastructure)
+* [Contributing](#contributing)
+* [License](#license)
+
+
+## Usage (systems)
+
+Systems that want to send metrics from the client should import and construct a metrics client as part of their client-side JavaScript. If you're writing code that's shared across multiple systems **do not import and construct a metrics client**. See [the usage guide for shared libraries](#usage-shared-libraries).
+
+Install `@dotcom-reliability-kit/client-metrics-web` as a dependency:
+
+```bash
+npm install --save @dotcom-reliability-kit/client-metrics-web
+```
+
+Include in your client-side code:
+
+```js
+import { MetricsClient } from '@dotcom-reliability-kit/client-metrics-web';
+// or
+const { MetricsClient } = require('@dotcom-reliability-kit/client-metrics-web');
+```
+
+### `MetricsClient`
+
+The `MetricsClient` class wraps an AWS CloudWatch RUM client with some FT-specific configurations and limitations. This class should only ever be constructed once or you'll end up sending duplicate metrics.
+
+You should construct the metrics client as early as possible in the loading of the page. For the required options, see [configuration options](#configuration-options).
+
+```js
+const client = new MetricsClient({
+ // Options go here
+});
+```
+
+This will bind some global event handlers:
+
+ * `error` - records an error for uncaught errors thrown on the page
+ * `unhandledrejection` - records an error for unhandled promise rejections on the page
+ * `ft.clientMetric` - records a custom metric based on details found in the event ([see documentation below](#event-based-api))
+
+#### `client.recordEvent()`
+
+Record an event in AWS CloudWatch RUM:
+
+```js
+client.recordEvent('namespace.event', {
+ // Any event details you want to send can go here as key/value pairs
+});
+```
+
+The event namespace **MUST** include a period (`.`). It must be comprised of alphanumeric characters, underscores, and hyphens, separated by periods. When we record the event in AWS CloudWatch RUM we automatically prefix with `com.ft.`.
+
+Other than the above, the event namespace is free-form for now. A later major version of the client may lock down the top-level namespace further.
+
+#### `client.recordError()`
+
+> [!WARNING]
+> Errors in AWS CloudWatch RUM are unstructured so they're not as useful as sending appropriately-namespaced events, [please do this if possible](#clientrecordevent). If we see heavy use of errors from your system then we may work with you to move to metrics.
+
+If you need to record an error manually then you can do so with this method. It accepts an error object:
+
+```js
+client.recordError(new Error('oops'));
+```
+
+You'd normally do this in a try/catch block:
+
+```js
+try {
+ // Do something that might throw an error
+} catch (error) {
+ client.recordError(error);
+}
+```
+
+#### `client.enable()`
+
+Enable the client. This is called by default during instantiation but you may need to call this if the client is ever disabled.
+
+```js
+client.enable();
+```
+
+#### `client.disable()`
+
+Disable the client, preventing any metrics from being sent. Global event handlers are also unbound.
+
+```js
+client.disable();
+```
+
+#### `client.isEnabled`
+
+A boolean indicating whether the client is currently enabled.
+
+#### `client.isAvailable`
+
+A boolean indicating whether the client was correctly configured and set up. If this is `false` then it's not possible for events to be sent to AWS CloudWatch RUM.
+
+### Event-based API
+
+Passing around a single client instance may not be easy or preferable in your system, depending on complexity. For this we also bind a listener on `window` for the `ft.clientMetric` event.
+
+This allows you to send metric events from anywhere in your system, even if you don't have access to the client. You need to emit a custom event, either on window:
+
+```js
+window.dispatchEvent(
+ new CustomEvent('ft.clientMetric', {
+ detail: {
+ namespace: 'namespace.event',
+ // Any event details you want to send can go here as key/value pairs
+ }
+ }
+));
+```
+
+or from another element on the page, as long as you bubble the event:
+
+```js
+const element = document.getElementById('my-component');
+element.dispatchEvent(
+ new CustomEvent('ft.clientMetric', {
+ bubbles: true,
+ detail: {
+ namespace: 'namespace.event',
+ // Any event details you want to send can go here as key/value pairs
+ }
+ }
+));
+```
+
+### Error handling
+
+If something goes wrong with the configuration of the metrics client _or_ the sending of an event, we don't throw an error - this could result in an infinite loop where we try to record the thrown error and that throws another error.
+
+Instead of throwing an error, we log a warning to the console. If you're not seeing metrics in AWS CloudWatch RUM then check the browser console for these warnings. It's also a good idea to check for them in local development before pushing any changes.
+
+### Configuration options
+
+Config options can be passed into the `MetricsClient` function as an object with any of the keys below.
+
+```js
+new MetricsClient({
+ // Config options go here
+});
+```
+
+
+#### `options.allowedHostnamePattern`
+
+**Optional** `RegExp`. A pattern to match against the current window's hostname. If the window hostname matches this regular expression then the client will be set up successfully, otherwise it will fail with a warning. Defaults to `/\.ft\.com$/`.
+
+This is to avoid sending metrics from local or test environments. It should match your [app monitor's domain](https://docs.aws.amazon.com/cloudwatchrum/latest/APIReference/API_AppMonitor.html). (see [infrastructure for information on where to get this value](#usage-infrastructure)).
+
+```js
+// Allows the metrics client to work only on mydomain.com and subdomains
+new MetricsClient({ allowedHostnamePattern: /\.mydomain\.com$/ });
+```
+
+#### `options.awsAppMonitorId`
+
+**Required** `String`. The ID of the [App Monitor](https://docs.aws.amazon.com/cloudwatchrum/latest/APIReference/API_AppMonitor.html) you want to send metrics to (see [infrastructure for information on where to get this value](#usage-infrastructure)).
+
+> [!TIP]
+> This is not a secret, it's safe to be visible in client-side code.
+
+```js
+new MetricsClient({ awsAppMonitorId: '0990f36b-1af0-47d1-a155-873e6e566b0c' });
+```
+
+#### `options.awsAppMonitorRegion`
+
+**Required** `String`. The AWS region the [App Monitor](https://docs.aws.amazon.com/cloudwatchrum/latest/APIReference/API_AppMonitor.html) you want to send metrics to runs in (see [infrastructure for information on where to get this value](#usage-infrastructure)).
+
+```js
+new MetricsClient({ awsAppMonitorRegion: 'eu-west-1' });
+```
+
+#### `options.awsIdentityPoolId`
+
+**Required** `String`. The ID of the [Identity Pool](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-identity.html) your metrics client uses for authentication (see [infrastructure for information on where to get this value](#usage-infrastructure)).
+
+> [!TIP]
+> This is not a secret, it's safe to be visible in client-side code.
+
+```js
+new MetricsClient({ awsIdentityPoolId: 'eu-west-1:3b48b1c1-b286-4459-a755-f7074f4c8356' });
+```
+
+#### `options.samplePercentage`
+
+**Optional** `Number`. The percentage of requests to send metrics for. Sampling is important to keep our costs down - never set this to `100` for systems dealing with any amount of production traffic. Defaults to `5`.
+
+```js
+new MetricsClient({ samplePercentage: 25 });
+```
+
+#### `options.systemCode`
+
+**Required** `String`. The [Biz Ops system code](https://biz-ops.in.ft.com/list/Systems) you're sending metrics for.
+
+```js
+new MetricsClient({ systemCode: 'my-system' });
+```
+
+#### `options.systemVersion`
+
+**Optional** `String`. The version number of the currently running system, which helps us to spot issues in new versions. This could be a version number or a git commit hash. Defaults to `0.0.0`.
+
+```js
+new MetricsClient({ systemVersion: '1.2.3' });
+```
+
+
+## Usage (shared libraries)
+
+If you want to record operational metrics from another library that's shared between systems, **do not** install this module as a dependency. Instead you should rely solely on [the event-based API](#event-based-api).
+
+This ensures that:
+
+ * We don't end up with duplicate global event handlers
+ * The system installing your library does not have to inject the metrics client and manage the instance
+ * We don't end up with systems and libraries using different incompatible versions of the metrics client (dependency hell)
+
+We recommend that your library decides on a top-level namespace that all other events live under. E.g.
+
+```
+my-library.success
+my-library.failure
+```
+
+## Usage (infrastructure)
+
+As well as a client you'll need an AWS CloudWatch AppMonitor to send events to. You can set this up yourselves or, if you're in Customer Products, you can use one provided to you by the Reliability team.
+
+### Customer Products Client Metrics
+
+We maintain a system called [Customer Products Client Metrics](https://biz-ops.in.ft.com/System/cp-client-metrics). You can find all required options in Doppler under `cp-shared.prod`, look for those prefixed with `CLIENT_METRICS_`. Speak to the Reliability team about how to access the data once you're recording metrics.
+
+To get these shared configurations to the client side, we recommend referencing the shared secrets in your own Doppler project and using [dotcom-ui-app-context](https://github.com/Financial-Times/dotcom-page-kit/tree/main/packages/dotcom-ui-app-context) to pass them to the client side.
+
+If you want your events to be available as CloudWatch metrics and in Grafana then you'll need to add filters to the AppMonitor's `MetricDestinations`. Speak to the Reliability team about how to do this.
+
+### Running your own infrastructure
+
+If you want to set up your own AppMonitor to collect metrics, you can do so with the following CloudFormation:
+
+```yaml
+AWSTemplateFormatVersion: "2010-09-09"
+Description: A system to record RUM data
+Parameters:
+ SystemCode:
+ Type: String
+ Description: The system code to associate with the stack
+
+Resources:
+ RumIdentityPool:
+ Type: AWS::Cognito::IdentityPool
+ Properties:
+ IdentityPoolName: !Sub ${SystemCode}-${AWS::Region}-id-pool
+ AllowUnauthenticatedIdentities: true
+
+ RumIdentityPoolRoles:
+ Type: AWS::Cognito::IdentityPoolRoleAttachment
+ Properties:
+ IdentityPoolId: !Ref RumIdentityPool
+ Roles:
+ unauthenticated: !GetAtt RumClientRole.Arn
+
+ RumClientRole:
+ Type: AWS::IAM::Role
+ Properties:
+ RoleName: !Sub ${SystemCode}-${AWS::Region}-unauth
+ Description: Unauthenticated Role for AWS RUM Clients
+ AssumeRolePolicyDocument:
+ Version: 2012-10-17
+ Statement:
+ - Effect: Allow
+ Principal:
+ Federated:
+ - cognito-identity.amazonaws.com
+ Action:
+ - sts:AssumeRoleWithWebIdentity
+ Condition:
+ StringEquals:
+ cognito-identity.amazonaws.com:aud: !Ref RumIdentityPool
+ ForAnyValue:StringLike:
+ cognito-identity.amazonaws.com:amr: unauthenticated
+ Path: /
+ Policies:
+ - PolicyName: AWSRumClientPut
+ PolicyDocument:
+ Version: "2012-10-17"
+ Statement:
+ - Effect: Allow
+ Action:
+ - "rum:PutRumEvents"
+ Resource: !Sub arn:aws:rum:${AWS::Region}:${AWS::AccountId}:appmonitor/${SystemCode}
+
+ RumAppMonitor:
+ Type: AWS::RUM::AppMonitor
+ Properties:
+ Name: !Ref SystemCode
+ CustomEvents:
+ Status: ENABLED
+ Domain: '*.ft.com' # Change to the domain(s) you want to collect metrics on
+ AppMonitorConfiguration:
+ GuestRoleArn: !GetAtt RumClientRole.Arn
+ IdentityPoolId: !Ref RumIdentityPool
+ SessionSampleRate: 1 # 0 = 0%, 1 = 100%
+ Telemetries:
+ - errors
+```
+
+## Contributing
+
+See the [central contributing guide for Reliability Kit](https://github.com/Financial-Times/dotcom-reliability-kit/blob/main/docs/contributing.md).
+
+
+## License
+
+Licensed under the [MIT](https://github.com/Financial-Times/dotcom-reliability-kit/blob/main/LICENSE) license.
+Copyright © 2025, The Financial Times Ltd.
diff --git a/packages/client-metrics-web/lib/index.js b/packages/client-metrics-web/lib/index.js
new file mode 100644
index 00000000..4c2ffa95
--- /dev/null
+++ b/packages/client-metrics-web/lib/index.js
@@ -0,0 +1,218 @@
+/* eslint-disable no-console */
+const { AwsRum } = require('aws-rum-web');
+
+/**
+ * @import { AwsRumConfig } from 'aws-rum-web'
+ * @import { MetricsClientOptions, MetricsClient as MetricsClientType, MetricsEvent } from '@dotcom-reliability-kit/client-metrics-web'
+ */
+
+const namespacePattern = /^([a-z0-9_-]+)(\.[a-z0-9_-]+)+$/i;
+
+exports.MetricsClient = class MetricsClient {
+ /** @type {null | AwsRum} */
+ #rum = null;
+
+ /** @type {boolean} */
+ #isAvailable = false;
+
+ /** @type {boolean} */
+ #isEnabled = false;
+
+ /**
+ * @param {MetricsClientOptions} options
+ */
+ constructor(options) {
+ try {
+ const {
+ awsAppMonitorId,
+ awsAppMonitorRegion,
+ awsIdentityPoolId,
+ samplePercentage,
+ systemCode,
+ systemVersion
+ } = MetricsClient.#defaultOptions(options);
+
+ // Convert percentage-based sample rate to a decimal
+ const sessionSampleRate =
+ Math.round(Math.min(Math.max(samplePercentage, 0), 100)) / 100;
+
+ /** @type {AwsRumConfig} */
+ const awsRumConfig = {
+ allowCookies: false,
+ disableAutoPageView: true,
+ enableXRay: false,
+ endpoint: `https://dataplane.rum.${awsAppMonitorRegion}.amazonaws.com`,
+ identityPoolId: awsIdentityPoolId,
+ sessionAttributes: { systemCode },
+ sessionSampleRate,
+ telemetries: ['errors']
+ };
+
+ this.#rum = new AwsRum(
+ awsAppMonitorId,
+ systemVersion,
+ awsAppMonitorRegion,
+ awsRumConfig
+ );
+ this.#isAvailable = true;
+ } catch (/** @type {any} */ error) {
+ this.#isAvailable = false;
+ console.warn(`Client not initialised: ${error.message}`);
+ }
+
+ this.#handleMetricsEvent = this.#handleMetricsEvent.bind(this);
+ this.enable();
+ }
+
+ /** @type {MetricsClientType['isAvailable']} */
+ get isAvailable() {
+ return this.#isAvailable;
+ }
+
+ /** @type {MetricsClientType['isEnabled']} */
+ get isEnabled() {
+ return this.#isEnabled;
+ }
+
+ /** @type {MetricsClientType['enable']} */
+ enable() {
+ if (this.#isAvailable && !this.#isEnabled) {
+ this.#rum?.enable();
+ window.addEventListener('ft.clientMetric', this.#handleMetricsEvent);
+ this.#isEnabled = true;
+ }
+ }
+
+ /** @type {MetricsClientType['disable']} */
+ disable() {
+ if (this.#isAvailable && this.#isEnabled) {
+ this.#rum?.disable();
+ window.removeEventListener('ft.clientMetric', this.#handleMetricsEvent);
+ this.#isEnabled = false;
+ }
+ }
+
+ /** @type {MetricsClientType['recordError']} */
+ recordError(error) {
+ this.#rum?.recordError(error);
+ }
+
+ /** @type {MetricsClientType['recordEvent']} */
+ recordEvent(namespace, eventData = {}) {
+ try {
+ namespace = MetricsClient.#resolveNamespace(namespace);
+ this.#rum?.recordEvent(namespace, eventData);
+ } catch (/** @type {any} */ error) {
+ console.warn(`Invalid metrics event: ${error.message}`);
+ }
+ }
+
+ /**
+ * @param {Event} event
+ */
+ #handleMetricsEvent = (event) => {
+ try {
+ if (event instanceof CustomEvent) {
+ const { namespace, ...data } = MetricsClient.#resolveEventDetail(
+ event.detail
+ );
+ this.recordEvent(namespace, data);
+ }
+ } catch (/** @type {any} */ error) {
+ console.warn(`Invalid metrics event: ${error.message}`);
+ }
+ };
+
+ /**
+ * @param {MetricsClientOptions} options
+ * @returns {Required}
+ */
+ static #defaultOptions(options) {
+ /** @type {Required} */
+ const defaultedOptions = Object.assign(
+ {
+ allowedHostnamePattern: /\.ft\.com$/,
+ samplePercentage: 5,
+ systemVersion: '0.0.0'
+ },
+ options
+ );
+ this.#assertValidOptions(defaultedOptions);
+ return defaultedOptions;
+ }
+
+ /**
+ * @param {Required} options
+ * @returns {void}
+ */
+ static #assertValidOptions({
+ allowedHostnamePattern,
+ awsAppMonitorId,
+ awsAppMonitorRegion,
+ awsIdentityPoolId,
+ systemCode
+ }) {
+ if (!(allowedHostnamePattern instanceof RegExp)) {
+ throw new TypeError('option allowedHostnamePattern must be a RegExp');
+ }
+ if (typeof awsAppMonitorId !== 'string') {
+ throw new TypeError('option awsAppMonitorId must be a string');
+ }
+ if (typeof awsAppMonitorRegion !== 'string') {
+ throw new TypeError('option awsAppMonitorRegion must be a string');
+ }
+ if (typeof awsIdentityPoolId !== 'string') {
+ throw new TypeError('option awsIdentityPoolId must be a string');
+ }
+ if (typeof systemCode !== 'string') {
+ throw new TypeError('option systemCode must be a string');
+ }
+
+ // No point trying to send RUM events when we're not running on an allowed domain
+ const hostname = window.location.hostname;
+ if (!allowedHostnamePattern.test(hostname)) {
+ throw new Error(`client errors cannot be handled on ${hostname}`);
+ }
+ }
+
+ /**
+ * @param {string} namespace
+ * @returns {string}
+ */
+ static #resolveNamespace(namespace) {
+ if (typeof namespace !== 'string') {
+ throw new TypeError(`namespace (${typeof namespace}) must be a string`);
+ }
+ if (!namespace.includes('.')) {
+ throw new TypeError(
+ `namespace ("${namespace}") must include a period, the top level is reserved`
+ );
+ }
+ if (!namespacePattern.test(namespace)) {
+ throw new TypeError(
+ `namespace ("${namespace}") must be a combination of alphanumeric characters, underscores, and hyphens, separated by periods`
+ );
+ }
+ return `com.ft.${namespace.toLowerCase()}`;
+ }
+
+ /**
+ * @param {any} detail
+ * @returns {MetricsEvent}
+ */
+ static #resolveEventDetail(detail) {
+ if (
+ typeof detail !== 'object' ||
+ detail === null ||
+ Array.isArray(detail)
+ ) {
+ throw new TypeError('detail must be an object');
+ }
+ if (typeof detail.namespace !== 'string') {
+ throw new TypeError(
+ `detail.namespace (${typeof detail.namespace}) must be a string`
+ );
+ }
+ return detail;
+ }
+};
diff --git a/packages/client-metrics-web/package.json b/packages/client-metrics-web/package.json
new file mode 100644
index 00000000..1291f610
--- /dev/null
+++ b/packages/client-metrics-web/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@dotcom-reliability-kit/client-metrics-web",
+ "version": "0.0.0",
+ "description": "A client for sending operational metrics to AWS CloudWatch RUM from the web",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/Financial-Times/dotcom-reliability-kit.git",
+ "directory": "packages/client-metrics-web"
+ },
+ "homepage": "https://github.com/Financial-Times/dotcom-reliability-kit/tree/main/packages/client-metrics-web#readme",
+ "bugs": "https://github.com/Financial-Times/dotcom-reliability-kit/issues?q=label:\"package: client-metrics-web\"",
+ "license": "MIT",
+ "engines": {
+ "node": "20.x || 22.x"
+ },
+ "main": "lib/index.js",
+ "types": "types/index.d.ts",
+ "dependencies": {
+ "aws-rum-web": "^1.21.0"
+ }
+}
diff --git a/packages/client-metrics-web/test/unit/lib/index.spec.js b/packages/client-metrics-web/test/unit/lib/index.spec.js
new file mode 100644
index 00000000..a978b1ab
--- /dev/null
+++ b/packages/client-metrics-web/test/unit/lib/index.spec.js
@@ -0,0 +1,552 @@
+/* eslint-disable no-console */
+jest.mock('aws-rum-web');
+
+const { AwsRum } = require('aws-rum-web');
+const { MetricsClient } = require('../../..');
+
+describe('@dotcom-reliability-kit/client-metrics-web', () => {
+ beforeEach(() => {
+ global.window = {
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ location: {
+ hostname: 'mock-hostname'
+ }
+ };
+ jest.replaceProperty(global, 'console', {
+ log: console.log,
+ warn: jest.fn()
+ });
+ });
+
+ afterEach(() => {
+ delete global.window;
+ jest.resetAllMocks();
+ jest.restoreAllMocks();
+ });
+
+ it('exports a MetricsClient class', () => {
+ expect(MetricsClient).toBeInstanceOf(Function);
+ expect(() => {
+ MetricsClient();
+ }).toThrow(/class constructor/i);
+ });
+
+ describe('new MetricsClient(options)', () => {
+ let instance;
+ let options;
+
+ beforeEach(() => {
+ options = {
+ allowedHostnamePattern: /^mock-hostname$/,
+ awsAppMonitorId: 'mock-app-monitor-id',
+ awsAppMonitorRegion: 'mock-app-monitor-region',
+ awsIdentityPoolId: 'mock-identity-pool-id',
+ samplePercentage: 13,
+ systemCode: 'mock-system-code',
+ systemVersion: 'mock-version'
+ };
+ instance = new MetricsClient(options);
+ });
+
+ it('creates an AWS RUM client with the given options', () => {
+ expect(AwsRum).toHaveBeenCalledTimes(1);
+ expect(AwsRum).toHaveBeenCalledWith(
+ 'mock-app-monitor-id',
+ 'mock-version',
+ 'mock-app-monitor-region',
+ {
+ allowCookies: false,
+ disableAutoPageView: true,
+ enableXRay: false,
+ endpoint: `https://dataplane.rum.mock-app-monitor-region.amazonaws.com`,
+ identityPoolId: 'mock-identity-pool-id',
+ sessionAttributes: { systemCode: 'mock-system-code' },
+ sessionSampleRate: 0.13,
+ telemetries: ['errors']
+ }
+ );
+ });
+
+ it('enables the AWS RUM client', () => {
+ expect(AwsRum.mock.instances[0].enable).toHaveBeenCalledTimes(1);
+ });
+
+ it('adds an "ft.clientMetric" event listener to the window', () => {
+ expect(window.addEventListener).toHaveBeenCalledTimes(1);
+ // Jest expect.any(Function) does not work with bound functions so we can't
+ // use `toHaveBeenCalledWith`
+ const args = window.addEventListener.mock.calls[0];
+ expect(args[0]).toStrictEqual('ft.clientMetric');
+ expect(typeof args[1]).toStrictEqual('function');
+ });
+
+ it('does not log any warnings', () => {
+ expect(console.warn).toHaveBeenCalledTimes(0);
+ });
+
+ describe('.isAvailable', () => {
+ it('is set to true', () => {
+ expect(instance.isAvailable).toStrictEqual(true);
+ });
+ });
+
+ describe('.isEnabled', () => {
+ it('is set to true', () => {
+ expect(instance.isEnabled).toStrictEqual(true);
+ });
+ });
+
+ describe('.disable()', () => {
+ beforeEach(() => {
+ instance.disable();
+ });
+
+ it('disables the AWS RUM client', () => {
+ expect(AwsRum.mock.instances[0].disable).toHaveBeenCalledTimes(1);
+ });
+
+ it('removes the "ft.clientMetric" event listener from the window', () => {
+ expect(window.removeEventListener).toHaveBeenCalledTimes(1);
+ // Jest expect.any(Function) does not work with bound functions so we can't
+ // use `toHaveBeenCalledWith`
+ const args = window.removeEventListener.mock.calls[0];
+ expect(args[0]).toStrictEqual('ft.clientMetric');
+ expect(typeof args[1]).toStrictEqual('function');
+ });
+
+ it('sets the isEnabled property to false', () => {
+ expect(instance.isEnabled).toStrictEqual(false);
+ });
+
+ describe('when the client is already disabled', () => {
+ beforeEach(() => {
+ AwsRum.mock.instances[0].disable.mockClear();
+ window.removeEventListener.mockClear();
+ instance.disable();
+ });
+
+ it('does nothing', () => {
+ expect(AwsRum.mock.instances[0].disable).toHaveBeenCalledTimes(0);
+ expect(window.removeEventListener).toHaveBeenCalledTimes(0);
+ });
+ });
+ });
+
+ describe('.enable()', () => {
+ beforeEach(() => {
+ AwsRum.mock.instances[0].enable.mockClear();
+ window.addEventListener.mockClear();
+ instance.disable();
+ instance.enable();
+ });
+
+ it('re-enables the AWS RUM client', () => {
+ expect(AwsRum.mock.instances[0].enable).toHaveBeenCalledTimes(1);
+ });
+
+ it('re-adds the "ft.clientMetric" event listener to the window', () => {
+ expect(window.addEventListener).toHaveBeenCalledTimes(1);
+ // Jest expect.any(Function) does not work with bound functions so we can't
+ // use `toHaveBeenCalledWith`
+ const args = window.addEventListener.mock.calls[0];
+ expect(args[0]).toStrictEqual('ft.clientMetric');
+ expect(typeof args[1]).toStrictEqual('function');
+ });
+
+ it('sets the isEnabled property to true', () => {
+ expect(instance.isEnabled).toStrictEqual(true);
+ });
+
+ describe('when the client is already enabled', () => {
+ beforeEach(() => {
+ AwsRum.mock.instances[0].enable.mockClear();
+ window.addEventListener.mockClear();
+ instance.enable();
+ });
+
+ it('does nothing', () => {
+ expect(AwsRum.mock.instances[0].enable).toHaveBeenCalledTimes(0);
+ expect(window.addEventListener).toHaveBeenCalledTimes(0);
+ });
+ });
+ });
+
+ describe('.recordError(error)', () => {
+ let error;
+
+ beforeEach(() => {
+ error = new Error('mock error');
+ instance.recordError(error);
+ });
+
+ it('hands the error to the AWS RUM client', () => {
+ expect(AwsRum.mock.instances[0].recordError).toHaveBeenCalledTimes(1);
+ expect(AwsRum.mock.instances[0].recordError).toHaveBeenCalledWith(
+ error
+ );
+ });
+ });
+
+ describe('.recordEvent(namespace, data)', () => {
+ let eventData;
+
+ beforeEach(() => {
+ eventData = { mockEventData: true };
+ instance.recordEvent('mock.event', eventData);
+ });
+
+ it('hands the event to the AWS RUM client with the namespace prefixed', () => {
+ expect(AwsRum.mock.instances[0].recordEvent).toHaveBeenCalledTimes(1);
+ expect(AwsRum.mock.instances[0].recordEvent).toHaveBeenCalledWith(
+ 'com.ft.mock.event',
+ eventData
+ );
+ });
+
+ it('does not log any warnings', () => {
+ expect(console.warn).toHaveBeenCalledTimes(0);
+ });
+
+ describe('when the namespace includes uppercase characters', () => {
+ beforeEach(() => {
+ AwsRum.mock.instances[0].recordEvent.mockClear();
+ instance.recordEvent('Mock.UPPER.Event', eventData);
+ });
+
+ it('hands the event to the AWS RUM client with the namespace converted to lower case', () => {
+ expect(AwsRum.mock.instances[0].recordEvent).toHaveBeenCalledTimes(1);
+ expect(AwsRum.mock.instances[0].recordEvent).toHaveBeenCalledWith(
+ 'com.ft.mock.upper.event',
+ eventData
+ );
+ });
+ });
+
+ describe('when the namespace is not a string', () => {
+ beforeEach(() => {
+ AwsRum.mock.instances[0].recordEvent.mockClear();
+ instance.recordEvent(123, eventData);
+ });
+
+ it('does not hand the event to the AWS RUM client', () => {
+ expect(AwsRum.mock.instances[0].recordEvent).toHaveBeenCalledTimes(0);
+ });
+
+ it('logs a warning about the namespace type', () => {
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Invalid metrics event: namespace (number) must be a string'
+ );
+ });
+ });
+
+ describe('when the namespace does not include a period', () => {
+ beforeEach(() => {
+ AwsRum.mock.instances[0].recordEvent.mockClear();
+ instance.recordEvent('mock', eventData);
+ });
+
+ it('does not hand the event to the AWS RUM client', () => {
+ expect(AwsRum.mock.instances[0].recordEvent).toHaveBeenCalledTimes(0);
+ });
+
+ it('logs a warning about top-level namespaces being reserved', () => {
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Invalid metrics event: namespace ("mock") must include a period, the top level is reserved'
+ );
+ });
+ });
+
+ describe('when the namespace includes invalid characters', () => {
+ beforeEach(() => {
+ AwsRum.mock.instances[0].recordEvent.mockClear();
+ instance.recordEvent('mock . namespace', eventData);
+ });
+
+ it('does not hand the event to the AWS RUM client', () => {
+ expect(AwsRum.mock.instances[0].recordEvent).toHaveBeenCalledTimes(0);
+ });
+
+ it('logs a warning about valid namespace characters', () => {
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Invalid metrics event: namespace ("mock . namespace") must be a combination of alphanumeric characters, underscores, and hyphens, separated by periods'
+ );
+ });
+ });
+
+ describe('when event data is not defined', () => {
+ beforeEach(() => {
+ AwsRum.mock.instances[0].recordEvent.mockClear();
+ instance.recordEvent('mock.event');
+ });
+
+ it('hands the event to the AWS RUM client with an empty object as event data', () => {
+ expect(AwsRum.mock.instances[0].recordEvent).toHaveBeenCalledTimes(1);
+ expect(AwsRum.mock.instances[0].recordEvent).toHaveBeenCalledWith(
+ 'com.ft.mock.event',
+ {}
+ );
+ });
+ });
+ });
+
+ describe('ft.clientMetric event handler', () => {
+ let event;
+ let eventHandler;
+
+ beforeEach(() => {
+ jest.spyOn(instance, 'recordEvent');
+ eventHandler = window.addEventListener.mock.calls[0][1];
+ event = new CustomEvent('ft.clientMetric', {
+ detail: {
+ namespace: 'mock.event',
+ mockProperty: 'mock-value'
+ }
+ });
+ eventHandler(event);
+ });
+
+ it('calls recordEvent with the namespace and event data', () => {
+ expect(instance.recordEvent).toHaveBeenCalledTimes(1);
+ expect(instance.recordEvent).toHaveBeenCalledWith('mock.event', {
+ mockProperty: 'mock-value'
+ });
+ });
+
+ it('does not log any warnings', () => {
+ expect(console.warn).toHaveBeenCalledTimes(0);
+ });
+
+ describe('when event.detail.namespace is not a string', () => {
+ beforeEach(() => {
+ instance.recordEvent.mockClear();
+ event.detail.namespace = 123;
+ eventHandler(event);
+ });
+
+ it('does not call recordEvent', () => {
+ expect(instance.recordEvent).toHaveBeenCalledTimes(0);
+ });
+
+ it('logs a warning about the namespace type', () => {
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Invalid metrics event: detail.namespace (number) must be a string'
+ );
+ });
+ });
+
+ describe('when event.detail is not an object', () => {
+ beforeEach(() => {
+ instance.recordEvent.mockClear();
+ event = new CustomEvent('ft.clientMetric', { detail: 'nope' });
+ eventHandler(event);
+ });
+
+ it('does not call recordEvent', () => {
+ expect(instance.recordEvent).toHaveBeenCalledTimes(0);
+ });
+
+ it('logs a warning about the detail type', () => {
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Invalid metrics event: detail must be an object'
+ );
+ });
+ });
+
+ describe('when event is not a CustomEvent instance', () => {
+ beforeEach(() => {
+ instance.recordEvent.mockClear();
+ eventHandler({});
+ });
+
+ it('does nothing', () => {
+ // The condition that gets us here is mostly there to satisfy TypeScript
+ // so we don't care about anyhing getting logged - I don't think it's
+ // a case that can actually happen
+ expect(instance.recordEvent).toHaveBeenCalledTimes(0);
+ expect(console.warn).toHaveBeenCalledTimes(0);
+ });
+ });
+ });
+
+ describe('when options.allowedHostnamePattern does not match the window location', () => {
+ beforeEach(() => {
+ AwsRum.mockClear();
+ window.location.hostname = 'mock-non-matching-hostname';
+ instance = new MetricsClient(options);
+ });
+
+ it('does not create an AWS RUM client', () => {
+ expect(AwsRum).toHaveBeenCalledTimes(0);
+ });
+
+ it('logs a warning about hostname support', () => {
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Client not initialised: client errors cannot be handled on mock-non-matching-hostname'
+ );
+ });
+ });
+
+ describe('when options.allowedHostnamePattern is not set', () => {
+ beforeEach(() => {
+ AwsRum.mockClear();
+ delete options.allowedHostnamePattern;
+ window.location.hostname = 'example.ft.com';
+ instance = new MetricsClient(options);
+ });
+
+ it('defaults to matching *.ft.com', () => {
+ expect(AwsRum).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not log any warnings', () => {
+ expect(console.warn).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ describe('when options.allowedHostnamePattern is not a regular expression', () => {
+ beforeEach(() => {
+ AwsRum.mockClear();
+ options.allowedHostnamePattern = 'mock-pattern';
+ instance = new MetricsClient(options);
+ });
+
+ it('does not create an AWS RUM client', () => {
+ expect(AwsRum).toHaveBeenCalledTimes(0);
+ });
+
+ it('logs a warning about the invalid type', () => {
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Client not initialised: option allowedHostnamePattern must be a RegExp'
+ );
+ });
+ });
+
+ describe('when options.awsAppMonitorId is not a string', () => {
+ beforeEach(() => {
+ AwsRum.mockClear();
+ options.awsAppMonitorId = 123;
+ instance = new MetricsClient(options);
+ });
+
+ it('does not create an AWS RUM client', () => {
+ expect(AwsRum).toHaveBeenCalledTimes(0);
+ });
+
+ it('logs a warning about the invalid type', () => {
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Client not initialised: option awsAppMonitorId must be a string'
+ );
+ });
+ });
+
+ describe('when options.awsAppMonitorRegion is not a string', () => {
+ beforeEach(() => {
+ AwsRum.mockClear();
+ options.awsAppMonitorRegion = 123;
+ instance = new MetricsClient(options);
+ });
+
+ it('does not create an AWS RUM client', () => {
+ expect(AwsRum).toHaveBeenCalledTimes(0);
+ });
+
+ it('logs a warning about the invalid type', () => {
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Client not initialised: option awsAppMonitorRegion must be a string'
+ );
+ });
+ });
+
+ describe('when options.awsIdentityPoolId is not a string', () => {
+ beforeEach(() => {
+ AwsRum.mockClear();
+ options.awsIdentityPoolId = 123;
+ instance = new MetricsClient(options);
+ });
+
+ it('does not create an AWS RUM client', () => {
+ expect(AwsRum).toHaveBeenCalledTimes(0);
+ });
+
+ it('logs a warning about the invalid type', () => {
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Client not initialised: option awsIdentityPoolId must be a string'
+ );
+ });
+ });
+
+ describe('when options.samplePercentage is not set', () => {
+ beforeEach(() => {
+ AwsRum.mockClear();
+ delete options.samplePercentage;
+ instance = new MetricsClient(options);
+ });
+
+ it('creates an AWS RUM client with a default sample rate of 5%', () => {
+ expect(AwsRum).toHaveBeenCalledTimes(1);
+ expect(AwsRum).toHaveBeenCalledWith(
+ 'mock-app-monitor-id',
+ 'mock-version',
+ 'mock-app-monitor-region',
+ expect.objectContaining({ sessionSampleRate: 0.05 })
+ );
+ });
+
+ it('does not log any warnings', () => {
+ expect(console.warn).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ describe('when options.systemCode is not a string', () => {
+ beforeEach(() => {
+ AwsRum.mockClear();
+ options.systemCode = 123;
+ instance = new MetricsClient(options);
+ });
+
+ it('does not create an AWS RUM client', () => {
+ expect(AwsRum).toHaveBeenCalledTimes(0);
+ });
+
+ it('logs a warning about the invalid type', () => {
+ expect(console.warn).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalledWith(
+ 'Client not initialised: option systemCode must be a string'
+ );
+ });
+ });
+
+ describe('when options.systemVersion is not set', () => {
+ beforeEach(() => {
+ AwsRum.mockClear();
+ delete options.systemVersion;
+ instance = new MetricsClient(options);
+ });
+
+ it('creates an AWS RUM client with a default version of 0.0.0', () => {
+ expect(AwsRum).toHaveBeenCalledTimes(1);
+ expect(AwsRum).toHaveBeenCalledWith(
+ 'mock-app-monitor-id',
+ '0.0.0',
+ 'mock-app-monitor-region',
+ expect.any(Object)
+ );
+ });
+
+ it('does not log any warnings', () => {
+ expect(console.warn).toHaveBeenCalledTimes(0);
+ });
+ });
+ });
+});
diff --git a/packages/client-metrics-web/types/index.d.ts b/packages/client-metrics-web/types/index.d.ts
new file mode 100644
index 00000000..d9404471
--- /dev/null
+++ b/packages/client-metrics-web/types/index.d.ts
@@ -0,0 +1,26 @@
+declare module '@dotcom-reliability-kit/client-metrics-web' {
+ export type MetricsClientOptions = {
+ allowedHostnamePattern?: RegExp;
+ awsAppMonitorId: string;
+ awsAppMonitorRegion: string;
+ awsIdentityPoolId: string;
+ samplePercentage?: number;
+ systemCode: string;
+ systemVersion?: string;
+ };
+
+ export class MetricsClient {
+ constructor(options: Options): MetricsClient;
+ get isAvailable(): boolean;
+ get isEnabled(): boolean;
+ enable(): void;
+ disable(): void;
+ recordError(error: unknown): void;
+ recordEvent(namespace: string, eventData?: Record): void;
+ }
+
+ export type MetricsEvent = {
+ namespace: string;
+ [key: string]: any;
+ };
+}
diff --git a/release-please-config.json b/release-please-config.json
index 78691249..1bb8f09a 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -31,6 +31,7 @@
],
"packages": {
"packages/app-info": {},
+ "packages/client-metrics-web": {},
"packages/crash-handler": {},
"packages/errors": {},
"packages/eslint-config": {},