diff --git a/.github/workflows/cps-shared-ui-checkers.yml b/.github/workflows/cps-shared-ui-checkers.yml index 22158a6f2..d370db5ac 100644 --- a/.github/workflows/cps-shared-ui-checkers.yml +++ b/.github/workflows/cps-shared-ui-checkers.yml @@ -27,6 +27,9 @@ jobs: - name: Build app run: npm run build + - name: Build telemetry library + run: npm run build:telemetry + test: runs-on: ubuntu-latest steps: @@ -48,6 +51,9 @@ jobs: - name: Run library unit tests with coverage run: npm run test:coverage:cps-ui-kit + - name: Run telemetry library unit tests + run: npm run test:cps-telemetry + - name: Comment coverage on PR uses: ArtiomTr/jest-coverage-report-action@v2 if: github.event_name == 'pull_request' diff --git a/.gitignore b/.gitignore index c5e049d28..bbd009f36 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,10 @@ testem.log /typings .pa11y-temp.json +# Regenerated by `npm run trace:telemetry` — see +# projects/composition/src/app/telemetry-trace.capture.spec.ts +/projects/composition/telemetry-trace + # System files .DS_Store Thumbs.db diff --git a/angular.json b/angular.json index 44185b048..4af506468 100644 --- a/angular.json +++ b/angular.json @@ -37,6 +37,35 @@ } } }, + "cps-telemetry": { + "projectType": "library", + "root": "projects/cps-telemetry", + "sourceRoot": "projects/cps-telemetry/src", + "prefix": "cps", + "architect": { + "build": { + "builder": "@angular/build:ng-packagr", + "options": { + "project": "projects/cps-telemetry/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/cps-telemetry/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/cps-telemetry/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular-builders/jest:run", + "options": { + "zoneless": false + } + } + } + }, "composition": { "projectType": "application", "schematics": { @@ -73,6 +102,7 @@ ], "styles": ["projects/composition/src/styles.scss"], "scripts": [], + "allowedCommonJsDependencies": ["shimmer"], "browser": "projects/composition/src/main.ts" }, "configurations": { diff --git a/jest.config.js b/jest.config.js index 7a9cb9d14..c138131a1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -52,7 +52,10 @@ module.exports = { preset: 'jest-preset-angular', moduleNameMapper: { '^lodash-es$': 'lodash', - '^cps-ui-kit$': '/projects/cps-ui-kit/src/public-api.ts' + '^cps-ui-kit$': '/projects/cps-ui-kit/src/public-api.ts', + '^cps-telemetry$': '/projects/cps-telemetry/src/public-api.ts', + '^cps-telemetry/rum$': + '/projects/cps-telemetry/rum/src/public-api.ts' }, transformIgnorePatterns: [ 'node_modules/(?!(.*.mjs$|@angular/common/locales/.*.js$))' diff --git a/package-lock.json b/package-lock.json index 77d33ae17..f1493222a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@angular/router": "^22.1.3", "@e965/xlsx": "^0.20.3", "@types/lodash-es": "^4.17.12", + "aws-rum-web": "^3.2.1", "highlight.js": "^11.12.0", "lodash-es": "^4.17.21", "rxjs": "~7.8.2", @@ -672,6 +673,80 @@ "node": "^22.13.0 || >=24.0.0" } }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-rum/web-core": { + "version": "3.2.1", + "integrity": "sha512-i9wZrQYAaJNueBbHJsH04eFn7q+umtOmsm21i9kur2YYCsvGhOFADifbBW1vpv0TfO1nKfHjPMdbvb9AeiKVOA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@rrweb/record": "2.0.0-alpha.20", + "@smithy/fetch-http-handler": "^5.0.0", + "@smithy/protocol-http": "^5.0.0", + "@smithy/querystring-builder": "^4.0.0", + "@smithy/signature-v4": "^5.0.0", + "@smithy/util-hex-encoding": "^3.0.0", + "rrweb": "2.0.0-alpha.4", + "shimmer": "^1.2.1", + "uuid": "^9.0.0", + "web-vitals": "^4.0.0" + } + }, + "node_modules/@aws-rum/web-core/node_modules/uuid": { + "version": "9.0.1", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@aws-rum/web-slim": { + "version": "3.2.1", + "integrity": "sha512-CbNAuqWFvQ2VlA6m/aWvPylkT0TR+vBIP8RtP1/nnxinJah42vgIb4EbwFX8tAkzWkOIOpAqj0EeqTs9RopsbA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-rum/web-core": "3.2.1" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@axe-core/playwright": { "version": "4.13.0", "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", @@ -5461,20 +5536,6 @@ "webpack": "^5.54.0" } }, - "node_modules/@noble/hashes": { - "version": "2.3.0", - "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", @@ -7111,6 +7172,49 @@ "fsevents": "~2.3.2" } }, + "node_modules/@rrweb/record": { + "version": "2.0.0-alpha.20", + "integrity": "sha512-marVOU3Lc285mwLCp+vI6M/eJ+wZ6lcTa7fX0zcdmBsM+j5loXmMQjkmIvkji5+lAbkq5/w2cwto3GS0TaCjtg==", + "license": "MIT", + "dependencies": { + "@rrweb/types": "^2.0.0-alpha.20", + "@rrweb/utils": "^2.0.0-alpha.20", + "rrweb": "^2.0.0-alpha.20" + } + }, + "node_modules/@rrweb/record/node_modules/rrdom": { + "version": "2.1.1", + "integrity": "sha512-VBkTF3bGNqcZjqnbo/gKi9GVQPIxiG0dofw7CQdAZQFQ2juJdt2YKIf3oS9QudL8HTpGh9bz5TUN+3amBn1Geg==", + "license": "MIT", + "dependencies": { + "rrweb-snapshot": "^2.1.1" + } + }, + "node_modules/@rrweb/record/node_modules/rrweb": { + "version": "2.1.1", + "integrity": "sha512-ToxhJg3SsrAhw+/DPhI/2iiwZQIrGK5BGkZ0kHn4qUExcvQYRaolkciD1FWX2+r6vf1IxFyhsoRY18h3D+XoCg==", + "license": "MIT", + "dependencies": { + "@rrweb/types": "^2.1.1", + "@rrweb/utils": "^2.1.1", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "mitt": "^3.0.0", + "rrdom": "^2.1.1", + "rrweb-snapshot": "^2.1.1" + } + }, + "node_modules/@rrweb/types": { + "version": "2.1.1", + "integrity": "sha512-g0I3nCNL1S7slDXwhunxOuOoswkY8WVZJQrPFiwixOc6xoD514d1JLTuyAzCKIox8g/PaLKtV5g31Uk42inQSQ==", + "license": "MIT" + }, + "node_modules/@rrweb/utils": { + "version": "2.1.1", + "integrity": "sha512-x2SgJAD3YJ9eVcLZ5l6OqWMtczdA3VfS5XqPeqpZdQgV9oh6Id5GK2cDN4bimnkqryOcIJdz8cTvV0yNvqQ+nA==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", @@ -7202,6 +7306,125 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.2", + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.6.2", + "integrity": "sha512-Asd04MaxODN6FNY8EPTeCAM4kPNi3jDUAjZU0Y4F9rHvpLUrrUo7KLcxFgSthywFr6dZfIyDLIJda6jxmVTk5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.5.2", + "integrity": "sha512-TLuVW4cGFCj+LirJylCVcFwxcUCpZ5Av0I7K9k/xhm6v6zAUeXSNuZ9e2QKuxcwkgrejL8LZvQTJy42pmq5bOg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.2", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "3.0.0", + "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", @@ -7460,6 +7683,11 @@ "@types/node": "*" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", @@ -8474,6 +8702,11 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@xstate/fsm": { + "version": "1.6.5", + "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==", + "license": "MIT" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", @@ -8949,6 +9182,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-rum-web": { + "version": "3.2.1", + "integrity": "sha512-DBMYmZPBfCtK1ikXjGP5OE9yqiOymtHibn6BO3IFzK91td2tflYaq3ecjv+FDmai6uPXjjF7xjmdhK7gvVoa9A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-rum/web-core": "3.2.1", + "@aws-rum/web-slim": "3.2.1" + } + }, "node_modules/axe-core": { "version": "4.13.0", "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", @@ -9408,6 +9650,14 @@ "bare-path": "^3.0.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.15", "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", @@ -12651,6 +12901,11 @@ } } }, + "node_modules/fflate": { + "version": "0.4.9", + "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", @@ -17978,7 +18233,6 @@ "node_modules/mitt": { "version": "3.0.1", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, "license": "MIT" }, "node_modules/mrmime": { @@ -18063,7 +18317,6 @@ "node_modules/nanoid": { "version": "3.3.18", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, "funding": [ { "type": "github", @@ -19211,7 +19464,6 @@ "node_modules/picocolors": { "version": "1.1.1", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -19437,7 +19689,6 @@ "node_modules/postcss": { "version": "8.5.25", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", - "dev": true, "funding": [ { "type": "opencollective", @@ -20580,12 +20831,43 @@ "node": ">= 18" } }, + "node_modules/rrdom": { + "version": "0.1.7", + "integrity": "sha512-ZLd8f14z9pUy2Hk9y636cNv5Y2BMnNEY99wxzW9tD2BLDfe1xFxtLjB4q/xCBYo6HRe0wofzKzjm4JojmpBfFw==", + "license": "MIT", + "dependencies": { + "rrweb-snapshot": "^2.0.0-alpha.4" + } + }, + "node_modules/rrweb": { + "version": "2.0.0-alpha.4", + "integrity": "sha512-wEHUILbxDPcNwkM3m4qgPgXAiBJyqCbbOHyVoNEVBJzHszWEFYyTbrZqUdeb1EfmTRC2PsumCIkVcomJ/xcOzA==", + "license": "MIT", + "dependencies": { + "@rrweb/types": "^2.0.0-alpha.4", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "fflate": "^0.4.4", + "mitt": "^3.0.0", + "rrdom": "^0.1.7", + "rrweb-snapshot": "^2.0.0-alpha.4" + } + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, "license": "MIT" }, + "node_modules/rrweb-snapshot": { + "version": "2.1.1", + "integrity": "sha512-al4Kx7Am7YlIn/5Yb2EMr7cdmjGiK+cLhdilJEXqkFXgpnys8t/yMJumXy2Ormgirpg3MBnFha0GTgny+iIL2w==", + "license": "MIT", + "dependencies": { + "postcss": "^8.4.38" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", @@ -21134,6 +21416,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shimmer": { + "version": "1.2.1", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "license": "BSD-2-Clause" + }, "node_modules/side-channel": { "version": "1.1.1", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", @@ -21326,7 +21613,6 @@ "node_modules/source-map-js": { "version": "1.2.1", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -22970,6 +23256,11 @@ "license": "MIT", "optional": true }, + "node_modules/web-vitals": { + "version": "4.2.4", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/webdriver-bidi-protocol": { "version": "0.4.1", "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", diff --git a/package.json b/package.json index 814012621..53aaa32f2 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,14 @@ "ng": "ng", "start": "ng serve", "build": "ng build --configuration=production --project=cps-ui-kit", + "build:telemetry": "ng build --configuration=production --project=cps-telemetry", "build:documentation": "ng build --configuration=production --project=composition --base-href '/cps-shared-ui/'", "watch": "ng build cps-ui-kit --watch --configuration development", "test": "ng test", "test:composition": "ng test --project=composition", + "trace:telemetry": "ng test --project=composition --watch=false", "test:cps-ui-kit": "ng test --project=cps-ui-kit", + "test:cps-telemetry": "ng test --project=cps-telemetry", "test:coverage:cps-ui-kit": "COVERAGE_PROJECT=cps-ui-kit ng test --project=cps-ui-kit --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json", "test:coverage:composition": "ng test --project=composition --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json", "lint": "eslint \"**/*.ts\"", @@ -48,6 +51,7 @@ "@angular/router": "^22.1.3", "@e965/xlsx": "^0.20.3", "@types/lodash-es": "^4.17.12", + "aws-rum-web": "^3.2.1", "highlight.js": "^11.12.0", "lodash-es": "^4.17.21", "rxjs": "~7.8.2", diff --git a/projects/composition/src/app/app-routing.module.ts b/projects/composition/src/app/app-routing.module.ts index 660ad4556..b19ece1a2 100644 --- a/projects/composition/src/app/app-routing.module.ts +++ b/projects/composition/src/app/app-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes, UrlMatcher, UrlSegment } from '@angular/router'; +import { defaultTabRedirectGuard } from './guards/default-tab-redirect.guard'; const pathMatcher: (path: string) => UrlMatcher = (path: string) => (url) => { if (url.length === 1) { @@ -23,7 +24,7 @@ const pathMatcher: (path: string) => UrlMatcher = (path: string) => (url) => { return null; }; -const routes: Routes = [ +const routeDefs: Routes = [ { path: 'colors', title: 'Color pack', @@ -299,6 +300,10 @@ const routes: Routes = [ { path: '**', redirectTo: 'colors' } ]; +const routes: Routes = routeDefs.map((route) => + route.matcher ? { ...route, canMatch: [defaultTabRedirectGuard] } : route +); + @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] diff --git a/projects/composition/src/app/app.component.spec.ts b/projects/composition/src/app/app.component.spec.ts index 872deb483..6478df997 100644 --- a/projects/composition/src/app/app.component.spec.ts +++ b/projects/composition/src/app/app.component.spec.ts @@ -9,6 +9,13 @@ import { import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { Subject } from 'rxjs'; import { CpsThemeService } from 'cps-ui-kit'; +import { + CPS_LOG_API_PROVIDER, + CpsNoopTelemetrySink, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from './services/app-log-api.provider'; import { AppComponent } from './app.component'; jest.mock('../../../cps-ui-kit/package.json', () => ({ version: '1.0.0' }), { @@ -64,7 +71,14 @@ describe('AppComponent', () => { firstChild: { snapshot: { routeConfig: { title: 'Button' } } } } }, - { provide: CpsThemeService, useValue: { isDark: signal(false) } } + { provide: CpsThemeService, useValue: { isDark: signal(false) } }, + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } ] }).compileComponents(); diff --git a/projects/composition/src/app/app.component.ts b/projects/composition/src/app/app.component.ts index 15c212203..8d6adf2f7 100644 --- a/projects/composition/src/app/app.component.ts +++ b/projects/composition/src/app/app.component.ts @@ -14,6 +14,7 @@ import packageJson from '../../../cps-ui-kit/package.json'; import { NavigationSidebarComponent } from './components/navigation-sidebar/navigation-sidebar.component'; import { distinctUntilChanged, filter, map } from 'rxjs/operators'; import { CpsThemeService } from 'cps-ui-kit'; +import { AppTelemetryService } from './services/app-telemetry.service'; @Component({ selector: 'app-root', @@ -27,6 +28,7 @@ export class AppComponent { private readonly _platformId = inject(PLATFORM_ID); private readonly _document = inject(DOCUMENT); private readonly _themeService = inject(CpsThemeService); + private readonly _appTelemetry = inject(AppTelemetryService); componentTitle = ''; @@ -48,6 +50,8 @@ export class AppComponent { private _router: Router, private _activatedRoute: ActivatedRoute ) { + this._appTelemetry.start(); + if (isPlatformBrowser(this._platformId)) { this._mobileQuery = this._document.defaultView!.matchMedia( '(max-width: 37.5rem)' @@ -104,9 +108,13 @@ export class AppComponent { toggleSidebar() { this.sidebarExpanded = !this.sidebarExpanded; + this._appTelemetry.trackClick('sidebar_toggled', { + expanded: this.sidebarExpanded + }); } onNavLinkClicked() { + this._appTelemetry.markNavigationIntent(); if (this.isMobile) this.sidebarExpanded = false; this.focusMainContent(); } diff --git a/projects/composition/src/app/app.module.ts b/projects/composition/src/app/app.module.ts index b96364c70..cf4023bac 100644 --- a/projects/composition/src/app/app.module.ts +++ b/projects/composition/src/app/app.module.ts @@ -6,11 +6,21 @@ import { import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { TitleStrategy } from '@angular/router'; import { CpsIconComponent } from 'cps-ui-kit'; +import { CPS_LOG_API_PROVIDER, provideCpsTelemetry } from 'cps-telemetry'; +import { + CPS_RUM_CREDENTIALS_PROVIDER, + provideCpsTelemetryRumSink +} from 'cps-telemetry/rum'; +import packageJson from '../../../cps-ui-kit/package.json'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { AppPrefixTitleStrategy } from './app.prefix-title-strategy'; import { NavigationSidebarComponent } from './components/navigation-sidebar/navigation-sidebar.component'; import { ThemeToggleComponent } from './components/theme-toggle/theme-toggle.component'; +import { AppLogApiProvider } from './services/app-log-api.provider'; +import { AppRumCredentialsProvider } from './services/rum-credentials.provider'; +import { resolveDeploymentEnvironment } from './deployment-environment'; +import './services/telemetry.schema'; @NgModule({ declarations: [AppComponent], @@ -23,7 +33,18 @@ import { ThemeToggleComponent } from './components/theme-toggle/theme-toggle.com ThemeToggleComponent ], providers: [ - { provide: TitleStrategy, useClass: AppPrefixTitleStrategy } + { provide: TitleStrategy, useClass: AppPrefixTitleStrategy }, + provideCpsTelemetry({ + application: 'composition', + environment: resolveDeploymentEnvironment(), + version: packageJson.version + }), + provideCpsTelemetryRumSink(), + { + provide: CPS_RUM_CREDENTIALS_PROVIDER, + useExisting: AppRumCredentialsProvider + }, + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider } // provideClientHydration() ], bootstrap: [AppComponent] diff --git a/projects/composition/src/app/app.prefix-title-strategy.spec.ts b/projects/composition/src/app/app.prefix-title-strategy.spec.ts new file mode 100644 index 000000000..330568fc3 --- /dev/null +++ b/projects/composition/src/app/app.prefix-title-strategy.spec.ts @@ -0,0 +1,91 @@ +import { TestBed } from '@angular/core/testing'; +import { Title } from '@angular/platform-browser'; +import { RouterStateSnapshot, TitleStrategy } from '@angular/router'; +import { + CPS_LOG_API_PROVIDER, + CpsLogRecord, + CpsNoopTelemetrySink, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from './services/app-log-api.provider'; +import { AppPrefixTitleStrategy } from './app.prefix-title-strategy'; + +describe('AppPrefixTitleStrategy', () => { + let strategy: AppPrefixTitleStrategy; + let title: Title; + + /** Minimal router state; only `url` is read, plus `buildTitle` traversal. */ + function stateWithTitle(routeTitle?: string): RouterStateSnapshot { + return { + url: '/button', + root: { + children: [], + routeConfig: { title: routeTitle }, + title: routeTitle + } + } as unknown as RouterStateSnapshot; + } + + function logs(): CpsLogRecord[] { + return TestBed.inject(AppLogApiProvider).getRecords(); + } + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink }, + { provide: TitleStrategy, useClass: AppPrefixTitleStrategy } + ] + }); + strategy = TestBed.inject(TitleStrategy) as AppPrefixTitleStrategy; + title = TestBed.inject(Title); + jest.spyOn(title, 'setTitle'); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('should prefix a route title', () => { + jest.spyOn(strategy, 'buildTitle').mockReturnValue('Button'); + + strategy.updateTitle(stateWithTitle('Button')); + + expect(title.setTitle).toHaveBeenCalledWith('CPS UI Kit - Button'); + }); + + it('should fall back to the bare product name without a route title', () => { + jest.spyOn(strategy, 'buildTitle').mockReturnValue(undefined); + + strategy.updateTitle(stateWithTitle()); + + expect(title.setTitle).toHaveBeenCalledWith('CPS UI Kit'); + }); + + it('should not warn when the route has a title', () => { + jest.spyOn(strategy, 'buildTitle').mockReturnValue('Button'); + + strategy.updateTitle(stateWithTitle('Button')); + + expect(logs()).toHaveLength(0); + }); + + it('should warn, naming the route, when the title is missing', () => { + jest.spyOn(strategy, 'buildTitle').mockReturnValue(undefined); + + strategy.updateTitle(stateWithTitle()); + + expect(logs()).toHaveLength(1); + expect(logs()[0]).toMatchObject({ + level: 'warn', + message: 'No title defined for route', + context: 'TitleStrategy', + metadata: { url: '/button' } + }); + }); +}); diff --git a/projects/composition/src/app/app.prefix-title-strategy.ts b/projects/composition/src/app/app.prefix-title-strategy.ts index bc0a0f9bb..d6385b964 100644 --- a/projects/composition/src/app/app.prefix-title-strategy.ts +++ b/projects/composition/src/app/app.prefix-title-strategy.ts @@ -1,16 +1,27 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { TitleStrategy, RouterStateSnapshot } from '@angular/router'; +import { CpsLoggerService } from 'cps-telemetry'; +import './services/telemetry.schema'; @Injectable() export class AppPrefixTitleStrategy extends TitleStrategy { + private readonly logger = inject(CpsLoggerService).getLogger('routing'); + constructor(private readonly title: Title) { super(); } override updateTitle(routerState: RouterStateSnapshot) { const title = this.buildTitle(routerState); - if (!title) console.warn('No title defined for current route!'); + + if (!title) { + this.logger.warn('No title defined for route', { + context: 'TitleStrategy', + metadata: { url: routerState.url } + }); + } + this.title.setTitle(title ? `CPS UI Kit - ${title}` : 'CPS UI Kit'); } } diff --git a/projects/composition/src/app/components/code-example/code-example.component.spec.ts b/projects/composition/src/app/components/code-example/code-example.component.spec.ts index c4be17975..1bdcf8e60 100644 --- a/projects/composition/src/app/components/code-example/code-example.component.spec.ts +++ b/projects/composition/src/app/components/code-example/code-example.component.spec.ts @@ -1,15 +1,35 @@ import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { + CPS_LOG_API_PROVIDER, + CpsLogRecord, + CpsNoopTelemetrySink, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from '../../services/app-log-api.provider'; import { CodeExampleComponent } from './code-example.component'; +/** Telemetry wired to run for real, with nowhere to send anything. */ +const telemetryProviders = [ + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } +]; + describe('CodeExampleComponent', () => { let component: CodeExampleComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [CodeExampleComponent] + imports: [CodeExampleComponent], + providers: [...telemetryProviders] }).compileComponents(); fixture = TestBed.createComponent(CodeExampleComponent); @@ -20,6 +40,43 @@ describe('CodeExampleComponent', () => { jest.restoreAllMocks(); }); + describe('authoring diagnostics', () => { + function logs(): CpsLogRecord[] { + return TestBed.inject(AppLogApiProvider).getRecords(); + } + + it('should warn when neither htmlCode nor tsCode is provided', () => { + fixture.detectChanges(); + + expect(logs()).toHaveLength(1); + expect(logs()[0]).toMatchObject({ + level: 'warn', + message: 'At least one of htmlCode or tsCode must be provided', + context: 'CodeExampleComponent' + }); + }); + + it('should identify which example is misconfigured', () => { + fixture.componentRef.setInput('label', 'Basic usage'); + fixture.detectChanges(); + + expect(logs()[0].metadata).toMatchObject({ + instanceId: component.instanceId, + label: 'Basic usage' + }); + }); + + it.each([ + ['htmlCode', '
'], + ['tsCode', 'const a = 1;'] + ])('should stay quiet when %s is provided', (input, value) => { + fixture.componentRef.setInput(input, value); + fixture.detectChanges(); + + expect(logs()).toHaveLength(0); + }); + }); + describe('initialisation', () => { it('should create', () => { fixture = TestBed.createComponent(CodeExampleComponent); @@ -371,7 +428,10 @@ describe('CodeExampleComponent', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ imports: [CodeExampleComponent], - providers: [{ provide: PLATFORM_ID, useValue: 'server' }] + providers: [ + ...telemetryProviders, + { provide: PLATFORM_ID, useValue: 'server' } + ] }); fixture = TestBed.createComponent(CodeExampleComponent); component = fixture.componentInstance; diff --git a/projects/composition/src/app/components/code-example/code-example.component.ts b/projects/composition/src/app/components/code-example/code-example.component.ts index 29ac2a95d..c9a030741 100644 --- a/projects/composition/src/app/components/code-example/code-example.component.ts +++ b/projects/composition/src/app/components/code-example/code-example.component.ts @@ -15,6 +15,9 @@ import { import { isPlatformBrowser } from '@angular/common'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { CpsButtonComponent } from 'cps-ui-kit'; +import { CpsLoggerService } from 'cps-telemetry'; +import '../../services/telemetry.schema'; +import { AppTelemetryService } from '../../services/app-telemetry.service'; import hljs from 'highlight.js/lib/core'; import xml from 'highlight.js/lib/languages/xml'; import typescript from 'highlight.js/lib/languages/typescript'; @@ -41,6 +44,8 @@ export class CodeExampleComponent { private sanitizer = inject(DomSanitizer); private platformId = inject(PLATFORM_ID); + private logger = inject(CpsLoggerService).getLogger('docs'); + private appTelemetry = inject(AppTelemetryService); instanceId = `code-example-${++CodeExampleComponent.instanceCount}`; activeTab = signal('preview'); @@ -71,8 +76,12 @@ export class CodeExampleComponent { const tsCode = this.tsCode(); if (!htmlCode && !tsCode) { - console.warn( - 'CodeExampleComponent: At least one of htmlCode or tsCode must be provided' + this.logger.warn( + 'At least one of htmlCode or tsCode must be provided', + { + context: 'CodeExampleComponent', + metadata: { instanceId: this.instanceId, label: this.label() } + } ); } @@ -140,9 +149,16 @@ export class CodeExampleComponent { try { await navigator.clipboard.writeText(textToCopy); this.copied.set(true); + this.appTelemetry.trackClick('code_copied', { + language: this.activeTab() + }); setTimeout(() => this.copied.set(false), 2000); - } catch { + } catch (error) { this.copyFailed.set(true); + this.logger.warn('Failed to copy code to clipboard', { + context: 'CodeExample', + error + }); setTimeout(() => this.copyFailed.set(false), 2000); } } diff --git a/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.spec.ts b/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.spec.ts index 8b3ab037c..0c7b684da 100644 --- a/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.spec.ts +++ b/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.spec.ts @@ -2,6 +2,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; +import { + CPS_LOG_API_PROVIDER, + CpsNoopTelemetrySink, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from '../../services/app-log-api.provider'; +import { AppTelemetryService } from '../../services/app-telemetry.service'; import { NavigationSidebarComponent } from './navigation-sidebar.component'; describe('NavigationSidebarComponent', () => { @@ -11,7 +19,17 @@ describe('NavigationSidebarComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [NavigationSidebarComponent], - providers: [provideRouter([]), provideNoopAnimations()] + providers: [ + provideRouter([]), + provideNoopAnimations(), + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } + ] }).compileComponents(); fixture = TestBed.createComponent(NavigationSidebarComponent); @@ -157,6 +175,35 @@ describe('NavigationSidebarComponent', () => { }); }); + describe('ngOnDestroy', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('should not fire the debounced search report after the component is destroyed', () => { + const appTelemetry = TestBed.inject(AppTelemetryService); + const trackClick = jest.spyOn(appTelemetry, 'trackClick'); + + component.onSearchChanged('button'); + fixture.destroy(); + jest.advanceTimersByTime(400); + + expect(trackClick).not.toHaveBeenCalled(); + }); + + it('should still fire it normally when the component is not destroyed', () => { + const appTelemetry = TestBed.inject(AppTelemetryService); + const trackClick = jest.spyOn(appTelemetry, 'trackClick'); + + component.onSearchChanged('button'); + jest.advanceTimersByTime(400); + + expect(trackClick).toHaveBeenCalledWith( + 'sidebar_searched', + expect.objectContaining({ resultCount: expect.any(Number) }) + ); + }); + }); + describe('template', () => { it('renders nav with Main navigation aria-label', () => { const nav = fixture.debugElement.query(By.css('nav')); diff --git a/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.ts b/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.ts index d9628716e..404e8fab6 100644 --- a/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.ts +++ b/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.ts @@ -1,8 +1,10 @@ import { CommonModule } from '@angular/common'; import { Component, + DestroyRef, ElementRef, EventEmitter, + inject, Input, OnInit, Output, @@ -10,9 +12,13 @@ import { ViewChildren, ChangeDetectionStrategy } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { RouterLinkActive, RouterModule } from '@angular/router'; +import { Subject } from 'rxjs'; +import { debounceTime, filter } from 'rxjs/operators'; import { CpsInputComponent } from 'cps-ui-kit'; +import { AppTelemetryService } from '../../services/app-telemetry.service'; @Component({ imports: [RouterModule, CommonModule, FormsModule, CpsInputComponent], @@ -188,12 +194,31 @@ export class NavigationSidebarComponent implements OnInit { searchVal = ''; + private readonly appTelemetry = inject(AppTelemetryService); + private readonly destroyRef = inject(DestroyRef); + + /** Reports the search once typing pauses, not on every keystroke. */ + private readonly searchTelemetry$ = new Subject(); + ngOnInit(): void { this.filteredComponents = [...this._components]; + + this.searchTelemetry$ + .pipe( + debounceTime(400), + filter((value) => !!value), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.appTelemetry.trackClick('sidebar_searched', { + resultCount: this.filteredComponents.length + }); + }); } onSearchChanged(value: string) { this._filterComponentsList(value); + this.searchTelemetry$.next(value); } private _filterComponentsList(searchStr: string) { @@ -210,6 +235,8 @@ export class NavigationSidebarComponent implements OnInit { onLinkClick() { if (this.searchVal) { + // Resets the debounce timer so a pending search report doesn't fire after the search is cleared. + this.searchTelemetry$.next(''); this.searchVal = ''; this.filteredComponents = [...this._components]; } diff --git a/projects/composition/src/app/components/theme-toggle/theme-toggle.component.spec.ts b/projects/composition/src/app/components/theme-toggle/theme-toggle.component.spec.ts new file mode 100644 index 000000000..5950ca557 --- /dev/null +++ b/projects/composition/src/app/components/theme-toggle/theme-toggle.component.spec.ts @@ -0,0 +1,134 @@ +import { TestBed } from '@angular/core/testing'; +import { CpsThemeService } from 'cps-ui-kit'; +import { AppTelemetryService } from '../../services/app-telemetry.service'; +import { ThemeToggleComponent } from './theme-toggle.component'; + +describe('ThemeToggleComponent', () => { + let component: ThemeToggleComponent; + let themeService: { + isDark: jest.Mock; + colorTheme: jest.Mock; + radiusTheme: jest.Mock; + baseTheme: jest.Mock; + toggleTheme: jest.Mock; + setColorTheme: jest.Mock; + setRadiusTheme: jest.Mock; + setBaseTheme: jest.Mock; + }; + let appTelemetry: { trackThemeChanged: jest.Mock; trackClick: jest.Mock }; + + beforeEach(() => { + themeService = { + isDark: jest.fn(() => false), + colorTheme: jest.fn(() => 'neutral'), + radiusTheme: jest.fn(() => 'rounded'), + baseTheme: jest.fn(() => 'default'), + toggleTheme: jest.fn(), + setColorTheme: jest.fn(), + setRadiusTheme: jest.fn(), + setBaseTheme: jest.fn() + }; + appTelemetry = { + trackThemeChanged: jest.fn(), + trackClick: jest.fn() + }; + + TestBed.configureTestingModule({ + imports: [ThemeToggleComponent], + providers: [ + { provide: CpsThemeService, useValue: themeService }, + { provide: AppTelemetryService, useValue: appTelemetry } + ] + }); + + component = TestBed.createComponent(ThemeToggleComponent).componentInstance; + }); + + describe('toggleTheme', () => { + it('should toggle the underlying theme and report the mode it switched to', () => { + themeService.isDark.mockReturnValue(true); + + component.toggleTheme(); + + expect(themeService.toggleTheme).toHaveBeenCalled(); + expect(appTelemetry.trackThemeChanged).toHaveBeenCalledWith('dark'); + }); + + it('should report light when the toggle lands back on light mode', () => { + themeService.isDark.mockReturnValue(false); + + component.toggleTheme(); + + expect(appTelemetry.trackThemeChanged).toHaveBeenCalledWith('light'); + }); + + it('should read the resulting mode after toggling, not before', () => { + let toggled = false; + themeService.isDark.mockImplementation(() => toggled); + themeService.toggleTheme.mockImplementation(() => { + toggled = true; + }); + + component.toggleTheme(); + + expect(appTelemetry.trackThemeChanged).toHaveBeenCalledWith('dark'); + }); + }); + + describe('theme dimension changes', () => { + it('should apply and report a color theme change', () => { + component.setColorTheme('passion'); + + expect(themeService.setColorTheme).toHaveBeenCalledWith('passion'); + expect(appTelemetry.trackClick).toHaveBeenCalledWith( + 'theme_option_changed', + { dimension: 'color', value: 'passion' } + ); + }); + + it('should apply and report a radius theme change', () => { + component.setRadiusTheme('pill'); + + expect(themeService.setRadiusTheme).toHaveBeenCalledWith('pill'); + expect(appTelemetry.trackClick).toHaveBeenCalledWith( + 'theme_option_changed', + { dimension: 'radius', value: 'pill' } + ); + }); + + it('should apply and report a base theme change', () => { + component.setBaseTheme('midnight'); + + expect(themeService.setBaseTheme).toHaveBeenCalledWith('midnight'); + expect(appTelemetry.trackClick).toHaveBeenCalledWith( + 'theme_option_changed', + { dimension: 'base', value: 'midnight' } + ); + }); + }); + + describe('menu', () => { + it('should open and close the menu', () => { + expect(component.menuOpen).toBe(false); + + component.toggleMenu(); + expect(component.menuOpen).toBe(true); + + component.closeMenu(); + expect(component.menuOpen).toBe(false); + }); + + it('should close an open menu on Escape', () => { + component.toggleMenu(); + + component.onEscapeKey(); + + expect(component.menuOpen).toBe(false); + }); + + it('should do nothing on Escape when the menu is already closed', () => { + expect(() => component.onEscapeKey()).not.toThrow(); + expect(component.menuOpen).toBe(false); + }); + }); +}); diff --git a/projects/composition/src/app/components/theme-toggle/theme-toggle.component.ts b/projects/composition/src/app/components/theme-toggle/theme-toggle.component.ts index 6c40793b3..90523845f 100644 --- a/projects/composition/src/app/components/theme-toggle/theme-toggle.component.ts +++ b/projects/composition/src/app/components/theme-toggle/theme-toggle.component.ts @@ -6,6 +6,7 @@ import { CpsRadiusTheme, CpsThemeService } from 'cps-ui-kit'; +import { AppTelemetryService } from '../../services/app-telemetry.service'; @Component({ selector: 'app-theme-toggle', @@ -19,6 +20,7 @@ import { }) export class ThemeToggleComponent { private themeService = inject(CpsThemeService); + private appTelemetry = inject(AppTelemetryService); isDark = this.themeService.isDark; colorTheme = this.themeService.colorTheme; @@ -28,6 +30,9 @@ export class ThemeToggleComponent { toggleTheme(): void { this.themeService.toggleTheme(); + this.appTelemetry.trackThemeChanged( + this.themeService.isDark() ? 'dark' : 'light' + ); } toggleMenu(): void { @@ -45,14 +50,26 @@ export class ThemeToggleComponent { } setColorTheme(value: CpsColorTheme): void { - this.themeService.setColorTheme(value); + this._applyTheme('color', value, (v) => this.themeService.setColorTheme(v)); } setRadiusTheme(value: CpsRadiusTheme): void { - this.themeService.setRadiusTheme(value); + this._applyTheme('radius', value, (v) => + this.themeService.setRadiusTheme(v) + ); } setBaseTheme(value: CpsBaseTheme): void { - this.themeService.setBaseTheme(value); + this._applyTheme('base', value, (v) => this.themeService.setBaseTheme(v)); + } + + /** Applies one theme dimension and reports it. */ + private _applyTheme( + dimension: string, + value: T, + setter: (value: T) => void + ): void { + setter(value); + this.appTelemetry.trackClick('theme_option_changed', { dimension, value }); } } diff --git a/projects/composition/src/app/components/viewer/viewer.component.spec.ts b/projects/composition/src/app/components/viewer/viewer.component.spec.ts index ed39d369b..70fb24ece 100644 --- a/projects/composition/src/app/components/viewer/viewer.component.spec.ts +++ b/projects/composition/src/app/components/viewer/viewer.component.spec.ts @@ -57,12 +57,9 @@ describe('ViewerComponent', () => { fixture.detectChanges(); }); - it('navigates to ./examples when no type param is present', () => { + it('does not navigate when no type param is present (handled upstream by defaultTabRedirectGuard)', () => { routeParams$.next({}); - expect(mockRouter.navigate).toHaveBeenCalledWith(['./examples'], { - relativeTo: expect.any(Object), - replaceUrl: true - }); + expect(mockRouter.navigate).not.toHaveBeenCalled(); }); it('sets selectedTabIndex to 0 when type is "examples"', () => { @@ -79,7 +76,7 @@ describe('ViewerComponent', () => { ).toBe(1); }); - it('navigates to ./examples and resets selectedTabIndex to 0 on subsequent emission without type (component reuse)', () => { + it('does not navigate or change selectedTabIndex on a subsequent emission without type (component reuse)', () => { routeParams$.next({ type: 'api' }); expect( (component as unknown as { selectedTabIndex: number }).selectedTabIndex @@ -88,10 +85,10 @@ describe('ViewerComponent', () => { mockRouter.navigate.mockClear(); routeParams$.next({}); - expect(mockRouter.navigate).toHaveBeenCalledWith(['./examples'], { - relativeTo: expect.any(Object), - replaceUrl: true - }); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + expect( + (component as unknown as { selectedTabIndex: number }).selectedTabIndex + ).toBe(1); }); }); diff --git a/projects/composition/src/app/components/viewer/viewer.component.ts b/projects/composition/src/app/components/viewer/viewer.component.ts index db615a666..5c52a982c 100644 --- a/projects/composition/src/app/components/viewer/viewer.component.ts +++ b/projects/composition/src/app/components/viewer/viewer.component.ts @@ -27,13 +27,6 @@ export abstract class ViewerComponent implements OnInit, AfterViewInit { this._route.params .pipe(takeUntilDestroyed(this._destroyRef)) .subscribe((params) => { - if (!params.type) { - this._router.navigate(['./examples'], { - relativeTo: this._route, - replaceUrl: true - }); - return; - } if (params.type === 'examples') { this.selectedTabIndex = 0; } else if (params.type === 'api') { diff --git a/projects/composition/src/app/deployment-environment.spec.ts b/projects/composition/src/app/deployment-environment.spec.ts new file mode 100644 index 000000000..0fb613c29 --- /dev/null +++ b/projects/composition/src/app/deployment-environment.spec.ts @@ -0,0 +1,30 @@ +import { + classifyHostname, + resolveDeploymentEnvironment +} from './deployment-environment'; + +describe('classifyHostname', () => { + it.each(['localhost', '127.0.0.1', '[::1]', '0.0.0.0'])( + "should resolve '%s' to development", + (hostname) => { + expect(classifyHostname(hostname)).toBe('development'); + } + ); + + it.each(['app.example.com', 'staging.example.com', '203.0.113.5'])( + "should resolve '%s' to production", + (hostname) => { + expect(classifyHostname(hostname)).toBe('production'); + } + ); + + it('should default to production with no hostname at all', () => { + expect(classifyHostname(undefined)).toBe('production'); + }); +}); + +describe('resolveDeploymentEnvironment', () => { + it('should read the real window.location.hostname', () => { + expect(resolveDeploymentEnvironment()).toBe('development'); + }); +}); diff --git a/projects/composition/src/app/deployment-environment.ts b/projects/composition/src/app/deployment-environment.ts new file mode 100644 index 000000000..cfac39607 --- /dev/null +++ b/projects/composition/src/app/deployment-environment.ts @@ -0,0 +1,15 @@ +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '0.0.0.0']); + +export function classifyHostname( + hostname: string | undefined +): 'development' | 'production' { + return hostname !== undefined && LOCAL_HOSTNAMES.has(hostname) + ? 'development' + : 'production'; +} + +export function resolveDeploymentEnvironment(): 'development' | 'production' { + return classifyHostname( + typeof window !== 'undefined' ? window.location.hostname : undefined + ); +} diff --git a/projects/composition/src/app/guards/default-tab-redirect.guard.spec.ts b/projects/composition/src/app/guards/default-tab-redirect.guard.spec.ts new file mode 100644 index 000000000..89ac9b273 --- /dev/null +++ b/projects/composition/src/app/guards/default-tab-redirect.guard.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { Route, Router, UrlSegment, UrlTree } from '@angular/router'; +import { defaultTabRedirectGuard } from './default-tab-redirect.guard'; + +describe('defaultTabRedirectGuard', () => { + let mockRouter: { parseUrl: jest.Mock }; + + beforeEach(() => { + mockRouter = { + parseUrl: jest.fn((url: string) => ({ url }) as unknown as UrlTree) + }; + + TestBed.configureTestingModule({ + providers: [{ provide: Router, useValue: mockRouter }] + }); + }); + + function runGuard(segments: string[]) { + const urlSegments = segments.map((path) => new UrlSegment(path, {})); + return TestBed.runInInjectionContext(() => + defaultTabRedirectGuard({} as Route, urlSegments, undefined as never) + ); + } + + it('allows matching when more than one segment is present', () => { + const result = runGuard(['checkbox', 'examples']); + expect(result).toBe(true); + expect(mockRouter.parseUrl).not.toHaveBeenCalled(); + }); + + it('redirects to the examples tab when only the bare segment is present', () => { + runGuard(['checkbox']); + expect(mockRouter.parseUrl).toHaveBeenCalledWith('/checkbox/examples'); + }); +}); diff --git a/projects/composition/src/app/guards/default-tab-redirect.guard.ts b/projects/composition/src/app/guards/default-tab-redirect.guard.ts new file mode 100644 index 000000000..777adaa6f --- /dev/null +++ b/projects/composition/src/app/guards/default-tab-redirect.guard.ts @@ -0,0 +1,12 @@ +import { inject } from '@angular/core'; +import { CanMatchFn, Router } from '@angular/router'; + +export const defaultTabRedirectGuard: CanMatchFn = (_route, segments) => { + if (segments.length !== 1) { + return true; + } + + const router = inject(Router); + const path = segments.map((segment) => segment.path).join('/'); + return router.parseUrl(`/${path}/examples`); +}; diff --git a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.spec.ts b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.spec.ts new file mode 100644 index 000000000..68590c067 --- /dev/null +++ b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.spec.ts @@ -0,0 +1,218 @@ +import { TestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { throwError } from 'rxjs'; +import { + CPS_LOG_API_PROVIDER, + CpsNoopTelemetrySink, + CpsScenarioTelemetryService, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from '../../services/app-log-api.provider'; +import { AutocompletePageComponent } from './autocomplete-page.component'; + +describe('AutocompletePageComponent', () => { + let component: AutocompletePageComponent; + let scenarioTelemetry: CpsScenarioTelemetryService; + + function createComponent(): AutocompletePageComponent { + return TestBed.runInInjectionContext( + () => new AutocompletePageComponent(TestBed.inject(FormBuilder)) + ); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } + ] + }).compileComponents(); + + component = createComponent(); + component.ngOnInit(); + component.singleOptionsObservable$?.subscribe(); + component.multiOptionsObservable$?.subscribe(); + scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + }); + + afterEach(() => jest.restoreAllMocks()); + + describe('search error handling', () => { + function makeSearchThrow(): void { + jest + .spyOn( + component as unknown as { _getOptionsFromServer: unknown }, + '_getOptionsFromServer' + ) + .mockReturnValue(throwError(() => new Error('backend down'))); + } + + it('should not leave the spinner stuck when a search errors', () => { + makeSearchThrow(); + component.onSingleInputChanged('lon'); + + expect(component.isSingleLoading).toBe(false); + }); + + it('should not leave the scenario open when a search errors', () => { + makeSearchThrow(); + component.onSingleInputChanged('lon'); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should settle the scenario as failed, carrying the error', () => { + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + makeSearchThrow(); + component.onSingleInputChanged('lon'); + + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'failure', + error: expect.objectContaining({ message: 'backend down' }) + }) + ); + }); + + it('should not kill the pipeline for later searches after one errors', () => { + makeSearchThrow(); + component.onSingleInputChanged('lon'); + expect(component.isSingleLoading).toBe(false); + + jest.restoreAllMocks(); + component.onSingleInputChanged('par'); + + expect(component.isSingleLoading).toBe(true); + }); + }); + + describe('onOptionSelected', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('should cancel a still-running validation when a newer selection supersedes it', () => { + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + component.onOptionSelected(component.options[0]); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + + component.onOptionSelected(component.options[1]); + + expect(scenarioTelemetry.getActive()).toHaveLength(1); + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ status: 'abandoned' }) + ); + }); + + it("should let ngOnDestroy cancel the newer selection even past the superseded one's original timer mark", () => { + component.onOptionSelected(component.options[0]); + component.onOptionSelected(component.options[1]); + + jest.advanceTimersByTime(3000); + component.ngOnDestroy(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should settle the newer selection successfully once its own delay elapses', () => { + component.onOptionSelected(component.options[0]); + component.onOptionSelected(component.options[1]); + + jest.advanceTimersByTime(3000); + + expect(component.validating).toBe(false); + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should not leave the validating flag stuck when validation errors', () => { + jest + .spyOn( + component as unknown as { _validateOption: unknown }, + '_validateOption' + ) + .mockReturnValue( + throwError(() => new Error('validation backend down')) + ); + + component.onOptionSelected(component.options[0]); + + expect(component.validating).toBe(false); + expect(component.externalError).toBe('Validation failed'); + }); + + it('should not leave the scenario open when validation errors', () => { + jest + .spyOn( + component as unknown as { _validateOption: unknown }, + '_validateOption' + ) + .mockReturnValue( + throwError(() => new Error('validation backend down')) + ); + + component.onOptionSelected(component.options[0]); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should not kill the pipeline for later selections after one errors', () => { + jest + .spyOn( + component as unknown as { _validateOption: unknown }, + '_validateOption' + ) + .mockReturnValueOnce( + throwError(() => new Error('validation backend down')) + ); + + component.onOptionSelected(component.options[0]); + expect(component.validating).toBe(false); + + component.onOptionSelected(component.options[1]); + expect(component.validating).toBe(true); + }); + }); + + describe('ngOnDestroy', () => { + it('should cancel an in-flight single search, not leave it open', () => { + component.onSingleInputChanged('lon'); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + + component.ngOnDestroy(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should cancel an in-flight multi search independently of the single one', () => { + component.onSingleInputChanged('lon'); + component.onMultiInputChanged('par'); + expect(scenarioTelemetry.getActive()).toHaveLength(2); + + component.ngOnDestroy(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should cancel an in-flight selection validation', () => { + component.onOptionSelected(component.options[0]); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + + component.ngOnDestroy(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should not throw when nothing is in flight', () => { + expect(() => component.ngOnDestroy()).not.toThrow(); + }); + }); +}); diff --git a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.ts b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.ts index b21f67591..ec58db7eb 100644 --- a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.ts +++ b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.ts @@ -1,4 +1,10 @@ -import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { + Component, + OnInit, + OnDestroy, + ChangeDetectionStrategy, + inject +} from '@angular/core'; import { FormsModule, ReactiveFormsModule, @@ -11,9 +17,16 @@ import { ComponentDocsViewerComponent } from '../../components/component-docs-vi import { CodeExampleComponent } from '../../components/code-example/code-example.component'; import ComponentData from '../../api-data/cps-autocomplete.json'; import { autocompleteExamples } from './autocomplete-page.examples'; -import { Observable, Subject, of, delay } from 'rxjs'; -import { switchMap, tap } from 'rxjs/operators'; +import { Observable, Subject, Subscription, of, delay } from 'rxjs'; +import { catchError, switchMap, tap } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; +import { + CpsLoggerService, + CpsScenario, + CpsScenarioTelemetryService, + traceScenario +} from 'cps-telemetry'; +import '../../services/telemetry.schema'; @Component({ imports: [ @@ -30,7 +43,7 @@ import { CommonModule } from '@angular/common'; changeDetection: ChangeDetectionStrategy.Eager, host: { class: 'composition-page' } }) -export class AutocompletePageComponent implements OnInit { +export class AutocompletePageComponent implements OnInit, OnDestroy { options = [ { name: 'New York', data: { code: 'NY' }, alias: 'NYC' }, { @@ -89,6 +102,42 @@ export class AutocompletePageComponent implements OnInit { validating = false; selectedOption: any = null; + private readonly scenarioTelemetry = inject(CpsScenarioTelemetryService); + private readonly logger = inject(CpsLoggerService).getLogger('autocomplete'); + + /** The in-flight search per autocomplete, so a newer query can cancel it. */ + private singleSearchScenario?: CpsScenario; + private multiSearchScenario?: CpsScenario; + /** The in-flight selection validation, so a newer one can cancel it. */ + private validateScenario?: CpsScenario; + + private readonly _validateOptionSubject$ = new Subject(); + private _validateSubscription?: Subscription; + + /** Per-side accessors for the two identically-shaped autocompletes. */ + private readonly _searchSides: Record< + 'single' | 'multi', + { + operation: 'single' | 'multi'; + getScenario: () => CpsScenario | undefined; + setScenario: (scenario: CpsScenario | undefined) => void; + setLoading: (loading: boolean) => void; + } + > = { + single: { + operation: 'single', + getScenario: () => this.singleSearchScenario, + setScenario: (scenario) => (this.singleSearchScenario = scenario), + setLoading: (loading) => (this.isSingleLoading = loading) + }, + multi: { + operation: 'multi', + getScenario: () => this.multiSearchScenario, + setScenario: (scenario) => (this.multiSearchScenario = scenario), + setLoading: (loading) => (this.isMultiLoading = loading) + } + }; + get availableOptionInfo() { return this.options.map((option) => option.name).join(', '); } @@ -104,13 +153,22 @@ export class AutocompletePageComponent implements OnInit { this.singleOptionsObservable$ = this._defineOptionsObservable( this._singleFilterOptionSubject$, - true + 'single' ); this.multiOptionsObservable$ = this._defineOptionsObservable( this._multiFilterOptionSubject$, - false + 'multi' ); + + this._validateSubscription = this._defineValidateOptionObservable(); + } + + ngOnDestroy(): void { + this.singleSearchScenario?.cancel({ reason: 'component-destroyed' }); + this.multiSearchScenario?.cancel({ reason: 'component-destroyed' }); + this.validateScenario?.cancel({ reason: 'component-destroyed' }); + this._validateSubscription?.unsubscribe(); } onSingleInputChanged(val: string) { @@ -128,24 +186,62 @@ export class AutocompletePageComponent implements OnInit { private _defineOptionsObservable( subject$: Subject, - single: boolean + side: 'single' | 'multi' ): Observable | undefined { + const state = this._searchSides[side]; + return subject$.pipe( switchMap((value) => { - if (single) this.isSingleLoading = true; - else this.isMultiLoading = true; + state.getScenario()?.cancel({ reason: 'superseded' }); + + const scenario = this.scenarioTelemetry.start({ + name: 'autocomplete-search', + feature: 'autocomplete', + operation: state.operation + }); + scenario.step('query'); + state.setScenario(scenario); + state.setLoading(true); + + let resultCount = 0; return this._getOptionsFromServer(value).pipe( tap({ + next: (results: unknown[]) => { + resultCount = results.length; + }, complete: () => { - if (single) this.isSingleLoading = false; - else this.isMultiLoading = false; + scenario.complete({ metadata: { resultCount } }); + this._clearSearchState(side); } + }), + catchError((error: unknown) => { + this.logger.error('Autocomplete search failed', { + error, + context: 'Autocomplete', + correlationId: scenario.id + }); + scenario.fail({ error }); + this._clearSearchState(side); + return of([]); }) ); }) ); } + /** Resets the loading flag and forgets the finished scenario for one side. */ + private _clearSearchState(side: 'single' | 'multi'): void { + const state = this._searchSides[side]; + state.setLoading(false); + state.setScenario(undefined); + } + + /** The `validate` counterpart of {@link _clearSearchState}. */ + private _clearValidateState(): void { + this.validating = false; + this.validateScenario = undefined; + } + private _getOptionsFromServer(val: string): Observable { const filteredRes = this.options.filter((option) => { return option.name?.toLowerCase()?.includes(val); @@ -153,25 +249,51 @@ export class AutocompletePageComponent implements OnInit { return of(filteredRes).pipe(delay(1000)); } + /** Simulates async validation of a selected option with a delay. */ + private _validateOption(option: any): Observable { + return of(option).pipe(delay(3000)); + } + // Method to handle selection changes for async validation onOptionSelected(option: any) { this.validating = true; this.selectedOption = option; this.externalError = ''; - // Simulate async validation with a delay - of(option) + this._validateOptionSubject$.next(option); + } + + /** Routes selections through `switchMap` so a newer one cancels a running validation. */ + private _defineValidateOptionObservable(): Subscription { + return this._validateOptionSubject$ .pipe( - delay(3000) // Simulate a delay of 2 seconds + switchMap((option) => { + this.validateScenario?.cancel({ reason: 'superseded' }); + + const scenario = this.scenarioTelemetry.start({ + name: 'autocomplete-validate', + feature: 'autocomplete' + }); + scenario.step('validate'); + this.validateScenario = scenario; + + return this._validateOption(option).pipe( + traceScenario(scenario), + tap(() => this._clearValidateState()), + catchError((error: unknown) => { + // Handle errors and finalize validation state + this.externalError = 'Validation failed'; + this.logger.error('Autocomplete selection failed validation', { + error, + context: 'Autocomplete', + correlationId: scenario.id + }); + this._clearValidateState(); + return of(undefined); + }) + ); + }) ) - .subscribe({ - next: () => { - this.validating = false; - }, - error: () => { - // Handle errors and finalize validation state - this.externalError = 'Validation failed'; - } - }); + .subscribe(); } readonly examples = autocompleteExamples; diff --git a/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.html b/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.html index f0bce5632..9eec1da03 100644 --- a/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.html +++ b/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.html @@ -37,9 +37,9 @@ ariaLabel="Upload pictures or PDFs" width="31.25rem" fileNameTooltipOffset="0.9375rem" - [fileProcessingCallback]="processUploadedFile" + [fileProcessingCallback]="processExtraInfoUploadedFile" (fileProcessingFailed)="onFileProcessingFailed($event)" - (fileProcessingCancelled)="onFileProcessingCancelled($event)" + (fileProcessingCancelled)="onExtraInfoFileProcessingCancelled($event)" (fileUploadFailed)="onFileUploadFailed($event)" (fileUploaded)="onFileUploaded($event)" (fileProcessed)="onFileProcessed($event)" @@ -58,6 +58,7 @@ width="31.25rem" [fileProcessingCallback]="processFailingUploadedFile" (fileProcessingFailed)="onFileProcessingFailed($event)" + (fileProcessingCancelled)="onFailingFileProcessingCancelled()" (fileUploaded)="onFileUploaded($event)"> @@ -75,9 +76,9 @@ width="31.25rem" fileNameTooltipOffset="0.9375rem" [disabled]="isDisabled" - [fileProcessingCallback]="processUploadedFile" + [fileProcessingCallback]="processDisabledUploadedFile" (fileProcessingFailed)="onFileProcessingFailed($event)" - (fileProcessingCancelled)="onFileProcessingCancelled($event)" + (fileProcessingCancelled)="onDisabledFileProcessingCancelled($event)" (fileUploadFailed)="onFileUploadFailed($event)" (fileUploaded)="onFileUploaded($event)" (fileProcessed)="onFileProcessed($event)" diff --git a/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.spec.ts b/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.spec.ts new file mode 100644 index 000000000..979ad803f --- /dev/null +++ b/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.spec.ts @@ -0,0 +1,175 @@ +import { TestBed } from '@angular/core/testing'; +import { take } from 'rxjs'; +import { + CPS_LOG_API_PROVIDER, + CpsNoopTelemetrySink, + CpsScenarioTelemetryService, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from '../../services/app-log-api.provider'; +import { FileUploadPageComponent } from './file-upload-page.component'; + +/** jsdom's `File` has no `.text()`, so this stands in with just what's used. */ +function makeFile(name: string): File { + return { + name, + text: () => Promise.resolve('contents') + } as unknown as File; +} + +describe('FileUploadPageComponent', () => { + let component: FileUploadPageComponent; + let scenarioTelemetry: CpsScenarioTelemetryService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } + ] + }).compileComponents(); + + component = TestBed.runInInjectionContext( + () => new FileUploadPageComponent() + ); + scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + }); + + describe('processing scenarios within one widget', () => { + it("should track each filename's scenario independently", () => { + component.processExtraInfoUploadedFile(makeFile('a.txt')).subscribe(); + component.processExtraInfoUploadedFile(makeFile('b.txt')).subscribe(); + + expect(scenarioTelemetry.getActive()).toHaveLength(2); + }); + + it('should cancel only the file that fired the cancel, not a different one still in flight', () => { + component.processExtraInfoUploadedFile(makeFile('a.txt')).subscribe(); + component.processExtraInfoUploadedFile(makeFile('b.txt')).subscribe(); + + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + component.onExtraInfoFileProcessingCancelled('a.txt'); + + expect(settled).toHaveBeenCalledTimes(1); + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ status: 'abandoned' }) + ); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + }); + + it('should not leave either scenario open once both are cancelled', () => { + component.processExtraInfoUploadedFile(makeFile('a.txt')).subscribe(); + component.processExtraInfoUploadedFile(makeFile('b.txt')).subscribe(); + + component.onExtraInfoFileProcessingCancelled('a.txt'); + component.onExtraInfoFileProcessingCancelled('b.txt'); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should tolerate a cancel for a filename with nothing in flight', () => { + expect(() => + component.onExtraInfoFileProcessingCancelled('never-started.txt') + ).not.toThrow(); + }); + }); + + describe('processing scenarios across the two widgets', () => { + it('should track both scenarios independently even when the two widgets upload a same-named file', () => { + component + .processExtraInfoUploadedFile(makeFile('report.csv')) + .subscribe(); + component.processDisabledUploadedFile(makeFile('report.csv')).subscribe(); + + expect(scenarioTelemetry.getActive()).toHaveLength(2); + }); + + it("should cancel only the widget that fired the cancel, not the other widget's same-named file", () => { + component + .processExtraInfoUploadedFile(makeFile('report.csv')) + .subscribe(); + component.processDisabledUploadedFile(makeFile('report.csv')).subscribe(); + + component.onExtraInfoFileProcessingCancelled('report.csv'); + + expect(scenarioTelemetry.getActive()).toHaveLength(1); + + component.onDisabledFileProcessingCancelled('report.csv'); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + }); + + describe('successful processing', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('should settle the scenario as success, even wrapped in take(1) the way CpsFileUploadComponent consumes it', async () => { + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + component + .processExtraInfoUploadedFile(makeFile('a.txt')) + .pipe(take(1)) + .subscribe(); + + await jest.advanceTimersByTimeAsync(3000); + + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ status: 'success' }) + ); + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + }); + + describe('onFailingFileProcessingCancelled', () => { + it('should cancel the failing-widget scenario, not leave it open until its timeout', () => { + component.processFailingUploadedFile().subscribe(); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + + component.onFailingFileProcessingCancelled(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should settle it as abandoned, not let it self-settle as a timeout', () => { + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + component.processFailingUploadedFile().subscribe(); + component.onFailingFileProcessingCancelled(); + + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ status: 'abandoned' }) + ); + }); + + it('should tolerate a cancel with nothing in flight', () => { + expect(() => component.onFailingFileProcessingCancelled()).not.toThrow(); + }); + }); + + describe('ngOnDestroy', () => { + it('should cancel every widget still processing when the page is destroyed', () => { + component.processExtraInfoUploadedFile(makeFile('a.txt')).subscribe(); + component.processDisabledUploadedFile(makeFile('b.txt')).subscribe(); + component.processFailingUploadedFile().subscribe(); + + component.ngOnDestroy(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should not throw when nothing is in flight', () => { + expect(() => component.ngOnDestroy()).not.toThrow(); + }); + }); +}); diff --git a/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts b/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts index ddfed7ee5..4895fd406 100644 --- a/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts +++ b/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts @@ -1,4 +1,10 @@ -import { Component, ViewChild, ChangeDetectionStrategy } from '@angular/core'; +import { + Component, + OnDestroy, + ViewChild, + ChangeDetectionStrategy, + inject +} from '@angular/core'; import { CpsFileUploadComponent, CpsButtonToggleComponent, @@ -6,6 +12,12 @@ import { CpsButtonComponent } from 'cps-ui-kit'; import { Observable, catchError, delay, from, map, of } from 'rxjs'; +import { + CpsLoggerService, + CpsScenario, + CpsScenarioTelemetryService +} from 'cps-telemetry'; +import '../../services/telemetry.schema'; import ComponentData from '../../api-data/cps-file-upload.json'; import { ComponentDocsViewerComponent } from '../../components/component-docs-viewer/component-docs-viewer.component'; @@ -26,9 +38,25 @@ import { fileUploadExamples } from './file-upload-page.examples'; changeDetection: ChangeDetectionStrategy.Eager, host: { class: 'composition-page' } }) -export class FileUploadPageComponent { +export class FileUploadPageComponent implements OnDestroy { @ViewChild('fileUpload') fileUpload?: CpsFileUploadComponent; + private readonly scenarioTelemetry = inject(CpsScenarioTelemetryService); + private readonly logger = inject(CpsLoggerService).getLogger('file-upload'); + + /** Processing scenarios in flight for the "extra info" widget, keyed by filename. */ + private extraInfoUploadScenarios = new Map(); + + /** Same as {@link extraInfoUploadScenarios}, for the "disabled" widget. */ + private disabledUploadScenarios = new Map(); + + /** + * The processing scenario for the failing-upload demo widget, which has no + * `fileProcessingCancelled` wiring of its own and whose callback receives + * no `File` to key a map entry by. + */ + private failingUploadScenario?: CpsScenario; + componentData = ComponentData; readonly examples = fileUploadExamples; @@ -45,22 +73,63 @@ export class FileUploadPageComponent { fileInfo: string = 'The file should be a small sample file to infer the schema, which will be shown in the next step'; - processUploadedFile(file: File): Observable { + processExtraInfoUploadedFile = (file: File): Observable => + this._processUploadedFile(file, this.extraInfoUploadScenarios); + + processDisabledUploadedFile = (file: File): Observable => + this._processUploadedFile(file, this.disabledUploadScenarios); + + private _processUploadedFile( + file: File, + scenarios: Map + ): Observable { + const scenario = this._startUploadScenario('process'); + scenarios.set(file.name, scenario); + return from(file.text()).pipe( delay(3000), map((fileContentsAsText) => { console.log(fileContentsAsText); + scenario.complete({ metadata: { fileSize: file.size } }); + scenarios.delete(file.name); return true; }), catchError((error) => { - console.error('Error reading file', error); + scenario.fail({ error }); + this.logger.error('Error reading file', { + error, + context: 'FileUpload', + correlationId: scenario.id + }); + scenarios.delete(file.name); return of(false); }) ); } - processFailingUploadedFile(): Observable { - return of(false).pipe(delay(500)); + processFailingUploadedFile = (): Observable => { + const scenario = this._startUploadScenario('process-failing'); + this.failingUploadScenario = scenario; + + return of(false).pipe( + delay(500), + map((result) => { + scenario.fail(); + this.failingUploadScenario = undefined; + return result; + }) + ); + }; + + /** Starts a `file-upload` scenario and opens its `process` step. */ + private _startUploadScenario(operation: string): CpsScenario { + const scenario = this.scenarioTelemetry.start({ + name: 'file-upload', + feature: 'file-upload', + operation + }); + scenario.step('process'); + return scenario; } onFileUploaded(file: File) { @@ -79,8 +148,41 @@ export class FileUploadPageComponent { console.log('File processing failed', fileName); } - onFileProcessingCancelled(fileName: string) { + onExtraInfoFileProcessingCancelled(fileName: string) { console.log('File processing cancelled', fileName); + this._cancel(this.extraInfoUploadScenarios, fileName, 'user-cancelled'); + } + + onDisabledFileProcessingCancelled(fileName: string) { + console.log('File processing cancelled', fileName); + this._cancel(this.disabledUploadScenarios, fileName, 'user-cancelled'); + } + + onFailingFileProcessingCancelled(): void { + this.failingUploadScenario?.cancel({ reason: 'user-cancelled' }); + this.failingUploadScenario = undefined; + } + + private _cancel( + scenarios: Map, + fileName: string, + reason: string + ): void { + scenarios.get(fileName)?.cancel({ reason }); + scenarios.delete(fileName); + } + + ngOnDestroy(): void { + this._cancelAll(this.extraInfoUploadScenarios); + this._cancelAll(this.disabledUploadScenarios); + this.failingUploadScenario?.cancel({ reason: 'component-destroyed' }); + } + + private _cancelAll(scenarios: Map): void { + for (const scenario of scenarios.values()) { + scenario.cancel({ reason: 'component-destroyed' }); + } + scenarios.clear(); } onUploadedFileRemoved(fileName: string) { diff --git a/projects/composition/src/app/pages/table-page/table-page.component.spec.ts b/projects/composition/src/app/pages/table-page/table-page.component.spec.ts new file mode 100644 index 000000000..312c7fbd9 --- /dev/null +++ b/projects/composition/src/app/pages/table-page/table-page.component.spec.ts @@ -0,0 +1,122 @@ +import { TestBed } from '@angular/core/testing'; +import { + CPS_LOG_API_PROVIDER, + CpsNoopTelemetrySink, + CpsScenarioTelemetryService, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from '../../services/app-log-api.provider'; +import { TablePageComponent } from './table-page.component'; + +describe('TablePageComponent', () => { + let component: TablePageComponent; + let scenarioTelemetry: CpsScenarioTelemetryService; + + beforeEach(async () => { + jest.useFakeTimers(); + + await TestBed.configureTestingModule({ + imports: [TablePageComponent], + providers: [ + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } + ] + }).compileComponents(); + + component = TestBed.runInInjectionContext(() => new TablePageComponent()); + scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + }); + + afterEach(() => jest.useRealTimers()); + + describe('ngOnDestroy', () => { + it('should cancel a lazy-load scenario still in flight, not leave it open', () => { + component.onLazyLoad({ first: 0, rows: 10 }); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + + component.ngOnDestroy(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should settle it as abandoned, not leave it to auto-settle as a misleading timeout', () => { + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + component.onLazyLoad({ first: 0, rows: 10 }); + component.ngOnDestroy(); + + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ status: 'abandoned' }) + ); + }); + + it('should not throw when there is no lazy load in flight', () => { + expect(() => component.ngOnDestroy()).not.toThrow(); + }); + + it('should stop a pending lazy-load timer from completing a destroyed scenario', async () => { + component.onLazyLoad({ first: 0, rows: 10 }); + await Promise.resolve(); + + component.ngOnDestroy(); + jest.advanceTimersByTime(600); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should not populate lazy data via a timer created after a same-tick destroy', async () => { + component.dataVirtual = Array.from({ length: 5 }, (_, i) => ({ + a: `a${i}`, + b: `b${i % 5}`, + c: i, + d: new Date(), + e: i % 2 === 0, + f: new Date() + })); + + component.onLazyLoad({ first: 0, rows: 10 }); + component.ngOnDestroy(); + + await Promise.resolve(); + jest.advanceTimersByTime(600); + + expect(component.lazyData).toEqual([]); + expect(component.lazyLoading).toBe(false); + }); + }); + + describe('onLazyLoad', () => { + it('should aggregate one format-row call per fetched row', async () => { + component.dataVirtual = Array.from({ length: 10 }, (_, i) => ({ + a: `a${i}`, + b: `b${i % 5}`, + c: i, + d: new Date(), + e: i % 2 === 0, + f: new Date() + })); + + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + component.onLazyLoad({ first: 0, rows: 10 }); + await Promise.resolve(); + jest.advanceTimersByTime(600); + + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ + aggregates: [ + expect.objectContaining({ name: 'format-row', callCount: 10 }) + ] + }) + ); + }); + }); +}); diff --git a/projects/composition/src/app/pages/table-page/table-page.component.ts b/projects/composition/src/app/pages/table-page/table-page.component.ts index c2f84a5a7..315e3d50d 100644 --- a/projects/composition/src/app/pages/table-page/table-page.component.ts +++ b/projects/composition/src/app/pages/table-page/table-page.component.ts @@ -30,6 +30,8 @@ import { tableExamples } from './table-page.examples'; import ComponentData from '../../api-data/cps-table.json'; import { DatePipe, PercentPipe, UpperCasePipe } from '@angular/common'; +import { CpsScenario, CpsScenarioTelemetryService } from 'cps-telemetry'; +import '../../services/telemetry.schema'; @Component({ selector: 'app-table-page', @@ -415,6 +417,10 @@ export class TablePageComponent implements OnInit, OnDestroy { private _lazyLoadTimeout?: ReturnType; private _lastLazyFirst = -1; private _lastLazyRows = -1; + private readonly _scenarioTelemetry = inject(CpsScenarioTelemetryService); + private _lazyLoadScenario?: CpsScenario; + /** Checked in the queued callbacks below, past `ngOnDestroy`'s clearTimeout. */ + private _destroyed = false; manyRecordsForPaginator = false; @@ -443,7 +449,9 @@ export class TablePageComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this._destroyed = true; clearTimeout(this._lazyLoadTimeout); + this._lazyLoadScenario?.cancel({ reason: 'component-destroyed' }); } private _genVirtualData() { @@ -506,13 +514,36 @@ export class TablePageComponent implements OnInit, OnDestroy { this._lastLazyFirst = first; this._lastLazyRows = rows; + this._lazyLoadScenario?.cancel({ reason: 'superseded' }); + const scenario = this._scenarioTelemetry.start({ + name: 'table-page-load', + feature: 'table' + }); + scenario.step('fetch'); + this._lazyLoadScenario = scenario; + Promise.resolve().then(() => { + if (this._destroyed) return; this.lazyLoading = true; clearTimeout(this._lazyLoadTimeout); this._lazyLoadTimeout = setTimeout(() => { + if (this._destroyed) return; this.lazyTotalRecords = this.dataVirtual.length; - this.lazyData = this.dataVirtual.slice(first, first + rows); + + this.lazyData = this.dataVirtual + .slice(first, first + rows) + .map((row) => { + scenario.aggregateStart('format-row'); + const formatted = { ...row }; + scenario.aggregateEnd('format-row'); + return formatted; + }); + this.lazyLoading = false; + scenario.complete({ + metadata: { first, rows, totalRecords: this.lazyTotalRecords } + }); + this._lazyLoadScenario = undefined; }, 600); }); } diff --git a/projects/composition/src/app/services/app-log-api.provider.spec.ts b/projects/composition/src/app/services/app-log-api.provider.spec.ts new file mode 100644 index 000000000..1cc9035db --- /dev/null +++ b/projects/composition/src/app/services/app-log-api.provider.spec.ts @@ -0,0 +1,194 @@ +import { TestBed } from '@angular/core/testing'; +import { CpsLogRecord } from 'cps-telemetry'; +import { AppLogApiProvider } from './app-log-api.provider'; + +/** Builds a minimal, valid CpsLogRecord, overridable per test. */ +function record(overrides: Partial = {}): CpsLogRecord { + return { + timestamp: '2024-01-01T00:00:00.000Z', + level: 'log', + message: 'hello', + application: 'composition', + environment: 'test', + version: '1.0.0', + ...overrides + }; +} + +describe('AppLogApiProvider', () => { + let provider: AppLogApiProvider; + + beforeEach(() => { + TestBed.configureTestingModule({}); + provider = TestBed.inject(AppLogApiProvider); + }); + + describe('send / getRecords', () => { + it('should retain sent records, oldest first', () => { + provider.send(record({ message: 'first' })); + provider.send(record({ message: 'second' })); + + expect(provider.getRecords().map((r) => r.message)).toEqual([ + 'first', + 'second' + ]); + }); + + it('should return a copy, not the live buffer', () => { + provider.send(record()); + const records = provider.getRecords(); + records.push(record({ message: 'injected' })); + + expect(provider.getRecords()).toHaveLength(1); + }); + + it('should evict the oldest records once the buffer limit is exceeded', () => { + for (let i = 0; i < 505; i++) { + provider.send(record({ message: `msg-${i}` })); + } + + const records = provider.getRecords(); + expect(records).toHaveLength(500); + expect(records[0].message).toBe('msg-5'); + expect(records[records.length - 1].message).toBe('msg-504'); + }); + }); + + describe('query', () => { + beforeEach(() => { + provider.send( + record({ + correlationId: 'c-1', + logger: 'file-upload', + level: 'log', + timestamp: '2024-01-01T00:00:00.000Z' + }) + ); + provider.send( + record({ + correlationId: 'c-2', + logger: 'file-upload', + level: 'warn', + timestamp: '2024-01-02T00:00:00.000Z' + }) + ); + provider.send( + record({ + correlationId: 'c-1', + logger: 'autocomplete', + level: 'error', + timestamp: '2024-01-03T00:00:00.000Z' + }) + ); + }); + + it('should filter by correlationId', async () => { + const found = await provider.query({ correlationId: 'c-1' }); + expect(found.map((r) => r.logger)).toEqual([ + 'file-upload', + 'autocomplete' + ]); + }); + + it('should filter by logger', async () => { + const found = await provider.query({ logger: 'autocomplete' }); + expect(found).toHaveLength(1); + expect(found[0].correlationId).toBe('c-1'); + }); + + it('should filter by minLevel, keeping that level and above', async () => { + const found = await provider.query({ minLevel: 'warn' }); + expect(found.map((r) => r.level)).toEqual(['warn', 'error']); + }); + + it('should filter by an inclusive from/to timestamp range', async () => { + const found = await provider.query({ + from: '2024-01-02T00:00:00.000Z', + to: '2024-01-02T00:00:00.000Z' + }); + expect(found).toHaveLength(1); + expect(found[0].correlationId).toBe('c-2'); + }); + + it('should compare a non-UTC-offset from bound by its actual instant, not by lexicographic string order', async () => { + const found = await provider.query({ from: '2024-01-02T01:00:00+02:00' }); + expect(found.map((r) => r.correlationId)).toEqual(['c-2', 'c-1']); + }); + + it('should cap the result at limit', async () => { + const found = await provider.query({ limit: 2 }); + expect(found).toHaveLength(2); + }); + + it('should combine filters with AND', async () => { + const found = await provider.query({ + correlationId: 'c-1', + logger: 'file-upload' + }); + expect(found).toHaveLength(1); + expect(found[0].logger).toBe('file-upload'); + }); + + it('should return everything when the filter is empty', async () => { + const found = await provider.query({}); + expect(found).toHaveLength(3); + }); + }); + + describe('download', () => { + // jsdom has no URL.createObjectURL/revokeObjectURL, so these are assigned + // directly rather than jest.spyOn, which needs the property to exist. + let originalCreateObjectURL: typeof URL.createObjectURL; + let originalRevokeObjectURL: typeof URL.revokeObjectURL; + + beforeEach(() => { + originalCreateObjectURL = URL.createObjectURL; + originalRevokeObjectURL = URL.revokeObjectURL; + }); + + afterEach(() => { + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + jest.restoreAllMocks(); + }); + + it('should do nothing when URL.createObjectURL is unavailable', () => { + // @ts-expect-error removing it to exercise the guard + delete URL.createObjectURL; + + provider.send(record()); + expect(() => provider.download()).not.toThrow(); + }); + + it('should build and click a download link, then revoke the object URL', () => { + const objectUrl = 'blob:mock-url'; + URL.createObjectURL = jest.fn(() => objectUrl); + URL.revokeObjectURL = jest.fn(); + const clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => undefined); + + provider.send(record({ message: 'downloadable' })); + provider.download('my-logs.json'); + + expect(clickSpy).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith(objectUrl); + expect(document.querySelector('a[download]')).toBeNull(); + }); + + it('should revoke the object URL even if the click throws', () => { + URL.createObjectURL = jest.fn(() => 'blob:mock-url'); + URL.revokeObjectURL = jest.fn(); + jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => { + throw new Error('blocked by browser'); + }); + + provider.send(record()); + expect(() => provider.download()).toThrow('blocked by browser'); + expect(URL.revokeObjectURL).toHaveBeenCalled(); + expect(document.querySelector('a[download]')).toBeNull(); + }); + }); +}); diff --git a/projects/composition/src/app/services/app-log-api.provider.ts b/projects/composition/src/app/services/app-log-api.provider.ts new file mode 100644 index 000000000..206b07a1c --- /dev/null +++ b/projects/composition/src/app/services/app-log-api.provider.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@angular/core'; +import { CPS_LOG_LEVEL_ORDER } from 'cps-telemetry'; +import type { + CpsLogApiProvider, + CpsLogQuery, + CpsLogRecord +} from 'cps-telemetry'; + +/** Records retained before the oldest are dropped. */ +const BUFFER_LIMIT = 500; + +/** + * This application's in-memory log store. + * + * The telemetry library expects a backend to receive log records; this is + * that backend for the documentation site. Records are held in a bounded + * ring buffer and go nowhere — a real application replaces the body of + * {@link send} with a call to its own log-ingestion endpoint. + * + * Read them from DevTools with `logger.query({ correlationId })`, or set + * `localStorage.debugLogger = 'true'` to watch them arrive. + */ +@Injectable({ providedIn: 'root' }) +export class AppLogApiProvider implements CpsLogApiProvider { + private readonly buffer: CpsLogRecord[] = []; + + /** @inheritdoc */ + send(record: CpsLogRecord): void { + this.buffer.push(record); + if (this.buffer.length > BUFFER_LIMIT) { + this.buffer.splice(0, this.buffer.length - BUFFER_LIMIT); + } + } + + /** @inheritdoc */ + query(filter: CpsLogQuery): Promise { + let found = [...this.buffer]; + + if (filter.correlationId) { + found = found.filter((r) => r.correlationId === filter.correlationId); + } + if (filter.logger) { + found = found.filter((r) => r.logger === filter.logger); + } + if (filter.minLevel) { + const floor = CPS_LOG_LEVEL_ORDER[filter.minLevel]; + found = found.filter((r) => CPS_LOG_LEVEL_ORDER[r.level] >= floor); + } + if (filter.from) { + const fromTime = new Date(filter.from).getTime(); + found = found.filter((r) => new Date(r.timestamp).getTime() >= fromTime); + } + if (filter.to) { + const toTime = new Date(filter.to).getTime(); + found = found.filter((r) => new Date(r.timestamp).getTime() <= toTime); + } + if (filter.limit !== undefined) { + found = found.slice(0, filter.limit); + } + + return Promise.resolve(found); + } + + /** + * The retained records, oldest first. Synchronous, for inspection from + * DevTools and tests. + * + * @returns a copy of the buffer + */ + getRecords(): CpsLogRecord[] { + return [...this.buffer]; + } + + /** + * Saves the retained records to a file for download. + * + * @param filename the file to save as + */ + download(filename = 'cps-logs.json'): void { + if ( + typeof document === 'undefined' || + !document.body || + typeof Blob !== 'function' || + typeof URL.createObjectURL !== 'function' + ) { + return; + } + + const url = URL.createObjectURL( + new Blob([JSON.stringify(this.buffer, undefined, 2)], { + type: 'application/json' + }) + ); + + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + + try { + anchor.click(); + } finally { + anchor.remove(); + URL.revokeObjectURL(url); + } + } +} diff --git a/projects/composition/src/app/services/app-telemetry.service.spec.ts b/projects/composition/src/app/services/app-telemetry.service.spec.ts new file mode 100644 index 000000000..3f113d574 --- /dev/null +++ b/projects/composition/src/app/services/app-telemetry.service.spec.ts @@ -0,0 +1,431 @@ +import { TestBed } from '@angular/core/testing'; +import { + NavigationCancel, + NavigationCancellationCode, + NavigationEnd, + NavigationError, + NavigationStart, + Router +} from '@angular/router'; +import { Subject } from 'rxjs'; +import { + CPS_LOG_API_PROVIDER, + CpsLogRecord, + CpsScenarioTelemetryService, + CpsTelemetrySink, + provideCpsTelemetry, + withScenarios +} from 'cps-telemetry'; +import { AppLogApiProvider } from '../services/app-log-api.provider'; +import { AppTelemetryService } from './app-telemetry.service'; + +interface RecordedEvent { + eventType: string; + payload: Record; +} + +describe('AppTelemetryService', () => { + let service: AppTelemetryService; + let routerEvents: Subject; + let events: RecordedEvent[]; + + beforeEach(() => { + routerEvents = new Subject(); + events = []; + + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }, + withScenarios({ defaultTimeoutMs: 0 }) + ), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { + provide: CpsTelemetrySink, + useValue: { + record: (eventType: string, payload: Record) => + events.push({ eventType, payload }), + recordError: () => undefined, + getSessionId: () => 'session-1', + setUserId: () => undefined, + getUserId: () => undefined, + flush: () => undefined + } + }, + { provide: Router, useValue: { events: routerEvents.asObservable() } } + ] + }); + + service = TestBed.inject(AppTelemetryService); + service.start(); + }); + + /** The scenario records emitted so far. */ + function scenarios(): Record[] { + return events + .filter((e) => e.eventType === 'com.cps.scenario') + .map((e) => e.payload); + } + + it('should not emit anything before a navigation starts', () => { + expect(events).toHaveLength(0); + }); + + it('should open a scenario on NavigationStart', () => { + routerEvents.next(new NavigationStart(1, '/button')); + + const scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + expect(scenarioTelemetry.getActive()[0].name).toBe('route-navigation'); + expect(events).toHaveLength(0); + }); + + it('should complete the scenario on NavigationEnd', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + + const [record] = scenarios(); + expect(record).toMatchObject({ + scenarioName: 'route-navigation', + operation: 'lazy-route-load', + route: '/button', + status: 'success', + metadata: { finalUrl: '/button' } + }); + expect(record.steps).toHaveLength(4); + }); + + it('should strip a query string and fragment from the route before recording it', () => { + routerEvents.next(new NavigationStart(1, '/colors?experimental=true#hash')); + routerEvents.next( + new NavigationEnd(1, '/colors?experimental=true#hash', '/colors') + ); + + expect(scenarios()[0]).toMatchObject({ route: '/colors' }); + }); + + it('should strip matrix parameters from every segment, not just the query string and fragment', () => { + routerEvents.next( + new NavigationStart(1, '/customers;id=123/details;tab=billing?x=1#h') + ); + routerEvents.next( + new NavigationEnd( + 1, + '/customers;id=123/details;tab=billing?x=1#h', + '/customers/details' + ) + ); + + expect(scenarios()[0]).toMatchObject({ route: '/customers/details' }); + }); + + it('should cancel the scenario on NavigationCancel', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationCancel(1, '/button', 'guard rejected')); + + expect(scenarios()[0]).toMatchObject({ + status: 'abandoned', + message: 'guard rejected' + }); + }); + + it('should fail the scenario on NavigationError', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next( + new NavigationError(1, '/button', new Error('chunk load failed')) + ); + + const record = scenarios()[0]; + expect(record.status).toBe('failure'); + expect(record.error).toMatchObject({ message: 'chunk load failed' }); + }); + + describe('logging', () => { + /** Log records captured by the default in-memory transport. */ + function logs(): CpsLogRecord[] { + return TestBed.inject(AppLogApiProvider).getRecords(); + } + + it('should log once when tracking starts', () => { + const started = logs().filter((r) => r.message === 'Application started'); + expect(started).toHaveLength(1); + expect(started[0]).toMatchObject({ + level: 'log', + context: 'AppTelemetry' + }); + }); + + it('should not log again when start is called twice', () => { + service.start(); + expect( + logs().filter((r) => r.message === 'Application started') + ).toHaveLength(1); + }); + + it('should log a navigation failure', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next( + new NavigationError(1, '/button', new Error('chunk load failed')) + ); + + const failure = logs().find((r) => r.message === 'Navigation failed'); + expect(failure).toMatchObject({ + level: 'error', + context: 'route-navigation', + error: { message: 'chunk load failed' }, + metadata: { url: '/button' } + }); + }); + + it('should correlate the log line with the scenario record', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next( + new NavigationError(1, '/button', new Error('chunk load failed')) + ); + + const failure = logs().find((r) => r.message === 'Navigation failed'); + expect(failure?.correlationId).toBe(scenarios()[0].scenarioId); + }); + + it('should not log on a successful navigation', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + + expect(logs().some((r) => r.level === 'error')).toBe(false); + }); + + it('should not log on a cancelled navigation', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationCancel(1, '/button', 'superseded')); + + expect(logs().some((r) => r.level === 'error')).toBe(false); + }); + }); + + it('should keep concurrent navigations independent', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationStart(2, '/select')); + + const scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + expect(scenarioTelemetry.getActive()).toHaveLength(2); + + routerEvents.next(new NavigationCancel(1, '/button', 'superseded')); + routerEvents.next(new NavigationEnd(2, '/select', '/select')); + + const records = scenarios(); + expect(records).toHaveLength(2); + expect(records[0]).toMatchObject({ route: '/button', status: 'abandoned' }); + expect(records[1]).toMatchObject({ route: '/select', status: 'success' }); + expect(records[0].scenarioId).not.toBe(records[1].scenarioId); + }); + + describe('redirect-caused navigation restarts', () => { + it('should continue the same scenario across a guard-redirect restart, instead of opening a second one', () => { + routerEvents.next(new NavigationStart(1, '/checkbox')); + routerEvents.next( + new NavigationCancel( + 1, + '/checkbox', + 'redirect', + NavigationCancellationCode.Redirect + ) + ); + routerEvents.next(new NavigationStart(2, '/checkbox/examples')); + routerEvents.next( + new NavigationEnd(2, '/checkbox/examples', '/checkbox/examples') + ); + + const records = scenarios(); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + status: 'success', + route: '/checkbox', + metadata: { finalUrl: '/checkbox/examples' } + }); + }); + + it('should still cancel the scenario when the cancellation is not a redirect', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next( + new NavigationCancel( + 1, + '/button', + 'guard rejected', + NavigationCancellationCode.GuardRejected + ) + ); + routerEvents.next(new NavigationStart(2, '/select')); + routerEvents.next(new NavigationEnd(2, '/select', '/select')); + + const records = scenarios(); + expect(records).toHaveLength(2); + expect(records[0]).toMatchObject({ + status: 'abandoned', + route: '/button' + }); + expect(records[1]).toMatchObject({ status: 'success', route: '/select' }); + }); + }); + + describe('navigation intent', () => { + it('should backdate the scenario to the recorded click', () => { + const nowSpy = jest.spyOn(Date, 'now'); + const clickedAt = Date.now(); + + nowSpy.mockReturnValue(clickedAt); + service.markNavigationIntent(); + + nowSpy.mockReturnValue(clickedAt + 300); + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + + const startTimeMs = new Date( + scenarios()[0].startTime as string + ).getTime(); + expect(startTimeMs).toBeLessThanOrEqual(clickedAt + 300); + }); + + it('should ignore a stale intent from an unrelated click', () => { + const nowSpy = jest.spyOn(Date, 'now'); + const clickedAt = Date.now(); + + nowSpy.mockReturnValue(clickedAt); + service.markNavigationIntent(); + + nowSpy.mockReturnValue(clickedAt + 10_000); + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + + expect(scenarios()[0].delta).toBeLessThan(1_000); + }); + + it('should consume the intent so it cannot backdate a second navigation', () => { + service.markNavigationIntent(); + + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + routerEvents.next(new NavigationStart(2, '/select')); + routerEvents.next(new NavigationEnd(2, '/select', '/select')); + + expect(scenarios()).toHaveLength(2); + expect(scenarios()[1].delta).toBeLessThan(1_000); + }); + + it('should work with no intent recorded at all', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + + expect(scenarios()[0].status).toBe('success'); + }); + }); + + it('should ignore a settle event for an unknown navigation', () => { + routerEvents.next(new NavigationEnd(99, '/never-started', '/x')); + expect(events).toHaveLength(0); + }); + + it('should not double-subscribe when started twice', () => { + service.start(); + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + + expect(scenarios()).toHaveLength(1); + }); + + it('should track a theme change as a BI event', () => { + service.trackThemeChanged('dark'); + + const bi = events.filter((e) => e.eventType === 'com.cps.bi'); + expect(bi).toHaveLength(1); + expect(bi[0].payload).toMatchObject({ + eventName: 'theme_changed', + metadata: { theme: 'dark' } + }); + }); + + describe('trackClick', () => { + /** The BI events emitted so far. */ + function biEvents(): Record[] { + return events + .filter((e) => e.eventType === 'com.cps.bi') + .map((e) => e.payload); + } + + it('should use the action as the BI event name', () => { + service.trackClick('export_clicked'); + + expect(biEvents()).toHaveLength(1); + expect(biEvents()[0]).toMatchObject({ eventName: 'export_clicked' }); + }); + + it('should forward metadata describing the interaction', () => { + service.trackClick('tab_selected', { + tabName: 'api', + index: 2, + isDefault: false + }); + + expect(biEvents()[0]).toMatchObject({ + eventName: 'tab_selected', + metadata: { tabName: 'api', index: 2, isDefault: false } + }); + }); + + it('should work without metadata', () => { + service.trackClick('modal_opened'); + expect(biEvents()[0].metadata).toBeUndefined(); + }); + + it('should collapse a double-fired click', () => { + service.trackClick('export_clicked'); + service.trackClick('export_clicked'); + + expect(biEvents()).toHaveLength(1); + }); + + it('should not throw when the telemetry sink is broken', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { + provide: CpsTelemetrySink, + useValue: { + record: () => { + throw new Error('sink is broken'); + }, + recordError: () => undefined, + getSessionId: () => undefined, + setUserId: () => undefined, + getUserId: () => undefined, + flush: () => undefined + } + }, + { provide: Router, useValue: { events: routerEvents.asObservable() } } + ] + }); + + const isolated = TestBed.inject(AppTelemetryService); + expect(() => isolated.trackClick('export_clicked')).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('failed'), + expect.any(Error) + ); + + consoleError.mockRestore(); + }); + }); +}); diff --git a/projects/composition/src/app/services/app-telemetry.service.ts b/projects/composition/src/app/services/app-telemetry.service.ts new file mode 100644 index 000000000..b2c1ccd95 --- /dev/null +++ b/projects/composition/src/app/services/app-telemetry.service.ts @@ -0,0 +1,231 @@ +import { DestroyRef, inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + Event as RouterEvent, + NavigationCancel, + NavigationCancellationCode, + NavigationEnd, + NavigationError, + NavigationStart, + Router +} from '@angular/router'; +import { + CpsBiTelemetryService, + CpsLoggerService, + CpsScenario, + CpsScenarioTelemetryService, + CpsTelemetryMetadata +} from 'cps-telemetry'; +// Side-effect import for the module augmentation declaring the scenario and +// step vocabulary used below. +import './telemetry.schema'; + +/** Name shared by every route-navigation scenario. */ +const NAVIGATION_SCENARIO = 'route-navigation'; + +/** Beyond this, a recorded click is assumed not to have caused the navigation. */ +const INTENT_MAX_AGE_MS = 2_000; + +/** + * Wires the documentation app's own telemetry: tracks route navigations as + * scenarios and forwards BI and log events. + * + * Started from `AppComponent` so it is created once the application is running. + */ +@Injectable({ providedIn: 'root' }) +export class AppTelemetryService { + private readonly router = inject(Router); + private readonly scenarioTelemetry = inject(CpsScenarioTelemetryService); + private readonly biTelemetry = inject(CpsBiTelemetryService); + private readonly logger = inject(CpsLoggerService).getLogger('app'); + private readonly destroyRef = inject(DestroyRef); + + /** + * Angular identifies concurrent navigations by id, so scenarios are keyed the + * same way. A single "current navigation" field would attribute the wrong + * duration whenever one navigation supersedes another. + */ + private readonly navigations = new Map(); + + private pendingRedirectScenario?: CpsScenario; + + /** When the user last did something that should start a navigation. */ + private navigationIntentAt?: number; + + private started = false; + + /** Begins tracking router navigations. Safe to call more than once. */ + start(): void { + if (this.started) { + return; + } + this.started = true; + + // Marks the start of a session in the log stream. Application, environment + // and version are stamped onto every record by the logger, so this line + // exists to bound the session rather than to carry them. + this.logger.log('Application started', { + context: 'AppTelemetry', + metadata: { language: navigator.language } + }); + + this.router.events + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => this.onRouterEvent(event)); + } + + /** + * Records that the user just did something expected to start a navigation. + * + * The router raises `NavigationStart` a little after the click that caused it — + * event handling, guards and change detection all run first. Measuring from + * here rather than from the router event keeps that gap, which the user + * experienced as part of the wait, inside the recorded duration. + */ + markNavigationIntent(): void { + this.navigationIntentAt = Date.now(); + } + + /** + * Records a business event for a theme change. + * + * @param theme the theme the user switched to + */ + trackThemeChanged(theme: string): void { + this.biTelemetry.track('theme_changed', { theme }); + } + + /** + * Records a meaningful user interaction. + * + * Repeat clicks within a short window are collapsed by the telemetry layer, + * so no throttling is needed here. + * + * @param action the interaction name, e.g. `export_clicked` + * @param metadata attributes describing the interaction — ids, route names + * and tab names only, never emails, usernames or account numbers + */ + trackClick(action: string, metadata?: CpsTelemetryMetadata): void { + this.biTelemetry.track(action, metadata); + } + + private onRouterEvent(event: RouterEvent): void { + if (event instanceof NavigationStart) { + const scenario = + this.consumePendingRedirectScenario() ?? + this.scenarioTelemetry.start({ + name: NAVIGATION_SCENARIO, + operation: 'lazy-route-load', + route: this.routeTemplate(event.url), + // The journey began when the user clicked, which is a little + // before this handler runs. Backdating to the router's own event + // timestamp keeps that gap inside the measurement. + startedAt: this.navigationStartedAt(), + // A navigation that has neither completed nor been superseded + // within this window is a stuck chunk load, not a slow one. + timeoutMs: 30_000 + }); + scenario.step('resolve-route'); + this.navigations.set(event.id, scenario); + return; + } + + if (event instanceof NavigationEnd) { + this.settle(event.id, (scenario) => { + scenario.step('activate'); + scenario.complete({ + metadata: { finalUrl: event.urlAfterRedirects } + }); + }); + return; + } + + if (event instanceof NavigationCancel) { + const scenario = this.navigations.get(event.id); + if (scenario && event.code === NavigationCancellationCode.Redirect) { + this.navigations.delete(event.id); + this.pendingRedirectScenario = scenario; + return; + } + + this.settle(event.id, (scenario) => + scenario.cancel({ message: event.reason || 'navigation-cancelled' }) + ); + return; + } + + if (event instanceof NavigationError) { + this.settle(event.id, (scenario) => { + // The scenario id doubles as the correlation id, which is what lets + // this line and the scenario record be pulled together afterwards. + this.logger.error('Navigation failed', { + error: event.error, + context: NAVIGATION_SCENARIO, + correlationId: scenario.id, + metadata: { url: event.url } + }); + scenario.fail({ error: event.error }); + }); + } + } + + /** + * Consumes the pending navigation-intent timestamp, if one is fresh enough. + * + * A stale mark — the user clicked a link, then navigated by some other route + * entirely — would backdate an unrelated journey, so anything older than the + * window is discarded. + * + * @returns the click timestamp, or `undefined` to measure from now + */ + /** + * Reduces a router event's URL to a route template. + * + * `event.url` is the *resolved* URL — exactly what `cps-telemetry`'s own + * doc comment on `route` warns against passing directly: it wants a + * template (`/customers/:id`), not `/customers/john@example.com`, so that + * `route` stays one metric dimension per route rather than splitting into + * one series per resolved value. This app's own routes are all static (see + * `app-routing.module.ts` — no `:id`-style segments anywhere), so a + * resolved URL and its template only ever differ by the query string, + * fragment, and any matrix parameters (`;key=value`) a link happened to + * carry; stripping those is enough here. Matrix parameters are legal on + * any segment of a static route too, not just parameterized ones, so they + * are stripped per-segment rather than assumed absent. + */ + private routeTemplate(url: string): string { + return url + .split(/[?#]/)[0] + .split('/') + .map((segment) => segment.split(';')[0]) + .join('/'); + } + + private navigationStartedAt(): number | undefined { + const at = this.navigationIntentAt; + this.navigationIntentAt = undefined; + + if (at === undefined || Date.now() - at > INTENT_MAX_AGE_MS) { + return undefined; + } + return at; + } + + private consumePendingRedirectScenario(): CpsScenario | undefined { + const scenario = this.pendingRedirectScenario; + this.pendingRedirectScenario = undefined; + return scenario; + } + + private settle( + navigationId: number, + apply: (scenario: CpsScenario) => void + ): void { + const scenario = this.navigations.get(navigationId); + if (!scenario) { + return; + } + this.navigations.delete(navigationId); + apply(scenario); + } +} diff --git a/projects/composition/src/app/services/rum-credentials.provider.spec.ts b/projects/composition/src/app/services/rum-credentials.provider.spec.ts new file mode 100644 index 000000000..4d2bd3127 --- /dev/null +++ b/projects/composition/src/app/services/rum-credentials.provider.spec.ts @@ -0,0 +1,112 @@ +import { TestBed } from '@angular/core/testing'; +import { AppRumCredentialsProvider } from './rum-credentials.provider'; + +/** A well-formed `/rum/init` broker response. */ +function initResponse(overrides: Record = {}) { + return { + enabled: true, + config: { + applicationId: 'app-monitor-1', + region: 'eu-west-1', + sessionSampleRate: 0.5, + applicationVersion: '3.0.0' + }, + credentials: { + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: '2030-01-01T00:00:00.000Z' + }, + ...overrides + }; +} + +function mockFetch(status: number, body: unknown): jest.Mock { + const fetchMock = jest.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + json: () => Promise.resolve(body) + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + +describe('AppRumCredentialsProvider', () => { + let provider: AppRumCredentialsProvider; + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + TestBed.configureTestingModule({}); + provider = TestBed.inject(AppRumCredentialsProvider); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('should request /rum/init, asking for a JSON response and bypassing the HTTP cache', async () => { + const fetchMock = mockFetch(200, initResponse()); + + await provider.load(); + + expect(fetchMock).toHaveBeenCalledWith('/rum/init', { + headers: { Accept: 'application/json' }, + cache: 'no-store' + }); + }); + + it('should map a well-formed response onto CpsRumBootstrap field-for-field', async () => { + mockFetch(200, initResponse()); + + const bootstrap = await provider.load(); + + expect(bootstrap).toEqual({ + config: { + applicationId: 'app-monitor-1', + region: 'eu-west-1', + applicationVersion: '3.0.0', + sessionSampleRate: 0.5 + }, + credentials: { + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: '2030-01-01T00:00:00.000Z' + } + }); + }); + + it('should return null when the broker responds with a non-ok status', async () => { + mockFetch(500, {}); + + expect(await provider.load()).toBeNull(); + }); + + it('should return null when RUM is switched off for this deployment', async () => { + mockFetch(200, initResponse({ enabled: false })); + + expect(await provider.load()).toBeNull(); + }); + + it('should return null when config is missing even though enabled is true', async () => { + mockFetch(200, initResponse({ config: undefined })); + + expect(await provider.load()).toBeNull(); + }); + + it('should return null when credentials are missing, not start an unauthenticated client', async () => { + mockFetch(200, initResponse({ credentials: undefined })); + + expect(await provider.load()).toBeNull(); + }); + + it('should return null, not reject, when fetch itself fails at the network level', async () => { + globalThis.fetch = jest + .fn() + .mockRejectedValue( + new TypeError('Failed to fetch') + ) as unknown as typeof fetch; + + await expect(provider.load()).resolves.toBeNull(); + }); +}); diff --git a/projects/composition/src/app/services/rum-credentials.provider.ts b/projects/composition/src/app/services/rum-credentials.provider.ts new file mode 100644 index 000000000..9e85f0bba --- /dev/null +++ b/projects/composition/src/app/services/rum-credentials.provider.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@angular/core'; +import type { + CpsRumBootstrap, + CpsRumCredentialsProvider +} from 'cps-telemetry/rum'; + +/** + * Shape returned by the backend RUM broker. + */ +interface RumInitResponse { + enabled: boolean; + config?: { + applicationId: string; + region: string; + sessionSampleRate: number; + applicationVersion: string; + }; + credentials?: { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + expiration: string; + }; +} + +/** + * Supplies CloudWatch RUM settings and credentials from this application's + * backend broker. + * + * The backend vends short-lived credentials, so the browser never holds a + * long-lived AWS identity. + */ +@Injectable({ providedIn: 'root' }) +export class AppRumCredentialsProvider implements CpsRumCredentialsProvider { + /** + * Fetches the current app monitor settings and credentials. + * + * @returns the bootstrap payload, or `null` when RUM is switched off for this + * deployment or the broker is unreachable + */ + async load(): Promise { + let response: Response; + try { + response = await fetch('/rum/init', { + headers: { Accept: 'application/json' }, + cache: 'no-store' + }); + } catch { + // A network-level failure (offline, DNS, connection refused, ...) + // rejects before any response exists — treat it the same as an + // unreachable broker, per this method's own documented contract. + return null; + } + + if (!response.ok) { + return null; + } + + const init = (await response.json()) as RumInitResponse; + + // Credentials are required: without them the client would start and then + // fail every dispatch silently. + if (!init?.enabled || !init.config || !init.credentials) { + return null; + } + + return { + config: { + applicationId: init.config.applicationId, + region: init.config.region, + applicationVersion: init.config.applicationVersion, + sessionSampleRate: init.config.sessionSampleRate + }, + credentials: init.credentials + }; + } +} diff --git a/projects/composition/src/app/services/telemetry.schema.ts b/projects/composition/src/app/services/telemetry.schema.ts new file mode 100644 index 000000000..333d7e667 --- /dev/null +++ b/projects/composition/src/app/services/telemetry.schema.ts @@ -0,0 +1,71 @@ +/** + * This application's scenario vocabulary. + * + * Scenario and step names are metric dimensions, declared once here rather + * than as free text at each call site. Anything not listed below is a + * compile error. + */ +declare module 'cps-telemetry' { + interface CpsScenarioNames { + /** A router navigation, from click to activated route. */ + 'route-navigation': true; + + /** Reading and simulating server-side processing of an uploaded file. */ + 'file-upload': true; + + /** A typeahead query against the demo autocomplete dataset. */ + 'autocomplete-search': true; + + /** Simulated async validation of a selected autocomplete option. */ + 'autocomplete-validate': true; + + /** Simulated server-side page fetch for a lazy-loaded table. */ + 'table-page-load': true; + } + + interface CpsScenarioSteps { + /** Resolving and loading the lazy route chunk. */ + 'resolve-route': true; + + /** Activating the resolved component. */ + activate: true; + + /** Simulated processing of an uploaded file's contents. */ + process: true; + + /** Fetching matching options for a typed query. */ + query: true; + + /** Simulated async validation of a selection. */ + validate: true; + + /** Fetching one page of table rows. */ + fetch: true; + + /** + * Formatting one fetched table row for display. Runs once per row, so + * it's timed as an {@link CpsScenario.aggregateStart}/ + * {@link CpsScenario.aggregateEnd} total rather than as its own step. + */ + 'format-row': true; + } + + interface CpsLoggerNames { + /** Application lifecycle and router navigation. */ + app: true; + + /** Route titles. */ + routing: true; + + /** The documentation site's own components. */ + docs: true; + + /** The file-upload demo page. */ + 'file-upload': true; + + /** The autocomplete demo page. */ + autocomplete: true; + } +} + +export {}; diff --git a/projects/composition/src/app/telemetry-trace.capture.spec.ts b/projects/composition/src/app/telemetry-trace.capture.spec.ts new file mode 100644 index 000000000..5fa9cf60b --- /dev/null +++ b/projects/composition/src/app/telemetry-trace.capture.spec.ts @@ -0,0 +1,889 @@ +import { TestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, + Router +} from '@angular/router'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Subject, throwError, timer } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { + CPS_LOG_API_PROVIDER, + CpsLoggerService, + CpsScenarioTelemetryService, + CpsTelemetrySink, + provideCpsTelemetry, + traceScenario, + withLogging, + withRedaction +} from 'cps-telemetry'; +import { AppLogApiProvider } from './services/app-log-api.provider'; +import { AppTelemetryService } from './services/app-telemetry.service'; +import { TablePageComponent } from './pages/table-page/table-page.component'; +import { FileUploadPageComponent } from './pages/file-upload-page/file-upload-page.component'; +import { AutocompletePageComponent } from './pages/autocomplete-page/autocomplete-page.component'; +import './services/telemetry.schema'; + +/** + * Telemetry wire-audit capture harness — not a regression suite, though it + * lives in a `.spec.ts` file and runs under the `composition` Jest project. + * + * Drives `composition`'s real wiring with `debugScenario`/`debugLogger`/ + * `debugBI` on, and records two things per case: the exact payload handed + * to `CpsTelemetrySink`/`AppLogApiProvider`, and the exact console line the + * library's debug flags print. + * + * Every `it()` appends to one shared `trace`/`consoleLines` pair; the final + * test writes both to disk as JSON for `npm run trace:telemetry` to produce. + * + * `real: true` entries come from driving composition's actual pages. + * `real: false` entries call the real library classes directly, for + * statuses/fields composition's demo data doesn't reach on its own. + */ + +interface TraceEntry { + seq: number; + group: string; + case: string; + real: boolean; + source: 'AWS RUM' | 'LOG API'; + eventType: string; + payload: unknown; +} + +interface ConsoleLine { + seq: number; + channel: 'log' | 'warn' | 'error'; + text: string; + payload: unknown; +} + +const trace: TraceEntry[] = []; +const consoleLines: ConsoleLine[] = []; +let seq = 0; + +function pushEntry( + group: string, + caseName: string, + real: boolean, + source: 'AWS RUM' | 'LOG API', + eventType: string, + payload: unknown +): void { + trace.push({ + seq: ++seq, + group, + case: caseName, + real, + source, + eventType, + payload: clone(payload) + }); +} + +/** Deep-clones via JSON round-trip so later mutation of a live object can't retroactively change an already-captured entry. */ +function clone(value: T): T { + if (value === undefined) return value; + return JSON.parse(JSON.stringify(value)); +} + +/** Captures what a `RecordingSink`-style double is handed. */ +class CapturingSink extends CpsTelemetrySink { + group = 'unlabeled'; + real = true; + + record(eventType: string, payload: object): void { + pushEntry( + this.group, + this.currentCase, + this.real, + 'AWS RUM', + eventType, + payload + ); + } + + recordError(error: unknown): void { + pushEntry( + this.group, + this.currentCase, + this.real, + 'AWS RUM', + 'sink.recordError', + error + ); + } + + currentCase = ''; + + getSessionId(): string | undefined { + return 'audit-session'; + } + + setUserId(): void {} + + getUserId(): string | undefined { + return undefined; + } + + flush(): void {} +} + +let restoreConsole: () => void; + +beforeAll(() => { + localStorage.setItem('debugScenario', 'true'); + localStorage.setItem('debugLogger', 'true'); + localStorage.setItem('debugBI', 'true'); + + const original = { + log: console.log, + warn: console.warn, + error: console.error + }; + (['log', 'warn', 'error'] as const).forEach((channel) => { + jest.spyOn(console, channel).mockImplementation((...args: unknown[]) => { + consoleLines.push({ + seq: ++seq, + channel, + text: String(args[0]), + payload: clone(args[1]) + }); + }); + }); + restoreConsole = () => { + console.log = original.log; + console.warn = original.warn; + console.error = original.error; + }; +}); + +afterAll(() => { + restoreConsole(); + localStorage.clear(); +}); + +describe('Real: route-navigation', () => { + let service: AppTelemetryService; + let routerEvents: Subject; + let sink: CapturingSink; + let logApi: AppLogApiProvider; + let nextId = 1; + + beforeEach(async () => { + jest.useFakeTimers(); + routerEvents = new Subject(); + sink = new CapturingSink(); + sink.group = 'route-navigation'; + + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition', + environment: 'production', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink }, + { provide: Router, useValue: { events: routerEvents.asObservable() } } + ] + }).compileComponents(); + + service = TestBed.inject(AppTelemetryService); + logApi = TestBed.inject(AppLogApiProvider); + service.start(); + }); + + afterEach(() => jest.useRealTimers()); + + it('logs "Application started" at boot', () => { + sink.currentCase = 'app start'; + const started = logApi + .getRecords() + .find((r) => r.message === 'Application started'); + pushEntry( + 'route-navigation', + 'app start log line', + true, + 'LOG API', + 'log', + started + ); + expect(started).toBeDefined(); + }); + + it('NavigationStart -> NavigationEnd settles as success', () => { + sink.currentCase = 'success'; + const id = nextId++; + routerEvents.next(new NavigationStart(id, '/table', undefined, undefined)); + jest.advanceTimersByTime(180); + routerEvents.next(new NavigationEnd(id, '/table', '/table')); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('NavigationStart -> NavigationCancel settles as abandoned', () => { + sink.currentCase = 'abandoned (cancel)'; + const id = nextId++; + routerEvents.next(new NavigationStart(id, '/file-upload')); + jest.advanceTimersByTime(60); + routerEvents.next( + new NavigationCancel(id, '/file-upload', 'guard rejected') + ); + const entry = trace.find( + (e) => e.group === sink.group && e.case === sink.currentCase + ); + expect( + (entry?.payload as { metadata?: { abandonedBy?: string } })?.metadata + ?.abandonedBy + ).toBe('caller'); + }); + + it('a second NavigationStart supersedes the first (abandoned)', () => { + sink.currentCase = 'superseded'; + const first = nextId++; + const second = nextId++; + routerEvents.next(new NavigationStart(first, '/autocomplete')); + jest.advanceTimersByTime(50); + routerEvents.next( + new NavigationCancel(first, '/autocomplete', 'superseded by navigation') + ); + routerEvents.next(new NavigationStart(second, '/autocomplete')); + jest.advanceTimersByTime(160); + routerEvents.next( + new NavigationEnd(second, '/autocomplete', '/autocomplete') + ); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('NavigationStart -> NavigationError settles as failure, correlated to a log line', () => { + sink.currentCase = 'failure'; + const id = nextId++; + routerEvents.next(new NavigationStart(id, '/broken-chunk')); + jest.advanceTimersByTime(220); + routerEvents.next( + new NavigationError( + id, + '/broken-chunk', + 'ChunkLoadError: Loading chunk 12 failed.' + ) + ); + const failLog = logApi + .getRecords() + .find((r) => r.message === 'Navigation failed'); + pushEntry( + 'route-navigation', + 'failure -> correlated log line', + true, + 'LOG API', + 'error', + failLog + ); + expect(failLog?.correlationId).toBeDefined(); + }); + + it('never settling times out after 30s', () => { + sink.currentCase = 'timeout'; + const id = nextId++; + routerEvents.next(new NavigationStart(id, '/stuck-chunk')); + jest.advanceTimersByTime(30_000); + const entry = trace.find( + (e) => e.group === sink.group && e.case === sink.currentCase + ); + expect((entry?.payload as { status?: string })?.status).toBe('timeout'); + }); +}); + +describe('Real: table-page-load', () => { + let component: TablePageComponent; + let sink: CapturingSink; + + function seedRows(n: number) { + component.dataVirtual = Array.from({ length: n }, (_, i) => ({ + a: `a${i}`, + b: `b${i % 5}`, + c: i, + d: new Date(), + e: i % 2 === 0, + f: new Date() + })); + } + + beforeEach(async () => { + jest.useFakeTimers(); + sink = new CapturingSink(); + sink.group = 'table-page-load'; + + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition', + environment: 'production', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink } + ] + }).compileComponents(); + + component = TestBed.runInInjectionContext(() => new TablePageComponent()); + seedRows(20); + }); + + afterEach(() => jest.useRealTimers()); + + it('lazy load settles as success, with the format-row aggregate', async () => { + sink.currentCase = 'success + format-row aggregate'; + component.onLazyLoad({ first: 0, rows: 10 }); + await Promise.resolve(); + jest.advanceTimersByTime(600); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('a second lazy load supersedes the first (abandoned)', async () => { + sink.currentCase = 'superseded'; + component.onLazyLoad({ first: 0, rows: 10 }); + await Promise.resolve(); + jest.advanceTimersByTime(250); + component.onLazyLoad({ first: 10, rows: 10 }); + await Promise.resolve(); + jest.advanceTimersByTime(600); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('component destroyed mid-flight settles as abandoned', async () => { + sink.currentCase = 'component-destroyed'; + component.onLazyLoad({ first: 0, rows: 10 }); + await Promise.resolve(); + jest.advanceTimersByTime(300); + component.ngOnDestroy(); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); +}); + +describe('Real: file-upload', () => { + let component: FileUploadPageComponent; + let sink: CapturingSink; + let logApi: AppLogApiProvider; + + function makeFile(name: string, rejects = false): File { + return { + name, + text: () => + rejects + ? Promise.reject(new Error('Read permission denied by the OS')) + : Promise.resolve('contents') + } as unknown as File; + } + + beforeEach(async () => { + jest.useFakeTimers(); + sink = new CapturingSink(); + sink.group = 'file-upload'; + + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition', + environment: 'production', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink } + ] + }).compileComponents(); + + component = TestBed.runInInjectionContext( + () => new FileUploadPageComponent() + ); + logApi = TestBed.inject(AppLogApiProvider); + }); + + afterEach(() => jest.useRealTimers()); + + it('processExtraInfoUploadedFile settles as success', async () => { + sink.currentCase = 'success'; + component.processExtraInfoUploadedFile(makeFile('schema.json')).subscribe(); + await Promise.resolve(); + jest.advanceTimersByTime(3000); + await Promise.resolve(); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('processFailingUploadedFile settles as failure with no error object', async () => { + sink.currentCase = 'failure (no error argument)'; + component.processFailingUploadedFile().subscribe(); + jest.advanceTimersByTime(500); + const entry = trace.find( + (e) => e.group === sink.group && e.case === sink.currentCase + ); + expect(entry).toBeDefined(); + expect( + (entry?.payload as { status?: string; error?: unknown })?.status + ).toBe('failure'); + expect( + (entry?.payload as { status?: string; error?: unknown })?.error + ).toBeUndefined(); + }); + + it('onExtraInfoFileProcessingCancelled settles as abandoned', async () => { + sink.currentCase = 'abandoned (user-cancelled)'; + component.processExtraInfoUploadedFile(makeFile('big.csv')).subscribe(); + await Promise.resolve(); + jest.advanceTimersByTime(1200); + component.onExtraInfoFileProcessingCancelled('big.csv'); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('ngOnDestroy mid-flight settles as abandoned', async () => { + sink.currentCase = 'abandoned (component-destroyed)'; + component + .processExtraInfoUploadedFile(makeFile('mid-flight.csv')) + .subscribe(); + await Promise.resolve(); + jest.advanceTimersByTime(800); + component.ngOnDestroy(); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('a rejecting file read logs "Error reading file"', async () => { + sink.currentCase = 'error reading file -> log line'; + component + .processExtraInfoUploadedFile(makeFile('corrupt.csv', true)) + .subscribe({ error: () => undefined }); + await Promise.resolve(); + await Promise.resolve(); + const errLog = logApi + .getRecords() + .find((r) => r.message === 'Error reading file'); + pushEntry( + 'file-upload', + 'error reading file -> log line', + true, + 'LOG API', + 'error', + errLog + ); + expect(errLog).toBeDefined(); + }); +}); + +describe('Real: autocomplete', () => { + let component: AutocompletePageComponent; + let sink: CapturingSink; + + beforeEach(async () => { + jest.useFakeTimers(); + sink = new CapturingSink(); + sink.group = 'autocomplete'; + + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition', + environment: 'production', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink }, + FormBuilder + ] + }).compileComponents(); + + component = TestBed.runInInjectionContext( + () => new AutocompletePageComponent(TestBed.inject(FormBuilder)) + ); + component.ngOnInit(); + component.singleOptionsObservable$?.subscribe(); + component.multiOptionsObservable$?.subscribe(); + }); + + afterEach(() => jest.useRealTimers()); + + it('a search settles as success', () => { + sink.currentCase = 'search success'; + component.onSingleInputChanged('lon'); + jest.advanceTimersByTime(1000); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('a search settles as failure (backend stubbed down, same pattern the existing spec uses)', () => { + sink.currentCase = 'search failure'; + jest + .spyOn( + component as unknown as { + _getOptionsFromServer: () => ReturnType; + }, + '_getOptionsFromServer' + ) + // `delay()` doesn't delay an error — use `timer` + `switchMap` instead. + .mockReturnValue( + timer(1000).pipe( + switchMap(() => throwError(() => new Error('backend down'))) + ) + ); + component.onSingleInputChanged('lon'); + jest.advanceTimersByTime(1000); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('a second keystroke supersedes the first search (abandoned)', () => { + sink.currentCase = 'search superseded'; + component.onSingleInputChanged('lon'); + jest.advanceTimersByTime(220); + component.onSingleInputChanged('par'); + jest.advanceTimersByTime(1000); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('selecting an option settles validation as success', () => { + sink.currentCase = 'validate success'; + component.onOptionSelected(component.options[0]); + jest.advanceTimersByTime(3000); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('destroying the component mid-flight settles every open scenario as abandoned', () => { + sink.currentCase = 'destroyed mid-flight'; + component.onSingleInputChanged('lon'); + component.onMultiInputChanged('lon'); + component.onOptionSelected(component.options[1]); + jest.advanceTimersByTime(400); + component.ngOnDestroy(); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); +}); + +describe('Real: BI events', () => { + let service: AppTelemetryService; + let sink: CapturingSink; + + beforeEach(async () => { + sink = new CapturingSink(); + sink.group = 'BI events'; + + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition', + environment: 'production', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink }, + { provide: Router, useValue: { events: new Subject().asObservable() } } + ] + }).compileComponents(); + + service = TestBed.inject(AppTelemetryService); + }); + + it('trackThemeChanged records theme_changed', () => { + sink.currentCase = 'theme_changed'; + service.trackThemeChanged('dark'); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('trackClick records sidebar_toggled', () => { + sink.currentCase = 'sidebar_toggled'; + service.trackClick('sidebar_toggled', { expanded: true }); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('trackClick records theme_option_changed', () => { + sink.currentCase = 'theme_option_changed'; + service.trackClick('theme_option_changed', { + dimension: 'color', + value: 'blue' + }); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('an identical click within the dedup window is suppressed', () => { + sink.currentCase = 'code_copied (twice, back to back — second suppressed)'; + const countBefore = trace.filter((e) => e.group === 'BI events').length; + service.trackClick('code_copied', { componentName: 'cps-table' }); + service.trackClick('code_copied', { componentName: 'cps-table' }); + const countAfter = trace.filter((e) => e.group === 'BI events').length; + pushEntry( + 'BI events', + 'duplicate call within 400ms dedup window — second call suppressed', + true, + 'AWS RUM', + 'observation', + { firstCallRecorded: countAfter - countBefore === 1 } + ); + expect(countAfter - countBefore).toBe(1); + }); + + it('the same click after the dedup window elapses is recorded again', () => { + sink.currentCase = 'code_copied (twice, 401ms apart — both recorded)'; + jest.useFakeTimers(); + const countBefore = trace.filter((e) => e.group === sink.group).length; + service.trackClick('code_copied', { componentName: 'cps-select' }); + jest.advanceTimersByTime(401); + service.trackClick('code_copied', { componentName: 'cps-select' }); + const countAfter = trace.filter((e) => e.group === sink.group).length; + jest.useRealTimers(); + expect(countAfter - countBefore).toBe(2); + }); +}); + +describe('Synthetic: statuses and fields composition never triggers', () => { + let sink: CapturingSink; + let scenarioTelemetry: CpsScenarioTelemetryService; + + beforeEach(async () => { + sink = new CapturingSink(); + sink.group = 'synthetic'; + sink.real = false; + + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition', + environment: 'production', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink } + ] + }).compileComponents(); + + scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + }); + + it('`incomplete` — no composition page ever calls .incomplete()', () => { + sink.currentCase = 'incomplete status (no real trigger exists)'; + const scenario = scenarioTelemetry.start({ + name: 'table-page-load', + feature: 'table' + }); + scenario.incomplete({ reason: 'no-results' }); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('`exceededStepsLimit` — no composition scenario ever opens 50+ steps', () => { + sink.currentCase = 'exceededStepsLimit (no real trigger reaches maxSteps)'; + const scenario = scenarioTelemetry.start({ + name: 'table-page-load', + feature: 'table' + }); + for (let i = 0; i < 55; i++) { + scenario.step('fetch'); + } + scenario.complete(); + const entry = trace.find( + (e) => e.group === sink.group && e.case === sink.currentCase + ); + expect( + (entry?.payload as { exceededStepsLimit?: boolean })?.exceededStepsLimit + ).toBe(true); + }); + + it('autocomplete-validate failure — onOptionSelected has no stubbable network seam', () => { + sink.currentCase = + 'autocomplete-validate failure (no stubbable seam on the component)'; + const scenario = scenarioTelemetry.start({ + name: 'autocomplete-validate', + feature: 'autocomplete' + }); + scenario.step('validate'); + throwError(() => new Error('Selected option failed remote validation')) + .pipe(traceScenario(scenario)) + .subscribe({ error: () => undefined }); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); +}); + +describe('Synthetic: mirrorErrorsToRum (composition leaves this off)', () => { + it('an error-level log is also mirrored to AWS RUM when the config is turned on', () => { + const sink = new CapturingSink(); + sink.group = 'synthetic'; + sink.real = false; + sink.currentCase = 'mirrorErrorsToRum: true (composition never sets this)'; + + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { + application: 'composition', + environment: 'production', + version: '1.0.0' + }, + withLogging({ mirrorErrorsToRum: true }) + ), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink } + ] + }); + + const logger = TestBed.inject(CpsLoggerService).getLogger('app'); + logger.error('Simulated unexpected failure', { + error: new Error('Simulated unexpected failure') + }); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); +}); + +describe('Synthetic: redaction (composition never logs PII-shaped data)', () => { + it('the key denylist and value-pattern scan both redact, when scanValuePatterns is turned on', () => { + const sink = new CapturingSink(); + sink.group = 'synthetic'; + sink.real = false; + sink.currentCase = + 'redaction of a contact-form-shaped payload (composition never logs anything PII-shaped)'; + + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { + application: 'composition', + environment: 'production', + version: '1.0.0' + }, + withRedaction({ scanValuePatterns: ['email', 'creditCard'] }) + ), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink } + ] + }); + + const logApi = TestBed.inject(AppLogApiProvider); + const logger = TestBed.inject(CpsLoggerService).getLogger('app'); + logger.log('User submitted contact form', { + metadata: { + email: 'jane.doe@example.com', + password: 'hunter2', + notes: 'Card ending in 4111 1111 1111 1111, please call back.' + } + }); + const record = logApi + .getRecords() + .find((r) => r.message === 'User submitted contact form'); + pushEntry( + 'synthetic', + 'redaction with scanValuePatterns on', + false, + 'LOG API', + 'log', + record + ); + expect(record?.metadata?.password).toBe('[redacted]'); + }); +}); + +describe('Synthetic: minLevel filtering (composition leaves minLevel at the default "log")', () => { + it('a log-level call is dropped once minLevel is raised to "warn"', () => { + const sink = new CapturingSink(); + sink.group = 'synthetic'; + sink.real = false; + sink.currentCase = + 'minLevel: "warn" (composition never overrides minLevel)'; + + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { + application: 'composition', + environment: 'production', + version: '1.0.0' + }, + withLogging({ minLevel: 'warn' }) + ), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink } + ] + }); + + const logApi = TestBed.inject(AppLogApiProvider); + const logger = TestBed.inject(CpsLoggerService).getLogger('app'); + logger.log('This should be dropped by minLevel'); + logger.warn('This should still go through'); + + const records = logApi.getRecords(); + pushEntry( + 'synthetic', + 'minLevel: "warn" filtering observation', + false, + 'LOG API', + 'observation', + { + droppedCallRecorded: records.some( + (r) => r.message === 'This should be dropped by minLevel' + ), + warnCallRecorded: records.some( + (r) => r.message === 'This should still go through' + ) + } + ); + expect( + records.some((r) => r.message === 'This should be dropped by minLevel') + ).toBe(false); + expect( + records.some((r) => r.message === 'This should still go through') + ).toBe(true); + }); +}); + +describe('Write trace', () => { + it('writes the captured trace + console transcript to disk', () => { + const outDir = path.join(__dirname, '..', '..', 'telemetry-trace'); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync( + path.join(outDir, 'trace.json'), + JSON.stringify( + { + generatedAt: new Date().toISOString(), + entries: trace, + consoleLines + }, + undefined, + 2 + ) + ); + expect(trace.length).toBeGreaterThan(0); + expect(consoleLines.length).toBeGreaterThan(0); + }); +}); diff --git a/projects/composition/tsconfig.app.json b/projects/composition/tsconfig.app.json index 2d412bbf4..922b756e4 100644 --- a/projects/composition/tsconfig.app.json +++ b/projects/composition/tsconfig.app.json @@ -3,6 +3,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/app", + "rootDir": "../../", + "skipLibCheck": true, "types": [] }, "files": ["src/main.ts"], diff --git a/projects/composition/tsconfig.json b/projects/composition/tsconfig.json index 7af56b668..fbd0afe94 100644 --- a/projects/composition/tsconfig.json +++ b/projects/composition/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/app", - "rootDir": "src", + "rootDir": "../../", + "skipLibCheck": true, "types": [] }, "include": ["src/**/*.ts"], diff --git a/projects/cps-telemetry/DESIGN.md b/projects/cps-telemetry/DESIGN.md new file mode 100644 index 000000000..6958c6b5e --- /dev/null +++ b/projects/cps-telemetry/DESIGN.md @@ -0,0 +1,1643 @@ +# cps-telemetry — development design + +A reusable Angular telemetry layer covering three separate concerns — +application logs, scenario health telemetry, and business/UX events — built +on a shared internal abstraction, with an AWS CloudWatch RUM sink. + +--- + +## 1. Goals + +- **Scenario health.** Measure whether a user journey (load customer data, + open a report, submit a search, export data) succeeded, how long it took, + and where the time went — so a regression shows up as a metric, not as a + support ticket. +- **Correlated diagnostics.** When a journey fails, make it possible to pull + the frontend logs, the frontend telemetry, and the backend logs for _that + one run_, using a single identifier. +- **Code-level diagnostics.** A scenario's status tells you a journey broke; + logs tell you where in the code and why. Both carry the same + `correlationId`, so together they read as one trail — from "what failed" + to "what the code was doing when it did." +- **Product signal.** Record feature adoption and interaction events without + putting business vocabulary inside the telemetry infrastructure itself. +- **Developer debugging.** Let any developer see exactly what telemetry is + being produced, in any environment, just by setting a LocalStorage flag — + no rebuild, no config change, no production switch. +- **Reusability.** Ship as a library another Angular application can install + and configure, with no trace of the Composition application inside it. +- **Safety.** Telemetry can never break the application, and it never + quietly leaks sensitive data. + +## 2. Non-goals + +Explicitly out of scope: + +- Backend telemetry or log-ingestion APIs. +- CloudWatch infrastructure, dashboards, alarms, or metric definitions. +- Lambda, API Gateway, IAM policies, CDK/Terraform. +- Backend log storage or retention policy. +- Session replay — recording and replaying a user's on-screen session as + video, DOM mutations and all. RUM supports it; we do not enable it. +- A general-purpose observability platform. This is a small library. + +We assume the backend RUM credential broker and the log store already exist: +the library just defines `CpsLogApiProvider`, and the consuming application +implements it. Nothing here mocks either one — a library that ships a fake +backend is shipping a lie about what it actually does. + +--- + +## 3. Proposed architecture + +```mermaid +flowchart TD + A[Angular Application] + + A --> B[CpsLoggerService] + A --> C[CpsScenarioTelemetryService] + A --> D[CpsBiTelemetryService] + + C -->|creates| C2[CpsScenario instance] + + B --> E[CpsLogApiProvider] + + C2 --> G[CpsTelemetrySink] + D --> G + G --> G1[CpsRumTelemetrySink] + G --> G2[CpsNoopTelemetrySink] + + G1 --> H[aws-rum-web] + H --> I[AWS RUM] + I --> J[CloudWatch] + + I --> K[Built-in: page views, web vitals,
JS errors, HTTP, resources, session] + J --> L[Custom: scenario + BI events
→ extended metrics / Logs Insights] + +``` + +### Components + +| Component | Responsibility | +| ----------------------------- | ------------------------------------------------------------------------------------------------ | +| `CpsLoggerService` | Structured `log`/`warn`/`error`, plus child loggers with a bound correlation id | +| `CpsScenarioTelemetryService` | Creates scenarios; flushes any still-running ones at page unload | +| `CpsScenario` | One independent journey — its steps, aggregates, and outcome | +| `CpsBiTelemetryService` | Discrete business/UX events, deduplicated within a short window | +| `CpsTelemetrySink` | Abstract destination for scenario and BI events | +| `CpsRumTelemetrySink` | The AWS RUM adapter — lazy SDK load, credentials, a pre-init buffer, and flushing | +| `CpsNoopTelemetrySink` | Explicit opt-out — everything runs, nothing ships | +| `CpsLogApiProvider` | The seam where the application supplies its own log store — send, query, and an optional `flush` | +| `CpsRumCredentialsProvider` | The seam where the application supplies its AWS details | + +### Shared infrastructure + +`CpsTelemetryIdentity` + `CPS_TELEMETRY_IDENTITY` (identity, shared by every +concern), `CPS_LOG_CONFIG` / `CPS_SCENARIO_CONFIG` / `CPS_BI_CONFIG` / +`CPS_REDACT_CONFIG` (one per concern), `CpsTelemetryMetadata`, +`cpsIsDebugEnabled`, `cpsRedactMetadata` / `cpsNormalizeError` / +`cpsScrubString` / `cpsRedactConfigFor`, `cpsSafe` / `cpsSafeVoid` / +`cpsUuid` / `cpsNow`. + +Everything is wired through Angular DI, so the AWS implementation can be +swapped out in production and stubbed in tests. + +### Entry points + +Two: `cps-telemetry` and `cps-telemetry/rum`. Both barrels list their +exports one by one rather than re-exporting whole modules — this is a +published package, so every exported name is a permanent compatibility +promise. Id generation, the clock, the fail-open wrappers, user timings, +and the broadcast channel plumbing all stay internal, so they can change +freely without a breaking release. Redaction is the exception: an author +writing a custom sink needs `cpsRedactMetadata`, `cpsNormalizeError`, +`cpsScrubString` and `cpsRedactConfigFor`, so those are exported. + +`CpsRumTelemetrySink`, `provideCpsTelemetryRumSink`, and everything +`CpsRumCredentialsProvider`-shaped live in `cps-telemetry/rum`, not the main +entry point. `aws-rum-web` is an optional peer dependency: a bundler +resolves every statically-imported module in a file's graph unconditionally, +and a dynamic `import()`'s specifier at build time regardless of whether +that code path runs — so keeping `CpsRumTelemetrySink` (and its +`await import('aws-rum-web')`) out of the main entry point's module graph is +what keeps `aws-rum-web` resolvable only for code that imports +`cps-telemetry/rum`. An app calling only +`provideCpsTelemetrySink('broadcast' | 'noop')` never needs it. + +`cps-telemetry/rum` carries its own private copy of +`cpsSafe`/`cpsSafeVoid`/`cpsIsBrowser`/`cpsIsDevMode`/`cpsUuid`. ng-packagr +fixes every entry point's `rootDir` to its own `src` directory (see its own +`tsconfig.js`), so `cps-telemetry/rum` cannot reach the main entry point's +internal utilities by relative import. + +**Test doubles are not shipped, and do not get their own folder or file.** A +`RecordingSink` or `ThrowingSink` is declared inline, at the top of whichever +spec needs it — duplicated per file rather than shared, since a double used +by one or two consumers does not earn its own abstraction. None of them are +exported. They exist purely to test _this library_; the one browser gap that +still needs a stub is `BroadcastChannel`, which jsdom does not implement at +all, and even that is just a small inline stub in `cps-broadcast.spec.ts` +rather than a dedicated file. An application testing its own code needs a +five-line provider array and, if it wants to assert on emitted events, a +six-method sink double — both are shorter to write than to depend on, and +neither one then constrains this package's API. The build confirms the +separation: none of this appears in the emitted bundle or the `.d.ts`. + +--- + +## 4. Data model + +### Log record + +```ts +interface CpsLogRecord { + timestamp: string; // ISO-8601 + level: 'log' | 'warn' | 'error'; + message: string; // scrubbed, length-capped + logger?: CpsLoggerName; // which part of the app wrote it + context?: string; // free-form subsystem label + metadata?: CpsTelemetryMetadata; + error?: CpsTelemetryError; + correlationId?: string; // usually a scenarioId + application: string; + environment: string; + version: string; + userId?: string; + sessionId?: string; // taken from the sink, so logs join the RUM stream +} +``` + +Only `timestamp`, `level`, `message`, and the application identity are +always present. `logger.log('message')` is a complete, valid call on its +own. + +### The name vocabulary + +Scenario names, step names, aggregate names, and logger names are all finite +types, not plain `string`: + +```ts +// declared by the library, empty +interface CpsScenarioNames {} +interface CpsScenarioSteps {} +interface CpsLoggerNames {} + +type CpsScenarioName = keyof CpsScenarioNames extends never + ? string + : keyof CpsScenarioNames; +``` + +Scenario, step, and aggregate names are metric dimensions. A misspelled one +does not produce a wrong figure — it silently starts a second, incomplete +series, and the alarm built on the first one keeps reading healthy. An +interpolated id causes the exact same problem, just with unlimited possible +values. Both are now compile errors instead. + +A logger name fails differently, but no better: it is the key every record +carries to the backend, so a typo means a whole stream quietly lands in the +wrong place. + +A published package cannot know the names an application will use in +advance, so the registries above start out empty, and the **consuming +application declares its own vocabulary** in a schema file that augments +them: + +```ts +declare module 'cps-telemetry' { + interface CpsScenarioSteps { + 'resolve-route': true; + } +} +``` + +`keyof` an empty interface is `never`, which would make every call +uncallable — hence the fallback to `string`. Adoption is therefore opt-in +and incremental: names are unconstrained until the first augmentation, then +fully checked from then on. Steps and aggregates share one registry because +they name the same kind of thing — a name declared for a step is just as +valid passed to `aggregateStart`. + +The library never writes any log lines of its own, so `CpsLoggerName` is +exactly whatever the application declares — there are no reserved names to +work around. + +### Scenario + +```ts +interface CpsScenarioRecord { + scenarioId: string; // uuid — the correlation id + parentScenarioId?: string; + scenarioName: CpsScenarioName; + feature?: string; + operation?: string; + route?: string; + status?: CpsScenarioStatus; // undefined on a toRecord() snapshot taken mid-flight + statusCode?: string | number; // HTTP status or business code + message?: string; + reason?: string; // structured, low-cardinality — independent of message + error?: CpsTelemetryError; + startTime: string; // ISO-8601 + endTime?: string; // ISO-8601; wall-clock position, not a duration + delta: number; // ms — total duration, the headline latency measure + elapsed: number; // ms since this page loaded, at settle — a timeline position + stepCount: number; + steps: CpsScenarioStep[]; // forensics in a log query, not metric material (§7) + exceededStepsLimit?: boolean; // steps[] truncated at maxSteps + previousStep?: CpsStepName; // last real step closed before settling + aggregates?: CpsScenarioAggregate[]; + metadata?: CpsTelemetryMetadata; + application: string; + sessionId?: string; + userId?: string; +} + +interface CpsScenarioAggregate { + name: CpsStepName; // shares the step vocabulary + elapsed: number; // summed across every call + callCount: number; +} +``` + +An aggregate is reported separately from steps because it has a duration but +no position on the timeline — it is for work that runs many times inside one +scenario, where the total is the useful number and a hundred individual +steps would just be noise. + +**No `spanId` field.** A 16-hex W3C/X-Ray-format trace span id, separate from +`scenarioId` (the journey correlation id), would only earn its place if it +actually lined a scenario up against a distributed trace — nothing in any +consuming application wires it into an X-Ray header, and nothing else in +this library reads one either. Shipping it anyway would be dead weight on +every event, for a use case nobody exercises. It can be added the day +something actually threads it through. + +**`application`, `sessionId` and `userId` are on this record.** The +alternative — relying on the fact that AWS's own `Dispatch.js` already +attaches `UserDetails: { userId, sessionId }` to _every_ `PutRumEvents` +request, so leaving them off the payload body would save real bytes against +the 200-event session cap — is a real saving, but it loses to a bigger cost: +a payload that makes you cross-reference the outer request envelope just to +answer "who did this, which session, which app" is a worse experience when +you are reading the record directly, whether in a Logs Insights query against +the event body or in any consumer that only ever sees the payload. +Consequently, the extra bytes win. `sessionId` and `userId` stay optional at the type level: +`sessionId` genuinely is not there before the RUM client finishes +initializing, and `userId` only exists once someone has signed in. + +`CpsLogRecord` carries both too, but for a related, distinct reason: logs go +straight to a backend of the application's own choosing, with no RUM +envelope to inherit identity from at all — so the record has to state it +outright. + +### Scenario step + +```ts +interface CpsScenarioStep { + name: CpsStepName | 'scenario-start' | 'scenario-end'; + startOffset: number; // ms from scenario start, not an epoch + endOffset?: number; + stepDelta?: number; // this step's own duration + elapsed?: number; // ms since page load, at step close — a timeline position + status?: CpsScenarioStepStatus; // same union as the scenario + message?: string; + reason?: string; // structured, low-cardinality — independent of message + error?: CpsTelemetryError; + metadata?: CpsTelemetryMetadata; +} +``` + +Offsets are relative to the scenario's own start, so they stay as small +integers. That matters because every step ships inside the scenario event's +own payload. + +`elapsed`/`stepDelta` split the same way the record's own `elapsed`/`delta` +do, and for the same reason: a duration and a timeline position are +different questions, and should not share one name. `delta` names a duration +on the scenario; `stepDelta` names the same idea on a step. + +**Once a scenario settles, `steps` has at least two entries.** Two +zero-duration markers bookend whatever real steps the caller declared: +`scenario-start`, written in the constructor, and `scenario-end`, written at +settlement, carrying the scenario's own final status plus whichever of +`message`/`metadata`/`error` the settle-time `CpsScenarioOutcome` supplied. +That is the same detail that also closes whatever real step was still open, +and that lands on the record's own root fields. All three views agree with +each other, so reading `steps[]` alone tells the full story without +cross-referencing the root record. A scenario that never calls `.step()` +still gets exactly the two boundary markers once it settles. Neither counts +toward `stepCount`, `exceededStepsLimit` or `maxSteps` — that budget is only +about steps the caller actually opened. The type allows the two literal +names alongside `CpsStepName` rather than requiring every consuming +application to register them, because they are the library's own to write, +not the application's to declare. + +`scenario-end` is only written at settlement, so this two-entry minimum is a +property of a _settled_ record, not a guaranteed one: `toRecord()` is public +and safe to call on a still-running scenario, and a snapshot taken before +the first `.step()` call — before `scenario-end` exists — can have as few as +one entry (just `scenario-start`). + +### BI event + +```ts +interface CpsBiEvent { + eventName: string; // supplied by the application + eventTime: string; + scenarioId?: string; // optional correlation to a journey + feature?: string; + // no `route` — the RUM envelope's pageId already carries the page + metadata?: CpsTelemetryMetadata; + application: string; +} +``` + +`application` is here for the same self-describing-payload reason as the +scenario record. `sessionId`/`userId` were not asked for on BI events, and the +same "AWS already has it" argument applies here unweakened — so they were +left off. + +### Fields deliberately left out + +The brief suggested several fields that are **not** collected. Each has its +own reason: + +| Omitted | Why | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `device`, `browser`, `page` | The RUM envelope already attaches `browserName`, `browserVersion`, `osName`, `osVersion`, `deviceType`, `pageUrl`, `pageId`, `countryCode`, `referrerUrl` to _every_ event. Repeating them would just be duplicated payload against a capped event budget. | +| `sessionId`, `userId` on BI events | Already carried once per session via `addSessionAttributes` for every event type, BI included. The scenario record makes the opposite trade for these two specifically — see above for why. | +| `environment`, `version` on events | Same session-attributes argument. `application` is the one exception here (see above); `environment`/`version` stay off both records. **Except when forwarded across realms**: the host records through _its own_ client, so `CpsBroadcastTelemetrySink` stamps its own `application`/`environment`/`appVersion` onto each forwarded event's _metadata_ — a second, independent place a fragment's identity ends up, since the host has no other way to tell which fragment sent it (§13). A forwarded **error** has no metadata field to carry this in — `AwsRum.recordError(error: any)` takes no second argument at all — so `CpsRumTelemetrySink` instead folds a differing origin into the error's own `name` (e.g. `[fragment-app] TypeError`) rather than silently attributing every fragment error to the shell. | +| `networkType` | The Network Information API is Chromium-only and its values are coarse and unreliable — a metric nobody can trust across browsers is worse than no metric at all. | +| `timeToStart` | RUM's built-in navigation timing already answers "how long until the app was interactive". | +| Separate `latency` | For a scenario that is `delta`; for a step it is the step's own `stepDelta`. A third name for the same number would just invite inconsistent dashboards. | + +--- + +## 5. Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> running: start() + running --> running: step() / setData() / aggregateStart() + running --> SUCCESS: complete() + running --> FAILURE: fail() + running --> ABANDONED: cancel() / pagehide + running --> INCOMPLETE: incomplete() + running --> TIMEOUT: deadline passes + SUCCESS --> [*] + FAILURE --> [*] + ABANDONED --> [*] + INCOMPLETE --> [*] + TIMEOUT --> [*] +``` + +**Valid transitions.** A running scenario can go to exactly one of +`success`, `failure`, `abandoned`, `incomplete`, or `timeout`. Nothing else. + +`timeout` is a deliberate fifth status, split out of `abandoned` — see +below. There is no "in progress" status at all. A running scenario has no +status: `CpsScenario.status` is `undefined` until it settles, and +`isSettled` is just derived from that same field (`status !== undefined`) +rather than tracked as a second, separate boolean that could drift out of +sync with it. + +Nothing is emitted at start either, in any mode: a scenario that has not +settled has no status to report yet, so a start event would have nothing to +say. + +**Why there are four unsuccessful outcomes, not one.** Each demands a +different response, and collapsing them together would make the failure +rate useless for alerting: + +| Status | Meaning | Response | +| ------------ | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `failure` | A defect — the journey broke | Investigate; page someone if the rate spikes | +| `abandoned` | The journey stopped being relevant: navigated away, or the page unloaded | Engagement signal, not a defect | +| `incomplete` | An expected path that did not reach the goal — no results, a guard declined, a flag routed elsewhere | Product signal, not an engineering one | +| `timeout` | The scenario never settled within its deadline | Engagement signal, distinct from a defect and from a deliberate abandon | + +**`timeout` is its own status, not folded into `abandoned`.** `incomplete` +already establishes that this design does not treat every unsuccessful +outcome as a variant of `abandoned` — it gets its own status whenever the +cause is worth distinguishing. Applying that same idea to timeout means it +can be filtered or alerted on directly (`status = 'timeout'`) instead of +reaching into `metadata.abandonedBy`. `metadata.abandonedBy` still +distinguishes the two remaining causes folded into `abandoned`: +`'page-hidden'` for a scenario the page ended under the user, and `'caller'` +for a deliberate `cancel()`. `cancel(outcome?)` accepts the same +`CpsScenarioOutcome` shape as the other three settle methods, so custom +metadata and status codes are available if the caller wants to record +contextual details (which modal action triggered the cancel, for example). + +**RxJS pipeline integration (`traceScenario`).** For Observable-based flows +(HTTP fetches, dialog results), the pipeable `traceScenario` operator wires +Angular reactive pipelines directly into scenario lifecycle, with no manual +`tap`/`catchError` boilerplate — it settles on `complete` with optional +derived outcome metadata, and fails with the caught error on `error`. A +teardown that reaches neither — a superseding `switchMap`, `takeUntilDestroyed()`, +a manual unsubscribe — cancels the scenario instead, guarded by `isSettled` +so it never re-cancels one that already completed or failed (`tap`'s own +`unsubscribe` hook fires after every teardown, settled or not). Without +this, a cancelled-by-unsubscription scenario would sit active until its own +timeout and record as `timeout` rather than the caller-driven abandonment +it actually was. + +**Active registry leak detection.** In long-lived single-page applications +or kiosks, scenarios with `timeoutMs: 0` that get forgotten because of a +component lifecycle bug stay in memory until the page unloads. The service +issues a dev-mode warning once `active.size` passes 50, so authors notice +during local development. + +**No `started` or `in_progress` state.** The brief proposed both, but +nothing would ever actually query for one: a scenario has no status until it +settles, and nothing is emitted until then either, so a "running" value +would just sit unread between the two moments that actually matter. + +**Terminal states stay terminal.** Calling `complete()` on a scenario that +already failed is a no-op, logged when `debugScenario` is on. This matters +in real code: a `catch` block calls `fail()` and a `finally` block calls +`complete()`, and the scenario has to record the failure, not the last call +made. + +**Step closing is implicit.** Opening a step closes the previous one as +completed; settling closes whatever step is still open with the scenario's +own settling status — an unfinished step failed because its scenario +failed, not because of anything it did on its own. + +**Backdating.** `startedAt` moves the start earlier, in epoch milliseconds, +so a journey can be measured from the click rather than from the handler +that eventually runs. It is converted through `performance.timeOrigin` and +clamped to the page's lifetime; an unusable value falls back to now instead +of producing a negative duration. + +**Settling on paint is the caller's business.** A journey ending in a +render is not finished when the JavaScript is — but how long to wait for +pixels, and what to do when none arrive, is an application decision with no +single right answer. The library therefore settles nothing on paint, and ships no +helper for it either: two animation frames is a one-liner, and a caller who +needs a real paint can mark the element with `elementtiming` and observe +`element` entries with a `PerformanceObserver` — that is Chromium-only, so a +fallback is needed regardless. If nothing ever paints, the scenario is left +to its own timeout and settles as `timeout`, which is the truthful outcome — +the user never saw the result. + +A `completeOnNextPaint()` convenience method — one that resolves three ways +and records which one fired — is not offered, because two of the three +outcomes would be wrong in exactly the way that matters. A paint that never +happens would have to settle as **success** to fit that shape, inflating the +success rate exactly when rendering is worst. An observed-paint path, in turn, can +only timestamp the callback rather than the paint itself, so even the one +accurate route would still be inflated by dispatch latency. The example's +`stopAtNextFrame` gets both right — it cancels on timeout and backdates via +`overrideTimestamp` — but reproducing that faithfully would be more +machinery than the convenience is worth. + +**Timeout.** A scenario that never settles would silently vanish, inflating +the apparent success rate. Each one carries a timer (default 30s, +per-scenario overridable, `0` disables it) that settles it as `timeout`. + +**Unload.** On `pagehide`, every in-flight scenario settles as `abandoned` +with reason `page-hidden`, and the sink is flushed with `dispatchBeacon()`. +This is what makes abandonment measurable at all. Emission is synchronous +throughout, so everything settled here reaches the sink before the beacon +goes out. + +**Going hidden.** Mobile browsers routinely kill a backgrounded tab without +ever firing `pagehide`, so anything still buffered at that point would +otherwise just be lost. On `visibilitychange` going `hidden`, the sink is +flushed the same way — but scenarios are left running. Only `pagehide` +settles them, since the user may still come back. + +**Pause/resume is not implemented.** Nothing in scope needs a scenario that +spans app backgrounding, and pause handling — tracking paused duration, +adjusting every mark/step/settle path to consult it — is real complexity not +worth carrying for a capability nothing here actually uses. + +--- + +## 6. AWS mapping + +```mermaid +flowchart TD + A1["scenario.complete()"] --> S1["CpsTelemetrySink"] + A2["logger.error()"] --> S2["CpsLogApiProvider"] + A3["biTelemetry.track()"] --> S3["CpsTelemetrySink"] + + S1 --> R1["aws-rum-web: recordEvent('com.cps.scenario', …)"] + S3 --> R2["aws-rum-web: recordEvent('com.cps.bi', …)"] + + R1 --> RUM["AWS RUM"] + R2 --> RUM + S2 --> LOGAPI["the app's log API"] + + RUM --> CW["CloudWatch — app monitor log group
(scenario events also power extended metrics)"] +``` + +### Verified against the installed SDK + +Checked against `aws-rum-web@3.2.1` as installed in this repository — not +against documentation. + +| Capability | Verdict | Evidence | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| Custom events | ✅ `recordEvent(eventType, eventData, metadata?)` | `@aws-rum/web-slim/dist/es/orchestration/Orchestration.d.ts` | +| Custom event attributes | ✅ third `metadata` argument, `Record` | `@aws-rum/web-core/dist/es/plugins/types.d.ts` | +| Global event decoration | ✅ `setEventMetadataHook()` / `clearEventMetadataHook()` | same | +| Session attributes | ✅ `addSessionAttributes()` | same | +| Session identification | ✅ `getSessionId()`, `pinSessionId()`, `startSession()` | same | +| User identification | ✅ `getUserId()`, `pinUserId()` | same | +| Page / action tracking | ✅ `recordPageView()`, `registerDomEvents()` | same | +| Errors | ✅ `recordError()` | same | +| Performance measurements | ✅ via built-in `performance` telemetry + Web Vitals plugin | `WebVitalsPlugin`, `NavigationPlugin`, `ResourcePlugin` | +| Flushing / buffering | ✅ `dispatch()`, `dispatchBeacon()`; `dispatchInterval` 5s, `batchLimit` 100 by default, both configurable via `CpsRumAppMonitorConfig` | `Orchestration.js` defaults | +| Custom **metrics** | ❌ **not an SDK concept.** Emit events; define CloudWatch RUM _extended metrics_ server-side | +| Correlation identifiers | ⚠️ **no built-in scenario correlation.** X-Ray trace ids exist for HTTP; journey correlation is ours to design | +| Reading telemetry back from the browser | ❌ no export/read API | +| Offline behaviour | ⚠️ events sit in the in-memory cache (`eventCacheSize` 1000 by default, configurable) and are lost on tab close; there is no persistent queue | +| Web Worker execution | ❌ **not possible** — `SessionManager` reads `window.location.hostname`, `document.cookie` and `navigator.cookieEnabled`, none of which exist in a worker context | + +### The constraint that shaped the design + +SDK defaults, from `@aws-rum/web-slim/dist/es/orchestration/Orchestration.js`: + +``` +sessionEventLimit: 200 <-- hard cap on events per session +eventCacheSize: 1000 +batchLimit: 100 +dispatchInterval: 5000ms +sessionSampleRate: 1 +``` + +All five are now exposed on `CpsRumAppMonitorConfig` (§10) — an application +that knows its own traffic can deliberately raise `sessionEventLimit`. The +numbers above are still the _default_, and the design below assumes the +default. It describes what happens when nobody has opted into a larger +budget, not what the SDK allows in general. + +**200 events per session, across all telemetry, by default.** A running +scenario has no `started` event — nothing is emitted until it settles (§5) +— so a six-step scenario emitting one packed event at settlement spends +exactly 1 of the 200; with `emitLifecycleEvents: true` it would spend 7 (one +per step plus the final packed event, N+1 not N+2). Roughly thirty six-step, +lifecycle-emitting scenarios exhaust the default session budget — including +the budget for page views, web vitals, and JS errors, which then get +dropped. + +Consequently, **a scenario emits exactly one RUM event, at settlement**, with its steps +packed into the payload. That is the default, not just an option, and it +stays the default even though `sessionEventLimit` is now configurable: +raising the cap moves the ceiling, it does not change the shape of the +emission. An application that wants step-level events sets +`scenario.emitLifecycleEvents: true` and, if the wider event count needs +headroom, raises `sessionEventLimit` alongside it. Per-lifecycle-event +emission is meant for local debugging, with the cost documented here and in +§7. + +### Event types + +One event type per concern, with whatever varies carried as data: + +| Type | When | +| -------------------- | -------------------------------------------------------------------- | +| `{ns}.scenario` | Once per scenario at settlement (`status` distinguishes the outcome) | +| `{ns}.scenario.step` | Per step — verbose mode only | +| `{ns}.bi` | Per business/UX event (`eventName` carries the vocabulary) | + +Distinct types per transition (`scenario_started`, `scenario_completed`, …) +were considered and rejected: they'd multiply both the schemas to query and +the extended-metric definitions to maintain, where a `status` dimension does +the same job on a single schema. + +`{ns}` is `eventNamespace`, defaulting to `com.cps` (exported as +`CPS_DEFAULT_EVENT_NAMESPACE`, alongside the `cpsEventTypes()`/ +`CpsTelemetryEventTypes` helpers a custom sink can use to derive the same +`{ns}.scenario`/`{ns}.scenario.step`/`{ns}.bi` strings). It is configurable +because **event types are a contract with whatever already queries them** — +extended metrics, Logs Insights queries, dashboards. An application +migrating onto this library keeps its own namespace, and none of that has to +be rewritten: + +```ts +provideCpsTelemetry({ ..., eventNamespace: 'com.data-gateway' }); +// -> com.data-gateway.scenario / .scenario.step / .bi +``` + +If a dashboard is keyed on one specific legacy type rather than a +namespace, a single BI event can override its type: + +```ts +biTelemetry.track( + 'click', + { source: 'toolbar' }, + { + eventType: 'com.data-gateway.click' + } +); +``` + +That is a migration escape hatch, not a pattern to reach for — giving every +business event its own type is exactly what the single-type design is meant +to avoid. + +The `aws:` metadata prefix is reserved — the client drops any such key with +a console warning — so the sink filters them out before recording. + +### Which signals come from where + +| Layer | Answers | Source | +| ---------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **RUM built-in** | Is the app fast? Is it erroring? Who is using it? | Automatic: page views, navigation timing, Web Vitals (LCP/FID/CLS/INP), JS errors, HTTP errors, resource timing, session start, Apdex-style perceived performance | +| **Scenario telemetry** | Does _this journey_ work, how long does it take, where does the time go, why does it fail? | Custom `{ns}.scenario` | +| **BI telemetry** | Is the feature used, by how many people, in what order? | Custom `{ns}.bi` | +| **Application logs** | What exactly happened during this one run? | `CpsLoggerService` → the application's `CpsLogApiProvider` | + +Nothing in the custom layers duplicates a built-in signal. The library +does not record page views at all, does not re-capture unhandled JS errors, +and does not collect browser or device attributes. + +### CloudWatch + +- **Custom RUM events do not automatically become CloudWatch metrics.** They + land in the app monitor's log group. To get a metric, you have to define a + RUM _extended metric_ that maps the event to a metric with dimensions. + That is server-side configuration, out of scope here — the frontend's job + is just to emit fields that are suitable as dimensions (low cardinality: + `scenarioName`, `status`, `feature`, `operation`) and values (`delta`, + `stepCount`). +- **Percentiles (P50/P75/P95/P99) are achievable**, two ways: CloudWatch + Logs Insights over the app monitor log group (`stats pct(delta, 95) by +scenarioName`), or a CloudWatch percentile statistic over an extended + metric. +- **Percentiles are never computed in the browser.** A single client only + sees a handful of samples; a percentile over them would be meaningless, + and shipping one would throw away the raw values needed to compute the + real figure. The frontend ships raw `delta` per scenario and lets AWS + aggregate it. +- **CloudWatch Logs and CloudWatch RUM are separate streams**, and they are + treated as such. They are joined analytically via `sessionId` and + `scenarioId`, not by any automatic AWS-side correlation. + +--- + +## 7. Metrics enabled + +### Reliability + +Derived from `status` over `com.cps.scenario`, grouped by `scenarioName`: + +- success rate — `success / total` +- failure rate — `failure / total` +- abandonment rate — `abandoned / total` +- timeout rate — `timeout / total` +- incomplete rate — `incomplete / total` +- error-category distribution — group by `statusCode` and `error.name` + +Each of these is a query on `status`, a low-cardinality dimension that +works well as a CloudWatch extended metric. Within `abandoned`, +`metadata.abandonedBy` separates a page unload from an in-application +cancellation; `timeout` is its own status and needs no such lookup. + +Keeping these apart is the whole point: a user navigating away mid-load +is not a defect, a search with no results is not a defect either, and folding +either of them into `failed` makes the failure rate useless for alerting. + +### Latency + +Whole-journey, straight from the packed event: + +- total scenario duration — `delta` +- perceived vs. scripted duration — for journeys where the caller settles on + an observed paint, `delta` covers what the user actually waited for + rather than when the JavaScript finished. Mark those scenarios in their + own `metadata` if you need to separate them from the rest + +Percentiles P50/P75/P95/P99 over either, computed AWS-side (§6). + +`endTime` is **not** a second duration: by construction it is +`startTime + delta` (as an instant, once both are ISO), so the gap between +them is just `delta` under another name. The pair exists to place a journey +on the wall clock — when it ran, and which journeys overlapped — not to +measure how long it took. `elapsed`, unlike either, is not about this journey +specifically at all — it is a timeline position (ms since the page loaded), +the same idea a step's own `elapsed` carries, and it is there so events from +one session can be lined up against each other without this library having +a session start time to work from. That proxy is inexact across a hard +reload: the RUM session cookie (`allowCookies` on by default, 30-minute +default length) survives a reload that `performance.timeOrigin` does not, so +a `sessionId` can span an `elapsed` reset. + +**Within-journey latency is payload, not a dashboard metric.** +`steps[].stepDelta`, `aggregates[].elapsed` and `callCount` all ship on the +record and answer "where did the time go" in a Logs Insights query. They +cannot drive a CloudWatch metric while scenarios emit packed, because a +metric definition selects one value per event and a step array holds N. The +same is true of anything derived from them, including slow-step rate. + +There are two ways to get step-level metrics when they are worth their cost: + +- `emitLifecycleEvents: true` emits a `{ns}.scenario.step` event per step, + with `name`, `stepDelta` and `status` at the top level — directly usable + as a metric with dimensions. The cost is the session budget: a six-step + scenario spends seven of the 200 events — one per step plus the final + packed event — so a few dozen scenarios exhaust it, and later events, + errors included, get dropped. +- Keep packed emission and read step timings in Logs Insights instead, + accepting that they are a troubleshooting tool rather than something an + alarm watches. + +The top-level fields already answer the two questions most often asked of +step data without unpacking anything: `previousStep` says where journeys +stop, and `exceededStepsLimit` says when a step list was truncated and +should not be read as a short journey. + +### Usage + +- scenario count, starts and completions — event counts by `status` +- scenario frequency — counts over time +- unique users — RUM's own `userId` dimension +- feature adoption — `com.cps.bi` counts by `eventName` and `feature` + +### Additional signals evaluated + +| Signal | Kept? | Reasoning | +| ------------------------------------------ | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| retry count | ❌ | Evaluated and not collected. A retry is a step like any other, so `stepCount` and repeated step names already carry the signal; a dedicated counter would mean adding a `retry()` method this design has no other reason to want | +| number of steps | ✅ `stepCount` | Detects runaway loops and identifies which code path ran | +| success-after-retry | ❌ | Follows retry count out: without a counter there is nothing to derive it from | +| slow-step rate | ⚠️ derived, verbose only | Thresholding step `stepDelta` needs one event per step, so it is only a metric under `emitLifecycleEvents`; packed, it is a Logs Insights query | +| error-category distribution | ✅ `statusCode` + `error.name` | Separates "backend 500" from "client-side type error" | +| client/application version | ✅ session attribute | Attributes a regression to a release | +| network/API dependency failures | ✅ | Already covered by RUM's built-in HTTP telemetry — not duplicated | +| scenario version / feature version | ❌ | Folded into `feature` plus the application version; separate version fields on every event would be payload with no distinct question behind them | +| time spent per step (as a separate metric) | ❌ | That _is_ `steps[].stepDelta` — carried on the record, queryable, but not a dashboard metric while packed (see above) | +| aggregate operation totals | ✅ `aggregates[]` | Repeated work — a formatter per row — where the total matters and per-call steps would be noise. Payload for queries, not a metric, for the same array reason | +| truncation awareness | ✅ `exceededStepsLimit` | A dashboard should not need to know the configured `maxSteps` to spot a partial step list | +| paint-aware duration | ⚠️ caller-driven | A caller may settle on a real paint of its own; the library records no paint field of its own, having removed one that was wrong on two of its three paths | + +Nothing here is collected "for completeness" — each field answers a +troubleshooting or product-health question stated above. + +--- + +## 8. Correlation + +```text +user (userId) + └── session (sessionId, from the RUM client) + ├── scenario (scenarioId) + │ ├── steps + │ └── BI events carrying that scenarioId + ├── BI events (standalone) + └── logs (correlationId = scenarioId) +``` + +| Identifier | Origin | Purpose | +| --------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------- | +| `userId` | Application, via `CpsTelemetrySink.setUserId` → `pinUserId` | Unique users; cross-session journeys | +| _sign-out_ | `setUserId(undefined)` → `startSession({ userId: })` | Stops attributing later events to whoever just left | +| `sessionId` | The RUM client (`getSessionId`), or the shell in a follower realm | Joins logs to the RUM stream — deliberately _not_ minted separately | +| `scenarioId` | `cpsUuid()` per scenario | The join key across telemetry, logs, and the backend | +| `correlationId` | A log field, normally set to a `scenarioId` | Ties log lines to a journey | + +Both `userId` and `sessionId` live on the sink, and that is the point: the +logger reads them from there whenever it stamps a record, so a log line and +a RUM event can never disagree about who is signed in or which session this +is. There is no separate context service holding its own copy of `userId` — +a second place for the same value to live would only ever drift out of sync +with the sink's own. Sign-out is `setUserId(undefined)`, and correlation +runs only through `getSessionId()`/`setUserId()`. + +The API makes correlation the path of least resistance rather than +something to remember: + +```ts +const scenario = scenarioTelemetry.start({ name: 'load-customer-data' }); + +logger.error('Failed to load customer data', { correlationId: scenario.id }); +``` + +Alternatively, pass a `logger` to `start()` and the scenario binds the id +for you: `scenario.logger?.error('…')`. It is supplied rather than injected, +so scenario telemetry never requires the logging stack to already be +configured. + +**Backend correlation is by convention, not magic.** AWS performs no +automatic cross-system correlation. Send `scenario.id` to the backend as a +request header (e.g. `X-Correlation-Id`) and have backend logs record it +under the same name — the join then works on its own. The library +deliberately does not ship an HTTP interceptor for this — header naming and +which hosts may receive the header are application decisions to make. + +--- + +## 9. Privacy + +### Rules + +1. **Metadata is flat and primitive-only.** `CpsTelemetryMetadata` is + `Record`. It is the type + system, not a runtime heuristic, that stops an application from passing + a response body, a `User` object, or a DOM node into telemetry. Values + that reach the runtime through an `any` are dropped, not flattened. +2. **Arbitrary objects are never serialized.** There is no recursive + serializer to mis-tune. Objects, arrays, functions, symbols and + `undefined` are all dropped. +3. **Denylisted keys are redacted**, case-insensitively, matching + `pass(word|wd)?`, `secret`, `token`, `auth`, `credential`, `cookie`, + `api[-_]?key`, `bearer`, `jwt`, `signature`, `session[-_]?key`, `ssn`. + Applications can extend the list via `redact.extraKeyPatterns`. +4. **URL query strings and fragments are stripped**, by default, from every + string value, including URLs embedded inside error messages. Query + strings routinely carry access tokens, one-time links, search terms, and + record identifiers. This is configurable off via + `redact.stripUrlQuery: false`. +5. **Value-content PII shapes can be scanned for, opt-in.** The key + denylist above only catches PII living under a conventionally-named + key; it cannot see a value that happens to be an email or a card number + sitting under an innocuous key like `notes`. `redact.scanValuePatterns` + (default `[]`, off) enables one or more of `'email'`, `'creditCard'`, + `'ssn'`, `'ipv4'`, `'phone'` — each is a heuristic regex, not a + certified detector, run independently of rule 4 (a value is scanned + as-is if `stripUrlQuery` is off). See "Value-content scanning" below for + why these five and not a broader set. +6. **Errors are normalized** to `{ name, message, stack? }`. A raw + `HttpErrorResponse` carries its entire response body; only these three + bounded fields survive. +7. **Everything is size-capped** — strings 1024 chars, stacks 2048, 50 keys + per payload. Truncation, never a throw. +8. **Scenario and BI names are treated as metric dimensions.** They are + documented as low-cardinality; never interpolate a record id into an + event name. +9. **A consumer can layer its own scrubbing on top, opt-in.** + `redact.extraValueTransforms` (default `[]`) runs application-supplied + `(value: string) => string` functions on every string value, after + everything above — an escape hatch for redaction logic no regex can + express (rules 3-5 are all pattern-matching; this is arbitrary code). + Runs independently of `scanValuePatterns`/`extraValuePatterns`: a + consumer wanting only its own logic doesn't have to enable the built-in + patterns to get it. A throwing function is skipped (logged in dev mode) + rather than blocking the rest of the pipeline — see §11's fail-open + posture. + +### Never collected + +Passwords, authentication or access tokens, cookies, and raw +request/response bodies. The type system already rules out request/response +bodies (rule 1: metadata is flat and primitive-only, so an object never +reaches telemetry to begin with), and the key denylist (rule 3) catches +conventionally-named password/token/cookie/credential fields regardless of +their value shape, with no configuration required. + +Everything else — email addresses, usernames, account numbers, or any other +personal data sitting under an unconventional key — is **not** covered by +default. `scanValuePatterns` (rule 5) closes the gap for exactly five value +shapes (email, credit card, SSN, IPv4, phone), is off by default, and has no +shape at all for usernames or account numbers — see "Assumptions" below for +what stays the application's own responsibility. + +### Assumptions + +**Sign-out starts a new session.** `pinUserId` has no inverse, so clearing +the signal alone would leave the client attributing every subsequent event +to the person who just left — on a shared device, to the wrong person +entirely. The SDK's `startSession` is documented for exactly this case +("sign-in, sign-out, kiosk handoff"), so `clearUserId()` starts a fresh +session with a fresh anonymous id. The cost is one `session_start` event and +a re-rolled sampling decision. + +- Applications pass **opaque** user identifiers to `setUserId`. If that + identifier happens to be email-shaped and `scanValuePatterns: ['email']` + is enabled, it is now caught like any other value — but the library still + cannot tell that a non-email-shaped identifier (an internal user id, for example) + is personal; the API documents this requirement regardless. +- Applications choose their own metadata keys responsibly. The denylist + catches conventional names, not a key called `x1` — value scanning + narrows this gap for the five shapes it covers, but does not close it for + PII with no recognizable shape (a bare name, for example). +- Stack traces are assumed not to contain personal data. They can be + disabled entirely with `redact.includeStack: false`. +- RUM's automatic metadata (country code, browser, device) is treated as + non-personal. + +### URLs are the weak point + +A path can carry personal data — `/customers/john.smith@example.com` — and +`cpsScrubString` does not catch it. That function strips query strings and +fragments (`PATH_WITH_QUERY` only fires on `?` or `#`); a path _segment_ +passes straight through. A field claiming to be scrubbed while still +carrying a route parameter is worse than one that makes no such claim at +all. + +Value-content scanning (`redact.scanValuePatterns`, below) closes this exact +case when the shape is recognizable: `cpsScrubString` runs value-pattern +matching over the string _after_ the URL-query scrub, not just before it, +so `scanValuePatterns: ['email']` does catch `john.smith@example.com` +sitting in that path segment. It is a narrowing, not a fix for the general +problem — an opaque numeric or alphanumeric customer id in the same +position has no recognizable shape and still passes through untouched. The +remedy below ("What the library cannot fix") is still the complete answer +for that case. + +Three things follow from this. + +**Page views are the RUM client's own, and the library adds nothing.** The +client's `PageViewPlugin` is installed unless `disableAutoPageView` is set, +and it patches `History.prototype.pushState` / `replaceState` and listens +to `popstate`, recording `location.pathname` on each. Angular navigates +through `pushState`, so every route change is already a page view with no +wiring at all needed. + +There is deliberately no `provideCpsRouterPageViews()` calling +`recordPageView` with the route template on `NavigationEnd`. The client's +own plugin stays enabled either way, so a second recorder on top of it +would produce **two** page views per navigation, double the `interaction` +counter, and leave the first page with a `timeOnParentPage` of roughly zero +— while the resolved path is still what the client's own plugin records +regardless, so a route-template recorder would not even achieve the privacy +goal it might seem to serve. + +What remains true is the reason someone might want templates. +`EventCache.createEvent` merges the current page attributes into **every** +event it records — + +```js +const eventMetadata = { ...pageAttrs, ...hookOutput, ...sanitizedManual }; +``` + +— so the page id reaches the envelope of every scenario, BI event, JS +error, and web vital that follows it, and with `pageIdFormat: 'PATH'` that +page id is the resolved path. An application whose routes carry +identifiers should therefore derive the template itself, call +`CpsRumTelemetrySink.recordPageView` with it, **and** set +`disableAutoPageView: true` — the library ships no route-template helper of +its own, so both halves of that pairing are the application's to provide. + +Anyone deriving a route template should know the trap to avoid: a route +declared with `matcher` has no `path` at all, so walking +`routeConfig.path` reports every such route as `/` — for composition, where +33 of 35 routes use a matcher, that is the entire site collapsing into one +page. The template has to come from the segments the match actually +consumed, with anything bound as a parameter put back as `:name` so the +value cannot escape. + +**BI events carry no `route`.** With `pageIdFormat: 'PATH'` (the default), +the client's `pageId` _is_ `location.pathname`, so the field would just be +the same value sent twice, and a second way for a parameter to escape. + +**Scenario `route` is documented as a template.** It survives because it is +captured at `start()`, and is therefore genuinely different information — +where the journey began, versus where the page id says it ended. The +library cannot enforce the template form; the field's documentation states +it, and the application supplies it. + +**What the library cannot fix.** The RUM client captures `pageUrl` itself +from `location.href`, at a layer below anything here. If paths carry +personal data, that URL still gets shipped. The remedies are AWS-side or +application-side: `pagesToExclude` / `pagesToInclude` to stop recording +those pages, or a URL design that keeps identifiers out of paths. Stated +plainly here rather than listed as an accepted assumption. + +### Value-content scanning + +There is no single canonical "PII redaction standard" this triangulates +against — the reasoning below draws on a few: + +- **OWASP Logging Cheat Sheet** lists the same categories the key denylist + already targets (credentials, tokens, session data, regulated personal + data) and recommends masking, which is the approach already taken here; + it does not mandate specific regexes, so it shapes the _what_, not the + _how_. +- **PCI DSS Requirement 3.4** requires that a Primary Account Number + (credit or debit card number) never appear in cleartext logs — the + standards-backed reason `'creditCard'` is in the built-in set at all, not + just "email would be nice." +- **NIST SP 800-122** ("Guide to Protecting the Confidentiality of PII") + gives a broad PII definition that names SSN and financial account + numbers as high-priority examples, supporting `'ssn'`'s inclusion + alongside `'creditCard'`. +- **AWS Comprehend's PII entity types and Microsoft Presidio's default + recognizers** are not regulations, but they are the closest thing to an + industry-common list of which value shapes a lightweight scanner + typically covers (`EMAIL_ADDRESS`, `CREDIT_CARD`, `US_SSN`, + `IP_ADDRESS`, `PHONE_NUMBER`, …). Borrowed here for naming and scope, + deliberately **not** for approach — both are full ML/NER services, the + wrong weight class for a synchronous, browser-side pass that has to stay + at "microseconds" (see §11, Performance). `scanValuePatterns` is + regex-only, on purpose. + +**Off by default, opt in per shape** — matching this config's own existing +convention (`CPS_DEFAULT_REDACT_CONFIG`'s own doc comment: "Conservative by +design — widen them deliberately"). No consumer sees a behavior change +unless it opts in, and the check is skipped entirely (no array iteration) +for the zero-config default, so the "microseconds, shallow pass" claim +still holds for anyone who has not turned this on. + +`'creditCard'` is handled differently from the other four: a plain regex +cannot express a Luhn checksum, so candidate 13-19 digit runs are validated +against one before being redacted. This is a real precision/recall trade, +made deliberately: without it, any order number or internal id of the +right length would get redacted too, which is worse for data utility than +the (cheap, one-pass, no allocation) checksum is for performance. + +`'phone'` is named explicitly as the highest false-positive-risk pattern in +its own doc comment — any sufficiently number-like string collides with +it. It still ships, opt-in, rather than being left out entirely, because +the alternative — silently excluding it — would just move the decision +from the application (who knows whether phone numbers are a real risk in +its own metadata) to this library (who does not). + +### Direct CloudWatch reads from the browser + +**Technically possible; recommended against, and not implemented.** + +A browser could call CloudWatch Logs with `@aws-sdk/client-cloudwatch-logs` +using Cognito or STS credentials. Doing so would require granting +`logs:StartQuery` / `logs:FilterLogEvents` to credentials held in the +browser, which means: + +- any user could read **every** tenant's logs in that log group — log + groups have no row-level authorization; +- there is no server-side place to enforce who may see what; +- the credentials are extractable from the browser and reusable outside the + app; +- log-query costs become user-controllable. + +Log retrieval belongs behind a backend API that authorizes the request and +returns only that user's own data. The frontend's credentials are scoped to +_writing_ RUM events, nothing else. + +### Downloading logs to a file from the browser + +The brief asks whether this is possible. It is, but **not through AWS**, +and the distinction matters because the two routes solve different +problems. + +_Through AWS_ — technically available via `@aws-sdk/client-cloudwatch-logs`, +and rejected for every reason above. It also answers the wrong question: by +the time a record is in CloudWatch it is minutes old and mixed in with +everyone else's, which is rarely what someone asking for "the logs" +actually wants. + +_From the page_ — `CpsLoggerService.query()` returns records from the +application's own backend, which the application can serialize to a file +however it likes. No AWS, no credentials: if the backend is local the +records never leave the tab, and if it is remote the application has already +authorized the request. This is the useful answer for a developer +reproducing something, or for a support flow that asks a user to attach +their session's logs to a ticket. + +For logs that have already been shipped, retrieval stays a backend concern +— the same authorized API described above. + +--- + +## 10. Configuration + +```ts +provideCpsTelemetry( + // Identity: mandatory, shared verbatim by every concern below — an + // application's environment cannot honestly be 'prod' for its logs and + // 'staging' for its scenarios, so this is one shared fact, not one per + // concern. + { + application: 'composition', // required + environment: 'production', // required + version: '22.0.0', // required + + // Prefix for the emitted event types. Set it to keep an existing + // CloudWatch contract when migrating an application onto this library. + eventNamespace: 'com.cps' + }, + + // Every concern below is an optional, independently omittable feature — + // an application configuring only logging never has to think about + // scenarios, BI events, or redaction. + withScenarios({ + defaultTimeoutMs: 30_000, + emitLifecycleEvents: false, // see the session event budget in §6 + maxSteps: 50, + userTimings: false, // also switched on by the debugScenario flag + markCleanupFallbackMs: 300_000, // see "Marks are normally cleared..." below + redact: true // per-concern opt-out — see "Turning redaction off..." below + }), + withLogging({ + minLevel: 'log', + mirrorErrorsToRum: false, + redact: true + }), + withBiEvents({ + dedupWindowMs: 400, + dedupMaxKeys: 100, + redact: true + }), + withRedaction({ + extraKeyPatterns: [], + maxStringLength: 1024, + maxKeys: 50, + maxStackLength: 2048, + includeStack: true, + stripUrlQuery: true, + scanValuePatterns: [], // opt-in value-content PII scanning — see §9 + extraValuePatterns: [], + extraValueTransforms: [] // opt-in custom scrubbing functions — see §9 + }) +); +``` + +**Why one `provideCpsTelemetry()` call, composed with `with*()` features,** +instead of either a single flat config object or a fully separate +`provide*` function per concern. This mirrors Angular's own +`provideHttpClient(withInterceptors(...), withJsonpSupport())`, a pattern +not previously used anywhere in this codebase before this design: + +- Identity is mandatory for any use of the library at all — every log + record, scenario record, and BI event carries it. That requirement + does not go away under any provider shape, so something has to supply it + once. A fully separate function per concern + (`provideCpsTelemetryLogging({ application, environment, version, +minLevel, ... })`, `provideCpsTelemetryScenarios({ application, +environment, version, maxSteps, ... })`) would mean either restating + identity at every call site — the same fact, in multiple places, free to + silently drift apart — or each function quietly depending on a separate + identity provider underneath, which is real DI-ordering ceremony for a + capability composable features already give for free. +- Each concern still gets its own DI token (`CPS_LOG_CONFIG`, + `CPS_SCENARIO_CONFIG`, `CPS_BI_CONFIG`, `CPS_REDACT_CONFIG`), so a + consumer overriding one directly through DI substitution — a test, a + runtime-computed value — can target that token alone, without + reconstructing the whole identity or touching unrelated concerns. A + single flat `CpsTelemetryConfig` object bundling every concern under one + token was the library's original shape; splitting the tokens is what + makes "configure logging independently of scenarios" literally true at + the DI layer, not just true of the input object's optional sub-fields. +- `provideCpsTelemetry()` provides a library default for every one of + these tokens unconditionally, so each `with*()` call — spread into the + same providers array after the defaults — simply replaces its own + token's provider, using the same override-by-last-registration mechanic + Angular's own DI already uses everywhere else. No service ever needs an + optional-injection fallback for a narrow token: whether or not an + application calls `withLogging(...)`, `CPS_LOG_CONFIG` is always bound. + +Telemetry infrastructure is kept separate from application configuration: +the config above contains no AWS account details. Those arrive through +`CPS_RUM_CREDENTIALS_PROVIDER`, implemented by the application. +`CpsRumAppMonitorConfig` (passed as `config` in the `CpsRumBootstrap` a +`CpsRumCredentialsProvider` returns) exposes nearly all of `aws-rum-web`'s +own configuration surface, grouped and documented field-by-field in its own +JSDoc — see the README for a worked example of the advanced fields. + +Optional providers, each isolating a dependency: + +- `provideCpsTelemetryRumSink()`, from the `cps-telemetry/rum` entry point — + the only thing that pulls in `aws-rum-web` + +Nothing pulls in `@angular/router` at all, so it is not a peer dependency. + +`provideCpsTelemetry` alone is deliberately **not** functional on its own: +it registers configuration and no destination, so injecting a telemetry +service without `provideCpsTelemetrySink(...)` and a `CPS_LOG_API_PROVIDER` +fails on first use. + +Defaulting them to `CpsNoopTelemetrySink` and an in-memory transport would +let an application forget to wire a destination and still run perfectly +while shipping nothing — invisible until somebody asks why the dashboard is +empty. It would also make the `'noop'` mode pointless, since the default +would already do silently what that mode exists to state out loud. A +missing provider is a configuration error caught on the first run, not a +runtime failure; the guarantee that telemetry never breaks the application +is about a sink or transport _throwing_, which remains fully guarded +regardless. + +`CpsLoggerService` is the one deliberate exception to the sink half of this +rule — `inject(CpsTelemetrySink, { optional: true })` rather than a hard +dependency. The reasoning above still holds for +`CpsScenarioTelemetryService`/`CpsBiTelemetryService`, whose entire purpose +is reaching a sink: for those, a missing provider silently doing nothing at +runtime is exactly the failure mode this design exists to prevent. Logging +is different in kind, not just degree: its actual destination is +`CPS_LOG_API_PROVIDER`, which keeps the identical hard-fail guarantee +untouched — a missing log API provider still fails `CpsLoggerService`'s +injection outright. The sink there is only an enrichment source +(`sessionId`/`userId` correlation with RUM, optional `mirrorErrorsToRum` +mirroring), not where logs actually go, so an application that only wants +structured logging is not forced to configure a sink — not even `'noop'` — +purely to satisfy a dependency it never uses for its actual output. + +Entry names are `:::`. The +prefix is the consuming application's own name rather than this library's: +a Timings track already carries the framework's entries and the +application's own, and knowing which _library_ emitted an entry is never +the question a developer is actually asking. It also keeps two realms of a +composed page apart, which one library-wide prefix could not do. + +`userTimings` is worth calling out on its own: it is off by default because +nothing consumes `performance` entries in production, but the +`debugScenario` LocalStorage flag turns it on regardless of configuration. +That is deliberate — a developer investigating a deployed build cannot change +config, which is the entire reason the debug flags exist at all. + +Marks are normally cleared at settle time. However, a scenario with its timeout +disabled (`timeoutMs: 0`) that never settles and never sees `pagehide` (no +navigation, no tab close) has no settle event to clear them at. `CpsScenario` +guards this specific gap with its own independent fallback: when +`scheduleTimeout` finds no real timeout to rely on, it schedules a separate +timer — `scenario.markCleanupFallbackMs`, default 5 minutes — whose only job +is clearing the marks; it never settles the scenario or touches its status. +A scenario with a real timeout, however long, never gets this fallback +scheduled at all: its own timeout already guarantees a settle, and +therefore a cleanup, on its own schedule, and racing a second timer against +it would either be redundant or (if the fallback were shorter) wipe out the +marks of a scenario that is still legitimately running. + +The default is deliberately generous rather than aggressive, for the same +reason: `timeoutMs: 0` is an explicit opt-out of any time-based cutoff, so +the population this fallback applies to skews toward long-running +_legitimate_ work (uploads, long polls) — exactly what a short default +would routinely misfire on. All this has to bound is a genuine leak (a +scenario abandoned by a bug and left for the life of the page), which 5 +minutes catches just as surely as 60 seconds would, without the false +positives. + +Deliberately not settling the scenario itself: `timeoutMs: 0` is the caller +explicitly asking for no auto-settlement, and silently overriding that on a +timer would be a worse surprise than the leak it fixes. One consequence +worth naming — a scenario that takes this path and is never manually +settled also never fires `onSettled`, so it stays in +`CpsScenarioTelemetryService`'s active registry, and in memory, for the +life of the page, not just for as long as its marks do. This only matters +for a scenario abandoned by a bug and left to accumulate; one a caller does +intend to settle itself is unaffected. + +Deliberately not derived from `defaultTimeoutMs`: the two cannot be tied +together, since the common way to reach this fallback at all is +`defaultTimeoutMs: 0` itself — there'd be nothing to derive from. `0` +disables the fallback outright, matching `defaultTimeoutMs`'s own +convention, for an application that wants the pre-fallback behavior back. + +### Turning redaction off per concern + +`redact: boolean` (default `true`) lives on each of `CpsLogConfig`, +`CpsScenarioConfig` and `CpsBiConfig` — not on `CpsRedactConfig` itself — +so `withLogging`/`withScenarios`/`withBiEvents` can each opt a concern out +independently, matching every other field in this section being configured +per concern through its own `with*()` call rather than centrally. + +`CPS_REDACT_CONFIG` stays the single shared token either way (§9's +reasoning for keeping identity and redaction shared, not duplicated, is +unaffected): each service resolves its own effective config once, via +`cpsRedactConfigFor(inject(CPS_REDACT_CONFIG), thisConcernsOwnRedactFlag)`, +rather than the token itself varying per concern. `cpsRedactConfigFor` +returns the injected config unchanged when the flag is `true`, and a +derived variant with `extraKeyPatterns`, `scanValuePatterns`, +`extraValuePatterns` and `stripUrlQuery` all cleared when it's `false`. + +Turning a concern's redaction "off" is deliberately narrow: it only skips +the scrubbing that is actually configurable — `extraKeyPatterns`, +value-pattern scanning (rule 5), and URL-query stripping (rule 4). Four +things stay on no matter what, and this flag cannot touch them at all: the +built-in credential denylist (rule 3), size caps (rule 7), error +normalization (rule 6), and any `extraValueTransforms` (rule 9). The +denylist check in particular is hardcoded inside `isDenied()` — it never +reads from `CpsRedactConfig`, so there is no setting for a per-concern flag +to even switch off. + +This is intentional, not something that was missed. `CPS_REDACT_CONFIG`'s +own doc comment already treats size caps and error normalization as +guarantees, not options — the same reasoning applies to the credential +denylist. The `redact` flag exists so a consumer can quiet down false +positives (e.g. an aggressive URL-stripping rule mangling their own data), +not to remove the one check standing between a stray `password` field in +someone's metadata and it showing up in CloudWatch. If `redact: false` +could disable that too, one careless call would quietly open a real +security hole — so it cannot. + +--- + +## 11. Error handling and performance + +**Telemetry cannot break the application.** Every public entry point is +wrapped in `cpsSafe`/`cpsSafeVoid`, which never rethrows. It does not, +however, _silently_ swallow errors: in development (`isDevMode()`) the suppressed +error is reported to `console.error`, so bugs in this library still surface +during development and in tests. In production it stays silent. The +console report is itself wrapped in a try/catch — an application that has +patched or otherwise broken `console.error` cannot turn a suppressed +telemetry failure into a rethrow, in `cpsSafe`, or into a fresh unhandled +rejection, in `cpsSafeVoidMaybeAsync`'s async path. Tests +assert both halves, and assert that a sink or transport throwing on every +call leaves application code unaffected. + +**Performance.** Deliberately not over-engineered: + +- One RUM event per scenario, not per step (the packed model). +- BI events deduplicated within a 400ms window, absorbing double-fires from + a handler bound to both `click` and `keydown`. Two events count as the + same only if their name, scenario correlation, event-type override, + feature, and metadata content all match — differing in any one of those + is a distinct event, not a duplicate. +- Redaction is a single shallow pass over a flat object. +- Durations use `performance.now()`, which is immune to wall-clock + adjustments — a sleeping device would otherwise produce negative or + wildly inflated durations. +- Batching, retry and network dispatch of already-recorded events are left + to the SDK, which already does them well. +- **Credential refresh** is this sink's own responsibility, and never fires + immediately: a broker returning already-expired or near-expiry + credentials falls back to the same bounded retry delay used for a failed + refresh, so a broker stuck returning bad credentials can't tight-loop + the sink. A refresh returning `null` — the documented session-disable + signal on `CpsRumCredentialsProvider.load()` — tears the client down and + stops scheduling further refreshes, instead of retrying forever against + an already-disabled sink still holding a stale, capped event buffer. The + very first load honors the identical contract: `performInit()` sets the + same `disabled` flag when the broker declines before the client is ever + constructed, not only on a later refresh — otherwise every `record()` + call would buffer into the capped pre-init queue forever instead of + becoming the clean no-op a deliberately-disabled session should be. +- A bounded 100-item pre-init buffer preserves bootstrap telemetry — + events, page views, and errors alike, replayed through the same code + path once the client is ready — without growing without limit if init + never completes. + +--- + +## 12. Usage + +### Bootstrap + +```ts +import { + provideCpsTelemetryRumSink, + CPS_RUM_CREDENTIALS_PROVIDER +} from 'cps-telemetry/rum'; + +providers: [ + provideCpsTelemetry({ + application: 'composition', + environment: 'production', + version: packageJson.version + }), + provideCpsTelemetryRumSink(), + { + provide: CPS_RUM_CREDENTIALS_PROVIDER, + useExisting: AppRumCredentialsProvider + } +]; +``` + +### Scenarios + +```ts +const scenario = this.scenarioTelemetry.start({ + name: 'load-customer-data', + feature: 'customers' +}); + +try { + scenario.step('fetch-data'); + const rows = await this.api.fetchCustomers(); + + scenario.step('render'); + this.rows.set(rows); + + scenario.complete({ metadata: { rowCount: rows.length } }); +} catch (error) { + scenario.fail({ error }); +} +``` + +### BI events + +```ts +biTelemetry.track('export_clicked', { + exportType: 'csv', + source: 'customer-table' +}); + +// correlated to a journey +biTelemetry.track( + 'export_clicked', + { exportType: 'csv' }, + { + scenarioId: scenario.id + } +); +``` + +### Logging + +```ts +logger.error('Failed to load customer data', { + correlationId: scenario.id +}); +``` + +### Debugging + +```js +localStorage.setItem('debugLogger', 'true'); +localStorage.setItem('debugScenario', 'true'); +localStorage.setItem('debugBI', '1'); +``` + +All three are off by default, accept only `'true'` and `'1'`, work in every +environment, and are read on each emit — so a DevTools toggle takes effect +without a reload. No production configuration switch is involved. + +--- + +## 13. Multiple realms — micro-frontends and web fragments + +A composed page may run the shell and each fragment in its own JavaScript +context. [Web Fragments](https://web-fragments.dev), for example, "utilizes +a hidden iframe to create a clean JavaScript context that is used to load +and evaluate all of the scripts of the application" — the iframe itself is +never rendered; its DOM output is reprojected into a Shadow Root in the +host document, so what the user sees is ordinary Shadow DOM content in the +host page, not a visibly embedded frame. The iframe's `window.location` is +kept in sync with the host's, and `BroadcastChannel` is the sanctioned +channel between realms. + +A separate realm means a separate Angular injector, which means **every +realm builds its own copy of every telemetry service** — including its own +AWS RUM client. + +### Why that is a problem + +| Left alone | Consequence | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------- | +| N RUM clients | Each mints its own session. One human becomes N sessions and N users; unique-user counts are wrong | +| N × `sessionEventLimit` | Separate event budgets, separate dispatches, N calls to the credentials broker, N copies of the SDK downloaded | +| Shared origin, shared cookies | N clients contend for one RUM session cookie | + +### The arrangement + +**One realm hosts; the rest forward.** + +```mermaid +flowchart LR + subgraph shell [Shell realm] + S[provideCpsTelemetryRumSink] --> R[AwsRum] + H[provideCpsTelemetryBroadcastHost] + H --> S + end + subgraph f1 [Fragment realm] + A[CpsBroadcastTelemetrySink] + end + subgraph f2 [Fragment realm] + B[CpsBroadcastTelemetrySink] + end + A -- BroadcastChannel --> H + B -- BroadcastChannel --> H + R --> AWS[AWS RUM] +``` + +```ts +// shell +providers: [ + provideCpsTelemetry({ application: 'shell', environment: 'prod', version }), + provideCpsTelemetryRumSink(), + provideCpsTelemetryBroadcastHost() +]; + +// fragment — no AWS client, no SDK bundle, no broker call +providers: [ + provideCpsTelemetry({ application: 'cart', environment: 'prod', version }), + provideCpsTelemetrySink('broadcast') +]; +``` + +One session, one budget, one bundle. Application code inside a fragment +does not change — it injects the same services and calls the same methods; +only the sink binding differs. This is exactly what `CpsTelemetrySink` was +built as an abstraction _for_. + +Two settings have to agree across realms: **`eventNamespace`**, or the +event types diverge and CloudWatch queries fragment along with the UI, and +the **channel name**, which both `provideCpsTelemetrySink('broadcast', …)` +and `provideCpsTelemetryBroadcastHost` take as an optional argument. +`application` should differ per realm — that is what identifies which +fragment emitted what. + +The host answers an identity handshake so followers report the shell's +session id and user id on their log records, and it announces itself +unsolicited at startup so a fragment that booted first is not left waiting. +Both travel together in every `identity` message — a fragment that only +learned the session id would carry a stale user id (or the reverse) until +something else happened to trigger a full re-announce. `getSessionId()`/ +`getUserId()` in a follower return `undefined` for the one task before the +answer arrives. + +### Fields that behave correctly across realms + +- **Durations.** `startedAt` is epoch milliseconds and `cpsEpochToPerf` + converts using the _local_ `timeOrigin`, so a timestamp taken in the + shell reads correctly in a fragment despite the iframe having a later + origin. The one limit: a moment from _before_ the fragment's realm + existed gets clamped away and falls back to now. +- **Page identity.** The iframe's `location` is synced to the host's, so + the X-Ray same-origin regex and the route template both resolve against + the real URL. +- **Debug flags.** Same origin means one `localStorage`: setting + `debugScenario` once turns it on in every realm, and all of them log to + the same console. +- **Correlation.** A `CpsScenario` is a class instance and cannot cross a + realm, but `scenarioId` is just a string. Broadcast it and open a child + scenario with `parentScenarioId`. +- **User Timings.** Same origin means marks and measures are written to + `top.performance` rather than the fragment's own, so every realm's + entries land on the one Timings track DevTools actually has open, not + hidden inside each iframe. `cpsMarkName` prefixes every entry with the + realm's own `application`, so several fragments landing in that one + track stay distinguishable rather than colliding. + +### What a follower realm must not provide + +Three of these are really the same mistake — recreating in a fragment +something that belongs to the shell — and the fourth is the one people get +wrong by symmetry: + +| Not in a fragment | Why | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------- | +| `provideCpsTelemetryRumSink()` | A second AWS client, which is exactly the situation this whole arrangement exists to prevent | +| `CPS_RUM_CREDENTIALS_PROVIDER` | Without a RUM sink there is nothing to authenticate | +| `provideCpsTelemetryBroadcastHost()` | Two hosts elect one leader and idle the other (§13) — still wasted setup, not a reason to rely on it | + +### Fragments that also deploy standalone + +A fragment shipped both ways needs a different sink in each: forwarding +when embedded, its own client when it is the whole page. +`provideCpsTelemetrySink('broadcast')` covers forwarding; +`provideCpsTelemetryRumSink()`, from the separate `cps-telemetry/rum` entry +point, covers a standalone deployment's own client. Which one applies is +read from the deployment's own configuration. + +Runtime detection was considered and rejected. A realm cannot tell +synchronously whether a shell is listening; the identity handshake takes a +task in each direction. Detecting would mean buffering every event during a +probe window and then guessing when the window expires — and losing that +race against a slow-booting shell produces exactly the two sessions this +arrangement exists to prevent. Whether a fragment is embedded is not +something to be discovered at runtime — it is a deployment fact, and +configuration states facts exactly. + +The trade is one line of conditional configuration in the fragment. +Application code is unchanged either way. + +### Behaviour without a host + +A follower whose messages reach nobody — no shell yet, a browser with no +`BroadcastChannel`, a server-side render — degrades to a no-op sink. +Scenarios run and settle, logs are written to the transport, nothing +throws; the telemetry is simply not shipped, and starts being shipped the +moment a host appears. + +That is deliberate: a fragment has to be developable and testable on its +own, and failing loudly just because the composition it will eventually +live in is absent would be the wrong trade. It is the same fail-open posture +the RUM sink takes when the credentials broker is unreachable. + +### Multiple hosts on one channel + +`BroadcastChannel` is origin-wide, not page-local — a shell opened in two +tabs starts two independent `CpsTelemetryBroadcastHost` instances on the +same channel, each with its own injector and its own AWS RUM client. +Recording through both would double every forwarded event, so +`CpsTelemetryBroadcastHost` runs a Web Locks-based leader election +(`cpsElectBroadcastHostLeader`) in its constructor: the same lock name, +requested by every host on a channel, is granted to exactly one caller at +a time. Only the elected leader records anything or announces identity; +every other host stays fully passive until the leader is destroyed (its +tab closes) and releases the lock, at which point the next queued host +takes over. + +Feature-detected and fail-open both ways: a browser without the Web Locks +API elects immediately (matching this arrangement's pre-election, single- +host behaviour), and a lock _request_ that fails — document not fully +active, a Permissions-Policy blocking Web Locks — also elects immediately +rather than leaving a host silently non-leader, and therefore permanently +inert, for the rest of the session. That covers a rejected promise and a +synchronous throw from `request()` itself alike (a `try`/`catch` around +the call, not just a `.catch()` on its result) — this runs unguarded from +`CpsTelemetryBroadcastHost`'s constructor, itself constructed eagerly +inside an `APP_INITIALIZER`, so an uncaught synchronous throw here would +crash application bootstrap rather than just fail to elect a leader. + +A host destroyed while its own request is still queued — never granted — +is also handled correctly: `cpsElectBroadcastHostLeader` tracks that a +release was requested even though there was nothing to release yet, so +when the lock is eventually granted to that (now-destroyed) request, it +resolves immediately without electing instead of holding the lock open. +Without this, the lock would never be released again — the returned +`release` closure only ever fires once, before the grant reassigns it — +permanently starving every host still queued behind it. + +### Known limits + +- **Forwarding at unload is best-effort.** `BroadcastChannel` delivers on a + later task, and a fragment torn down before that task runs may never + have its message received by the host — nothing here specifically + targets that. The general `visibilitychange → hidden` flush (§5) helps + incidentally, since it dispatches whatever the sink already holds, + forwarded messages included, but it is a page-lifecycle safety net for + every app, not a fragment-specific fix, and it cannot rescue a message + still in flight on the channel when the fragment's frame is torn down. diff --git a/projects/cps-telemetry/README.md b/projects/cps-telemetry/README.md new file mode 100644 index 000000000..4321ae820 --- /dev/null +++ b/projects/cps-telemetry/README.md @@ -0,0 +1,1056 @@ +# cps-telemetry + +A small, reusable Angular telemetry library. It covers three things: +**application logs**, **scenario health telemetry**, and **business/UX +events**, with an optional AWS CloudWatch RUM sink. + +Nothing in this library is tied to one specific application. The host app +supplies its own event names, configuration, and AWS credentials. + +See [DESIGN.md](./DESIGN.md) for the architecture, the AWS capability checks, +and the reasoning behind each decision. + +## Install + +```bash +npm install cps-telemetry +# optional, only if you use the AWS RUM sink +npm install aws-rum-web +``` + +## Setup + +```ts +import { + CPS_LOG_API_PROVIDER, + provideCpsTelemetry, + provideCpsTelemetrySink, + withLogging, + withScenarios +} from 'cps-telemetry'; +import { + CPS_RUM_CREDENTIALS_PROVIDER, + provideCpsTelemetryRumSink +} from 'cps-telemetry/rum'; + +providers: [ + provideCpsTelemetry( + // Identity: required, shared by every concern below. + { + application: 'my-app', + environment: 'production', + version: '1.0.0', + + // Optional. Prefixes the emitted event types — `com.cps.scenario`, + // `com.cps.scenario.step`, `com.cps.bi` by default. Set your own + // namespace when migrating an app whose CloudWatch metrics, queries or + // dashboards already key on one. + eventNamespace: 'com.my-app' + }, + + // Every concern below is optional — skip a with*() call to take the + // library default for it, the same way Angular's own + // provideHttpClient(withInterceptors(...)) composes optional behavior. + withLogging({ minLevel: 'warn' }), + withScenarios({ maxSteps: 10 }) + // withBiEvents({ ... }), withRedaction({ ... }) are also available. + ), + + // Required. Both are chosen explicitly. There is no default destination, + // so an app can never look wired up while actually shipping nothing. + // provideCpsTelemetryRumSink() (from 'cps-telemetry/rum') is the RUM sink; + // provideCpsTelemetrySink('broadcast' | 'noop') covers the other two — + // see "Two entry points" below for why RUM is split out. + provideCpsTelemetryRumSink(), + { provide: CPS_LOG_API_PROVIDER, useExisting: MyLogBackend }, + + // Optional. + { + provide: CPS_RUM_CREDENTIALS_PROVIDER, + useExisting: AppRumCredentialsProvider + } +]; +``` + +### Two entry points + +`CpsRumTelemetrySink`, `provideCpsTelemetryRumSink`, and everything +`CpsRumCredentialsProvider`-shaped live in a separate secondary entry point, +`cps-telemetry/rum`, not the main `cps-telemetry` barrel. That's so an app +using only `'broadcast'`/`'noop'` never needs `aws-rum-web` (an optional peer +dependency) resolvable at build time — importing anything from +`cps-telemetry/rum` is what opts an app into that requirement. See +[DESIGN.md §3, "Entry points"](./DESIGN.md#entry-points) for the full +reasoning. + +Each concern is independently optional. An app that only configures logging +never has to think about scenarios, BI events, or redaction. If you want to +override one concern directly through Angular's DI — in a test, or with a +value computed at runtime — each has its own token: `CPS_LOG_CONFIG`, +`CPS_SCENARIO_CONFIG`, `CPS_BI_CONFIG`, `CPS_REDACT_CONFIG`. You target that +token directly, with no need to rebuild the whole identity object. See +[DESIGN.md](./DESIGN.md) for why identity is required and shared while every +other concern gets its own token. + +`provideCpsTelemetry()` only registers configuration. If you inject +`CpsScenarioTelemetryService` or `CpsBiTelemetryService` without configuring a +sink, it fails at bootstrap with `NG0201` — the same way a missing +`provideRouter()` does. A log API provider is likewise required to inject +`CpsLoggerService` at all. For local development, or a deployment that ships +nothing, say so explicitly: + +```ts +providers: [ + provideCpsTelemetrySink('noop'), + { provide: CPS_LOG_API_PROVIDER, useExisting: MyLogBackend } +]; +``` + +`CpsLoggerService` is the one exception to the sink rule above. Its actual +destination is the log API provider, and a sink is only there to enrich it — +`sessionId`/`userId` correlation (see [Who is signed in](#who-is-signed-in)) +and the optional `mirrorErrorsToRum` mirroring (see [Logging](#logging)). If +you only want structured logging — no scenarios, no BI events, no RUM at all +— you can leave out `provideCpsTelemetrySink(...)` entirely. The logger still +works, just without that identity correlation, and `mirrorErrorsToRum` +silently does nothing instead of failing at bootstrap. + +Supply AWS details by implementing `CpsRumCredentialsProvider`: + +```ts +@Injectable({ providedIn: 'root' }) +export class AppRumCredentialsProvider implements CpsRumCredentialsProvider { + async load(): Promise { + // no-store: this response carries live, temporary AWS credentials. + const res = await fetch('/rum/init', { cache: 'no-store' }); + if (!res.ok) return null; + + const { enabled, config, credentials } = await res.json(); + return enabled ? { config, credentials } : null; + } +} +``` + +Returning `null` turns off shipping without disabling the library — including +from a later refresh, not just the initial load, so a provider can revoke +telemetry mid-session and the already-running client is torn down rather +than left collecting with stale credentials. Returning a bootstrap with +`credentials` omitted is a different, valid state: an app monitor configured +for unauthenticated access, not a disable signal — as long as the session was +never authenticated to begin with. The underlying AWS RUM client has no way +to clear credentials once applied, so a _later_ refresh that omits +`credentials` for a session that already had real ones doesn't downgrade it +to unauthenticated; the sink keeps using the existing credentials and retries +the refresh instead, reporting a dev-mode warning rather than going silent. + +### Advanced RUM configuration + +Almost every `aws-rum-web` option is available on `config`, each with a +sensible default if you leave it out — see `CpsRumAppMonitorConfig`'s own +JSDoc for the full, grouped list. A representative sample: + +```ts +async load(): Promise { + const res = await fetch('/rum/init'); + if (!res.ok) return null; + const { enabled, config, credentials } = await res.json(); + if (!enabled) return null; + + return { + config: { + ...config, + sessionEventLimit: 400, // raise the session's 200-event budget + cookieAttributes: { sameSite: 'Lax' }, + pagesToExclude: [/^\/admin/], + disableAutoPageView: true, + headers: { 'x-app-build': config.buildId } + }, + credentials + }; +} +``` + +## Scenarios + +A scenario is one user journey — load customer data, open a report, submit a +search. Any number can run at once, and each is fully independent. + +### Declaring the vocabulary + +Scenario and step names are **metric dimensions**, not free text, so they are +a finite type. Declare your application's names once in a schema file: + +```ts +// src/app/telemetry.schema.ts +declare module 'cps-telemetry' { + interface CpsScenarioNames { + 'load-customer-data': true; + } + + interface CpsScenarioSteps { + 'fetch-data': true; + render: true; + } +} + +export {}; +``` + +Import that file once — anywhere in the compilation — and every name gets +checked from then on: + +```ts +scenario.step('fetch-data'); // ok +scenario.step('fetch-dat'); // error TS2345 +``` + +A typo here would not just produce a wrong number. It would silently start a +**second, incomplete** metric series, and the dashboard meant to catch the +regression would keep reading healthy. Interpolating an id — +``step(`load-${customerId}`)`` — causes the same problem at a larger scale. +The type turns both from a data problem you find a month later into a compile +error you find right away. + +Both registries start empty, and `CpsScenarioName` / `CpsStepName` fall back +to `string` until the first augmentation — so adopting this is optional, and +you can do it one name at a time. `CpsScenarioSteps` also covers +`aggregateStart` / `aggregateEnd`, since they name a phase of work, just like +a step does. + +### Recording a journey + +```ts +const scenario = this.scenarioTelemetry.start({ + name: 'load-customer-data', + feature: 'customers' +}); + +try { + scenario.step('fetch-data'); + const rows = await this.api.fetchCustomers(); + + scenario.step('render'); // closes 'fetch-data' automatically + this.rows.set(rows); + + scenario.complete({ metadata: { rowCount: rows.length } }); +} catch (error) { + scenario.fail({ error }); +} +``` + +`complete`/`fail`/`incomplete`/`cancel` all take the same shape, +`(outcome?: CpsScenarioOutcome)`, so there is exactly one thing to remember no +matter which one you call: + +| Method | Effect | +| --------------------------------------------- | ------------------------------------------------------------------------------- | +| `step(name, metadata?)` | Opens a step, closing the previous one as completed | +| `endStep(detail?)` | Closes the open step early, when the work finishes well before the next step | +| `failStep(error, detail?)` | Closes the open step as failed; the scenario keeps running | +| `setData(metadata)` | Merges attributes into the scenario while it is still running | +| `aggregateStart(name)` / `aggregateEnd(name)` | Sums repeated calls of one operation | +| `complete(outcome?)` | Settles as `success` | +| `fail(outcome?)` | Settles as `failure` — pass the thrown value as `outcome.error` | +| `cancel(outcome?)` | Settles as `abandoned`, caused by the caller (`metadata.abandonedBy: 'caller'`) | +| `incomplete(outcome?)` | Settles as `incomplete` | +| `settle(status, outcome?, error?)` | Settles into a status you already have as data — **for adapters**, see below | +| `toRecord()` | Snapshots the current `CpsScenarioRecord`; safe to call mid-flight | + +`CpsScenarioOutcome` carries `statusCode`, `message`, `reason`, `metadata`, +and `error`. `message` and `reason` are separate fields on purpose: `message` +is a free-text note, `reason` is a short structured value you can group by +(`incomplete({ reason: 'no-results' })`). `statusCode` and `error` are kept +apart the same way. All of them end up on the record. + +### RxJS streams and `traceScenario` + +For Observable-driven journeys (an `HttpClient` request, for example), use the +pipeable `traceScenario` operator. It completes or fails the scenario +automatically based on how the stream ends — and if it's torn down some +other way (a superseding `switchMap`, `takeUntilDestroyed()`, a manual +unsubscribe) before that, it cancels the scenario instead of leaving it to +self-settle as a `timeout`. + +```ts +import { traceScenario } from 'cps-telemetry'; + +this.api + .fetchCustomers() + .pipe( + traceScenario(scenario, (rows) => ({ + metadata: { rowCount: rows.length } + })) + ) + .subscribe(); +``` + +There are five statuses: `success`, `failure`, `abandoned`, `incomplete` and +`timeout` — there is no "in progress" value. `scenario.status` stays +`undefined` until it settles; use `scenario.isSettled` to check whether it is +done. Once settled, a scenario stays settled: calling a settle method again is +a no-op, never a throw. A scenario that never settles on its own auto-settles +as `timeout` once its deadline passes (30s by default); one still running when +the page unloads auto-settles as `abandoned`. That way journeys the user +walked away from, or that never finished, show up in the data instead of just +vanishing. + +### Choosing an outcome + +`failure` means something broke — a defect. `abandoned` means the journey +stopped mattering: the user navigated away, or the page unloaded. +`metadata.abandonedBy` records which one — `'caller'` or `'page-hidden'`. +`timeout` means the deadline passed before the journey settled; it is its own +status rather than a cause folded into `abandoned`, so you can filter or +alert on it directly. `incomplete` is an expected path that did not reach the +goal — no search results, a guard declined, a flag routed the journey +somewhere else. + +Keeping these apart is what makes the failure rate usable for alerting. + +```ts +const rows = await this.api.search(term); +if (!rows.length) { + scenario.incomplete({ reason: 'no-results' }); // not a failure + return; +} +``` + +### Settling from a status you were handed + +In application code, call `complete()` / `fail()` / `cancel()` / +`incomplete()` directly. Naming the outcome at the call site is what lets +someone find every place a journey can fail just by searching for `.fail(`. + +`settle()` exists for adapters — places where the outcome genuinely arrives +as data: + +```ts +const outcomeFor: Record = { + ok: 'success', + error: 'failure', + superseded: 'abandoned' +}; + +jobUpdates.subscribe((update) => + this.scenarios.get(update.id)?.settle(outcomeFor[update.state]) +); +``` + +Examples include a bridge settling a scenario from a message, a replay of +recorded telemetry, or a mapping from an HTTP or job status. If you find yourself +reaching for `settle()` inside a feature, the outcome was probably known all +along — use the named method instead. + +### Measuring what the user waited for + +`complete()` stops the clock the moment the JavaScript finishes. For a +journey that ends in a render, the user is often still looking at the old +screen at that exact moment. + +The library does not settle scenarios on paint for you. Waiting is a decision +for the caller to make — there is no single right answer for how long to wait, +or what to do if nothing ever paints. + +For a rough measure, two animation frames is a one-liner and needs nothing +from this library: + +```ts +scenario.step('render'); +this.rows.set(rows); +requestAnimationFrame(() => requestAnimationFrame(() => scenario.complete())); +``` + +That only proves a frame boundary passed — not that the pixels you actually +care about were drawn. To measure the real paint, mark the element with an +`elementtiming` attribute and observe `element` entries with a +`PerformanceObserver`. Two things to know before you do: pass +`buffered: true`, since the paint usually happens before the observer starts +watching, and check `PerformanceObserver.supportedEntryTypes` first — Element +Timing only works in Chromium, so most sessions will still need one of the +fallbacks above. + +If nothing ever paints — a backgrounded tab, a render that stalled — none of +this settles the scenario, and its own `defaultTimeoutMs` settles it as +`timeout`. That is the honest outcome: the user never saw the result, so the +journey did not reach its goal. + +### Repeated work + +```ts +for (const row of rows) { + scenario.aggregateStart('format-row'); + format(row); + scenario.aggregateEnd('format-row'); +} +// -> aggregates: [{ name: 'format-row', elapsed: 84, callCount: 500 }] +``` + +### Backdating + +A journey usually starts before the code measuring it runs. Record the real +starting point and pass it as `startedAt`: + +```ts +onClick() { this.clickedAt = Date.now(); } + +// later, in the async handler +scenarioTelemetry.start({ name: 'export', startedAt: this.clickedAt }); +``` + +### Reacting to outcomes + +```ts +scenarioTelemetry.settled$ + .pipe(filter((r) => r.status === 'failure')) + .subscribe((r) => this.notifications.warn(`${r.scenarioName} failed`)); +``` + +Each emission is an independent copy, not the record shipped to the sink — +mutating it in a subscriber can never change what was already (or is about +to be) sent. + +### Seeing scenarios in DevTools + +Set `scenario.userTimings: true` — or just turn on the `debugScenario` flag — +and every scenario and step is mirrored to `performance.mark`/`measure`. That +puts the journey on the **Performance → Timings** track, right next to paint, +layout and network. That is where you find out _why_ a step was slow, which +the aggregate numbers in CloudWatch cannot tell you. + +Entries are named `:::`, using +the `application` name you configured as the prefix. Filtering the Timings +track by your app's own name shows just your journeys, and on a composed page +it keeps one fragment's entries apart from another's. + +## BI events + +```ts +biTelemetry.track('export_clicked', { + exportType: 'csv', + source: 'customer-table' +}); + +// optionally correlated to a journey +biTelemetry.track( + 'export_clicked', + { exportType: 'csv' }, + { + scenarioId: scenario.id + } +); +``` + +Identical events within 400ms are collapsed into one — "identical" meaning +the same name, scenario correlation, event type, feature, and metadata +content; differing in any one of those is a distinct event. + +All BI events share a single event type, with `eventName` carried as a field +— one schema to query, one extended-metric definition. If an existing +dashboard is keyed on a specific legacy type, a single event can override it: + +```ts +biTelemetry.track( + 'click', + { source: 'toolbar' }, + { + eventType: 'com.data-gateway.click' + } +); +``` + +Use that only for migration — not to give every event its own type. + +## Logging + +```ts +logger.log('Cache warmed'); +logger.warn('Falling back to defaults', { context: 'ConfigService' }); +logger.error('Failed to load customer data', { + error, + correlationId: scenario.id +}); +``` + +Alternatively, hand the scenario a logger and let it bind the id for you: + +```ts +const scenario = telemetry.start({ + name: 'load-customer-data', + logger: this.logger +}); + +scenario.logger?.error('Failed to load customer data'); +// -> correlationId === scenario.id, and the logger keeps its own name +``` + +`logger` is optional and supplied rather than injected. Scenarios and logging +stay independent this way: an application can use either one without +configuring the other, and a scenario never logs anything on its own. + +`withLogging({ mirrorErrorsToRum: true })` also reports every +`logger.error(...)` call to the RUM sink as an error. This is off by +default, since the RUM client's error plugin already captures _unhandled_ +errors, and this would add _handled_ ones on top, competing for the same +session event budget. It mirrors `error` when the call supplies one; a +message-only call (`logger.error('Something went wrong')`, no `error`) has +nothing to normalize, so a synthetic `Error` gets built from the message text +instead. + +Logs go to a backend **you** provide, not to AWS RUM — see DESIGN.md §6. +Where records are kept, how they are authorized, and how long they live are +your decisions, so the library asks for a backend instead of assuming one: + +```ts +@Injectable({ providedIn: 'root' }) +export class MyLogBackend implements CpsLogApiProvider { + send(record: CpsLogRecord): void { + // Fire and forget: logging must never break the application. + void fetch('/api/logs', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(record), + keepalive: true // survives page unload + }).catch(() => undefined); + } + + query(filter: CpsLogQuery): Promise { + const params = new URLSearchParams(filter as Record); + return fetch(`/api/logs?${params}`).then((r) => r.json()); + } +} +``` + +```ts +providers: [{ provide: CPS_LOG_API_PROVIDER, useExisting: MyLogBackend }]; +``` + +One binding, and that is the whole of it. `provideCpsTelemetry` wires up +everything that calls it — that part is not something you choose. + +There is no default provider, though. A default destination would let an +application ship nothing while still looking fully wired up, and you'd only +notice once you went looking for a line that should have been there. So +forgetting this binding fails on first use instead of failing silently. + +**Delivery is entirely your provider's policy.** The library does no +batching of its own — `send` is called once per record, right when it is +written. Retries, batching, backoff and authentication all live in your +implementation, where you know what your endpoint expects. + +If your endpoint is cheap to call once per line, the example above is the +whole story. If a busy session would mean too many requests, batch inside +your own provider — collect into your own queue and flush it on your own +terms: + +```ts +@Injectable({ providedIn: 'root' }) +export class MyLogBackend implements CpsLogApiProvider { + private buffer: CpsLogRecord[] = []; + + send(record: CpsLogRecord): void { + this.buffer.push(record); + if (this.buffer.length >= 25) { + this.deliver(); + } + } + + /** Called on `pagehide`, on visibilitychange going hidden, and on teardown. Must be synchronous. */ + flush(): void { + this.deliver(); + } + + private deliver(): void { + if (!this.buffer.length) { + return; + } + const records = this.buffer; + this.buffer = []; + navigator.sendBeacon('/api/logs', JSON.stringify({ records })); + } + + query(filter: CpsLogQuery): Promise { + /* … */ + } +} +``` + +The one thing you cannot reliably catch from outside a provider is the moment +the tab closes. Implement the optional `flush()` above, and the library +calls it on `pagehide`, on `visibilitychange` going hidden (mobile browsers +routinely kill a backgrounded tab without ever firing `pagehide`), and on +teardown — so a queue like the one above never loses its last, still-pending +records. Leave `flush` out if you never buffer anything; it is optional, and +the library only calls it when it is there. + +### Reading logs back + +```ts +const lines = await this.logger.query({ correlationId: scenario.id }); +``` + +`CpsLoggerService.query` just calls your backend, so the same service that +wrote a line fetches it back — you never touch `CPS_LOG_API_PROVIDER` +directly. Filter by `correlationId`, `logger`, `minLevel`, a time range, or +`limit`. + +It fails open, like everything else: with no backend bound, or one that +rejects, it resolves to `[]` instead of throwing into a component. Records +come back exactly as they were stored — already redacted on the way out, so +there is nothing left to strip. + +To get logs out as a **file**, just serialize whatever `query()` returns. +Pulling them out of CloudWatch instead would need credentials that can query +the entire log group, which is the wrong trade — see DESIGN.md §9. + +### Named loggers + +Declare a logger per area of the application and bind it once, instead of +repeating a label at every call: + +```ts +// src/app/telemetry.schema.ts +declare module 'cps-telemetry' { + interface CpsLoggerNames { + checkout: true; + admin: true; + } +} + +export {}; +``` + +```ts +class CheckoutService { + private readonly logger = inject(CpsLoggerService).getLogger('checkout'); + + submit() { + this.logger.log('Submitting order'); // -> { logger: 'checkout', … } + } +} +``` + +The name lands on `record.logger` and is what per-logger levels, the debug +flag, and your own backend all key off. It is separate from `context`, which +stays free text describing what the individual line is about — `logger` says +_where the record came from_, `context` says _what it is about_, and only the +first one gets routed on. + +Records written through the bare `CpsLoggerService` are simply unnamed. + +### Sending loggers to different destinations + +The library does not route anything for you. Every record reaches your +provider carrying its `logger`, so keeping streams apart is just a switch in +the one place that already knows where things go: + +```ts +@Injectable({ providedIn: 'root' }) +export class MyLogBackend implements CpsLogApiProvider { + send(record: CpsLogRecord): void { + const endpoint = + record.logger === 'checkout' ? '/api/logs/checkout' : '/api/logs'; + + void fetch(endpoint, { + method: 'POST', + body: JSON.stringify(record), + keepalive: true + }).catch(() => undefined); + } +} +``` + +If a provider wants to batch per destination instead of sending each record +right away, it buffers into its own per-endpoint queues — the same as the +single-queue example above. Splitting streams does not change that pattern. + +### Per-logger levels + +`minLevel` is the floor for everything; `levels` overrides it by name, either +raising or lowering it: + +```ts +provideCpsTelemetry( + { application: 'shop', environment, version }, + withLogging({ minLevel: 'warn', levels: { checkout: 'log' } }) +); +``` + +## Who is signed in + +Telemetry is anonymous until you say otherwise. Attribution lives on the +sink, so one call covers both streams — logs and RUM events read the same +value from the same place, and can never drift apart: + +```ts +@Injectable({ providedIn: 'root' }) +export class AuthService { + private sink = inject(CpsTelemetrySink); + + onSignIn(user: User) { + // An opaque id — never an email, a username or an account number. + this.sink.setUserId(user.id); + } + + onSignOut() { + this.sink.setUserId(undefined); + } +} +``` + +Sign-out is not just cosmetic. `pinUserId` has no inverse, so the RUM sink +starts a fresh session with a fresh anonymous id — otherwise the client would +keep attributing everything to the person who just left, which matters most +on a shared device. **So the session id changes as a result**, at the cost of +one `session_start` event and a re-rolled sampling decision. + +In a fragment, this call is forwarded to the shell, so the whole composed +page agrees on who is signed in, no matter which realm made the call. + +## Micro-frontends and web fragments + +Each fragment runs in its own JavaScript realm, so each would otherwise build +its own AWS client — turning one visitor into N separate sessions, each with +its own slice of the event budget and its own copy of the SDK. + +Instead, one realm hosts and the rest forward to it over `BroadcastChannel`: + +```ts +// shell — owns the only AWS client +providers: [ + provideCpsTelemetry({ application: 'shell', environment: 'prod', version }), + provideCpsTelemetryRumSink(), + provideCpsTelemetryBroadcastHost() +]; + +// fragment — no AWS client, no SDK bundle, no broker call +providers: [ + provideCpsTelemetry({ application: 'cart', environment: 'prod', version }), + provideCpsTelemetrySink('broadcast') +]; +``` + +Code inside a fragment does not change: same services, same calls. One +session, one budget, one bundle. + +### Connecting a fragment + +Two providers, and that is the whole integration: + +```ts +bootstrapApplication(FragmentRoot, { + providers: [ + provideCpsTelemetry({ + application: 'cart', // this fragment's own name + environment: 'production', + version: packageJson.version, + eventNamespace: 'com.my-app' // must match the shell + }), + provideCpsTelemetrySink('broadcast') + ] +}); +``` + +**Settings that have to match the shell** + +| Setting | Rule | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `eventNamespace` | **Must match.** Otherwise your CloudWatch queries fragment along with your UI | +| Channel name | **Must match.** The default (`CPS_DEFAULT_BROADCAST_CHANNEL`, `'cps-telemetry'`) needs no argument; if the shell passes a custom name, pass the same one — `provideCpsTelemetrySink('broadcast', { channelName: 'my-channel' })` | +| `application` | **Should differ** per fragment. The forwarding sink stamps its realm's `application`, `environment` and `appVersion` onto every event it sends, so you can tell which fragment emitted what in the data | + +**What a fragment must not provide** + +| Do not | Why | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------- | +| `provideCpsTelemetryRumSink()` | Builds a second AWS client — one visitor becomes two sessions, exactly what this arrangement is meant to avoid | +| `CPS_RUM_CREDENTIALS_PROVIDER` | Nothing in a fragment needs AWS credentials | +| `provideCpsTelemetryBroadcastHost()` | Only one realm should host — a second one just wins or loses a leader election and idles either way (see below) | + +The shell itself can safely be open in more than one tab: `BroadcastChannel` +is origin-wide, so every tab's host shares the same channel, but only one is +elected leader (a Web Locks-based election) and actually records forwarded +telemetry — the rest stay fully passive until the leader tab closes. See +DESIGN.md §13 for the mechanism. + +### A fragment that also deploys standalone + +Embedded, a fragment has to forward so the composed page keeps a single +session. Deployed on its own, there is no shell to forward to, so it needs its +own client. Which one applies is a fact about the deployment, so read it from +configuration instead of trying to detect it: + +```ts +providers: [ + provideCpsTelemetry({ + application: 'cart', + environment: environment.name, + version: packageJson.version, + eventNamespace: 'com.my-app' + }), + + environment.embedded + ? provideCpsTelemetrySink('broadcast') + : provideCpsTelemetryRumSink(), + + // Used only when standalone; harmless when embedded. + { provide: CPS_RUM_CREDENTIALS_PROVIDER, useExisting: CartRumCredentials } +]; +``` + +| Mode | Sends to | Needs | +| ------------- | --------------------------------------------------------------- | ---------------------------------------------------- | +| `'broadcast'` | The shell's host, over `BroadcastChannel` | A shell running `provideCpsTelemetryBroadcastHost()` | +| RUM | AWS CloudWatch RUM directly, via `provideCpsTelemetryRumSink()` | `CPS_RUM_CREDENTIALS_PROVIDER` | +| `'noop'` | Nowhere — everything runs, nothing ships | Nothing. Useful for local development | + +Use `provideCpsTelemetrySink('broadcast', { channelName })` if the shell uses +a custom channel. + +Application code stays identical across these modes, so nothing outside this +provider list needs to be conditional. Note that in RUM mode the fragment is +a full telemetry client on its own — its own session, its own event budget. +That is correct when it is the whole page, and exactly what you are trying +to avoid when it is not. + +Deciding this here, rather than probing at runtime, is deliberate: a fragment +cannot tell synchronously whether a shell exists. Runtime detection would mean +buffering events during a probe window and losing the race if the shell boots +slowly — which produces the two sessions this arrangement exists to prevent. + +### Continuing a shell journey + +A `CpsScenario` is a class instance and cannot cross a realm, but its id is +just a string. Send it over whatever channel already carries your app state: + +```ts +// shell publishes +new BroadcastChannel('app').postMessage({ scenarioId: checkout.id }); + +// fragment continues it +this.scenarioTelemetry.start({ + name: 'add-to-cart', + parentScenarioId: msg.scenarioId +}); +``` + +Both records then join on `parentScenarioId`. + +### What to expect + +- **`getSessionId()` is `undefined` for the first task.** A follower asks the + shell for the session id when it is constructed, and the answer arrives one + task later. Log records written in that window carry no `sessionId` — they + are still correlated by `scenarioId`. Do not assert on it right at bootstrap. +- **Paint observation does not work in a fragment.** A `PerformanceObserver` + inside a fragment watches its own hidden iframe, never the host frame where + the pixels actually appear — so it will not fire. Settle with `complete()` + instead. +- **A fragment with no shell still works.** If nothing is listening — no host + yet, or the browser has no `BroadcastChannel` at all — the sink degrades to + a no-op. Scenarios run, logs are written, nothing throws; the telemetry + just is not shipped. A fragment developed standalone will not break, and starts + reporting as soon as it is composed into a shell that hosts. + +See DESIGN.md §13 for the reasoning and the remaining limits. + +## Debugging + +Off by default, in every environment. Set from DevTools, and nothing needs a +reload: + +```js +localStorage.setItem('debugLogger', 'true'); +localStorage.setItem('debugScenario', 'true'); +localStorage.setItem('debugBI', '1'); +``` + +`'true'` and `'1'` turn everything on. `debugLogger` also accepts a +comma-separated list of logger names, which is how you switch on one noisy +area without the rest: + +```js +localStorage.setItem('debugLogger', 'checkout,cart'); +``` + +## Privacy + +Telemetry attributes are typed +`Record` — flat and primitive-only, +so an object full of personal data cannot be passed in by accident. + +**Page ids come from the RUM client, and they are resolved paths.** The client +patches `history.pushState` — which is how Angular navigates — and records a +page view as `location.pathname` on its own. The library does not wire up page +views at all, so there is nothing to configure here and nothing that +double-records. + +The consequence is worth knowing: the current page id is stamped onto +**every** event recorded after it, so a parameter in the path reaches all of +them. If your routes carry identifiers, record page ids yourself instead — +walk the activated snapshot for the route template, call +`CpsRumTelemetrySink.recordPageView` with it on `NavigationEnd`, and set +`disableAutoPageView: true` in the RUM configuration so the client's own page +view recording is off. Concretely, that means a broker response like this: + +```ts +// broker response +{ + config: { + applicationId: '...', + region: 'eu-west-1', + applicationVersion: '1.0.0', + disableAutoPageView: true + } +} +``` + +A scenario's `route` field wants the same kind of template. Either way, the +client still captures the full `pageUrl` — keeping identifiers out of paths +is an application concern. On top of that, the library redacts values under +sensitive keys (`password`, `token`, `authorization`, `apiKey`, …), strips URL +query strings and fragments, normalizes errors to +`{ name, message, stack? }`, and caps every size. Arbitrary objects are never +serialized. + +The key denylist only catches PII sitting under a conventionally-named key — +it cannot tell that a value under an innocuous key like `notes` happens to be +an email address. `redact.scanValuePatterns` closes part of that gap, opt-in, +off by default: + +```ts +redact: { + scanValuePatterns: ['email', 'creditCard', 'ssn']; +} +``` + +Available shapes: `'email'`, `'creditCard'` (Luhn-validated), `'ssn'`, +`'ipv4'`, `'phone'` (NANP and African countries). An +`extraValuePatterns: RegExp[]` field, shaped like `extraKeyPatterns`, covers +an application's own content patterns as regexes. + +For redaction logic no regex can express, `extraValueTransforms` takes +plain functions instead — each receives a string (already scrubbed by +everything above) and returns the value to carry forward: + +```ts +redact: { + extraValueTransforms: [(value) => value.replace(/ACC-\d+/g, '[redacted]')]; +} +``` + +A throwing transform is skipped, not fatal — logged in dev mode, the rest of +the pipeline keeps running. + +### Turning redaction off per concern + +`withLogging`/`withScenarios`/`withBiEvents` each take a `redact: boolean` +(default `true`), so you can turn off redaction for one concern without +touching the others: + +```ts +withScenarios({ redact: false }); +``` + +This only skips the _configurable_ scrubbing for that concern — +`extraKeyPatterns`, `scanValuePatterns`/`extraValuePatterns`, and URL-query +stripping. The built-in credential denylist (`password`, `token`, `secret`, +`apiKey`, …), size caps, error normalization, and `extraValueTransforms` all +keep applying regardless — those are safety guarantees, not privacy +opt-ins, so turning redaction "off" can't accidentally leak an actual +credential. + +## Testing + +The library ships **no test helpers**. Everything a test needs is already in +the main entry, and it comes down to three providers: + +```ts +TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'my-app-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useClass: RecordingLogBackend }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } + ] +}); +``` + +Telemetry runs for real and is discarded; logs land in your test backend, +which is just two methods: + +```ts +@Injectable() +class RecordingLogBackend implements CpsLogApiProvider { + readonly records: CpsLogRecord[] = []; + + send(record: CpsLogRecord): void { + this.records.push(record); + } + query(): Promise { + return Promise.resolve(this.records); + } +} +``` + +Nothing else is needed: the library does no batching of its own, so a record +reaches the provider as soon as it is written. + +To assert on emitted telemetry, bind a sink that records instead of +discarding. `CpsTelemetrySink` is six methods, so the double is short and +stays yours to write: + +```ts +@Injectable() +class RecordingSink extends CpsTelemetrySink { + readonly events: { eventType: string; payload: object }[] = []; + + record(eventType: string, payload: object): void { + this.events.push({ eventType, payload }); + } + // recordError also takes an optional `metadata` second argument — a + // sink can drop it, as here, or use it the way the RUM sink does, to + // fold a broadcast-forwarded error's origin into its own record. + recordError(): void {} + getSessionId(): string | undefined { + return 'test-session'; + } + setUserId(): void {} + getUserId(): string | undefined { + return undefined; + } + flush(): void {} +} + +TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'my-app-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useClass: RecordingLogBackend }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] +}); + +TestBed.inject(RecordingSink).events.filter( + (e) => e.eventType === CPS_TELEMETRY_EVENT_TYPE.scenario +); +``` + +One thing worth knowing if you are testing code that uses this library under +jsdom: jsdom implements no `BroadcastChannel`, so the broadcast sink degrades +to a no-op there. That is the documented, expected behavior — not a failure. + +## Guarantees + +- **Telemetry never breaks the application.** Every entry point is wrapped + and never rethrows. In development, suppressed errors are still reported to + the console so bugs surface; in production it stays silent. +- **SSR-safe.** Every browser-touching path is a no-op on the server. +- **Fail-open.** A broker outage, expired credentials, or an SDK throw all + leave the application behaving exactly as it would with healthy telemetry. diff --git a/projects/cps-telemetry/ng-package.json b/projects/cps-telemetry/ng-package.json new file mode 100644 index 000000000..1ce74ad54 --- /dev/null +++ b/projects/cps-telemetry/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/cps-telemetry", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/cps-telemetry/package.json b/projects/cps-telemetry/package.json new file mode 100644 index 000000000..b4a997fa0 --- /dev/null +++ b/projects/cps-telemetry/package.json @@ -0,0 +1,26 @@ +{ + "name": "cps-telemetry", + "version": "0.1.0", + "description": "Reusable Angular telemetry layer: application logs, scenario health telemetry and business/UX events, with an optional AWS CloudWatch RUM sink.", + "peerDependencies": { + "@angular/common": "^22.1.3", + "@angular/core": "^22.1.3", + "aws-rum-web": "^3.2.1", + "rxjs": "^7.8.2" + }, + "peerDependenciesMeta": { + "aws-rum-web": { + "optional": true + } + }, + "repository": { + "type": "git", + "url": "https://github.com/AbsaOSS/cps-shared-ui" + }, + "homepage": "https://github.com/AbsaOSS/cps-shared-ui", + "license": "Apache License 2.0", + "dependencies": { + "tslib": "^2.8.1" + }, + "sideEffects": false +} diff --git a/projects/cps-telemetry/rum/ng-package.json b/projects/cps-telemetry/rum/ng-package.json new file mode 100644 index 000000000..e09fb3fd0 --- /dev/null +++ b/projects/cps-telemetry/rum/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/cps-telemetry/rum/src/lib/cps-rum-credentials/cps-rum-credentials.ts b/projects/cps-telemetry/rum/src/lib/cps-rum-credentials/cps-rum-credentials.ts new file mode 100644 index 000000000..13e187e2c --- /dev/null +++ b/projects/cps-telemetry/rum/src/lib/cps-rum-credentials/cps-rum-credentials.ts @@ -0,0 +1,307 @@ +import { InjectionToken } from '@angular/core'; + +/** + * Short-lived AWS credentials for the RUM data plane. + * + * @group Interfaces + */ +export interface CpsRumCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + + /** ISO-8601 expiry. Used to schedule a refresh before the credentials lapse. */ + expiration: string; +} + +/** + * Mirrors `aws-rum-web`'s own `AwsCredentialIdentity | AwsCredentialIdentityProvider` + * union — used only by {@link CpsRumAppMonitorConfig.clientBuilder}. + */ +type ClientBuilderCredentials = + | { accessKeyId: string; secretAccessKey: string; sessionToken?: string } + | (() => Promise<{ + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + }>); + +/** + * Settings for the RUM app monitor, mapped onto the underlying `aws-rum-web` + * SDK's own `Config`. Every optional field left unset falls back to a + * default: `allowCookies`, `enableXRay`, `endpoint` and `telemetries` fall + * back to this library's own override; every other field falls back to the + * SDK's own default (see `buildRumConfig()`). + * + * Matches the SDK's `Config` field for field, with three omissions: + * + * - `endpointUrl` — always recomputed by the SDK from `endpoint`; setting it + * independently has no effect. + * - `identityPoolId`, `guestRoleArn` — the SDK's own Cognito guest-identity + * credential flow. AWS credentials come from a backend-vended + * `CpsRumCredentialsProvider` instead (see + * `CpsRumTelemetrySink.applyCredentials`). + * - `sessionId`, `userId` — owned at runtime by `CpsRumTelemetrySink`'s + * `setUserId`, `pinUserId` and `startSession`. + * + * None of the field types below are imported from `aws-rum-web`; every type + * here is hand-written. This keeps `aws-rum-web` an optional peer + * dependency for applications that only use the `broadcast` or `noop` sink. + * + * @group Interfaces + */ +export interface CpsRumAppMonitorConfig { + /** CloudWatch RUM app monitor id. */ + applicationId: string; + + /** AWS region hosting the app monitor, e.g. `eu-west-1`. */ + region: string; + + /** Version string reported with every event. */ + applicationVersion: string; + + // ---- Sampling & session ---- + + /** Fraction of sessions recorded, `0`–`1`. Default `1`. */ + sessionSampleRate?: number; + + /** Session length before a new one begins, in seconds. Default `1800` (30 minutes). */ + sessionLengthSeconds?: number; + + /** + * Hard cap on events recorded per session, across all telemetry. Default + * `200`. See DESIGN.md §7. + */ + sessionEventLimit?: number; + + /** + * Suppresses the `session_start` event the SDK would otherwise emit at the + * start of a session. Default `false`. + */ + suppressSessionStartEvent?: boolean; + + /** Days an anonymous user id is retained in cookies/local storage. Default `30`. */ + userIdRetentionDays?: number; + + /** + * Attributes attached to every event in the session, seeded at + * construction. Default `{}`. `CpsRumTelemetrySink` merges `application`, + * `environment` and `appVersion` on top of this after construction; those + * three keys always win. + */ + sessionAttributes?: Record; + + /** + * Attributes attached to every event for the lifetime of the app monitor + * client, independent of session. Default unset. + */ + applicationAttributes?: Record; + + // ---- Telemetry collection & tracing ---- + + /** + * Built-in telemetry categories to enable. Defaults to errors, performance + * and same-origin HTTP. The SDK's own default also includes `'replay'` + * (session replay via `rrweb`); this library does not enable it by + * default. + */ + telemetries?: (string | (string | object)[])[]; + + /** + * Custom event plugins to load alongside the built-in telemetry, each + * implementing `aws-rum-web`'s own `Plugin` interface. Default `[]`. + */ + eventPluginsToLoad?: unknown[]; + + /** Whether to emit X-Ray trace ids. Default `true`. */ + enableXRay?: boolean; + + /** Whether to emit W3C trace context ids alongside X-Ray's. Default `false`. */ + enableW3CTraceId?: boolean; + + /** Whether resource-timing events record the resource's URL. Default `true`. */ + recordResourceUrl?: boolean; + + // ---- Dispatch & buffering ---- + + /** Data plane endpoint. Defaults to the regional RUM endpoint. */ + endpoint?: string; + + /** Extra headers sent with every dispatch request. Default unset. */ + headers?: Record; + + /** Free-form label reported alongside dispatched events. Default unset. */ + alias?: string; + + /** Maximum events sent per dispatch request. Default `100`. */ + batchLimit?: number; + + /** Interval between automatic dispatches, in milliseconds. Default `5000`. */ + dispatchInterval?: number; + + /** Maximum events held in the in-memory cache awaiting dispatch. Default `1000`. */ + eventCacheSize?: number; + + /** Maximum not-yet-recorded candidate events cached. Default `10`. */ + candidatesCacheSize?: number; + + /** Retries for a failed dispatch request. Default `2`. */ + retries?: number; + + /** Whether unload-time flushes use `navigator.sendBeacon`. Default `true`. */ + useBeacon?: boolean; + + /** + * Whether dispatch requests are SigV4-signed. Default `true`. Signing is + * active whenever credentials are present — which they are here, via + * `setAwsCredentials` from `CpsRumCredentialsProvider` — independent of + * `identityPoolId`; that field only matters for the SDK's own, separate + * Cognito guest-identity flow, which this library does not use. Leave + * this enabled for broker-supplied credentials: disabling it here still + * sends unsigned requests to the same signed-only endpoint and gets + * rejected. Only turn it off if something in front of dispatch (a proxy, + * a different transport) already signs the request itself. + */ + signing?: boolean; + + /** Whether the request body is gzip-compressed before dispatch. Default `{ enabled: true }`. */ + compressionStrategy?: { enabled: boolean }; + + /** + * Overrides the SDK's own `fetch`-based transport. Default unset — the + * SDK uses the global `fetch`. + */ + fetchFunction?: typeof fetch; + + /** + * Overrides how the SDK builds its data-plane HTTP client. An advanced + * escape hatch — see `aws-rum-web`'s own `ClientBuilder` type for the exact + * signature (`(endpoint: URL, region: string, credentials?, compressionStrategy?) => DataPlaneClient`). + * Default unset. + */ + clientBuilder?: ( + endpoint: URL, + region: string, + credentials?: ClientBuilderCredentials, + compressionStrategy?: { enabled: boolean } + ) => unknown; + + // ---- Cookies & privacy ---- + + /** Whether the client may use cookies for session continuity. Default `true`. */ + allowCookies?: boolean; + + /** + * Overrides for the cookies the client sets. Unset fields fall back to the + * SDK's own defaults (`domain` = current hostname, `path` = `/`, + * `sameSite` = `'Strict'`, `secure` = `true`, `unique` = `false`). + */ + cookieAttributes?: { + unique?: boolean; + domain?: string; + path?: string; + sameSite?: string; + secure?: boolean; + }; + + // ---- Page tracking ---- + + /** + * Disables the client's own automatic page-view recording on navigation. + * Pair with `CpsRumTelemetrySink.recordPageView()` to record page ids + * yourself. Default `false`. + */ + disableAutoPageView?: boolean; + + /** How the page id is derived from the URL. Default `'PATH'`. */ + pageIdFormat?: 'PATH' | 'HASH' | 'PATH_AND_HASH'; + + /** Pages recorded, matched against the page id. Default matches every page. */ + pagesToInclude?: RegExp[]; + + /** Pages excluded from recording, matched against the page id. Default `[]`. */ + pagesToExclude?: RegExp[]; + + /** How long, in milliseconds, a route change waits before being considered complete. Default `100`. */ + routeChangeComplete?: number; + + /** Timeout, in milliseconds, before an incomplete route change is abandoned. Default `10000`. */ + routeChangeTimeout?: number; + + // ---- Advanced / escape hatches ---- + + /** + * Label reported as the SDK's own install method. Default is the SDK's + * internal module-install constant; overriding it is rarely useful outside + * of testing the SDK itself. + */ + client?: string; + + /** Release identifier reported with every event, e.g. a build SHA. Default unset. */ + releaseId?: string; + + /** Logs the SDK's own internal diagnostics to the console. Default `false`. */ + debug?: boolean; + + /** Master switch for the RUM client. Default `true`. */ + enableRumClient?: boolean; +} + +/** + * The payload returned by a {@link CpsRumCredentialsProvider}. + * + * @group Interfaces + */ +export interface CpsRumBootstrap { + /** App monitor settings. */ + config: CpsRumAppMonitorConfig; + + /** + * Credentials for the data plane. Omit when the app monitor is configured for + * unauthenticated access. + */ + credentials?: CpsRumCredentials; +} + +/** + * What the RUM sink needs from the host application to start. + * + * Keeps the telemetry layer free of AWS account details — how the app + * monitor id is discovered and how credentials are vended is entirely the + * application's business. + * + * @example + * ```typescript + * @Injectable({ providedIn: 'root' }) + * export class AppRumCredentialsProvider implements CpsRumCredentialsProvider { + * async load(): Promise { + * const res = await fetch('/rum/init'); + * if (!res.ok) return null; + * const { enabled, config, credentials } = await res.json(); + * return enabled ? { config, credentials } : null; + * } + * } + * ``` + * + * @group Interfaces + */ +export interface CpsRumCredentialsProvider { + /** + * Resolves the app monitor settings and a set of credentials. + * + * Called once at startup and again shortly before each credential expiry. + * Returning `null` disables RUM for the session — the telemetry layer stays + * fully functional, it simply stops shipping. + * + * @returns the bootstrap payload, or `null` to disable RUM + */ + load(): Promise; +} + +/** + * Binds the application's {@link CpsRumCredentialsProvider}. + * + * @group Tokens + */ +export const CPS_RUM_CREDENTIALS_PROVIDER = + new InjectionToken('CPS_RUM_CREDENTIALS_PROVIDER'); diff --git a/projects/cps-telemetry/rum/src/lib/cps-rum-internal.util/cps-rum-internal.util.ts b/projects/cps-telemetry/rum/src/lib/cps-rum-internal.util/cps-rum-internal.util.ts new file mode 100644 index 000000000..dd16c3b9e --- /dev/null +++ b/projects/cps-telemetry/rum/src/lib/cps-rum-internal.util/cps-rum-internal.util.ts @@ -0,0 +1,120 @@ +import { isPlatformBrowser } from '@angular/common'; +import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; + +/** + * A local copy of `cps-telemetry`'s own internal + * `cps-telemetry-safe-internal.util.ts` — + * `cpsSafe`/`cpsSafeVoid`/`cpsIsBrowser`/`cpsIsDevMode`/`cpsUuid`, verbatim. + * + * Not imported from the main entry point: ng-packagr fixes every entry + * point's `rootDir` to its own `src` directory (unconditionally — see + * `initializeTsConfig` in ng-packagr's own `tsconfig.js`), so a secondary + * entry point cannot reach a file physically outside `rum/src` by relative + * path. The main entry deliberately doesn't export these either — per + * DESIGN.md §3, the fail-open wrappers and id generation stay internal so + * they can change freely without a breaking release, and widening the + * public API just to satisfy this entry point's own plumbing would trade + * away that freedom for every consumer, not just this one. A small, + * verbatim, unexported copy is the cheaper trade. + */ + +/** + * Runs a telemetry operation without letting it affect the application. + * + * Every public entry point in this library routes through this wrapper — + * it never rethrows. In development the caught error is reported to the + * console; in production nothing is printed. + */ +export function cpsSafe(operation: string, fn: () => T, fallback: T): T { + try { + return fn(); + } catch (error) { + reportSuppressed(operation, error); + return fallback; + } +} + +/** + * Void-returning form of {@link cpsSafe}, for the common case of an emit call + * whose result nobody reads. + */ +export function cpsSafeVoid(operation: string, fn: () => void): void { + cpsSafe(operation, fn, undefined); +} + +/** `isDevMode()` throws outside an Angular context, so this guards that once for every caller. */ +export function cpsIsDevMode(): boolean { + try { + return isDevMode(); + } catch { + return false; + } +} + +/** + * Whether this code is running in a browser, as opposed to a server-side + * render. Must be called from an injection context, same as + * `inject(PLATFORM_ID)` itself requires. + */ +export function cpsIsBrowser(): boolean { + return isPlatformBrowser(inject(PLATFORM_ID)); +} + +function reportSuppressed(operation: string, error: unknown): void { + if (!cpsIsDevMode()) { + return; + } + try { + // eslint-disable-next-line no-console + console.error(`[cps-telemetry] ${operation} failed`, error); + } catch { + // A patched/throwing console must never escape telemetry suppression. + } +} + +/** + * Fills a byte array from the best available source. `crypto.getRandomValues` + * works on plain http:// too, where `crypto.randomUUID` doesn't. `Math.random` + * is the last-resort fallback for an environment with no Web Crypto at all. + */ +function randomBytes(count: number): Uint8Array { + const bytes = new Uint8Array(count); + const cryptoRef = globalThis.crypto; + + if (typeof cryptoRef?.getRandomValues === 'function') { + return cryptoRef.getRandomValues(bytes); + } + + for (let i = 0; i < count; i++) { + bytes[i] = (Math.random() * 256) | 0; + } + return bytes; +} + +/** Lowercase hex encoding, two characters per byte. */ +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Generates a correlation identifier. Prefers `crypto.randomUUID`, falling + * back to formatting random bytes as a v4 UUID. + */ +export function cpsUuid(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + const bytes = randomBytes(16); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + + const hex = toHex(bytes); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20) + ].join('-'); +} diff --git a/projects/cps-telemetry/rum/src/lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink.spec.ts b/projects/cps-telemetry/rum/src/lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink.spec.ts new file mode 100644 index 000000000..537023671 --- /dev/null +++ b/projects/cps-telemetry/rum/src/lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink.spec.ts @@ -0,0 +1,1205 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + CPS_DEFAULT_TELEMETRY_CONFIG, + CPS_REDACT_CONFIG, + CPS_TELEMETRY_EVENT_TYPE, + CPS_TELEMETRY_IDENTITY +} from 'cps-telemetry'; +import { + CPS_RUM_CREDENTIALS_PROVIDER, + CpsRumBootstrap +} from '../cps-rum-credentials/cps-rum-credentials'; +import { CpsRumTelemetrySink } from './cps-rum-telemetry.sink'; + +/** Stand-in for the real `AwsRum` client. */ +const awsRumInstance = { + recordEvent: jest.fn(), + recordError: jest.fn(), + recordPageView: jest.fn(), + addSessionAttributes: jest.fn(), + setEventMetadataHook: jest.fn(), + setAwsCredentials: jest.fn(), + pinUserId: jest.fn(), + startSession: jest.fn(() => 'rum-session-2'), + getSessionId: jest.fn(() => 'rum-session-1'), + dispatch: jest.fn(), + dispatchBeacon: jest.fn(), + disable: jest.fn() +}; + +// Typed on the real `new AwsRum(applicationId, applicationVersion, region, +// config)` signature so `.mock.calls[0][3]` typechecks. +const AwsRumCtor = jest.fn( + ( + _applicationId: string, + _applicationVersion: string, + _region: string, + _config: unknown + ) => awsRumInstance +); + +jest.mock( + 'aws-rum-web', + () => ({ + AwsRum: function (this: unknown, ...args: unknown[]) { + return AwsRumCtor(...(args as Parameters)); + } + }), + { virtual: true } +); + +function bootstrap( + overrides?: Partial, + expiresInMs = 60 * 60 * 1000 +): CpsRumBootstrap { + return { + config: { + applicationId: 'app-monitor-1', + region: 'eu-west-1', + applicationVersion: '1.0.0', + ...overrides + }, + credentials: { + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: new Date(Date.now() + expiresInMs).toISOString() + } + }; +} + +describe('CpsRumTelemetrySink', () => { + let sink: CpsRumTelemetrySink; + let load: jest.Mock; + + function configure(options?: { + platformId?: object; + loadImpl?: () => Promise; + }): void { + load = jest.fn(options?.loadImpl ?? (async () => bootstrap())); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { + provide: CPS_TELEMETRY_IDENTITY, + useValue: { + application: 'test-app', + environment: 'prod', + version: '3.0.0' + } + }, + { + provide: CPS_REDACT_CONFIG, + useValue: CPS_DEFAULT_TELEMETRY_CONFIG.redact + }, + { provide: PLATFORM_ID, useValue: options?.platformId ?? 'browser' }, + { provide: CPS_RUM_CREDENTIALS_PROVIDER, useValue: { load } }, + CpsRumTelemetrySink + ] + }); + sink = TestBed.inject(CpsRumTelemetrySink); + } + + beforeEach(() => { + jest.clearAllMocks(); + configure(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('initialization', () => { + it('should construct the client with the broker-supplied identity', async () => { + await sink.init(); + + expect(AwsRumCtor).toHaveBeenCalledWith( + 'app-monitor-1', + '1.0.0', + 'eu-west-1', + expect.objectContaining({ + endpoint: 'https://dataplane.rum.eu-west-1.amazonaws.com' + }) + ); + }); + + it('should enable the built-in errors, performance and http telemetries', async () => { + await sink.init(); + + const config = AwsRumCtor.mock.calls[0][3] as { telemetries: unknown[] }; + expect(config.telemetries[0]).toBe('errors'); + expect(config.telemetries[1]).toBe('performance'); + expect(config.telemetries[2]).toEqual([ + 'http', + { addXRayTraceIdHeader: [expect.any(RegExp)] } + ]); + }); + + it('should scope the X-Ray trace header to the exact origin, not a look-alike host', async () => { + await sink.init(); + + const config = AwsRumCtor.mock.calls[0][3] as { + telemetries: [ + string, + string, + [string, { addXRayTraceIdHeader: RegExp[] }] + ]; + }; + const sameOrigin = config.telemetries[2][1].addXRayTraceIdHeader[0]; + const origin = window.location.origin; + + expect(sameOrigin.test(`${origin}/api/customers`)).toBe(true); + expect(sameOrigin.test(origin)).toBe(true); + expect(sameOrigin.test(`${origin}.attacker.example/api`)).toBe(false); + }); + + it('should honour explicitly supplied app monitor settings', async () => { + configure({ + loadImpl: async () => + bootstrap({ + sessionSampleRate: 0.1, + endpoint: 'https://custom.example.com', + telemetries: ['errors'], + allowCookies: false, + enableXRay: false + }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + sessionSampleRate: 0.1, + endpoint: 'https://custom.example.com', + telemetries: ['errors'], + allowCookies: false, + enableXRay: false + }); + }); + + it('should honour a representative sample of advanced app monitor settings', async () => { + configure({ + loadImpl: async () => + bootstrap({ + sessionLengthSeconds: 3600, + sessionEventLimit: 500, + suppressSessionStartEvent: true, + applicationAttributes: { tier: 'gold' }, + eventPluginsToLoad: [{ id: 'custom' }], + enableW3CTraceId: true, + headers: { 'x-test': '1' }, + batchLimit: 50, + signing: false, + compressionStrategy: { enabled: false }, + cookieAttributes: { sameSite: 'Lax' }, + disableAutoPageView: true, + pageIdFormat: 'HASH', + pagesToExclude: [/^\/admin/], + client: 'custom-client', + releaseId: 'abc123', + debug: true + }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + sessionLengthSeconds: 3600, + sessionEventLimit: 500, + suppressSessionStartEvent: true, + applicationAttributes: { tier: 'gold' }, + eventPluginsToLoad: [{ id: 'custom' }], + enableW3CTraceId: true, + headers: { 'x-test': '1' }, + batchLimit: 50, + signing: false, + compressionStrategy: { enabled: false }, + cookieAttributes: { sameSite: 'Lax' }, + disableAutoPageView: true, + pageIdFormat: 'HASH', + pagesToExclude: [/^\/admin/], + client: 'custom-client', + releaseId: 'abc123', + debug: true + }); + }); + + it('should accept a real-shaped ClientBuilder for clientBuilder with no cast', async () => { + const clientBuilder = ( + endpoint: URL, + region: string, + credentials?: + | { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + } + | (() => Promise<{ + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + }>), + compressionStrategy?: { enabled: boolean } + ) => ({ endpoint, region, credentials, compressionStrategy }); + + configure({ + loadImpl: async () => bootstrap({ clientBuilder }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ clientBuilder }); + }); + + it("should infer clientBuilder's credentials/compressionStrategy parameter types from CpsRumAppMonitorConfig itself, with no explicit annotation needed", async () => { + configure({ + loadImpl: async () => + bootstrap({ + clientBuilder: ( + endpoint, + region, + credentials, + compressionStrategy + ) => ({ + endpoint, + region, + accessKeyId: + credentials && 'accessKeyId' in credentials + ? credentials.accessKeyId + : undefined, + compressionEnabled: compressionStrategy?.enabled + }) + }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + clientBuilder: expect.any(Function) + }); + }); + + it('should pass through every SDK-default-mirroring field the representative-sample test above leaves uncovered', async () => { + const fetchFunction = async () => new Response(); + + configure({ + loadImpl: async () => + bootstrap({ + userIdRetentionDays: 30, + sessionAttributes: { plan: 'enterprise' }, + recordResourceUrl: false, + dispatchInterval: 5000, + eventCacheSize: 200, + candidatesCacheSize: 100, + retries: 3, + useBeacon: false, + fetchFunction, + pagesToInclude: [/^\/dashboard/], + routeChangeComplete: 500, + routeChangeTimeout: 2000, + enableRumClient: false + }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + userIdRetentionDays: 30, + sessionAttributes: { plan: 'enterprise' }, + recordResourceUrl: false, + dispatchInterval: 5000, + eventCacheSize: 200, + candidatesCacheSize: 100, + retries: 3, + useBeacon: false, + fetchFunction, + pagesToInclude: [/^\/dashboard/], + routeChangeComplete: 500, + routeChangeTimeout: 2000, + enableRumClient: false + }); + }); + + it('should keep its own literal default only for the fields it deliberately overrides', async () => { + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + allowCookies: true, + enableXRay: true, + endpoint: 'https://dataplane.rum.eu-west-1.amazonaws.com', + telemetries: [ + 'errors', + 'performance', + ['http', { addXRayTraceIdHeader: [expect.any(RegExp)] }] + ] + }); + }); + + it('should omit every SDK-default-mirroring field entirely when unset, not send it as undefined', async () => { + await sink.init(); + + const config = AwsRumCtor.mock.calls[0][3] as Record; + const omittedWhenUnset = [ + 'sessionSampleRate', + 'sessionLengthSeconds', + 'sessionEventLimit', + 'suppressSessionStartEvent', + 'userIdRetentionDays', + 'sessionAttributes', + 'eventPluginsToLoad', + 'enableW3CTraceId', + 'recordResourceUrl', + 'batchLimit', + 'dispatchInterval', + 'eventCacheSize', + 'candidatesCacheSize', + 'retries', + 'useBeacon', + 'signing', + 'compressionStrategy', + 'fetchFunction', + 'pageIdFormat', + 'pagesToInclude', + 'pagesToExclude', + 'routeChangeComplete', + 'routeChangeTimeout', + 'disableAutoPageView', + 'debug', + 'enableRumClient', + 'client', + 'applicationAttributes', + 'headers', + 'alias', + 'releaseId', + 'clientBuilder', + 'cookieAttributes' + ]; + + for (const field of omittedWhenUnset) { + expect(Object.prototype.hasOwnProperty.call(config, field)).toBe(false); + } + }); + + it('should pass every SDK-default-mirroring field through untouched when the consumer sets it', async () => { + configure({ + loadImpl: async () => + bootstrap({ + sessionSampleRate: 0.5, + sessionEventLimit: 500, + pageIdFormat: 'HASH', + debug: true + }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + sessionSampleRate: 0.5, + sessionEventLimit: 500, + pageIdFormat: 'HASH', + debug: true + }); + }); + + it('should include client only when explicitly set', async () => { + configure({ + loadImpl: async () => bootstrap({ client: 'custom-client' }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + client: 'custom-client' + }); + }); + + it('should apply the credentials from the broker', async () => { + await sink.init(); + + expect(awsRumInstance.setAwsCredentials).toHaveBeenCalledWith({ + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token' + }); + }); + + it('should stamp application identity as session attributes and a metadata hook', async () => { + await sink.init(); + + const attributes = { + application: 'test-app', + environment: 'prod', + appVersion: '3.0.0' + }; + expect(awsRumInstance.addSessionAttributes).toHaveBeenCalledWith( + attributes + ); + expect(awsRumInstance.setEventMetadataHook).toHaveBeenCalled(); + + const hook = awsRumInstance.setEventMetadataHook.mock.calls[0][0]; + expect(hook()).toEqual(attributes); + }); + + it('should be a no-op during server-side rendering', async () => { + configure({ platformId: 'server' as unknown as object }); + await sink.init(); + + expect(load).not.toHaveBeenCalled(); + expect(AwsRumCtor).not.toHaveBeenCalled(); + }); + + it('should only initialize once', async () => { + await sink.init(); + await sink.init(); + + expect(AwsRumCtor).toHaveBeenCalledTimes(1); + }); + + it('should stay disabled when the broker declines', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + configure({ loadImpl: async () => null }); + await sink.init(); + + expect(AwsRumCtor).not.toHaveBeenCalled(); + + sink.record('a', {}); + sink.flush(); + expect(awsRumInstance.recordEvent).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('RUM event(s) lost') + ); + }); + + it('should clear the pre-init buffer, not just stop future buffering, when the broker declines', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let resolveLoad!: (bootstrap: CpsRumBootstrap | null) => void; + configure({ + loadImpl: () => + new Promise((resolve) => { + resolveLoad = resolve; + }) + }); + + const initPromise = sink.init(); + sink.record('a', {}); + + resolveLoad(null); + await initPromise; + + sink.flush(); + + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('RUM event(s) lost') + ); + }); + + it('should not throw when the broker rejects', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + configure({ + loadImpl: async () => { + throw new Error('broker unreachable'); + } + }); + + await expect(sink.init()).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + '[cps-telemetry] RUM init failed, monitoring disabled', + expect.objectContaining({ + name: 'Error', + message: 'broker unreachable' + }) + ); + }); + + it('should disable the client, not just log a warning, when the SDK throws after construction', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + awsRumInstance.addSessionAttributes.mockImplementationOnce(() => { + throw new Error('sdk exploded'); + }); + + await sink.init(); + + expect(AwsRumCtor).toHaveBeenCalled(); + expect(awsRumInstance.disable).toHaveBeenCalled(); + + sink.record('a', {}); + expect(awsRumInstance.recordEvent).not.toHaveBeenCalled(); + }); + + it('should disable RUM and clear the pre-init buffer, not buffer forever, when init throws', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let rejectLoad!: (error: Error) => void; + configure({ + loadImpl: () => + new Promise((_resolve, reject) => { + rejectLoad = reject; + }) + }); + + const initPromise = sink.init(); + sink.record('a', {}); + + rejectLoad(new Error('broker unreachable')); + await initPromise; + + sink.record('b', {}); + sink.flush(); + + expect(awsRumInstance.recordEvent).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('RUM event(s) lost') + ); + }); + + it("should not reject init()'s promise even if console.warn itself throws", async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => { + throw new Error('console patched to throw'); + }); + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + configure({ + loadImpl: async () => { + throw new Error('broker unreachable'); + } + }); + + await expect(sink.init()).resolves.toBeUndefined(); + + warn.mockRestore(); + error.mockRestore(); + }); + + it('should leave the application functional after a failed init', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + configure({ + loadImpl: async () => { + throw new Error('broker unreachable'); + } + }); + await sink.init(); + + expect(() => sink.record('t', {})).not.toThrow(); + expect(() => sink.flush()).not.toThrow(); + expect(sink.getSessionId()).toBeUndefined(); + }); + + it('should not construct the RUM client if destroyed while the first load is in flight', async () => { + let resolveLoad!: (bootstrap: CpsRumBootstrap | null) => void; + configure({ + loadImpl: () => + new Promise((resolve) => { + resolveLoad = resolve; + }) + }); + + const initPromise = sink.init(); + sink.ngOnDestroy(); + resolveLoad(bootstrap()); + await initPromise; + + expect(AwsRumCtor).not.toHaveBeenCalled(); + }); + + it('should disable the already-constructed AWS RUM client on destroy, not just stop the credential-refresh timer', async () => { + await sink.init(); + expect(AwsRumCtor).toHaveBeenCalled(); + + sink.ngOnDestroy(); + + expect(awsRumInstance.disable).toHaveBeenCalled(); + }); + + it('should work without credentials for an unauthenticated app monitor', async () => { + configure({ + loadImpl: async () => ({ config: bootstrap().config }) + }); + await sink.init(); + + expect(AwsRumCtor).toHaveBeenCalled(); + expect(awsRumInstance.setAwsCredentials).not.toHaveBeenCalled(); + }); + }); + + describe('recording', () => { + it('should forward events to the client once initialized', async () => { + await sink.init(); + sink.record(CPS_TELEMETRY_EVENT_TYPE.bi, { eventName: 'clicked' }); + + expect(awsRumInstance.recordEvent).toHaveBeenCalledWith( + CPS_TELEMETRY_EVENT_TYPE.bi, + { eventName: 'clicked' }, + undefined + ); + }); + + it('should buffer events recorded before init and replay them in order', async () => { + sink.record('a', { n: 1 }); + sink.record('b', { n: 2 }); + + expect(awsRumInstance.recordEvent).not.toHaveBeenCalled(); + + await sink.init(); + + expect(awsRumInstance.recordEvent).toHaveBeenCalledTimes(2); + expect(awsRumInstance.recordEvent.mock.calls[0][0]).toBe('a'); + expect(awsRumInstance.recordEvent.mock.calls[1][0]).toBe('b'); + }); + + it('should self-initialize and eventually deliver even if init() is never called directly', async () => { + sink.record('a', { n: 1 }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(awsRumInstance.recordEvent).toHaveBeenCalledWith( + 'a', + { n: 1 }, + undefined + ); + }); + + it('should drop the oldest buffered events beyond the limit', async () => { + for (let i = 0; i < 150; i++) { + sink.record('e', { i }); + } + await sink.init(); + + expect(awsRumInstance.recordEvent).toHaveBeenCalledTimes(100); + expect(awsRumInstance.recordEvent.mock.calls[0][1]).toEqual({ i: 50 }); + }); + + it('should not buffer during server-side rendering', async () => { + configure({ platformId: 'server' as unknown as object }); + sink.record('a', {}); + await sink.init(); + + expect(awsRumInstance.recordEvent).not.toHaveBeenCalled(); + }); + + it('should drop metadata keys in the reserved aws: namespace', async () => { + await sink.init(); + sink.record('a', {}, { 'aws:client': 'x', keep: 'yes', dropped: null }); + + expect(awsRumInstance.recordEvent).toHaveBeenCalledWith( + 'a', + {}, + { + keep: 'yes' + } + ); + }); + + it('should pass no metadata when nothing survives sanitization', async () => { + await sink.init(); + sink.record('a', {}, { 'aws:client': 'x' }); + + expect(awsRumInstance.recordEvent).toHaveBeenCalledWith( + 'a', + {}, + undefined + ); + }); + + it('should forward handled errors to the client', async () => { + await sink.init(); + sink.recordError({ name: 'TypeError', message: 'boom' }); + + expect(awsRumInstance.recordError).toHaveBeenCalledWith({ + name: 'TypeError', + message: 'boom' + }); + }); + + it('should fold a forwarded error origin into its name, since the SDK has no metadata channel for errors', async () => { + await sink.init(); + sink.recordError( + { name: 'TypeError', message: 'boom' }, + { application: 'fragment-app' } + ); + + expect(awsRumInstance.recordError).toHaveBeenCalledWith({ + name: '[fragment-app] TypeError', + message: 'boom' + }); + }); + + it('should not modify the error when its origin matches this realm', async () => { + await sink.init(); + sink.recordError( + { name: 'TypeError', message: 'boom' }, + { application: 'test-app' } + ); + + expect(awsRumInstance.recordError).toHaveBeenCalledWith({ + name: 'TypeError', + message: 'boom' + }); + }); + + it('should buffer an error recorded before init and replay it once ready', async () => { + sink.recordError({ name: 'TypeError', message: 'boom' }); + + expect(awsRumInstance.recordError).not.toHaveBeenCalled(); + + await sink.init(); + + expect(awsRumInstance.recordError).toHaveBeenCalledWith({ + name: 'TypeError', + message: 'boom' + }); + }); + + it('should fold origin into a buffered error at replay time, same as an immediate one', async () => { + sink.recordError( + { name: 'TypeError', message: 'boom' }, + { application: 'fragment-app' } + ); + + await sink.init(); + + expect(awsRumInstance.recordError).toHaveBeenCalledWith({ + name: '[fragment-app] TypeError', + message: 'boom' + }); + }); + + it('should forward page views', async () => { + await sink.init(); + sink.recordPageView('/customers'); + + expect(awsRumInstance.recordPageView).toHaveBeenCalledWith('/customers'); + }); + + it('should buffer a page view recorded before init and replay it, same as record()', async () => { + sink.recordPageView('/customers'); + + expect(awsRumInstance.recordPageView).not.toHaveBeenCalled(); + + await sink.init(); + + expect(awsRumInstance.recordPageView).toHaveBeenCalledWith('/customers'); + }); + + it('should replay buffered events and page views in the order they were recorded', async () => { + sink.record('a', { n: 1 }); + sink.recordPageView('/customers'); + sink.record('b', { n: 2 }); + + await sink.init(); + + expect(awsRumInstance.recordEvent.mock.calls[0][0]).toBe('a'); + expect(awsRumInstance.recordPageView).toHaveBeenCalledWith('/customers'); + expect(awsRumInstance.recordEvent.mock.calls[1][0]).toBe('b'); + }); + + it('should not throw when the client throws', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await sink.init(); + awsRumInstance.recordEvent.mockImplementationOnce(() => { + throw new Error('sdk exploded'); + }); + + expect(() => sink.record('a', {})).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('rum.record failed'), + expect.any(Error) + ); + + consoleError.mockRestore(); + }); + }); + + describe('identity', () => { + it('should pin a user id set after init', async () => { + await sink.init(); + sink.setUserId('user-42'); + + expect(awsRumInstance.pinUserId).toHaveBeenCalledWith('user-42'); + }); + + it('should start a fresh anonymous session on sign-out', async () => { + await sink.init(); + sink.setUserId('user-42'); + + sink.setUserId(undefined); + + expect(awsRumInstance.startSession).toHaveBeenCalledWith({ + userId: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ) + }); + }); + + it('should not re-pin a signed-out user when the client starts later', async () => { + sink.setUserId('user-42'); + sink.setUserId(undefined); + + await sink.init(); + + expect(awsRumInstance.pinUserId).not.toHaveBeenCalled(); + }); + + it('should pin a user id set before init once the client exists', async () => { + sink.setUserId('user-42'); + expect(awsRumInstance.pinUserId).not.toHaveBeenCalled(); + + await sink.init(); + + expect(awsRumInstance.pinUserId).toHaveBeenCalledWith('user-42'); + }); + + it('should report the client session id', async () => { + await sink.init(); + expect(sink.getSessionId()).toBe('rum-session-1'); + }); + + it('should report no session id before init', () => { + expect(sink.getSessionId()).toBeUndefined(); + }); + }); + + describe('flushing', () => { + it('should dispatch normally by default', async () => { + await sink.init(); + sink.flush(); + + expect(awsRumInstance.dispatch).toHaveBeenCalled(); + expect(awsRumInstance.dispatchBeacon).not.toHaveBeenCalled(); + }); + + it('should use a beacon when asked, so unload does not lose the batch', async () => { + await sink.init(); + sink.flush(true); + + expect(awsRumInstance.dispatchBeacon).toHaveBeenCalled(); + expect(awsRumInstance.dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('credential refresh', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('should refresh shortly before the credentials expire', async () => { + configure({ loadImpl: async () => bootstrap(undefined, 60 * 60 * 1000) }); + await sink.init(); + + expect(load).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(55 * 60 * 1000); + + expect(load).toHaveBeenCalledTimes(2); + expect(awsRumInstance.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should retry after the bounded delay for already-expired credentials, instead of refreshing immediately', async () => { + configure({ loadImpl: async () => bootstrap(undefined, -1000) }); + await sink.init(); + + expect(load).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(0); + expect(load).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('should retry, not schedule a near-immediate refresh, for credentials expiring within the skew window', async () => { + configure({ loadImpl: async () => bootstrap(undefined, 2 * 60 * 1000) }); + await sink.init(); + + expect(load).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(0); + expect(load).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('should cap delay at 2^31-1 ms to prevent 32-bit integer overflow for far-future credentials', async () => { + configure({ + loadImpl: async () => bootstrap(undefined, 100 * 24 * 60 * 60 * 1000) + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(1000); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('should not throw when a refresh fails', async () => { + let calls = 0; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + configure({ + loadImpl: async () => { + if (calls++ === 0) { + return bootstrap(undefined, 6 * 60 * 1000); + } + throw new Error('broker down'); + } + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + + expect(warn).toHaveBeenCalledWith( + '[cps-telemetry] RUM credential refresh failed', + expect.objectContaining({ name: 'Error', message: 'broker down' }) + ); + }); + + it('should cancel the pending refresh on destroy', async () => { + configure({ loadImpl: async () => bootstrap(undefined, 60 * 60 * 1000) }); + await sink.init(); + + sink.ngOnDestroy(); + await jest.advanceTimersByTimeAsync(60 * 60 * 1000); + + expect(load).toHaveBeenCalledTimes(1); + }); + + it('should retry a failed refresh instead of the chain dying permanently', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let calls = 0; + configure({ + loadImpl: async () => { + calls++; + if (calls === 2) { + throw new Error('transient broker blip'); + } + return bootstrap(undefined, 6 * 60 * 1000); + } + }); + + return sink.init().then(async () => { + expect(calls).toBe(1); + + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(calls).toBe(2); + expect(awsRumInstance.setAwsCredentials).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '[cps-telemetry] RUM credential refresh failed', + expect.objectContaining({ + name: 'Error', + message: 'transient broker blip' + }) + ); + + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(calls).toBe(3); + expect(awsRumInstance.setAwsCredentials).toHaveBeenCalledTimes(2); + + warn.mockRestore(); + }); + }); + + it('should not retry when the very first load is unauthenticated (credentials omitted from the start)', async () => { + configure({ + loadImpl: async () => ({ config: bootstrap().config }) + }); + await sink.init(); + + expect(awsRumInstance.setAwsCredentials).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(60 * 60 * 1000); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('should keep retrying, not go silent forever, when an already-authenticated session refreshes into credentials omitted', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let calls = 0; + configure({ + loadImpl: async () => { + calls++; + if (calls === 2) { + return { config: bootstrap().config }; + } + return bootstrap(undefined, 6 * 60 * 1000); + } + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(calls).toBe(2); + expect(awsRumInstance.setAwsCredentials).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '[cps-telemetry] RUM credential refresh', + expect.objectContaining({ + name: 'Error', + message: expect.stringContaining('already-authenticated') + }) + ); + + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(calls).toBe(3); + + warn.mockRestore(); + }); + + it('should disable RUM for the session when a scheduled refresh returns null, per the provider contract', async () => { + let calls = 0; + configure({ + loadImpl: async () => { + calls++; + if (calls === 2) { + return null; + } + return bootstrap(undefined, 6 * 60 * 1000); + } + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(calls).toBe(2); + + // No further refresh or retry should be scheduled once disabled. + await jest.advanceTimersByTimeAsync(60 * 60 * 1000); + expect(calls).toBe(2); + + sink.record('a', {}); + expect(awsRumInstance.recordEvent).not.toHaveBeenCalled(); + }); + + it('should disable the already-constructed AWS RUM client, not just drop the reference, when a scheduled refresh returns null', async () => { + let calls = 0; + configure({ + loadImpl: async () => { + calls++; + if (calls === 2) { + return null; + } + return bootstrap(undefined, 6 * 60 * 1000); + } + }); + await sink.init(); + expect(awsRumInstance.disable).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + + expect(awsRumInstance.disable).toHaveBeenCalled(); + }); + + it('should drop, not buffer, events recorded after being disabled', async () => { + let calls = 0; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + configure({ + loadImpl: async () => { + calls++; + if (calls === 2) { + return null; + } + return bootstrap(undefined, 6 * 60 * 1000); + } + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(calls).toBe(2); + + sink.record('a', {}); + sink.record('b', {}); + sink.flush(); + + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('RUM event(s) lost') + ); + }); + + it('should not arm a new refresh timer if destroyed while a refresh is in flight', async () => { + let resolveSecondLoad!: (bootstrap: CpsRumBootstrap) => void; + let calls = 0; + configure({ + loadImpl: () => { + calls++; + if (calls === 1) { + return Promise.resolve(bootstrap(undefined, 6 * 60 * 1000)); + } + return new Promise((resolve) => { + resolveSecondLoad = resolve; + }); + } + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(calls).toBe(2); + + sink.ngOnDestroy(); + + resolveSecondLoad(bootstrap(undefined, 6 * 60 * 1000)); + await Promise.resolve(); + await Promise.resolve(); + + await jest.advanceTimersByTimeAsync(10 * 60 * 1000); + expect(calls).toBe(2); + }); + + it('should not report a failure for a refresh that rejects after the sink was destroyed', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let rejectSecondLoad!: (error: Error) => void; + let calls = 0; + configure({ + loadImpl: () => { + calls++; + if (calls === 1) { + return Promise.resolve(bootstrap(undefined, 6 * 60 * 1000)); + } + return new Promise((_resolve, reject) => { + rejectSecondLoad = reject; + }); + } + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(calls).toBe(2); + + sink.ngOnDestroy(); + + rejectSecondLoad(new Error('broker down')); + await Promise.resolve(); + await Promise.resolve(); + + expect(warn).not.toHaveBeenCalled(); + }); + }); + + describe('flush before init resolves', () => { + it('should warn in development and not throw when buffered events cannot be flushed', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let resolveLoad!: (bootstrap: CpsRumBootstrap | null) => void; + configure({ + loadImpl: () => + new Promise((resolve) => { + resolveLoad = resolve; + }) + }); + + const initPromise = sink.init(); + sink.record('com.cps.bi', { eventName: 'x' }); + + expect(() => sink.flush(true)).not.toThrow(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('1 RUM event(s) lost') + ); + + resolveLoad(bootstrap()); + await initPromise; + }); + + it('should not warn when the buffer is empty', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let resolveLoad!: (bootstrap: CpsRumBootstrap | null) => void; + configure({ + loadImpl: () => + new Promise((resolve) => { + resolveLoad = resolve; + }) + }); + + const initPromise = sink.init(); + sink.flush(true); + + expect(warn).not.toHaveBeenCalled(); + + resolveLoad(bootstrap()); + await initPromise; + }); + }); +}); diff --git a/projects/cps-telemetry/rum/src/lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink.ts b/projects/cps-telemetry/rum/src/lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink.ts new file mode 100644 index 000000000..9d5852c48 --- /dev/null +++ b/projects/cps-telemetry/rum/src/lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink.ts @@ -0,0 +1,626 @@ +import { DOCUMENT } from '@angular/common'; +import { inject, Injectable, OnDestroy } from '@angular/core'; +import type { AwsRum, AwsRumConfig } from 'aws-rum-web'; +import { + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY, + CpsTelemetryError, + CpsTelemetryMetadata, + CpsTelemetrySink, + cpsNormalizeError +} from 'cps-telemetry'; +import { + cpsIsBrowser, + cpsIsDevMode, + cpsSafe, + cpsSafeVoid, + cpsUuid +} from '../cps-rum-internal.util/cps-rum-internal.util'; +import { + CPS_RUM_CREDENTIALS_PROVIDER, + CpsRumBootstrap, + CpsRumCredentials +} from '../cps-rum-credentials/cps-rum-credentials'; + +/** Refresh credentials this long before they expire. */ +const CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000; + +/** + * Maximum delay supported by setTimeout (2^31 - 1 ms, ~24.85 days). + * Prevents 32-bit signed integer overflow triggering an immediate execution. + */ +const MAX_TIMEOUT_MS = 2_147_483_647; + +/** + * Retry delay after a scheduled refresh fails or returns no credentials. + * Shorter than the normal refresh-ahead-of-expiry interval. + */ +const CREDENTIAL_RETRY_DELAY_MS = 30 * 1000; + +/** + * Events retained while the SDK loads. Bounded so a failed init cannot + * grow the buffer without limit. + */ +const PRE_INIT_BUFFER_LIMIT = 100; + +/** Metadata keys under this prefix are reserved by the RUM client. */ +const RESERVED_PREFIX = 'aws:'; + +interface BufferedEvent { + kind: 'event'; + eventType: string; + payload: object; + metadata?: CpsTelemetryMetadata; +} + +interface BufferedPageView { + kind: 'pageView'; + pageId: string; +} + +interface BufferedError { + kind: 'error'; + error: CpsTelemetryError; + metadata?: CpsTelemetryMetadata; +} + +/** Anything the pre-init buffer holds. */ +type BufferedItem = BufferedEvent | BufferedPageView | BufferedError; + +/** + * Drops every key whose value is `undefined`. + * + * Used to build the SDK config object from optional consumer fields: a key + * present with value `undefined` would overwrite the SDK's own default for + * that key, so unset fields must be absent rather than `undefined`. + */ +function omitUndefined(obj: T): Partial { + const result: Partial = {}; + for (const key of Object.keys(obj) as (keyof T)[]) { + if (obj[key] !== undefined) { + result[key] = obj[key]; + } + } + return result; +} + +/** + * Telemetry sink backed by the AWS CloudWatch RUM web client. + * + * Custom scenario and BI events travel through `recordEvent`, alongside the + * page views, web vitals, JS errors, HTTP failures and resource timing the + * client records on its own. The SDK is imported lazily, so an application + * that never provides this sink never pays for the bundle. Every operation + * is fail-open: if the broker is down, credentials lapse, or the SDK + * throws, the application behaves as if RUM were healthy. + * + * @group Services + */ +@Injectable() +export class CpsRumTelemetrySink extends CpsTelemetrySink implements OnDestroy { + private readonly config = inject(CPS_TELEMETRY_IDENTITY); + private readonly redact = inject(CPS_REDACT_CONFIG); + private readonly credentialsProvider = inject(CPS_RUM_CREDENTIALS_PROVIDER); + private readonly document = inject(DOCUMENT); + private readonly isBrowser = cpsIsBrowser(); + + private awsRum: AwsRum | null = null; + private refreshTimer: ReturnType | null = null; + private buffer: BufferedItem[] = []; + private userId?: string; + /** Set once a refresh returns null/undefined; distinct from `!awsRum` alone. */ + private disabled = false; + /** + * Set once real credentials have ever been applied to `awsRum`. The AWS + * RUM client has no API to clear already-applied credentials, so this + * distinguishes "never authenticated" from "was + * authenticated". + */ + private hasAppliedCredentials = false; + /** + * Memoized so every caller — `provideCpsTelemetrySink('rum')`'s + * `APP_INITIALIZER`, {@link ensureInitialized}, and a caller awaiting + * `init()` directly — awaits the same underlying work, regardless of who + * triggered it first. + */ + private initPromise?: Promise; + /** + * Set once in `ngOnDestroy`. `refreshCredentials()` nulls `refreshTimer` + * before its own `await`, so a destroy during that window would leave a + * new timer nothing could clear. Checked after every await in the + * refresh chain instead. + */ + private destroyed = false; + + /** + * Loads and starts the RUM client. + * + * Fire-and-forget: it must never block bootstrap and never reject into the + * caller. Safe to call more than once; subsequent calls are no-ops. + * + * @returns a promise that resolves once initialization has been attempted + */ + async init(): Promise { + if (!this.isBrowser) { + return; + } + if (!this.initPromise) { + this.initPromise = this.performInit(); + } + return this.initPromise; + } + + private async performInit(): Promise { + try { + const bootstrap = await this.credentialsProvider.load(); + if (!bootstrap?.config) { + this.disableAwsRum(); + this.buffer = []; + return; + } + + const { AwsRum } = await import('aws-rum-web'); + if (this.destroyed) { + return; + } + + this.awsRum = new AwsRum( + bootstrap.config.applicationId, + bootstrap.config.applicationVersion, + bootstrap.config.region, + this.buildRumConfig(bootstrap) + ); + + this.applyIdentity(); + this.applyCredentials(bootstrap.credentials); + this.replayBuffer(); + } catch (error) { + this.disableAwsRum(); + this.buffer = []; + this.reportFailure('RUM init failed, monitoring disabled', error); + } + } + + /** + * Kicks off `init()` on first real use, for a sink bound directly (e.g. + * `{ provide: CpsTelemetrySink, useClass: CpsRumTelemetrySink }`) rather + * than through `provideCpsTelemetrySink('rum')`, which is what normally + * calls `init()` via `APP_INITIALIZER`. `init()` is idempotent and + * memoized, so this is a no-op once already initialized or already in + * flight, and never races with a caller awaiting `init()` directly. + */ + private ensureInitialized(): void { + if (!this.initPromise) { + this.init().catch(() => undefined); + } + } + + /** + * Shared shape behind {@link record}, {@link recordError} and + * {@link recordPageView}: kicks off init, then either buffers `item` + * (pre-init) or hands the live client to `dispatch`, all inside the same + * fail-open guard. + */ + private recordOrBuffer( + operation: string, + item: BufferedItem, + dispatch: (awsRum: AwsRum) => void + ): void { + this.ensureInitialized(); + cpsSafeVoid(operation, () => { + if (!this.isBrowser || this.disabled) { + return; + } + + const awsRum = this.awsRum; + if (!awsRum) { + this.bufferItem(item); + return; + } + + dispatch(awsRum); + }); + } + + /** @inheritdoc */ + record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void { + this.recordOrBuffer( + 'rum.record', + { kind: 'event', eventType, payload, metadata }, + (awsRum) => + awsRum.recordEvent(eventType, payload, this.sanitize(metadata)) + ); + } + + /** @inheritdoc */ + recordError(error: CpsTelemetryError, metadata?: CpsTelemetryMetadata): void { + this.recordOrBuffer( + 'rum.recordError', + { kind: 'error', error, metadata }, + (awsRum) => awsRum.recordError(this.withOrigin(error, metadata)) + ); + } + + /** @inheritdoc */ + getSessionId(): string | undefined { + return cpsSafe( + 'rum.getSessionId', + () => this.awsRum?.getSessionId(), + undefined + ); + } + + /** + * Associates subsequent events with a user, or with nobody. + * + * Signing in pins the id, and works even before `init()` resolves. + * Signing out starts a fresh session with a new anonymous id (`pinUserId` + * has no inverse), which emits a `session_start` event and re-rolls + * sampling. + * + * @param userId the application's own user identifier, or `undefined` on + * sign-out + */ + setUserId(userId: string | undefined): void { + cpsSafeVoid('rum.setUserId', () => { + this.userId = userId; + + if (userId) { + this.awsRum?.pinUserId(userId); + return; + } + + this.awsRum?.startSession({ userId: cpsUuid() }); + }); + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(beacon = false): void { + cpsSafeVoid('rum.flush', () => { + if (!this.awsRum) { + if (this.buffer.length > 0 && cpsIsDevMode()) { + // eslint-disable-next-line no-console + console.warn( + `[cps-telemetry] ${this.buffer.length} RUM event(s) lost: page unloaded before RUM finished initializing` + ); + } + return; + } + + if (beacon) { + this.awsRum.dispatchBeacon(); + } else { + this.awsRum.dispatch(); + } + }); + } + + /** + * Records a page view. + * + * The RUM client already records a page view on every navigation via + * `history.pushState`, covering Angular's router automatically. Use this + * to report a different page id (e.g. a route template instead of the + * resolved URL); also set `disableAutoPageView: true` in the RUM config + * to avoid recording the navigation twice. Buffered like {@link record} + * when called before `init()` resolves. + * + * @param pageId the page identity, e.g. `/customers/:id` + */ + recordPageView(pageId: string): void { + this.recordOrBuffer( + 'rum.recordPageView', + { kind: 'pageView', pageId }, + (awsRum) => awsRum.recordPageView(pageId) + ); + } + + /** @inheritdoc */ + ngOnDestroy(): void { + this.destroyed = true; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.disableAwsRum(); + } + + /** + * Disables the sink: stops the AWS RUM client's own listeners, plugins, + * and dispatch timer before dropping the reference — clearing `awsRum` + * alone only stops this class from talking to it; the already-constructed + * instance would otherwise keep running orphaned in the page (and, on + * destroy, could duplicate telemetry if the app is bootstrapped again in + * the same one) — and marks the session disabled, so every call site that + * tears the client down does so atomically with the flag `recordOrBuffer` + * checks, instead of each caller having to remember to set both. + */ + private disableAwsRum(): void { + cpsSafeVoid('rum.disable', () => this.awsRum?.disable()); + this.awsRum = null; + this.disabled = true; + } + + /** + * Maps {@link CpsRumAppMonitorConfig} onto the SDK's own `PartialConfig`. + * + * The SDK merges this object over its own defaults with a shallow + * `Object.assign`, so a key present with value `undefined` overwrites the + * SDK's default instead of falling through to it. `allowCookies`, + * `enableXRay`, `endpoint` and `telemetries` always carry this library's + * own override; every other field goes through {@link omitUndefined} (or, + * for `client`, a conditional spread) so an unset field is absent and the + * SDK's own default applies. + */ + private buildRumConfig(bootstrap: CpsRumBootstrap): AwsRumConfig { + const { config } = bootstrap; + const origin = this.document.defaultView?.location?.origin ?? ''; + + // X-Ray trace header only on same-origin requests; a cross-origin + // request with an unexpected header fails CORS preflight. Anchored at + // both ends (with a path/query/fragment/end-of-string lookahead) so + // `https://example.com` doesn't also match a look-alike host like + // `https://example.com.attacker.example`. + const sameOrigin = new RegExp( + '^' + origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[/?#]|$)' + ); + + return { + allowCookies: config.allowCookies ?? true, + enableXRay: config.enableXRay ?? true, + endpoint: + config.endpoint ?? + `https://dataplane.rum.${config.region}.amazonaws.com`, + telemetries: config.telemetries ?? [ + 'errors', + 'performance', + ['http', { addXRayTraceIdHeader: [sameOrigin] }] + ], + + ...(config.client !== undefined ? { client: config.client } : {}), + + ...omitUndefined({ + releaseId: config.releaseId, + clientBuilder: config.clientBuilder, + applicationAttributes: config.applicationAttributes, + alias: config.alias, + headers: config.headers, + cookieAttributes: config.cookieAttributes, + sessionSampleRate: config.sessionSampleRate, + sessionLengthSeconds: config.sessionLengthSeconds, + sessionEventLimit: config.sessionEventLimit, + suppressSessionStartEvent: config.suppressSessionStartEvent, + userIdRetentionDays: config.userIdRetentionDays, + sessionAttributes: config.sessionAttributes, + eventPluginsToLoad: config.eventPluginsToLoad, + enableW3CTraceId: config.enableW3CTraceId, + recordResourceUrl: config.recordResourceUrl, + batchLimit: config.batchLimit, + dispatchInterval: config.dispatchInterval, + eventCacheSize: config.eventCacheSize, + candidatesCacheSize: config.candidatesCacheSize, + retries: config.retries, + useBeacon: config.useBeacon, + signing: config.signing, + compressionStrategy: config.compressionStrategy, + fetchFunction: config.fetchFunction, + pageIdFormat: config.pageIdFormat, + pagesToInclude: config.pagesToInclude, + pagesToExclude: config.pagesToExclude, + routeChangeComplete: config.routeChangeComplete, + routeChangeTimeout: config.routeChangeTimeout, + disableAutoPageView: config.disableAutoPageView, + debug: config.debug, + enableRumClient: config.enableRumClient + }) + } as unknown as AwsRumConfig; + } + + /** + * Attaches application identity once per session rather than once per event. + * + * `addSessionAttributes` covers custom events; the metadata hook stamps + * the same attributes onto the client's own built-in events too. + * + * See {@link CpsRumAppMonitorConfig.sessionAttributes} for how this + * merges with a consumer-supplied base. + */ + private applyIdentity(): void { + if (!this.awsRum) { + return; + } + + const attributes = { + application: this.config.application, + environment: this.config.environment, + appVersion: this.config.version + }; + + this.awsRum.addSessionAttributes(attributes); + this.awsRum.setEventMetadataHook(() => attributes); + + if (this.userId) { + this.awsRum.pinUserId(this.userId); + } + } + + private applyCredentials(credentials?: CpsRumCredentials): void { + if (!this.awsRum || !credentials) { + return; + } + + this.awsRum.setAwsCredentials({ + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken + }); + this.hasAppliedCredentials = true; + + this.scheduleRefresh(credentials.expiration); + } + + private scheduleRefresh(expiration: string): void { + const delay = + new Date(expiration).getTime() - Date.now() - CREDENTIAL_REFRESH_SKEW_MS; + + if (!Number.isFinite(delay) || delay <= 0) { + // Avoid tight-looping on an already-expired/unparsable expiration. + this.scheduleRetry(); + return; + } + + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout( + () => { + this.refreshCredentials().catch(() => undefined); + }, + Math.min(delay, MAX_TIMEOUT_MS) + ); + } + + private async refreshCredentials(): Promise { + this.refreshTimer = null; + try { + const bootstrap = await this.credentialsProvider.load(); + if (this.destroyed) { + return; + } + + if (!bootstrap) { + // null/undefined disables RUM for the session (see load()'s contract). + this.disableAwsRum(); + return; + } + + // Omitted credentials mean unauthenticated access, not a failure — + // but only for a session that was never authenticated. + if (bootstrap.credentials) { + this.applyCredentials(bootstrap.credentials); + } else if (this.hasAppliedCredentials) { + this.scheduleRetry(); + this.reportFailure( + 'RUM credential refresh', + new Error( + 'bootstrap omitted credentials for an already-authenticated ' + + 'session; keeping existing credentials and retrying' + ) + ); + } + } catch (error) { + if (!this.destroyed) { + this.scheduleRetry(); + this.reportFailure('RUM credential refresh failed', error); + } + } + } + + private scheduleRetry(): void { + if (this.destroyed) { + return; + } + + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(() => { + this.refreshCredentials().catch(() => undefined); + }, CREDENTIAL_RETRY_DELAY_MS); + } + + /** + * Reports an init/refresh failure without leaking raw error content into + * the production console. + */ + private reportFailure(operation: string, error: unknown): void { + if (!cpsIsDevMode()) { + return; + } + cpsSafeVoid('rum.reportFailure', () => { + // eslint-disable-next-line no-console + console.warn( + `[cps-telemetry] ${operation}`, + cpsNormalizeError(error, this.redact) + ); + }); + } + + private bufferItem(item: BufferedItem): void { + this.buffer.push(item); + if (this.buffer.length > PRE_INIT_BUFFER_LIMIT) { + this.buffer.shift(); + } + } + + private replayBuffer(): void { + const pending = this.buffer; + this.buffer = []; + + for (const item of pending) { + cpsSafeVoid('rum.replay', () => { + if (item.kind === 'event') { + this.awsRum?.recordEvent( + item.eventType, + item.payload, + this.sanitize(item.metadata) + ); + } else if (item.kind === 'pageView') { + this.awsRum?.recordPageView(item.pageId); + } else { + this.awsRum?.recordError(this.withOrigin(item.error, item.metadata)); + } + }); + } + } + + /** + * Drops metadata keys under the reserved `aws:` prefix, which the client + * would otherwise discard with a console warning. Also drops `null` + * values, which {@link CpsTelemetryMetadata} permits but the SDK's + * metadata type does not. + */ + private sanitize( + metadata?: CpsTelemetryMetadata + ): Record | undefined { + if (!metadata) { + return undefined; + } + + const result: Record = {}; + for (const [key, value] of Object.entries(metadata)) { + if (key.startsWith(RESERVED_PREFIX) || value === null) { + continue; + } + result[key] = value; + } + + return Object.keys(result).length ? result : undefined; + } + + /** + * Folds a forwarded error's origin into its name. `AwsRum.recordError` + * takes no metadata argument, so `name` is the only field left to carry it. + */ + private withOrigin( + error: CpsTelemetryError, + metadata?: CpsTelemetryMetadata + ): CpsTelemetryError { + const origin = metadata?.application; + if (typeof origin !== 'string' || origin === this.config.application) { + return error; + } + return { ...error, name: `[${origin}] ${error.name}` }; + } +} diff --git a/projects/cps-telemetry/rum/src/lib/cps-rum.providers/cps-rum.providers.spec.ts b/projects/cps-telemetry/rum/src/lib/cps-rum.providers/cps-rum.providers.spec.ts new file mode 100644 index 000000000..2dc224609 --- /dev/null +++ b/projects/cps-telemetry/rum/src/lib/cps-rum.providers/cps-rum.providers.spec.ts @@ -0,0 +1,91 @@ +import { ApplicationInitStatus, Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + CpsScenarioTelemetryService, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { + CPS_RUM_CREDENTIALS_PROVIDER, + CpsRumBootstrap, + CpsRumCredentialsProvider +} from '../cps-rum-credentials/cps-rum-credentials'; +import { CpsRumTelemetrySink } from '../cps-rum-telemetry.sink/cps-rum-telemetry.sink'; +import { provideCpsTelemetryRumSink } from './cps-rum.providers'; + +jest.mock('aws-rum-web', () => ({ AwsRum: class {} }), { virtual: true }); + +/** Declines every load — the documented session-disable signal. */ +@Injectable() +class StubCredentialsProvider implements CpsRumCredentialsProvider { + async load(): Promise { + return null; + } +} + +describe('provideCpsTelemetryRumSink', () => { + function configure(): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'my-app', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetryRumSink(), + { + provide: CPS_RUM_CREDENTIALS_PROVIDER, + useClass: StubCredentialsProvider + } + ] + }); + } + + beforeEach(() => configure()); + + it('should replace the default sink with the RUM sink', () => { + expect(TestBed.inject(CpsTelemetrySink)).toBeInstanceOf( + CpsRumTelemetrySink + ); + }); + + it('should resolve the sink token and the concrete class to one instance', () => { + expect(TestBed.inject(CpsTelemetrySink)).toBe( + TestBed.inject(CpsRumTelemetrySink) + ); + }); + + it("should call the sink's init() automatically via app initialization", async () => { + const initSpy = jest.spyOn(CpsRumTelemetrySink.prototype, 'init'); + + await TestBed.inject(ApplicationInitStatus).donePromise; + + expect(initSpy).toHaveBeenCalled(); + initSpy.mockRestore(); + }); + + it('should leave application code unchanged, the same as broadcast/noop', () => { + const scenario = TestBed.inject(CpsScenarioTelemetryService).start({ + name: 'add-to-cart' + }); + + expect(() => scenario.step('one').complete()).not.toThrow(); + expect(scenario.status).toBe('success'); + }); + + it('should report a settled scenario as lost, not silently dropped, once buffered telemetry is flushed at teardown', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + TestBed.inject(CpsScenarioTelemetryService) + .start({ name: 'add-to-cart' }) + .step('one') + .complete(); + + TestBed.resetTestingModule(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('RUM event(s) lost') + ); + }); +}); diff --git a/projects/cps-telemetry/rum/src/lib/cps-rum.providers/cps-rum.providers.ts b/projects/cps-telemetry/rum/src/lib/cps-rum.providers/cps-rum.providers.ts new file mode 100644 index 000000000..c5ffeb763 --- /dev/null +++ b/projects/cps-telemetry/rum/src/lib/cps-rum.providers/cps-rum.providers.ts @@ -0,0 +1,52 @@ +import { + EnvironmentProviders, + inject, + makeEnvironmentProviders, + provideAppInitializer +} from '@angular/core'; +import { CpsTelemetrySink } from 'cps-telemetry'; +import { CpsRumTelemetrySink } from '../cps-rum-telemetry.sink/cps-rum-telemetry.sink'; + +/** + * Sends telemetry straight to AWS CloudWatch RUM. Requires + * {@link CPS_RUM_CREDENTIALS_PROVIDER}. + * + * Exported from a separate entry point (`cps-telemetry/rum`), not the main + * one, on purpose: `aws-rum-web` is an optional peer dependency, and an + * application that only ever calls `provideCpsTelemetrySink('broadcast' | + * 'noop')` must never be required to install it. Keeping `CpsRumTelemetrySink` + * (and this function) out of the main entry point's module graph is what + * makes that true — a bundler resolves a dynamic `import()`'s specifier at + * build time regardless of whether that branch ever runs, so a static + * import of the RUM sink anywhere in the common providers module would + * force every consumer to have `aws-rum-web` resolvable, not just the ones + * that select `'rum'`. See DESIGN.md §3, "Entry points". + * + * @example + * ```typescript + * import { provideCpsTelemetryRumSink } from 'cps-telemetry/rum'; + * + * providers: [ + * provideCpsTelemetry({ application: 'cart', environment, version }), + * provideCpsTelemetryRumSink(), + * { provide: CPS_RUM_CREDENTIALS_PROVIDER, useExisting: CartRumCredentials } + * ] + * ``` + * + * @returns providers wiring the RUM sink + * + * @group Utils + */ +export function provideCpsTelemetryRumSink(): EnvironmentProviders { + return makeEnvironmentProviders([ + CpsRumTelemetrySink, + { provide: CpsTelemetrySink, useExisting: CpsRumTelemetrySink }, + provideAppInitializer(() => { + // Not returned, so a slow or hung credential broker doesn't delay + // first paint. + inject(CpsRumTelemetrySink) + .init() + .catch(() => undefined); + }) + ]); +} diff --git a/projects/cps-telemetry/rum/src/public-api.ts b/projects/cps-telemetry/rum/src/public-api.ts new file mode 100644 index 000000000..c2ff3e33b --- /dev/null +++ b/projects/cps-telemetry/rum/src/public-api.ts @@ -0,0 +1,19 @@ +/* + * Public API Surface of cps-telemetry/rum + * + * A separate entry point from `cps-telemetry` itself, so that an + * application using only `provideCpsTelemetrySink('broadcast' | 'noop')` + * never needs `aws-rum-web` (an optional peer dependency) resolvable at + * build time — see `provideCpsTelemetryRumSink`'s own doc comment, and + * DESIGN.md §3, "Entry points". + */ + +export { provideCpsTelemetryRumSink } from './lib/cps-rum.providers/cps-rum.providers'; +export { + CPS_RUM_CREDENTIALS_PROVIDER, + CpsRumAppMonitorConfig, + CpsRumBootstrap, + CpsRumCredentials, + CpsRumCredentialsProvider +} from './lib/cps-rum-credentials/cps-rum-credentials'; +export { CpsRumTelemetrySink } from './lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink'; diff --git a/projects/cps-telemetry/rum/tsconfig.lib.json b/projects/cps-telemetry/rum/tsconfig.lib.json new file mode 100644 index 000000000..f8fd390a8 --- /dev/null +++ b/projects/cps-telemetry/rum/tsconfig.lib.json @@ -0,0 +1,25 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib-rum", + "rootDir": "..", + "lib": ["ES2022", "DOM"], + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"], + "angularCompilerOptions": { + "extendedDiagnostics": { + "checks": { + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + } + } +} diff --git a/projects/cps-telemetry/rum/tsconfig.spec.json b/projects/cps-telemetry/rum/tsconfig.spec.json new file mode 100644 index 000000000..b8b95e2a0 --- /dev/null +++ b/projects/cps-telemetry/rum/tsconfig.spec.json @@ -0,0 +1,11 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/spec-rum", + "rootDir": "..", + "types": ["jest"], + "resolveJsonModule": true + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/projects/cps-telemetry/src/lib/config/cps-bi.config/cps-bi.config.spec.ts b/projects/cps-telemetry/src/lib/config/cps-bi.config/cps-bi.config.spec.ts new file mode 100644 index 000000000..0b5860620 --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-bi.config/cps-bi.config.spec.ts @@ -0,0 +1,11 @@ +import { CPS_DEFAULT_BI_CONFIG } from './cps-bi.config'; + +describe('CPS_DEFAULT_BI_CONFIG', () => { + it('should default the dedup window and key cap', () => { + expect(CPS_DEFAULT_BI_CONFIG).toEqual({ + dedupWindowMs: 400, + dedupMaxKeys: 100, + redact: true + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/config/cps-bi.config/cps-bi.config.ts b/projects/cps-telemetry/src/lib/config/cps-bi.config/cps-bi.config.ts new file mode 100644 index 000000000..60cadb9fa --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-bi.config/cps-bi.config.ts @@ -0,0 +1,44 @@ +import { InjectionToken } from '@angular/core'; + +/** + * Business and UX event tracking configuration. + * + * @group Interfaces + */ +export interface CpsBiConfig { + /** Milliseconds within which an identical event is treated as a double-fire. */ + dedupWindowMs: number; + + /** + * Distinct event keys tracked before expired ones are swept. + * + * Keyed per `eventName|scenarioId|eventType|feature|metadata`, which + * grows unbounded over a long session without this cap. + */ + dedupMaxKeys: number; + + /** + * Whether redaction runs on BI events. On by default. Turning it off + * skips only the *configurable* PII scrubbing (`extraKeyPatterns`, + * value-pattern scanning, URL-query stripping) — the built-in credential + * denylist, size caps, error normalization and any + * `CpsRedactConfig.extraValueTransforms` still apply; see + * {@link cpsRedactConfigFor}. + */ + redact: boolean; +} + +/** Default BI event tracking settings. */ +export const CPS_DEFAULT_BI_CONFIG: CpsBiConfig = { + dedupWindowMs: 400, + dedupMaxKeys: 100, + redact: true +}; + +/** + * Resolved BI event configuration. Provided by {@link provideCpsTelemetry}, + * overridden with `withBiEvents(...)`. + * + * @group Tokens + */ +export const CPS_BI_CONFIG = new InjectionToken('CPS_BI_CONFIG'); diff --git a/projects/cps-telemetry/src/lib/config/cps-log.config/cps-log.config.spec.ts b/projects/cps-telemetry/src/lib/config/cps-log.config/cps-log.config.spec.ts new file mode 100644 index 000000000..84926e86b --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-log.config/cps-log.config.spec.ts @@ -0,0 +1,15 @@ +import { CPS_DEFAULT_LOG_CONFIG } from './cps-log.config'; + +describe('CPS_DEFAULT_LOG_CONFIG', () => { + it('should keep RUM error mirroring off by default', () => { + expect(CPS_DEFAULT_LOG_CONFIG.mirrorErrorsToRum).toBe(false); + }); + + it('should default the minimum level to log', () => { + expect(CPS_DEFAULT_LOG_CONFIG.minLevel).toBe('log'); + }); + + it('should keep redaction on by default', () => { + expect(CPS_DEFAULT_LOG_CONFIG.redact).toBe(true); + }); +}); diff --git a/projects/cps-telemetry/src/lib/config/cps-log.config/cps-log.config.ts b/projects/cps-telemetry/src/lib/config/cps-log.config/cps-log.config.ts new file mode 100644 index 000000000..290acdc89 --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-log.config/cps-log.config.ts @@ -0,0 +1,62 @@ +import { InjectionToken } from '@angular/core'; +import { + CpsLoggerName, + CpsLogLevel +} from '../../models/cps-log.models/cps-log.models'; + +/** + * Application logging configuration. + * + * @group Interfaces + */ +export interface CpsLogConfig { + /** Records below this severity are discarded. */ + minLevel: CpsLogLevel; + + /** + * Per-logger overrides of {@link minLevel}, keyed by logger name. Loggers + * without an override use {@link minLevel}. + * + * ```typescript + * withLogging({ minLevel: 'warn', levels: { checkout: 'log' } }) + * ``` + */ + levels?: Partial>; + + /** + * Also report `logger.error` calls to the telemetry sink as RUM errors. Off + * by default; competes for the same session event budget as scenario and + * BI data. + * + * Mirrors `detail.error` when supplied; a message-only call builds a + * synthetic `Error` from the redacted message text instead. + */ + mirrorErrorsToRum: boolean; + + /** + * Whether redaction runs on log records. On by default. Turning it off + * skips only the *configurable* PII scrubbing (`extraKeyPatterns`, + * value-pattern scanning, URL-query stripping) — the built-in credential + * denylist, size caps, error normalization and any + * `CpsRedactConfig.extraValueTransforms` still apply; see + * {@link cpsRedactConfigFor}. + */ + redact: boolean; +} + +/** Default application logging settings. */ +export const CPS_DEFAULT_LOG_CONFIG: CpsLogConfig = { + minLevel: 'log', + mirrorErrorsToRum: false, + redact: true +}; + +/** + * Resolved logging configuration. Provided by {@link provideCpsTelemetry}, + * overridden with `withLogging(...)`. + * + * @group Tokens + */ +export const CPS_LOG_CONFIG = new InjectionToken( + 'CPS_LOG_CONFIG' +); diff --git a/projects/cps-telemetry/src/lib/config/cps-scenario.config/cps-scenario.config.spec.ts b/projects/cps-telemetry/src/lib/config/cps-scenario.config/cps-scenario.config.spec.ts new file mode 100644 index 000000000..df3eb1737 --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-scenario.config/cps-scenario.config.spec.ts @@ -0,0 +1,24 @@ +import { CPS_DEFAULT_SCENARIO_CONFIG } from './cps-scenario.config'; + +describe('CPS_DEFAULT_SCENARIO_CONFIG', () => { + it('should keep verbose lifecycle emission off by default', () => { + expect(CPS_DEFAULT_SCENARIO_CONFIG.emitLifecycleEvents).toBe(false); + }); + + it('should default to a 30s timeout and a 50-step cap', () => { + expect(CPS_DEFAULT_SCENARIO_CONFIG.defaultTimeoutMs).toBe(30_000); + expect(CPS_DEFAULT_SCENARIO_CONFIG.maxSteps).toBe(50); + }); + + it('should keep User Timings off by default', () => { + expect(CPS_DEFAULT_SCENARIO_CONFIG.userTimings).toBe(false); + }); + + it('should default the mark cleanup fallback to 5 minutes', () => { + expect(CPS_DEFAULT_SCENARIO_CONFIG.markCleanupFallbackMs).toBe(300_000); + }); + + it('should keep redaction on by default', () => { + expect(CPS_DEFAULT_SCENARIO_CONFIG.redact).toBe(true); + }); +}); diff --git a/projects/cps-telemetry/src/lib/config/cps-scenario.config/cps-scenario.config.ts b/projects/cps-telemetry/src/lib/config/cps-scenario.config/cps-scenario.config.ts new file mode 100644 index 000000000..f9bd08b2b --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-scenario.config/cps-scenario.config.ts @@ -0,0 +1,78 @@ +import { InjectionToken } from '@angular/core'; + +/** + * Scenario telemetry configuration. + * + * @group Interfaces + */ +export interface CpsScenarioConfig { + /** + * Milliseconds after which an unsettled scenario auto-settles as `timeout`. + * `0` disables the default; individual scenarios can still opt in. + */ + defaultTimeoutMs: number; + + /** + * Emit an event per lifecycle transition instead of one packed event at + * settlement. Off by default. + * + * Counts against the RUM session's shared `sessionEventLimit` (200 by + * default). Suited to local debugging, not production traffic. + */ + emitLifecycleEvents: boolean; + + /** + * Maximum number of steps retained per scenario. Further steps are counted in + * `stepCount` but not stored, bounding the payload of a runaway loop. + */ + maxSteps: number; + + /** + * Mirror scenario and step boundaries into `performance.mark` / `measure`, + * visible on the DevTools Performance track. Off by default. + * + * The `debugScenario` LocalStorage flag switches them on regardless of this + * setting. Marks are cleared when a scenario settles; see + * {@link markCleanupFallbackMs} for scenarios that never do. + */ + userTimings: boolean; + + /** + * How long in milliseconds an unsettled scenario's User Timing marks can + * remain in the Performance buffer before a fallback clears them. + * + * Only applies when `userTimings` is enabled and `timeoutMs: 0`. `0` + * disables the fallback, so marks accumulate for the page's lifetime. + */ + markCleanupFallbackMs: number; + + /** + * Whether redaction runs on scenario records. On by default. Turning it + * off skips only the *configurable* PII scrubbing (`extraKeyPatterns`, + * value-pattern scanning, URL-query stripping) — the built-in credential + * denylist, size caps, error normalization and any + * `CpsRedactConfig.extraValueTransforms` still apply; see + * {@link cpsRedactConfigFor}. + */ + redact: boolean; +} + +/** Default scenario telemetry settings. */ +export const CPS_DEFAULT_SCENARIO_CONFIG: CpsScenarioConfig = { + defaultTimeoutMs: 30_000, + emitLifecycleEvents: false, + maxSteps: 50, + userTimings: false, + markCleanupFallbackMs: 300_000, + redact: true +}; + +/** + * Resolved scenario telemetry configuration. Provided by + * {@link provideCpsTelemetry}, overridden with `withScenarios(...)`. + * + * @group Tokens + */ +export const CPS_SCENARIO_CONFIG = new InjectionToken( + 'CPS_SCENARIO_CONFIG' +); diff --git a/projects/cps-telemetry/src/lib/config/cps-telemetry-common.config/cps-telemetry-common.config.spec.ts b/projects/cps-telemetry/src/lib/config/cps-telemetry-common.config/cps-telemetry-common.config.spec.ts new file mode 100644 index 000000000..eceb82c30 --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-telemetry-common.config/cps-telemetry-common.config.spec.ts @@ -0,0 +1,18 @@ +import { CPS_DEFAULT_BI_CONFIG } from '../cps-bi.config/cps-bi.config'; +import { CPS_DEFAULT_LOG_CONFIG } from '../cps-log.config/cps-log.config'; +import { CPS_DEFAULT_SCENARIO_CONFIG } from '../cps-scenario.config/cps-scenario.config'; +import { CPS_DEFAULT_TELEMETRY_CONFIG } from './cps-telemetry-common.config'; + +describe('CPS_DEFAULT_TELEMETRY_CONFIG', () => { + it('should default the event namespace to com.cps', () => { + expect(CPS_DEFAULT_TELEMETRY_CONFIG.eventNamespace).toBe('com.cps'); + }); + + it("should compose each concern's own default", () => { + expect(CPS_DEFAULT_TELEMETRY_CONFIG.scenario).toBe( + CPS_DEFAULT_SCENARIO_CONFIG + ); + expect(CPS_DEFAULT_TELEMETRY_CONFIG.logs).toBe(CPS_DEFAULT_LOG_CONFIG); + expect(CPS_DEFAULT_TELEMETRY_CONFIG.bi).toBe(CPS_DEFAULT_BI_CONFIG); + }); +}); diff --git a/projects/cps-telemetry/src/lib/config/cps-telemetry-common.config/cps-telemetry-common.config.ts b/projects/cps-telemetry/src/lib/config/cps-telemetry-common.config/cps-telemetry-common.config.ts new file mode 100644 index 000000000..a3d2f3400 --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-telemetry-common.config/cps-telemetry-common.config.ts @@ -0,0 +1,84 @@ +import { InjectionToken } from '@angular/core'; +import { + CpsBiConfig, + CPS_DEFAULT_BI_CONFIG +} from '../cps-bi.config/cps-bi.config'; +import { + CpsLogConfig, + CPS_DEFAULT_LOG_CONFIG +} from '../cps-log.config/cps-log.config'; +import { + CpsScenarioConfig, + CPS_DEFAULT_SCENARIO_CONFIG +} from '../cps-scenario.config/cps-scenario.config'; +import { CPS_DEFAULT_EVENT_NAMESPACE } from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { + CPS_DEFAULT_REDACT_CONFIG, + CpsRedactConfig +} from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; + +/** + * The identity every telemetry record and event carries — application name, + * deployment environment and version, plus the event-type namespace. + * + * @group Interfaces + */ +export interface CpsTelemetryIdentity { + /** Application name, e.g. `composition`. Becomes a session attribute. */ + application: string; + + /** Deployment environment, e.g. `dev`, `qa`, `prod`. */ + environment: string; + + /** Application version, e.g. `22.0.0`. */ + version: string; + + /** + * Prefix for the custom event types this library emits — `{namespace}.scenario`, + * `{namespace}.scenario.step` and `{namespace}.bi`. + * + * Defaults to `com.cps`. + */ + eventNamespace?: string; +} + +/** + * Library defaults for every concern not carried by {@link CpsTelemetryIdentity} + * — applied by {@link provideCpsTelemetry} to anything not overridden through a + * `with*()` feature. + * + * @group Utils + */ +export const CPS_DEFAULT_TELEMETRY_CONFIG: { + eventNamespace: string; + scenario: CpsScenarioConfig; + logs: CpsLogConfig; + bi: CpsBiConfig; + redact: CpsRedactConfig; +} = { + eventNamespace: CPS_DEFAULT_EVENT_NAMESPACE, + scenario: CPS_DEFAULT_SCENARIO_CONFIG, + logs: CPS_DEFAULT_LOG_CONFIG, + bi: CPS_DEFAULT_BI_CONFIG, + redact: CPS_DEFAULT_REDACT_CONFIG +}; + +/** + * The application's identity — shared by every concern; see + * {@link CpsTelemetryIdentity}. + * + * @group Tokens + */ +export const CPS_TELEMETRY_IDENTITY = new InjectionToken( + 'CPS_TELEMETRY_IDENTITY' +); + +/** + * Resolved redaction configuration, shared by every concern. Provided by + * {@link provideCpsTelemetry}, overridden with `withRedaction(...)`. + * + * @group Tokens + */ +export const CPS_REDACT_CONFIG = new InjectionToken( + 'CPS_REDACT_CONFIG' +); diff --git a/projects/cps-telemetry/src/lib/models/cps-bi.models/cps-bi.models.ts b/projects/cps-telemetry/src/lib/models/cps-bi.models/cps-bi.models.ts new file mode 100644 index 000000000..fa65b2419 --- /dev/null +++ b/projects/cps-telemetry/src/lib/models/cps-bi.models/cps-bi.models.ts @@ -0,0 +1,57 @@ +import { CpsTelemetryMetadata } from '../cps-telemetry-common.models/cps-telemetry-common.models'; + +/** + * Correlation fields carried through unchanged onto the emitted + * {@link CpsBiEvent} — shared by {@link CpsBiEventDetail} and + * {@link CpsBiEvent} so neither declares its own copy. + * + * @group Interfaces + */ +export interface CpsBiEventCorrelation { + /** Scenario this event happened inside. Pass {@link CpsScenario.id}. */ + scenarioId?: string; + + /** Product area, e.g. `customers`. Scrubbed the same as any other string value. */ + feature?: string; +} + +/** + * Optional correlation detail for a business/UX event. + * + * @group Interfaces + */ +export interface CpsBiEventDetail extends CpsBiEventCorrelation { + /** + * Overrides the RUM event type for this one event. BI events normally + * share one type, with `eventName` as a field. Use this only when an + * existing dashboard or metric filter needs a specific event type. + */ + eventType?: string; +} + +/** + * A discrete business or UX event. + * + * Browser, device and page attributes come from the RUM envelope and aren't + * repeated here — the client already stamps `pageId`/`pageUrl` on every + * event, so a `route` field would just duplicate that. `application` is + * carried anyway, for a self-describing record; see {@link CpsScenarioRecord}. + * + * @group Interfaces + */ +export interface CpsBiEvent extends CpsBiEventCorrelation { + /** + * Event name, e.g. `export_clicked`. Supplied by the application — this + * library never hardcodes business event names. + */ + eventName: string; + + /** ISO-8601 timestamp. */ + eventTime: string; + + /** Redacted structured attributes. */ + metadata?: CpsTelemetryMetadata; + + /** Application name, from {@link CpsTelemetryIdentity.application}. */ + application: string; +} diff --git a/projects/cps-telemetry/src/lib/models/cps-log.models/cps-log.models.ts b/projects/cps-telemetry/src/lib/models/cps-log.models/cps-log.models.ts new file mode 100644 index 000000000..425adf047 --- /dev/null +++ b/projects/cps-telemetry/src/lib/models/cps-log.models/cps-log.models.ts @@ -0,0 +1,138 @@ +import { + CpsRegistered, + CpsTelemetryError, + CpsTelemetryMetadata +} from '../cps-telemetry-common.models/cps-telemetry-common.models'; + +/** + * Registry of this application's logger names. + * + * A logger name says which part of the application a record came from, and + * selects its per-logger level. Declaring the vocabulary turns a typo'd + * name into a compile error instead of a filter that silently never matches. + * + * @example + * ```typescript + * // src/app/telemetry.schema.ts + * declare module 'cps-telemetry' { + * interface CpsLoggerNames { + * checkout: true; + * admin: true; + * } + * } + * export {}; + * ``` + * + * @group Interfaces + */ +// Empty by design — see CpsScenarioNames in cps-scenario.models.ts. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface CpsLoggerNames {} + +/** + * Every logger name this application declares. + * + * Resolves to `string` until {@link CpsLoggerNames} is augmented. + * + * @group Types + */ +export type CpsLoggerName = CpsRegistered; + +/** + * Severity of a log record. Ordered: `log` < `warn` < `error`. + * + * @group Types + */ +export type CpsLogLevel = 'log' | 'warn' | 'error'; + +/** + * Numeric ordering used to apply {@link CpsLogConfig.minLevel}. + * + * @group Types + */ +export const CPS_LOG_LEVEL_ORDER: Record = { + log: 0, + warn: 1, + error: 2 +}; + +/** + * Optional per-call detail accepted by the logger. + * + * Every field is optional — `logger.log('message')` is a valid call. + * + * @group Interfaces + */ +export interface CpsLogDetail { + /** + * Which named logger this record belongs to, declared in + * {@link CpsLoggerNames}. Bind it once with {@link CpsLoggerService.getLogger} + * instead of passing it per call. + */ + logger?: CpsLoggerName; + + /** + * Free-form subsystem label, e.g. a service or component name, for + * filtering logs without parsing the message. Scrubbed like `message`. + */ + context?: string; + + /** Structured attributes. Redacted before leaving the browser. */ + metadata?: CpsTelemetryMetadata; + + /** Thrown value. Normalized via {@link cpsNormalizeError}. */ + error?: unknown; + + /** + * Identifier joining this log line to other telemetry. Pass a + * {@link CpsScenario.id} to correlate with scenario telemetry, or use + * {@link CpsLoggerService.child} to bind it once. + */ + correlationId?: string; +} + +/** + * The structured record handed to the application's {@link CpsLogApiProvider}. + * + * @group Interfaces + */ +export interface CpsLogRecord { + /** ISO-8601 timestamp of the moment the log call was made. */ + timestamp: string; + + /** Severity. */ + level: CpsLogLevel; + + /** Human-readable message. Redacted and length-capped. */ + message: string; + + /** Named logger this record came from, when it came from one. */ + logger?: CpsLoggerName; + + /** Subsystem label, when supplied. Redacted and length-capped, like `message`. */ + context?: string; + + /** Redacted structured attributes, when supplied. */ + metadata?: CpsTelemetryMetadata; + + /** Normalized error, when supplied. */ + error?: CpsTelemetryError; + + /** Correlation identifier, when supplied. */ + correlationId?: string; + + /** Application name. */ + application: string; + + /** Deployment environment. */ + environment: string; + + /** Application version. */ + version: string; + + /** Application-supplied user identifier, when known. */ + userId?: string; + + /** Session identifier reported by the sink, when available. */ + sessionId?: string; +} diff --git a/projects/cps-telemetry/src/lib/models/cps-scenario.models/cps-scenario.models.ts b/projects/cps-telemetry/src/lib/models/cps-scenario.models/cps-scenario.models.ts new file mode 100644 index 000000000..80e87dae6 --- /dev/null +++ b/projects/cps-telemetry/src/lib/models/cps-scenario.models/cps-scenario.models.ts @@ -0,0 +1,352 @@ +import type { CpsLogger } from '../../services/cps-logger.service/cps-logger.service'; +import { + CpsRegistered, + CpsTelemetryError, + CpsTelemetryMetadata +} from '../cps-telemetry-common.models/cps-telemetry-common.models'; + +/** + * Registry of this application's scenario names. + * + * Empty in the library. Augment it from your application, and every + * `start({ name })` call is checked against it from then on. + * + * @example + * ```typescript + * // src/app/telemetry/scenarios.schema.ts + * declare module 'cps-telemetry' { + * interface CpsScenarioNames { + * 'route-navigation': true; + * 'load-dashboard': true; + * } + * } + * export {}; + * ``` + * + * @group Interfaces + */ +// Empty by design: the application fills it in. See the doc comment above. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface CpsScenarioNames {} + +/** + * Registry of this application's step and aggregate names. + * + * Steps and aggregates share one vocabulary — a name declared for a step is + * also valid passed to `aggregateStart`. + * + * @example + * ```typescript + * // src/app/telemetry/scenarios.schema.ts + * declare module 'cps-telemetry' { + * interface CpsScenarioSteps { + * 'resolve-route': true; + * 'activate': true; + * } + * } + * export {}; + * ``` + * + * @group Interfaces + */ +// Empty by design — see CpsScenarioNames above. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface CpsScenarioSteps {} + +/** + * Every scenario name this application declares. + * + * Resolves to `string` until {@link CpsScenarioNames} is augmented. + * + * @group Types + */ +export type CpsScenarioName = CpsRegistered; + +/** + * Every step and aggregate name this application declares. + * + * Resolves to `string` until {@link CpsScenarioSteps} is augmented. + * + * @group Types + */ +export type CpsStepName = CpsRegistered; + +/** + * Lifecycle state of a scenario. + * + * - `success` — the journey reached its goal. + * - `failure` — a defect. Investigate. + * - `abandoned` — the user navigated away or the page went away. + * `metadata.abandonedBy` (`'caller'` or `'page-hidden'`) says which. + * - `incomplete` — an expected path that didn't reach the goal: no results, + * a declined guard, a feature flag routing elsewhere. Not an error. + * - `timeout` — the scenario never settled within its deadline. + * + * There is no "in progress" status — {@link CpsScenario.status} is + * `undefined` until it settles, and {@link CpsScenario.isSettled} is derived + * from that. Settling an already-settled scenario is a no-op, never a throw. + * + * @group Types + */ +export type CpsScenarioStatus = + 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'; + +/** + * Outcome of a single step — the same union as the scenario itself, since a + * step still open when its scenario settles inherits that settlement. + * + * @group Types + */ +export type CpsScenarioStepStatus = CpsScenarioStatus; + +/** + * Detail accepted when closing a step. + * + * @group Interfaces + */ +export interface CpsScenarioStepDetail { + /** Optional human-readable note. */ + message?: string; + + /** + * Short, structured explanation, e.g. `'cache-hit'`, `'no-results'` — a + * stable, low-cardinality value for grouping and filtering, independent + * of `message`. + */ + reason?: string; + + /** Attributes merged into the resulting record. */ + metadata?: CpsTelemetryMetadata; +} + +/** + * One measured phase within a scenario. + * + * Offsets are milliseconds relative to the scenario start rather than absolute + * epochs — they stay small integers, which keeps the packed step array cheap to + * serialize and read. + * + * @group Interfaces + */ +export interface CpsScenarioStep extends CpsScenarioStepDetail { + /** + * Step name, e.g. `fetch-data`. Declared in {@link CpsScenarioSteps} — + * except the two boundary markers every scenario carries automatically, + * `scenario-start` and `scenario-end`, which need no declaration because + * the library writes them itself. See {@link CpsScenarioRecord.steps}. + */ + name: CpsStepName | 'scenario-start' | 'scenario-end'; + + /** Milliseconds from scenario start to step start. */ + startOffset: number; + + /** Milliseconds from scenario start to step end. Absent while open. */ + endOffset?: number; + + /** Step duration in milliseconds. Absent while open. */ + stepDelta?: number; + + /** + * Milliseconds since this page loaded (`performance.timeOrigin`), at the + * moment this step closed — a timeline position, not a duration. + */ + elapsed?: number; + + /** Outcome. Absent while the step is still open. */ + status?: CpsScenarioStepStatus; + + /** Normalized error, when the step failed. */ + error?: CpsTelemetryError; +} + +/** + * Total time spent across repeated calls of one operation — a formatter + * called per row, a validator called per field — where the total matters + * more than a hundred individual steps. + * + * @group Interfaces + */ +export interface CpsScenarioAggregate { + /** Operation name. Declared in {@link CpsScenarioSteps}. */ + name: CpsStepName; + + /** Summed duration across every completed call, in milliseconds. */ + elapsed: number; + + /** Number of completed calls contributing to `elapsed`. */ + callCount: number; +} + +/** + * Fields fixed at {@link CpsScenario.start} that identify the journey and + * its place in a larger one, carried through onto the emitted + * {@link CpsScenarioRecord}. + * + * @group Interfaces + */ +export interface CpsScenarioIdentityDetail { + /** Product area, e.g. `customers`. */ + feature?: string; + + /** Operation discriminator within the feature, e.g. `export`. */ + operation?: string; + + /** + * Route the journey started from, as a **template** — `/customers/:id`, + * never a resolved `/customers/john@example.com`. Captured at `start()`, + * so it reflects where the user began, not where a later navigation left + * them. A resolved URL still has its query string stripped, but that's a + * safety net, not a substitute — it would still split one metric + * dimension into one series per customer. + */ + route?: string; + + /** + * Identifier of an enclosing scenario, for nested journeys. Enables + * parent/child reconstruction in CloudWatch. + */ + parentScenarioId?: string; +} + +/** + * Options accepted when starting a scenario. + * + * @group Interfaces + */ +export interface CpsScenarioOptions extends CpsScenarioIdentityDetail { + /** + * Stable scenario name, declared in {@link CpsScenarioNames}. A metric + * dimension — never interpolate an id into it. + */ + name: CpsScenarioName; + + /** + * Milliseconds after which the scenario auto-settles as `timeout`. Defaults + * to {@link CpsScenarioConfig.defaultTimeoutMs}. Pass `0` to disable. + */ + timeoutMs?: number; + + /** + * Backdates the scenario start, in **epoch milliseconds** (`Date.now()`) — + * useful when the journey begins before the code measuring it runs, e.g. + * at a click rather than in the async handler it reaches. Clamped to the + * page's lifetime; an out-of-range value is ignored. + */ + startedAt?: number; + + /** Attributes applied to the scenario record and to every emitted event. */ + metadata?: CpsTelemetryMetadata; + + /** + * Logger this scenario should bind its identity onto, exposed as + * {@link CpsScenario.logger}. Optional — the scenario itself never logs + * anything. + */ + logger?: CpsLogger; +} + +/** + * Detail accepted when settling a scenario. + * + * @group Interfaces + */ +export interface CpsScenarioOutcome extends CpsScenarioStepDetail { + /** + * Result code — an HTTP status, or a business error code. Kept separate from + * `error` so it can serve as a low-cardinality metric dimension for + * error-category distribution. + */ + statusCode?: string | number; + + /** The thrown value, for {@link CpsScenario.fail}. Normalized before emission. */ + error?: unknown; +} + +/** + * The complete scenario payload emitted to the telemetry sink. + * + * Fields the AWS RUM client already attaches to every event — browser, OS, + * device, page — stay absent to avoid duplicating the RUM envelope. + * `application`, `sessionId` and `userId` are carried anyway, so a record + * is self-describing without cross-referencing the envelope. + * + * @group Interfaces + */ +export interface CpsScenarioRecord + extends Omit, CpsScenarioIdentityDetail { + /** Unique scenario identifier. Doubles as the correlation id. */ + scenarioId: string; + + /** Scenario name, from {@link CpsScenarioOptions.name}. */ + scenarioName: CpsScenarioName; + + /** + * Lifecycle state at the moment of emission. `undefined` only for a + * snapshot taken via {@link CpsScenario.toRecord} while still running. + */ + status?: CpsScenarioStatus; + + /** Normalized error, when the scenario failed. */ + error?: CpsTelemetryError; + + /** ISO-8601 timestamp at scenario start. */ + startTime: string; + + /** ISO-8601 timestamp at settlement. Absent while in progress. */ + endTime?: string; + + /** + * Total scenario duration in milliseconds — the headline latency measure. + * + * Named `delta`, not `elapsed`: this record's `elapsed` field means + * something else — see below. + */ + delta: number; + + /** + * Milliseconds since this page loaded (`performance.timeOrigin`), at the + * moment this record was built — a timeline position, not a duration. Not + * exact for the RUM session itself, since the session cookie can survive a + * reload that resets `performance.timeOrigin`. + */ + elapsed: number; + + /** Number of steps the caller declared, including any dropped past `maxSteps`. */ + stepCount: number; + + /** + * Every step, in the order they were opened. The library adds two + * boundary markers — `scenario-start` and `scenario-end` — that bookend + * the real steps and don't count toward `stepCount`/`maxSteps`. A + * mid-flight {@link CpsScenario.toRecord} snapshot may hold only + * `scenario-start`. + */ + steps: CpsScenarioStep[]; + + /** + * Set when `stepCount` exceeded `maxSteps` and `steps` was truncated, so a + * consumer need not know the configured limit to spot a partial list. + */ + exceededStepsLimit?: boolean; + + /** Name of the last step closed before the scenario settled. */ + previousStep?: CpsStepName; + + /** Totals recorded via {@link CpsScenario.aggregateStart}. */ + aggregates?: CpsScenarioAggregate[]; + + /** Application name, from {@link CpsTelemetryIdentity.application}. */ + application: string; + + /** + * Session identifier from the active telemetry sink, when it has one. + * `undefined` before the RUM client finishes initializing. + */ + sessionId?: string; + + /** + * Application user identifier, when one is signed in. Never an email, + * username or account number — see {@link CpsTelemetrySink.setUserId}. + */ + userId?: string; +} diff --git a/projects/cps-telemetry/src/lib/models/cps-telemetry-common.models/cps-telemetry-common.models.spec.ts b/projects/cps-telemetry/src/lib/models/cps-telemetry-common.models/cps-telemetry-common.models.spec.ts new file mode 100644 index 000000000..964b094d5 --- /dev/null +++ b/projects/cps-telemetry/src/lib/models/cps-telemetry-common.models/cps-telemetry-common.models.spec.ts @@ -0,0 +1,36 @@ +import { + CPS_DEFAULT_EVENT_NAMESPACE, + CPS_TELEMETRY_EVENT_TYPE, + cpsEventTypes +} from './cps-telemetry-common.models'; + +describe('cpsEventTypes', () => { + it('should derive the three types from the default namespace', () => { + expect(cpsEventTypes()).toEqual({ + scenario: 'com.cps.scenario', + scenarioStep: 'com.cps.scenario.step', + bi: 'com.cps.bi' + }); + }); + + it('should let an application keep its own namespace', () => { + expect(cpsEventTypes('com.data-gateway')).toEqual({ + scenario: 'com.data-gateway.scenario', + scenarioStep: 'com.data-gateway.scenario.step', + bi: 'com.data-gateway.bi' + }); + }); + + it.each([[''], [undefined]])( + 'should fall back to the default namespace for %p', + (value) => { + expect(cpsEventTypes(value).bi).toBe(`${CPS_DEFAULT_EVENT_NAMESPACE}.bi`); + } + ); + + it('should expose the default namespace as a constant', () => { + expect(CPS_TELEMETRY_EVENT_TYPE).toEqual( + cpsEventTypes(CPS_DEFAULT_EVENT_NAMESPACE) + ); + }); +}); diff --git a/projects/cps-telemetry/src/lib/models/cps-telemetry-common.models/cps-telemetry-common.models.ts b/projects/cps-telemetry/src/lib/models/cps-telemetry-common.models/cps-telemetry-common.models.ts new file mode 100644 index 000000000..cf27c59a5 --- /dev/null +++ b/projects/cps-telemetry/src/lib/models/cps-telemetry-common.models/cps-telemetry-common.models.ts @@ -0,0 +1,99 @@ +/** + * Attribute bag accepted by every telemetry API in this library. + * + * Flat and primitive-only — the type system alone keeps a response body, DOM + * node, or populated user object out of telemetry. Matches what AWS RUM + * accepts as event metadata, so nothing needs converting at the sink. + * + * @group Types + */ +export type CpsTelemetryMetadata = Record< + string, + string | number | boolean | null +>; + +/** + * Normalized, size-bounded representation of a thrown value. + * + * Produced by {@link cpsNormalizeError}. Raw `Error` objects (and HTTP + * client errors, which can carry whole request/response bodies) are never + * placed into a telemetry payload directly. + * + * @group Interfaces + */ +export interface CpsTelemetryError { + /** Constructor name, e.g. `TypeError`, `HttpErrorResponse`. */ + name: string; + + /** Redacted, length-capped error message. */ + message: string; + + /** Length-capped stack trace. Omitted when stack capture is disabled. */ + stack?: string; +} + +/** + * The keys of a registry, or `string` while it is still empty — so an + * application with no schema yet gets `string`, not `never`. Names are + * unconstrained until the first augmentation, then checked against it. + * + * Shared by every closed-vocabulary name type in this library (scenario, + * step and logger names). + */ +export type CpsRegistered = keyof TRegistry extends never + ? string + : keyof TRegistry; + +/** + * Prefix used for event types when the application does not choose its own. + * + * @group Utils + */ +export const CPS_DEFAULT_EVENT_NAMESPACE = 'com.cps'; + +/** + * The custom event types this library emits. + * + * Scenario and BI telemetry each use a single type carrying a `status` / + * `eventName` field, rather than one type per event — one type means one + * schema to query and one extended-metric definition in CloudWatch. + * + * @group Interfaces + */ +export interface CpsTelemetryEventTypes { + /** A settled scenario, with its steps packed into the payload. */ + scenario: string; + + /** An individual scenario step. Only emitted in verbose mode. */ + scenarioStep: string; + + /** A business or UX event. */ + bi: string; +} + +/** + * Derives the event types for a namespace, so an application migrating onto + * this library can keep its existing namespace. + * + * @param namespace the prefix, e.g. `com.my-app` + * @returns the three event types under that namespace + * + * @group Utils + */ +export function cpsEventTypes( + namespace: string = CPS_DEFAULT_EVENT_NAMESPACE +): CpsTelemetryEventTypes { + const prefix = namespace || CPS_DEFAULT_EVENT_NAMESPACE; + return { + scenario: `${prefix}.scenario`, + scenarioStep: `${prefix}.scenario.step`, + bi: `${prefix}.bi` + }; +} + +/** + * The event types under the default namespace. + * + * @group Utils + */ +export const CPS_TELEMETRY_EVENT_TYPE = cpsEventTypes(); diff --git a/projects/cps-telemetry/src/lib/providers/cps-log-api.provider/cps-log-api.provider.ts b/projects/cps-telemetry/src/lib/providers/cps-log-api.provider/cps-log-api.provider.ts new file mode 100644 index 000000000..a532c17d2 --- /dev/null +++ b/projects/cps-telemetry/src/lib/providers/cps-log-api.provider/cps-log-api.provider.ts @@ -0,0 +1,104 @@ +import { InjectionToken } from '@angular/core'; +import { + CpsLoggerName, + CpsLogLevel, + CpsLogRecord +} from '../../models/cps-log.models/cps-log.models'; + +/** + * Filter accepted by {@link CpsLoggerService.query}. + * + * Every field is optional and they combine with AND. A provider translates + * them however its store requires. + * + * @group Interfaces + */ +export interface CpsLogQuery { + /** + * Pulls one journey's lines together. Pass a {@link CpsScenario.id} to read + * everything logged during that scenario, across services. + */ + correlationId?: string; + + /** Restricts to one named logger. */ + logger?: CpsLoggerName; + + /** Discards records below this severity. */ + minLevel?: CpsLogLevel; + + /** ISO-8601 lower bound on `timestamp`, inclusive. */ + from?: string; + + /** ISO-8601 upper bound on `timestamp`, inclusive. */ + to?: string; + + /** Maximum number of records to return. */ + limit?: number; +} + +/** + * The application's log store, as this library needs to see it. + * + * Where records are kept, how they are authorised and how long they are + * retained are decisions for the consuming application; this is the seam it + * supplies them through. + * + * @example + * ```typescript + * send(record: CpsLogRecord): void { + * this.http.post('/api/logs', record).subscribe({ error: () => undefined }); + * } + * + * query(filter: CpsLogQuery): Promise { + * return firstValueFrom(this.http.get('/api/logs', { params: { ...filter } })); + * } + * ``` + * + * @group Interfaces + */ +export interface CpsLogApiProvider { + /** + * Ships one record. + * + * Called once per record, as it is written. Must be non-blocking and must + * not throw. A provider that batches accumulates its own queue here and + * flushes it via {@link flush}. + * + * @param record the redacted record + */ + send(record: CpsLogRecord): void; + + /** + * Reads records back. + * + * @param filter narrows what is returned; an empty filter means "everything + * this provider is willing to return" + * @returns the matching records + */ + query(filter: CpsLogQuery): Promise; + + /** + * Ships whatever this provider is holding, if it holds anything. + * + * Optional; only needed by a provider that batches in {@link send} instead + * of sending immediately. + * + * Called on `pagehide`, on `visibilitychange` going hidden, and on + * teardown. Must be synchronous and non-blocking. `fetch` with `keepalive` + * or `navigator.sendBeacon` are the usual choices. + */ + flush?(): void; +} + +/** + * Binds the application's {@link CpsLogApiProvider}. + * + * Bound by the application; `provideCpsTelemetry` wires everything else. + * Has no default — an unbound provider fails at injection rather than + * silently discarding logs. + * + * @group Tokens + */ +export const CPS_LOG_API_PROVIDER = new InjectionToken( + 'CPS_LOG_API_PROVIDER' +); diff --git a/projects/cps-telemetry/src/lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers.spec.ts b/projects/cps-telemetry/src/lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers.spec.ts new file mode 100644 index 000000000..98f295dd4 --- /dev/null +++ b/projects/cps-telemetry/src/lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers.spec.ts @@ -0,0 +1,482 @@ +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; +import { ApplicationInitStatus, Injectable, PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CpsLoggerService } from '../../services/cps-logger.service/cps-logger.service'; +import { CpsScenarioTelemetryService } from '../../services/cps-scenario-telemetry.service/cps-scenario-telemetry.service'; +import { CpsNoopTelemetrySink } from '../../sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { + CPS_LOG_API_PROVIDER, + CpsLogApiProvider, + CpsLogQuery +} from '../cps-log-api.provider/cps-log-api.provider'; +import { + CPS_DEFAULT_TELEMETRY_CONFIG, + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY +} from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CPS_BI_CONFIG } from '../../config/cps-bi.config/cps-bi.config'; +import { CPS_LOG_CONFIG } from '../../config/cps-log.config/cps-log.config'; +import { CPS_SCENARIO_CONFIG } from '../../config/cps-scenario.config/cps-scenario.config'; +import { CpsBroadcastTelemetrySink } from '../../sinks/cps-broadcast/cps-broadcast-telemetry.sink'; +import { CPS_BROADCAST_CHANNEL } from '../../sinks/cps-broadcast/cps-broadcast.messages'; +import { + CpsTelemetryFeature, + CpsTelemetryLocalSinkMode, + provideCpsTelemetry, + provideCpsTelemetryBroadcastHost, + provideCpsTelemetrySink, + withBiEvents, + withLogging, + withRedaction, + withScenarios +} from './cps-telemetry-common.providers'; + +/** Keeps every record, so a test can assert on what was shipped. */ +@Injectable() +class RecordingLogApi implements CpsLogApiProvider { + readonly records: CpsLogRecord[] = []; + + send(record: CpsLogRecord): void { + this.records.push(record); + } + + query(filter: CpsLogQuery): Promise { + let found = this.records; + if (filter.correlationId) { + found = found.filter((r) => r.correlationId === filter.correlationId); + } + if (filter.logger) { + found = found.filter((r) => r.logger === filter.logger); + } + if (filter.limit !== undefined) { + found = found.slice(0, filter.limit); + } + return Promise.resolve(found); + } +} + +/** + * Minimal `BroadcastChannel` stand-in for observing what one connection + * posts. See `cps-broadcast.spec.ts` for the full cross-realm stub. + */ +class RecordingChannelStub { + static posted: unknown[] = []; + + onmessage: ((event: { data: unknown }) => void) | null = null; + readonly name: string; + + constructor(name: string) { + this.name = name; + } + + postMessage(message: unknown): void { + RecordingChannelStub.posted.push(message); + } + + close(): void {} + + static install(): void { + RecordingChannelStub.posted = []; + Object.defineProperty(globalThis, 'BroadcastChannel', { + value: RecordingChannelStub, + configurable: true, + writable: true + }); + } + + static uninstall(): void { + delete (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel; + } +} + +describe('provideCpsTelemetry', () => { + /** Configuration only — a destination has to be chosen separately. */ + function configureAlone(): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'my-app', environment: 'prod', version: '1.0.0' }, + withLogging({ minLevel: 'warn' }) + ) + ] + }); + } + + it('should provide the application identity', () => { + configureAlone(); + expect(TestBed.inject(CPS_TELEMETRY_IDENTITY)).toMatchObject({ + application: 'my-app', + environment: 'prod', + version: '1.0.0' + }); + }); + + it('should apply a with*() override over the library default', () => { + configureAlone(); + expect(TestBed.inject(CPS_LOG_CONFIG)).toMatchObject({ minLevel: 'warn' }); + }); + + it('should default every concern not given a with*() feature', () => { + configureAlone(); + expect(TestBed.inject(CPS_SCENARIO_CONFIG)).toEqual( + CPS_DEFAULT_TELEMETRY_CONFIG.scenario + ); + expect(TestBed.inject(CPS_BI_CONFIG)).toEqual( + CPS_DEFAULT_TELEMETRY_CONFIG.bi + ); + expect(TestBed.inject(CPS_REDACT_CONFIG)).toEqual( + CPS_DEFAULT_TELEMETRY_CONFIG.redact + ); + }); + + it('should provide no sink of its own', () => { + configureAlone(); + expect(() => TestBed.inject(CpsTelemetrySink)).toThrow(); + }); + + it('should provide no log destination of its own', () => { + configureAlone(); + expect(() => TestBed.inject(CpsLoggerService)).toThrow( + /CPS_LOG_API_PROVIDER/ + ); + }); + + it('should fail loudly when a service is injected with no destination', () => { + configureAlone(); + expect(() => TestBed.inject(CpsScenarioTelemetryService)).toThrow(); + }); + + it('should work once a destination is chosen', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'my-app', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink('noop'), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi } + ] + }); + + const scenario = TestBed.inject(CpsScenarioTelemetryService).start({ + name: 'load' + }); + + expect(() => scenario.step('one').complete()).not.toThrow(); + expect(scenario.status).toBe('success'); + expect(TestBed.inject(CpsLoggerService)).toBeTruthy(); + }); +}); + +describe('provideCpsTelemetrySink', () => { + function configure( + mode: CpsTelemetryLocalSinkMode, + options?: { channelName?: string } + ): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'cart', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink(mode, options), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi } + ] + }); + } + + it('should wire the forwarding sink for an embedded deployment', () => { + configure('broadcast'); + expect(TestBed.inject(CpsTelemetrySink)).toBeInstanceOf( + CpsBroadcastTelemetrySink + ); + }); + + it('should wire a discarding sink when telemetry is switched off', () => { + configure('noop'); + expect(TestBed.inject(CpsTelemetrySink)).toBeInstanceOf( + CpsNoopTelemetrySink + ); + }); + + it('should pass the channel name through in broadcast mode', () => { + configure('broadcast', { channelName: 'my-channel' }); + expect(TestBed.inject(CPS_BROADCAST_CHANNEL)).toBe('my-channel'); + }); + + it('should leave the channel name unbound when none is given', () => { + configure('broadcast'); + expect( + TestBed.inject(CPS_BROADCAST_CHANNEL, null, { optional: true }) + ).toBeNull(); + }); + + it.each(['broadcast', 'noop'] as CpsTelemetryLocalSinkMode[])( + 'should leave application code unchanged in %s mode', + (mode) => { + configure(mode); + const scenario = TestBed.inject(CpsScenarioTelemetryService).start({ + name: 'add-to-cart' + }); + + expect(() => scenario.step('one').complete()).not.toThrow(); + expect(scenario.status).toBe('success'); + } + ); +}); + +describe('provideCpsTelemetryBroadcastHost', () => { + afterEach(() => { + RecordingChannelStub.uninstall(); + }); + + it('should construct the host eagerly via app initialization, with nothing else injecting it', async () => { + RecordingChannelStub.install(); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + provideCpsTelemetry({ + application: 'shell', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink('noop'), + provideCpsTelemetryBroadcastHost() + ] + }); + + await TestBed.inject(ApplicationInitStatus).donePromise; + + expect(RecordingChannelStub.posted).toContainEqual( + expect.objectContaining({ kind: 'identity' }) + ); + }); + + it('should pass the channel name through', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + provideCpsTelemetry({ + application: 'shell', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink('noop'), + provideCpsTelemetryBroadcastHost('my-channel') + ] + }); + + expect(TestBed.inject(CPS_BROADCAST_CHANNEL)).toBe('my-channel'); + }); + + it('should leave the channel name unbound when none is given', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + provideCpsTelemetry({ + application: 'shell', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink('noop'), + provideCpsTelemetryBroadcastHost() + ] + }); + + expect( + TestBed.inject(CPS_BROADCAST_CHANNEL, null, { optional: true }) + ).toBeNull(); + }); +}); + +describe('custom implementations', () => { + it('should reach the application log API through one binding alone', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'my-app', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink('noop'), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi } + ] + }); + + const api = TestBed.inject(RecordingLogApi); + TestBed.inject(CpsLoggerService).log('hello'); + + expect(api.records).toHaveLength(1); + expect(api.records[0].message).toBe('hello'); + }); +}); + +describe('with*() features', () => { + const identity = { + application: 'my-app', + environment: 'prod', + version: '1.0.0' + }; + + function configure(...features: CpsTelemetryFeature[]): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideCpsTelemetry(identity, ...features)] + }); + } + + it('should default the event namespace to com.cps', () => { + configure(); + expect(TestBed.inject(CPS_TELEMETRY_IDENTITY).eventNamespace).toBe( + 'com.cps' + ); + }); + + it('should accept an application-specific event namespace', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ ...identity, eventNamespace: 'com.data-gateway' }) + ] + }); + expect(TestBed.inject(CPS_TELEMETRY_IDENTITY).eventNamespace).toBe( + 'com.data-gateway' + ); + }); + + it('should merge withLogging over the library default rather than replacing it wholesale', () => { + configure(withLogging({ minLevel: 'warn' })); + const logs = TestBed.inject(CPS_LOG_CONFIG); + expect(logs.minLevel).toBe('warn'); + expect(logs.mirrorErrorsToRum).toBe( + CPS_DEFAULT_TELEMETRY_CONFIG.logs.mirrorErrorsToRum + ); + }); + + it('should merge withScenarios over the library default', () => { + configure(withScenarios({ maxSteps: 10 })); + const scenario = TestBed.inject(CPS_SCENARIO_CONFIG); + expect(scenario.maxSteps).toBe(10); + expect(scenario.defaultTimeoutMs).toBe( + CPS_DEFAULT_TELEMETRY_CONFIG.scenario.defaultTimeoutMs + ); + }); + + it('should merge withBiEvents over the library default', () => { + configure(withBiEvents({ dedupWindowMs: 1_000 })); + const bi = TestBed.inject(CPS_BI_CONFIG); + expect(bi.dedupWindowMs).toBe(1_000); + expect(bi.dedupMaxKeys).toBe(CPS_DEFAULT_TELEMETRY_CONFIG.bi.dedupMaxKeys); + }); + + it('should merge withRedaction over the library default', () => { + configure(withRedaction({ includeStack: false })); + const redact = TestBed.inject(CPS_REDACT_CONFIG); + expect(redact.includeStack).toBe(false); + expect(redact.maxStringLength).toBe( + CPS_DEFAULT_TELEMETRY_CONFIG.redact.maxStringLength + ); + }); + + describe('withRedaction array identity', () => { + it('should give each call its own extraKeyPatterns array', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideCpsTelemetry(identity, withRedaction())] + }); + const redactA = TestBed.inject(CPS_REDACT_CONFIG); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideCpsTelemetry(identity, withRedaction())] + }); + const redactB = TestBed.inject(CPS_REDACT_CONFIG); + + expect(redactA.extraKeyPatterns).not.toBe(redactB.extraKeyPatterns); + expect(redactA.extraKeyPatterns).not.toBe( + CPS_DEFAULT_TELEMETRY_CONFIG.redact.extraKeyPatterns + ); + }); + + it('should not leak a mutation of one resolved extraKeyPatterns into another', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideCpsTelemetry(identity, withRedaction())] + }); + TestBed.inject(CPS_REDACT_CONFIG).extraKeyPatterns.push(/leaked/i); + + expect(CPS_DEFAULT_TELEMETRY_CONFIG.redact.extraKeyPatterns).toEqual([]); + }); + + it('should still copy an explicitly supplied array, not share it back', () => { + const shared = [/customerRef/i]; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + identity, + withRedaction({ extraKeyPatterns: shared }) + ) + ] + }); + + TestBed.inject(CPS_REDACT_CONFIG).extraKeyPatterns.push(/addedLater/i); + + expect(shared).toHaveLength(1); + }); + + it('should give each call its own extraValueTransforms array too', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideCpsTelemetry(identity, withRedaction())] + }); + const redactA = TestBed.inject(CPS_REDACT_CONFIG); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideCpsTelemetry(identity, withRedaction())] + }); + const redactB = TestBed.inject(CPS_REDACT_CONFIG); + + expect(redactA.extraValueTransforms).not.toBe( + redactB.extraValueTransforms + ); + expect(redactA.extraValueTransforms).not.toBe( + CPS_DEFAULT_TELEMETRY_CONFIG.redact.extraValueTransforms + ); + }); + + it('should still copy an explicitly supplied extraValueTransforms array, not share it back', () => { + const shared = [(value: string) => value]; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + identity, + withRedaction({ extraValueTransforms: shared }) + ) + ] + }); + + TestBed.inject(CPS_REDACT_CONFIG).extraValueTransforms.push( + (value) => value + ); + + expect(shared).toHaveLength(1); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers.ts b/projects/cps-telemetry/src/lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers.ts new file mode 100644 index 000000000..46a984d3a --- /dev/null +++ b/projects/cps-telemetry/src/lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers.ts @@ -0,0 +1,296 @@ +import { + EnvironmentProviders, + inject, + makeEnvironmentProviders, + Provider, + provideAppInitializer +} from '@angular/core'; +import { CpsTelemetryBroadcastHost } from '../../sinks/cps-broadcast/cps-broadcast-host.service'; +import { CpsBroadcastTelemetrySink } from '../../sinks/cps-broadcast/cps-broadcast-telemetry.sink'; +import { CPS_BROADCAST_CHANNEL } from '../../sinks/cps-broadcast/cps-broadcast.messages'; +import { CpsNoopTelemetrySink } from '../../sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { CpsRedactConfig } from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY, + CPS_DEFAULT_TELEMETRY_CONFIG, + CpsTelemetryIdentity +} from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { + CPS_BI_CONFIG, + CpsBiConfig +} from '../../config/cps-bi.config/cps-bi.config'; +import { + CPS_LOG_CONFIG, + CpsLogConfig +} from '../../config/cps-log.config/cps-log.config'; +import { + CPS_SCENARIO_CONFIG, + CpsScenarioConfig +} from '../../config/cps-scenario.config/cps-scenario.config'; + +/** + * One optional concern's providers, composed onto {@link provideCpsTelemetry}. + * + * Applications never construct one directly — only through `withLogging`, + * `withScenarios`, `withBiEvents` or `withRedaction`. + * + * @group Types + */ +export interface CpsTelemetryFeature { + providers: Provider[]; +} + +/** + * Configures logging. Omit to take the library defaults. + * + * @param config overrides merged over the library defaults + * @returns a feature for {@link provideCpsTelemetry} + * + * @group Utils + */ +export function withLogging( + config: Partial = {} +): CpsTelemetryFeature { + return { + providers: [ + { + provide: CPS_LOG_CONFIG, + useValue: { ...CPS_DEFAULT_TELEMETRY_CONFIG.logs, ...config } + } + ] + }; +} + +/** + * Configures scenario telemetry. Omit to take the library defaults. + * + * @param config overrides merged over the library defaults + * @returns a feature for {@link provideCpsTelemetry} + * + * @group Utils + */ +export function withScenarios( + config: Partial = {} +): CpsTelemetryFeature { + return { + providers: [ + { + provide: CPS_SCENARIO_CONFIG, + useValue: { ...CPS_DEFAULT_TELEMETRY_CONFIG.scenario, ...config } + } + ] + }; +} + +/** + * Configures BI event tracking. Omit to take the library defaults. + * + * @param config overrides merged over the library defaults + * @returns a feature for {@link provideCpsTelemetry} + * + * @group Utils + */ +export function withBiEvents( + config: Partial = {} +): CpsTelemetryFeature { + return { + providers: [ + { + provide: CPS_BI_CONFIG, + useValue: { ...CPS_DEFAULT_TELEMETRY_CONFIG.bi, ...config } + } + ] + }; +} + +/** + * Configures PII redaction, shared by every concern. Omit to take the library + * defaults. + * + * `extraKeyPatterns`/`extraValuePatterns` are copied into a fresh array, so + * callers never share array identity with the defaults or each other. + * + * @param config overrides merged over the library defaults + * @returns a feature for {@link provideCpsTelemetry} + * + * @group Utils + */ +export function withRedaction( + config: Partial = {} +): CpsTelemetryFeature { + const defaults = CPS_DEFAULT_TELEMETRY_CONFIG.redact; + return { + providers: [ + { + provide: CPS_REDACT_CONFIG, + useValue: { + ...defaults, + ...config, + extraKeyPatterns: [ + ...(config.extraKeyPatterns ?? defaults.extraKeyPatterns) + ], + extraValuePatterns: [ + ...(config.extraValuePatterns ?? defaults.extraValuePatterns) + ], + extraValueTransforms: [ + ...(config.extraValueTransforms ?? defaults.extraValueTransforms) + ] + } + } + ] + }; +} + +/** + * Registers the telemetry layer's configuration. + * + * Identity — `application`/`environment`/`version` — is mandatory and stated + * once; every other concern is an optional, individually named feature + * (`withLogging`, `withScenarios`, `withBiEvents`, `withRedaction`). Each + * feature's token (e.g. {@link CPS_LOG_CONFIG}) can also be overridden + * directly via plain DI substitution. + * + * Provides no destination. A sink and a log API provider must be bound + * separately; injecting a telemetry service without them fails at bootstrap + * with `NG0201`. + * + * @example + * ```typescript + * import { provideCpsTelemetryRumSink, CPS_RUM_CREDENTIALS_PROVIDER } from 'cps-telemetry/rum'; + * + * providers: [ + * provideCpsTelemetry( + * { application: 'composition', environment: 'prod', version: '22.0.0' }, + * withLogging({ minLevel: 'warn' }), + * withScenarios({ maxSteps: 10 }) + * ), + * provideCpsTelemetryRumSink(), + * { provide: CPS_LOG_API_PROVIDER, useExisting: MyLogApiProvider }, + * + * { provide: CPS_RUM_CREDENTIALS_PROVIDER, useExisting: AppRumCredentials } + * ] + * ``` + * + * @param identity the application's identity, shared by every concern + * @param features optional per-concern configuration; omitted ones take the + * library defaults + * @returns providers for the telemetry configuration + * + * @group Utils + */ +export function provideCpsTelemetry( + identity: CpsTelemetryIdentity, + ...features: CpsTelemetryFeature[] +): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: CPS_TELEMETRY_IDENTITY, + useValue: { + application: identity.application, + environment: identity.environment, + version: identity.version, + eventNamespace: + identity.eventNamespace || CPS_DEFAULT_TELEMETRY_CONFIG.eventNamespace + } + }, + ...withLogging().providers, + ...withScenarios().providers, + ...withBiEvents().providers, + ...withRedaction().providers, + ...features.flatMap((f) => f.providers) + ]); +} + +/** + * Where a realm sends its telemetry, selectable via + * {@link provideCpsTelemetrySink} without any optional peer dependency. + * AWS RUM isn't one of these — it lives in its own entry point; see + * {@link provideCpsTelemetryRumSink}. + * + * @group Types + */ +export type CpsTelemetryLocalSinkMode = + /** To a shell realm running {@link provideCpsTelemetryBroadcastHost}. */ + | 'broadcast' + /** Nowhere. Everything still runs; nothing is shipped. */ + | 'noop'; + +/** + * Binds the telemetry destination. Every application needs exactly one call + * — this one, or {@link provideCpsTelemetryRumSink} for the RUM sink. + * + * - `'broadcast'` forwards to a shell realm running + * {@link provideCpsTelemetryBroadcastHost}, on a channel both sides name + * identically. + * - `'noop'` discards everything. + * + * Sending straight to AWS CloudWatch RUM is `provideCpsTelemetryRumSink()`, + * imported from `cps-telemetry/rum` — a separate entry point, not a third + * mode here, so that an application using only `'broadcast'`/`'noop'` is + * never required to have the optional `aws-rum-web` peer resolvable at + * build time. See DESIGN.md §3, "Entry points". + * + * @example + * ```typescript + * providers: [ + * provideCpsTelemetry({ application: 'cart', environment, version }), + * provideCpsTelemetrySink(environment.embedded ? 'broadcast' : 'noop') + * ] + * ``` + * + * @param mode where this realm should send telemetry + * @param options `channelName` for `broadcast` mode; must match the host's + * @returns providers wiring the chosen sink + * + * @group Utils + */ +export function provideCpsTelemetrySink( + mode: CpsTelemetryLocalSinkMode, + options?: { channelName?: string } +): EnvironmentProviders { + switch (mode) { + case 'broadcast': + return makeEnvironmentProviders([ + CpsBroadcastTelemetrySink, + { provide: CpsTelemetrySink, useExisting: CpsBroadcastTelemetrySink }, + ...(options?.channelName + ? [{ provide: CPS_BROADCAST_CHANNEL, useValue: options.channelName }] + : []) + ]); + + case 'noop': + return makeEnvironmentProviders([ + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } + ]); + + default: + throw new Error(`[cps-telemetry] Unknown sink mode "${mode}".`); + } +} + +/** + * Records telemetry forwarded by follower realms through this realm's sink. + * + * Provide it in the shell, alongside the real sink. Exactly one realm should. + * + * @param channelName the `BroadcastChannel` name; must match the followers' + * @returns providers wiring the broadcast host + * + * @group Utils + */ +export function provideCpsTelemetryBroadcastHost( + channelName?: string +): EnvironmentProviders { + return makeEnvironmentProviders([ + CpsTelemetryBroadcastHost, + ...(channelName + ? [{ provide: CPS_BROADCAST_CHANNEL, useValue: channelName }] + : []), + // Constructed eagerly so it is listening before any fragment sends. + provideAppInitializer(() => { + inject(CpsTelemetryBroadcastHost); + }) + ]); +} diff --git a/projects/cps-telemetry/src/lib/scenario/cps-scenario-operators/cps-scenario-operators.spec.ts b/projects/cps-telemetry/src/lib/scenario/cps-scenario-operators/cps-scenario-operators.spec.ts new file mode 100644 index 000000000..2e6ce5f02 --- /dev/null +++ b/projects/cps-telemetry/src/lib/scenario/cps-scenario-operators/cps-scenario-operators.spec.ts @@ -0,0 +1,215 @@ +import { defer, EMPTY, of, Subject, switchMap, throwError } from 'rxjs'; +import { CpsScenario } from '../cps-scenario/cps-scenario'; +import { traceScenario } from './cps-scenario-operators'; + +describe('traceScenario operator', () => { + let scenario: jest.Mocked; + + beforeEach(() => { + scenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + }); + + it('should complete scenario on successful stream completion', (done) => { + of(['user1', 'user2']) + .pipe(traceScenario(scenario)) + .subscribe({ + next: (val) => { + expect(val).toEqual(['user1', 'user2']); + }, + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should complete scenario with mapped outcome using function argument', (done) => { + of(['item1', 'item2', 'item3']) + .pipe( + traceScenario(scenario, (items) => ({ + metadata: { itemCount: items.length } + })) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith({ + metadata: { itemCount: 3 } + }); + done(); + } + }); + }); + + it('should complete scenario with mapped outcome using options object', (done) => { + of({ status: 200, data: 'ok' }) + .pipe( + traceScenario(scenario, { + outcome: (res) => ({ + statusCode: res.status, + metadata: { result: res.data } + }) + }) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith({ + statusCode: 200, + metadata: { result: 'ok' } + }); + done(); + } + }); + }); + + it('should complete scenario with no outcome when the stream completes with no emissions', (done) => { + EMPTY.pipe( + traceScenario(scenario, () => ({ metadata: { unreachable: true } })) + ).subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should fail scenario when the stream errors and re-throw the error', (done) => { + const error = new Error('network down'); + throwError(() => error) + .pipe(traceScenario(scenario)) + .subscribe({ + error: (err) => { + expect(err).toBe(error); + expect(scenario.fail).toHaveBeenCalledWith({ error }); + expect(scenario.complete).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should still settle the scenario when the outcome mapper throws', (done) => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + of('value') + .pipe( + traceScenario(scenario, () => { + throw new Error('mapper is broken'); + }) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('failed'), + expect.any(Error) + ); + consoleError.mockRestore(); + done(); + } + }); + }); + + it('should not leak lastValue/hasValue into a resubscription of the same piped observable', (done) => { + let subscriptionCount = 0; + const source = defer(() => { + subscriptionCount++; + return subscriptionCount === 1 ? of('a') : EMPTY; + }); + + const traced$ = source.pipe( + traceScenario(scenario, (value) => ({ metadata: { value } })) + ); + + traced$.subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenNthCalledWith(1, { + metadata: { value: 'a' } + }); + + traced$.subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenNthCalledWith(2, undefined); + done(); + } + }); + } + }); + }); + + describe('teardown without a source complete/error', () => { + it('should cancel the scenario on a manual unsubscribe', () => { + const source = new Subject(); + const subscription = source.pipe(traceScenario(scenario)).subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledTimes(1); + expect(scenario.complete).not.toHaveBeenCalled(); + expect(scenario.fail).not.toHaveBeenCalled(); + }); + + it('should cancel a superseded scenario when switchMap moves to the next inner observable', () => { + const supersededScenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + const pendingFirstRequest = new Subject(); + const secondRequest = of('b'); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + (n === 1 ? pendingFirstRequest : secondRequest).pipe( + traceScenario(n === 1 ? supersededScenario : scenario) + ) + ) + ) + .subscribe(); + + trigger.next(1); // subscribes traceScenario(supersededScenario), still pending + trigger.next(2); // switchMap unsubscribes it before it ever settles + + expect(supersededScenario.cancel).toHaveBeenCalledTimes(1); + expect(supersededScenario.complete).not.toHaveBeenCalled(); + expect(supersededScenario.fail).not.toHaveBeenCalled(); + expect(scenario.complete).toHaveBeenCalledWith(undefined); + }); + + it('should not cancel a scenario that already completed normally', (done) => { + of('value') + .pipe(traceScenario(scenario)) + .subscribe({ + complete: () => { + (scenario as unknown as { isSettled: boolean }).isSettled = true; + expect(scenario.cancel).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should not cancel a scenario that already failed', (done) => { + const error = new Error('boom'); + throwError(() => error) + .pipe(traceScenario(scenario)) + .subscribe({ + error: () => { + (scenario as unknown as { isSettled: boolean }).isSettled = true; + expect(scenario.cancel).not.toHaveBeenCalled(); + done(); + } + }); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/scenario/cps-scenario-operators/cps-scenario-operators.ts b/projects/cps-telemetry/src/lib/scenario/cps-scenario-operators/cps-scenario-operators.ts new file mode 100644 index 000000000..b606f6951 --- /dev/null +++ b/projects/cps-telemetry/src/lib/scenario/cps-scenario-operators/cps-scenario-operators.ts @@ -0,0 +1,82 @@ +import { defer, MonoTypeOperatorFunction, Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { CpsScenarioOutcome } from '../../models/cps-scenario.models/cps-scenario.models'; +import { CpsScenario } from '../cps-scenario/cps-scenario'; +import { cpsSafe } from '../../utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util'; + +/** + * Options for configuring scenario completion within an RxJS stream. + * + * @group Interfaces + */ +export interface CpsTraceScenarioOptions { + /** + * Optional mapper that returns an outcome (metadata, statusCode, message) + * based on the emitted value when the Observable completes successfully. + */ + outcome?: (value: T) => CpsScenarioOutcome | void; +} + +/** + * An RxJS pipeable operator that automatically settles a {@link CpsScenario} + * based on the stream's outcome: `complete()` on completion, `fail({ error })` + * on error. + * + * @example + * ```typescript + * this.http.get('/api/customers') + * .pipe( + * traceScenario(scenario, { + * outcome: (customers) => ({ metadata: { count: customers.length } }) + * }) + * ) + * .subscribe(); + * ``` + * + * @param scenario the scenario instance to track + * @param options optional outcome mapper or configuration + * + * @group Utils + */ +export function traceScenario( + scenario: CpsScenario, + options?: + CpsTraceScenarioOptions | ((value: T) => CpsScenarioOutcome | void) +): MonoTypeOperatorFunction { + const outcomeMapper = + typeof options === 'function' ? options : options?.outcome; + + return (source: Observable): Observable => + defer(() => { + let lastValue: T | undefined; + let hasValue = false; + + return source.pipe( + tap({ + next: (value) => { + lastValue = value; + hasValue = true; + }, + complete: () => { + const outcome = + hasValue && outcomeMapper + ? cpsSafe( + 'scenario.traceOutcome', + () => outcomeMapper(lastValue as T), + undefined + ) + : undefined; + scenario.complete(outcome || undefined); + }, + error: (error: unknown) => { + scenario.fail({ error }); + }, + unsubscribe: () => { + if (!scenario.isSettled) { + scenario.cancel(); + } + } + }) + ); + }); +} diff --git a/projects/cps-telemetry/src/lib/scenario/cps-scenario/cps-scenario.spec.ts b/projects/cps-telemetry/src/lib/scenario/cps-scenario/cps-scenario.spec.ts new file mode 100644 index 000000000..cc79bd93f --- /dev/null +++ b/projects/cps-telemetry/src/lib/scenario/cps-scenario/cps-scenario.spec.ts @@ -0,0 +1,288 @@ +import { CPS_DEFAULT_TELEMETRY_CONFIG } from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CpsScenarioRecord } from '../../models/cps-scenario.models/cps-scenario.models'; +import { CpsTelemetryMetadata } from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { CPS_DEFAULT_REDACT_CONFIG } from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { CpsScenario, CpsScenarioDeps } from './cps-scenario'; + +/** Captures what the scenario emitted, so a test can assert on it. */ +class RecordingSink extends CpsTelemetrySink { + readonly events: { eventType: string; payload: Record }[] = + []; + + sessionId: string | undefined = 'test-session'; + userId: string | undefined; + + record(eventType: string, payload: object): void { + this.events.push({ + eventType, + payload: payload as Record + }); + } + + recordError(): void {} + + getSessionId(): string | undefined { + return this.sessionId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(): void {} +} + +function createDeps(overrides: Partial = {}): { + deps: CpsScenarioDeps; + sink: RecordingSink; + settled: CpsScenarioRecord[]; +} { + const sink = new RecordingSink(); + const settled: CpsScenarioRecord[] = []; + const deps: CpsScenarioDeps = { + identity: { + application: 'test-app', + environment: 'test', + version: '1.0.0' + }, + scenarioConfig: { ...CPS_DEFAULT_TELEMETRY_CONFIG.scenario }, + redact: CPS_DEFAULT_REDACT_CONFIG, + sink, + onSettled: (_id, record) => settled.push(record), + ...overrides + }; + return { deps, sink, settled }; +} + +describe('CpsScenario (direct construction)', () => { + describe('construction', () => { + it('should construct from a plain CpsScenarioDeps object, with no Angular DI involved', () => { + const { deps } = createDeps(); + expect(() => new CpsScenario({ name: 'checkout' }, deps)).not.toThrow(); + }); + + it('should assign a unique id to every instance', () => { + const { deps } = createDeps(); + const a = new CpsScenario({ name: 'checkout' }, deps); + const b = new CpsScenario({ name: 'checkout' }, deps); + expect(a.id).not.toBe(b.id); + }); + + it('should expose the name it was started with', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + expect(scenario.name).toBe('checkout'); + }); + + it('should report no status and isSettled=false before settling', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + expect(scenario.status).toBeUndefined(); + expect(scenario.isSettled).toBe(false); + }); + }); + + describe('steps', () => { + it('should record a scenario-start boundary even with no steps taken', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + const record = scenario.toRecord(); + + expect(record.steps).toHaveLength(1); + expect(record.steps[0]).toMatchObject({ + name: 'scenario-start', + status: 'success' + }); + }); + + it('should close the previous step when the next one opens', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.step('fetch'); + scenario.step('render'); + + const fetchStep = scenario + .toRecord() + .steps.find((s) => s.name === 'fetch'); + expect(fetchStep?.status).toBe('success'); + }); + + it('should mark a step failed via failStep without settling the scenario', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.step('fetch'); + scenario.failStep(new Error('network down')); + + expect(scenario.isSettled).toBe(false); + const fetchStep = scenario + .toRecord() + .steps.find((s) => s.name === 'fetch'); + expect(fetchStep?.status).toBe('failure'); + expect(fetchStep?.error).toMatchObject({ message: 'network down' }); + }); + + it('should ignore mutation calls once settled', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.complete(); + + expect(() => scenario.step('too-late')).not.toThrow(); + expect(scenario.toRecord().stepCount).toBe(0); + }); + + it("should not let a mutated mid-flight toRecord() snapshot change what's later emitted", () => { + const { deps, sink } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.step('fetch', { count: 1 }); + scenario.setData({ owner: 'checkout-team' }); + + const snapshot = scenario.toRecord(); + const fetchStep = snapshot.steps.find((s) => s.name === 'fetch'); + (fetchStep!.metadata as CpsTelemetryMetadata).count = 999; + (snapshot.metadata as CpsTelemetryMetadata).owner = 'hijacked'; + + scenario.complete(); + + const emitted = sink.events[0].payload as unknown as CpsScenarioRecord; + const emittedFetchStep = emitted.steps.find((s) => s.name === 'fetch'); + expect(emittedFetchStep?.metadata?.count).toBe(1); + expect(emitted.metadata?.owner).toBe('checkout-team'); + }); + }); + + describe('settle', () => { + it('should emit exactly one scenario event to the sink', () => { + const { deps, sink } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.complete(); + + expect(sink.events).toHaveLength(1); + expect(sink.events[0].eventType).toBe('com.cps.scenario'); + }); + + it('should notify onSettled with the final record', () => { + const { deps, settled } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.complete({ message: 'done' }); + + expect(settled).toHaveLength(1); + expect(settled[0].status).toBe('success'); + expect(settled[0].message).toBe('done'); + }); + + it('should keep message and reason independent, never merged', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.complete({ message: 'human note', reason: 'cache-hit' }); + + const record = scenario.toRecord(); + expect(record.message).toBe('human note'); + expect(record.reason).toBe('cache-hit'); + }); + + it('should ignore a second settle call after the first', () => { + const { deps, settled } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.complete(); + scenario.fail({ error: new Error('too late') }); + + expect(settled).toHaveLength(1); + expect(scenario.status).toBe('success'); + }); + + it('should attach the normalized error to the root record even for a non-failure status', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.settle('success', {}, new Error('odd but allowed')); + + const record = scenario.toRecord(); + expect(record.status).toBe('success'); + expect(record.error).toMatchObject({ message: 'odd but allowed' }); + + const scenarioEndStep = record.steps.find( + (s) => s.name === 'scenario-end' + ); + expect(scenarioEndStep?.error).toBeUndefined(); + }); + + it('should retain a legitimately falsy thrown value, not treat it as no error', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.fail({ error: 0 }); + + const record = scenario.toRecord(); + expect(record.error).toBeDefined(); + expect(record.error).toMatchObject({ name: 'number' }); + }); + }); + + describe('CpsScenarioDeps contract', () => { + it('should read identity from the supplied deps, not a default', () => { + const { deps } = createDeps({ + identity: { + application: 'custom-app', + environment: 'qa', + version: '9.9.9' + } + }); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + expect(scenario.toRecord().application).toBe('custom-app'); + }); + + it("should call onSettled exactly once, with this scenario's own id", () => { + const calls: string[] = []; + const { deps } = createDeps({ onSettled: (id) => calls.push(id) }); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.complete(); + + expect(calls).toEqual([scenario.id]); + }); + + it('should read the session and user id from the supplied sink', () => { + const { deps, sink } = createDeps(); + sink.sessionId = 's-1'; + sink.userId = 'u-1'; + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + const record = scenario.toRecord(); + expect(record.sessionId).toBe('s-1'); + expect(record.userId).toBe('u-1'); + }); + + it('should redact metadata using the supplied redact config, not the library default', () => { + const consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const { deps } = createDeps({ + redact: { ...CPS_DEFAULT_REDACT_CONFIG, maxKeys: 1 } + }); + const scenario = new CpsScenario( + { name: 'checkout', metadata: { a: 1, b: 2 } as CpsTelemetryMetadata }, + deps + ); + + expect(Object.keys(scenario.toRecord().metadata ?? {})).toHaveLength(1); + + consoleWarn.mockRestore(); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/scenario/cps-scenario/cps-scenario.ts b/projects/cps-telemetry/src/lib/scenario/cps-scenario/cps-scenario.ts new file mode 100644 index 000000000..30f08fc50 --- /dev/null +++ b/projects/cps-telemetry/src/lib/scenario/cps-scenario/cps-scenario.ts @@ -0,0 +1,893 @@ +import { CpsTelemetryIdentity } from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CpsScenarioConfig } from '../../config/cps-scenario.config/cps-scenario.config'; +import { + CpsScenarioAggregate, + CpsScenarioName, + CpsScenarioOptions, + CpsScenarioOutcome, + CpsScenarioRecord, + CpsScenarioStatus, + CpsScenarioStep, + CpsScenarioStepDetail, + CpsScenarioStepStatus, + CpsStepName +} from '../../models/cps-scenario.models/cps-scenario.models'; +import { + cpsEventTypes, + CpsTelemetryEventTypes, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import type { CpsLogger } from '../../services/cps-logger.service/cps-logger.service'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { cpsIsDebugEnabled } from '../../utils/cps-debug-flag.util/cps-debug-flag.util'; +import { + CpsRedactConfig, + cpsNormalizeError, + cpsRedactMetadata, + cpsScrubString +} from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { + cpsDeepClone, + cpsEpochToPerf, + cpsNow, + cpsSafe, + cpsSafeVoid, + cpsUuid +} from '../../utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util'; +import { + cpsClearMarks, + cpsMark, + cpsMarkName, + cpsMeasure +} from '../../utils/cps-user-timings-internal.util/cps-user-timings-internal.util'; + +/** + * Collaborators a scenario needs. Supplied by + * {@link CpsScenarioTelemetryService}; not part of the public API. + */ +export interface CpsScenarioDeps { + /** The application's identity, shared by every telemetry concern. */ + identity: CpsTelemetryIdentity; + /** Scenario-specific tuning — timeouts, step cap, User Timing marks. */ + scenarioConfig: CpsScenarioConfig; + /** Redaction settings, shared by every telemetry concern. */ + redact: CpsRedactConfig; + sink: CpsTelemetrySink; + /** Called once with the scenario's id when it reaches a terminal state. */ + onSettled: (scenarioId: string, record: CpsScenarioRecord) => void; +} + +/** Boundary names used for User Timing marks (see `timingMark`). */ +const START_MARK = 'start'; +const SETTLE_MARK = 'settle'; + +/** + * A single in-flight user journey or feature execution. + * + * Each call to {@link CpsScenarioTelemetryService.start} returns a fresh + * instance, so scenarios can run concurrently without interfering. + * + * @example + * ```typescript + * try { + * scenario.step('fetch-data'); + * const rows = await this.api.fetchCustomers(); + * scenario.complete({ metadata: { rowCount: rows.length } }); + * } catch (error) { + * scenario.fail({ error }); + * } + * ``` + * + * @group Classes + */ +export class CpsScenario { + private readonly _id = cpsUuid(); + private readonly startedAt: number; + private readonly _startTime: number; + private readonly steps: CpsScenarioStep[] = []; + private readonly metadata: CpsTelemetryMetadata; + + /** Running totals for {@link aggregateStart} / {@link aggregateEnd}. */ + private readonly aggregates = new Map< + CpsStepName, + { total: number; callCount: number; openedAt?: number } + >(); + + /** User Timing mark names created so far, cleared from the buffer at settle. */ + private readonly timingMarks: string[] = []; + + /** Event types for the application's configured namespace. */ + private readonly eventTypes: CpsTelemetryEventTypes; + + private _status?: CpsScenarioStatus; + private _stepCount = 0; + private openStep?: CpsScenarioStep; + /** Whether the open step counted against {@link CpsScenarioConfig.maxSteps}. */ + private openStepIncluded = false; + private previousStep?: CpsStepName; + private lastTimingMark: string; + private timeoutHandle?: ReturnType; + /** See {@link scheduleMarkCleanupFallback}. */ + private markCleanupTimer?: ReturnType; + + private _elapsed = 0; + private _logger?: CpsLogger; + private settleOutcome?: CpsScenarioOutcome; + private settleError?: unknown; + /** Scrubbed once in {@link settle}, reused by {@link toRecord} — see that method's doc comment. */ + private settledMessage?: string; + /** Scrubbed once in {@link settle}, reused by {@link toRecord} — see that method's doc comment. */ + private settledReason?: string; + + /** + * Scrubbed once here and reused by {@link toRecord} and + * {@link measureScenarioTiming}. `route` is expected to be a template + * (`/customers/:id`); the library cannot enforce that. + */ + private readonly feature?: string; + private readonly operation?: string; + private readonly route?: string; + + /** + * Whether User Timing entries should be produced. + * + * The debug flag overrides the config, so a developer can + * get timeline entries out of an already-deployed build. + */ + private readonly userTimingsEnabled: boolean; + + constructor( + private readonly options: CpsScenarioOptions, + private readonly deps: CpsScenarioDeps + ) { + this.eventTypes = cpsEventTypes(deps.identity.eventNamespace); + this.userTimingsEnabled = + deps.scenarioConfig.userTimings || cpsIsDebugEnabled('debugScenario'); + this.metadata = cpsSafe( + 'scenario.construct', + () => cpsRedactMetadata(options.metadata, deps.redact) ?? {}, + {} + ); + this.feature = options.feature + ? cpsScrubString(options.feature, deps.redact) + : undefined; + this.operation = options.operation + ? cpsScrubString(options.operation, deps.redact) + : undefined; + this.route = options.route + ? cpsScrubString(options.route, deps.redact) + : undefined; + + const backdated = + options.startedAt !== undefined + ? cpsEpochToPerf(options.startedAt) + : undefined; + + this.startedAt = backdated ?? cpsNow(); + this._startTime = Date.now() - Math.round(cpsNow() - this.startedAt); + + this.steps.push({ + name: 'scenario-start', + startOffset: 0, + endOffset: 0, + stepDelta: 0, + elapsed: Math.round(cpsNow()), + status: 'success' + }); + + this.lastTimingMark = this.timingMark(START_MARK); + this.scheduleTimeout(); + this.debug('started'); + } + + /** + * The scenario's unique identifier and correlation id — pass it to + * {@link CpsLoggerService} calls, BI events, and backend requests to + * reassemble a journey across frontend and backend telemetry. + */ + get id(): string { + return this._id; + } + + /** The scenario name supplied at start. */ + get name(): CpsScenarioName { + return this.options.name; + } + + /** + * The settled status, or `undefined` while the scenario is still running. + * + * Use {@link isSettled} to check whether it is done. + */ + get status(): CpsScenarioStatus | undefined { + return this._status; + } + + /** Whether the scenario has settled. */ + get isSettled(): boolean { + return this._status !== undefined; + } + + /** + * Duration in milliseconds — how long the scenario has run, or its final + * duration once settled. + * + * Named `delta`, not `elapsed`: the record's `elapsed` field means + * milliseconds since the page loaded, a different value. + */ + get delta(): number { + return this.isSettled ? this._elapsed : cpsNow() - this.startedAt; + } + + /** + * The logger passed to {@link CpsScenarioTelemetryService.start}, with this + * scenario's id bound as the correlation id. + * + * `undefined` unless a logger was supplied. + */ + get logger(): CpsLogger | undefined { + if (!this._logger && this.options.logger) { + this._logger = cpsSafe( + 'scenario.logger', + () => + this.options.logger?.child({ + context: this.options.name, + correlationId: this._id + }), + undefined + ); + } + return this._logger; + } + + /** + * Opens a step, implicitly closing the previous one as completed. + * + * @param name the step name, declared in {@link CpsScenarioSteps} + * @param metadata attributes recorded on the step + * @returns this scenario, for chaining + */ + step(name: CpsStepName, metadata?: CpsTelemetryMetadata): this { + return this.mutate('step', () => { + this.closeOpenStep('success'); + + const step: CpsScenarioStep = { + name, + startOffset: Math.round(cpsNow() - this.startedAt), + metadata: cpsRedactMetadata(metadata, this.deps.redact) + }; + + this.openStep = step; + this._stepCount++; + + this.openStepIncluded = + this._stepCount <= this.deps.scenarioConfig.maxSteps; + if (this.openStepIncluded) { + this.steps.push(step); + } + + this.debug(`step ${name}`); + }); + } + + /** + * Closes the open step as completed. + * + * Rarely needed — opening the next step or settling the scenario closes it + * automatically. Use it when the step ends well before the next one starts. + * + * @param detail optional note and attributes + * @returns this scenario, for chaining + */ + endStep(detail?: CpsScenarioStepDetail): this { + return this.mutate('endStep', () => this.closeOpenStep('success', detail)); + } + + /** + * Closes the open step as failed, leaving the scenario itself in progress. + * + * Use when a step fails but the journey recovers — a retried request, an + * optional resource that could not be loaded. + * + * @param error the thrown value + * @param detail optional note and attributes + * @returns this scenario, for chaining + */ + failStep(error: unknown, detail?: CpsScenarioStepDetail): this { + return this.mutate('failStep', () => + this.closeOpenStep('failure', detail, error) + ); + } + + /** + * Merges attributes into the scenario record while it is still running. + * + * Facts worth attaching to the whole journey — a row count, which strategy was + * chosen, whether a cache was warm — are usually learned partway through, + * after `start` and before any outcome is known. + * + * @param metadata attributes merged into the scenario record + * @returns this scenario, for chaining + */ + setData(metadata: CpsTelemetryMetadata): this { + return this.mutate('setData', () => { + const safe = cpsRedactMetadata(metadata, this.deps.redact); + if (safe) { + Object.assign(this.metadata, safe); + } + }); + } + + /** + * Starts timing one call of a repeatedly-invoked operation. + * + * Use for an operation that happens many times, where a step would bury + * the scenario in noise. A second call for the same name before its + * matching {@link aggregateEnd} is ignored. + * + * @param name the operation name, declared in {@link CpsScenarioSteps} + * @returns this scenario, for chaining + */ + aggregateStart(name: CpsStepName): this { + return this.mutate('aggregateStart', () => { + const entry = this.aggregates.get(name) ?? { total: 0, callCount: 0 }; + if (entry.openedAt === undefined) { + entry.openedAt = cpsNow(); + } + this.aggregates.set(name, entry); + }); + } + + /** + * Stops timing one call and adds it to the running total. + * + * Ignored when there is no matching {@link aggregateStart}, so an early return + * inside the measured operation cannot corrupt the total. + * + * @param name the operation name, declared in {@link CpsScenarioSteps} + * @returns this scenario, for chaining + */ + aggregateEnd(name: CpsStepName): this { + return this.mutate('aggregateEnd', () => { + const entry = this.aggregates.get(name); + if (!entry || entry.openedAt === undefined) { + return; + } + + entry.total += cpsNow() - entry.openedAt; + entry.callCount++; + entry.openedAt = undefined; + }); + } + + /** + * Settles the scenario successfully. + * + * @param outcome optional status code, note and attributes + */ + complete(outcome?: CpsScenarioOutcome): void { + this.settle('success', outcome); + } + + /** + * Settles the scenario as `incomplete` — it neither succeeded nor broke. + * + * For expected code paths that do not reach the goal. Kept apart from + * `failed` and `cancelled` so it does not skew either. + * + * @param outcome optional status code, message, reason and attributes + */ + incomplete(outcome?: CpsScenarioOutcome): void { + this.settle('incomplete', outcome); + } + + /** + * Settles the scenario as failed. + * + * @param outcome optional status code, note, attributes, and the thrown + * `error` — normalized before emission + */ + fail(outcome?: CpsScenarioOutcome): void { + this.settle('failure', outcome, outcome?.error); + } + + /** + * Settles the scenario as abandoned — it stopped being relevant. + * + * Covers the user or page navigating away. A scenario that times out + * settles as `timeout` instead, not `abandoned`. + * + * Records `metadata.abandonedBy: 'caller'`. + * + * @param outcome optional status code, message, reason and additional metadata + */ + cancel(outcome?: CpsScenarioOutcome): void { + this.settle('abandoned', { + ...outcome, + metadata: { + ...outcome?.metadata, + // Last, so caller-supplied metadata cannot override it. + abandonedBy: 'caller' + } + }); + } + + /** + * Builds the payload that is emitted to the sink. + * + * @returns the scenario record as it currently stands. A snapshot taken + * mid-flight can have fewer than two steps, since `scenario-end` is only + * written at settlement. + */ + toRecord(): CpsScenarioRecord { + const { redact } = this.deps; + const settled = this.isSettled; + + const aggregates = this.collectAggregates(); + + const record: CpsScenarioRecord = { + scenarioId: this._id, + parentScenarioId: this.options.parentScenarioId, + scenarioName: this.options.name, + feature: this.feature, + operation: this.operation, + route: this.route, + status: this._status, + startTime: new Date(this._startTime).toISOString(), + endTime: settled + ? new Date(this._startTime + Math.round(this._elapsed)).toISOString() + : undefined, + delta: Math.round(this.delta), + elapsed: Math.round(cpsNow()), + stepCount: this._stepCount, + steps: cpsDeepClone(this.steps), + previousStep: this.previousStep, + aggregates: aggregates.length ? aggregates : undefined, + metadata: Object.keys(this.metadata).length + ? cpsDeepClone(this.metadata) + : undefined, + application: this.deps.identity.application, + sessionId: this.sessionId(), + userId: this.userId() + }; + + if (this._stepCount > this.deps.scenarioConfig.maxSteps) { + record.exceededStepsLimit = true; + } + + if (this.settleOutcome?.statusCode !== undefined) { + record.statusCode = this.settleOutcome.statusCode; + } + if (this.settledMessage) { + record.message = this.settledMessage; + } + if (this.settledReason) { + record.reason = this.settledReason; + } + if (this.settleError !== undefined) { + record.error = cpsNormalizeError(this.settleError, redact); + } + + return record; + } + + /** + * Settles the scenario into a status supplied as data. + * + * **Prefer {@link complete}, {@link fail}, {@link cancel} or + * {@link incomplete}** — they name the outcome at the call site so it can + * be found by searching. Use this form for adapters that map an external + * status onto a scenario without knowing the outcome in advance. + * + * @param status the terminal state to settle into + * @param outcome optional status code, note and attributes + * @param error the thrown value, when settling as `failed` + */ + settle( + status: CpsScenarioStatus, + outcome?: CpsScenarioOutcome, + error?: unknown + ): void { + cpsSafeVoid(`scenario.${status}`, () => { + if (this.guardSettled(status)) { + return; + } + + this.clearTimeout(); + this.clearMarkCleanupTimer(); + this._elapsed = cpsNow() - this.startedAt; + this._status = status; + + const { message, reason } = outcome ?? {}; + const { redact } = this.deps; + + const resolvedMessage = message + ? cpsScrubString(message, redact) + : undefined; + const resolvedReason = reason + ? cpsScrubString(reason, redact) + : undefined; + const resolvedMetadata = cpsRedactMetadata(outcome?.metadata, redact); + + this.closeOpenStep( + status, + { + message: resolvedMessage, + reason: resolvedReason, + metadata: resolvedMetadata + }, + error, + true + ); + + this.steps.push({ + name: 'scenario-end', + startOffset: Math.round(this._elapsed), + endOffset: Math.round(this._elapsed), + stepDelta: 0, + elapsed: Math.round(cpsNow()), + status, + ...(resolvedMessage && { message: resolvedMessage }), + ...(resolvedReason && { reason: resolvedReason }), + ...(resolvedMetadata && { metadata: resolvedMetadata }), + ...(status === 'failure' && + error !== undefined && { + error: cpsNormalizeError(error, redact) + }) + }); + + this.settleOutcome = outcome; + this.settleError = error; + this.settledMessage = resolvedMessage; + this.settledReason = resolvedReason; + + if (resolvedMetadata) { + Object.assign(this.metadata, resolvedMetadata); + } + + this.measureScenarioTiming(); + + const record = this.toRecord(); + this.debugEmit( + this.eventTypes.scenario, + record, + `${status} in ${Math.round(this._elapsed)}ms` + ); + this.deps.onSettled(this._id, record); + this.emitScenarioEvent(record); + }); + } + + /** The active telemetry sink's session id, when it has one. */ + private sessionId(): string | undefined { + return cpsSafe( + 'scenario.getSessionId', + () => this.deps.sink.getSessionId(), + undefined + ); + } + + /** The active telemetry sink's application user id, when one is signed in. */ + private userId(): string | undefined { + return cpsSafe( + 'scenario.getUserId', + () => this.deps.sink.getUserId(), + undefined + ); + } + + /** Emits the settled record. */ + private emitScenarioEvent(record: CpsScenarioRecord): void { + this.deps.sink.record( + this.eventTypes.scenario, + record as unknown as object + ); + } + + /** Snapshots the aggregate totals, closing any call still open. */ + private collectAggregates(): CpsScenarioAggregate[] { + const result: CpsScenarioAggregate[] = []; + const now = this.isSettled ? this.startedAt + this._elapsed : cpsNow(); + + for (const [name, entry] of this.aggregates) { + let total = entry.total; + let callCount = entry.callCount; + + if (entry.openedAt !== undefined) { + total += now - entry.openedAt; + callCount++; + } + + result.push({ name, elapsed: Math.round(total), callCount }); + } + + return result; + } + + /** + * @param alreadyRedacted `true` when `detail`'s fields are already + * scrubbed, so they are not redacted a second time. Defaults to `false`. + */ + private closeOpenStep( + status: CpsScenarioStepStatus, + detail?: CpsScenarioStepDetail, + error?: unknown, + alreadyRedacted = false + ): void { + const step = this.openStep; + if (!step) { + return; + } + this.openStep = undefined; + + const { redact } = this.deps; + step.endOffset = Math.round(cpsNow() - this.startedAt); + step.stepDelta = step.endOffset - step.startOffset; + step.elapsed = Math.round(cpsNow()); + step.status = status; + + if (detail?.message) { + step.message = alreadyRedacted + ? detail.message + : cpsScrubString(detail.message, redact); + } + if (detail?.reason) { + step.reason = alreadyRedacted + ? detail.reason + : cpsScrubString(detail.reason, redact); + } + if (detail?.metadata) { + step.metadata = { + ...step.metadata, + ...(alreadyRedacted + ? detail.metadata + : cpsRedactMetadata(detail.metadata, redact)) + }; + } + if (status === 'failure' && error !== undefined) { + step.error = cpsNormalizeError(error, redact); + } + + // The two synthetic boundary markers never pass through here, so this + // is always a real step name. + const realStepName = step.name as CpsStepName; + this.measureStepTiming(realStepName); + this.previousStep = realStepName; + + if (this.deps.scenarioConfig.emitLifecycleEvents && this.openStepIncluded) { + const stepEvent = { + scenarioId: this._id, + scenarioName: this.options.name, + application: this.deps.identity.application, + sessionId: this.sessionId(), + userId: this.userId(), + ...step + }; + this.debugEmit( + this.eventTypes.scenarioStep, + stepEvent, + `step ${step.name}` + ); + this.deps.sink.record(this.eventTypes.scenarioStep, stepEvent); + } + } + + /** + * Records a `performance.mark` for a boundary and remembers its name so + * it can be cleared at settle. + * + * @param boundary the boundary name — `start`, a step name, or `settle` + * @returns the mark name, for use as a measure endpoint + */ + private timingMark(boundary: string): string { + const name = cpsMarkName( + this.deps.identity.application, + this.options.name, + this._id, + boundary + ); + + if (this.userTimingsEnabled) { + cpsMark(name); + this.timingMarks.push(name); + } + + return name; + } + + /** + * Records the DevTools timeline entry for a step that has just closed. + * + * Purely instrumentation — the step itself was already recorded by + * {@link closeOpenStep}, which is what reaches the telemetry sink. + */ + private measureStepTiming(stepName: CpsStepName): void { + if (!this.userTimingsEnabled) { + return; + } + + const endMark = this.timingMark(stepName); + cpsMeasure( + `${this.options.name} [${stepName}]`, + this.lastTimingMark, + endMark + ); + this.lastTimingMark = endMark; + } + + /** Measures the whole scenario, then drops its marks from the buffer. */ + private measureScenarioTiming(): void { + if (!this.userTimingsEnabled) { + return; + } + + const startMark = cpsMarkName( + this.deps.identity.application, + this.options.name, + this._id, + START_MARK + ); + const endMark = this.timingMark(SETTLE_MARK); + + const feature = this.feature ? ` (${this.feature})` : ''; + cpsMeasure(`${this.options.name}${feature}`, startMark, endMark); + + cpsClearMarks(this.timingMarks); + this.timingMarks.length = 0; + } + + private scheduleTimeout(): void { + const timeoutMs = + this.options.timeoutMs ?? this.deps.scenarioConfig.defaultTimeoutMs; + + if (!timeoutMs || timeoutMs <= 0) { + this.scheduleMarkCleanupFallback(); + return; + } + + const remainingMs = Math.max(0, timeoutMs - (cpsNow() - this.startedAt)); + + this.timeoutHandle = setTimeout(() => { + this.timeoutHandle = undefined; + this.settle('timeout', { + message: `Scenario did not settle within ${timeoutMs}ms` + }); + }, remainingMs); + } + + private clearTimeout(): void { + this.timeoutHandle = CpsScenario.clearHandle(this.timeoutHandle); + } + + /** + * Defensive backstop for a scenario with no business timeout: clears its + * User Timing marks after {@link CpsScenarioConfig.markCleanupFallbackMs}, + * independent of whether the scenario ever settles. + * + * A no-op when `userTimings` is off or `markCleanupFallbackMs` is `0`. + * + * This clears marks only — it does not settle the scenario. A scenario + * left unsettled this way never fires {@link CpsScenarioDeps.onSettled}, + * so it stays in {@link CpsScenarioTelemetryService}'s active registry for + * the life of the page. + */ + private scheduleMarkCleanupFallback(): void { + const fallbackMs = this.deps.scenarioConfig.markCleanupFallbackMs; + if (!this.userTimingsEnabled || !fallbackMs || fallbackMs <= 0) { + return; + } + + this.markCleanupTimer = setTimeout(() => { + this.markCleanupTimer = undefined; + cpsClearMarks(this.timingMarks); + this.timingMarks.length = 0; + }, fallbackMs); + } + + private clearMarkCleanupTimer(): void { + this.markCleanupTimer = CpsScenario.clearHandle(this.markCleanupTimer); + } + + /** + * Clears a scheduled `setTimeout` if one is pending, otherwise a no-op. + * + * @param handle the timer handle, or `undefined` if nothing is scheduled + * @returns `undefined`, so a call site can reassign its field directly: + * `this.timeoutHandle = CpsScenario.clearHandle(this.timeoutHandle)` + */ + private static clearHandle( + handle: ReturnType | undefined + ): undefined { + if (handle !== undefined) { + clearTimeout(handle); + } + return undefined; + } + + /** + * Applies a mutation to a still-open scenario, and returns it for + * chaining. Fail-open, and ignored once the scenario has settled. + * + * Settling methods do not use it — they end the scenario, not mutate it. + * + * @param operation the method name, for debug output and error reports + * @param apply the mutation, run only while the scenario is open + * @returns this scenario + */ + private mutate(operation: string, apply: () => void): this { + cpsSafeVoid(`scenario.${operation}`, () => { + if (this.guardSettled(operation)) { + return; + } + apply(); + }); + return this; + } + + /** + * Returns `true` when the operation should be skipped because the + * scenario has already settled. + */ + private guardSettled(operation: string): boolean { + if (!this.isSettled) { + return false; + } + this.debug(`ignored ${operation} — already ${this._status}`); + return true; + } + + /** + * Logs the exact payload handed to the sink, with the event type it is + * sent under. + * + * @param eventType the type the payload is recorded under + * @param payload the object passed to {@link CpsTelemetrySink.record} + * @param summary a short human-readable prefix + */ + private debugEmit(eventType: string, payload: object, summary: string): void { + this.logIfDebugging(() => + // eslint-disable-next-line no-console + console.log( + `[cps][scenario] ${this.options.name} ${summary} -> ${eventType}`, + payload + ) + ); + } + + /** Progress trace for things that are never sent anywhere. */ + private debug(message: string): void { + this.logIfDebugging(() => + // eslint-disable-next-line no-console + console.log( + `[cps][scenario] ${this.options.name} ${message}`, + this.debugSnapshot() + ) + ); + } + + /** + * The object attached to a {@link debug} line — enough to follow what a + * scenario is doing and to tell concurrent scenarios of the same name + * apart. + */ + private debugSnapshot(): Record { + return { + scenarioId: this._id, + stepCount: this._stepCount, + previousStep: this.previousStep, + delta: Math.round(this.delta) + }; + } + + /** + * Shared guard and fail-open wrapper for {@link debug} and {@link + * debugEmit} — both gate on the same flag and must not let a broken + * console take the caller down. + */ + private logIfDebugging(log: () => void): void { + if (!cpsIsDebugEnabled('debugScenario')) { + return; + } + cpsSafeVoid('scenario.debug', log); + } +} diff --git a/projects/cps-telemetry/src/lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service.spec.ts b/projects/cps-telemetry/src/lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service.spec.ts new file mode 100644 index 000000000..0e49a1a03 --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service.spec.ts @@ -0,0 +1,689 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + provideCpsTelemetry, + withBiEvents, + withRedaction, + withScenarios +} from '../../providers/cps-telemetry-common.providers/cps-telemetry-common.providers'; +import { CpsBiEvent } from '../../models/cps-bi.models/cps-bi.models'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { + CPS_LOG_API_PROVIDER, + CpsLogApiProvider, + CpsLogQuery +} from '../../providers/cps-log-api.provider/cps-log-api.provider'; +import { CPS_REDACTED } from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { CpsBiTelemetryService } from './cps-bi-telemetry.service'; +import { CpsScenarioTelemetryService } from '../cps-scenario-telemetry.service/cps-scenario-telemetry.service'; +import { + CPS_TELEMETRY_EVENT_TYPE, + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; + +/** Captures what the library emitted, so a test can assert on it. */ +@Injectable() +class RecordingSink extends CpsTelemetrySink { + readonly events: { + eventType: string; + payload: Record; + metadata?: CpsTelemetryMetadata; + }[] = []; + + readonly errors: CpsTelemetryError[] = []; + readonly flushes: boolean[] = []; + userId?: string; + sessionId: string | undefined = 'test-session'; + + record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void { + this.events.push({ + eventType, + payload: payload as Record, + metadata + }); + } + + recordError(error: CpsTelemetryError): void { + this.errors.push(error); + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(beacon = false): void { + this.flushes.push(beacon); + } + + ofType(eventType: string) { + return this.events.filter((event) => event.eventType === eventType); + } +} + +/** Fails on every call, to prove telemetry cannot break the caller. */ +@Injectable() +class ThrowingSink extends CpsTelemetrySink { + record(): never { + throw new Error('sink is broken'); + } + + recordError(): never { + throw new Error('sink is broken'); + } + + getSessionId(): never { + throw new Error('sink is broken'); + } + + setUserId(): never { + throw new Error('sink is broken'); + } + + getUserId(): never { + throw new Error('sink is broken'); + } + + flush(): never { + throw new Error('sink is broken'); + } +} + +/** Keeps every batch, so a test can assert on what was shipped. */ +@Injectable() +class RecordingLogApi implements CpsLogApiProvider { + readonly records: CpsLogRecord[] = []; + + send(record: CpsLogRecord): void { + this.records.push(record); + } + + query(filter: CpsLogQuery): Promise { + let found = this.records; + if (filter.correlationId) { + found = found.filter((r) => r.correlationId === filter.correlationId); + } + if (filter.logger) { + found = found.filter((r) => r.logger === filter.logger); + } + if (filter.limit !== undefined) { + found = found.slice(0, filter.limit); + } + return Promise.resolve(found); + } +} + +describe('CpsBiTelemetryService', () => { + let service: CpsBiTelemetryService; + let sink: RecordingSink; + + function configure(sinkClass: unknown = RecordingSink): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + sinkClass as never, + { provide: CpsTelemetrySink, useExisting: sinkClass as never } + ] + }); + service = TestBed.inject(CpsBiTelemetryService); + } + + function lastEvent(): CpsBiEvent { + const events = sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi); + return events[events.length - 1].payload as unknown as CpsBiEvent; + } + + beforeEach(() => { + localStorage.clear(); + configure(); + sink = TestBed.inject(RecordingSink); + }); + + afterEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + describe('event creation', () => { + it('should record the application-supplied event name and metadata', () => { + service.track('export_clicked', { + exportType: 'csv', + source: 'customer-table' + }); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(1); + expect(lastEvent()).toMatchObject({ + eventName: 'export_clicked', + metadata: { exportType: 'csv', source: 'customer-table' } + }); + }); + + it('should work with no metadata at all', () => { + service.track('modal_opened'); + expect(lastEvent()).toMatchObject({ eventName: 'modal_opened' }); + expect(lastEvent().metadata).toBeUndefined(); + }); + + it('should carry an ISO-8601 event time', () => { + service.track('tab_selected'); + expect(lastEvent().eventTime).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + ); + }); + + it('should not carry a route — the RUM envelope already stamps the page', () => { + service.track('filter_changed'); + + expect(lastEvent()).not.toHaveProperty('route'); + }); + + it('should ignore an empty event name', () => { + service.track(''); + expect(sink.events).toHaveLength(0); + }); + + it('should redact sensitive metadata', () => { + service.track('sign_in_submitted', { + password: 'hunter2', + method: 'sso' + }); + + expect(lastEvent().metadata).toEqual({ + password: CPS_REDACTED, + method: 'sso' + }); + }); + + it('should drop nested objects rather than serializing them', () => { + service.track('row_selected', { + customer: { id: 1, email: 'a@b.c' }, + rowIndex: 4 + } as never); + + expect(lastEvent().metadata).toEqual({ rowIndex: 4 }); + }); + }); + + describe('withBiEvents({ redact: false })', () => { + it('should skip configurable PII scrubbing but keep the built-in credential denylist', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withBiEvents({ redact: false }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const unredactedService = TestBed.inject(CpsBiTelemetryService); + const unredactedSink = TestBed.inject(RecordingSink); + + unredactedService.track('sign_in_submitted', { + password: 'hunter2', + method: 'sso' + }); + + const [event] = unredactedSink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi); + expect((event.payload as { metadata: unknown }).metadata).toEqual({ + password: CPS_REDACTED, + method: 'sso' + }); + }); + }); + + describe('scenario correlation', () => { + it('should attach the supplied scenario id', () => { + const scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + const scenario = scenarioTelemetry.start({ name: 'export-data' }); + + service.track( + 'export_clicked', + { exportType: 'csv' }, + { scenarioId: scenario.id, feature: 'customers' } + ); + + expect(lastEvent()).toMatchObject({ + scenarioId: scenario.id, + feature: 'customers' + }); + }); + + it('should length-cap feature, the same as any other string field', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withRedaction({ maxStringLength: 4 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsBiTelemetryService); + const cappedSink = TestBed.inject(RecordingSink); + + capped.track('export_clicked', undefined, { feature: 'customers' }); + + const [event] = cappedSink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi); + expect((event.payload as { feature: string }).feature).toBe('cust…'); + }); + + it('should leave the scenario id absent when there is no scenario', () => { + service.track('export_clicked'); + expect(lastEvent().scenarioId).toBeUndefined(); + }); + }); + + describe('deduplication', () => { + it('should collapse an identical event fired twice in quick succession', () => { + service.track('export_clicked'); + service.track('export_clicked'); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(1); + }); + + it('should not collapse different event names', () => { + service.track('export_clicked'); + service.track('modal_opened'); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2); + }); + + it('should not collapse the same event across different scenarios', () => { + service.track('export_clicked', undefined, { scenarioId: 'a' }); + service.track('export_clicked', undefined, { scenarioId: 'b' }); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2); + }); + + it('should not collapse the same event across different features', () => { + service.track('export_clicked', undefined, { feature: 'customers' }); + service.track('export_clicked', undefined, { feature: 'invoices' }); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2); + }); + + it('should not collapse the same event across different event types', () => { + service.track('export_clicked', undefined, { eventType: 'com.cps.a' }); + service.track('export_clicked', undefined, { eventType: 'com.cps.b' }); + + expect(sink.events).toHaveLength(2); + }); + + it('should allow the event again once the window has passed', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(1_000_000); + service.track('export_clicked'); + + nowSpy.mockReturnValue(1_000_000 + 500); + service.track('export_clicked'); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2); + }); + + it('should read the dedup window from CPS_BI_CONFIG, not a hardcoded value', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withBiEvents({ dedupWindowMs: 5_000 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const configured = TestBed.inject(CpsBiTelemetryService); + const configuredSink = TestBed.inject(RecordingSink); + + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + configured.track('export_clicked'); + + nowSpy.mockReturnValue(1_000); + configured.track('export_clicked'); + + expect(configuredSink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength( + 1 + ); + }); + + it('should not collapse events with different metadata under the same name', () => { + service.track('theme_option_changed', { dimension: 'color' }); + service.track('theme_option_changed', { dimension: 'radius' }); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2); + }); + + it('should still collapse identical metadata built in a different key order', () => { + service.track('theme_option_changed', { dimension: 'color', value: 'x' }); + service.track('theme_option_changed', { value: 'x', dimension: 'color' }); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(1); + }); + + it('should not collapse metadata that only collides under naive string joining', () => { + service.track('order_placed', { a: '1&b=2' } as never); + service.track('order_placed', { a: '1', b: '2' } as never); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2); + }); + + it('should not collapse two events whose eventType/feature only collide under naive delimiter joining', () => { + service.track('checkout', undefined, { eventType: 'x|y', feature: 'z' }); + service.track('checkout', undefined, { eventType: 'x', feature: 'y|z' }); + + expect(sink.events).toHaveLength(2); + }); + + it('should sweep stale entries once the key cap is reached, freeing them for reuse', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withBiEvents({ dedupWindowMs: 50, dedupMaxKeys: 2 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsBiTelemetryService); + const cappedSink = TestBed.inject(RecordingSink); + const nowSpy = jest.spyOn(performance, 'now'); + + nowSpy.mockReturnValue(0); + capped.track('event_a'); + capped.track('event_b'); + + nowSpy.mockReturnValue(100); + capped.track('event_c'); + capped.track('event_a'); + capped.track('event_b'); + + expect(cappedSink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(5); + }); + + it('should evict the oldest key once the cap is reached even with nothing stale to sweep', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withBiEvents({ dedupWindowMs: 100_000, dedupMaxKeys: 2 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsBiTelemetryService); + const cappedSink = TestBed.inject(RecordingSink); + const nowSpy = jest.spyOn(performance, 'now'); + + nowSpy.mockReturnValue(0); + capped.track('key_a'); + nowSpy.mockReturnValue(1); + capped.track('key_b'); + + nowSpy.mockReturnValue(2); + capped.track('key_c'); + + nowSpy.mockReturnValue(3); + capped.track('key_a'); + + expect(cappedSink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(4); + }); + + it('should not evict a just-refreshed key over a genuinely older one, purely because Map.set left it at its original insertion position', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withBiEvents({ dedupWindowMs: 1_000, dedupMaxKeys: 3 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsBiTelemetryService); + const cappedSink = TestBed.inject(RecordingSink); + const nowSpy = jest.spyOn(performance, 'now'); + + nowSpy.mockReturnValue(0); + capped.track('key_c'); + + nowSpy.mockReturnValue(500); + capped.track('key_d'); + + nowSpy.mockReturnValue(1_100); + capped.track('key_c'); + + nowSpy.mockReturnValue(1_150); + capped.track('key_e'); + + nowSpy.mockReturnValue(1_160); + capped.track('key_f'); + + nowSpy.mockReturnValue(1_170); + capped.track('key_c'); + + const keyCEvents = cappedSink + .ofType(CPS_TELEMETRY_EVENT_TYPE.bi) + .filter( + (e) => (e.payload as { eventName: string }).eventName === 'key_c' + ); + expect(keyCEvents).toHaveLength(2); + }); + + it('should not evict a frequently-duplicated ("hot") key over one that was only ever touched once, purely because duplicate hits never move it in Map order', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withBiEvents({ dedupWindowMs: 1_000, dedupMaxKeys: 2 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsBiTelemetryService); + const cappedSink = TestBed.inject(RecordingSink); + const nowSpy = jest.spyOn(performance, 'now'); + + nowSpy.mockReturnValue(0); + capped.track('hot'); + + nowSpy.mockReturnValue(100); + capped.track('cold'); + + nowSpy.mockReturnValue(200); + capped.track('hot'); + + nowSpy.mockReturnValue(300); + capped.track('warm'); + + nowSpy.mockReturnValue(310); + capped.track('hot'); + + const hotEvents = cappedSink + .ofType(CPS_TELEMETRY_EVENT_TYPE.bi) + .filter( + (e) => (e.payload as { eventName: string }).eventName === 'hot' + ); + expect(hotEvents).toHaveLength(1); + }); + + it('should not let a backwards Date.now jump affect dedup timing at all', () => { + const dateSpy = jest.spyOn(Date, 'now'); + dateSpy.mockReturnValue(2_000_000_000); + service.track('export_clicked'); + + dateSpy.mockReturnValue(1_000_000); + service.track('export_clicked'); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(1); + dateSpy.mockRestore(); + }); + }); + + describe('debug output', () => { + let consoleLog: jest.SpyInstance; + + beforeEach(() => { + consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should stay silent by default', () => { + service.track('export_clicked'); + expect(consoleLog).not.toHaveBeenCalled(); + }); + + it.each(['true', '1'])( + 'should log the event when debugBI is "%s"', + (value) => { + localStorage.setItem('debugBI', value); + service.track('export_clicked', { exportType: 'csv' }); + + expect(consoleLog).toHaveBeenCalledWith( + '[cps][bi] export_clicked -> com.cps.bi', + expect.objectContaining({ eventName: 'export_clicked' }) + ); + } + ); + + it('should log the very object handed to the sink', () => { + localStorage.setItem('debugBI', 'true'); + service.track('export_clicked', { exportType: 'csv' }); + + const [label, logged] = consoleLog.mock.calls[0]; + const sent = sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)[0]; + + expect(logged).toBe(sent.payload); + expect(label).toContain(CPS_TELEMETRY_EVENT_TYPE.bi); + }); + + it('should stay silent for an invalid debugBI value', () => { + localStorage.setItem('debugBI', 'yes'); + service.track('export_clicked'); + expect(consoleLog).not.toHaveBeenCalled(); + }); + + it('should not log secrets even when debugging is on', () => { + localStorage.setItem('debugBI', 'true'); + service.track('sign_in', { password: 'hunter2' }); + + expect(consoleLog).toHaveBeenCalledWith( + '[cps][bi] sign_in -> com.cps.bi', + expect.objectContaining({ metadata: { password: CPS_REDACTED } }) + ); + }); + }); + + describe('event namespace', () => { + it('should emit under the default namespace', () => { + service.track('export_clicked'); + expect(sink.events[0].eventType).toBe('com.cps.bi'); + }); + + it('should emit under an application-specific namespace', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'test-app', + environment: 'test', + version: '1.0.0', + eventNamespace: 'com.data-gateway' + }), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + + const namespaced = TestBed.inject(CpsBiTelemetryService); + const namespacedSink = TestBed.inject(RecordingSink); + namespaced.track('export_clicked'); + + expect(namespacedSink.events[0].eventType).toBe('com.data-gateway.bi'); + }); + + it('should let one event override the type, for a legacy dashboard', () => { + service.track( + 'click', + { source: 'toolbar' }, + { + eventType: 'com.data-gateway.click' + } + ); + + expect(sink.events[0].eventType).toBe('com.data-gateway.click'); + expect(sink.events[0].payload.eventName).toBe('click'); + }); + + it('should fall back to the configured type when the override is empty', () => { + service.track('click', undefined, { eventType: '' }); + expect(sink.events[0].eventType).toBe('com.cps.bi'); + }); + }); + + describe('failure isolation', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('should never let a broken sink reach application code', () => { + configure(ThrowingSink); + expect(() => service.track('export_clicked')).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('failed'), + expect.any(Error) + ); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service.ts b/projects/cps-telemetry/src/lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service.ts new file mode 100644 index 000000000..aad29187a --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service.ts @@ -0,0 +1,184 @@ +import { inject, Injectable } from '@angular/core'; +import { + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY +} from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CPS_BI_CONFIG } from '../../config/cps-bi.config/cps-bi.config'; +import { + CpsBiEvent, + CpsBiEventDetail +} from '../../models/cps-bi.models/cps-bi.models'; +import { + cpsEventTypes, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { cpsIsDebugEnabled } from '../../utils/cps-debug-flag.util/cps-debug-flag.util'; +import { + cpsRedactConfigFor, + cpsRedactMetadata, + cpsScrubString +} from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { + cpsNow, + cpsSafeVoid +} from '../../utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util'; + +/** + * Business and UX event tracking — feature adoption, interaction analysis, + * funnel steps. + * + * Unlike scenarios, BI events are discrete: no duration, no lifecycle. Event + * names and attributes come entirely from the application. + * + * Repeated identical events within a short window are collapsed, absorbing + * double-fires from a `click` handler also bound to `keydown`, or from a + * user clicking twice. + * + * Console output is off unless the `debugBI` LocalStorage flag is set: + * + * ```js + * localStorage.setItem('debugBI', 'true'); + * ``` + * + * @example + * ```typescript + * class CustomerTableComponent { + * private biTelemetry = inject(CpsBiTelemetryService); + * + * onExport(format: string) { + * this.biTelemetry.track('export_clicked', { + * exportType: format, + * source: 'customer-table' + * }); + * } + * } + * ``` + * + * @group Services + */ +@Injectable({ providedIn: 'root' }) +export class CpsBiTelemetryService { + private readonly identity = inject(CPS_TELEMETRY_IDENTITY); + private readonly biConfig = inject(CPS_BI_CONFIG); + private readonly redact = cpsRedactConfigFor( + inject(CPS_REDACT_CONFIG), + this.biConfig.redact + ); + + private readonly sink = inject(CpsTelemetrySink); + private readonly eventTypes = cpsEventTypes(this.identity.eventNamespace); + private readonly lastEmittedAt = new Map(); + + /** + * Records a business or UX event. + * + * @param eventName the application's own event name, e.g. `export_clicked`. + * Treat it as a metric dimension: keep the cardinality low and never + * interpolate an identifier into it. + * @param metadata flat attributes describing the interaction + * @param detail optional scenario correlation, feature and event-type override + */ + track( + eventName: string, + metadata?: CpsTelemetryMetadata, + detail?: CpsBiEventDetail + ): void { + cpsSafeVoid('biTelemetry.track', () => { + if (!eventName) { + return; + } + + if (this.isDuplicate(eventName, metadata, detail)) { + return; + } + + const event: CpsBiEvent = { + eventName, + eventTime: new Date().toISOString(), + scenarioId: detail?.scenarioId, + feature: detail?.feature + ? cpsScrubString(detail.feature, this.redact) + : undefined, + metadata: cpsRedactMetadata(metadata, this.redact), + application: this.identity.application + }; + + const eventType = detail?.eventType || this.eventTypes.bi; + + if (cpsIsDebugEnabled('debugBI')) { + writeToConsole(eventName, eventType, event); + } + + this.sink.record(eventType, event as unknown as object); + }); + } + + private isDuplicate( + eventName: string, + metadata: CpsTelemetryMetadata | undefined, + detail?: CpsBiEventDetail + ): boolean { + const key = JSON.stringify([ + eventName, + detail?.scenarioId ?? '', + detail?.eventType ?? '', + detail?.feature ?? '', + this.metadataKey(metadata) + ]); + const now = cpsNow(); + const last = this.lastEmittedAt.get(key); + + if (last !== undefined && now - last < this.biConfig.dedupWindowMs) { + this.lastEmittedAt.delete(key); + this.lastEmittedAt.set(key, last); + return true; + } + + if (this.lastEmittedAt.size >= this.biConfig.dedupMaxKeys) { + for (const [staleKey, at] of this.lastEmittedAt) { + if (now - at >= this.biConfig.dedupWindowMs) { + this.lastEmittedAt.delete(staleKey); + } + } + + if (this.lastEmittedAt.size >= this.biConfig.dedupMaxKeys) { + const oldestKey = this.lastEmittedAt.keys().next().value; + if (oldestKey !== undefined) { + this.lastEmittedAt.delete(oldestKey); + } + } + } + + this.lastEmittedAt.delete(key); + this.lastEmittedAt.set(key, now); + return false; + } + + /** + * A stable string encoding a flat metadata object's content, for use in + * the dedup key — otherwise two same-named events with different metadata + * would collide and the second would be silently dropped. Keys are sorted + * so property order doesn't affect the result, and built from the raw + * metadata, before redaction runs. + */ + private metadataKey(metadata: CpsTelemetryMetadata | undefined): string { + if (!metadata) { + return ''; + } + return JSON.stringify( + Object.keys(metadata) + .sort() + .map((k) => [k, metadata[k]]) + ); + } +} + +function writeToConsole( + eventName: string, + eventType: string, + event: CpsBiEvent +): void { + // eslint-disable-next-line no-console + console.log(`[cps][bi] ${eventName} -> ${eventType}`, event); +} diff --git a/projects/cps-telemetry/src/lib/services/cps-logger.service/cps-logger.service.spec.ts b/projects/cps-telemetry/src/lib/services/cps-logger.service/cps-logger.service.spec.ts new file mode 100644 index 000000000..3bed0cdae --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-logger.service/cps-logger.service.spec.ts @@ -0,0 +1,847 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CpsLogConfig } from '../../config/cps-log.config/cps-log.config'; +import { + provideCpsTelemetry, + withLogging, + withRedaction +} from '../../providers/cps-telemetry-common.providers/cps-telemetry-common.providers'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { + CPS_LOG_API_PROVIDER, + CpsLogApiProvider, + CpsLogQuery +} from '../../providers/cps-log-api.provider/cps-log-api.provider'; +import * as cpsTelemetryRedactUtil from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { CPS_REDACTED } from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { CpsLoggerService } from './cps-logger.service'; +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; + +/** Captures what the library emitted, so a test can assert on it. */ +@Injectable() +class RecordingSink extends CpsTelemetrySink { + readonly events: { + eventType: string; + payload: Record; + metadata?: CpsTelemetryMetadata; + }[] = []; + + readonly errors: CpsTelemetryError[] = []; + readonly flushes: boolean[] = []; + userId?: string; + sessionId: string | undefined = 'test-session'; + + record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void { + this.events.push({ + eventType, + payload: payload as Record, + metadata + }); + } + + recordError(error: CpsTelemetryError): void { + this.errors.push(error); + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(beacon = false): void { + this.flushes.push(beacon); + } + + ofType(eventType: string) { + return this.events.filter((event) => event.eventType === eventType); + } +} + +/** Fails on every call, to prove telemetry cannot break the caller. */ +@Injectable() +class ThrowingSink extends CpsTelemetrySink { + record(): never { + throw new Error('sink is broken'); + } + + recordError(): never { + throw new Error('sink is broken'); + } + + getSessionId(): never { + throw new Error('sink is broken'); + } + + setUserId(): never { + throw new Error('sink is broken'); + } + + getUserId(): never { + throw new Error('sink is broken'); + } + + flush(): never { + throw new Error('sink is broken'); + } +} + +/** Keeps every record, so a test can assert on what was shipped. */ +@Injectable() +class RecordingLogApi implements CpsLogApiProvider { + readonly records: CpsLogRecord[] = []; + flushCount = 0; + + send(record: CpsLogRecord): void { + this.records.push(record); + } + + query(filter: CpsLogQuery): Promise { + let found = this.records; + if (filter.correlationId) { + found = found.filter((r) => r.correlationId === filter.correlationId); + } + if (filter.logger) { + found = found.filter((r) => r.logger === filter.logger); + } + if (filter.limit !== undefined) { + found = found.slice(0, filter.limit); + } + return Promise.resolve(found); + } + + flush(): void { + this.flushCount++; + } +} + +/** A provider implementing no `flush` at all, the common case. */ +@Injectable() +class NoFlushLogApi implements CpsLogApiProvider { + send(): void {} + + query(): Promise { + return Promise.resolve([]); + } +} + +/** Fails on every call, to prove logging cannot break the caller. */ +@Injectable() +class ThrowingLogApi implements CpsLogApiProvider { + send(): never { + throw new Error('log backend is down'); + } + + query(): Promise { + return Promise.reject(new Error('log backend is down')); + } +} + +describe('CpsLoggerService', () => { + let logger: CpsLoggerService; + let transport: RecordingLogApi; + let sink: RecordingSink; + + function configure(logsOverrides?: Partial): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withLogging(logsOverrides) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + logger = TestBed.inject(CpsLoggerService); + transport = TestBed.inject(RecordingLogApi); + sink = TestBed.inject(RecordingSink); + } + + beforeEach(() => { + localStorage.clear(); + configure(); + }); + + afterEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + describe('levels', () => { + it.each(['log', 'warn', 'error'] as const)( + 'should send a %s record to the transport', + (level) => { + logger[level]('a message'); + + const records = transport.records; + expect(records).toHaveLength(1); + expect(records[0].level).toBe(level); + expect(records[0].message).toBe('a message'); + } + ); + + it('should discard records below the configured minimum level', () => { + configure({ minLevel: 'warn' }); + + logger.log('dropped'); + logger.warn('kept'); + logger.error('kept too'); + + expect(transport.records.map((r) => r.level)).toEqual(['warn', 'error']); + }); + }); + + describe('record shape', () => { + it('should stamp the ambient application context onto every record', () => { + logger.log('hello'); + + expect(transport.records[0]).toMatchObject({ + application: 'test-app', + environment: 'test', + version: '1.0.0', + sessionId: 'test-session' + }); + }); + + it('should take the user id from the sink, not from a copy of its own', () => { + expect(transport.records).toHaveLength(0); + sink.setUserId('user-42'); + logger.log('after sign-in'); + + expect(transport.records[0].userId).toBe('user-42'); + }); + + it('should stop attributing records after sign-out', () => { + sink.setUserId('user-42'); + sink.setUserId(undefined); + logger.log('after sign-out'); + + const record = transport.records[0]; + expect(record).toHaveProperty('userId', undefined); + }); + + it('should carry an ISO-8601 timestamp', () => { + logger.log('hello'); + expect(transport.records[0].timestamp).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + ); + }); + + it('should record context, metadata and correlation id when supplied', () => { + logger.warn('careful', { + context: 'CustomerService', + metadata: { attempt: 2 }, + correlationId: 'scenario-1' + }); + + expect(transport.records[0]).toMatchObject({ + context: 'CustomerService', + metadata: { attempt: 2 }, + correlationId: 'scenario-1' + }); + }); + + it('should length-cap context, the same as message', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withRedaction({ maxStringLength: 4 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsLoggerService); + const cappedTransport = TestBed.inject(RecordingLogApi); + + capped.warn('careful', { context: 'CustomerService' }); + + expect(cappedTransport.records[0].context).toBe('Cust…'); + }); + + it('should require nothing beyond the message', () => { + expect(() => logger.log('bare')).not.toThrow(); + expect(transport.records[0].metadata).toBeUndefined(); + expect(transport.records[0].error).toBeUndefined(); + }); + + it('should normalize an error rather than passing it through raw', () => { + logger.error('failed', { error: new TypeError('boom') }); + + const { error } = transport.records[0]; + expect(error).toMatchObject({ name: 'TypeError', message: 'boom' }); + expect(error).not.toBeInstanceOf(Error); + }); + + it('should redact sensitive metadata before it leaves the browser', () => { + logger.log('sign-in attempt', { + metadata: { password: 'hunter2', username: 'ada' } + }); + + expect(transport.records[0].metadata).toEqual({ + password: CPS_REDACTED, + username: 'ada' + }); + }); + + it('should strip URL query strings from the message', () => { + logger.error('GET https://api.dev/me?access_token=xyz failed'); + expect(transport.records[0].message).toBe( + 'GET https://api.dev/me failed' + ); + }); + + it('should scrub correlationId the same way context is scrubbed, not pass it through unredacted', () => { + logger.log('hello', { + correlationId: 'https://api.dev/trace?access_token=xyz' + }); + + expect(transport.records[0].correlationId).toBe('https://api.dev/trace'); + }); + }); + + describe('withLogging({ redact: false })', () => { + it('should skip configurable PII scrubbing but keep the built-in credential denylist and size caps', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withLogging({ redact: false }), + withRedaction({ maxStringLength: 4 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const unredactedLogger = TestBed.inject(CpsLoggerService); + const unredactedTransport = TestBed.inject(RecordingLogApi); + + unredactedLogger.error('GET https://api.dev/me?access_token=xyz failed', { + metadata: { password: 'hunter2', username: 'ada' } + }); + + expect(unredactedTransport.records[0].message).toBe('GET …'); + expect(unredactedTransport.records[0].metadata).toEqual({ + password: CPS_REDACTED, + username: 'ada' + }); + }); + }); + + describe('console output', () => { + let consoleLog: jest.SpyInstance; + let consoleWarn: jest.SpyInstance; + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should stay silent by default', () => { + logger.log('quiet'); + logger.warn('quiet'); + logger.error('quiet'); + + expect(consoleLog).not.toHaveBeenCalled(); + expect(consoleWarn).not.toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('should write to the console when debugLogger is "true"', () => { + localStorage.setItem('debugLogger', 'true'); + logger.log('loud'); + expect(consoleLog).toHaveBeenCalledWith( + '[cps] loud', + expect.objectContaining({ message: 'loud' }) + ); + }); + + it('should log the very record handed to the transport', () => { + localStorage.setItem('debugLogger', 'true'); + logger.log('loud', { metadata: { a: 1 } }); + + const [label, logged] = consoleLog.mock.calls[0]; + + expect(label).toBe('[cps] loud'); + expect(logged).toBe(transport.records[0]); + }); + + it('should write only the named logger when debugLogger names it', () => { + localStorage.setItem('debugLogger', 'checkout'); + logger.getLogger('checkout').log('loud'); + logger.getLogger('admin').log('quiet'); + logger.log('quiet too'); + + expect(consoleLog).toHaveBeenCalledTimes(1); + expect(consoleLog).toHaveBeenCalledWith( + '[cps] loud', + expect.objectContaining({ logger: 'checkout' }) + ); + }); + + it('should accept a comma-separated list of logger names', () => { + localStorage.setItem('debugLogger', 'checkout, admin'); + logger.getLogger('checkout').log('a'); + logger.getLogger('admin').log('b'); + logger.getLogger('reports').log('c'); + + expect(consoleLog).toHaveBeenCalledTimes(2); + }); + + it('should still write every logger when debugLogger is "true"', () => { + localStorage.setItem('debugLogger', 'true'); + logger.getLogger('checkout').log('a'); + logger.log('b'); + + expect(consoleLog).toHaveBeenCalledTimes(2); + }); + + it('should write to the console when debugLogger is "1"', () => { + localStorage.setItem('debugLogger', '1'); + logger.warn('loud'); + expect(consoleWarn).toHaveBeenCalled(); + }); + + it('should stay silent for an invalid debugLogger value', () => { + localStorage.setItem('debugLogger', 'yes'); + logger.log('quiet'); + expect(consoleLog).not.toHaveBeenCalled(); + }); + + it('should use the console method matching the level', () => { + localStorage.setItem('debugLogger', 'true'); + logger.error('bad'); + expect(consoleError).toHaveBeenCalled(); + expect(consoleLog).not.toHaveBeenCalled(); + }); + + it('should include the context and correlation id in the console line', () => { + localStorage.setItem('debugLogger', 'true'); + logger.log('working', { + context: 'Loader', + correlationId: 'abc-123' + }); + expect(consoleLog).toHaveBeenCalledWith( + '[cps][Loader] working (abc-123)', + expect.objectContaining({ correlationId: 'abc-123' }) + ); + }); + + it('should still send to the transport while console output is on', () => { + localStorage.setItem('debugLogger', 'true'); + logger.log('both'); + expect(transport.records).toHaveLength(1); + }); + }); + + describe('child loggers', () => { + it('should pre-bind the correlation id onto every call', () => { + const child = logger.child({ correlationId: 'scenario-9' }); + + child.log('one'); + child.error('two'); + + expect(transport.records.map((r) => r.correlationId)).toEqual([ + 'scenario-9', + 'scenario-9' + ]); + }); + + it('should let a per-call value override a binding', () => { + const child = logger.child({ correlationId: 'bound' }); + child.log('one', { correlationId: 'explicit' }); + expect(transport.records[0].correlationId).toBe('explicit'); + }); + + it('should merge bound metadata with per-call metadata', () => { + const child = logger.child({ metadata: { feature: 'customers' } }); + child.log('one', { metadata: { attempt: 1 } }); + + expect(transport.records[0].metadata).toEqual({ + feature: 'customers', + attempt: 1 + }); + }); + + it('should support nesting', () => { + const nested = logger + .child({ context: 'Outer' }) + .child({ correlationId: 'inner-id' }); + + nested.log('deep'); + + expect(transport.records[0]).toMatchObject({ + context: 'Outer', + correlationId: 'inner-id' + }); + }); + }); + + describe('named loggers', () => { + it('should stamp the name onto every record', () => { + configure(); + logger.getLogger('checkout').log('submitting'); + + expect(transport.records[0].logger).toBe('checkout'); + }); + + it('should keep context free-form alongside the name', () => { + configure(); + logger + .getLogger('checkout', { context: 'OrderService' }) + .log('submitting'); + + expect(transport.records[0]).toMatchObject({ + logger: 'checkout', + context: 'OrderService' + }); + }); + + it('should let a per-call logger override the bound one', () => { + configure(); + logger.getLogger('checkout').log('elsewhere', { logger: 'admin' }); + + expect(transport.records[0].logger).toBe('admin'); + }); + + it('should carry the name through a nested child', () => { + configure(); + logger + .getLogger('checkout') + .child({ correlationId: 'abc' }) + .log('nested'); + + expect(transport.records[0]).toMatchObject({ + logger: 'checkout', + correlationId: 'abc' + }); + }); + + it('should leave records from the bare service unnamed', () => { + configure(); + logger.log('no name'); + + expect(transport.records[0].logger).toBeUndefined(); + }); + }); + + describe('per-logger levels', () => { + it('should let one logger run below the global floor', () => { + configure({ minLevel: 'warn', levels: { checkout: 'log' } }); + + logger.getLogger('checkout').log('kept'); + logger.getLogger('admin').log('dropped'); + logger.log('dropped too'); + + expect(transport.records.map((r) => r.message)).toEqual(['kept']); + }); + + it('should let one logger be quieter than the global floor', () => { + configure({ minLevel: 'log', levels: { checkout: 'error' } }); + + logger.getLogger('checkout').warn('dropped'); + logger.getLogger('checkout').error('kept'); + logger.getLogger('admin').warn('kept too'); + + expect(transport.records.map((r) => r.message)).toEqual([ + 'kept', + 'kept too' + ]); + }); + + it('should fall back to the global floor for an unlisted logger', () => { + configure({ minLevel: 'error', levels: { checkout: 'log' } }); + + logger.getLogger('admin').warn('dropped'); + + expect(transport.records).toHaveLength(0); + }); + }); + + describe('query', () => { + it('should read records back from the backend', async () => { + configure(); + logger.getLogger('checkout').log('first'); + logger.log('second'); + + const all = await logger.query(); + + expect(all.map((r) => r.message)).toEqual(['first', 'second']); + }); + + it('should pass the filter through to the backend', async () => { + configure(); + const scenarioId = 'abc-123'; + logger.log('mine', { correlationId: scenarioId }); + logger.log('someone else'); + + const found = await logger.query({ correlationId: scenarioId }); + + expect(found.map((r) => r.message)).toEqual(['mine']); + }); + + it('should resolve to an empty array when the backend rejects', async () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'test-app', + environment: 'test', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useClass: ThrowingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + + await expect(TestBed.inject(CpsLoggerService).query()).resolves.toEqual( + [] + ); + }); + }); + + describe('requires a log API provider', () => { + it('should fail construction with no CPS_LOG_API_PROVIDER bound', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'test-app', + environment: 'test', + version: '1.0.0' + }), + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + + expect(() => TestBed.inject(CpsLoggerService)).toThrow(); + }); + }); + + describe('RUM error mirroring', () => { + it('should not mirror errors to the sink by default', () => { + logger.error('failed', { error: new Error('boom') }); + expect(sink.errors).toHaveLength(0); + }); + + it('should mirror errors to the sink when enabled', () => { + configure({ mirrorErrorsToRum: true }); + logger.error('failed', { error: new Error('boom') }); + + expect(sink.errors).toEqual([ + expect.objectContaining({ name: 'Error', message: 'boom' }) + ]); + }); + + it('should mirror a message-only error using the message', () => { + configure({ mirrorErrorsToRum: true }); + logger.error('no error object supplied'); + + expect(sink.errors[0].message).toBe('no error object supplied'); + }); + + it('should not mirror non-error levels', () => { + configure({ mirrorErrorsToRum: true }); + logger.warn('careful', { error: new Error('boom') }); + expect(sink.errors).toHaveLength(0); + }); + }); + + describe('without a sink configured', () => { + function configureWithoutSink(logsOverrides?: Partial): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withLogging(logsOverrides) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi } + ] + }); + logger = TestBed.inject(CpsLoggerService); + transport = TestBed.inject(RecordingLogApi); + } + + it('should not throw on any level with no sink provided', () => { + configureWithoutSink(); + expect(() => { + logger.log('a'); + logger.warn('b'); + logger.error('c'); + }).not.toThrow(); + }); + + it('should still deliver the record to the log backend with no sink', () => { + configureWithoutSink(); + logger.log('still delivered'); + expect(transport.records).toHaveLength(1); + }); + + it('should omit sessionId and userId entirely with no sink, not merely leave them undefined', () => { + configureWithoutSink(); + logger.log('no identity source'); + + const record = transport.records[0]; + expect(record).not.toHaveProperty('sessionId'); + expect(record).not.toHaveProperty('userId'); + }); + + it('should not throw when mirrorErrorsToRum is on but no sink is provided', () => { + configureWithoutSink({ mirrorErrorsToRum: true }); + expect(() => + logger.error('failed', { error: new Error('boom') }) + ).not.toThrow(); + }); + + it('should skip building the mirrored error entirely with no sink to send it to', () => { + const normalizeSpy = jest.spyOn( + cpsTelemetryRedactUtil, + 'cpsNormalizeError' + ); + configureWithoutSink({ mirrorErrorsToRum: true }); + + logger.error('message-only error'); + + expect(normalizeSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('failure isolation', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('should not propagate a throwing transport', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'test-app', + environment: 'test', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useClass: ThrowingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + + const isolated = TestBed.inject(CpsLoggerService); + expect(() => isolated.log('still fine')).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('logger.deliver failed'), + expect.any(Error) + ); + }); + + it('should not propagate a throwing sink while mirroring errors', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withLogging({ mirrorErrorsToRum: true }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + ThrowingSink, + { provide: CpsTelemetrySink, useExisting: ThrowingSink } + ] + }); + + const isolated = TestBed.inject(CpsLoggerService); + expect(() => isolated.error('still fine')).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('failed'), + expect.any(Error) + ); + }); + }); + + describe('page unload', () => { + it('should flush the provider on pagehide', () => { + window.dispatchEvent(new Event('pagehide')); + expect(transport.flushCount).toBe(1); + }); + + it('should flush the provider when the page goes hidden', () => { + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + expect(transport.flushCount).toBe(1); + }); + + it('should not flush merely on becoming visible again', () => { + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); + document.dispatchEvent(new Event('visibilitychange')); + expect(transport.flushCount).toBe(0); + }); + + it('should flush the provider on destroy', () => { + logger.ngOnDestroy(); + expect(transport.flushCount).toBe(1); + }); + + it('should stop listening once destroyed', () => { + logger.ngOnDestroy(); + transport.flushCount = 0; + + window.dispatchEvent(new Event('pagehide')); + + expect(transport.flushCount).toBe(0); + }); + + it('should tolerate a provider that implements no flush', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'test-app', + environment: 'test', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useClass: NoFlushLogApi } + ] + }); + TestBed.inject(CpsLoggerService); + + expect(() => window.dispatchEvent(new Event('pagehide'))).not.toThrow(); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/services/cps-logger.service/cps-logger.service.ts b/projects/cps-telemetry/src/lib/services/cps-logger.service/cps-logger.service.ts new file mode 100644 index 000000000..121e7e579 --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-logger.service/cps-logger.service.ts @@ -0,0 +1,339 @@ +import { DOCUMENT } from '@angular/common'; +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY +} from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CPS_LOG_CONFIG } from '../../config/cps-log.config/cps-log.config'; +import { + CPS_LOG_LEVEL_ORDER, + CpsLoggerName, + CpsLogDetail, + CpsLogLevel, + CpsLogRecord +} from '../../models/cps-log.models/cps-log.models'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { + CPS_LOG_API_PROVIDER, + CpsLogQuery +} from '../../providers/cps-log-api.provider/cps-log-api.provider'; +import { cpsIsDebugEnabled } from '../../utils/cps-debug-flag.util/cps-debug-flag.util'; +import { + cpsNormalizeError, + cpsRedactConfigFor, + cpsRedactMetadata, + cpsScrubString +} from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { + cpsIsBrowser, + cpsSafe, + cpsSafeVoid, + cpsSafeVoidMaybeAsync +} from '../../utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util'; + +/** + * Detail pre-bound onto a child logger by {@link CpsLogger.child}. + * + * @group Types + */ +export type CpsLogBindings = Pick< + CpsLogDetail, + 'logger' | 'context' | 'correlationId' | 'metadata' +>; + +/** + * The application-facing logging API. + * + * @group Interfaces + */ +export interface CpsLogger { + log(message: string, detail?: CpsLogDetail): void; + warn(message: string, detail?: CpsLogDetail): void; + error(message: string, detail?: CpsLogDetail): void; + + /** + * Returns a logger that pre-applies the given detail to every call. + * + * @param bindings detail merged into every record from the child + */ + child(bindings: CpsLogBindings): CpsLogger; +} + +/** + * Structured application logging. + * + * Log records go to the application's {@link CpsLogApiProvider}, **not** to + * AWS RUM — RUM is a sampled, session-capped analytics stream, while logs + * need full fidelity and their own retention. + * + * Console output is off unless the `debugLogger` LocalStorage flag is set, in + * any environment: + * + * ```js + * localStorage.setItem('debugLogger', 'true'); + * ``` + * + * @example + * ```typescript + * private logger = inject(CpsLoggerService); + * + * this.logger.error('Failed to load customer data', { + * error, + * correlationId: scenario.id + * }); + * ``` + * + * @group Services + */ +@Injectable({ providedIn: 'root' }) +export class CpsLoggerService implements CpsLogger, OnDestroy { + private readonly identity = inject(CPS_TELEMETRY_IDENTITY); + private readonly logsConfig = inject(CPS_LOG_CONFIG); + private readonly redact = cpsRedactConfigFor( + inject(CPS_REDACT_CONFIG), + this.logsConfig.redact + ); + + /** Enrichment only (`sessionId`/`userId`, optional RUM mirroring) — see DESIGN.md §10. */ + private readonly sink = inject(CpsTelemetrySink, { optional: true }); + private readonly apiProvider = inject(CPS_LOG_API_PROVIDER); + private readonly document = inject(DOCUMENT); + private readonly isBrowser = cpsIsBrowser(); + + private readonly onPageHide = () => this.flushProvider(); + private readonly onVisibilityChange = () => { + if (this.document.visibilityState === 'hidden') { + this.flushProvider(); + } + }; + + constructor() { + if (this.isBrowser) { + this.document.defaultView?.addEventListener('pagehide', this.onPageHide); + + this.document.addEventListener( + 'visibilitychange', + this.onVisibilityChange + ); + } + } + + /** @inheritdoc */ + ngOnDestroy(): void { + if (this.isBrowser) { + this.document.defaultView?.removeEventListener( + 'pagehide', + this.onPageHide + ); + this.document.removeEventListener( + 'visibilitychange', + this.onVisibilityChange + ); + } + this.flushProvider(); + } + + /** + * Records an informational message. + * + * @param message the message; URL query strings are stripped from it + * @param detail optional context, metadata, error and correlation id + */ + log(message: string, detail?: CpsLogDetail): void { + this.emit('log', message, detail); + } + + /** + * Records a warning. + * + * @param message the message + * @param detail optional context, metadata, error and correlation id + */ + warn(message: string, detail?: CpsLogDetail): void { + this.emit('warn', message, detail); + } + + /** + * Records an error. + * + * @param message the message + * @param detail optional context, metadata, error and correlation id + */ + error(message: string, detail?: CpsLogDetail): void { + this.emit('error', message, detail); + } + + /** + * Returns a logger that stamps the given detail onto every record. + * + * @param bindings detail merged into every record from the child + * @returns a bound logger + */ + child(bindings: CpsLogBindings): CpsLogger { + return { + log: (message, detail) => this.log(message, merge(bindings, detail)), + warn: (message, detail) => this.warn(message, merge(bindings, detail)), + error: (message, detail) => this.error(message, merge(bindings, detail)), + child: (nested) => this.child(merge(bindings, nested) as CpsLogBindings) + }; + } + + /** + * Reads records back from the application's log backend. Useful for + * pulling one journey together — every line written during a scenario + * shares its id as the correlation id: + * + * @example + * ```typescript + * const lines = await this.logger.query({ correlationId: scenario.id }); + * ``` + * + * Fail-open: a provider that throws or rejects resolves to `[]`. + * + * @param filter narrows what is returned + * @returns the matching records, or `[]` when none can be read + */ + async query(filter: CpsLogQuery = {}): Promise { + const pending = cpsSafe( + 'logger.query', + () => this.apiProvider.query(filter), + undefined + ); + + return (await pending?.catch(() => undefined)) ?? []; + } + + /** + * Returns the named logger for one part of the application. The name + * lands on every record as `logger`, and is what + * {@link CpsLogConfig.levels} and the `debugLogger` flag target. Declare + * the name in {@link CpsLoggerNames} first, and bind it once as a field: + * + * @example + * ```typescript + * class CheckoutService { + * private readonly logger = inject(CpsLoggerService).getLogger('checkout'); + * + * submit() { + * this.logger.log('Submitting order'); + * } + * } + * ``` + * + * @param name the logger name, declared in {@link CpsLoggerNames} + * @param bindings further detail stamped onto every record from this logger + * @returns a logger bound to that name + */ + getLogger( + name: CpsLoggerName, + bindings?: Omit + ): CpsLogger { + return this.child({ ...bindings, logger: name }); + } + + /** Severity floor for one logger — its own override, or the global one. */ + private minLevelFor(logger?: CpsLoggerName): CpsLogLevel { + const { levels, minLevel } = this.logsConfig; + return (logger ? levels?.[logger] : undefined) ?? minLevel; + } + + private emit( + level: CpsLogLevel, + message: string, + detail?: CpsLogDetail + ): void { + cpsSafeVoid(`logger.${level}`, () => { + if ( + CPS_LOG_LEVEL_ORDER[level] < + CPS_LOG_LEVEL_ORDER[this.minLevelFor(detail?.logger)] + ) { + return; + } + + const record = this.buildRecord(level, message, detail); + + if (cpsIsDebugEnabled('debugLogger', record.logger)) { + writeToConsole(record); + } + + this.deliver(record); + + if (level === 'error' && this.logsConfig.mirrorErrorsToRum && this.sink) { + const mirrored = + record.error ?? + cpsNormalizeError(new Error(record.message), this.redact); + if (mirrored) { + this.sink.recordError(mirrored); + } + } + }); + } + + /** Guards against a throwing or secretly-async, rejecting provider. */ + private deliver(record: CpsLogRecord): void { + cpsSafeVoidMaybeAsync('logger.deliver', () => + this.apiProvider.send(record) + ); + } + + /** Gives the provider its chance to ship whatever it has queued itself. */ + private flushProvider(): void { + cpsSafeVoidMaybeAsync('logger.providerFlush', () => + this.apiProvider.flush?.() + ); + } + + private buildRecord( + level: CpsLogLevel, + message: string, + detail?: CpsLogDetail + ): CpsLogRecord { + const redact = this.redact; + const sink = this.sink; + const identity = this.identity; + + return { + timestamp: new Date().toISOString(), + level, + message: cpsScrubString(String(message ?? ''), redact), + logger: detail?.logger, + context: detail?.context + ? cpsScrubString(detail.context, redact) + : undefined, + metadata: cpsRedactMetadata(detail?.metadata, redact), + error: cpsNormalizeError(detail?.error, redact), + correlationId: detail?.correlationId + ? cpsScrubString(detail.correlationId, redact) + : undefined, + application: identity.application, + environment: identity.environment, + version: identity.version, + ...(sink && { + userId: cpsSafe('getUserId', () => sink.getUserId(), undefined), + sessionId: cpsSafe('getSessionId', () => sink.getSessionId(), undefined) + }) + }; + } +} + +function merge(bindings: CpsLogBindings, detail?: CpsLogDetail): CpsLogDetail { + return { + ...detail, + logger: detail?.logger ?? bindings.logger, + context: detail?.context ?? bindings.context, + correlationId: detail?.correlationId ?? bindings.correlationId, + metadata: + bindings.metadata || detail?.metadata + ? { ...bindings.metadata, ...detail?.metadata } + : undefined + }; +} + +/** Prints the record exactly as the transport receives it. */ +function writeToConsole(record: CpsLogRecord): void { + const prefix = record.context ? `[cps][${record.context}]` : '[cps]'; + const suffix = record.correlationId ? ` (${record.correlationId})` : ''; + + // eslint-disable-next-line no-console + console[record.level](`${prefix} ${record.message}${suffix}`, record); +} diff --git a/projects/cps-telemetry/src/lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service.spec.ts b/projects/cps-telemetry/src/lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service.spec.ts new file mode 100644 index 000000000..96668d1a1 --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service.spec.ts @@ -0,0 +1,2012 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CpsScenarioConfig } from '../../config/cps-scenario.config/cps-scenario.config'; +import { + provideCpsTelemetry, + withRedaction, + withScenarios +} from '../../providers/cps-telemetry-common.providers/cps-telemetry-common.providers'; +import { + CpsScenarioRecord, + CpsScenarioStatus +} from '../../models/cps-scenario.models/cps-scenario.models'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { + CPS_LOG_API_PROVIDER, + CpsLogApiProvider, + CpsLogQuery +} from '../../providers/cps-log-api.provider/cps-log-api.provider'; +import { CPS_REDACTED } from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { + CpsLogger, + CpsLoggerService +} from '../cps-logger.service/cps-logger.service'; +import { CpsScenarioTelemetryService } from './cps-scenario-telemetry.service'; +import { + CPS_TELEMETRY_EVENT_TYPE, + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; + +/** Captures what the library emitted, so a test can assert on it. */ +@Injectable() +class RecordingSink extends CpsTelemetrySink { + readonly events: { + eventType: string; + payload: Record; + metadata?: CpsTelemetryMetadata; + }[] = []; + + readonly errors: CpsTelemetryError[] = []; + readonly flushes: boolean[] = []; + userId?: string; + sessionId: string | undefined = 'test-session'; + + record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void { + this.events.push({ + eventType, + payload: payload as Record, + metadata + }); + } + + recordError(error: CpsTelemetryError): void { + this.errors.push(error); + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(beacon = false): void { + this.flushes.push(beacon); + } + + ofType(eventType: string) { + return this.events.filter((event) => event.eventType === eventType); + } +} + +/** Fails on every call, to prove telemetry cannot break the caller. */ +@Injectable() +class ThrowingSink extends CpsTelemetrySink { + record(): never { + throw new Error('sink is broken'); + } + + recordError(): never { + throw new Error('sink is broken'); + } + + getSessionId(): never { + throw new Error('sink is broken'); + } + + setUserId(): never { + throw new Error('sink is broken'); + } + + getUserId(): never { + throw new Error('sink is broken'); + } + + flush(): never { + throw new Error('sink is broken'); + } +} + +/** Keeps every batch, so a test can assert on what was shipped. */ +@Injectable() +class RecordingLogApi implements CpsLogApiProvider { + readonly records: CpsLogRecord[] = []; + + send(record: CpsLogRecord): void { + this.records.push(record); + } + + query(filter: CpsLogQuery): Promise { + let found = this.records; + if (filter.correlationId) { + found = found.filter((r) => r.correlationId === filter.correlationId); + } + if (filter.logger) { + found = found.filter((r) => r.logger === filter.logger); + } + if (filter.limit !== undefined) { + found = found.slice(0, filter.limit); + } + return Promise.resolve(found); + } +} + +describe('CpsScenarioTelemetryService', () => { + let service: CpsScenarioTelemetryService; + let sink: RecordingSink; + + function configure( + scenarioOverrides?: Partial, + eventNamespace?: string + ): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { + application: 'test-app', + environment: 'test', + version: '1.0.0', + ...(eventNamespace ? { eventNamespace } : {}) + }, + withScenarios({ + defaultTimeoutMs: 0, + ...scenarioOverrides + }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + service = TestBed.inject(CpsScenarioTelemetryService); + sink = TestBed.inject(RecordingSink); + } + + /** The single packed scenario event, asserted to be the only one emitted. */ + function onlyScenarioRecord(): CpsScenarioRecord { + const events = sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario); + expect(events).toHaveLength(1); + return events[0].payload as unknown as CpsScenarioRecord; + } + + beforeEach(() => { + localStorage.clear(); + configure(); + }); + + afterEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + describe('creation', () => { + it('should return a scenario carrying the requested name', () => { + const scenario = service.start({ name: 'load-customer-data' }); + expect(scenario.name).toBe('load-customer-data'); + expect(scenario.status).toBeUndefined(); + expect(scenario.isSettled).toBe(false); + }); + + it('should report an undefined status on a mid-flight toRecord() snapshot, not a lie', () => { + const scenario = service.start({ name: 'load-customer-data' }); + const snapshot = scenario.toRecord(); + expect(snapshot.status).toBeUndefined(); + expect(snapshot.endTime).toBeUndefined(); + + scenario.complete(); + expect(scenario.toRecord().status).toBe('success'); + }); + + it('should generate a unique id per scenario', () => { + const ids = new Set( + Array.from({ length: 50 }, () => service.start({ name: 's' }).id) + ); + expect(ids.size).toBe(50); + }); + + it('should not emit anything until the scenario settles', () => { + service.start({ name: 'load' }); + expect(sink.events).toHaveLength(0); + }); + + it('should track the scenario as active until it settles', () => { + const scenario = service.start({ name: 'load' }); + expect(service.find(scenario.id)).toBe(scenario); + expect(service.getActive()).toEqual([scenario]); + + scenario.complete(); + + expect(service.find(scenario.id)).toBeUndefined(); + expect(service.getActive()).toEqual([]); + }); + }); + + describe('timing', () => { + const isoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + + it('should record ISO-8601 start and end times', () => { + const before = new Date().toISOString(); + const scenario = service.start({ name: 'load' }); + scenario.complete(); + const after = new Date().toISOString(); + + const record = onlyScenarioRecord(); + expect(record.startTime).toMatch(isoPattern); + expect(record.endTime).toMatch(isoPattern); + expect(record.startTime >= before).toBe(true); + expect(record.endTime! <= after).toBe(true); + expect(record.endTime! >= record.startTime).toBe(true); + }); + + it('should measure a real duration as delta', () => { + jest.useFakeTimers(); + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(1000); + + const scenario = service.start({ name: 'load' }); + nowSpy.mockReturnValue(1250); + scenario.complete(); + + expect(onlyScenarioRecord().delta).toBe(250); + jest.useRealTimers(); + }); + + it('should expose a growing delta value while in progress', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + const scenario = service.start({ name: 'load' }); + + nowSpy.mockReturnValue(500); + expect(scenario.delta).toBe(500); + + nowSpy.mockReturnValue(900); + scenario.complete(); + nowSpy.mockReturnValue(5000); + expect(scenario.delta).toBe(900); + }); + + it('should stamp elapsed as milliseconds since page load, distinct from delta', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(2000); + const scenario = service.start({ name: 'load' }); + + nowSpy.mockReturnValue(2400); + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.delta).toBe(400); + expect(record.elapsed).toBe(2400); + }); + }); + + describe('concurrent scenarios', () => { + it('should keep interleaved scenarios completely independent', () => { + const a = service.start({ name: 'scenario-a' }); + const b = service.start({ name: 'scenario-b' }); + + a.step('a1'); + b.step('b1'); + a.step('a2'); + b.step('b2'); + a.step('a3'); + + expect(service.getActive()).toHaveLength(2); + + a.complete(); + b.fail({ error: new Error('b failed') }); + + const events = sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario); + expect(events).toHaveLength(2); + + const recordA = events[0].payload as unknown as CpsScenarioRecord; + const recordB = events[1].payload as unknown as CpsScenarioRecord; + + expect(recordA.scenarioName).toBe('scenario-a'); + expect(recordA.status).toBe('success'); + expect(recordA.steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'a1', + 'a2', + 'a3', + 'scenario-end' + ]); + + expect(recordB.scenarioName).toBe('scenario-b'); + expect(recordB.status).toBe('failure'); + expect(recordB.steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'b1', + 'b2', + 'scenario-end' + ]); + + expect(recordA.scenarioId).not.toBe(recordB.scenarioId); + }); + + it('should support many scenarios of the same name running at once', () => { + const scenarios = Array.from({ length: 5 }, () => + service.start({ name: 'load-widget' }) + ); + scenarios.forEach((s, i) => s.step(`step-${i}`)); + scenarios.forEach((s) => s.complete()); + + const records = sink + .ofType(CPS_TELEMETRY_EVENT_TYPE.scenario) + .map((e) => e.payload as unknown as CpsScenarioRecord); + + expect(records).toHaveLength(5); + expect(new Set(records.map((r) => r.scenarioId)).size).toBe(5); + records.forEach((record, i) => { + expect(record.steps.map((s) => s.name)).toEqual([ + 'scenario-start', + `step-${i}`, + 'scenario-end' + ]); + }); + }); + }); + + describe('steps', () => { + it('should close the previous step when the next one opens', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + + const scenario = service.start({ name: 'load' }); + nowSpy.mockReturnValue(100); + scenario.step('fetch'); + nowSpy.mockReturnValue(400); + scenario.step('render'); + nowSpy.mockReturnValue(500); + scenario.complete(); + + const [start, fetch, render, end] = onlyScenarioRecord().steps; + expect(start).toMatchObject({ name: 'scenario-start', stepDelta: 0 }); + expect(fetch).toMatchObject({ + name: 'fetch', + startOffset: 100, + endOffset: 400, + stepDelta: 300, + status: 'success' + }); + expect(render).toMatchObject({ + name: 'render', + startOffset: 400, + endOffset: 500, + stepDelta: 100, + status: 'success' + }); + expect(end).toMatchObject({ name: 'scenario-end', stepDelta: 0 }); + }); + + it('should always bookend the real steps with scenario-start and scenario-end', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.complete(); + + expect(onlyScenarioRecord().steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'fetch', + 'scenario-end' + ]); + }); + + it('should still have exactly the two boundary markers when no step is ever opened', () => { + const scenario = service.start({ name: 'load' }); + scenario.complete(); + + const steps = onlyScenarioRecord().steps; + expect(steps).toHaveLength(2); + expect(steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'scenario-end' + ]); + expect(steps[0]).toMatchObject({ + startOffset: 0, + endOffset: 0, + stepDelta: 0, + status: 'success' + }); + expect(steps[1]).toMatchObject({ stepDelta: 0, status: 'success' }); + }); + + it("should give scenario-end the scenario's own settled status", () => { + const scenario = service.start({ name: 'load' }); + scenario.fail({ error: new Error('boom') }); + + const steps = onlyScenarioRecord().steps; + expect(steps.at(-1)).toMatchObject({ + name: 'scenario-end', + status: 'failure' + }); + }); + + it('should close the open step when the scenario settles', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.complete(); + + expect(onlyScenarioRecord().steps[1].status).toBe('success'); + }); + + it('should mark the open step as failed when the scenario fails', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.fail({ error: new Error('network down') }); + + const step = onlyScenarioRecord().steps[1]; + expect(step.status).toBe('failure'); + expect(step.error).toMatchObject({ message: 'network down' }); + }); + + it("should merge the settle outcome's message and metadata into the step it closes", () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.complete({ + message: 'served from cache', + metadata: { cacheHit: true } + }); + + const step = onlyScenarioRecord().steps[1]; + expect(step).toMatchObject({ + name: 'fetch', + message: 'served from cache', + metadata: { cacheHit: true } + }); + }); + + it("should carry the settle outcome's reason onto the step it closes, independently of message", () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.cancel({ reason: 'user navigated away' }); + + const step = onlyScenarioRecord().steps[1]; + expect(step).toMatchObject({ + name: 'fetch', + reason: 'user navigated away' + }); + expect(step).not.toHaveProperty('message'); + }); + + it('should carry both message and reason, unmerged, onto the root record and scenario-end', () => { + const scenario = service.start({ name: 'load' }); + scenario.incomplete({ + message: 'search returned nothing to show', + reason: 'no-results' + }); + + const record = onlyScenarioRecord(); + expect(record).toMatchObject({ + message: 'search returned nothing to show', + reason: 'no-results' + }); + expect(record.steps.at(-1)).toMatchObject({ + name: 'scenario-end', + message: 'search returned nothing to show', + reason: 'no-results' + }); + }); + + it("should carry the settle outcome's message, metadata and error onto scenario-end", () => { + const scenario = service.start({ name: 'load' }); + scenario.fail({ + error: new Error('boom'), + message: 'checkout failed', + metadata: { step: 'payment' } + }); + + const end = onlyScenarioRecord().steps.at(-1); + expect(end).toMatchObject({ + name: 'scenario-end', + status: 'failure', + message: 'checkout failed', + metadata: { step: 'payment' }, + error: { message: 'boom' } + }); + }); + + it('should not carry an error onto scenario-end for a non-failure status', () => { + const scenario = service.start({ name: 'load' }); + scenario.settle('incomplete', undefined, new Error('irrelevant')); + + expect(onlyScenarioRecord().steps.at(-1)).not.toHaveProperty('error'); + }); + + it('should leave scenario-end without message or metadata when the outcome has none', () => { + const scenario = service.start({ name: 'load' }); + scenario.complete(); + + const end = onlyScenarioRecord().steps.at(-1); + expect(end).not.toHaveProperty('message'); + expect(end).not.toHaveProperty('metadata'); + }); + + it('should mark the open step as cancelled when the scenario is cancelled', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.cancel({ reason: 'user navigated away' }); + + expect(onlyScenarioRecord().steps[1].status).toBe('abandoned'); + }); + + it('should close a step explicitly via endStep', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch').endStep({ message: 'cache hit' }); + scenario.complete(); + + expect(onlyScenarioRecord().steps[1]).toMatchObject({ + status: 'success', + message: 'cache hit' + }); + }); + + it('should fail a step without settling the scenario', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('optional-resource').failStep(new Error('404')); + + expect(scenario.status).toBeUndefined(); + expect(scenario.isSettled).toBe(false); + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.status).toBe('success'); + expect(record.steps[1].status).toBe('failure'); + }); + + it('should tolerate endStep with no open step', () => { + const scenario = service.start({ name: 'load' }); + expect(() => scenario.endStep()).not.toThrow(); + scenario.complete(); + expect(onlyScenarioRecord().steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'scenario-end' + ]); + }); + + it('should count every real step but retain only up to maxSteps, plus the two markers', () => { + configure({ defaultTimeoutMs: 0, maxSteps: 3 }); + + const scenario = service.start({ name: 'loop' }); + for (let i = 0; i < 10; i++) { + scenario.step(`step-${i}`); + } + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.stepCount).toBe(10); + expect(record.steps).toHaveLength(5); + expect(record.steps[0].name).toBe('scenario-start'); + expect(record.steps.at(-1)!.name).toBe('scenario-end'); + }); + + it('should retain every step and leave exceededStepsLimit unset at the exact maxSteps boundary', () => { + configure({ defaultTimeoutMs: 0, maxSteps: 3 }); + + const scenario = service.start({ name: 'loop' }); + scenario.step('a').step('b').step('c'); + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.stepCount).toBe(3); + expect(record.steps).toHaveLength(5); + expect(record.exceededStepsLimit).toBeUndefined(); + }); + + it('should drop exactly one step and flag the limit at maxSteps + 1', () => { + configure({ defaultTimeoutMs: 0, maxSteps: 3 }); + + const scenario = service.start({ name: 'loop' }); + scenario.step('a').step('b').step('c').step('d'); + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.stepCount).toBe(4); + expect(record.steps).toHaveLength(5); + expect(record.exceededStepsLimit).toBe(true); + }); + + it('should retain no real steps and flag the limit immediately when maxSteps is 0', () => { + configure({ defaultTimeoutMs: 0, maxSteps: 0 }); + + const scenario = service.start({ name: 'loop' }); + scenario.step('a'); + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.stepCount).toBe(1); + expect(record.steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'scenario-end' + ]); + expect(record.exceededStepsLimit).toBe(true); + }); + + it('should redact sensitive step metadata', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('auth', { token: 'abc', attempt: 1 }); + scenario.complete(); + + expect(onlyScenarioRecord().steps[1].metadata).toEqual({ + token: CPS_REDACTED, + attempt: 1 + }); + }); + }); + + describe('outcomes', () => { + it('should settle as completed with a status code and metadata', () => { + const scenario = service.start({ name: 'load' }); + scenario.complete({ + statusCode: 200, + message: 'ok', + metadata: { rowCount: 42 } + }); + + expect(onlyScenarioRecord()).toMatchObject({ + status: 'success', + statusCode: 200, + message: 'ok', + metadata: { rowCount: 42 } + }); + }); + + it('should settle as failed with a normalized error', () => { + const scenario = service.start({ name: 'load' }); + scenario.fail({ error: new TypeError('bad shape'), statusCode: 500 }); + + const record = onlyScenarioRecord(); + expect(record.status).toBe('failure'); + expect(record.statusCode).toBe(500); + expect(record.error).toMatchObject({ + name: 'TypeError', + message: 'bad shape' + }); + }); + + it('should settle as failed even with no error supplied', () => { + service.start({ name: 'load' }).fail(); + expect(onlyScenarioRecord().status).toBe('failure'); + }); + + it('should settle as cancelled carrying the reason', () => { + service.start({ name: 'load' }).cancel({ reason: 'user navigated away' }); + + const record = onlyScenarioRecord(); + expect(record).toMatchObject({ + status: 'abandoned', + reason: 'user navigated away', + metadata: { abandonedBy: 'caller' } + }); + expect(record).not.toHaveProperty('message'); + }); + + it('should settle as cancelled carrying additional outcome metadata and status code', () => { + service.start({ name: 'load' }).cancel({ + reason: 'dialog-closed', + statusCode: 499, + metadata: { dialogButton: 'cancel', stepIndex: 2 } + }); + + expect(onlyScenarioRecord()).toMatchObject({ + status: 'abandoned', + reason: 'dialog-closed', + statusCode: 499, + metadata: { + abandonedBy: 'caller', + dialogButton: 'cancel', + stepIndex: 2 + } + }); + }); + + it("should not let caller metadata override cancel()'s own abandonedBy: caller", () => { + service.start({ name: 'load' }).cancel({ + metadata: { abandonedBy: 'page-hidden' } + }); + + expect(onlyScenarioRecord().metadata).toMatchObject({ + abandonedBy: 'caller' + }); + }); + + it('should return a shallow copy of steps from toRecord so external mutations do not affect internal state', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('first-step'); + const record = scenario.toRecord(); + + expect(record.steps).toHaveLength(2); + record.steps.pop(); + + scenario.complete(); + const finalRecord = onlyScenarioRecord(); + expect(finalRecord.steps).toHaveLength(3); + }); + + it('should carry the optional classification fields', () => { + service + .start({ + name: 'export', + feature: 'customers', + operation: 'csv-export', + route: '/customers', + parentScenarioId: 'parent-1', + metadata: { source: 'toolbar' } + }) + .complete(); + + expect(onlyScenarioRecord()).toMatchObject({ + scenarioName: 'export', + feature: 'customers', + operation: 'csv-export', + route: '/customers', + parentScenarioId: 'parent-1', + metadata: { source: 'toolbar' } + }); + }); + + it('should strip a query string from route, the same as any other URL-shaped string field', () => { + service.start({ name: 'export', route: '/customers?id=42' }).complete(); + + expect(onlyScenarioRecord()).toMatchObject({ route: '/customers' }); + }); + + it('should length-cap feature/operation/route, the same as any other string field', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }), + withRedaction({ maxStringLength: 4 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsScenarioTelemetryService); + const cappedSink = TestBed.inject(RecordingSink); + + capped + .start({ + name: 'export', + feature: 'customers', + operation: 'csv-export', + route: '/customers' + }) + .complete(); + + const record = cappedSink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)[0] + .payload as Record; + expect(record).toMatchObject({ + feature: 'cust…', + operation: 'csv-…', + route: '/cus…' + }); + }); + + it('should not carry browser, device or page fields already in the RUM envelope', () => { + service.start({ name: 'load' }).complete(); + const record = onlyScenarioRecord() as unknown as Record; + + expect(record.browser).toBeUndefined(); + expect(record.device).toBeUndefined(); + expect(record.page).toBeUndefined(); + }); + + it('should stamp application from config and sessionId from the sink', () => { + service.start({ name: 'load' }).complete(); + const record = onlyScenarioRecord(); + + expect(record.application).toBe('test-app'); + expect(record.sessionId).toBe('test-session'); + }); + + it('should leave userId absent until the sink has one, then carry it', () => { + const first = service.start({ name: 'load' }); + first.complete(); + expect(onlyScenarioRecord().userId).toBeUndefined(); + + sink.setUserId('user-42'); + const second = service.start({ name: 'load' }); + second.complete(); + + const records = sink + .ofType(CPS_TELEMETRY_EVENT_TYPE.scenario) + .map((e) => e.payload as unknown as CpsScenarioRecord); + expect(records[1].userId).toBe('user-42'); + }); + }); + + describe('settle', () => { + it.each([ + ['success'], + ['failure'], + ['abandoned'], + ['incomplete'], + ['timeout'] + ] as [CpsScenarioStatus][])( + 'should settle into %s from a status supplied as data', + (status) => { + service.start({ name: 'load' }).settle(status); + expect(onlyScenarioRecord().status).toBe(status); + } + ); + + it('should behave identically to the named method', () => { + service.start({ name: 'a' }).settle('success', { statusCode: 200 }); + service.start({ name: 'b' }).complete({ statusCode: 200 }); + + const [viaSettle, viaNamed] = sink + .ofType(CPS_TELEMETRY_EVENT_TYPE.scenario) + .map((e) => e.payload as unknown as CpsScenarioRecord); + + expect(viaSettle.status).toBe(viaNamed.status); + expect(viaSettle.statusCode).toBe(viaNamed.statusCode); + }); + + it('should accept an error when settling as failed', () => { + service + .start({ name: 'load' }) + .settle('failure', { statusCode: 500 }, new TypeError('bad shape')); + + expect(onlyScenarioRecord()).toMatchObject({ + status: 'failure', + statusCode: 500, + error: { name: 'TypeError', message: 'bad shape' } + }); + }); + + it('should close the open step with the settling status', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.settle('incomplete'); + + expect(onlyScenarioRecord().steps[1].status).toBe('incomplete'); + }); + + it('should be absorbing, like the named methods', () => { + const scenario = service.start({ name: 'load' }); + scenario.settle('failure'); + scenario.settle('success'); + + expect(scenario.status).toBe('failure'); + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)).toHaveLength(1); + }); + + it('should let an adapter map an external status onto a scenario', () => { + const fromMessage: Record = { + ok: 'success', + error: 'failure', + superseded: 'abandoned' + }; + + const scenario = service.start({ name: 'remote-job' }); + scenario.settle(fromMessage.error); + + expect(onlyScenarioRecord().status).toBe('failure'); + }); + }); + + describe('terminal states are absorbing', () => { + it('should ignore a second settle call', () => { + const scenario = service.start({ name: 'load' }); + scenario.complete(); + scenario.fail({ error: new Error('too late') }); + scenario.cancel(); + + expect(scenario.status).toBe('success'); + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)).toHaveLength(1); + }); + + it('should ignore step mutations after settling', () => { + const scenario = service.start({ name: 'load' }); + scenario.complete(); + + scenario.step('late').endStep().failStep(new Error()); + + expect(onlyScenarioRecord().steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'scenario-end' + ]); + }); + + it('should never throw when used after settling', () => { + const scenario = service.start({ name: 'load' }); + scenario.fail({ error: new Error('boom') }); + expect(() => scenario.step('late').complete()).not.toThrow(); + }); + }); + + describe('abandoned', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('should settle as timeout when the deadline passes', () => { + const scenario = service.start({ name: 'slow', timeoutMs: 5000 }); + + jest.advanceTimersByTime(5000); + + expect(scenario.status).toBe('timeout'); + expect(onlyScenarioRecord()).toMatchObject({ + status: 'timeout', + message: 'Scenario did not settle within 5000ms' + }); + }); + + it('should not record an abandonedBy cause for a timeout', () => { + service.start({ name: 'slow', timeoutMs: 5000 }); + + jest.advanceTimersByTime(5000); + + expect(onlyScenarioRecord().metadata).toBeUndefined(); + }); + + it('should record caller as the cause for an explicit cancel', () => { + service + .start({ name: 'quick' }) + .cancel({ reason: 'user navigated away' }); + + expect(onlyScenarioRecord()).toMatchObject({ + reason: 'user navigated away', + metadata: { abandonedBy: 'caller' } + }); + }); + + it('should record page-hidden as the cause at unload', () => { + service.start({ name: 'in-flight' }); + + window.dispatchEvent(new Event('pagehide')); + + const record = onlyScenarioRecord(); + expect(record.metadata).toMatchObject({ abandonedBy: 'page-hidden' }); + expect(record.reason).toBe('page-hidden'); + }); + + it('should not fire the timeout once the scenario has settled', () => { + const scenario = service.start({ name: 'quick', timeoutMs: 5000 }); + scenario.complete(); + + jest.advanceTimersByTime(10000); + + expect(scenario.status).toBe('success'); + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)).toHaveLength(1); + }); + + it('should apply the configured default timeout', () => { + configure({ defaultTimeoutMs: 1000 }); + const scenario = service.start({ name: 'slow' }); + + jest.advanceTimersByTime(1000); + + expect(scenario.status).toBe('timeout'); + }); + + it('should not schedule a timeout when it is zero', () => { + const scenario = service.start({ name: 'unbounded', timeoutMs: 0 }); + jest.advanceTimersByTime(600_000); + expect(scenario.status).toBeUndefined(); + expect(scenario.isSettled).toBe(false); + }); + }); + + describe('correlation', () => { + it('should expose no logger unless one is supplied', () => { + const scenario = service.start({ name: 'load' }); + + expect(scenario.logger).toBeUndefined(); + }); + + it('should bind its id onto a supplied logger', () => { + const logger = TestBed.inject(CpsLoggerService); + const scenario = service.start({ name: 'load-customer-data', logger }); + + scenario.logger?.error('Failed to load customer data'); + + expect(TestBed.inject(RecordingLogApi).records[0]).toMatchObject({ + level: 'error', + message: 'Failed to load customer data', + correlationId: scenario.id, + context: 'load-customer-data' + }); + }); + + it('should not require any logging to be wired', () => { + expect(() => service.start({ name: 'load' }).complete()).not.toThrow(); + }); + + it('should use the scenario id as the emitted correlation identifier', () => { + const scenario = service.start({ name: 'load' }); + const id = scenario.id; + scenario.complete(); + + expect(onlyScenarioRecord().scenarioId).toBe(id); + }); + }); + + describe('emission mode', () => { + it('should emit exactly one event per scenario by default', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('one').step('two').step('three'); + scenario.complete(); + + expect(sink.events).toHaveLength(1); + expect(sink.events[0].eventType).toBe(CPS_TELEMETRY_EVENT_TYPE.scenario); + expect( + (sink.events[0].payload as unknown as CpsScenarioRecord).steps + ).toHaveLength(5); // scenario-start + 3 real + scenario-end + }); + + it('should emit each step and the settlement in verbose mode', () => { + configure({ emitLifecycleEvents: true }); + + const scenario = service.start({ name: 'load' }); + scenario.step('one').step('two'); + scenario.complete(); + + expect(sink.events).toHaveLength(3); + expect(sink.events.map((e) => e.eventType)).toEqual([ + CPS_TELEMETRY_EVENT_TYPE.scenarioStep, + CPS_TELEMETRY_EVENT_TYPE.scenarioStep, + CPS_TELEMETRY_EVENT_TYPE.scenario + ]); + + expect(sink.events[2].payload.status).toBe('success'); + expect(sink.events[0].payload.scenarioId).toBe(scenario.id); + }); + + it('should stamp application, sessionId and userId on each verbose step event, same as the packed record', () => { + configure({ emitLifecycleEvents: true }); + sink.setUserId('user-42'); + + const scenario = service.start({ name: 'load' }); + scenario.step('one'); + scenario.complete(); + + const stepEvent = sink.events.find( + (e) => e.eventType === CPS_TELEMETRY_EVENT_TYPE.scenarioStep + ); + expect(stepEvent?.payload.application).toBe('test-app'); + expect(stepEvent?.payload.sessionId).toBe('test-session'); + expect(stepEvent?.payload.userId).toBe('user-42'); + }); + + it('should stop emitting step events once maxSteps is exceeded', () => { + configure({ emitLifecycleEvents: true, maxSteps: 2 }); + + const scenario = service.start({ name: 'load' }); + scenario.step('one').step('two').step('three').step('four'); + scenario.complete(); + + const stepEvents = sink.events.filter( + (e) => e.eventType === CPS_TELEMETRY_EVENT_TYPE.scenarioStep + ); + expect(stepEvents).toHaveLength(2); + }); + + it("should close the open step with the scenario's own terminal status in verbose mode", () => { + configure({ emitLifecycleEvents: true }); + + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.fail({ error: new Error('boom') }); + + const stepEvents = sink.events.filter( + (e) => e.eventType === CPS_TELEMETRY_EVENT_TYPE.scenarioStep + ); + expect(stepEvents).toHaveLength(1); + expect(stepEvents[0].payload).toMatchObject({ + name: 'fetch', + status: 'failure' + }); + + const record = onlyScenarioRecord(); + expect(record.steps.find((s) => s.name === 'fetch')).toMatchObject({ + status: 'failure' + }); + }); + }); + + describe('debug output', () => { + let consoleLog: jest.SpyInstance; + + beforeEach(() => { + consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should stay silent by default', () => { + service.start({ name: 'load' }).step('one').complete(); + expect(consoleLog).not.toHaveBeenCalled(); + }); + + it.each(['true', '1'])( + 'should log every operation when debugScenario is "%s"', + (value) => { + localStorage.setItem('debugScenario', value); + + service.start({ name: 'load' }).step('one').complete(); + + expect(consoleLog).toHaveBeenCalledWith( + '[cps][scenario] load started', + expect.any(Object) + ); + expect(consoleLog).toHaveBeenCalledWith( + '[cps][scenario] load step one', + expect.any(Object) + ); + expect(consoleLog).toHaveBeenCalledWith( + expect.stringMatching( + /^\[cps]\[scenario] load success in \d+ms -> com\.cps\.scenario$/ + ), + expect.any(Object) + ); + } + ); + + it('should carry a lightweight progress snapshot on debug lines, not a record', () => { + localStorage.setItem('debugScenario', 'true'); + const scenario = service.start({ name: 'load' }); + scenario.step('one'); + scenario.step('two'); + + const stepTwoCall = consoleLog.mock.calls.find( + ([label]: [string]) => label === '[cps][scenario] load step two' + ); + + expect(stepTwoCall?.[1]).toMatchObject({ + scenarioId: scenario.id, + stepCount: 2, + previousStep: 'one' + }); + expect(stepTwoCall?.[1]).toHaveProperty('delta'); + expect(stepTwoCall?.[1]).not.toHaveProperty('status'); + expect(stepTwoCall?.[1]).not.toHaveProperty('steps'); + }); + + it('should log the very record handed to the sink', () => { + localStorage.setItem('debugScenario', 'true'); + service.start({ name: 'load' }).step('one').complete(); + + const settleCall = consoleLog.mock.calls.find(([label]: [string]) => + label.includes('success in') + ); + const sent = sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)[0]; + + expect(settleCall?.[1]).toBe(sent.payload); + }); + + it('should report ignored operations after settling', () => { + localStorage.setItem('debugScenario', 'true'); + const scenario = service.start({ name: 'load' }); + scenario.complete(); + scenario.step('late'); + + expect(consoleLog).toHaveBeenCalledWith( + '[cps][scenario] load ignored step — already success', + expect.any(Object) + ); + }); + + it('should stay silent for an invalid debugScenario value', () => { + localStorage.setItem('debugScenario', 'yes'); + service.start({ name: 'load' }).complete(); + expect(consoleLog).not.toHaveBeenCalled(); + }); + }); + + describe('page unload', () => { + it('should abandon in-flight scenarios and beacon-flush on pagehide', () => { + const a = service.start({ name: 'a' }); + const b = service.start({ name: 'b' }); + b.complete(); + + window.dispatchEvent(new Event('pagehide')); + + expect(a.status).toBe('abandoned'); + const abandoned = sink + .ofType(CPS_TELEMETRY_EVENT_TYPE.scenario) + .map((e) => e.payload as unknown as CpsScenarioRecord) + .find((r) => r.scenarioName === 'a'); + expect(abandoned?.status).toBe('abandoned'); + expect(abandoned?.reason).toBe('page-hidden'); + expect(sink.flushes).toContain(true); + }); + + it('should leave an already-completed scenario alone at unload', () => { + const done = service.start({ name: 'b' }); + done.complete(); + + window.dispatchEvent(new Event('pagehide')); + + expect(done.status).toBe('success'); + }); + + it('should abandon in-flight scenarios on destroy', () => { + const scenario = service.start({ name: 'a' }); + service.ngOnDestroy(); + expect(scenario.status).toBe('abandoned'); + }); + + it('should flush the sink when the page is hidden, without settling', () => { + const scenario = service.start({ name: 'a' }); + + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + + expect(sink.flushes).toEqual([true]); + expect(scenario.status).toBeUndefined(); + expect(scenario.isSettled).toBe(false); + expect(sink.events).toHaveLength(0); + }); + + it('should not flush when the page becomes visible again', () => { + service.start({ name: 'a' }); + + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); + document.dispatchEvent(new Event('visibilitychange')); + + expect(sink.flushes).toEqual([]); + }); + + it('should stop listening for visibility changes after destroy', () => { + service.ngOnDestroy(); + sink.flushes.length = 0; + + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + + expect(sink.flushes).toEqual([]); + }); + + it('should stop listening after destroy', () => { + service.ngOnDestroy(); + const scenario = service.start({ name: 'a' }); + + window.dispatchEvent(new Event('pagehide')); + + expect(scenario.status).toBeUndefined(); + expect(scenario.isSettled).toBe(false); + }); + }); + + describe('incomplete', () => { + it('should settle as incomplete, apart from failed and cancelled', () => { + service.start({ name: 'search' }).incomplete({ reason: 'no-results' }); + + expect(onlyScenarioRecord()).toMatchObject({ + status: 'incomplete', + reason: 'no-results' + }); + }); + + it('should accept a status code and metadata', () => { + service.start({ name: 'search' }).incomplete({ + reason: 'no-results', + statusCode: 204, + metadata: { hits: 0 } + }); + + expect(onlyScenarioRecord()).toMatchObject({ + status: 'incomplete', + statusCode: 204, + metadata: { hits: 0 } + }); + }); + + it('should mark the open step incomplete, not failed', () => { + const scenario = service.start({ name: 'search' }); + scenario.step('query'); + scenario.incomplete({ reason: 'no-results' }); + + expect(onlyScenarioRecord().steps[1].status).toBe('incomplete'); + }); + + it('should be absorbing', () => { + const scenario = service.start({ name: 'search' }); + scenario.incomplete({ reason: 'no-results' }); + scenario.complete(); + + expect(scenario.status).toBe('incomplete'); + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)).toHaveLength(1); + }); + + it('should carry no error object', () => { + service.start({ name: 'search' }).incomplete({ reason: 'no-results' }); + expect(onlyScenarioRecord().error).toBeUndefined(); + }); + }); + + describe('setData', () => { + it('should merge attributes recorded mid-flight', () => { + const scenario = service.start({ name: 'load' }); + scenario.setData({ rowCount: 42 }); + scenario.complete(); + + expect(onlyScenarioRecord().metadata).toMatchObject({ rowCount: 42 }); + }); + + it('should merge with start and settle metadata', () => { + const scenario = service.start({ + name: 'load', + metadata: { source: 'toolbar' } + }); + scenario.setData({ strategy: 'cache' }); + scenario.complete({ metadata: { rowCount: 3 } }); + + expect(onlyScenarioRecord().metadata).toEqual({ + source: 'toolbar', + strategy: 'cache', + rowCount: 3 + }); + }); + + it('should let a later call overwrite an earlier one', () => { + const scenario = service.start({ name: 'load' }); + scenario.setData({ strategy: 'cache' }); + scenario.setData({ strategy: 'network' }); + scenario.complete(); + + expect(onlyScenarioRecord().metadata).toMatchObject({ + strategy: 'network' + }); + }); + + it('should redact sensitive attributes', () => { + const scenario = service.start({ name: 'load' }); + scenario.setData({ token: 'abc', rows: 1 }); + scenario.complete(); + + expect(onlyScenarioRecord().metadata).toMatchObject({ + token: CPS_REDACTED, + rows: 1 + }); + }); + + it('should still redact via the built-in credential denylist when withScenarios({ redact: false })', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withScenarios({ defaultTimeoutMs: 0, redact: false }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const unredacted = TestBed.inject(CpsScenarioTelemetryService); + const unredactedSink = TestBed.inject(RecordingSink); + + unredacted + .start({ name: 'load' }) + .setData({ token: 'abc', rows: 1 }) + .complete(); + + const record = unredactedSink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)[0] + .payload as Record; + + expect(record.metadata).toMatchObject({ + token: CPS_REDACTED, + rows: 1 + }); + }); + + it('should be ignored after settling', () => { + const scenario = service.start({ name: 'load' }); + scenario.complete(); + scenario.setData({ late: true }); + + expect(onlyScenarioRecord().metadata).toBeUndefined(); + }); + }); + + describe('backdated start', () => { + it('should measure from the supplied epoch timestamp', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(5_000); + + const scenario = service.start({ + name: 'nav', + startedAt: performance.timeOrigin + 3_000 + }); + + nowSpy.mockReturnValue(5_500); + scenario.complete(); + + expect(onlyScenarioRecord().delta).toBe(2_500); + }); + + it('should ignore a timestamp from the future', () => { + const scenario = service.start({ + name: 'nav', + startedAt: Date.now() + 60_000 + }); + scenario.complete(); + + expect(onlyScenarioRecord().delta).toBeGreaterThanOrEqual(0); + expect(onlyScenarioRecord().delta).toBeLessThan(1_000); + }); + + it('should ignore a timestamp from before the page loaded', () => { + const scenario = service.start({ name: 'nav', startedAt: 0 }); + scenario.complete(); + + expect(onlyScenarioRecord().delta).toBeGreaterThanOrEqual(0); + expect(onlyScenarioRecord().delta).toBeLessThan(1_000); + }); + + it('should behave normally when omitted', () => { + service.start({ name: 'nav' }).complete(); + expect(onlyScenarioRecord().delta).toBeLessThan(1_000); + }); + + it('should backdate startTime to match the backdated delta', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(10_000); + + const scenario = service.start({ + name: 'nav', + startedAt: performance.timeOrigin + 6_000 + }); + nowSpy.mockReturnValue(10_000); + scenario.complete(); + + const record = onlyScenarioRecord(); + const startMs = new Date(record.startTime).getTime(); + const endMs = new Date(record.endTime!).getTime(); + expect(endMs - startMs).toBe(record.delta); + }); + + it('should shorten the scheduled timeout by however much startedAt already backdates it', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(5_000); + const setTimeoutSpy = jest + .spyOn(globalThis, 'setTimeout') + .mockReturnValue(0 as unknown as ReturnType); + + service.start({ + name: 'nav', + timeoutMs: 5_000, + startedAt: performance.timeOrigin + 2_000 + }); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2_000); + }); + }); + + describe('aggregates', () => { + it('should sum repeated calls of one operation', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + const scenario = service.start({ name: 'render-table' }); + + nowSpy.mockReturnValue(100); + scenario.aggregateStart('format-cell'); + nowSpy.mockReturnValue(130); + scenario.aggregateEnd('format-cell'); + + nowSpy.mockReturnValue(200); + scenario.aggregateStart('format-cell'); + nowSpy.mockReturnValue(220); + scenario.aggregateEnd('format-cell'); + + scenario.complete(); + + expect(onlyScenarioRecord().aggregates).toEqual([ + { name: 'format-cell', elapsed: 50, callCount: 2 } + ]); + }); + + it('should keep separate operations apart', () => { + const scenario = service.start({ name: 'render' }); + scenario.aggregateStart('a').aggregateEnd('a'); + scenario.aggregateStart('b').aggregateEnd('b'); + scenario.complete(); + + expect(onlyScenarioRecord().aggregates?.map((a) => a.name)).toEqual([ + 'a', + 'b' + ]); + }); + + it('should ignore an end with no matching start', () => { + const scenario = service.start({ name: 'render' }); + scenario.aggregateEnd('never-started'); + scenario.complete(); + + expect(onlyScenarioRecord().aggregates).toBeUndefined(); + }); + + it('should not double-count an overlapping start', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + const scenario = service.start({ name: 'render' }); + + nowSpy.mockReturnValue(100); + scenario.aggregateStart('op'); + nowSpy.mockReturnValue(150); + scenario.aggregateStart('op'); // ignored — already open + nowSpy.mockReturnValue(300); + scenario.aggregateEnd('op'); + scenario.complete(); + + expect(onlyScenarioRecord().aggregates).toEqual([ + { name: 'op', elapsed: 200, callCount: 1 } + ]); + }); + + it('should close an aggregate still open when the scenario settles', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + const scenario = service.start({ name: 'render' }); + + nowSpy.mockReturnValue(100); + scenario.aggregateStart('op'); + nowSpy.mockReturnValue(400); + scenario.complete(); + + expect(onlyScenarioRecord().aggregates).toEqual([ + { name: 'op', elapsed: 300, callCount: 1 } + ]); + }); + + it('should freeze an aggregate left open at settlement, not keep advancing on later toRecord() calls', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + const scenario = service.start({ name: 'render' }); + + nowSpy.mockReturnValue(100); + scenario.aggregateStart('op'); + nowSpy.mockReturnValue(400); + scenario.complete(); + + const atSettlement = scenario.toRecord().aggregates; + expect(atSettlement).toEqual([ + { name: 'op', elapsed: 300, callCount: 1 } + ]); + + nowSpy.mockReturnValue(10_000); + const muchLater = scenario.toRecord().aggregates; + + expect(muchLater).toEqual(atSettlement); + }); + + it('should omit the field when nothing was aggregated', () => { + service.start({ name: 'render' }).complete(); + expect(onlyScenarioRecord().aggregates).toBeUndefined(); + }); + + it('should be ignored after settling', () => { + const scenario = service.start({ name: 'render' }); + scenario.complete(); + scenario.aggregateStart('late').aggregateEnd('late'); + + expect(onlyScenarioRecord().aggregates).toBeUndefined(); + }); + }); + + describe('trace and ordering fields', () => { + it('should emit one event per lifecycle transition in verbose mode', () => { + configure({ emitLifecycleEvents: true }); + + const scenario = service.start({ name: 'load' }); + scenario.step('one').step('two'); + scenario.complete(); + + expect(sink.events).toHaveLength(3); + }); + + it('should name the last step closed before settling', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('first').step('second'); + scenario.complete(); + + expect(onlyScenarioRecord().previousStep).toBe('second'); + }); + + it('should have no previousStep when no step was opened', () => { + service.start({ name: 'load' }).complete(); + expect(onlyScenarioRecord().previousStep).toBeUndefined(); + }); + }); + + describe('event namespace', () => { + it('should emit under the default namespace', () => { + service.start({ name: 'load' }).complete(); + expect(sink.events[0].eventType).toBe('com.cps.scenario'); + }); + + it('should emit scenario and step events under a configured namespace', () => { + configure({ emitLifecycleEvents: true }, 'com.data-gateway'); + + const scenario = service.start({ name: 'load' }); + scenario.step('one'); + scenario.complete(); + + expect(sink.events.map((e) => e.eventType)).toEqual([ + 'com.data-gateway.scenario.step', + 'com.data-gateway.scenario' + ]); + }); + }); + + describe('exceededStepsLimit', () => { + it('should flag a truncated step list', () => { + configure({ defaultTimeoutMs: 0, maxSteps: 2 }); + + const scenario = service.start({ name: 'loop' }); + for (let i = 0; i < 5; i++) { + scenario.step(`s${i}`); + } + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.exceededStepsLimit).toBe(true); + expect(record.stepCount).toBe(5); + expect(record.steps).toHaveLength(4); // scenario-start + 2 real + scenario-end + }); + + it('should be absent when every step was retained', () => { + const scenario = service.start({ name: 'loop' }); + scenario.step('one').step('two'); + scenario.complete(); + + expect(onlyScenarioRecord().exceededStepsLimit).toBeUndefined(); + }); + }); + + describe('user timings', () => { + let perfApi: { + mark: jest.Mock; + measure: jest.Mock; + clearMarks: jest.Mock; + clearMeasures: jest.Mock; + }; + const realPerformance = globalThis.performance; + + /** jsdom has no mark/measure, so the API is installed for these tests. */ + function installPerf(): void { + perfApi = { + mark: jest.fn(), + measure: jest.fn(), + clearMarks: jest.fn(), + clearMeasures: jest.fn() + }; + Object.defineProperty(globalThis, 'performance', { + value: { + ...perfApi, + now: () => realPerformance.now(), + timeOrigin: realPerformance.timeOrigin, + getEntriesByName: () => [] + }, + // required so later tests can still redefine window.performance + writable: true, + configurable: true + }); + } + + afterEach(() => { + Object.defineProperty(globalThis, 'performance', { + value: realPerformance, + writable: true, + configurable: true + }); + }); + + it('should emit nothing when disabled', () => { + installPerf(); + service.start({ name: 'load' }).step('one').complete(); + + expect(perfApi.mark).not.toHaveBeenCalled(); + expect(perfApi.measure).not.toHaveBeenCalled(); + }); + + it('should mark start, steps and settle when enabled by config', () => { + configure({ defaultTimeoutMs: 0, userTimings: true }); + installPerf(); + + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.complete(); + + const marked = perfApi.mark.mock.calls.map((c) => c[0] as string); + expect(marked).toContain(`test-app:load:start:${scenario.id}`); + expect(marked).toContain(`test-app:load:fetch:${scenario.id}`); + expect(marked).toContain(`test-app:load:settle:${scenario.id}`); + }); + + it('should not collide marks between concurrent scenarios of the same name', () => { + configure({ defaultTimeoutMs: 0, userTimings: true }); + installPerf(); + + const first = service.start({ name: 'load' }); + first.step('fetch'); + const second = service.start({ name: 'load' }); + second.step('fetch'); + + first.complete(); + second.complete(); + + const marked = perfApi.mark.mock.calls.map((c) => c[0] as string); + expect(marked).toContain(`test-app:load:fetch:${first.id}`); + expect(marked).toContain(`test-app:load:fetch:${second.id}`); + expect(new Set(marked).size).toBe(marked.length); + + const cleared = perfApi.clearMarks.mock.calls.map((c) => c[0] as string); + expect(cleared).toContain(`test-app:load:settle:${first.id}`); + expect(cleared).toContain(`test-app:load:settle:${second.id}`); + }); + + it('should be enabled by the debugScenario flag with config off', () => { + localStorage.setItem('debugScenario', 'true'); + jest.spyOn(console, 'log').mockImplementation(() => {}); + installPerf(); + + service.start({ name: 'load' }).complete(); + + expect(perfApi.mark).toHaveBeenCalled(); + }); + + it('should measure each step and the whole scenario', () => { + configure({ defaultTimeoutMs: 0, userTimings: true }); + installPerf(); + + const scenario = service.start({ name: 'load', feature: 'customers' }); + scenario.step('fetch').step('render'); + scenario.complete(); + + const measured = perfApi.measure.mock.calls.map((c) => c[0] as string); + expect(measured).toContain('load [fetch]'); + expect(measured).toContain('load [render]'); + expect(measured).toContain('load (customers)'); + }); + + it('should clear its marks once the scenario settles', () => { + configure({ defaultTimeoutMs: 0, userTimings: true }); + installPerf(); + + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.complete(); + + const cleared = perfApi.clearMarks.mock.calls.map((c) => c[0] as string); + expect(cleared).toContain(`test-app:load:start:${scenario.id}`); + expect(cleared).toContain(`test-app:load:settle:${scenario.id}`); + }); + + it('should clear its marks via the configured fallback when it has no timeout and never settles', () => { + jest.useFakeTimers(); + configure({ userTimings: true, markCleanupFallbackMs: 90_000 }); + installPerf(); + + const scenario = service.start({ name: 'load' }); + scenario.step('fetch').step('render'); + + jest.advanceTimersByTime(89_999); + expect(perfApi.clearMarks).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + const cleared = perfApi.clearMarks.mock.calls.map((c) => c[0] as string); + expect(cleared).toContain(`test-app:load:start:${scenario.id}`); + expect(cleared).toContain(`test-app:load:fetch:${scenario.id}`); + + jest.useRealTimers(); + }); + + it('should not schedule a mark cleanup fallback when a real timeout is configured', () => { + jest.useFakeTimers(); + configure({ + defaultTimeoutMs: 10 * 60 * 1000, + userTimings: true, + markCleanupFallbackMs: 1_000 + }); + installPerf(); + + service.start({ name: 'load' }).step('fetch'); + + jest.advanceTimersByTime(60_000); + + expect(perfApi.clearMarks).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('should disable the fallback entirely when markCleanupFallbackMs is 0', () => { + jest.useFakeTimers(); + configure({ userTimings: true, markCleanupFallbackMs: 0 }); + installPerf(); + + service.start({ name: 'load' }).step('fetch'); + + jest.advanceTimersByTime(60 * 60 * 1000); + + expect(perfApi.clearMarks).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('should not throw when the browser has no User Timing API', () => { + configure({ defaultTimeoutMs: 0, userTimings: true }); + + expect(() => { + service.start({ name: 'load' }).step('one').complete(); + }).not.toThrow(); + expect(onlyScenarioRecord().status).toBe('success'); + }); + }); + + describe('settled$', () => { + it('should emit each scenario as it settles', () => { + const seen: string[] = []; + service.settled$.subscribe((r) => + seen.push(`${r.scenarioName}:${r.status}`) + ); + + service.start({ name: 'a' }).complete(); + service.start({ name: 'b' }).fail({ error: new Error('boom') }); + + expect(seen).toEqual(['a:success', 'b:failure']); + }); + + it('should not emit for a scenario still running', () => { + const seen: unknown[] = []; + service.settled$.subscribe((r) => seen.push(r)); + + service.start({ name: 'a' }).step('one'); + + expect(seen).toEqual([]); + }); + + it('should deliver the full record', () => { + let record: CpsScenarioRecord | undefined; + service.settled$.subscribe((r) => (record = r)); + + service.start({ name: 'a' }).step('one').complete(); + + expect(record).toMatchObject({ + scenarioName: 'a', + status: 'success' + }); + expect(record?.steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'one', + 'scenario-end' + ]); + }); + + it('should complete on destroy', () => { + let completed = false; + service.settled$.subscribe({ complete: () => (completed = true) }); + + service.ngOnDestroy(); + + expect(completed).toBe(true); + }); + + it('should not let a throwing subscriber break the scenario', () => { + // RxJS's Subject.next() already isolates a throwing subscriber, not + // cpsSafeVoid — this confirms the guarantee holds either way. + service.settled$.subscribe(() => { + throw new Error('subscriber exploded'); + }); + + expect(() => service.start({ name: 'a' }).complete()).not.toThrow(); + }); + + it("should not let a subscriber's mutation affect what the sink receives", () => { + service.settled$.subscribe((r) => { + (r as { status: string }).status = 'tampered'; + r.metadata = { tampered: true }; + }); + + service.start({ name: 'a' }).complete({ metadata: { real: true } }); + + expect( + sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)[0].payload + ).toMatchObject({ status: 'success', metadata: { real: true } }); + }); + }); + + describe('failure isolation', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + ThrowingSink, + { provide: CpsTelemetrySink, useExisting: ThrowingSink } + ] + }); + service = TestBed.inject(CpsScenarioTelemetryService); + }); + + afterEach(() => { + configure(); + consoleError.mockRestore(); + }); + + it('should never let a broken sink reach application code', () => { + expect(() => { + const scenario = service.start({ name: 'load' }); + scenario.step('one'); + scenario.complete(); + }).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('failed'), + expect.any(Error) + ); + }); + + it('should still expose a usable scenario when the sink is broken', () => { + const scenario = service.start({ name: 'load' }); + expect(scenario.id).toBeTruthy(); + scenario.complete(); + expect(scenario.status).toBe('success'); + }); + + it('should never let a throwing metadata getter reach application code', () => { + const metadata: CpsTelemetryMetadata = {}; + Object.defineProperty(metadata, 'poison', { + enumerable: true, + get(): never { + throw new Error('metadata getter exploded'); + } + }); + + expect(() => service.start({ name: 'load', metadata })).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('failed'), + expect.any(Error) + ); + }); + + it('should never let a throwing logger.child reach application code', () => { + const throwingLogger: CpsLogger = { + log: () => undefined, + warn: () => undefined, + error: () => undefined, + child: () => { + throw new Error('logger.child exploded'); + } + }; + + const scenario = service.start({ name: 'load', logger: throwingLogger }); + expect(() => scenario.logger).not.toThrow(); + expect(scenario.logger).toBeUndefined(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('failed'), + expect.any(Error) + ); + }); + }); + + describe('active scenario registry', () => { + it('should warn in dev mode when active scenarios exceed the threshold', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + for (let i = 0; i < 51; i++) { + service.start({ name: 'load', timeoutMs: 0 }); + } + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + '[cps-telemetry] High number of active scenarios (51)' + ) + ); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service.ts b/projects/cps-telemetry/src/lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service.ts new file mode 100644 index 000000000..e96bca3bc --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service.ts @@ -0,0 +1,196 @@ +import { DOCUMENT } from '@angular/common'; +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY +} from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CPS_SCENARIO_CONFIG } from '../../config/cps-scenario.config/cps-scenario.config'; +import { + CpsScenarioOptions, + CpsScenarioRecord +} from '../../models/cps-scenario.models/cps-scenario.models'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { + cpsDeepClone, + cpsIsBrowser, + cpsIsDevMode, + cpsSafeVoid +} from '../../utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util'; +import { cpsRedactConfigFor } from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { CpsScenario } from '../../scenario/cps-scenario/cps-scenario'; + +/** + * Creates and tracks scenarios — user journeys whose health this telemetry + * layer measures. + * + * Each {@link start} call returns an independent {@link CpsScenario}; there is + * no "current" scenario, so overlapping journeys are tracked separately. + * Scenarios still in flight when the page unloads are settled as `abandoned`. + * + * @example + * ```typescript + * const scenario = scenarioTelemetry.start({ name: 'load-dashboard' }); + * try { + * scenario.step('fetch-widgets'); + * const widgets = await this.api.widgets(); + * scenario.complete({ metadata: { widgetCount: widgets.length } }); + * } catch (error) { + * scenario.fail({ error }); + * } + * ``` + * + * @group Services + */ +@Injectable({ providedIn: 'root' }) +export class CpsScenarioTelemetryService implements OnDestroy { + private readonly identity = inject(CPS_TELEMETRY_IDENTITY); + private readonly scenarioConfig = inject(CPS_SCENARIO_CONFIG); + private readonly redact = cpsRedactConfigFor( + inject(CPS_REDACT_CONFIG), + this.scenarioConfig.redact + ); + + private readonly sink = inject(CpsTelemetrySink); + private readonly document = inject(DOCUMENT); + private readonly isBrowser = cpsIsBrowser(); + + private readonly active = new Map(); + private readonly onPageHide = () => this.flushActive(); + private readonly onVisibilityChange = () => { + if (this.document.visibilityState === 'hidden') { + this.flushSink(); + } + }; + + private readonly _settled$ = new Subject(); + + /** + * Emits every scenario record as it settles. + * + * Useful for reacting to outcomes without wrapping every call site — a + * debug overlay, a retry prompt, a test harness asserting on journeys. + * + * @example + * ```typescript + * scenarioTelemetry.settled$ + * .pipe(filter((r) => r.status === 'failure')) + * .subscribe((r) => this.notifications.warn(`${r.scenarioName} failed`)); + * ``` + */ + readonly settled$: Observable = + this._settled$.asObservable(); + + constructor() { + if (this.isBrowser) { + this.document.defaultView?.addEventListener('pagehide', this.onPageHide); + + this.document.addEventListener( + 'visibilitychange', + this.onVisibilityChange + ); + } + } + + /** + * Starts a new scenario. + * + * @param options the scenario name and optional classification, timeout and + * attributes + * @returns an independent scenario instance + */ + start(options: CpsScenarioOptions): CpsScenario { + const scenario = new CpsScenario(options, { + identity: this.identity, + scenarioConfig: this.scenarioConfig, + redact: this.redact, + sink: this.sink, + onSettled: (scenarioId, record) => { + this.active.delete(scenarioId); + if (!this._settled$.observed) { + return; + } + // Cloned so a settled$ subscriber can't mutate the same object + // that's about to be (or already was) shipped to the sink. + cpsSafeVoid('scenarioTelemetry.notify', () => + this._settled$.next(cpsDeepClone(record)) + ); + } + }); + + cpsSafeVoid('scenarioTelemetry.register', () => { + this.active.set(scenario.id, scenario); + if (cpsIsDevMode() && this.active.size > 50) { + // eslint-disable-next-line no-console + console.warn( + `[cps-telemetry] High number of active scenarios (${this.active.size}). Ensure scenarios with timeoutMs: 0 or long lifecycles are settled on destroy.` + ); + } + }); + + return scenario; + } + + /** + * Looks up a scenario that has not yet settled. + * + * @param scenarioId the id to look up + * @returns the scenario, or `undefined` if unknown or already settled + */ + find(scenarioId: string): CpsScenario | undefined { + return this.active.get(scenarioId); + } + + /** + * Every scenario currently in flight. + * + * @returns the active scenarios, in start order + */ + getActive(): CpsScenario[] { + return [...this.active.values()]; + } + + /** @inheritdoc */ + ngOnDestroy(): void { + if (this.isBrowser) { + this.document.defaultView?.removeEventListener( + 'pagehide', + this.onPageHide + ); + this.document.removeEventListener( + 'visibilitychange', + this.onVisibilityChange + ); + } + this.flushActive(); + this._settled$.complete(); + } + + /** + * Dispatches what the sink holds, leaving running scenarios alone. + * + * Uses the beacon transport since the page may not survive a normal request. + */ + private flushSink(): void { + cpsSafeVoid('scenarioTelemetry.flushSink', () => this.sink.flush(true)); + } + + /** + * Settles every in-flight scenario as abandoned and asks the sink to send + * what it holds using a transport that survives unload. + * + * Scenario emission is synchronous, so everything settled here reaches the + * sink before the beacon goes out. + */ + private flushActive(): void { + cpsSafeVoid('scenarioTelemetry.flush', () => { + for (const scenario of this.getActive()) { + scenario.settle('abandoned', { + reason: 'page-hidden', + metadata: { abandonedBy: 'page-hidden' } + }); + } + this.sink.flush(true); + }); + } +} diff --git a/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast-host.service.ts b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast-host.service.ts new file mode 100644 index 000000000..d7e266260 --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast-host.service.ts @@ -0,0 +1,156 @@ +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { + cpsSafe, + cpsSafeVoid +} from '../../utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util'; +import { + CpsBroadcastConnection, + cpsConnectBroadcastChannel, + cpsElectBroadcastHostLeader, + cpsIsBroadcastMessage +} from './cps-broadcast.messages'; +import { CpsTelemetrySink } from '../cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; + +/** + * Receives telemetry forwarded by follower realms and records it through this + * realm's sink. + * + * Runs in the shell, the realm with the real sink. Fragments using + * {@link CpsBroadcastTelemetrySink} post their events here, so one AWS client, + * one session and one event budget serve the whole composed page. + * + * A Web Locks-based election ({@link cpsElectBroadcastHostLeader}) keeps + * exactly one host active per channel; others stay passive. + * + * @example + * ```typescript + * providers: [ + * provideCpsTelemetry({ application: 'shell', environment: 'prod', version: '1.0.0' }), + * provideCpsTelemetrySink('rum'), + * provideCpsTelemetryBroadcastHost() + * ] + * ``` + * + * @group Services + */ +@Injectable() +export class CpsTelemetryBroadcastHost implements OnDestroy { + private readonly sink = inject(CpsTelemetrySink); + private readonly connection: CpsBroadcastConnection = + cpsConnectBroadcastChannel('broadcastHost'); + + /** Number of messages accepted, for tests and diagnostics. */ + private _received = 0; + + /** + * The session and user id last sent in an `identity` message. + * + * Compared against the sink's current values on every follower activity, so + * a late-resolving session id or user id gets announced once it exists. + */ + private lastAnnouncedSessionId?: string; + private lastAnnouncedUserId?: string; + + /** Whether this realm won the leader election; non-leaders stay passive. */ + private isLeader = false; + private releaseLeadership: () => void = () => undefined; + + constructor() { + this.connection.onMessage((data) => this.onMessage(data)); + this.releaseLeadership = cpsElectBroadcastHostLeader( + this.connection.channelName, + () => { + this.isLeader = true; + this.announceIdentity(); + } + ); + } + + /** How many follower messages have been accepted. */ + get received(): number { + return this._received; + } + + /** @inheritdoc */ + ngOnDestroy(): void { + this.releaseLeadership(); + this.connection.close(); + } + + private onMessage(data: unknown): void { + cpsSafeVoid('broadcastHost.receive', () => { + if (!this.isLeader || !cpsIsBroadcastMessage(data)) { + return; + } + + if (data.kind !== 'identity' && data.kind !== 'identity-request') { + this._received++; + } + + switch (data.kind) { + case 'event': + this.sink.record(data.eventType, data.payload, data.metadata); + this.reannounceIfIdentityChanged(); + break; + case 'error': + this.sink.recordError(data.error, data.metadata); + this.reannounceIfIdentityChanged(); + break; + case 'user': + this.sink.setUserId(data.userId); + this.announceIdentity(); + break; + case 'flush': + this.sink.flush(data.beacon); + this.reannounceIfIdentityChanged(); + break; + case 'identity-request': + this.announceIdentity(); + break; + case 'identity': + // eslint-disable-next-line no-console + console.warn( + `[cps-telemetry] a second telemetry host is active on channel "${this.connection.channelName}"; only one realm should provide it` + ); + break; + } + }); + } + + private announceIdentity(): void { + const sessionId = this.currentSessionId(); + const userId = this.currentUserId(); + this.lastAnnouncedSessionId = sessionId; + this.lastAnnouncedUserId = userId; + this.connection.post({ kind: 'identity', sessionId, userId }); + } + + /** + * Re-announces identity when the sink's session id or user id has moved on + * from what followers were last told. + */ + private reannounceIfIdentityChanged(): void { + if ( + this.currentSessionId() !== this.lastAnnouncedSessionId || + this.currentUserId() !== this.lastAnnouncedUserId + ) { + this.announceIdentity(); + } + } + + private currentSessionId(): string | undefined { + return cpsSafe( + 'broadcastHost.getSessionId', + () => this.sink.getSessionId(), + undefined + ); + } + + private currentUserId(): string | undefined { + return cpsSafe( + 'broadcastHost.getUserId', + () => this.sink.getUserId(), + undefined + ); + } +} diff --git a/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast-telemetry.sink.ts b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast-telemetry.sink.ts new file mode 100644 index 000000000..295b2c5cb --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast-telemetry.sink.ts @@ -0,0 +1,133 @@ +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CPS_TELEMETRY_IDENTITY } from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { cpsSafeVoid } from '../../utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util'; +import { + CpsBroadcastConnection, + cpsConnectBroadcastChannel, + cpsIsBroadcastMessage +} from './cps-broadcast.messages'; +import { CpsTelemetrySink } from '../cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; + +/** + * Telemetry sink for a follower realm — a micro-frontend fragment, a webview + * panel, any context with its own JavaScript realm but the same origin. + * + * Creates no AWS client of its own. Events are forwarded over a + * `BroadcastChannel` to the realm running + * {@link provideCpsTelemetryBroadcastHost}, which records them through the one + * real sink. Application code sees no difference — it injects the same + * services and calls the same methods. + * + * @example + * ```typescript + * providers: [ + * provideCpsTelemetry({ application: 'cart', environment: 'prod', version: '1.0.0' }), + * provideCpsTelemetrySink('broadcast') + * ] + * ``` + * + * @group Services + */ +@Injectable() +export class CpsBroadcastTelemetrySink + extends CpsTelemetrySink + implements OnDestroy +{ + private readonly connection: CpsBroadcastConnection = + cpsConnectBroadcastChannel('broadcastSink'); + + /** The host's session id, once it has answered. */ + private sessionId?: string; + private userId?: string; + + private readonly config = inject(CPS_TELEMETRY_IDENTITY); + + /** + * This realm's own identity, stamped onto everything it forwards. + * + * The host records through its own RUM client, so without this, forwarded + * events would all be labelled as the host's realm. + */ + private readonly origin = { + application: this.config.application, + environment: this.config.environment, + appVersion: this.config.version + }; + + constructor() { + super(); + + this.connection.onMessage((data) => this.onMessage(data)); + this.connection.post({ kind: 'identity-request' }); + } + + /** @inheritdoc */ + record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void { + this.connection.post({ + kind: 'event', + eventType, + payload, + metadata: { ...metadata, ...this.origin } + }); + } + + /** @inheritdoc */ + recordError(error: CpsTelemetryError, metadata?: CpsTelemetryMetadata): void { + this.connection.post({ + kind: 'error', + error, + metadata: { ...metadata, ...this.origin } + }); + } + + /** + * The host's session id. + * + * Returns `undefined` until the host answers. Log records written before + * that carry no session id but are still correlated by `scenarioId`. + * + * @returns the shared session id, when known + */ + getSessionId(): string | undefined { + return this.sessionId; + } + + /** @inheritdoc */ + setUserId(userId: string | undefined): void { + this.userId = userId; + this.connection.post({ kind: 'user', userId }); + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(beacon = false): void { + this.connection.post({ kind: 'flush', beacon }); + } + + /** @inheritdoc */ + ngOnDestroy(): void { + this.connection.close(); + } + + private onMessage(data: unknown): void { + cpsSafeVoid('broadcastSink.receive', () => { + if (!cpsIsBroadcastMessage(data) || data.kind !== 'identity') { + return; + } + this.sessionId = data.sessionId; + this.userId = data.userId; + }); + } +} diff --git a/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast.messages.ts b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast.messages.ts new file mode 100644 index 000000000..0f694a117 --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast.messages.ts @@ -0,0 +1,260 @@ +import { inject, InjectionToken } from '@angular/core'; +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { + cpsIsBrowser, + cpsSafeVoid +} from '../../utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util'; + +/** + * Default `BroadcastChannel` name used between telemetry realms. + * + * @group Utils + */ +export const CPS_DEFAULT_BROADCAST_CHANNEL = 'cps-telemetry'; + +/** + * Overrides the channel name shared by the host and its followers. + * + * Both sides must agree. Change it only to run two independent telemetry + * networks on one origin. + * + * @group Tokens + */ +export const CPS_BROADCAST_CHANNEL = new InjectionToken( + 'CPS_BROADCAST_CHANNEL' +); + +/** + * Everything that travels between telemetry realms. + * + * Structured-cloned by `BroadcastChannel`, so every payload must be plain + * data. + */ +export type CpsBroadcastMessage = + | { + kind: 'event'; + eventType: string; + payload: object; + metadata?: CpsTelemetryMetadata; + } + | { + kind: 'error'; + error: CpsTelemetryError; + metadata?: CpsTelemetryMetadata; + } + | { kind: 'user'; userId: string | undefined } + | { kind: 'flush'; beacon: boolean } + | { kind: 'identity-request' } + /** The host announcing shared identity. Always sends both fields, even when only one changed. */ + | { kind: 'identity'; sessionId?: string; userId?: string }; + +/** Every `kind` the union above accepts, for {@link cpsIsBroadcastMessage}. */ +const MESSAGE_KINDS: ReadonlySet = new Set([ + 'event', + 'error', + 'user', + 'flush', + 'identity-request', + 'identity' +] as const); + +/** Minimal `BroadcastChannel` surface this library relies on. */ +export interface CpsBroadcastChannelLike { + postMessage(message: unknown): void; + close(): void; + onmessage: ((event: { data: unknown }) => void) | null; +} + +/** + * Opens a broadcast channel, when the browser provides the API. + * + * Feature-detected: unavailable under jsdom (tests) and in server-side + * rendering. + */ +export function cpsOpenBroadcastChannel( + name: string +): CpsBroadcastChannelLike | undefined { + const Channel = ( + globalThis as { + BroadcastChannel?: new (name: string) => CpsBroadcastChannelLike; + } + ).BroadcastChannel; + + if (typeof Channel !== 'function') { + return undefined; + } + + try { + return new Channel(name); + } catch { + return undefined; + } +} + +/** Minimal Web Locks API surface this library relies on. */ +interface CpsLockManagerLike { + request(name: string, callback: () => Promise): Promise; +} + +/** + * Elects exactly one leader among same-origin realms on this channel, via + * the Web Locks API — prevents two shell tabs from both recording every + * forwarded message. Fails open (elects immediately) when Web Locks is + * unavailable. + * + * @returns releases the lock so the next queued realm can become leader. + */ +export function cpsElectBroadcastHostLeader( + channelName: string, + onElected: () => void +): () => void { + const locks = (globalThis as { navigator?: { locks?: CpsLockManagerLike } }) + .navigator?.locks; + + if (!locks) { + onElected(); + return () => undefined; + } + + let released = false; + let release: () => void = () => { + released = true; + }; + + const failOpen = () => { + if (!released) { + onElected(); + } + }; + + try { + locks + .request( + `cps-telemetry-host:${channelName}`, + () => + new Promise((resolve) => { + if (released) { + resolve(); + return; + } + release = () => { + released = true; + resolve(); + }; + onElected(); + }) + ) + .catch(failOpen); + } catch { + failOpen(); + } + + return () => release(); +} + +/** A realm's live connection to the shared broadcast channel. */ +export interface CpsBroadcastConnection { + /** The channel name actually in use — the injected override, or the default. */ + readonly channelName: string; + + /** Sends a message to every other realm on this channel. No-op if unavailable. */ + post(message: CpsBroadcastMessage): void; + + /** Registers the handler for incoming messages. No-op if the channel could not be opened. */ + onMessage(handler: (data: unknown) => void): void; + + /** Closes the channel and stops any further delivery. Safe to call more than once. */ + close(): void; +} + +/** + * Opens this realm's connection to the shared broadcast channel: resolves the + * channel name, feature-detects `BroadcastChannel`, and wraps every operation + * in the library's fail-open guard. + * + * Must be called from an injection context (a field initializer or a + * constructor). + */ +export function cpsConnectBroadcastChannel( + operation: string +): CpsBroadcastConnection { + const channelName = + inject(CPS_BROADCAST_CHANNEL, { optional: true }) ?? + CPS_DEFAULT_BROADCAST_CHANNEL; + let channel = cpsIsBrowser() + ? cpsOpenBroadcastChannel(channelName) + : undefined; + + return { + channelName, + post(message) { + cpsSafeVoid(`${operation}.post`, () => channel?.postMessage(message)); + }, + onMessage(handler) { + if (channel) { + channel.onmessage = (event) => handler(event.data); + } + }, + close() { + cpsSafeVoid(`${operation}.close`, () => { + channel?.close(); + channel = undefined; + }); + } + }; +} + +/** + * Narrows an incoming `BroadcastChannel` payload to a telemetry message. + * + * Checks each kind's required fields, not just `kind`, so a same-named + * message from something else on the channel is rejected. + */ +export function cpsIsBroadcastMessage( + data: unknown +): data is CpsBroadcastMessage { + if (typeof data !== 'object' || data === null) { + return false; + } + + const message = data as Record; + const kind = message.kind as CpsBroadcastMessage['kind']; + if (!MESSAGE_KINDS.has(kind)) { + return false; + } + + switch (kind) { + case 'event': + return ( + typeof message.eventType === 'string' && + typeof message.payload === 'object' && + message.payload !== null + ); + case 'error': { + const error = message.error as Record | undefined; + return ( + typeof error === 'object' && + error !== null && + typeof error.name === 'string' && + typeof error.message === 'string' && + (error.stack === undefined || typeof error.stack === 'string') + ); + } + case 'flush': + return typeof message.beacon === 'boolean'; + case 'user': + // Absent or `undefined` userId both mean "clear it". + return message.userId === undefined || typeof message.userId === 'string'; + case 'identity': + return ( + (message.sessionId === undefined || + typeof message.sessionId === 'string') && + (message.userId === undefined || typeof message.userId === 'string') + ); + case 'identity-request': + return true; + } +} diff --git a/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast.spec.ts b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast.spec.ts new file mode 100644 index 000000000..36d6688b6 --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast.spec.ts @@ -0,0 +1,933 @@ +import { + CPS_TELEMETRY_EVENT_TYPE, + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; +import { DOCUMENT } from '@angular/common'; +import { Injectable, Injector, PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CpsLoggerService } from '../../services/cps-logger.service/cps-logger.service'; +import { + CPS_DEFAULT_TELEMETRY_CONFIG, + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY +} from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CPS_LOG_CONFIG } from '../../config/cps-log.config/cps-log.config'; +import { CPS_SCENARIO_CONFIG } from '../../config/cps-scenario.config/cps-scenario.config'; +import { CpsScenarioTelemetryService } from '../../services/cps-scenario-telemetry.service/cps-scenario-telemetry.service'; +import { + CPS_LOG_API_PROVIDER, + CpsLogApiProvider, + CpsLogQuery +} from '../../providers/cps-log-api.provider/cps-log-api.provider'; +import { CpsTelemetryBroadcastHost } from './cps-broadcast-host.service'; +import { CpsBroadcastTelemetrySink } from './cps-broadcast-telemetry.sink'; +import { + CPS_BROADCAST_CHANNEL, + CPS_DEFAULT_BROADCAST_CHANNEL, + cpsConnectBroadcastChannel, + cpsElectBroadcastHostLeader, + cpsIsBroadcastMessage +} from './cps-broadcast.messages'; +import { CpsTelemetrySink } from '../cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; + +/** + * Each realm gets its own injector — the shell and every fragment run in a + * separate JavaScript context and share no Angular injector. + */ +function createRealm(providers: unknown[]): Injector { + return Injector.create({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: DOCUMENT, useValue: document }, + { + provide: CPS_TELEMETRY_IDENTITY, + useValue: { + application: 'realm', + environment: 'test', + version: '1.0.0' + } + }, + { + provide: CPS_SCENARIO_CONFIG, + useValue: { + ...CPS_DEFAULT_TELEMETRY_CONFIG.scenario, + defaultTimeoutMs: 0 + } + }, + { provide: CPS_LOG_CONFIG, useValue: CPS_DEFAULT_TELEMETRY_CONFIG.logs }, + { + provide: CPS_REDACT_CONFIG, + useValue: CPS_DEFAULT_TELEMETRY_CONFIG.redact + }, + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + CpsLoggerService, + CpsScenarioTelemetryService, + ...(providers as never[]) + ] + }); +} + +/** + * In-memory stand-in for `BroadcastChannel`, since jsdom implements none. + * + * Delivers messages between instances sharing a name to every other + * instance, asynchronously, matching the real API. + */ +export class CpsBroadcastChannelStub { + private static channels = new Map(); + + onmessage: ((event: { data: unknown }) => void) | null = null; + + closed = false; + + constructor(readonly name: string) { + const peers = CpsBroadcastChannelStub.channels.get(name) ?? []; + peers.push(this); + CpsBroadcastChannelStub.channels.set(name, peers); + } + + /** Installs the stub as the global `BroadcastChannel`. */ + static install(): void { + Object.defineProperty(globalThis, 'BroadcastChannel', { + value: CpsBroadcastChannelStub, + configurable: true, + writable: true + }); + } + + /** + * Removes the global and forgets every channel. + * + * Closes every known channel first, so no delivery scheduled before + * teardown reaches a listener from a previous test. + */ + static uninstall(): void { + delete (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel; + + for (const peers of CpsBroadcastChannelStub.channels.values()) { + peers.forEach((peer) => (peer.closed = true)); + } + CpsBroadcastChannelStub.channels.clear(); + } + + /** + * Runs pending deliveries until the channel is quiet. + * + * Several turns, since a request/response exchange takes a task in each + * direction and a handler may post again. + */ + static async settle(turns = 5): Promise { + for (let i = 0; i < turns; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + postMessage(message: unknown): void { + if (this.closed) { + return; + } + + const peers = CpsBroadcastChannelStub.channels.get(this.name) ?? []; + const data = JSON.parse(JSON.stringify(message)); + + for (const peer of peers) { + if (peer === this) { + continue; + } + setTimeout(() => { + if (!peer.closed) { + peer.onmessage?.({ data }); + } + }, 0); + } + } + + close(): void { + this.closed = true; + const peers = CpsBroadcastChannelStub.channels.get(this.name) ?? []; + CpsBroadcastChannelStub.channels.set( + this.name, + peers.filter((peer) => peer !== this) + ); + } +} + +/** Minimal Web Locks API stub: grants each named lock to one requester at a time. */ +class LockManagerStub { + private readonly held = new Set(); + private readonly queues = new Map void>>(); + + request(name: string, callback: () => Promise): Promise { + return new Promise((resolve) => { + const grant = () => { + this.held.add(name); + callback().then(() => { + this.held.delete(name); + resolve(); + this.queues.get(name)?.shift()?.(); + }); + }; + + if (this.held.has(name)) { + const queue = this.queues.get(name) ?? []; + queue.push(grant); + this.queues.set(name, queue); + } else { + grant(); + } + }); + } + + /** Installs the stub as `navigator.locks`. */ + static install(): void { + Object.defineProperty(globalThis.navigator, 'locks', { + value: new LockManagerStub(), + configurable: true + }); + } + + static uninstall(): void { + delete (globalThis.navigator as { locks?: unknown }).locks; + } +} + +/** Captures what the library emitted, so a test can assert on it. */ +@Injectable() +class RecordingSink extends CpsTelemetrySink { + readonly events: { + eventType: string; + payload: Record; + metadata?: CpsTelemetryMetadata; + }[] = []; + + readonly errors: CpsTelemetryError[] = []; + readonly errorMetadata: (CpsTelemetryMetadata | undefined)[] = []; + readonly flushes: boolean[] = []; + userId?: string; + sessionId: string | undefined = 'test-session'; + + record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void { + this.events.push({ + eventType, + payload: payload as Record, + metadata + }); + } + + recordError(error: CpsTelemetryError, metadata?: CpsTelemetryMetadata): void { + this.errors.push(error); + this.errorMetadata.push(metadata); + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(beacon = false): void { + this.flushes.push(beacon); + } + + ofType(eventType: string) { + return this.events.filter((event) => event.eventType === eventType); + } +} + +/** Keeps every batch, so a test can assert on what was shipped. */ +@Injectable() +class RecordingLogApi implements CpsLogApiProvider { + readonly records: CpsLogRecord[] = []; + + send(record: CpsLogRecord): void { + this.records.push(record); + } + + query(filter: CpsLogQuery): Promise { + let found = this.records; + if (filter.correlationId) { + found = found.filter((r) => r.correlationId === filter.correlationId); + } + if (filter.logger) { + found = found.filter((r) => r.logger === filter.logger); + } + if (filter.limit !== undefined) { + found = found.slice(0, filter.limit); + } + return Promise.resolve(found); + } +} + +describe('broadcast telemetry across realms', () => { + let shellSink: RecordingSink; + let shell: Injector; + let host: CpsTelemetryBroadcastHost; + + beforeEach(() => { + CpsBroadcastChannelStub.install(); + TestBed.resetTestingModule(); + + shell = createRealm([ + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink }, + CpsTelemetryBroadcastHost + ]); + shellSink = shell.get(RecordingSink); + host = shell.get(CpsTelemetryBroadcastHost); + }); + + afterEach(() => { + CpsBroadcastChannelStub.uninstall(); + jest.restoreAllMocks(); + }); + + /** Builds a follower realm with a forwarding sink. */ + function createFragment(): Injector { + const realm = createRealm([ + CpsBroadcastTelemetrySink, + { provide: CpsTelemetrySink, useExisting: CpsBroadcastTelemetrySink } + ]); + // Resolve eagerly: Injector.create is lazy, and the sink's constructor + // opens the channel and requests identity. + realm.get(CpsTelemetrySink); + return realm; + } + + describe('forwarding', () => { + it('should record a fragment event through the shell sink', async () => { + const fragment = createFragment(); + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events).toEqual([ + expect.objectContaining({ + eventType: 'com.cps.bi', + payload: { eventName: 'x' } + }) + ]); + }); + + it('should attribute the event to the realm that emitted it', async () => { + const fragment = createFragment(); + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events[0].metadata).toMatchObject({ + application: 'realm', + environment: 'test', + appVersion: '1.0.0' + }); + }); + + it('should carry event metadata', async () => { + const fragment = createFragment(); + fragment + .get(CpsTelemetrySink) + .record('com.cps.bi', { eventName: 'x' }, { feature: 'cart' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events[0].metadata).toMatchObject({ feature: 'cart' }); + }); + + it('should forward handled errors', async () => { + const fragment = createFragment(); + fragment + .get(CpsTelemetrySink) + .recordError({ name: 'TypeError', message: 'boom' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.errors).toEqual([ + { name: 'TypeError', message: 'boom' } + ]); + }); + + it('should attribute a forwarded error to the realm that recorded it', async () => { + const fragment = createFragment(); + fragment + .get(CpsTelemetrySink) + .recordError({ name: 'TypeError', message: 'boom' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.errorMetadata[0]).toMatchObject({ + application: 'realm', + environment: 'test', + appVersion: '1.0.0' + }); + }); + + it('should forward flush requests, preserving the beacon flag', async () => { + const fragment = createFragment(); + fragment.get(CpsTelemetrySink).flush(true); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.flushes).toEqual([true]); + }); + + it('should forward a user id so one identity covers every realm', async () => { + const fragment = createFragment(); + fragment.get(CpsTelemetrySink).setUserId('user-42'); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.userId).toBe('user-42'); + }); + + it('should keep several fragments independent but pointed at one sink', async () => { + const a = createFragment(); + const b = createFragment(); + + a.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'from-a' }); + b.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'from-b' }); + + await CpsBroadcastChannelStub.settle(); + + expect( + shellSink.events.map( + (e) => (e.payload as { eventName: string }).eventName + ) + ).toEqual(['from-a', 'from-b']); + }); + + it('should not create an AWS client in the fragment realm', () => { + const fragment = createFragment(); + expect(fragment.get(CpsTelemetrySink)).toBeInstanceOf( + CpsBroadcastTelemetrySink + ); + }); + }); + + describe('shared session identity', () => { + it('should adopt the shell session id', async () => { + shellSink.sessionId = 'shell-session-1'; + const fragment = createFragment(); + + await CpsBroadcastChannelStub.settle(); + + expect(fragment.get(CpsTelemetrySink).getSessionId()).toBe( + 'shell-session-1' + ); + }); + + it('should report no session id before the shell answers', () => { + const fragment = createFragment(); + expect(fragment.get(CpsTelemetrySink).getSessionId()).toBeUndefined(); + }); + + it('should reach a fragment that started before the shell host existed', async () => { + host.ngOnDestroy(); + + const fragment = createFragment(); + await CpsBroadcastChannelStub.settle(); + expect(fragment.get(CpsTelemetrySink).getSessionId()).toBeUndefined(); + + const lateShell = createRealm([ + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink }, + CpsTelemetryBroadcastHost + ]); + lateShell.get(RecordingSink).sessionId = 'late-session'; + lateShell.get(CpsTelemetryBroadcastHost); + + await CpsBroadcastChannelStub.settle(); + + expect(fragment.get(CpsTelemetrySink).getSessionId()).toBe( + 'late-session' + ); + }); + + it('should re-announce once a session id that was not ready at construction resolves', async () => { + shellSink.sessionId = undefined; + const fragment = createFragment(); + await CpsBroadcastChannelStub.settle(); + expect(fragment.get(CpsTelemetrySink).getSessionId()).toBeUndefined(); + + shellSink.sessionId = 'shell-session-1'; + + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + await CpsBroadcastChannelStub.settle(); + + expect(fragment.get(CpsTelemetrySink).getSessionId()).toBe( + 'shell-session-1' + ); + }); + + it('should propagate a user id set in one fragment to a sibling fragment', async () => { + const a = createFragment(); + const b = createFragment(); + await CpsBroadcastChannelStub.settle(); + + a.get(CpsTelemetrySink).setUserId('user-42'); + await CpsBroadcastChannelStub.settle(); + + expect(b.get(CpsTelemetrySink).getUserId()).toBe('user-42'); + }); + + it("should clear a sibling fragment's user id on sign-out", async () => { + const a = createFragment(); + const b = createFragment(); + await CpsBroadcastChannelStub.settle(); + + a.get(CpsTelemetrySink).setUserId('user-42'); + await CpsBroadcastChannelStub.settle(); + expect(b.get(CpsTelemetrySink).getUserId()).toBe('user-42'); + + a.get(CpsTelemetrySink).setUserId(undefined); + await CpsBroadcastChannelStub.settle(); + + expect(b.get(CpsTelemetrySink).getUserId()).toBeUndefined(); + }); + + it('should announce a user id set directly on the shell, not only one relayed from a fragment', async () => { + const fragment = createFragment(); + await CpsBroadcastChannelStub.settle(); + + shellSink.userId = 'shell-user'; + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + await CpsBroadcastChannelStub.settle(); + + expect(fragment.get(CpsTelemetrySink).getUserId()).toBe('shell-user'); + }); + }); + + describe('scenarios in a fragment', () => { + it('should emit one packed record through the shell', async () => { + const fragment = createFragment(); + const scenarioTelemetry = fragment.get(CpsScenarioTelemetryService); + + const scenario = scenarioTelemetry.start({ name: 'add-to-cart' }); + scenario.step('validate').step('submit'); + scenario.complete(); + + await CpsBroadcastChannelStub.settle(); + + const records = shellSink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario); + expect(records).toHaveLength(1); + expect(records[0].payload).toMatchObject({ + scenarioName: 'add-to-cart', + status: 'success' + }); + }); + + it('should let a fragment scenario name a shell scenario as its parent', async () => { + const fragment = createFragment(); + const scenarioTelemetry = fragment.get(CpsScenarioTelemetryService); + + scenarioTelemetry + .start({ name: 'add-to-cart', parentScenarioId: 'shell-scenario-1' }) + .complete(); + + await CpsBroadcastChannelStub.settle(); + + expect( + shellSink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)[0].payload + ).toMatchObject({ parentScenarioId: 'shell-scenario-1' }); + }); + }); + + describe('robustness', () => { + it('should ignore unrelated traffic on the channel', async () => { + createFragment(); + const noise = new CpsBroadcastChannelStub('cps-telemetry'); + noise.postMessage({ some: 'other library' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events).toHaveLength(0); + expect(host.received).toBe(0); + }); + + it('should warn when a second host claims the same channel', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + createRealm([ + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink }, + CpsTelemetryBroadcastHost + ]).get(CpsTelemetryBroadcastHost); + + await CpsBroadcastChannelStub.settle(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('a second telemetry host is active') + ); + }); + + it('should stop delivering once the fragment is destroyed', async () => { + const fragment = createFragment(); + const sink = fragment.get(CpsBroadcastTelemetrySink); + + sink.ngOnDestroy(); + sink.record('com.cps.bi', { eventName: 'after-destroy' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events).toHaveLength(0); + }); + + it('should stop recording once the host is destroyed', async () => { + const fragment = createFragment(); + host.ngOnDestroy(); + + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events).toHaveLength(0); + }); + + it('should use a separate channel when one is configured', async () => { + const isolated = createRealm([ + CpsBroadcastTelemetrySink, + { provide: CpsTelemetrySink, useExisting: CpsBroadcastTelemetrySink }, + { provide: CPS_BROADCAST_CHANNEL, useValue: 'other-network' } + ]); + + isolated.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events).toHaveLength(0); + }); + }); + + describe('leader election', () => { + let consoleWarn: jest.SpyInstance; + + beforeEach(() => { + LockManagerStub.install(); + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + LockManagerStub.uninstall(); + consoleWarn.mockRestore(); + }); + + function createHost(): { + host: CpsTelemetryBroadcastHost; + sink: RecordingSink; + } { + const sink = new RecordingSink(); + const realmHost = createRealm([ + { provide: RecordingSink, useValue: sink }, + { provide: CpsTelemetrySink, useExisting: RecordingSink }, + CpsTelemetryBroadcastHost + ]).get(CpsTelemetryBroadcastHost); + return { host: realmHost, sink }; + } + + it('should keep a second host passive while the first is still leader', async () => { + const first = createHost(); + const second = createHost(); + + const fragment = createFragment(); + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + await CpsBroadcastChannelStub.settle(); + + expect(first.sink.events).toHaveLength(1); + expect(second.sink.events).toHaveLength(0); + expect(second.host.received).toBe(0); + }); + + it('should hand off leadership once the leader is destroyed', async () => { + const first = createHost(); + const second = createHost(); + + first.host.ngOnDestroy(); + await CpsBroadcastChannelStub.settle(); + + const fragment = createFragment(); + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + await CpsBroadcastChannelStub.settle(); + + expect(second.sink.events).toHaveLength(1); + expect(second.host.received).toBe(1); + }); + }); + + describe('cpsElectBroadcastHostLeader', () => { + afterEach(() => LockManagerStub.uninstall()); + + it('should elect immediately when the Locks API is unavailable', () => { + const onElected = jest.fn(); + cpsElectBroadcastHostLeader('cps-telemetry', onElected); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should elect once the lock is granted', () => { + LockManagerStub.install(); + const onElected = jest.fn(); + cpsElectBroadcastHostLeader('cps-telemetry', onElected); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should fail open and still elect when request() itself rejects', async () => { + Object.defineProperty(globalThis.navigator, 'locks', { + value: { request: () => Promise.reject(new Error('not-fully-active')) }, + configurable: true + }); + const onElected = jest.fn(); + + cpsElectBroadcastHostLeader('cps-telemetry', onElected); + // Let the rejection's microtask settle. + await Promise.resolve(); + await Promise.resolve(); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should fail open and still elect when request() itself throws synchronously', () => { + Object.defineProperty(globalThis.navigator, 'locks', { + value: { + request: () => { + throw new Error('locks unavailable in this context'); + } + }, + configurable: true + }); + const onElected = jest.fn(); + + expect(() => + cpsElectBroadcastHostLeader('cps-telemetry', onElected) + ).not.toThrow(); + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should not elect a requester released while still queued, and should let the next requester through', async () => { + LockManagerStub.install(); + const onElectedHolder = jest.fn(); + const onElectedA = jest.fn(); + const onElectedB = jest.fn(); + + const releaseHolder = cpsElectBroadcastHostLeader( + 'cps-telemetry', + onElectedHolder + ); + const releaseA = cpsElectBroadcastHostLeader('cps-telemetry', onElectedA); + cpsElectBroadcastHostLeader('cps-telemetry', onElectedB); + + // A is released before ever being granted the lock. + releaseA(); + // The current holder releases, so the lock passes down the queue. + releaseHolder(); + for (let i = 0; i < 6; i++) { + await Promise.resolve(); + } + + expect(onElectedA).not.toHaveBeenCalled(); + expect(onElectedB).toHaveBeenCalledTimes(1); + }); + }); + + describe('cpsIsBroadcastMessage', () => { + it.each([null, undefined, 'string', 42, ['array']])( + 'should reject the non-object payload %p', + (data) => { + expect(cpsIsBroadcastMessage(data)).toBe(false); + } + ); + + it('should reject a payload with an unknown kind', () => { + expect(cpsIsBroadcastMessage({ kind: 'other library' })).toBe(false); + }); + + it('should accept a well-formed event message', () => { + expect( + cpsIsBroadcastMessage({ + kind: 'event', + eventType: 'com.cps.bi', + payload: { eventName: 'x' } + }) + ).toBe(true); + }); + + it.each([ + { eventType: 'com.cps.bi' }, + { payload: {} }, + { eventType: 1, payload: {} }, + { eventType: 'com.cps.bi', payload: 'not-an-object' } + ])('should reject a malformed event message %p', (fields) => { + expect(cpsIsBroadcastMessage({ kind: 'event', ...fields })).toBe(false); + }); + + it('should accept a well-formed error message', () => { + expect( + cpsIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom' } + }) + ).toBe(true); + }); + + it.each([ + {}, + { error: { name: 'Error' } }, + { error: { message: 'boom' } }, + { error: 'boom' }, + { error: { name: 'Error', message: 'boom', stack: 42 } } + ])('should reject a malformed error message %p', (fields) => { + expect(cpsIsBroadcastMessage({ kind: 'error', ...fields })).toBe(false); + }); + + it('should accept an error message with a string stack', () => { + expect( + cpsIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom', stack: 'at foo.ts:1' } + }) + ).toBe(true); + }); + + it('should accept a well-formed flush message', () => { + expect(cpsIsBroadcastMessage({ kind: 'flush', beacon: true })).toBe(true); + }); + + it('should reject a flush message with a non-boolean beacon', () => { + expect(cpsIsBroadcastMessage({ kind: 'flush', beacon: 'true' })).toBe( + false + ); + }); + + it.each([{ userId: 'u-1' }, { userId: undefined }, {}])( + 'should accept a well-formed user message %p', + (fields) => { + expect(cpsIsBroadcastMessage({ kind: 'user', ...fields })).toBe(true); + } + ); + + it('should reject a user message with a non-string, non-undefined userId', () => { + expect(cpsIsBroadcastMessage({ kind: 'user', userId: 42 })).toBe(false); + }); + + it('should accept an identity-request message with no other fields', () => { + expect(cpsIsBroadcastMessage({ kind: 'identity-request' })).toBe(true); + }); + + it.each([ + {}, + { sessionId: 's-1' }, + { userId: 'u-1' }, + { + sessionId: 's-1', + userId: 'u-1' + } + ])('should accept an identity message %p', (fields) => { + expect(cpsIsBroadcastMessage({ kind: 'identity', ...fields })).toBe(true); + }); + + it.each([{ sessionId: 42 }, { userId: 42 }, { sessionId: 42, userId: 42 }])( + 'should reject an identity message with a non-string, non-undefined field %p', + (fields) => { + expect(cpsIsBroadcastMessage({ kind: 'identity', ...fields })).toBe( + false + ); + } + ); + }); + + describe('cpsConnectBroadcastChannel', () => { + it('should default the channel name to CPS_DEFAULT_BROADCAST_CHANNEL', () => { + const connection = TestBed.runInInjectionContext(() => + cpsConnectBroadcastChannel('test') + ); + expect(connection.channelName).toBe(CPS_DEFAULT_BROADCAST_CHANNEL); + }); + + it('should read an overridden channel name from CPS_BROADCAST_CHANNEL', () => { + TestBed.configureTestingModule({ + providers: [{ provide: CPS_BROADCAST_CHANNEL, useValue: 'my-channel' }] + }); + const connection = TestBed.runInInjectionContext(() => + cpsConnectBroadcastChannel('test') + ); + expect(connection.channelName).toBe('my-channel'); + }); + + it('should invoke the registered handler for an incoming message', async () => { + const connection = TestBed.runInInjectionContext(() => + cpsConnectBroadcastChannel('test') + ); + const received: unknown[] = []; + connection.onMessage((data) => received.push(data)); + + const peer = new CpsBroadcastChannelStub(CPS_DEFAULT_BROADCAST_CHANNEL); + peer.postMessage({ kind: 'flush', beacon: true }); + await CpsBroadcastChannelStub.settle(); + + expect(received).toEqual([{ kind: 'flush', beacon: true }]); + }); + + it('should stop delivering to a closed connection', async () => { + const connection = TestBed.runInInjectionContext(() => + cpsConnectBroadcastChannel('test') + ); + const received: unknown[] = []; + connection.onMessage((data) => received.push(data)); + connection.close(); + + const peer = new CpsBroadcastChannelStub(CPS_DEFAULT_BROADCAST_CHANNEL); + peer.postMessage({ kind: 'flush', beacon: true }); + await CpsBroadcastChannelStub.settle(); + + expect(received).toEqual([]); + }); + + it('should degrade to a safe no-op when BroadcastChannel is unavailable', () => { + CpsBroadcastChannelStub.uninstall(); + const connection = TestBed.runInInjectionContext(() => + cpsConnectBroadcastChannel('test') + ); + + expect(() => { + connection.post({ kind: 'identity-request' }); + connection.onMessage(() => undefined); + connection.close(); + }).not.toThrow(); + }); + }); + + describe('without BroadcastChannel support', () => { + beforeEach(() => CpsBroadcastChannelStub.uninstall()); + + it('should degrade to a no-op sink rather than throwing', () => { + const fragment = createFragment(); + const sink = fragment.get(CpsTelemetrySink); + + expect(() => { + sink.record('com.cps.bi', { eventName: 'x' }); + sink.recordError({ name: 'Error', message: 'boom' }); + sink.setUserId('user-1'); + sink.flush(true); + }).not.toThrow(); + expect(sink.getSessionId()).toBeUndefined(); + }); + + it('should let a scenario run to completion in the fragment', () => { + const fragment = createFragment(); + const scenarioTelemetry = fragment.get(CpsScenarioTelemetryService); + + const scenario = scenarioTelemetry.start({ name: 'add-to-cart' }); + expect(() => scenario.step('one').complete()).not.toThrow(); + expect(scenario.status).toBe('success'); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink.spec.ts b/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink.spec.ts new file mode 100644 index 000000000..d63dc5aaa --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink.spec.ts @@ -0,0 +1,53 @@ +import { CpsNoopTelemetrySink } from './cps-noop-telemetry.sink'; +import { CpsTelemetrySink } from '../cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; + +describe('CpsNoopTelemetrySink', () => { + let sink: CpsTelemetrySink; + + beforeEach(() => { + sink = new CpsNoopTelemetrySink(); + }); + + it('should discard record() without throwing', () => { + expect(() => sink.record('com.cps.bi', { x: 1 })).not.toThrow(); + }); + + it('should discard recordError() without throwing', () => { + expect(() => + sink.recordError({ name: 'Error', message: 'boom' }) + ).not.toThrow(); + }); + + it('should report no session id', () => { + expect(sink.getSessionId()).toBeUndefined(); + }); + + it('should discard setUserId() without throwing', () => { + expect(() => sink.setUserId('user-1')).not.toThrow(); + }); + + it('should report the user id last given to setUserId(), even though telemetry itself is discarded', () => { + sink.setUserId('user-1'); + expect(sink.getUserId()).toBe('user-1'); + }); + + it('should report no user id again after signing out', () => { + sink.setUserId('user-1'); + sink.setUserId(undefined); + expect(sink.getUserId()).toBeUndefined(); + }); + + it('should discard flush() without throwing', () => { + expect(() => sink.flush(true)).not.toThrow(); + }); + + it('should accept the full sink API when typed as the concrete class itself, not just through CpsTelemetrySink', () => { + const concrete = new CpsNoopTelemetrySink(); + expect(() => { + concrete.record('com.cps.bi', { x: 1 }, { a: 1 }); + concrete.recordError({ name: 'Error', message: 'boom' }, { a: 1 }); + concrete.setUserId('user-1'); + concrete.flush(true); + }).not.toThrow(); + }); +}); diff --git a/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink.ts b/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink.ts new file mode 100644 index 000000000..45c4c5ff4 --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsTelemetrySink } from '../cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; + +/** + * Sink that discards everything. + * + * Selected with `provideCpsTelemetrySink('noop')` — never a default. Debug + * flags, logging and scenario mechanics all still work, with no AWS account + * or `aws-rum-web` dependency needed. + * + * @group Services + */ +@Injectable() +export class CpsNoopTelemetrySink extends CpsTelemetrySink { + private userId?: string; + + /** @inheritdoc */ + record( + _eventType: string, + _payload: object, + _metadata?: CpsTelemetryMetadata + ): void {} + + /** @inheritdoc */ + recordError( + _error: CpsTelemetryError, + _metadata?: CpsTelemetryMetadata + ): void {} + + /** @inheritdoc */ + getSessionId(): string | undefined { + return undefined; + } + + /** @inheritdoc */ + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(_beacon?: boolean): void {} +} diff --git a/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink.ts b/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink.ts new file mode 100644 index 000000000..414e113f8 --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@angular/core'; +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../../models/cps-telemetry-common.models/cps-telemetry-common.models'; + +/** + * Destination for scenario and BI telemetry. + * + * Application code never touches this directly — it lets the AWS RUM + * integration be replaced or stubbed with no change to the telemetry + * services or the applications that use them. + * + * @group Services + */ +@Injectable() +export abstract class CpsTelemetrySink { + /** + * Records one custom event. Must be non-throwing and non-blocking. + * + * @param eventType one of {@link CPS_TELEMETRY_EVENT_TYPE} + * @param payload the event body + * @param metadata optional flat attributes attached to the event envelope + */ + abstract record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void; + + /** + * Records a handled error as a first-class error signal, alongside errors + * the RUM client captures itself. Only reached when + * {@link CpsLogConfig.mirrorErrorsToRum} is enabled. + * + * @param error the normalized error + * @param metadata optional flat attributes attached to the error envelope + * (e.g. a forwarding sink's own origin identity) + */ + abstract recordError( + error: CpsTelemetryError, + metadata?: CpsTelemetryMetadata + ): void; + + /** Returns the current session identifier, when the implementation has one. */ + abstract getSessionId(): string | undefined; + + /** + * Associates subsequent telemetry with an application user identifier, or + * with nobody. `undefined` means signed out. + * + * @param userId the application's own user identifier, or `undefined` to + * stop attributing telemetry to the previous one + */ + abstract setUserId(userId: string | undefined): void; + + /** Returns the user identifier last given to {@link setUserId}, if any. */ + abstract getUserId(): string | undefined; + + /** + * Requests that buffered telemetry be sent immediately. + * + * @param beacon use a transport that survives page unload + */ + abstract flush(beacon?: boolean): void; +} diff --git a/projects/cps-telemetry/src/lib/utils/cps-debug-flag.util/cps-debug-flag.util.spec.ts b/projects/cps-telemetry/src/lib/utils/cps-debug-flag.util/cps-debug-flag.util.spec.ts new file mode 100644 index 000000000..74502daff --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-debug-flag.util/cps-debug-flag.util.spec.ts @@ -0,0 +1,94 @@ +import { cpsIsDebugEnabled } from './cps-debug-flag.util'; + +describe('cpsIsDebugEnabled', () => { + afterEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + it('should be disabled by default when the key is absent', () => { + expect(cpsIsDebugEnabled('debugLogger')).toBe(false); + expect(cpsIsDebugEnabled('debugScenario')).toBe(false); + expect(cpsIsDebugEnabled('debugBI')).toBe(false); + }); + + it('should be enabled by the string "true"', () => { + localStorage.setItem('debugLogger', 'true'); + expect(cpsIsDebugEnabled('debugLogger')).toBe(true); + }); + + it('should be enabled by the string "1"', () => { + localStorage.setItem('debugScenario', '1'); + expect(cpsIsDebugEnabled('debugScenario')).toBe(true); + }); + + it('should accept surrounding whitespace and mixed casing', () => { + localStorage.setItem('debugBI', ' TRUE '); + expect(cpsIsDebugEnabled('debugBI')).toBe(true); + }); + + it.each(['false', '0', 'yes', 'on', '', 'null', 'undefined', '2'])( + 'should stay disabled for the invalid value "%s"', + (value) => { + localStorage.setItem('debugLogger', value); + expect(cpsIsDebugEnabled('debugLogger')).toBe(false); + } + ); + + it('should stay disabled when localStorage throws', () => { + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError: access denied'); + }); + expect(cpsIsDebugEnabled('debugLogger')).toBe(false); + }); + + it('should read the flag on every call so DevTools toggles take effect', () => { + expect(cpsIsDebugEnabled('debugLogger')).toBe(false); + localStorage.setItem('debugLogger', 'true'); + expect(cpsIsDebugEnabled('debugLogger')).toBe(true); + localStorage.removeItem('debugLogger'); + expect(cpsIsDebugEnabled('debugLogger')).toBe(false); + }); + + describe('scoped by name', () => { + it('should enable only a matching name', () => { + localStorage.setItem('debugLogger', 'checkout'); + + expect(cpsIsDebugEnabled('debugLogger', 'checkout')).toBe(true); + expect(cpsIsDebugEnabled('debugLogger', 'admin')).toBe(false); + }); + + it('should accept a comma-separated list', () => { + localStorage.setItem('debugLogger', 'checkout,admin'); + + expect(cpsIsDebugEnabled('debugLogger', 'checkout')).toBe(true); + expect(cpsIsDebugEnabled('debugLogger', 'admin')).toBe(true); + expect(cpsIsDebugEnabled('debugLogger', 'reports')).toBe(false); + }); + + it('should ignore whitespace and case around each entry', () => { + localStorage.setItem('debugLogger', ' Checkout , ADMIN '); + + expect(cpsIsDebugEnabled('debugLogger', 'checkout')).toBe(true); + expect(cpsIsDebugEnabled('debugLogger', 'admin')).toBe(true); + }); + + it('should read a list as disabled for a caller that asks unqualified', () => { + localStorage.setItem('debugLogger', 'checkout'); + + expect(cpsIsDebugEnabled('debugLogger')).toBe(false); + }); + + it('should enable every name when the value is "true"', () => { + localStorage.setItem('debugLogger', 'true'); + + expect(cpsIsDebugEnabled('debugLogger', 'checkout')).toBe(true); + expect(cpsIsDebugEnabled('debugLogger', 'anything')).toBe(true); + expect(cpsIsDebugEnabled('debugLogger')).toBe(true); + }); + + it('should stay disabled for a name when the flag is unset', () => { + expect(cpsIsDebugEnabled('debugLogger', 'checkout')).toBe(false); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/utils/cps-debug-flag.util/cps-debug-flag.util.ts b/projects/cps-telemetry/src/lib/utils/cps-debug-flag.util/cps-debug-flag.util.ts new file mode 100644 index 000000000..2d512abfb --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-debug-flag.util/cps-debug-flag.util.ts @@ -0,0 +1,47 @@ +/** + * LocalStorage keys recognised as telemetry debug switches. + * + * @group Types + */ +export type CpsDebugFlag = 'debugLogger' | 'debugScenario' | 'debugBI'; + +const ENABLED_VALUES = new Set(['true', '1']); + +/** + * Reports whether a telemetry debug flag is enabled in LocalStorage. + * + * Only `'true'` and `'1'` enable it (case-insensitive, trimmed). Any other + * non-empty value is treated as a comma-separated list of names, enabling the + * flag only when `name` matches one of them; a call with no `name` reads a + * list value as disabled. A missing key or a throwing `localStorage` also + * reads as disabled. + * + * @param flag the LocalStorage key to check + * @param name the name to test against a list value, when the flag is scoped + * @returns `true` only when the flag is enabled for this name + * + * @group Utils + */ +export function cpsIsDebugEnabled(flag: CpsDebugFlag, name?: string): boolean { + try { + const raw = globalThis.localStorage?.getItem(flag); + if (typeof raw !== 'string') { + return false; + } + + const value = raw.trim().toLowerCase(); + if (ENABLED_VALUES.has(value)) { + return true; + } + + if (name === undefined || !value) { + return false; + } + + return value + .split(',') + .some((entry) => entry.trim() === name.trim().toLowerCase()); + } catch { + return false; + } +} diff --git a/projects/cps-telemetry/src/lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util.spec.ts b/projects/cps-telemetry/src/lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util.spec.ts new file mode 100644 index 000000000..350c753c0 --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util.spec.ts @@ -0,0 +1,631 @@ +import { + CPS_DEFAULT_REDACT_CONFIG, + CPS_REDACTED, + cpsNormalizeError, + cpsRedactConfigFor, + cpsRedactMetadata, + cpsScrubString +} from './cps-telemetry-redact.util'; + +describe('cpsScrubString', () => { + it('should strip the query string and fragment from an absolute URL', () => { + expect( + cpsScrubString( + 'https://api.example.com/customers?token=abc123#section', + CPS_DEFAULT_REDACT_CONFIG + ) + ).toBe('https://api.example.com/customers'); + }); + + it('should strip a URL embedded inside a longer message', () => { + expect( + cpsScrubString( + 'Request to https://api.example.com/v1/users?apiKey=secret failed', + CPS_DEFAULT_REDACT_CONFIG + ) + ).toBe('Request to https://api.example.com/v1/users failed'); + }); + + it('should strip the query string from a root-relative path', () => { + expect( + cpsScrubString('/customers?ssn=123-45-6789', CPS_DEFAULT_REDACT_CONFIG) + ).toBe('/customers'); + }); + + it('should strip a root-relative path embedded inside a longer message', () => { + expect( + cpsScrubString( + 'Request to /api/customers/search?email=john@example.com&ssn=123-45-6789 failed', + CPS_DEFAULT_REDACT_CONFIG + ) + ).toBe('Request to /api/customers/search failed'); + }); + + it('should strip more than one embedded root-relative path in the same string', () => { + expect( + cpsScrubString( + 'compare /a/x?p=1 against /b/y?q=2 now', + CPS_DEFAULT_REDACT_CONFIG + ) + ).toBe('compare /a/x against /b/y now'); + }); + + it('should strip a root-relative path with no space before it', () => { + expect( + cpsScrubString( + 'Redirected to:/dashboard?sessionToken=abc123', + CPS_DEFAULT_REDACT_CONFIG + ) + ).toBe('Redirected to:/dashboard'); + }); + + it('should leave a plain message that merely ends in a question mark intact', () => { + expect( + cpsScrubString('Could not load the data?', CPS_DEFAULT_REDACT_CONFIG) + ).toBe('Could not load the data?'); + }); + + it('should truncate strings beyond the configured cap', () => { + const result = cpsScrubString('x'.repeat(50), { + ...CPS_DEFAULT_REDACT_CONFIG, + maxStringLength: 10 + }); + expect(result).toBe(`${'x'.repeat(10)}…`); + }); + + it('should leave URLs alone when stripping is disabled', () => { + expect( + cpsScrubString('https://example.com/a?b=c', { + ...CPS_DEFAULT_REDACT_CONFIG, + stripUrlQuery: false + }) + ).toBe('https://example.com/a?b=c'); + }); + + describe('value-pattern scanning', () => { + it('should not scan for any value pattern by default', () => { + expect( + cpsScrubString( + 'contact john.smith@example.com, card 4111111111111111, ssn 123-45-6789', + CPS_DEFAULT_REDACT_CONFIG + ) + ).toBe( + 'contact john.smith@example.com, card 4111111111111111, ssn 123-45-6789' + ); + }); + + it('should redact an email address when enabled', () => { + expect( + cpsScrubString('contact john.smith@example.com for help', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email'] + }) + ).toBe(`contact ${CPS_REDACTED} for help`); + }); + + it('should not match a bare @-handle with no domain as an email', () => { + expect( + cpsScrubString('cc @someuser on this', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email'] + }) + ).toBe('cc @someuser on this'); + }); + + it('should catch an email embedded in a URL path segment', () => { + expect( + cpsScrubString('/customers/john.smith@example.com', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email'] + }) + ).toBe(`/customers/${CPS_REDACTED}`); + }); + + it('should redact a Luhn-valid credit card number when enabled', () => { + expect( + cpsScrubString('card on file: 4111111111111111', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['creditCard'] + }) + ).toBe(`card on file: ${CPS_REDACTED}`); + }); + + it('should leave a 16-digit run alone when it fails the Luhn checksum', () => { + expect( + cpsScrubString('order number: 1234567890123456', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['creditCard'] + }) + ).toBe('order number: 1234567890123456'); + }); + + it('should redact a well-formatted SSN when enabled', () => { + expect( + cpsScrubString('ssn on file: 123-45-6789', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ssn'] + }) + ).toBe(`ssn on file: ${CPS_REDACTED}`); + }); + + it('should not match a bare 9-digit run with no dashes as an SSN', () => { + expect( + cpsScrubString('reference 123456789', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ssn'] + }) + ).toBe('reference 123456789'); + }); + + it('should redact an IPv4 address when enabled', () => { + expect( + cpsScrubString('client at 192.168.1.1', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ipv4'] + }) + ).toBe(`client at ${CPS_REDACTED}`); + }); + + it('should not match an out-of-range octet as an IPv4 address', () => { + expect( + cpsScrubString('version 999.999.999.999', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ipv4'] + }) + ).toBe('version 999.999.999.999'); + }); + + it('should redact a US-shaped phone number when enabled', () => { + expect( + cpsScrubString('call (555) 123-4567', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['phone'] + }) + ).toBe(`call ${CPS_REDACTED}`); + }); + + it('should redact a South African phone number in international format when enabled', () => { + expect( + cpsScrubString('call +27 82 123 4567', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['phone'] + }) + ).toBe(`call ${CPS_REDACTED}`); + }); + + it('should redact a value matching an application-supplied pattern', () => { + expect( + cpsScrubString('internal ref ACC-98765', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [/ACC-\d+/] + }) + ).toBe(`internal ref ${CPS_REDACTED}`); + }); + + it('should redact every occurrence even when the supplied pattern has no g flag', () => { + expect( + cpsScrubString('ref ACC-111 and also ref ACC-222', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [/ACC-\d+/] + }) + ).toBe(`ref ${CPS_REDACTED} and also ref ${CPS_REDACTED}`); + }); + + it('should fully redact a credit card number regardless of scanValuePatterns order', () => { + const value = 'card on file: 4111111111111111'; + expect( + cpsScrubString(value, { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['phone', 'creditCard'] + }) + ).toBe(`card on file: ${CPS_REDACTED}`); + expect( + cpsScrubString(value, { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['creditCard', 'phone'] + }) + ).toBe(`card on file: ${CPS_REDACTED}`); + }); + + it('should apply multiple enabled patterns to the same string', () => { + expect( + cpsScrubString('email a@b.com or call (555) 123-4567', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email', 'phone'] + }) + ).toBe(`email ${CPS_REDACTED} or call ${CPS_REDACTED}`); + }); + }); + + describe('extraValueTransforms', () => { + it('should run a custom transform on every string value', () => { + expect( + cpsScrubString('internal-id ACC-12345', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + (value) => value.replace(/ACC-\d+/, CPS_REDACTED) + ] + }) + ).toBe(`internal-id ${CPS_REDACTED}`); + }); + + it('should run regardless of scanValuePatterns/extraValuePatterns being empty', () => { + expect( + cpsScrubString('plain text', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [() => 'replaced'] + }) + ).toBe('replaced'); + }); + + it('should run multiple transforms in array order', () => { + expect( + cpsScrubString('start', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + (value) => `${value}-a`, + (value) => `${value}-b` + ] + }) + ).toBe('start-a-b'); + }); + + describe('a throwing transform', () => { + let consoleWarn: jest.SpyInstance; + + beforeEach(() => { + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarn.mockRestore(); + }); + + it('should skip it and keep the value from before it, reporting why', () => { + expect( + cpsScrubString('unchanged', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + () => { + throw new Error('broken transform'); + } + ] + }) + ).toBe('unchanged'); + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining('extraValueTransforms'), + expect.any(Error) + ); + }); + + it('should still apply subsequent transforms after it', () => { + expect( + cpsScrubString('start', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + () => { + throw new Error('broken transform'); + }, + (value) => `${value}-ok` + ] + }) + ).toBe('start-ok'); + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining('extraValueTransforms'), + expect.any(Error) + ); + }); + }); + + it('should still cap length after custom transforms run', () => { + expect( + cpsScrubString('short', { + ...CPS_DEFAULT_REDACT_CONFIG, + maxStringLength: 5, + extraValueTransforms: [() => 'a much longer replacement value'] + }) + ).toBe('a muc…'); + }); + }); +}); + +describe('cpsRedactMetadata', () => { + it('should keep primitive values', () => { + expect( + cpsRedactMetadata({ count: 3, name: 'csv', ok: true, empty: null }) + ).toEqual({ count: 3, name: 'csv', ok: true, empty: null }); + }); + + it.each([ + 'password', + 'passwd', + 'accessToken', + 'refresh_token', + 'Authorization', + 'clientSecret', + 'apiKey', + 'api_key', + 'Cookie', + 'bearerToken', + 'jwt', + 'signature', + 'sessionKey', + 'ssn' + ])('should redact the sensitive key "%s"', (key) => { + expect(cpsRedactMetadata({ [key]: 'super-secret-value' })).toEqual({ + [key]: CPS_REDACTED + }); + }); + + it('should redact keys matching an application-supplied pattern', () => { + expect( + cpsRedactMetadata( + { customerRef: 'ABC' }, + { ...CPS_DEFAULT_REDACT_CONFIG, extraKeyPatterns: [/customerRef/i] } + ) + ).toEqual({ customerRef: CPS_REDACTED }); + }); + + it('should redact the same key consistently across repeated calls with a global pattern', () => { + const config = { + ...CPS_DEFAULT_REDACT_CONFIG, + extraKeyPatterns: [/internalId/gi] + }; + for (let i = 0; i < 4; i++) { + expect(cpsRedactMetadata({ internalId: 'value' }, config)).toEqual({ + internalId: CPS_REDACTED + }); + } + }); + + it('should drop nested objects rather than serializing them', () => { + expect( + cpsRedactMetadata({ user: { id: 1, email: 'a@b.c' }, safe: 'yes' }) + ).toEqual({ safe: 'yes' }); + }); + + it('should drop arrays, functions, symbols and undefined', () => { + expect( + cpsRedactMetadata({ + rows: [1, 2, 3], + fn: () => undefined, + sym: Symbol('s'), + missing: undefined, + kept: 1 + }) + ).toEqual({ kept: 1 }); + }); + + it('should drop non-finite numbers', () => { + expect( + cpsRedactMetadata({ a: NaN, b: Infinity, c: -Infinity, d: 0 }) + ).toEqual({ d: 0 }); + }); + + it('should scrub URLs inside string values', () => { + expect(cpsRedactMetadata({ url: 'https://x.dev/p?token=1' })).toEqual({ + url: 'https://x.dev/p' + }); + }); + + it('should cap the number of retained keys', () => { + const consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const input: Record = {}; + for (let i = 0; i < 100; i++) { + input[`k${i}`] = i; + } + const result = cpsRedactMetadata(input, { + ...CPS_DEFAULT_REDACT_CONFIG, + maxKeys: 5 + }); + expect(Object.keys(result ?? {})).toHaveLength(5); + + consoleWarn.mockRestore(); + }); + + describe('truncation warning', () => { + let consoleWarn: jest.SpyInstance; + + beforeEach(() => { + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarn.mockRestore(); + }); + + it('should warn once when maxKeys actually truncates something', () => { + cpsRedactMetadata( + { a: 1, b: 2, c: 3 }, + { ...CPS_DEFAULT_REDACT_CONFIG, maxKeys: 2 } + ); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining('maxKeys') + ); + }); + + it('should stay silent when every key fits under maxKeys', () => { + cpsRedactMetadata( + { a: 1, b: 2 }, + { ...CPS_DEFAULT_REDACT_CONFIG, maxKeys: 2 } + ); + + expect(consoleWarn).not.toHaveBeenCalled(); + }); + }); + + it('should not throw on a cyclic object', () => { + const cyclic: Record = { name: 'root' }; + cyclic.self = cyclic; + expect(cpsRedactMetadata(cyclic)).toEqual({ name: 'root' }); + }); + + it.each([[null], [undefined], ['string'], [42], [[1, 2]]])( + 'should return undefined for the non-object input %p', + (input) => { + expect(cpsRedactMetadata(input)).toBeUndefined(); + } + ); + + it('should return undefined when nothing survives redaction', () => { + expect(cpsRedactMetadata({ nested: { a: 1 } })).toBeUndefined(); + }); +}); + +describe('cpsNormalizeError', () => { + it('should reduce an Error to name, message and stack', () => { + const result = cpsNormalizeError(new TypeError('boom')); + expect(result?.name).toBe('TypeError'); + expect(result?.message).toBe('boom'); + expect(typeof result?.stack).toBe('string'); + }); + + it('should omit the stack when capture is disabled', () => { + const result = cpsNormalizeError(new Error('boom'), { + ...CPS_DEFAULT_REDACT_CONFIG, + includeStack: false + }); + expect(result?.stack).toBeUndefined(); + }); + + it('should cap the stack length', () => { + const error = new Error('boom'); + error.stack = 'y'.repeat(5000); + const result = cpsNormalizeError(error, { + ...CPS_DEFAULT_REDACT_CONFIG, + maxStackLength: 100 + }); + expect(result?.stack?.length).toBe(101); + }); + + it('should scrub URLs out of the error message', () => { + const result = cpsNormalizeError( + new Error('GET https://api.dev/me?access_token=xyz returned 401') + ); + expect(result?.message).toBe('GET https://api.dev/me returned 401'); + }); + + it('should accept a thrown string', () => { + expect(cpsNormalizeError('plain failure')).toEqual({ + name: 'Error', + message: 'plain failure' + }); + }); + + it('should extract name and message from an HttpErrorResponse-shaped object', () => { + const httpError = { + name: 'HttpErrorResponse', + message: 'Http failure response for /api/customers: 404 Not Found', + status: 404, + statusText: 'Not Found', + url: '/api/customers', + ok: false, + error: { secret: 'raw response body — must never appear in output' } + }; + + const result = cpsNormalizeError(httpError); + + expect(result).toEqual({ + name: 'HttpErrorResponse', + message: 'Http failure response for /api/customers: 404 Not Found' + }); + expect(JSON.stringify(result)).not.toContain('raw response body'); + }); + + it('should fall back to a generic name when the HTTP-error-shaped object has none', () => { + const result = cpsNormalizeError({ + message: 'Http failure response for /api/x: 500 Internal Server Error', + status: 500, + statusText: 'Internal Server Error' + }); + + expect(result?.name).toBe('HttpErrorResponse'); + }); + + it('should not treat an arbitrary object with a status-shaped key as an HTTP error', () => { + expect( + cpsNormalizeError({ status: 404, message: 'unrelated object' }) + ).toEqual({ name: 'UnknownError', message: CPS_REDACTED }); + }); + + it('should report the type of a thrown object without serializing it', () => { + expect(cpsNormalizeError({ password: 'hunter2' })).toEqual({ + name: 'UnknownError', + message: CPS_REDACTED + }); + }); + + it.each([[null], [undefined]])('should return undefined for %p', (input) => { + expect(cpsNormalizeError(input)).toBeUndefined(); + }); +}); + +describe('cpsRedactConfigFor', () => { + it('should return the same config unchanged when enabled', () => { + expect(cpsRedactConfigFor(CPS_DEFAULT_REDACT_CONFIG, true)).toBe( + CPS_DEFAULT_REDACT_CONFIG + ); + }); + + it('should turn off PII scrubbing when disabled', () => { + const config = cpsRedactConfigFor( + { + ...CPS_DEFAULT_REDACT_CONFIG, + extraKeyPatterns: [/x-internal/], + scanValuePatterns: ['email'], + extraValuePatterns: [/secret-\d+/] + }, + false + ); + + expect(config.extraKeyPatterns).toEqual([]); + expect(config.stripUrlQuery).toBe(false); + expect(config.scanValuePatterns).toEqual([]); + expect(config.extraValuePatterns).toEqual([]); + }); + + it('should keep size caps, error normalization inputs, and extraValueTransforms when disabled', () => { + const transform = (value: string) => value; + const config = cpsRedactConfigFor( + { + ...CPS_DEFAULT_REDACT_CONFIG, + maxStringLength: 10, + maxKeys: 5, + maxStackLength: 100, + includeStack: true, + extraValueTransforms: [transform] + }, + false + ); + + expect(config.maxStringLength).toBe(10); + expect(config.maxKeys).toBe(5); + expect(config.maxStackLength).toBe(100); + expect(config.includeStack).toBe(true); + expect(config.extraValueTransforms).toEqual([transform]); + }); + + it('should still redact a built-in denylisted key when disabled — it is a safety floor, not a privacy opt-in', () => { + const disabled = cpsRedactConfigFor(CPS_DEFAULT_REDACT_CONFIG, false); + + expect(cpsRedactMetadata({ password: 'hunter2' }, disabled)).toEqual({ + password: CPS_REDACTED + }); + }); + + it('should not redact an extraKeyPatterns-only key when disabled', () => { + const disabled = cpsRedactConfigFor( + { ...CPS_DEFAULT_REDACT_CONFIG, extraKeyPatterns: [/x-internal/] }, + false + ); + + expect(cpsRedactMetadata({ 'x-internal-id': 'abc' }, disabled)).toEqual({ + 'x-internal-id': 'abc' + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util.ts b/projects/cps-telemetry/src/lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util.ts new file mode 100644 index 000000000..13067d10a --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util.ts @@ -0,0 +1,414 @@ +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { cpsIsDevMode } from '../cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util'; + +/** + * Named, built-in value-content PII shapes {@link CpsRedactConfig.scanValuePatterns} + * can enable. + * + * @group Types + */ +export type CpsPiiValuePattern = + 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'; + +/** + * Tunables governing what telemetry is allowed to carry off the device. + * + * @group Interfaces + */ +export interface CpsRedactConfig { + /** + * Additional key patterns to redact, merged with the built-in denylist. + * Matched against the attribute key, case-insensitively. + */ + extraKeyPatterns: RegExp[]; + + /** Maximum length of any single string value before truncation. */ + maxStringLength: number; + + /** Maximum number of attributes retained on one payload. */ + maxKeys: number; + + /** Maximum length of a captured stack trace. */ + maxStackLength: number; + + /** Whether stack traces are captured at all. */ + includeStack: boolean; + + /** Whether URL query strings and fragments are stripped from string values. */ + stripUrlQuery: boolean; + + /** + * Value-content PII shapes to scan for, in addition to the key denylist. + * Off by default. Heuristic regexes, not certified detectors — `'phone'` + * is the noisiest; `'creditCard'` also requires a Luhn match. + */ + scanValuePatterns: CpsPiiValuePattern[]; + + /** + * Additional value-content regexes, merged with `scanValuePatterns` — the + * content counterpart to `extraKeyPatterns`. + */ + extraValuePatterns: RegExp[]; + + /** + * Application-supplied functions run on every string value, after all + * pattern-based scrubbing — an escape hatch for logic no regex can + * express. Runs independently of `scanValuePatterns`/`extraValuePatterns`. + * A throwing function is skipped (logged in dev mode), not fatal. + */ + extraValueTransforms: Array<(value: string) => string>; +} + +/** Keys whose values never leave the browser, matched case-insensitively (`Authorization`, `access_token`, `apiKey`, …). */ +const DENYLIST = + /pass(word|wd)?|secret|token|auth|credential|cookie|api[-_]?key|bearer|jwt|signature|session[-_]?key|ssn/i; + +/** Absolute http(s) URLs appearing anywhere inside a string. */ +const URL_PATTERN = /https?:\/\/[^\s"'<>]+/gi; + +/** + * A root-relative path with a query string or fragment. The lookbehind + * rejects a `/` that's mid-word (so this doesn't re-match inside an + * already-scrubbed URL) without requiring whitespace before the path — + * punctuation like `:` counts as a boundary too. + */ +const PATH_WITH_QUERY = /(?, + RegExp +> = { + // Common email shape, not full RFC 5322. + email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, + // Matches only the dashed `123-45-6789` format; a bare 9-digit run isn't + // reliably an SSN. + ssn: /\b\d{3}-\d{2}-\d{4}\b/g, + ipv4: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, + // NANP (US/Canada): 3-3-4 digits, optional `1`/`+1` country code, + // South Africa (27), Botswana (267), Ghana (233), Kenya + // (254), Mauritius (230), Mozambique (258), Namibia (264), Seychelles + // (248), Tanzania (255), Uganda (256), Zambia (260). + phone: + /(?:(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}|(?:\+|00)(?:27|267|233|254|230|258|264|248|255|256|260)(?:[-.\s]?\d){7,9})\b/g +}; + +/** + * Fixed order for {@link CpsRedactConfig.scanValuePatterns}, independent of + * the consumer's array order. `creditCard` must run before `phone`: phone + * has no leading `\b` and can match a trailing substring of a card number, + * fragmenting and leaking part of it if it ran first. + */ +const VALUE_PATTERN_ORDER: CpsPiiValuePattern[] = [ + 'email', + 'creditCard', + 'ssn', + 'ipv4', + 'phone' +]; + +/** Candidate digit runs, allowing the separators people actually type. */ +const CREDIT_CARD_CANDIDATE = /\b(?:\d[ -]?){13,19}\b/g; + +/** The standard Luhn checksum — tells a real card number from an unrelated digit run of the same length. */ +function isLuhnValid(digits: string): boolean { + let sum = 0; + let alternate = false; + + for (let i = digits.length - 1; i >= 0; i--) { + let n = digits.charCodeAt(i) - 48; // '0' + if (alternate) { + n *= 2; + if (n > 9) { + n -= 9; + } + } + sum += n; + alternate = !alternate; + } + + return digits.length > 0 && sum % 10 === 0; +} + +/** Redacts credit-card-shaped digit runs that also pass a Luhn checksum. */ +function scrubCreditCards(value: string): string { + return value.replace(CREDIT_CARD_CANDIDATE, (match) => { + const digits = match.replace(/\D/g, ''); + return digits.length >= 13 && digits.length <= 19 && isLuhnValid(digits) + ? CPS_REDACTED + : match; + }); +} + +/** Ensures a pattern has the `g` flag, so `.replace()` catches every occurrence, not just the first. */ +function ensureGlobal(pattern: RegExp): RegExp { + return pattern.global + ? pattern + : new RegExp(pattern.source, `${pattern.flags}g`); +} + +/** + * Default redaction settings. Conservative; widen as needed. + * + * @group Utils + */ +export const CPS_DEFAULT_REDACT_CONFIG: CpsRedactConfig = { + extraKeyPatterns: [], + maxStringLength: 1024, + maxKeys: 50, + maxStackLength: 2048, + includeStack: true, + stripUrlQuery: true, + scanValuePatterns: [], + extraValuePatterns: [], + extraValueTransforms: [] +}; + +/** + * Effective redact config for a concern's own `redact: boolean` toggle + * (`CpsLogConfig.redact`, `CpsScenarioConfig.redact`, `CpsBiConfig.redact`). + * Disabling it skips only the configurable scrubbing — the built-in + * credential denylist, size caps, error normalization and + * `extraValueTransforms` stay on regardless. See DESIGN.md §10, "Turning + * redaction off per concern", for why. + * + * @param config the concern's resolved `CPS_REDACT_CONFIG` + * @param enabled the concern's own `redact` setting + * @returns `config` unchanged when enabled, otherwise with configurable + * scrubbing turned off + * + * @group Utils + */ +export function cpsRedactConfigFor( + config: CpsRedactConfig, + enabled: boolean +): CpsRedactConfig { + if (enabled) { + return config; + } + + return { + ...config, + extraKeyPatterns: [], + stripUrlQuery: false, + scanValuePatterns: [], + extraValuePatterns: [] + }; +} + +function isDenied(key: string, config: CpsRedactConfig): boolean { + if (DENYLIST.test(key)) { + return true; + } + return config.extraKeyPatterns.some((pattern) => { + // A global/sticky pattern is stateful: .test() advances lastIndex, so + // reset it to avoid alternating matches across calls. + pattern.lastIndex = 0; + return pattern.test(key); + }); +} + +/** Reduces a URL to `origin + pathname` — tokens and identifiers tend to live in the query/fragment being dropped. */ +function scrubUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + const cut = url.search(/[?#]/); + return cut === -1 ? url : url.slice(0, cut); + } +} + +/** + * Strips URL query strings/fragments, scans for opted-in PII shapes, runs + * `extraValueTransforms`, then caps length — in that order, so value + * scanning also catches PII embedded in a URL path segment. + * + * @param value the string to clean + * @param config redaction settings + * @returns the cleaned, length-capped string + * + * @group Utils + */ +export function cpsScrubString(value: string, config: CpsRedactConfig): string { + let result = value; + + if (config.stripUrlQuery) { + result = result.replace(URL_PATTERN, (match) => scrubUrl(match)); + result = result.replace(PATH_WITH_QUERY, (match) => scrubUrl(match)); + } + + if (config.scanValuePatterns.length || config.extraValuePatterns.length) { + for (const name of VALUE_PATTERN_ORDER) { + if (!config.scanValuePatterns.includes(name)) { + continue; + } + result = + name === 'creditCard' + ? scrubCreditCards(result) + : result.replace(VALUE_PATTERNS[name], CPS_REDACTED); + } + for (const pattern of config.extraValuePatterns) { + result = result.replace(ensureGlobal(pattern), CPS_REDACTED); + } + } + + for (const transform of config.extraValueTransforms) { + try { + result = transform(result); + } catch (error) { + if (cpsIsDevMode()) { + // eslint-disable-next-line no-console + console.warn( + '[cps-telemetry] An extraValueTransforms function threw and was skipped', + error + ); + } + } + } + + if (result.length > config.maxStringLength) { + result = `${result.slice(0, config.maxStringLength)}…`; + } + + return result; +} + +/** + * Produces a telemetry-safe attribute bag: flat primitives only (enforcing + * at runtime what {@link CpsTelemetryMetadata} states at compile time), + * denylisted keys redacted, strings scrubbed via {@link cpsScrubString}, + * non-finite numbers and objects/functions/symbols dropped, and the + * attribute count capped (warns once via `console.warn` in dev mode). + * + * @param value the candidate attributes + * @param config redaction settings + * @returns a safe attribute bag, or `undefined` when nothing survives + * + * @group Utils + */ +export function cpsRedactMetadata( + value: unknown, + config: CpsRedactConfig = CPS_DEFAULT_REDACT_CONFIG +): CpsTelemetryMetadata | undefined { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const result: CpsTelemetryMetadata = {}; + let kept = 0; + let truncated = false; + + for (const [key, raw] of Object.entries(value)) { + if (kept >= config.maxKeys) { + truncated = true; + break; + } + + if (isDenied(key, config)) { + result[key] = CPS_REDACTED; + kept++; + continue; + } + + if (raw === null || typeof raw === 'boolean') { + result[key] = raw; + kept++; + } else if (typeof raw === 'number') { + if (Number.isFinite(raw)) { + result[key] = raw; + kept++; + } + } else if (typeof raw === 'string') { + result[key] = cpsScrubString(raw, config); + kept++; + } + } + + if (truncated && cpsIsDevMode()) { + // eslint-disable-next-line no-console + console.warn( + `[cps-telemetry] Metadata exceeded maxKeys (${config.maxKeys}) and was truncated. Attributes past the limit were dropped silently in production.` + ); + } + + return kept > 0 ? result : undefined; +} + +/** + * Converts a thrown value into a bounded, telemetry-safe shape — only the + * constructor name, scrubbed message and (optionally) a capped stack; never + * the raw error object. + * + * @param error any thrown value, including non-`Error` values + * @param config redaction settings + * @returns the normalized error, or `undefined` when there is nothing to report + * + * @group Utils + */ +export function cpsNormalizeError( + error: unknown, + config: CpsRedactConfig = CPS_DEFAULT_REDACT_CONFIG +): CpsTelemetryError | undefined { + if (error === null || error === undefined) { + return undefined; + } + + if (error instanceof Error) { + const normalized: CpsTelemetryError = { + name: error.name || 'Error', + message: cpsScrubString(error.message ?? '', config) + }; + + if (config.includeStack && typeof error.stack === 'string') { + normalized.stack = cpsScrubString(error.stack, { + ...config, + maxStringLength: config.maxStackLength + }); + } + + return normalized; + } + + if (typeof error === 'string') { + return { name: 'Error', message: cpsScrubString(error, config) }; + } + + if (isHttpErrorShaped(error)) { + // HttpErrorResponse doesn't extend Error, so instanceof above misses it. + return { + name: typeof error.name === 'string' ? error.name : 'HttpErrorResponse', + message: cpsScrubString(error.message, config) + }; + } + + return { + name: typeof error === 'object' ? 'UnknownError' : typeof error, + message: CPS_REDACTED + }; +} + +/** Duck-types an `HttpErrorResponse`-shaped object, avoiding an `@angular/common/http` import (and its runtime) just for this check. */ +function isHttpErrorShaped( + error: unknown +): error is { name?: unknown; message: string; status: number } { + if (error === null || typeof error !== 'object') { + return false; + } + const candidate = error as Record; + return ( + typeof candidate.message === 'string' && + typeof candidate.status === 'number' && + ('statusText' in candidate || 'url' in candidate) + ); +} diff --git a/projects/cps-telemetry/src/lib/utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util.spec.ts b/projects/cps-telemetry/src/lib/utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util.spec.ts new file mode 100644 index 000000000..544196a82 --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util.spec.ts @@ -0,0 +1,351 @@ +import { isDevMode } from '@angular/core'; +import { + cpsDeepClone, + cpsEpochToPerf, + cpsNow, + cpsSafe, + cpsSafeVoid, + cpsSafeVoidMaybeAsync, + cpsUuid +} from './cps-telemetry-safe-internal.util'; + +jest.mock('@angular/core', () => ({ + ...jest.requireActual('@angular/core'), + isDevMode: jest.fn(() => true) +})); + +const isDevModeMock = isDevMode as jest.Mock; + +describe('cpsSafe', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + isDevModeMock.mockReturnValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return the result when the operation succeeds', () => { + expect(cpsSafe('op', () => 42, 0)).toBe(42); + }); + + it('should return the fallback instead of throwing', () => { + const result = cpsSafe( + 'op', + () => { + throw new Error('boom'); + }, + 'fallback' + ); + expect(result).toBe('fallback'); + }); + + it('should never let a telemetry failure reach the caller', () => { + expect(() => + cpsSafeVoid('op', () => { + throw new Error('boom'); + }) + ).not.toThrow(); + }); + + it('should report the suppressed error in development mode', () => { + isDevModeMock.mockReturnValue(true); + cpsSafeVoid('scenario.step', () => { + throw new Error('boom'); + }); + expect(consoleError).toHaveBeenCalledWith( + '[cps-telemetry] scenario.step failed', + expect.any(Error) + ); + }); + + it('should stay silent in production mode', () => { + isDevModeMock.mockReturnValue(false); + cpsSafeVoid('scenario.step', () => { + throw new Error('boom'); + }); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('should not rethrow even when the dev-mode report itself throws', () => { + isDevModeMock.mockImplementation(() => { + throw new Error('outside injection context'); + }); + expect(() => + cpsSafeVoid('op', () => { + throw new Error('boom'); + }) + ).not.toThrow(); + }); + + it('should not rethrow when console.error itself throws', () => { + consoleError.mockImplementation(() => { + throw new Error('console is patched and broken'); + }); + expect(() => + cpsSafeVoid('op', () => { + throw new Error('boom'); + }) + ).not.toThrow(); + }); +}); + +describe('cpsSafeVoidMaybeAsync', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + isDevModeMock.mockReturnValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should behave exactly like cpsSafeVoid for a synchronous function', () => { + expect(() => + cpsSafeVoidMaybeAsync('op', () => { + throw new Error('boom'); + }) + ).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + '[cps-telemetry] op failed', + expect.any(Error) + ); + }); + + it('should not throw for a function that returns void', () => { + expect(() => cpsSafeVoidMaybeAsync('op', () => undefined)).not.toThrow(); + }); + + it('should report a rejection from an async function typed as void', async () => { + const asyncFn = (async () => { + throw new Error('async boom'); + }) as () => void; + + cpsSafeVoidMaybeAsync('logApi.deliver', asyncFn); + + await Promise.resolve(); + await Promise.resolve(); + + expect(consoleError).toHaveBeenCalledWith( + '[cps-telemetry] logApi.deliver failed', + expect.any(Error) + ); + }); + + it('should not produce an unhandled rejection for a rejecting async function', async () => { + const asyncFn = (async () => { + throw new Error('unhandled if unguarded'); + }) as () => void; + + expect(() => cpsSafeVoidMaybeAsync('op', asyncFn)).not.toThrow(); + await Promise.resolve(); + await Promise.resolve(); + }); + + it('should stay silent in production mode for an async rejection', async () => { + isDevModeMock.mockReturnValue(false); + const asyncFn = (async () => { + throw new Error('async boom'); + }) as () => void; + + cpsSafeVoidMaybeAsync('op', asyncFn); + await Promise.resolve(); + await Promise.resolve(); + + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('should not produce a new unhandled rejection when console.error itself throws', async () => { + const nodeProcess = ( + globalThis as unknown as { + process: { + on( + event: 'unhandledRejection', + listener: (reason: unknown) => void + ): void; + off( + event: 'unhandledRejection', + listener: (reason: unknown) => void + ): void; + }; + } + ).process; + + const unhandled = jest.fn(); + nodeProcess.on('unhandledRejection', unhandled); + consoleError.mockImplementation(() => { + throw new Error('console is patched and broken'); + }); + const asyncFn = (async () => { + throw new Error('async boom'); + }) as () => void; + + cpsSafeVoidMaybeAsync('op', asyncFn); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + nodeProcess.off('unhandledRejection', unhandled); + expect(unhandled).not.toHaveBeenCalled(); + }); +}); + +describe('cpsDeepClone', () => { + it('should produce a deep copy that mutation of the original does not affect', () => { + const original = { a: 1, nested: { b: [1, 2, 3] } }; + const clone = cpsDeepClone(original); + + original.nested.b.push(4); + + expect(clone).toEqual({ a: 1, nested: { b: [1, 2, 3] } }); + expect(clone).not.toBe(original); + expect(clone.nested).not.toBe(original.nested); + }); + + it('should fall back to a JSON round-trip when structuredClone is unavailable', () => { + const realStructuredClone = globalThis.structuredClone; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).structuredClone = undefined; + + try { + expect(cpsDeepClone({ a: 1, nested: { b: 2 } })).toEqual({ + a: 1, + nested: { b: 2 } + }); + } finally { + globalThis.structuredClone = realStructuredClone; + } + }); + + it('should fall back to a JSON round-trip when structuredClone throws', () => { + const realStructuredClone = globalThis.structuredClone; + globalThis.structuredClone = () => { + throw new Error('cannot clone'); + }; + + try { + expect(cpsDeepClone({ a: 1 })).toEqual({ a: 1 }); + } finally { + globalThis.structuredClone = realStructuredClone; + } + }); +}); + +describe('cpsUuid', () => { + const realCrypto = globalThis.crypto; + + afterEach(() => { + Object.defineProperty(globalThis, 'crypto', { + value: realCrypto, + configurable: true + }); + }); + + const useCrypto = (value: unknown) => + Object.defineProperty(globalThis, 'crypto', { + value, + configurable: true + }); + + it('should produce a UUID-shaped identifier', () => { + expect(cpsUuid()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + }); + + it('should produce distinct identifiers', () => { + const ids = new Set(Array.from({ length: 200 }, () => cpsUuid())); + expect(ids.size).toBe(200); + }); + + it('should fall back to getRandomValues when randomUUID is unavailable', () => { + useCrypto({ + getRandomValues: (array: Uint8Array) => { + for (let i = 0; i < array.length; i++) { + array[i] = i; + } + return array; + } + }); + + expect(cpsUuid()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); + + it('should fall back to Math.random when the Web Crypto API is absent', () => { + useCrypto(undefined); + + expect(cpsUuid()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); +}); + +describe('cpsNow', () => { + it('should return a number that does not go backwards', () => { + const first = cpsNow(); + const second = cpsNow(); + expect(typeof first).toBe('number'); + expect(second).toBeGreaterThanOrEqual(first); + }); +}); + +describe('cpsEpochToPerf', () => { + const realPerformance = globalThis.performance; + + afterEach(() => { + Object.defineProperty(globalThis, 'performance', { + value: realPerformance, + configurable: true + }); + }); + + function usePerformance(timeOrigin: number, now: number) { + Object.defineProperty(globalThis, 'performance', { + value: { timeOrigin, now: () => now }, + configurable: true + }); + } + + it('should convert an epoch timestamp into the performance timeline', () => { + usePerformance(1_000_000, 5_000); + expect(cpsEpochToPerf(1_002_000)).toBe(2_000); + }); + + it('should accept the exact page origin', () => { + usePerformance(1_000_000, 5_000); + expect(cpsEpochToPerf(1_000_000)).toBe(0); + }); + + it('should reject a timestamp from before the page loaded', () => { + usePerformance(1_000_000, 5_000); + expect(cpsEpochToPerf(999_000)).toBeUndefined(); + }); + + it('should reject a timestamp in the future', () => { + usePerformance(1_000_000, 5_000); + expect(cpsEpochToPerf(1_010_000)).toBeUndefined(); + }); + + it.each([[NaN], [Infinity], [-Infinity]])( + 'should reject the non-finite input %p', + (input) => { + usePerformance(1_000_000, 5_000); + expect(cpsEpochToPerf(input)).toBeUndefined(); + } + ); + + it('should return undefined without a usable performance object', () => { + Object.defineProperty(globalThis, 'performance', { + value: undefined, + configurable: true + }); + expect(cpsEpochToPerf(1_000)).toBeUndefined(); + }); +}); diff --git a/projects/cps-telemetry/src/lib/utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util.ts b/projects/cps-telemetry/src/lib/utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util.ts new file mode 100644 index 000000000..3a150ba91 --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-telemetry-safe-internal.util/cps-telemetry-safe-internal.util.ts @@ -0,0 +1,184 @@ +import { isPlatformBrowser } from '@angular/common'; +import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; + +/** + * Runs a telemetry operation without letting it affect the application. + * + * Every public entry point in this library routes through this wrapper — + * it never rethrows. In development the caught error is reported to the + * console; in production nothing is printed. + */ +export function cpsSafe(operation: string, fn: () => T, fallback: T): T { + try { + return fn(); + } catch (error) { + reportSuppressed(operation, error); + return fallback; + } +} + +/** + * Void-returning form of {@link cpsSafe}, for the common case of an emit call + * whose result nobody reads. + */ +export function cpsSafeVoid(operation: string, fn: () => void): void { + cpsSafe(operation, fn, undefined); +} + +/** + * Like {@link cpsSafeVoid}, but also catches a rejection from a `void`-typed + * extension point whose real implementation turns out to be `async` — an + * application-supplied provider hook, for instance. TypeScript accepts an + * `async` function wherever `() => void` is expected and discards the + * promise, so a plain `try`/`catch` can't see it reject. This checks for a + * thenable result and attaches a rejection handler. + * + * Not the default for every call: most `cpsSafeVoid` calls wrap this + * library's own synchronous code, where the extra check is pure overhead. + * Use this only for a function whose shape this library merely declares. + */ +export function cpsSafeVoidMaybeAsync(operation: string, fn: () => void): void { + cpsSafe( + operation, + () => { + const result: unknown = fn(); + if ( + result !== null && + typeof result === 'object' && + typeof (result as { then?: unknown }).then === 'function' + ) { + Promise.resolve(result as PromiseLike).catch((error) => + reportSuppressed(operation, error) + ); + } + }, + undefined + ); +} + +/** `isDevMode()` throws outside an Angular context, so this guards that once for every caller. */ +export function cpsIsDevMode(): boolean { + try { + return isDevMode(); + } catch { + return false; + } +} + +/** + * Whether this code is running in a browser, as opposed to a server-side + * render. Must be called from an injection context, same as + * `inject(PLATFORM_ID)` itself requires. + */ +export function cpsIsBrowser(): boolean { + return isPlatformBrowser(inject(PLATFORM_ID)); +} + +/** + * Deep-clones a plain-data value. Prefers `structuredClone`; falls back to + * a JSON round-trip where it's unavailable (e.g. under jsdom) or throws. + */ +export function cpsDeepClone(value: T): T { + if (typeof structuredClone === 'function') { + try { + return structuredClone(value); + } catch { + // fall through to the JSON fallback + } + } + return JSON.parse(JSON.stringify(value)) as T; +} + +function reportSuppressed(operation: string, error: unknown): void { + if (!cpsIsDevMode()) { + return; + } + try { + // eslint-disable-next-line no-console + console.error(`[cps-telemetry] ${operation} failed`, error); + } catch { + // A patched/throwing console must never escape telemetry suppression. + } +} + +/** + * Fills a byte array from the best available source. `crypto.getRandomValues` + * works on plain http:// too, where `crypto.randomUUID` doesn't. `Math.random` + * is the last-resort fallback for an environment with no Web Crypto at all. + */ +function randomBytes(count: number): Uint8Array { + const bytes = new Uint8Array(count); + const cryptoRef = globalThis.crypto; + + if (typeof cryptoRef?.getRandomValues === 'function') { + return cryptoRef.getRandomValues(bytes); + } + + for (let i = 0; i < count; i++) { + bytes[i] = (Math.random() * 256) | 0; + } + return bytes; +} + +/** Lowercase hex encoding, two characters per byte. */ +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Generates a correlation identifier. Prefers `crypto.randomUUID`, falling + * back to formatting random bytes as a v4 UUID. + */ +export function cpsUuid(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + const bytes = randomBytes(16); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + + const hex = toHex(bytes); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20) + ].join('-'); +} + +/** + * Monotonic-ish millisecond clock for duration measurement. + * `performance.now()` is preferred since it's immune to wall-clock + * adjustments. + */ +export function cpsNow(): number { + const perf = globalThis.performance; + return typeof perf?.now === 'function' ? perf.now() : Date.now(); +} + +/** + * Converts an epoch timestamp into the `performance.now()` timeline. + * Clamped to the current page's lifetime — a future timestamp, or one from + * before the page loaded, yields `undefined`. + */ +export function cpsEpochToPerf(epochMs: number): number | undefined { + if (!Number.isFinite(epochMs)) { + return undefined; + } + + const perf = globalThis.performance; + if (typeof perf?.now !== 'function' || typeof perf.timeOrigin !== 'number') { + return undefined; + } + + const converted = epochMs - perf.timeOrigin; + const now = perf.now(); + + if (converted < 0 || converted > now) { + return undefined; + } + + return converted; +} diff --git a/projects/cps-telemetry/src/lib/utils/cps-user-timings-internal.util/cps-user-timings-internal.util.spec.ts b/projects/cps-telemetry/src/lib/utils/cps-user-timings-internal.util/cps-user-timings-internal.util.spec.ts new file mode 100644 index 000000000..083e5638a --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-user-timings-internal.util/cps-user-timings-internal.util.spec.ts @@ -0,0 +1,161 @@ +import { + cpsClearMarks, + cpsMark, + cpsMarkName, + cpsMeasure +} from './cps-user-timings-internal.util'; + +/** + * jsdom implements `performance.now` and `timeOrigin` but neither `mark` nor + * `measure`, so the API has to be installed to test the supported path — and + * the unsupported path is simply the default. + */ +function installUserTimingApi(overrides: Record = {}) { + const api = { + mark: jest.fn(), + measure: jest.fn(), + clearMarks: jest.fn(), + clearMeasures: jest.fn(), + getEntriesByName: jest.fn(() => []), + now: () => 0, + timeOrigin: 0, + ...overrides + }; + + Object.defineProperty(globalThis, 'performance', { + value: api, + configurable: true + }); + + return api; +} + +describe('cps-user-timings', () => { + const realPerformance = globalThis.performance; + + afterEach(() => { + Object.defineProperty(globalThis, 'performance', { + value: realPerformance, + configurable: true + }); + jest.restoreAllMocks(); + }); + + describe('cpsMarkName', () => { + it('should namespace the entry and include the scenario id', () => { + expect(cpsMarkName('shop', 'load-data', 'abc-123', 'fetch')).toBe( + 'shop:load-data:fetch:abc-123' + ); + }); + + it('should namespace by application, not by this library', () => { + expect(cpsMarkName('checkout', 'load', 'id-1', 'start')).toBe( + 'checkout:load:start:id-1' + ); + }); + + it('should keep two applications apart on one composed page', () => { + expect(cpsMarkName('shell', 'load', 'id-1', 'start')).not.toBe( + cpsMarkName('cart', 'load', 'id-1', 'start') + ); + }); + + it('should distinguish concurrent scenarios of the same name', () => { + expect(cpsMarkName('shop', 'load', 'id-1', 'start')).not.toBe( + cpsMarkName('shop', 'load', 'id-2', 'start') + ); + }); + }); + + describe('when the browser has no User Timing API', () => { + it('should no-op rather than throw', () => { + expect(() => cpsMark('m')).not.toThrow(); + expect(() => cpsMeasure('name', 'a', 'b')).not.toThrow(); + expect(() => cpsClearMarks(['a', 'b'])).not.toThrow(); + }); + + it('should touch nothing when the API is only partly present', () => { + const api = installUserTimingApi({ measure: undefined }); + + expect(() => cpsMark('m')).not.toThrow(); + expect(api.mark).not.toHaveBeenCalled(); + }); + }); + + describe('when the browser supports User Timing', () => { + it('should record a mark', () => { + const api = installUserTimingApi(); + cpsMark('cps:load:start:id'); + expect(api.mark).toHaveBeenCalledWith('cps:load:start:id'); + }); + + it('should record a measure between two marks', () => { + const api = installUserTimingApi(); + cpsMeasure('load [fetch]', 'start-mark', 'end-mark'); + expect(api.measure).toHaveBeenCalledWith( + 'load [fetch]', + 'start-mark', + 'end-mark' + ); + }); + + it('should drop the measure entry once created, to bound the buffer', () => { + const api = installUserTimingApi(); + cpsMeasure('load', 'a', 'b'); + expect(api.clearMeasures).toHaveBeenCalledWith('load'); + }); + + it('should swallow a measure against a missing mark', () => { + const api = installUserTimingApi({ + measure: jest.fn(() => { + throw new SyntaxError("mark 'a' does not exist"); + }) + }); + + expect(() => cpsMeasure('load', 'a', 'b')).not.toThrow(); + expect(api.clearMeasures).not.toHaveBeenCalled(); + }); + + it('should swallow a throwing mark', () => { + installUserTimingApi({ + mark: jest.fn(() => { + throw new Error('quota exceeded'); + }) + }); + expect(() => cpsMark('m')).not.toThrow(); + }); + + it('should clear every named mark', () => { + const api = installUserTimingApi(); + cpsClearMarks(['a', 'b', 'c']); + + expect(api.clearMarks).toHaveBeenCalledTimes(3); + expect(api.clearMarks).toHaveBeenCalledWith('a'); + expect(api.clearMarks).toHaveBeenCalledWith('c'); + }); + + it('should keep clearing after one mark throws', () => { + const api = installUserTimingApi({ + clearMarks: jest.fn((name: string) => { + if (name === 'a') { + throw new Error('gone'); + } + }) + }); + + expect(() => cpsClearMarks(['a', 'b'])).not.toThrow(); + expect(api.clearMarks).toHaveBeenCalledTimes(2); + }); + }); + + describe('resolving the host performance object (see DESIGN.md §13)', () => { + // jsdom's `globalThis.top` is a non-configurable accessor always equal + // to `globalThis` itself, so a divergent `top` (a real fragment under a + // different frame) can't be constructed here — every mark/measure/clear + // test above already exercises the `top.performance` branch, just with + // `top` equal to `globalThis`. + it('should read the same object jsdom exposes as globalThis.top', () => { + expect(globalThis.top).toBe(globalThis); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/utils/cps-user-timings-internal.util/cps-user-timings-internal.util.ts b/projects/cps-telemetry/src/lib/utils/cps-user-timings-internal.util/cps-user-timings-internal.util.ts new file mode 100644 index 000000000..08bd78e10 --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-user-timings-internal.util/cps-user-timings-internal.util.ts @@ -0,0 +1,123 @@ +/** + * User Timing helpers, so scenarios show up in the browser's own performance + * tooling, lined up against paint, layout, script and network on the + * DevTools **Performance → Timings** track. + * + * Every function here feature-detects before touching the API — jsdom + * implements `performance.now` but neither `mark` nor `measure`, and older + * Safari lacks `measure` overloads too. + */ + +interface UserTimingApi { + mark(name: string): void; + measure(name: string, start: string, end: string): void; + clearMarks(name?: string): void; + clearMeasures(name?: string): void; +} + +/** Returns the User Timing API when the browser provides a usable one. */ +function api(): UserTimingApi | undefined { + const perf = hostPerformance(); + + if ( + typeof perf?.mark !== 'function' || + typeof perf.measure !== 'function' || + typeof perf.clearMarks !== 'function' || + typeof perf.clearMeasures !== 'function' + ) { + return undefined; + } + + return perf as UserTimingApi; +} + +/** + * The `performance` object marks and measures actually get written to. + * + * Prefers `top.performance` over the realm's own: in a composed page, a + * fragment runs in its own same-origin iframe, and writing to its own + * `performance` puts every entry on a timeline DevTools never has open. + * Reading `top` always succeeds, even cross-origin; only touching a + * property on it can throw, in which case this falls back to the realm's + * own `performance`. + */ +function hostPerformance(): Partial | undefined { + try { + const top = globalThis.top; + if (top?.performance) { + return top.performance as Partial; + } + } catch { + // cross-origin, or sandboxed without `allow-same-origin` + } + return globalThis.performance as Partial | undefined; +} + +/** + * Builds the mark name for one scenario boundary. Includes the scenario id + * so concurrent scenarios of the same name don't measure against each + * other's marks, and the application's name so entries from different + * fragments in a composed page stay distinguishable. + */ +export function cpsMarkName( + application: string, + scenarioName: string, + scenarioId: string, + step: string +): string { + return `${application}:${scenarioName}:${step}:${scenarioId}`; +} + +/** Records a mark, if the browser supports User Timing. */ +export function cpsMark(name: string): void { + try { + api()?.mark(name); + } catch { + // never let instrumentation affect the caller + } +} + +/** + * Records a measure between two marks and then drops the entry. `measure()` + * emits its trace event at call time, so removing the entry afterwards + * keeps the DevTools timeline intact while stopping the entry buffer from + * growing without bound. + * + * Silently does nothing when either mark is missing. + */ +export function cpsMeasure( + name: string, + startMark: string, + endMark: string +): void { + const perf = api(); + if (!perf) { + return; + } + + try { + perf.measure(name, startMark, endMark); + perf.clearMeasures(name); + } catch { + // a referenced mark doesn't exist + } +} + +/** + * Removes the given marks from the entry buffer. Called once a scenario + * settles, so marks don't accumulate for the life of the page. + */ +export function cpsClearMarks(names: readonly string[]): void { + const perf = api(); + if (!perf) { + return; + } + + for (const name of names) { + try { + perf.clearMarks(name); + } catch { + // entry already gone + } + } +} diff --git a/projects/cps-telemetry/src/public-api.ts b/projects/cps-telemetry/src/public-api.ts new file mode 100644 index 000000000..9f70b804c --- /dev/null +++ b/projects/cps-telemetry/src/public-api.ts @@ -0,0 +1,120 @@ +/* + * Public API Surface of cps-telemetry + * + * Covers two audiences: an application using telemetry, and an author + * writing a custom sink or log transport. + */ + +// Configuration +export { + CPS_DEFAULT_TELEMETRY_CONFIG, + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY, + CpsTelemetryIdentity +} from './lib/config/cps-telemetry-common.config/cps-telemetry-common.config'; +export { + CPS_BI_CONFIG, + CpsBiConfig +} from './lib/config/cps-bi.config/cps-bi.config'; +export { + CPS_LOG_CONFIG, + CpsLogConfig +} from './lib/config/cps-log.config/cps-log.config'; +export { + CPS_SCENARIO_CONFIG, + CpsScenarioConfig +} from './lib/config/cps-scenario.config/cps-scenario.config'; + +// Providers +export { + CpsTelemetryFeature, + CpsTelemetryLocalSinkMode, + provideCpsTelemetry, + provideCpsTelemetryBroadcastHost, + provideCpsTelemetrySink, + withBiEvents, + withLogging, + withRedaction, + withScenarios +} from './lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers'; +export { + CPS_LOG_API_PROVIDER, + CpsLogApiProvider, + CpsLogQuery +} from './lib/providers/cps-log-api.provider/cps-log-api.provider'; + +// Models +export { + CpsBiEvent, + CpsBiEventDetail +} from './lib/models/cps-bi.models/cps-bi.models'; +export { + CPS_LOG_LEVEL_ORDER, + CpsLoggerName, + CpsLoggerNames, + CpsLogDetail, + CpsLogLevel, + CpsLogRecord +} from './lib/models/cps-log.models/cps-log.models'; +export { + CpsScenarioAggregate, + CpsScenarioName, + CpsScenarioNames, + CpsScenarioOptions, + CpsScenarioOutcome, + CpsScenarioRecord, + CpsScenarioStatus, + CpsScenarioStep, + CpsScenarioStepDetail, + CpsScenarioStepStatus, + CpsScenarioSteps, + CpsStepName +} from './lib/models/cps-scenario.models/cps-scenario.models'; +export { + CPS_DEFAULT_EVENT_NAMESPACE, + CPS_TELEMETRY_EVENT_TYPE, + cpsEventTypes, + CpsTelemetryError, + CpsTelemetryEventTypes, + CpsTelemetryMetadata +} from './lib/models/cps-telemetry-common.models/cps-telemetry-common.models'; + +// Services +export { CpsBiTelemetryService } from './lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service'; +export { + CpsLogBindings, + CpsLogger, + CpsLoggerService +} from './lib/services/cps-logger.service/cps-logger.service'; +export { CpsScenario } from './lib/scenario/cps-scenario/cps-scenario'; +export { CpsScenarioTelemetryService } from './lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service'; + +// Sinks +export { CpsTelemetryBroadcastHost } from './lib/sinks/cps-broadcast/cps-broadcast-host.service'; +export { CpsBroadcastTelemetrySink } from './lib/sinks/cps-broadcast/cps-broadcast-telemetry.sink'; +export { + CPS_BROADCAST_CHANNEL, + CPS_DEFAULT_BROADCAST_CHANNEL +} from './lib/sinks/cps-broadcast/cps-broadcast.messages'; +export { CpsTelemetrySink } from './lib/sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +export { CpsNoopTelemetrySink } from './lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink'; + +// Utilities an application or a custom sink needs +export { + CpsTraceScenarioOptions, + traceScenario +} from './lib/scenario/cps-scenario-operators/cps-scenario-operators'; +export { + CpsDebugFlag, + cpsIsDebugEnabled +} from './lib/utils/cps-debug-flag.util/cps-debug-flag.util'; +export { + CPS_DEFAULT_REDACT_CONFIG, + CPS_REDACTED, + CpsPiiValuePattern, + CpsRedactConfig, + cpsNormalizeError, + cpsRedactConfigFor, + cpsRedactMetadata, + cpsScrubString +} from './lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; diff --git a/projects/cps-telemetry/telemetry-trace.txt b/projects/cps-telemetry/telemetry-trace.txt new file mode 100644 index 000000000..9c1ccdbdc --- /dev/null +++ b/projects/cps-telemetry/telemetry-trace.txt @@ -0,0 +1,1328 @@ +---------------------------------------------------------------------- +[1/27] SCENARIO route-navigation status=success delta=100ms +trigger: boot + navigate /colors -> /autocomplete + +{ + "scenarioId": "5e45c8e3-9a3b-438b-b1b5-018f31dfeb9d", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/colors?experimental=true", + "status": "success", + "startTime": "2026-08-27T19:43:20.590Z", + "endTime": "2026-08-27T19:43:20.690Z", + "delta": 100, + "elapsed": 725, + "stepCount": 2, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 623, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 100, + "stepDelta": 99, + "elapsed": 723, + "status": "success" + }, + { + "name": "activate", + "startOffset": 100, + "endOffset": 100, + "stepDelta": 0, + "elapsed": 724, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 100, + "endOffset": 100, + "stepDelta": 0, + "elapsed": 724, + "status": "success" + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/colors" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[2/27] SCENARIO route-navigation status=success delta=83ms +trigger: boot + navigate /colors -> /autocomplete + +{ + "scenarioId": "6fdbc6a5-42f7-4ab5-b8f8-bd02f181c40f", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/autocomplete?experimental=true", + "status": "success", + "startTime": "2026-08-27T19:43:21.118Z", + "endTime": "2026-08-27T19:43:21.201Z", + "delta": 83, + "elapsed": 327, + "stepCount": 2, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 243, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 83, + "stepDelta": 82, + "elapsed": 326, + "status": "success" + }, + { + "name": "activate", + "startOffset": 83, + "endOffset": 83, + "stepDelta": 0, + "elapsed": 326, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 83, + "endOffset": 83, + "stepDelta": 0, + "elapsed": 327, + "status": "success" + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/autocomplete" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[3/27] SCENARIO route-navigation status=success delta=92ms +trigger: boot + navigate /colors -> /autocomplete + +{ + "scenarioId": "978cc878-493a-409e-82ca-f9b0c96a0342", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/autocomplete/examples", + "status": "success", + "startTime": "2026-08-27T19:43:21.204Z", + "endTime": "2026-08-27T19:43:21.296Z", + "delta": 92, + "elapsed": 422, + "stepCount": 2, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 329, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 92, + "stepDelta": 91, + "elapsed": 421, + "status": "success" + }, + { + "name": "activate", + "startOffset": 92, + "endOffset": 92, + "stepDelta": 0, + "elapsed": 422, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 92, + "endOffset": 92, + "stepDelta": 0, + "elapsed": 422, + "status": "success" + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/autocomplete/examples" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[4/27] BI sidebar_toggled +trigger: sidebar toggle + theme toggle + +{ + "eventName": "sidebar_toggled", + "eventTime": "2026-08-27T19:43:21.389Z", + "metadata": { + "expanded": false + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[5/27] BI theme_changed +trigger: sidebar toggle + theme toggle + +{ + "eventName": "theme_changed", + "eventTime": "2026-08-27T19:43:21.751Z", + "metadata": { + "theme": "dark" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[6/27] BI sidebar_searched +trigger: sidebar search (debounced) + +{ + "eventName": "sidebar_searched", + "eventTime": "2026-08-27T19:43:22.334Z", + "metadata": { + "resultCount": 3 + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[7/27] SCENARIO autocomplete-search status=abandoned delta=761ms +trigger: autocomplete search: superseded query, then a settled one + +{ + "scenarioId": "74d872a5-8c4f-4e67-bdce-cec3ec7de0d9", + "scenarioName": "autocomplete-search", + "feature": "autocomplete", + "operation": "single", + "status": "abandoned", + "startTime": "2026-08-27T19:43:23.313Z", + "endTime": "2026-08-27T19:43:24.074Z", + "delta": 761, + "elapsed": 3199, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2438, + "status": "success" + }, + { + "name": "query", + "startOffset": 2, + "endOffset": 761, + "stepDelta": 759, + "elapsed": 3199, + "status": "abandoned" + }, + { + "name": "scenario-end", + "startOffset": 761, + "endOffset": 761, + "stepDelta": 0, + "elapsed": 3199, + "status": "abandoned" + } + ], + "previousStep": "query", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "message": "superseded" +} + +---------------------------------------------------------------------- +[8/27] SCENARIO autocomplete-search status=success delta=1002ms +trigger: autocomplete search: superseded query, then a settled one + +{ + "scenarioId": "67586749-9c69-40e2-b097-d5218065431e", + "scenarioName": "autocomplete-search", + "feature": "autocomplete", + "operation": "single", + "status": "success", + "startTime": "2026-08-27T19:43:24.075Z", + "endTime": "2026-08-27T19:43:25.077Z", + "delta": 1002, + "elapsed": 4201, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 3200, + "status": "success" + }, + { + "name": "query", + "startOffset": 0, + "endOffset": 1002, + "stepDelta": 1002, + "elapsed": 4201, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 1002, + "endOffset": 1002, + "stepDelta": 0, + "elapsed": 4201, + "status": "success" + } + ], + "previousStep": "query", + "metadata": { + "resultCount": 1 + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[9/27] SCENARIO autocomplete-validate status=success delta=3002ms +trigger: autocomplete validate: success + +{ + "scenarioId": "5718c228-2359-4bda-9f94-0b243e49fc89", + "scenarioName": "autocomplete-validate", + "feature": "autocomplete", + "status": "success", + "startTime": "2026-08-27T19:43:26.420Z", + "endTime": "2026-08-27T19:43:29.422Z", + "delta": 3002, + "elapsed": 8547, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 5545, + "status": "success" + }, + { + "name": "validate", + "startOffset": 1, + "endOffset": 3002, + "stepDelta": 3001, + "elapsed": 8547, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 3002, + "endOffset": 3002, + "stepDelta": 0, + "elapsed": 8547, + "status": "success" + } + ], + "previousStep": "validate", + "application": "composition" +} + +---------------------------------------------------------------------- +[10/27] SCENARIO route-navigation status=success delta=37ms +trigger: file upload: success path (3s processing) + +{ + "scenarioId": "24c11afa-c30a-41cf-9fb2-ed40bc199d26", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/file-upload?experimental=true", + "status": "success", + "startTime": "2026-08-27T19:43:30.250Z", + "endTime": "2026-08-27T19:43:30.287Z", + "delta": 37, + "elapsed": 341, + "stepCount": 2, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 304, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 37, + "stepDelta": 36, + "elapsed": 340, + "status": "success" + }, + { + "name": "activate", + "startOffset": 37, + "endOffset": 37, + "stepDelta": 0, + "elapsed": 341, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 37, + "endOffset": 37, + "stepDelta": 0, + "elapsed": 341, + "status": "success" + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/file-upload" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[11/27] SCENARIO route-navigation status=success delta=37ms +trigger: file upload: success path (3s processing) + +{ + "scenarioId": "608b98b9-542e-4b7a-9213-b7d6f822a191", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/file-upload/examples", + "status": "success", + "startTime": "2026-08-27T19:43:30.290Z", + "endTime": "2026-08-27T19:43:30.327Z", + "delta": 37, + "elapsed": 380, + "stepCount": 2, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 343, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 36, + "stepDelta": 35, + "elapsed": 379, + "status": "success" + }, + { + "name": "activate", + "startOffset": 36, + "endOffset": 37, + "stepDelta": 1, + "elapsed": 380, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 37, + "endOffset": 37, + "stepDelta": 0, + "elapsed": 380, + "status": "success" + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/file-upload/examples" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[12/27] SCENARIO file-upload status=success delta=3011ms +trigger: file upload: success path (3s processing) + +{ + "scenarioId": "ce0bfb9f-15d6-4b25-a98e-886f78cb7c57", + "scenarioName": "file-upload", + "feature": "file-upload", + "operation": "process", + "status": "success", + "startTime": "2026-08-27T19:43:30.400Z", + "endTime": "2026-08-27T19:43:33.411Z", + "delta": 3011, + "elapsed": 3465, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 453, + "status": "success" + }, + { + "name": "process", + "startOffset": 0, + "endOffset": 3011, + "stepDelta": 3011, + "elapsed": 3464, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 3011, + "endOffset": 3011, + "stepDelta": 0, + "elapsed": 3465, + "status": "success" + } + ], + "previousStep": "process", + "application": "composition" +} + +---------------------------------------------------------------------- +[13/27] SCENARIO file-upload status=failure delta=503ms +trigger: file upload: failure path (always-false processing callback) + +{ + "scenarioId": "68e17550-4524-4b9b-a153-6261432de835", + "scenarioName": "file-upload", + "feature": "file-upload", + "operation": "process-failing", + "status": "failure", + "startTime": "2026-08-27T19:43:33.928Z", + "endTime": "2026-08-27T19:43:34.431Z", + "delta": 503, + "elapsed": 4484, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 3981, + "status": "success" + }, + { + "name": "process", + "startOffset": 1, + "endOffset": 503, + "stepDelta": 502, + "elapsed": 4484, + "status": "failure" + }, + { + "name": "scenario-end", + "startOffset": 503, + "endOffset": 503, + "stepDelta": 0, + "elapsed": 4484, + "status": "failure" + } + ], + "previousStep": "process", + "application": "composition" +} + +---------------------------------------------------------------------- +[14/27] SCENARIO file-upload status=abandoned delta=538ms +trigger: file upload: cancel path + +{ + "scenarioId": "ec62f773-502a-4939-b14f-53e81fbc7c2d", + "scenarioName": "file-upload", + "feature": "file-upload", + "operation": "process", + "status": "abandoned", + "startTime": "2026-08-27T19:43:34.992Z", + "endTime": "2026-08-27T19:43:35.530Z", + "delta": 538, + "elapsed": 5583, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 5045, + "status": "success" + }, + { + "name": "process", + "startOffset": 0, + "endOffset": 538, + "stepDelta": 538, + "elapsed": 5583, + "status": "abandoned" + }, + { + "name": "scenario-end", + "startOffset": 538, + "endOffset": 538, + "stepDelta": 0, + "elapsed": 5583, + "status": "abandoned" + } + ], + "previousStep": "process", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "message": "user-cancelled" +} + +---------------------------------------------------------------------- +[15/27] SCENARIO route-navigation status=success delta=67ms +trigger: table lazy-load + +{ + "scenarioId": "2c0ebd28-78ea-4084-855b-b142ccab0ddd", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/table?experimental=true", + "status": "success", + "startTime": "2026-08-27T19:43:36.099Z", + "endTime": "2026-08-27T19:43:36.166Z", + "delta": 67, + "elapsed": 324, + "stepCount": 2, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 257, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 67, + "stepDelta": 66, + "elapsed": 324, + "status": "success" + }, + { + "name": "activate", + "startOffset": 67, + "endOffset": 67, + "stepDelta": 0, + "elapsed": 324, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 67, + "endOffset": 67, + "stepDelta": 0, + "elapsed": 324, + "status": "success" + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/table" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[16/27] SCENARIO route-navigation status=success delta=430ms +trigger: table lazy-load + +{ + "scenarioId": "b115ec8e-595c-457c-ba59-aab7f62ec9bb", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/table/examples", + "status": "success", + "startTime": "2026-08-27T19:43:36.169Z", + "endTime": "2026-08-27T19:43:36.599Z", + "delta": 430, + "elapsed": 758, + "stepCount": 2, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 328, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 429, + "stepDelta": 428, + "elapsed": 757, + "status": "success" + }, + { + "name": "activate", + "startOffset": 429, + "endOffset": 430, + "stepDelta": 1, + "elapsed": 758, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 430, + "endOffset": 430, + "stepDelta": 0, + "elapsed": 758, + "status": "success" + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/table/examples" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[17/27] SCENARIO table-page-load status=success delta=660ms +trigger: table lazy-load + +{ + "scenarioId": "f253f737-96d4-4ca7-a7c1-b7dbeb4fd408", + "scenarioName": "table-page-load", + "feature": "table", + "status": "success", + "startTime": "2026-08-27T19:43:36.539Z", + "endTime": "2026-08-27T19:43:37.199Z", + "delta": 660, + "elapsed": 1358, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 698, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 1, + "endOffset": 660, + "stepDelta": 659, + "elapsed": 1358, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 660, + "endOffset": 660, + "stepDelta": 0, + "elapsed": 1358, + "status": "success" + } + ], + "previousStep": "fetch", + "metadata": { + "first": 0, + "rows": 10, + "totalRecords": 1001 + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[18/27] SCENARIO route-navigation status=success delta=38ms +trigger: code example: copy code + +{ + "scenarioId": "2fb9d69a-ead3-405a-867f-6a2f3e529e7e", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/autocomplete?experimental=true", + "status": "success", + "startTime": "2026-08-27T19:43:38.529Z", + "endTime": "2026-08-27T19:43:38.567Z", + "delta": 38, + "elapsed": 310, + "stepCount": 2, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 273, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 37, + "stepDelta": 36, + "elapsed": 310, + "status": "success" + }, + { + "name": "activate", + "startOffset": 37, + "endOffset": 38, + "stepDelta": 1, + "elapsed": 310, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 38, + "endOffset": 38, + "stepDelta": 0, + "elapsed": 310, + "status": "success" + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/autocomplete" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[19/27] SCENARIO route-navigation status=success delta=60ms +trigger: code example: copy code + +{ + "scenarioId": "95e5c444-b189-4cd4-b17e-795fda575675", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/autocomplete/examples", + "status": "success", + "startTime": "2026-08-27T19:43:38.569Z", + "endTime": "2026-08-27T19:43:38.629Z", + "delta": 60, + "elapsed": 372, + "stepCount": 2, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 312, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 59, + "stepDelta": 58, + "elapsed": 372, + "status": "success" + }, + { + "name": "activate", + "startOffset": 59, + "endOffset": 60, + "stepDelta": 1, + "elapsed": 372, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 60, + "endOffset": 60, + "stepDelta": 0, + "elapsed": 372, + "status": "success" + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/autocomplete/examples" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[20/27] SCENARIO route-navigation status=success delta=54ms +trigger: theme option changes (experimental) + +{ + "scenarioId": "2171dc2f-f74f-4472-954e-13515bd9bea8", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/colors?experimental=true", + "status": "success", + "startTime": "2026-08-27T19:43:38.989Z", + "endTime": "2026-08-27T19:43:39.043Z", + "delta": 54, + "elapsed": 154, + "stepCount": 2, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 99, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 54, + "stepDelta": 53, + "elapsed": 153, + "status": "success" + }, + { + "name": "activate", + "startOffset": 54, + "endOffset": 54, + "stepDelta": 0, + "elapsed": 154, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 54, + "endOffset": 54, + "stepDelta": 0, + "elapsed": 154, + "status": "success" + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/colors" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[21/27] BI theme_option_changed +trigger: theme option changes (experimental) + +{ + "eventName": "theme_option_changed", + "eventTime": "2026-08-27T19:43:39.346Z", + "metadata": { + "dimension": "color", + "value": "calm" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[22/27] SCENARIO route-navigation status=success delta=0ms +trigger: userId: scenario recorded before sign-in (userId absent) + +{ + "scenarioId": "bd9b3b20-5ffc-4ed5-a71d-d5737d1525a8", + "scenarioName": "route-navigation", + "operation": "telemetry-verification-presignin-probe", + "status": "success", + "startTime": "2026-08-27T19:43:39.453Z", + "endTime": "2026-08-27T19:43:39.453Z", + "delta": 0, + "elapsed": 564, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 564, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 564, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 564, + "status": "success" + } + ], + "previousStep": "resolve-route", + "application": "composition" +} + +---------------------------------------------------------------------- +[23/27] SCENARIO route-navigation status=success delta=0ms +trigger: userId: scenario recorded after sign-in (userId present) + +{ + "scenarioId": "ecad7217-ef98-4f63-848f-0e5d4f6da417", + "scenarioName": "route-navigation", + "operation": "telemetry-verification-postsignin-probe", + "status": "success", + "startTime": "2026-08-27T19:43:39.459Z", + "endTime": "2026-08-27T19:43:39.459Z", + "delta": 0, + "elapsed": 570, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 570, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 570, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 570, + "status": "success" + } + ], + "previousStep": "resolve-route", + "application": "composition", + "userId": "demo-user-42" +} + +---------------------------------------------------------------------- +[24/27] SCENARIO route-navigation status=timeout delta=502ms +trigger: edge case: scenario auto-abandon on timeout + +{ + "scenarioId": "6293c234-40e0-446b-8880-9565d85878a7", + "scenarioName": "route-navigation", + "operation": "telemetry-verification-timeout-probe", + "status": "timeout", + "startTime": "2026-08-27T19:43:39.463Z", + "endTime": "2026-08-27T19:43:39.965Z", + "delta": 502, + "elapsed": 1076, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 574, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 0, + "endOffset": 502, + "stepDelta": 502, + "elapsed": 1075, + "status": "timeout" + }, + { + "name": "scenario-end", + "startOffset": 502, + "endOffset": 502, + "stepDelta": 0, + "elapsed": 1076, + "status": "timeout" + } + ], + "previousStep": "resolve-route", + "application": "composition", + "userId": "demo-user-42", + "message": "Scenario did not settle within 500ms" +} + +---------------------------------------------------------------------- +[25/27] SCENARIO autocomplete-search status=incomplete delta=1ms +trigger: edge case: scenario settled as incomplete + +{ + "scenarioId": "05d136cf-12d8-420e-8219-7a7cc4fa6467", + "scenarioName": "autocomplete-search", + "feature": "autocomplete", + "operation": "telemetry-verification-incomplete-probe", + "status": "incomplete", + "startTime": "2026-08-27T19:43:40.370Z", + "endTime": "2026-08-27T19:43:40.371Z", + "delta": 1, + "elapsed": 1482, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 1481, + "status": "success" + }, + { + "name": "query", + "startOffset": 1, + "endOffset": 1, + "stepDelta": 0, + "elapsed": 1482, + "status": "incomplete" + }, + { + "name": "scenario-end", + "startOffset": 1, + "endOffset": 1, + "stepDelta": 0, + "elapsed": 1482, + "status": "incomplete" + } + ], + "previousStep": "query", + "application": "composition", + "userId": "demo-user-42", + "message": "no-results-found" +} + +---------------------------------------------------------------------- +[26/27] SCENARIO autocomplete-validate status=failure delta=1ms +trigger: edge case: autocomplete-validate failure (dead error branch in real code) + +{ + "scenarioId": "d5890ce3-eb19-40ca-ab4f-3a707bce4dd6", + "scenarioName": "autocomplete-validate", + "operation": "telemetry-verification-failure-probe", + "status": "failure", + "startTime": "2026-08-27T19:43:40.383Z", + "endTime": "2026-08-27T19:43:40.384Z", + "delta": 1, + "elapsed": 1494, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 1493, + "status": "success" + }, + { + "name": "validate", + "startOffset": 0, + "endOffset": 1, + "stepDelta": 1, + "elapsed": 1494, + "status": "failure", + "error": { + "name": "Error", + "message": "simulated validation failure", + "stack": "Error: simulated validation failure\n at eval (eval at evaluate (:311:30), :13:19)\n at UtilityScript.evaluate (:313:16)\n at UtilityScript. (:1:44)" + } + }, + { + "name": "scenario-end", + "startOffset": 1, + "endOffset": 1, + "stepDelta": 0, + "elapsed": 1494, + "status": "failure" + } + ], + "previousStep": "validate", + "application": "composition", + "userId": "demo-user-42", + "error": { + "name": "Error", + "message": "simulated validation failure", + "stack": "Error: simulated validation failure\n at eval (eval at evaluate (:311:30), :13:19)\n at UtilityScript.evaluate (:313:16)\n at UtilityScript. (:1:44)" + } +} + +---------------------------------------------------------------------- +[27/27] BI telemetry_verification_dedup_probe +trigger: edge case: BI dedup — two rapid identical events collapse to one + +{ + "eventName": "telemetry_verification_dedup_probe", + "eventTime": "2026-08-27T19:43:40.391Z", + "metadata": { + "probe": true + }, + "application": "composition" +} + + +====================================================================== +-> Log API (CpsLoggerService) +====================================================================== +Each entry is the literal CpsLogRecord handed to CpsLogBatcher. + +---------------------------------------------------------------------- +[1/7] LOG Application started status=log +trigger: boot + navigate /colors -> /autocomplete + +{ + "timestamp": "2026-08-27T19:43:20.551Z", + "level": "log", + "message": "Application started", + "logger": "app", + "context": "AppTelemetry", + "metadata": { + "language": "en-US" + }, + "application": "composition", + "environment": "production", + "version": "22.0.0" +} + +---------------------------------------------------------------------- +[2/7] LOG Application started status=log +trigger: boot + navigate /colors -> /autocomplete + +{ + "timestamp": "2026-08-27T19:43:21.105Z", + "level": "log", + "message": "Application started", + "logger": "app", + "context": "AppTelemetry", + "metadata": { + "language": "en-US" + }, + "application": "composition", + "environment": "production", + "version": "22.0.0" +} + +---------------------------------------------------------------------- +[3/7] LOG Application started status=log +trigger: file upload: success path (3s processing) + +{ + "timestamp": "2026-08-27T19:43:30.235Z", + "level": "log", + "message": "Application started", + "logger": "app", + "context": "AppTelemetry", + "metadata": { + "language": "en-US" + }, + "application": "composition", + "environment": "production", + "version": "22.0.0" +} + +---------------------------------------------------------------------- +[4/7] LOG Application started status=log +trigger: table lazy-load + +{ + "timestamp": "2026-08-27T19:43:36.085Z", + "level": "log", + "message": "Application started", + "logger": "app", + "context": "AppTelemetry", + "metadata": { + "language": "en-US" + }, + "application": "composition", + "environment": "production", + "version": "22.0.0" +} + +---------------------------------------------------------------------- +[5/7] LOG Application started status=log +trigger: code example: copy code + +{ + "timestamp": "2026-08-27T19:43:38.515Z", + "level": "log", + "message": "Application started", + "logger": "app", + "context": "AppTelemetry", + "metadata": { + "language": "en-US" + }, + "application": "composition", + "environment": "production", + "version": "22.0.0" +} + +---------------------------------------------------------------------- +[6/7] LOG Application started status=log +trigger: theme option changes (experimental) + +{ + "timestamp": "2026-08-27T19:43:38.975Z", + "level": "log", + "message": "Application started", + "logger": "app", + "context": "AppTelemetry", + "metadata": { + "language": "en-US" + }, + "application": "composition", + "environment": "production", + "version": "22.0.0" +} + +---------------------------------------------------------------------- +[7/7] LOG Autocomplete selection failed validation (simulated) status=error +trigger: edge case: autocomplete-validate failure (dead error branch in real code) + +{ + "timestamp": "2026-08-27T19:43:40.383Z", + "level": "error", + "message": "Autocomplete selection failed validation (simulated)", + "logger": "autocomplete", + "context": "Autocomplete", + "error": { + "name": "Error", + "message": "simulated validation failure", + "stack": "Error: simulated validation failure\n at eval (eval at evaluate (:311:30), :9:14)\n at UtilityScript.evaluate (:313:16)\n at UtilityScript. (:1:44)" + }, + "correlationId": "d5890ce3-eb19-40ca-ab4f-3a707bce4dd6", + "application": "composition", + "environment": "production", + "version": "22.0.0", + "userId": "demo-user-42" +} + diff --git a/projects/cps-telemetry/tsconfig.lib.json b/projects/cps-telemetry/tsconfig.lib.json new file mode 100644 index 000000000..8ebefa5e3 --- /dev/null +++ b/projects/cps-telemetry/tsconfig.lib.json @@ -0,0 +1,25 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/lib", + "rootDir": "./src", + "lib": ["ES2022", "DOM"], + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"], + "angularCompilerOptions": { + "extendedDiagnostics": { + "checks": { + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + } + } +} diff --git a/projects/cps-telemetry/tsconfig.lib.prod.json b/projects/cps-telemetry/tsconfig.lib.prod.json new file mode 100644 index 000000000..9fb436e8a --- /dev/null +++ b/projects/cps-telemetry/tsconfig.lib.prod.json @@ -0,0 +1,16 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial", + "extendedDiagnostics": { + "checks": { + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + } + } +} diff --git a/projects/cps-telemetry/tsconfig.spec.json b/projects/cps-telemetry/tsconfig.spec.json new file mode 100644 index 000000000..16f49dcef --- /dev/null +++ b/projects/cps-telemetry/tsconfig.spec.json @@ -0,0 +1,11 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "rootDir": "./src", + "types": ["jest"], + "resolveJsonModule": true + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts index cc01c32ad..2ba70d0d5 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts @@ -1454,7 +1454,7 @@ describe('CpsAutocompleteComponent', () => { const idAtOriginalIndex = component.getOptionId(option, 0); const idWithDifferentIndexArg = component.getOptionId(option, 5); expect(idWithDifferentIndexArg).toBe(idAtOriginalIndex); - expect(idWithDifferentIndexArg).not.toContain('-5'); + expect(idWithDifferentIndexArg).not.toMatch(/-5$/); }); }); diff --git a/tsconfig.json b/tsconfig.json index f8c74ca9c..4bfabfdc2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,11 @@ "compileOnSave": false, "compilerOptions": { "resolveJsonModule": true, - "paths": { "cps-ui-kit": ["./projects/cps-ui-kit/src/public-api.ts"] }, + "paths": { + "cps-ui-kit": ["./projects/cps-ui-kit/src/public-api.ts"], + "cps-telemetry": ["./projects/cps-telemetry/src/public-api.ts"], + "cps-telemetry/rum": ["./projects/cps-telemetry/rum/src/public-api.ts"] + }, "esModuleInterop": true, "outDir": "./dist/out-tsc", "forceConsistentCasingInFileNames": true, @@ -33,6 +37,10 @@ "references": [ { "path": "./projects/cps-ui-kit/tsconfig.lib.json" }, { "path": "./projects/cps-ui-kit/tsconfig.spec.json" }, + { "path": "./projects/cps-telemetry/tsconfig.lib.json" }, + { "path": "./projects/cps-telemetry/tsconfig.spec.json" }, + { "path": "./projects/cps-telemetry/rum/tsconfig.lib.json" }, + { "path": "./projects/cps-telemetry/rum/tsconfig.spec.json" }, { "path": "./projects/composition/tsconfig.app.json" }, { "path": "./projects/composition/tsconfig.spec.json" } ],