From dbd8494e3ff2121c58d8cfedd8491621d15d7820 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 7 Oct 2026 18:54:35 +0200 Subject: [PATCH 01/51] feat: add diagnostics popup --- README.md | 1 + jest.config.js | 5 +- package-lock.json | 107 +++++- package.json | 10 +- projects/ngx-ui-watchtower/DESIGN.md | 250 ++++++++++++- projects/ngx-ui-watchtower/README.md | 14 +- .../diagnostics/ng-package.json | 6 + .../uwt-diagnostics-dialog.component.html | 78 ++++ .../uwt-diagnostics-dialog.component.scss | 102 ++++++ .../uwt-diagnostics-dialog.component.spec.ts | 343 ++++++++++++++++++ .../uwt-diagnostics-dialog.component.ts | 154 ++++++++ .../uwt-diagnostics-export.spec.ts | 140 +++++++ .../uwt-diagnostics-export.ts | 119 ++++++ .../uwt-diagnostics-exporter.ts | 137 +++++++ .../uwt-diagnostics-filter-bar.component.html | 97 +++++ .../uwt-diagnostics-filter-bar.component.scss | 52 +++ ...t-diagnostics-filter-bar.component.spec.ts | 98 +++++ .../uwt-diagnostics-filter-bar.component.ts | 142 ++++++++ .../uwt-diagnostics-filter.spec.ts | 234 ++++++++++++ .../uwt-diagnostics-filter.ts | 210 +++++++++++ .../uwt-diagnostics-internal.util.ts | 53 +++ .../uwt-diagnostics-json.component.html | 37 ++ .../uwt-diagnostics-json.component.scss | 41 +++ .../uwt-diagnostics-json.component.spec.ts | 90 +++++ .../uwt-diagnostics-json.component.ts | 82 +++++ .../uwt-diagnostics-columns.ts | 199 ++++++++++ .../uwt-diagnostics-section.component.html | 161 ++++++++ .../uwt-diagnostics-section.component.scss | 108 ++++++ .../uwt-diagnostics-section.component.spec.ts | 126 +++++++ .../uwt-diagnostics-section.component.ts | 168 +++++++++ .../uwt-diagnostics-shortcut.spec.ts | 104 ++++++ .../uwt-diagnostics-shortcut.ts | 92 +++++ .../uwt-diagnostics-store.spec.ts | 264 ++++++++++++++ .../uwt-diagnostics-store.ts | 337 +++++++++++++++++ .../uwt-diagnostics.models.ts | 141 +++++++ .../uwt-diagnostics.providers.ts | 60 +++ .../uwt-telemetry-diagnostics.service.spec.ts | 194 ++++++++++ .../uwt-telemetry-diagnostics.service.ts | 167 +++++++++ .../diagnostics/src/public-api.ts | 21 ++ .../diagnostics/tsconfig.lib.json | 25 ++ .../diagnostics/tsconfig.spec.json | 11 + projects/ngx-ui-watchtower/package.json | 12 + .../uwt-telemetry-monitor.models.ts | 125 +++++++ .../uwt-scenario/uwt-scenario.spec.ts | 2 + .../lib/scenario/uwt-scenario/uwt-scenario.ts | 16 +- .../uwt-bi-telemetry.service.ts | 9 + .../uwt-logger.service/uwt-logger.service.ts | 37 +- .../uwt-scenario-telemetry.service.ts | 3 + .../uwt-telemetry-monitor.integration.spec.ts | 210 +++++++++++ .../uwt-telemetry-monitor.service.spec.ts | 118 ++++++ .../uwt-telemetry-monitor.service.ts | 81 +++++ .../uwt-broadcast-host.service.ts | 99 ++++- .../sinks/uwt-broadcast/uwt-broadcast.spec.ts | 154 ++++++++ projects/ngx-ui-watchtower/src/public-api.ts | 10 + tsconfig.json | 7 +- 55 files changed, 5632 insertions(+), 31 deletions(-) create mode 100644 projects/ngx-ui-watchtower/diagnostics/ng-package.json create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.html create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.scss create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.spec.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-export.spec.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-export.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-exporter.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.html create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.scss create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.spec.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter/uwt-diagnostics-filter.spec.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter/uwt-diagnostics-filter.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-internal.util/uwt-diagnostics-internal.util.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.html create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.scss create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.spec.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-columns.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.html create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.scss create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.spec.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-shortcut/uwt-diagnostics-shortcut.spec.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-shortcut/uwt-diagnostics-shortcut.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-store/uwt-diagnostics-store.spec.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-store/uwt-diagnostics-store.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics.models/uwt-diagnostics.models.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics.providers/uwt-diagnostics.providers.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.spec.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/src/public-api.ts create mode 100644 projects/ngx-ui-watchtower/diagnostics/tsconfig.lib.json create mode 100644 projects/ngx-ui-watchtower/diagnostics/tsconfig.spec.json create mode 100644 projects/ngx-ui-watchtower/src/lib/models/uwt-telemetry-monitor.models/uwt-telemetry-monitor.models.ts create mode 100644 projects/ngx-ui-watchtower/src/lib/services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.integration.spec.ts create mode 100644 projects/ngx-ui-watchtower/src/lib/services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.service.spec.ts create mode 100644 projects/ngx-ui-watchtower/src/lib/services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.service.ts diff --git a/README.md b/README.md index 85513da..be75dd4 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ How it is designed: [projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watch projects/ngx-ui-watchtower/ src/ main entry point — @absaoss-cps/ngx-ui-watchtower rum/ secondary entry point — @absaoss-cps/ngx-ui-watchtower/rum + diagnostics/ secondary entry point — @absaoss-cps/ngx-ui-watchtower/diagnostics README.md usage guide, published to npm DESIGN.md full design package.json the published package's manifest diff --git a/jest.config.js b/jest.config.js index 3a8a9c8..921e2a2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,10 +3,13 @@ module.exports = { coverageDirectory: '/coverage', preset: 'jest-preset-angular', moduleNameMapper: { + '^lodash-es$': 'lodash', '^@absaoss-cps/ngx-ui-watchtower$': '/projects/ngx-ui-watchtower/src/public-api.ts', '^@absaoss-cps/ngx-ui-watchtower/rum$': - '/projects/ngx-ui-watchtower/rum/src/public-api.ts' + '/projects/ngx-ui-watchtower/rum/src/public-api.ts', + '^@absaoss-cps/ngx-ui-watchtower/diagnostics$': + '/projects/ngx-ui-watchtower/diagnostics/src/public-api.ts' }, transformIgnorePatterns: [ 'node_modules/(?!(.*.mjs$|@angular/common/locales/.*.js$))' diff --git a/package-lock.json b/package-lock.json index 6f764f7..81f82b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,14 +19,20 @@ }, "devDependencies": { "@angular-builders/jest": "^22.0.1", + "@angular/animations": "^22.1.3", "@angular/build": "^22.1.5", "@angular/cli": "~22.1.5", "@angular/compiler-cli": "^22.1.3", + "@angular/forms": "^22.1.3", + "@angular/router": "^22.1.3", + "@e965/xlsx": "^0.20.3", "@types/jest": "^30.0.0", + "@types/lodash-es": "^4.17.12", "@types/node": "^22.10.10", "@typescript-eslint/eslint-plugin": "^8.67.0", "@typescript-eslint/parser": "^8.67.0", "aws-rum-web": "^3.2.1", + "cps-ui-kit": "^22.0.1", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-config-standard": "^17.1.0", @@ -38,6 +44,8 @@ "jest": "^30.3.0", "jest-environment-jsdom": "^30.3.0", "jest-preset-angular": "^17.0.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", "ng-packagr": "^22.1.1", "prettier": "^3.4.2", "typescript": "~6.0.3" @@ -305,9 +313,8 @@ "version": "22.2.1", "integrity": "sha512-vTxZN6uL/m61lv7ZCvfmQ3VuFb4o/tniGLpc3SpkNHE6MLM8WN0IjfNBRLMYMovd1uUBSPwhcG4Llcmucr9dmw==", "deprecated": "@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.", + "devOptional": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -714,6 +721,26 @@ } } }, + "node_modules/@angular/forms": { + "version": "22.2.1", + "integrity": "sha512-xHM+PhrvYDJ+edPxh6PWhkE99tXWbsMcxdXCWF6QcyByX+bsW2rVlNZ7dumOsUle9MYa8Yj0lZFACUWbwFw+4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "tslib": "^2.3.0", + "zod": "^4.0.10" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/common": "22.2.1", + "@angular/core": "22.2.1", + "@angular/platform-browser": "22.2.1", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/platform-browser": { "version": "22.2.1", "integrity": "sha512-KQ3oguemaSqyNP00yqEvPouY7NC4Lji+6S8yfjaWaUZjSth/GckpeP/Fo9HtMqd04y6MZ6lEb2hbQtTqFcSdEQ==", @@ -753,6 +780,24 @@ "@angular/platform-browser": "22.2.1" } }, + "node_modules/@angular/router": { + "version": "22.2.1", + "integrity": "sha512-HJfJ4yA1znKd9YFtbDOJfKmD12x0PhS7LkmYdWlJRu2WkQZRBVrdGKdmbvV5UvrqCRllVWKpzw1TIZJM69rlcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/common": "22.2.1", + "@angular/core": "22.2.1", + "@angular/platform-browser": "22.2.1", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "7.1.3", "integrity": "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==", @@ -2642,6 +2687,18 @@ "node": ">=14.17.0" } }, + "node_modules/@e965/xlsx": { + "version": "0.20.3", + "integrity": "sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", @@ -7008,6 +7065,12 @@ "node": ">=14.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.4", "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", @@ -7377,6 +7440,21 @@ "license": "MIT", "peer": true }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/node": { "version": "22.20.5", "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", @@ -10176,6 +10254,25 @@ } } }, + "node_modules/cps-ui-kit": { + "version": "22.0.1", + "integrity": "sha512-9N5K+0wEiqzJB8do9awT3abAIOFN8VqPjJZmLdOzpk8QmintWQ0SLmWA5CVvruP993r7j85RDbzX/bVrkDub2w==", + "dev": true, + "license": "Apache License 2.0", + "dependencies": { + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@angular/common": "^22.1.3", + "@angular/core": "^22.1.3", + "@angular/forms": "^22.1.3", + "@e965/xlsx": "^0.20.3", + "@types/lodash-es": "^4.17.12", + "lodash-es": "^4.17.21", + "rxjs": "^7.8.2", + "zone.js": "^0.15.1 || ^0.16.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", @@ -16134,6 +16231,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", diff --git a/package.json b/package.json index dc64593..07ab38e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "lint": "eslint \"**/*.ts\" --max-warnings=0", "format": "prettier --write \"**/*.{ts,html}\"", "format:check": "prettier --check \"**/*.{ts,html}\"", - "typecheck": "tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit" + "typecheck": "tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/diagnostics/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/diagnostics/tsconfig.spec.json --noEmit" }, "private": true, "dependencies": { @@ -24,14 +24,20 @@ }, "devDependencies": { "@angular-builders/jest": "^22.0.1", + "@angular/animations": "^22.1.3", "@angular/build": "^22.1.5", "@angular/cli": "~22.1.5", "@angular/compiler-cli": "^22.1.3", + "@angular/forms": "^22.1.3", + "@angular/router": "^22.1.3", + "@e965/xlsx": "^0.20.3", "@types/jest": "^30.0.0", + "@types/lodash-es": "^4.17.12", "@types/node": "^22.10.10", "@typescript-eslint/eslint-plugin": "^8.67.0", "@typescript-eslint/parser": "^8.67.0", "aws-rum-web": "^3.2.1", + "cps-ui-kit": "^22.0.1", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-config-standard": "^17.1.0", @@ -43,6 +49,8 @@ "jest": "^30.3.0", "jest-environment-jsdom": "^30.3.0", "jest-preset-angular": "^17.0.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", "ng-packagr": "^22.1.1", "prettier": "^3.4.2", "typescript": "~6.0.3" diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index ecfed37..49c9f59 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -24,7 +24,7 @@ configuration, log backend and AWS credentials. - **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 - produced, in any environment, with a LocalStorage flag — + produced, in any environment, with a LocalStorage flag or an in-app popup — no rebuild, no config change, no production switch. - **Reusability.** Ship as a library any Angular application can install and configure. @@ -85,24 +85,28 @@ flowchart TD | `UwtNoopTelemetrySink` | Explicit opt-out — everything runs, nothing ships | | `UwtLogApiProvider` | The seam where the application supplies its log store — send, query, optional flush | | `UwtRumCredentialsProvider` | The seam where the application supplies its AWS details | +| `UwtTelemetryMonitor` | A read-only stream of everything handed to a destination | Everything is wired through Angular DI, so a destination can be swapped in production and stubbed in tests. ### Entry points -Two, each pulling in only what it needs: +Three, each pulling in only what it needs: -| Entry point | Contains | Needs | -| ------------------------------------ | ------------------------------------ | ------------------------------- | -| `@absaoss-cps/ngx-ui-watchtower` | Everything except the one below | Angular | -| `@absaoss-cps/ngx-ui-watchtower/rum` | `UwtRumTelemetrySink`, its providers | `aws-rum-web`, an optional peer | +| Entry point | Contains | Needs | +| -------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------- | +| `@absaoss-cps/ngx-ui-watchtower` | Everything except the two below | Angular | +| `@absaoss-cps/ngx-ui-watchtower/rum` | `UwtRumTelemetrySink`, its providers | `aws-rum-web`, an optional peer | +| `@absaoss-cps/ngx-ui-watchtower/diagnostics` | The [diagnostics popup](#diagnostics-popup) | `cps-ui-kit` (with `@angular/forms`, `@angular/animations`), optional peers | A bundler resolves every static import in a module graph, and a dynamic `import()`'s specifier, whether or not that code path ever runs. Keeping the RUM sink and its `await import('aws-rum-web')` out of the main entry is what lets an app using only `'broadcast'`/`'noop'` build without `aws-rum-web` -installed. +installed. The popup is split out the same way: it is the only UI in the +package, and nothing in the main entry — bundle or `.d.ts` — references +cps-ui-kit. ### Public API @@ -120,6 +124,7 @@ Everything each entry point exports, by role. | Name registries | `UwtScenarioNames` / `UwtScenarioName`, `UwtScenarioSteps` / `UwtStepName`, `UwtBIEventNames` / `UwtBIEventName`, `UwtLoggerNames` / `UwtLoggerName` | | Sinks | `UwtTelemetrySink` (abstract), `UwtNoopTelemetrySink`, `UwtBroadcastTelemetrySink`, `UwtTelemetryBroadcastHost`, `UWT_BROADCAST_CHANNEL`, `UWT_DEFAULT_BROADCAST_CHANNEL` (`'ngx-ui-watchtower'`); `uwtClassifyTelemetryEvent` + `UwtTelemetrySinkEvent`, for a sink to tell what it received | | Shared models | `UwtTelemetryMetadata`, `UwtTelemetryError`, `UwtTelemetryAttribution`; event types `UWT_DEFAULT_EVENT_NAMESPACE`, `UWT_TELEMETRY_EVENT_TYPE`, `uwtEventTypes()`, `UwtTelemetryEventTypes` | +| Monitor | `UwtTelemetryMonitor`; `UwtTelemetryObservedEvent`, `UwtTelemetryPublishInput`, `UwtTelemetryEventKind`, `UwtTelemetryDestination`, `UwtTelemetryEventOrigin`, `UwtJsonValue`, `UwtJsonObject` | | Redaction, for custom sinks | `uwtRedactMetadata`, `uwtNormalizeError`, `uwtScrubString`, `uwtRedactConfigFor`, `UwtRedactConfig`, `UWT_DEFAULT_REDACT_CONFIG`, `UwtPiiValuePattern`, `UWT_REDACTED` (`'[redacted]'`, the replacement value) | | Utilities | `uwtUuid()`, `uwtIsDebugEnabled(flag, name?)` + `UwtDebugFlag` (`'debugLogger' \| 'debugScenario' \| 'debugBI'`) | @@ -129,6 +134,14 @@ Everything each entry point exports, by role. `UwtRumCredentials` (`accessKeyId`, `secretAccessKey`, `sessionToken`, `expiration`). +**`@absaoss-cps/ngx-ui-watchtower/diagnostics`:** `provideUwtTelemetryDiagnostics`, +`UwtTelemetryDiagnosticsService`, `UWT_TELEMETRY_DIAGNOSTICS_CONFIG`, +`UwtTelemetryDiagnosticsConfig`, `UWT_DEFAULT_DIAGNOSTICS_CONFIG`, +`UwtDiagnosticsShortcut`, `UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS`, +`UwtDiagnosticsExport`, `UwtDiagnosticsSectionId`, and the filter types +`UwtDiagnosticsFilterState`, `UwtDiagnosticsFieldFilter`, +`UwtDiagnosticsFilterOperator` — see §12, "Diagnostics popup". + ### Destinations A realm has exactly **one telemetry destination**: the sink every scenario @@ -176,7 +189,9 @@ redacted), deduplicate BI events, time anything, or guard the library's calls — every call the library makes into it, `init` included, is wrapped fail-open. The one exception is `setUserId`/`getUserId`, which the application calls on the sink directly; a destination should not throw -there. Log lines are not part of it: they go to the application's +there. The +diagnostics monitor observes the hand-off, so the popup works with any +destination. Log lines are not part of it: they go to the application's own log backend through `UWT_LOG_API_PROVIDER`, whatever the destination. ```ts @@ -187,6 +202,16 @@ providers: [ ]; ``` +### The telemetry monitor + +Every place that hands something to a destination — the scenario's emit, +`track()`, the logger's delivery and its RUM mirror, and the broadcast host +forwarding a fragment's events and logs — then publishes the same object to +`UwtTelemetryMonitor`, inside the same fail-open block. So nothing is sent +twice, and a hand-off that throws is not shown. A fragment's event appears +once in each popup: as its own in the fragment's, as forwarded in the +shell's. + ### Packaging and internals - **Every export is explicit.** The barrels list their exports one by one — @@ -195,10 +220,14 @@ providers: [ broadcast plumbing stay internal so they can change freely. Redaction is the exception: a custom sink needs `uwtRedactMetadata`, `uwtNormalizeError`, `uwtScrubString` and `uwtRedactConfigFor`. -- **The secondary entry keeps private copies of a few utilities.** ng-packagr +- **Secondary entries keep private copies of a few utilities.** ng-packagr fixes each entry point's `rootDir` to its own `src`, so - `@absaoss-cps/ngx-ui-watchtower/rum` can't import the main entry's - internals by relative path and carries small copies of what it needs. + `@absaoss-cps/ngx-ui-watchtower/rum` and `@absaoss-cps/ngx-ui-watchtower/diagnostics` can't import the main + entry's internals by relative path and carry small copies of what they + need. +- **cps-ui-kit comes from npm.** It is an optional peer of the package and a + dev dependency of the workspace, so the build and the unit tests resolve it + from `node_modules` and it is never compiled into the package. - **Test doubles live in the specs that use them**, declared inline, never in a shared file and never exported. The one browser gap needing a stub is `BroadcastChannel`, which jsdom doesn't implement. @@ -1190,6 +1219,21 @@ Reading through AWS 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. +### The diagnostics popup + +The popup shows exactly what is sent, after redaction — including session +and user ids — and redacts nothing further, since a popup that hid fields +would misreport what leaves the browser. That is acceptable in every +environment because it exposes nothing new: the data is the current user's +own, already in their browser and visible in the network panel. It stores +nothing, sends nothing and grants no remote access. + +Avoid sharing your screen while it is open, and treat downloads as you would +any file with ids in it; filenames carry only the application name, the +section and a timestamp +(`ngx-ui-watchtower-diagnostics--
-.json`). The shortcut prevents accidental opening; it is not access +control — use `enabled` for that, or leave the provider out. + --- ## 10. Configuration @@ -1437,7 +1481,10 @@ convention, for an application that wants the pre-fallback behavior back. becoming stable. - **Bounded.** A 100-item buffer keeps events, page views and errors recorded before the RUM client is ready, then replays them; it never grows past - that if initialization never completes. + that if initialization never completes. The diagnostics monitor costs one + check per event while nobody watches; the popup keeps at most 500 events + per section, updates the screen at most every 250 ms, and releases + everything when it closes. The console report is itself wrapped in a try/catch — an application that has patched or otherwise broken `console.error` cannot turn a suppressed @@ -1472,6 +1519,8 @@ How an application uses the library, end to end. npm install @absaoss-cps/ngx-ui-watchtower # optional, only if you use the AWS RUM sink npm install aws-rum-web +# optional, only if you use the diagnostics popup +npm install cps-ui-kit ``` ### Setup @@ -1816,6 +1865,173 @@ writes to the console, so starting a scenario, opening a step or calling a method after settlement — none of which sends anything — print nothing. To see each step, turn on `emitLifecycleEvents`, which makes steps real events. +### Diagnostics popup + +An in-app window showing, live, every BI event, scenario event and log +record your app hands to its telemetry destinations — no DevTools, no +backend access: + +```ts +import { provideUwtTelemetryDiagnostics } from '@absaoss-cps/ngx-ui-watchtower/diagnostics'; + +providers: [ + provideUwtTelemetry({ application: 'my-app', environment, version }), + provideUwtTelemetryDiagnostics() +]; +``` + +It is built from cps-ui-kit, so the app needs cps-ui-kit set up as for any +cps-ui-kit dialog (styles, icons, animations). It supports the light theme +only, for now: cps-dialog, which frames it, is light-only, and the kit's +dark theme is not yet complete for the components inside it. + +#### Opening it + +| Platform | Shortcut | +| ----------------- | -------------------------- | +| macOS | **⇧ ⌥ ⌘ 8** | +| Windows and Linux | **Ctrl + Alt + Shift + 8** | + +Four keys, so it never opens by accident; both combinations work +everywhere, which covers external keyboards and remote desktops. Keys are +matched by physical position (`KeyboardEvent.code`), so they work on any +layout — Option rewrites the typed character on macOS. Every modifier must +match exactly, held-down repeats are ignored, and AltGr — which many +European Windows layouts report as Ctrl+Alt — never triggers it. ⌥⌘8 alone +is macOS Accessibility Zoom, which is why Shift is part of it. + +The popup is not modal: it docks on the right at half the screen and leaves +the app usable, so you can watch events arrive. It is draggable, resizable +and maximizable. With it open, the shortcut brings focus back to +it from the app, and closes it when focus is already inside. Escape closes it +too. + +It works in every environment. To change the keys, turn the shortcut off, or +limit who can open it: + +```ts +provideUwtTelemetryDiagnostics({ + // Your own combination — replaces the defaults. + shortcuts: [ + { + code: 'KeyD', + ctrl: true, + alt: true, + shift: true, + label: 'Ctrl+Alt+Shift+D' + } + ], + + // Or no keyboard at all, opening it from your own UI instead: + // shortcuts: [], then inject(UwtTelemetryDiagnosticsService).open(); + + // Read once at startup. + enabled: () => inject(AuthService).isSupportStaff() +}); +``` + +#### What it shows + +Three sections — BI telemetry, scenario telemetry and logging — listing +events newest first, from the moment the popup opens. Expand a row for the +full payload as JSON. + +Each section has its own tools, which never affect the other two: + +- **Search** matches any field's value or name. **Add filter** compares one + field — `metadata.theme`, `status`, or `steps[].name` for any item of a + list. Field suggestions come from the events that section has seen. +- **Download JSON** saves every event that section captured, oldest first, + whatever its filters, with those filters recorded in the file. Each event + keeps its `sequence` and `capturedAt`, so downloads from different + sections can be interleaved again. **Copy JSON** puts the same JSON on the + clipboard, for when a browser blocks downloads. + +Two controls at the top apply to all three sections, because they are about +capturing rather than viewing: + +- **Pause live updates** freezes the view while you read; events are still + captured. +- **Clear** empties the history; capturing continues. + +Each section keeps the latest 500 events (`maxEventsPerSection`). History is +discarded when the popup closes. + +What it does **not** show: + +- Anything before the popup opened. +- RUM's own automatic events — page views, web vitals, HTTP and JS errors — + which never pass through this library. +- Whether the server accepted an event. It shows what was handed to the sink + or log provider; RUM can still drop events, for example when it samples a + session out or reaches its event limit. + +In a composed page, the shell's popup also shows what its fragments send — BI +and scenario events and log records alike, since the shell delivers them — +each marked with `↪` and the fragment's name. + +#### Configuration + +| Option | Default | Meaning | +| ----------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `enabled` | `true` | `false`, or a function read once at startup, turns the popup off — shortcut and `open()` alike | +| `shortcuts` | `UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS` | Replaces the defaults; `[]` disables the keyboard | +| `maxEventsPerSection` | `500` | Events kept per section; the oldest are dropped, and the count of dropped ones is shown | +| `maxPayloadCharsInView` | `262144` (256 × 1024) | A payload whose JSON is longer is shown truncated to its first 65,536 characters; downloads stay complete | + +Characters here are UTF-16 code units (JavaScript's `length`), not bytes: the +limit protects rendering, which costs by the length of the text. A cut never +splits a surrogate pair. + +A shortcut is a `UwtDiagnosticsShortcut` — `{ code, ctrl?, alt?, shift?, +meta?, label? }`, where `code` is a `KeyboardEvent.code` such as `'Digit8'` +or `'KeyD'`, and an omitted modifier must be up. Spread +`UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS` to add one rather than replace them. + +`UwtTelemetryDiagnosticsService` opens the popup from code: `open()`, +`close()`, `toggle()`, and an `isOpen` signal. + +A download is a `UwtDiagnosticsExport`: + +```ts +interface UwtDiagnosticsExport { + format: 'ngx-ui-watchtower-diagnostics'; + formatVersion: 1; + section: 'bi' | 'scenario' | 'logging'; + exportedAt: string; + capture: { startedAt: string; endedAt: string; paused: boolean }; + app: { + application: string; + environment: string; + version: string; + eventNamespace: string; + }; + session: { sessionId?: string; userId?: string }; + userAgent: string; + activeFilters: UwtDiagnosticsFilterState; // recorded, not applied + count: { captured: number; droppedOldest: number }; + events: UwtTelemetryObservedEvent[]; // oldest first +} +``` + +Field filters use the operators `contains` (the default), `equals`, +`not-contains`, `exists` and `missing`, all case-insensitive; several filters +combine with AND. A path such as `steps[].name` matches any item of a list. + +#### Reading events from code + +The popup is built on `UwtTelemetryMonitor`, from the main entry point, which +anyone can observe — a test, or your own overlay: + +```ts +inject(UwtTelemetryMonitor) + .events$.pipe(filter((e) => e.kind === 'scenario')) + .subscribe((e) => console.table(e.payload)); +``` + +Each event is a deep copy of what was sent, so observers can't change it. +While nothing subscribes, it costs one check per event. + ### Testing The library ships **no test helpers**. Everything a test needs is already in @@ -2012,7 +2228,7 @@ error's name instead (`[cart] TypeError`). | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `provideUwtTelemetryRumSink()` | Builds a second AWS client — one visitor becomes two sessions. Listed next to `'broadcast'`, it is a second destination, so bootstrap fails | | `UWT_RUM_CREDENTIALS_PROVIDER` | Nothing in a fragment needs AWS credentials | -| `UWT_LOG_API_PROVIDER` | `'broadcast'` mode already binds it to the forwarding provider. One bound after it wins, and the fragment ships its own logs — the shell never sees them | +| `UWT_LOG_API_PROVIDER` | `'broadcast'` mode already binds it to the forwarding provider. One bound after it wins, and the fragment ships its own logs — the shell, and its diagnostics popup, never see them | | `provideUwtTelemetryBroadcastHost()` | A host receives its own realm's messages too, so beside the forwarding sink it would forward them again, forever. It detects this, warns (`... cannot also host it ...`) and stays inactive | #### A realm that both forwards and hosts @@ -2084,7 +2300,13 @@ never injects `UwtLoggerService` sends none in the first place. With `mirrorErrorsToRum`, each error-level log line leaves a fragment as two messages — the record for the log provider, then its mirrored error for the sink — with no id in common. Each destination still receives it once: the -log backend gets the record, RUM gets the error. +log backend gets the record, RUM gets the error. The host links them for its +monitor anyway, so the shell's diagnostics popup shows the mirror against +its record as the fragment's own does: an `error` arriving straight after an +error-level record from the same realm, with the name and message the logger +mirrors for it, gets that record's sequence as `relatedSequence`. Both are +posted in the same task, so nothing can arrive between them. A mirror that +doesn't match exactly is shown unlinked, never linked wrongly. ### A fragment that also deploys standalone diff --git a/projects/ngx-ui-watchtower/README.md b/projects/ngx-ui-watchtower/README.md index 6187cdd..3a23416 100644 --- a/projects/ngx-ui-watchtower/README.md +++ b/projects/ngx-ui-watchtower/README.md @@ -300,7 +300,7 @@ Fragment code doesn't change. Give each fragment its own `application` name. ## Seeing what is sent -Run in DevTools, no reload needed: +**In the console** — run in DevTools, no reload needed: ```js localStorage.setItem('debugScenario', 'true'); @@ -308,6 +308,18 @@ localStorage.setItem('debugBI', 'true'); localStorage.setItem('debugLogger', 'true'); // or 'checkout,cart' for some loggers ``` +**In the app** — add the diagnostics popup (needs `cps-ui-kit`): + +```ts +import { provideUwtTelemetryDiagnostics } from '@absaoss-cps/ngx-ui-watchtower/diagnostics'; + +providers: [provideUwtTelemetryDiagnostics()]; +``` + +Open it with **⇧⌥⌘8** on macOS or **Ctrl+Alt+Shift+8** on Windows and Linux. +It lists everything the app sends from that moment on, with search, filters +and JSON download. + ## Testing ```ts diff --git a/projects/ngx-ui-watchtower/diagnostics/ng-package.json b/projects/ngx-ui-watchtower/diagnostics/ng-package.json new file mode 100644 index 0000000..e09fb3f --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/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/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.html b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.html new file mode 100644 index 0000000..f8c839a --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.html @@ -0,0 +1,78 @@ +
+
+

+ + {{ store.paused() ? 'Paused' : 'Capturing' }} since {{ since() }} · + {{ store.total() }} event{{ store.total() === 1 ? '' : 's' }} +

+
+ + + + +
+
+ + @if (store.paused() && store.newWhilePaused() > 0) { +
+ {{ store.newWhilePaused() }} new event{{ + store.newWhilePaused() === 1 ? '' : 's' + }} + held back while paused. + + +
+ } + + @if (store.total() === 0 && !store.paused()) { +

+ Capturing from now. Use the app to generate events; they appear here as + they are sent. +

+ } + +
+ @for (section of sections; track section) { + + + } +
+ +
+

+ Shows what this app hands to its telemetry destinations, after redaction. + A destination can still drop an event, for example when RUM samples a + session out. RUM's own page views, errors and performance events aren't + shown. +

+

+ Contains your session and user ids: avoid sharing your screen while it's + open, and share downloads only with your team. History is discarded when + this window closes. + @if (shortcut) { + Shortcut: {{ shortcut }}. + } +

+
+
diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.scss b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.scss new file mode 100644 index 0000000..9079222 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.scss @@ -0,0 +1,102 @@ +:host { + display: block; + color: var(--cps-text-primary); +} + +.uwt-diagnostics-dialog { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.uwt-diagnostics-dialog__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; +} + +.uwt-diagnostics-dialog__status { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 0.875rem; + color: var(--cps-text-secondary); + font-variant-numeric: tabular-nums; +} + +.uwt-diagnostics-dialog__dot { + width: 0.5rem; + height: 0.5rem; + border-radius: var(--cps-radius-full, 50%); + background: var(--cps-state-success); + animation: uwt-diagnostics-pulse 1.6s ease-in-out infinite; + + &--paused { + background: var(--cps-state-warn); + animation: none; + } +} + +@keyframes uwt-diagnostics-pulse { + 50% { + opacity: 0.35; + } +} + +@media (prefers-reduced-motion: reduce) { + .uwt-diagnostics-dialog__dot { + animation: none; + } +} + +.uwt-diagnostics-dialog__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.uwt-diagnostics-dialog__paused { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + border-radius: var(--cps-radius-sm); + background: var(--cps-state-warn-surface); + color: var(--cps-text-primary); + font-size: 0.8125rem; +} + +.uwt-diagnostics-dialog__hint { + margin: 0; + font-size: 0.875rem; + color: var(--cps-text-secondary); +} + +.uwt-diagnostics-dialog__sections { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.uwt-diagnostics-dialog__footer { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding-top: 0.5rem; + border-top: 1px solid var(--cps-border-color); + font-size: 0.75rem; + color: var(--cps-text-secondary); + + p { + margin: 0; + max-width: 75ch; + } + + kbd { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.spec.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.spec.ts new file mode 100644 index 0000000..113b2f7 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.spec.ts @@ -0,0 +1,343 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + UWT_TELEMETRY_IDENTITY, + UwtTelemetryMonitor, + UwtTelemetryPublishInput +} from '@absaoss-cps/ngx-ui-watchtower'; +import { CPS_LIVE_ANNOUNCER_SERVICE } from 'cps-ui-kit'; +import * as exportUtil from '../uwt-diagnostics-export/uwt-diagnostics-export'; +import { + UWT_DIAGNOSTICS_FLUSH_MS, + UwtDiagnosticsStore +} from '../uwt-diagnostics-store/uwt-diagnostics-store'; +import { UwtDiagnosticsDialogComponent } from './uwt-diagnostics-dialog.component'; + +const bi = (name: string, theme = 'dark'): UwtTelemetryPublishInput => ({ + kind: 'bi', + eventType: 'com.uwt.bi', + payload: { + eventName: name, + eventTime: '2026-01-01T00:00:00.000Z', + application: 'app', + metadata: { theme } + }, + destination: 'sink', + origin: { forwarded: false } +}); + +const scenario: UwtTelemetryPublishInput = { + kind: 'scenario', + eventType: 'com.uwt.scenario', + payload: { + scenarioId: 'abc', + scenarioName: 'load-dashboard', + status: 'success', + startTime: '2026-01-01T00:00:00.000Z', + delta: 42, + elapsed: 1000, + stepCount: 1, + steps: [], + application: 'app' + }, + destination: 'sink', + origin: { forwarded: false } +}; + +const warn: UwtTelemetryPublishInput = { + kind: 'log', + payload: { + timestamp: '2026-01-01T00:00:00.000Z', + level: 'warn', + message: 'Careful now', + logger: 'checkout', + application: 'app', + environment: 'test', + version: '1' + }, + destination: 'log-provider', + origin: { forwarded: false } +}; + +describe('UwtDiagnosticsDialogComponent', () => { + let fixture: ComponentFixture; + let monitor: UwtTelemetryMonitor; + let announce: jest.Mock; + + const el = () => fixture.nativeElement as HTMLElement; + /** The popup's own actions, as its buttons and switch call them. */ + const ui = () => + fixture.componentInstance as unknown as { + setPaused(paused: boolean): void; + clear(): void; + }; + /** A button found by the start of its accessible name. */ + const button = (name: string) => { + const found = el().querySelector( + `button[aria-label^="${name}"]` + ); + if (!found) { + throw new Error(`No button named "${name}…"`); + } + return found; + }; + const text = () => el().textContent?.replace(/\s+/g, ' ') ?? ''; + const titles = () => + Array.from( + el().querySelectorAll('[data-testid="cps-expansion-panel-title"]') + ).map((t) => t.textContent?.trim()); + const bodyRows = () => + el().querySelectorAll('td.uwt-diagnostics-section__time').length; + + function settle(): void { + jest.advanceTimersByTime(UWT_DIAGNOSTICS_FLUSH_MS); + fixture.detectChanges(); + } + + beforeEach(async () => { + jest.useFakeTimers(); + announce = jest.fn(); + await TestBed.configureTestingModule({ + imports: [UwtDiagnosticsDialogComponent, NoopAnimationsModule], + providers: [ + { + provide: UWT_TELEMETRY_IDENTITY, + useValue: { + application: 'app', + environment: 'test', + version: '1.0.0' + } + }, + { + provide: CPS_LIVE_ANNOUNCER_SERVICE, + useValue: { announce, clear: jest.fn() } + } + ] + }).compileComponents(); + monitor = TestBed.inject(UwtTelemetryMonitor); + fixture = TestBed.createComponent(UwtDiagnosticsDialogComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('should open empty, with three sections and an explanation', () => { + expect(titles()).toEqual([ + 'BI telemetry · no events yet', + 'Scenario telemetry · no events yet', + 'Logging · no events yet' + ]); + expect(text()).toContain('Capturing from now.'); + expect(text()).toContain('No BI events since this window opened.'); + }); + + it('should show events live, in the right section, with the right columns', () => { + monitor.publish(bi('theme_changed')); + monitor.publish(scenario); + monitor.publish(warn); + settle(); + + expect(bodyRows()).toBe(3); + expect(titles()[0]).toMatch(/^BI telemetry · 1 · last \d\d:\d\d:\d\d$/); + expect(text()).toContain('theme_changed'); + expect(text()).toContain('theme=dark'); + expect(text()).toContain('load-dashboard'); + expect(text()).toContain('42 ms'); + expect(text()).toContain('Careful now'); + expect(text()).toContain('3 events'); + }); + + it('should mark the newest rows as new', () => { + monitor.publish(bi('a')); + settle(); + expect( + el().querySelector('td.uwt-diagnostics-section__time cps-chip') + ).not.toBeNull(); + }); + + it('should show the full payload when a row is expanded', () => { + monitor.publish(bi('theme_changed')); + settle(); + + ( + el().querySelector('button[aria-label="Expand row"]') as HTMLElement + ).click(); + fixture.detectChanges(); + + const pre = el().querySelector('pre'); + expect(pre?.getAttribute('aria-label')).toMatch(/^Payload of event #\d+$/); + expect(JSON.parse(pre?.textContent ?? '')).toMatchObject({ + eventName: 'theme_changed', + metadata: { theme: 'dark' } + }); + expect(text()).toContain('telemetry sink'); + }); + + it('should filter one section without touching the others', () => { + monitor.publish(bi('theme_changed', 'dark')); + monitor.publish(bi('sidebar_toggled', 'light')); + monitor.publish(warn); + settle(); + + const search = el().querySelector( + 'input[aria-label="Search BI telemetry events"]' + ) as HTMLInputElement; + search.value = 'dark'; + search.dispatchEvent(new Event('input')); + jest.advanceTimersByTime(200); + fixture.detectChanges(); + + expect(titles()[0]).toMatch(/^BI telemetry · 1 of 2/); + // Logging has its own filters, still empty. + expect(titles()[2]).toMatch(/^Logging · 1 · /); + expect(text()).toContain('Careful now'); + }); + + it('should give each section its own search field', () => { + const labels = Array.from( + el().querySelectorAll('.uwt-diagnostics-search input') + ).map((i) => i.getAttribute('aria-label')); + expect(labels).toEqual([ + 'Search BI telemetry events', + 'Search Scenario telemetry events', + 'Search Logging events' + ]); + }); + + it('should clear history and keep capturing', () => { + monitor.publish(bi('a')); + settle(); + + Array.from(el().querySelectorAll('button')) + .find((b) => b.textContent?.trim() === 'Clear') + ?.click(); + fixture.detectChanges(); + expect(bodyRows()).toBe(0); + expect(announce).toHaveBeenCalledWith( + 'History cleared. Capturing continues.', + 'polite' + ); + + monitor.publish(bi('b')); + settle(); + expect(bodyRows()).toBe(1); + }); + + it('should hold events back while paused, then show them on resume', () => { + monitor.publish(bi('a')); + settle(); + + ui().setPaused(true); + monitor.publish(bi('b')); + settle(); + expect(bodyRows()).toBe(1); + expect(text()).toContain('1 new event held back while paused.'); + + ui().setPaused(false); + fixture.detectChanges(); + expect(bodyRows()).toBe(2); + }); + + it("should download a section's own events, whatever its filters", () => { + const download = jest + .spyOn(exportUtil, 'uwtDownloadJson') + .mockImplementation(() => undefined); + monitor.publish(bi('a')); + monitor.publish(bi('b')); + monitor.publish(warn); + settle(); + // A BI filter hiding one event must not remove it from BI's download. + fixture.debugElement.injector + .get(UwtDiagnosticsStore) + .setText('bi', 'nothing matches this'); + fixture.detectChanges(); + + button('Download BI telemetry').click(); + + expect(download).toHaveBeenCalledTimes(1); + const [, filename, json] = download.mock.calls[0]; + expect(filename).toMatch( + /^ngx-ui-watchtower-diagnostics-app-bi-\d{8}-\d{6}\.json$/ + ); + const doc = JSON.parse(json); + expect(doc).toMatchObject({ + format: 'ngx-ui-watchtower-diagnostics', + section: 'bi', + app: { application: 'app' }, + count: { captured: 2, droppedOldest: 0 }, + activeFilters: { text: 'nothing matches this' } + }); + expect(doc.events.map((e: { kind: string }) => e.kind)).toEqual([ + 'bi', + 'bi' + ]); + + button('Download Logging').click(); + const logging = JSON.parse(download.mock.calls[1][2]); + expect(logging.section).toBe('logging'); + expect(logging.events.map((e: { kind: string }) => e.kind)).toEqual([ + 'log' + ]); + }); + + it("should copy a section's own events as JSON", async () => { + const writeText = jest.fn((_text: string) => Promise.resolve()); + Object.assign(navigator, { clipboard: { writeText } }); + monitor.publish(bi('a')); + monitor.publish(warn); + settle(); + + button('Copy Logging').click(); + await Promise.resolve(); + + const doc = JSON.parse(writeText.mock.calls[0][0]); + expect(doc.section).toBe('logging'); + expect(doc.events).toHaveLength(1); + }); + + it('should announce new arrivals politely, at most every ten seconds', () => { + monitor.publish(bi('a')); + monitor.publish(bi('b')); + monitor.publish(warn); + settle(); + expect(announce).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(10_000); + expect(announce).toHaveBeenCalledWith( + '3 new events: 2 BI, 1 log.', + 'polite' + ); + + announce.mockClear(); + jest.advanceTimersByTime(10_000); + expect(announce).not.toHaveBeenCalled(); // nothing new + }); + + it('should produce no telemetry of its own while being used', () => { + const seen: unknown[] = []; + // Subscribe as a second observer, alongside the popup's own store. + monitor.events$.subscribe((e) => seen.push(e)); + + ui().clear(); + ui().setPaused(true); + ui().setPaused(false); + jest + .spyOn(exportUtil, 'uwtDownloadJson') + .mockImplementation(() => undefined); + button('Download BI telemetry').click(); + button('Download Logging').click(); + settle(); + + expect(seen).toHaveLength(0); + }); + + it('should stop observing when closed', () => { + expect(monitor.observed).toBe(true); + fixture.destroy(); + expect(monitor.observed).toBe(false); + }); +}); diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.ts new file mode 100644 index 0000000..7d8a240 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.ts @@ -0,0 +1,154 @@ +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + ElementRef, + inject +} from '@angular/core'; +import { + CPS_LIVE_ANNOUNCER_SERVICE, + CpsButtonComponent, + CpsSwitchComponent +} from 'cps-ui-kit'; +import { UWT_TELEMETRY_DIAGNOSTICS_CONFIG } from '../uwt-diagnostics.models/uwt-diagnostics.models'; +import { + UWT_DEFAULT_DIAGNOSTICS_CONFIG, + UWT_DIAGNOSTICS_SECTIONS, + UwtDiagnosticsStore +} from '../uwt-diagnostics-store/uwt-diagnostics-store'; +import { UwtDiagnosticsSectionComponent } from '../uwt-diagnostics-section/uwt-diagnostics-section.component'; +import { + UWT_DIAGNOSTICS_SECTION_NOUN, + UwtDiagnosticsExporter +} from '../uwt-diagnostics-export/uwt-diagnostics-exporter'; +import { uwtDiagnosticsTime } from '../uwt-diagnostics-section/uwt-diagnostics-columns'; +import { + UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS, + uwtIsApplePlatform, + uwtShortcutLabel +} from '../uwt-diagnostics-shortcut/uwt-diagnostics-shortcut'; + +/** Screen readers hear about new events at most this often, in ms. */ +const ANNOUNCE_EVERY_MS = 10_000; + +/** + * The diagnostics popup's content, opened by + * {@link UwtTelemetryDiagnosticsService} inside a non-modal cps-dialog. + * Capturing is controlled here — pause and clear apply to everything — + * while each section filters, downloads and copies its own events. + * + * Owns its {@link UwtDiagnosticsStore}, so the captured history lives + * exactly as long as this window: it starts empty and is discarded on + * close. Uses no telemetry service itself, so opening or using it never + * produces events of its own. + */ +@Component({ + selector: 'uwt-diagnostics-dialog', + imports: [ + CpsButtonComponent, + CpsSwitchComponent, + UwtDiagnosticsSectionComponent + ], + providers: [UwtDiagnosticsStore, UwtDiagnosticsExporter], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './uwt-diagnostics-dialog.component.html', + styleUrl: './uwt-diagnostics-dialog.component.scss' +}) +export class UwtDiagnosticsDialogComponent { + protected readonly store = inject(UwtDiagnosticsStore); + + private readonly document = inject(DOCUMENT); + private readonly host: ElementRef = inject(ElementRef); + private readonly announcer = inject(CPS_LIVE_ANNOUNCER_SERVICE, { + optional: true + }); + + private readonly config = { + ...UWT_DEFAULT_DIAGNOSTICS_CONFIG, + shortcuts: UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS, + ...inject(UWT_TELEMETRY_DIAGNOSTICS_CONFIG, { optional: true }) + }; + + protected readonly sections = UWT_DIAGNOSTICS_SECTIONS; + protected readonly maxPayloadChars = this.config.maxPayloadCharsInView; + + protected readonly since = computed(() => + uwtDiagnosticsTime(this.store.startedAt()).slice(0, 8) + ); + + protected readonly shortcut = uwtShortcutLabel( + this.config.shortcuts, + uwtIsApplePlatform(this.document.defaultView?.navigator) + ); + + private lastAnnounced = { bi: 0, scenario: 0, logging: 0 }; + + constructor() { + const timer = setInterval(() => this.announceNew(), ANNOUNCE_EVERY_MS); + inject(DestroyRef).onDestroy(() => clearInterval(timer)); + } + + /** Whether keyboard focus is anywhere in the popup, header included. */ + containsFocus(): boolean { + const dialog = this.dialogElement(); + return !!dialog && dialog.contains(this.document.activeElement); + } + + /** Moves focus to the search field. */ + focusSearch(): void { + const input = this.host.nativeElement.querySelector( + '.uwt-diagnostics-search input' + ); + (input ?? this.dialogElement())?.focus(); + } + + protected setPaused(paused: boolean): void { + this.store.setPaused(paused); + this.announce( + paused + ? 'Live updates paused. Events are still captured.' + : 'Live updates resumed.' + ); + } + + protected clear(): void { + this.store.clear(); + this.lastAnnounced = { bi: 0, scenario: 0, logging: 0 }; + this.announce('History cleared. Capturing continues.'); + } + + /** One polite summary of what arrived since the last one, if anything. */ + private announceNew(): void { + if (this.store.paused()) { + return; + } + const received = this.store.received(); + const parts = UWT_DIAGNOSTICS_SECTIONS.map((id) => ({ + id, + n: received[id] - this.lastAnnounced[id] + })).filter((p) => p.n > 0); + this.lastAnnounced = { ...received }; + if (parts.length === 0) { + return; + } + const total = parts.reduce((sum, p) => sum + p.n, 0); + const detail = parts + .map((p) => `${p.n} ${UWT_DIAGNOSTICS_SECTION_NOUN[p.id]}`) + .join(', '); + this.announce(`${total} new event${total === 1 ? '' : 's'}: ${detail}.`); + } + + private announce(message: string): void { + try { + this.announcer?.announce(message, 'polite'); + } catch { + // Announcements are a courtesy; never let one break the popup. + } + } + + private dialogElement(): HTMLElement | null { + return this.host.nativeElement.closest('[role="dialog"]'); + } +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-export.spec.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-export.spec.ts new file mode 100644 index 0000000..8fe39b2 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-export.spec.ts @@ -0,0 +1,140 @@ +import { UwtTelemetryObservedEvent } from '@absaoss-cps/ngx-ui-watchtower'; +import { + uwtBuildDiagnosticsExport, + uwtDiagnosticsFilename, + uwtDownloadJson, + uwtSafeStringify +} from './uwt-diagnostics-export'; + +function bi(sequence: number): UwtTelemetryObservedEvent { + return { + kind: 'bi', + eventType: 'com.uwt.bi', + sequence, + capturedAt: '2026-01-01T10:00:00.000Z', + destination: 'sink', + origin: { forwarded: false }, + payload: { + eventName: `e${sequence}`, + eventTime: '2026-01-01T10:00:00.000Z', + application: 'app', + metadata: { nested: 'kept' } + } + }; +} + +const context = { + section: 'bi' as const, + startedAt: '2026-01-01T09:00:00.000Z', + paused: false, + app: { + application: 'app', + environment: 'test', + version: '1.0.0', + eventNamespace: 'com.uwt' + }, + session: { sessionId: 's-1' }, + userAgent: 'jest', + activeFilters: { + text: 'e2', + fields: [ + { id: '1', path: 'kind', operator: 'equals' as const, value: 'bi' } + ] + }, + count: { captured: 3, droppedOldest: 0 }, + now: new Date('2026-01-01T10:05:00.000Z') +}; + +describe('uwtBuildDiagnosticsExport', () => { + it("should contain every one of the section's events in hand-off order, whatever the filters", () => { + const doc = uwtBuildDiagnosticsExport([bi(3), bi(1), bi(2)], context); + + expect(doc.events.map((e) => e.sequence)).toEqual([1, 2, 3]); + // Filters are recorded for context, not applied. + expect(doc.activeFilters.text).toBe('e2'); + }); + + it('should carry the format, capture window and app identity', () => { + const doc = uwtBuildDiagnosticsExport([bi(1)], context); + + expect(doc).toMatchObject({ + format: 'ngx-ui-watchtower-diagnostics', + formatVersion: 1, + section: 'bi', + exportedAt: '2026-01-01T10:05:00.000Z', + capture: { + startedAt: '2026-01-01T09:00:00.000Z', + endedAt: '2026-01-01T10:05:00.000Z', + paused: false + }, + app: { application: 'app', eventNamespace: 'com.uwt' }, + session: { sessionId: 's-1' }, + count: { captured: 3, droppedOldest: 0 } + }); + }); + + it('should round-trip as valid JSON with nested payloads intact', () => { + const json = uwtSafeStringify( + uwtBuildDiagnosticsExport([bi(1)], context), + 2 + ); + const parsed = JSON.parse(json); + expect(parsed.events[0].payload.metadata.nested).toBe('kept'); + }); +}); + +describe('uwtSafeStringify', () => { + it('should replace a cycle instead of throwing', () => { + const value: Record = { a: 1 }; + value.self = value; + expect(JSON.parse(uwtSafeStringify(value))).toEqual({ + a: 1, + self: '[Circular]' + }); + }); + + it('should keep an object that is shared, but not circular', () => { + const shared = { x: 1 }; + expect(JSON.parse(uwtSafeStringify({ a: shared, b: shared }))).toEqual({ + a: { x: 1 }, + b: { x: 1 } + }); + }); + + it('should write a bigint as text', () => { + expect(uwtSafeStringify({ n: BigInt(12) })).toBe('{"n":"12"}'); + }); +}); + +describe('uwtDiagnosticsFilename', () => { + it('should name the app, section and time, and nothing identifying', () => { + expect( + uwtDiagnosticsFilename( + 'my app/x', + 'logging', + new Date(2026, 0, 2, 3, 4, 5) + ) + ).toBe( + 'ngx-ui-watchtower-diagnostics-my-app-x-logging-20260102-030405.json' + ); + }); +}); + +describe('uwtDownloadJson', () => { + it('should click a temporary link to a JSON blob, then remove it', () => { + const created = jest.fn(() => 'blob:x'); + Object.assign(URL, { + createObjectURL: created, + revokeObjectURL: jest.fn() + }); + const click = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => undefined); + + uwtDownloadJson(document, 'f.json', '{}'); + + expect(created).toHaveBeenCalledWith(expect.any(Blob)); + expect(click).toHaveBeenCalledTimes(1); + expect(document.querySelector('a[download]')).toBeNull(); + }); +}); diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-export.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-export.ts new file mode 100644 index 0000000..97baa7b --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-export.ts @@ -0,0 +1,119 @@ +import { UwtTelemetryObservedEvent } from '@absaoss-cps/ngx-ui-watchtower'; +import { + UwtDiagnosticsExport, + UwtDiagnosticsFilterState, + UwtDiagnosticsSectionId +} from '../uwt-diagnostics.models/uwt-diagnostics.models'; + +/** What the export needs to know beyond the events themselves. */ +export interface UwtDiagnosticsExportContext { + section: UwtDiagnosticsSectionId; + startedAt: string; + paused: boolean; + app: UwtDiagnosticsExport['app']; + session: UwtDiagnosticsExport['session']; + userAgent: string; + activeFilters: UwtDiagnosticsFilterState; + count: UwtDiagnosticsExport['count']; + now?: Date; +} + +/** + * One section's downloadable document: every event captured in it, in the + * order they were handed over, regardless of the filters on screen. + */ +export function uwtBuildDiagnosticsExport( + events: readonly UwtTelemetryObservedEvent[], + context: UwtDiagnosticsExportContext +): UwtDiagnosticsExport { + const now = (context.now ?? new Date()).toISOString(); + return { + format: 'ngx-ui-watchtower-diagnostics', + formatVersion: 1, + section: context.section, + exportedAt: now, + capture: { + startedAt: context.startedAt, + endedAt: now, + paused: context.paused + }, + app: context.app, + session: context.session, + userAgent: context.userAgent, + activeFilters: context.activeFilters, + count: context.count, + events: [...events].sort((a, b) => a.sequence - b.sequence) + }; +} + +/** + * `JSON.stringify` that never throws on a payload: a repeated reference + * becomes `"[Circular]"`, and a `bigint` becomes its decimal text. + */ +export function uwtSafeStringify(value: unknown, indent?: number): string { + const ancestors: object[] = []; + return JSON.stringify( + value, + function (this: unknown, _key: string, v: unknown) { + if (typeof v === 'bigint') { + return v.toString(); + } + if (v === null || typeof v !== 'object') { + return v; + } + // `this` is the object holding v: unwind to it, then check for a loop. + while (ancestors.length && ancestors[ancestors.length - 1] !== this) { + ancestors.pop(); + } + if (ancestors.includes(v)) { + return '[Circular]'; + } + ancestors.push(v); + return v; + }, + indent + ); +} + +/** + * `ngx-ui-watchtower-diagnostics--
-.json` + * — no session or user id, since the name ends up in folders and tickets. + */ +export function uwtDiagnosticsFilename( + application: string, + section: UwtDiagnosticsSectionId, + at: Date +): string { + const pad = (n: number) => String(n).padStart(2, '0'); + const stamp = + `${at.getFullYear()}${pad(at.getMonth() + 1)}${pad(at.getDate())}-` + + `${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}`; + const app = application.replace(/[^a-z0-9._-]+/gi, '-') || 'app'; + return `ngx-ui-watchtower-diagnostics-${app}-${section}-${stamp}.json`; +} + +/** + * Saves `json` as a file through a temporary link. The browser may still + * block it without telling the page, so the popup also offers copying. + */ +export function uwtDownloadJson( + doc: Document, + filename: string, + json: string +): void { + const url = URL.createObjectURL( + new Blob([json], { type: 'application/json' }) + ); + const link = doc.createElement('a'); + link.href = url; + link.download = filename; + link.style.display = 'none'; + doc.body.appendChild(link); + try { + link.click(); + } finally { + link.remove(); + // Revoked after the click has been handed to the browser. + setTimeout(() => URL.revokeObjectURL(url), 0); + } +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-exporter.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-exporter.ts new file mode 100644 index 0000000..b8f6a0e --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-exporter.ts @@ -0,0 +1,137 @@ +import { DOCUMENT } from '@angular/common'; +import { inject, Injectable } from '@angular/core'; +import { + UWT_DEFAULT_EVENT_NAMESPACE, + UWT_TELEMETRY_IDENTITY, + UwtTelemetrySink +} from '@absaoss-cps/ngx-ui-watchtower'; +import { CPS_LIVE_ANNOUNCER_SERVICE, CpsNotificationService } from 'cps-ui-kit'; +import { UwtDiagnosticsSectionId } from '../uwt-diagnostics.models/uwt-diagnostics.models'; +import { UwtDiagnosticsStore } from '../uwt-diagnostics-store/uwt-diagnostics-store'; +import { + uwtBuildDiagnosticsExport, + uwtDiagnosticsFilename, + uwtDownloadJson, + uwtSafeStringify +} from './uwt-diagnostics-export'; + +/** What each section calls its events, when telling people about them. */ +export const UWT_DIAGNOSTICS_SECTION_NOUN: Record< + UwtDiagnosticsSectionId, + string +> = { bi: 'BI', scenario: 'scenario', logging: 'log' }; + +/** + * Downloads or copies one section's captured events as JSON. Provided by + * the popup, beside its {@link UwtDiagnosticsStore}. + */ +@Injectable() +export class UwtDiagnosticsExporter { + private readonly store = inject(UwtDiagnosticsStore); + private readonly identity = inject(UWT_TELEMETRY_IDENTITY, { + optional: true + }); + + private readonly sink = inject(UwtTelemetrySink, { optional: true }); + private readonly document = inject(DOCUMENT); + private readonly announcer = inject(CPS_LIVE_ANNOUNCER_SERVICE, { + optional: true + }); + + private readonly notifications = inject(CpsNotificationService, { + optional: true + }); + + /** Saves the section's events as a JSON file. */ + download(section: UwtDiagnosticsSectionId): void { + try { + const json = this.json(section); + uwtDownloadJson( + this.document, + uwtDiagnosticsFilename( + this.identity?.application ?? 'app', + section, + new Date() + ), + json + ); + this.announce( + `Downloaded ${this.store.sectionCount(section).captured} ${ + UWT_DIAGNOSTICS_SECTION_NOUN[section] + } events.` + ); + } catch (error) { + this.fail("Couldn't create the download", error); + } + } + + /** + * Puts the section's events on the clipboard, for when a browser blocks + * downloads. + * + * @returns whether copying worked + */ + async copy(section: UwtDiagnosticsSectionId): Promise { + try { + await navigator.clipboard.writeText(this.json(section)); + this.announce( + `${UWT_DIAGNOSTICS_SECTION_NOUN[section]} events copied as JSON.` + ); + return true; + } catch (error) { + this.fail("Couldn't copy to the clipboard", error); + return false; + } + } + + /** The section's export document, serialized. */ + json(section: UwtDiagnosticsSectionId): string { + return uwtSafeStringify( + uwtBuildDiagnosticsExport(this.store.sectionEvents(section), { + section, + startedAt: this.store.startedAt(), + paused: this.store.paused(), + app: { + application: this.identity?.application ?? '', + environment: this.identity?.environment ?? '', + version: this.identity?.version ?? '', + eventNamespace: + this.identity?.eventNamespace ?? UWT_DEFAULT_EVENT_NAMESPACE + }, + session: this.session(), + userAgent: this.document.defaultView?.navigator.userAgent ?? '', + activeFilters: this.store.filters()[section], + count: this.store.sectionCount(section) + }), + 2 + ); + } + + private session(): { sessionId?: string; userId?: string } { + try { + return { + sessionId: this.sink?.getSessionId(), + userId: this.sink?.getUserId() + }; + } catch { + return {}; + } + } + + private announce(message: string): void { + try { + this.announcer?.announce(message, 'polite'); + } catch { + // Announcements are a courtesy; never let one break the popup. + } + } + + private fail(message: string, error: unknown): void { + const detail = error instanceof Error ? error.message : String(error); + try { + this.notifications?.error(message, detail); + } catch { + // Nothing more to do; the popup keeps working. + } + } +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.html b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.html new file mode 100644 index 0000000..e765ec1 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.html @@ -0,0 +1,97 @@ +
+
+ + + + + + +
+ + @if (builderOpen()) { +
+ + + + + @if (needsValue()) { + + + } + + +
+ } + + @if (chips().length > 0 || anyActive()) { +
+ Filters: +
    + @for (chip of chips(); track chip.id) { +
  • + + +
  • + } +
+ + +
+ } +
diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.scss b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.scss new file mode 100644 index 0000000..11a4ef0 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.scss @@ -0,0 +1,52 @@ +:host { + display: block; +} + +.uwt-diagnostics-filter-bar { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.uwt-diagnostics-filter-bar__row, +.uwt-diagnostics-filter-bar__builder, +.uwt-diagnostics-filter-bar__active { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.5rem; +} + +.uwt-diagnostics-filter-bar__active { + align-items: center; +} + +.uwt-diagnostics-filter-bar__search { + flex: 1 1 18rem; +} + +.uwt-diagnostics-filter-bar__path { + flex: 2 1 14rem; +} + +.uwt-diagnostics-filter-bar__operator { + flex: 1 1 9rem; +} + +.uwt-diagnostics-filter-bar__value { + flex: 2 1 12rem; +} + +.uwt-diagnostics-filter-bar__label { + font-size: 0.8125rem; + color: var(--cps-text-secondary); +} + +.uwt-diagnostics-filter-bar__chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin: 0; + padding: 0; + list-style: none; +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.spec.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.spec.ts new file mode 100644 index 0000000..ba09957 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.spec.ts @@ -0,0 +1,98 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { UwtDiagnosticsStore } from '../uwt-diagnostics-store/uwt-diagnostics-store'; +import { UwtDiagnosticsFilterBarComponent } from './uwt-diagnostics-filter-bar.component'; + +describe('UwtDiagnosticsFilterBarComponent', () => { + let fixture: ComponentFixture; + let store: UwtDiagnosticsStore; + const bar = () => + fixture.componentInstance as unknown as { + builderOpen: { set(v: boolean): void }; + onPath(v: unknown): void; + onOperator(v: unknown): void; + value: { set(v: string): void }; + add(): void; + canAdd(): boolean; + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UwtDiagnosticsFilterBarComponent, NoopAnimationsModule], + providers: [UwtDiagnosticsStore] + }).compileComponents(); + store = TestBed.inject(UwtDiagnosticsStore); + fixture = TestBed.createComponent(UwtDiagnosticsFilterBarComponent); + fixture.componentRef.setInput('section', 'logging'); + fixture.componentRef.setInput('sectionName', 'Logging'); + fixture.detectChanges(); + }); + + it('should need a field, and a value unless checking presence', () => { + bar().builderOpen.set(true); + fixture.detectChanges(); + expect(bar().canAdd()).toBe(false); + + bar().onPath('metadata.theme'); + expect(bar().canAdd()).toBe(false); + bar().value.set('dark'); + expect(bar().canAdd()).toBe(true); + + bar().value.set(''); + bar().onOperator('exists'); + expect(bar().canAdd()).toBe(true); + }); + + it('should name its controls after the section', () => { + const el = fixture.nativeElement as HTMLElement; + expect( + el + .querySelector('.uwt-diagnostics-search input') + ?.getAttribute('aria-label') + ).toBe('Search Logging events'); + }); + + it('should add a filter as a removable chip with a spoken name', () => { + bar().onPath('metadata.theme'); + bar().onOperator('equals'); + bar().value.set('dark'); + bar().add(); + fixture.detectChanges(); + + // Written to this bar's own section only. + expect(store.filters().bi.fields).toHaveLength(0); + expect(store.filters().logging.fields).toEqual([ + expect.objectContaining({ + path: 'metadata.theme', + operator: 'equals', + value: 'dark' + }) + ]); + const close = fixture.nativeElement.querySelector( + 'cps-chip button' + ) as HTMLButtonElement; + expect(close.getAttribute('aria-label')).toBe( + 'Remove filter metadata.theme equals "dark"' + ); + + close.click(); + fixture.detectChanges(); + expect(store.filters().logging.fields).toHaveLength(0); + }); + + it('should clear the text and every filter together', () => { + store.setText('logging', 'x'); + store.addFilter('logging', { path: 'kind', operator: 'exists', value: '' }); + store.setText('bi', 'kept'); + fixture.detectChanges(); + + const clearBtn = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll('button') + ).find((b) => b.textContent?.trim() === 'Clear filters'); + clearBtn?.click(); + + expect(store.filters().logging).toEqual({ text: '', fields: [] }); + // Another section's filters are left alone. + expect(store.filters().bi.text).toBe('kept'); + }); +}); diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.ts new file mode 100644 index 0000000..105e967 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.ts @@ -0,0 +1,142 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + inject, + input, + signal +} from '@angular/core'; +import { + CpsAutocompleteComponent, + CpsButtonComponent, + CpsChipComponent, + CpsInfoCircleComponent, + CpsInputComponent, + CpsSelectComponent +} from 'cps-ui-kit'; +import { + UwtDiagnosticsFilterOperator, + UwtDiagnosticsSectionId +} from '../uwt-diagnostics.models/uwt-diagnostics.models'; +import { UwtDiagnosticsStore } from '../uwt-diagnostics-store/uwt-diagnostics-store'; +import { + uwtDescribeFilter, + uwtOperatorNeedsValue +} from '../uwt-diagnostics-filter/uwt-diagnostics-filter'; + +/** Typing settles for this long before filtering runs, in milliseconds. */ +const SEARCH_DEBOUNCE_MS = 150; + +/** + * One section's free-text search, field-filter builder and active filters. + * Each section filters on its own; nothing here affects the others. + */ +@Component({ + selector: 'uwt-diagnostics-filter-bar', + imports: [ + CpsInputComponent, + CpsAutocompleteComponent, + CpsSelectComponent, + CpsButtonComponent, + CpsChipComponent, + CpsInfoCircleComponent + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './uwt-diagnostics-filter-bar.component.html', + styleUrl: './uwt-diagnostics-filter-bar.component.scss' +}) +export class UwtDiagnosticsFilterBarComponent { + protected readonly store = inject(UwtDiagnosticsStore); + + /** The section these filters belong to. */ + readonly section = input.required(); + /** The section's name as people read it, e.g. `BI telemetry`. */ + readonly sectionName = input.required(); + + protected readonly filters = computed( + () => this.store.filters()[this.section()] + ); + + protected readonly operators: { + label: string; + value: UwtDiagnosticsFilterOperator; + }[] = [ + { label: 'contains', value: 'contains' }, + { label: 'equals', value: 'equals' }, + { label: 'does not contain', value: 'not-contains' }, + { label: 'exists', value: 'exists' }, + { label: 'is missing', value: 'missing' } + ]; + + protected readonly builderOpen = signal(false); + protected readonly path = signal(''); + protected readonly operator = + signal('contains'); + + protected readonly value = signal(''); + + protected readonly pathOptions = computed(() => + this.store.knownPaths()[this.section()].map((p) => ({ label: p, value: p })) + ); + + protected readonly needsValue = computed(() => + uwtOperatorNeedsValue(this.operator()) + ); + + protected readonly canAdd = computed( + () => !!this.path().trim() && (!this.needsValue() || !!this.value().trim()) + ); + + protected readonly chips = computed(() => + this.filters().fields.map((f) => ({ + id: f.id, + text: uwtDescribeFilter(f) + })) + ); + + protected readonly anyActive = computed(() => { + const f = this.filters(); + return !!f.text.trim() || f.fields.length > 0; + }); + + private searchTimer?: ReturnType; + + constructor() { + inject(DestroyRef).onDestroy(() => clearTimeout(this.searchTimer)); + } + + protected onSearch(text: string): void { + clearTimeout(this.searchTimer); + this.searchTimer = setTimeout( + () => this.store.setText(this.section(), text ?? ''), + SEARCH_DEBOUNCE_MS + ); + } + + protected onPath(value: unknown): void { + this.path.set(typeof value === 'string' ? value : ''); + } + + protected onOperator(value: unknown): void { + const match = this.operators.find((o) => o.value === value); + this.operator.set(match?.value ?? 'contains'); + } + + protected add(): void { + if (!this.canAdd()) { + return; + } + this.store.addFilter(this.section(), { + path: this.path().trim(), + operator: this.operator(), + value: this.needsValue() ? this.value().trim() : '' + }); + this.value.set(''); + } + + protected clearAll(): void { + clearTimeout(this.searchTimer); + this.store.clearFilters(this.section()); + } +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter/uwt-diagnostics-filter.spec.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter/uwt-diagnostics-filter.spec.ts new file mode 100644 index 0000000..af026f7 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter/uwt-diagnostics-filter.spec.ts @@ -0,0 +1,234 @@ +import { UwtTelemetryObservedEvent } from '@absaoss-cps/ngx-ui-watchtower'; +import { + UwtDiagnosticsFieldFilter, + UwtDiagnosticsFilterState +} from '../uwt-diagnostics.models/uwt-diagnostics.models'; +import { + uwtDescribeFilter, + uwtDiagnosticsSectionOf, + uwtFlattenEvent, + uwtKnownPaths, + uwtMatchesFilters +} from './uwt-diagnostics-filter'; + +const base = { + sequence: 7, + capturedAt: '2026-01-01T10:00:00.000Z', + destination: 'sink' as const, + origin: { forwarded: false as const } +}; + +const scenario: UwtTelemetryObservedEvent = { + ...base, + kind: 'scenario', + eventType: 'com.uwt.scenario', + payload: { + scenarioId: 'abc-123', + scenarioName: 'load-dashboard', + status: 'failure', + startTime: '2026-01-01T09:59:59.000Z', + delta: 812, + elapsed: 5000, + stepCount: 2, + steps: [ + { name: 'scenario-start', startOffset: 0 }, + { name: 'fetch', startOffset: 10, status: 'success' }, + { name: 'render', startOffset: 400, status: 'failure' } + ], + metadata: { theme: 'Dark', retry: null }, + application: 'app' + } +}; + +function state( + text = '', + fields: Partial[] = [] +): UwtDiagnosticsFilterState { + return { + text, + fields: fields.map((f, i) => ({ + id: String(i), + path: '', + operator: 'contains', + value: '', + ...f + })) + }; +} + +describe('uwtFlattenEvent', () => { + const fields = uwtFlattenEvent(scenario); + const byPath = (p: string) => fields.find((f) => f.path === p); + + it('should expose nested payload fields at their own paths', () => { + expect(byPath('metadata.theme')?.value).toBe('Dark'); + expect(byPath('scenarioName')?.value).toBe('load-dashboard'); + }); + + it('should index array items, with an index-free pattern', () => { + expect(byPath('steps[2].name')).toEqual({ + path: 'steps[2].name', + pattern: 'steps[].name', + value: 'render' + }); + }); + + it('should include the envelope', () => { + expect(byPath('kind')?.value).toBe('scenario'); + expect(byPath('eventType')?.value).toBe('com.uwt.scenario'); + expect(byPath('origin.forwarded')?.value).toBe('false'); + expect(byPath('sequence')?.value).toBe('7'); + }); + + it('should keep null as text, and address empty containers', () => { + expect(byPath('metadata.retry')?.value).toBe('null'); + const empty = uwtFlattenEvent({ + ...base, + kind: 'bi', + eventType: 'com.uwt.bi', + payload: { tags: [], extra: {} } + }); + expect(empty.map((f) => f.path)).toEqual( + expect.arrayContaining(['tags', 'extra']) + ); + }); + + it('should survive a circular payload', () => { + const payload: Record = { a: 1 }; + payload.self = payload; + const event = { + ...base, + kind: 'unknown', + eventType: 'x', + payload + } as unknown as UwtTelemetryObservedEvent; + + const result = uwtFlattenEvent(event); + expect(result.find((f) => f.path === 'self')?.value).toBe('[Circular]'); + }); + + it('should cap the number of fields for a huge payload', () => { + const payload = Object.fromEntries( + Array.from({ length: 5000 }, (_, i) => [`k${i}`, i]) + ); + const result = uwtFlattenEvent({ + ...base, + kind: 'bi', + eventType: 'com.uwt.bi', + payload + }); + expect(result.length).toBeLessThanOrEqual(2000 + 10); + }); +}); + +describe('uwtMatchesFilters', () => { + const fields = uwtFlattenEvent(scenario); + const matches = (s: UwtDiagnosticsFilterState) => + uwtMatchesFilters(fields, s); + + it('should match everything with no filters', () => { + expect(matches(state())).toBe(true); + }); + + it('should match free text anywhere, ignoring case', () => { + expect(matches(state('DASHBOARD'))).toBe(true); + expect(matches(state('dark'))).toBe(true); + expect(matches(state('metadata.the'))).toBe(true); // path text too + expect(matches(state('nowhere'))).toBe(false); + }); + + it('should apply contains, equals and not-contains to one path', () => { + expect(matches(state('', [{ path: 'scenarioName', value: 'dash' }]))).toBe( + true + ); + expect( + matches( + state('', [{ path: 'scenarioName', operator: 'equals', value: 'dash' }]) + ) + ).toBe(false); + expect( + matches( + state('', [ + { path: 'status', operator: 'not-contains', value: 'success' } + ]) + ) + ).toBe(true); + }); + + it('should match any array item by pattern, or one item by index', () => { + expect( + matches(state('', [{ path: 'steps[].name', value: 'render' }])) + ).toBe(true); + expect( + matches(state('', [{ path: 'steps[1].name', value: 'render' }])) + ).toBe(false); + }); + + it('should treat exists and missing as presence checks', () => { + expect( + matches(state('', [{ path: 'metadata.theme', operator: 'exists' }])) + ).toBe(true); + expect( + matches(state('', [{ path: 'error.message', operator: 'missing' }])) + ).toBe(true); + }); + + it('should require every filter and the text together', () => { + expect( + matches( + state('dashboard', [ + { path: 'status', operator: 'equals', value: 'failure' }, + { path: 'metadata.theme', value: 'light' } + ]) + ) + ).toBe(false); + }); + + it('should find nothing for a path that does not exist', () => { + expect(matches(state('', [{ path: 'level', value: 'warn' }]))).toBe(false); + }); +}); + +describe('helpers', () => { + it('should put each kind in its section', () => { + expect(uwtDiagnosticsSectionOf('bi')).toBe('bi'); + expect(uwtDiagnosticsSectionOf('unknown')).toBe('bi'); + expect(uwtDiagnosticsSectionOf('scenario-step')).toBe('scenario'); + expect(uwtDiagnosticsSectionOf('error')).toBe('logging'); + }); + + it('should describe a filter as a short sentence', () => { + expect( + uwtDescribeFilter({ + id: '1', + path: 'level', + operator: 'not-contains', + value: 'log' + }) + ).toBe('level does not contain "log"'); + expect( + uwtDescribeFilter({ + id: '1', + path: 'error', + operator: 'exists', + value: '' + }) + ).toBe('error exists'); + }); + + it('should suggest envelope paths first, then the most common', () => { + const paths = uwtKnownPaths([ + uwtFlattenEvent(scenario), + uwtFlattenEvent(scenario) + ]); + expect(paths.slice(0, 5)).toEqual([ + 'kind', + 'eventType', + 'destination', + 'origin.application', + 'capturedAt' + ]); + expect(paths).toContain('steps[].name'); + expect(new Set(paths).size).toBe(paths.length); + }); +}); diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter/uwt-diagnostics-filter.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter/uwt-diagnostics-filter.ts new file mode 100644 index 0000000..3826694 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter/uwt-diagnostics-filter.ts @@ -0,0 +1,210 @@ +import { + UwtTelemetryEventKind, + UwtTelemetryObservedEvent +} from '@absaoss-cps/ngx-ui-watchtower'; +import { + UwtDiagnosticsField, + UwtDiagnosticsFieldFilter, + UwtDiagnosticsFilterOperator, + UwtDiagnosticsFilterState, + UwtDiagnosticsSectionId +} from '../uwt-diagnostics.models/uwt-diagnostics.models'; + +/** Deeper structures are cut off, so a pathological payload stays cheap. */ +const MAX_DEPTH = 12; +/** Leaves per event considered for filtering. */ +const MAX_FIELDS = 2000; +/** Characters of each value considered for matching. */ +const MAX_VALUE_CHARS = 2000; + +/** + * Which section an event is shown in. An event forwarded from another + * realm with an unrecognised type is shown with BI, alongside the other + * custom events. + */ +export function uwtDiagnosticsSectionOf( + kind: UwtTelemetryEventKind +): UwtDiagnosticsSectionId { + switch (kind) { + case 'scenario': + case 'scenario-step': + return 'scenario'; + case 'log': + case 'error': + return 'logging'; + default: + return 'bi'; + } +} + +/** + * Every leaf of an event as `path = value` text, done once when the event + * arrives so filtering never walks payloads again. + * + * Payload fields come first, at their own paths (`metadata.theme`, + * `steps[2].name`); the envelope follows (`kind`, `eventType`, + * `destination`, `origin.application`, `sequence`, `capturedAt`). Cycles, + * excessive depth and oversized strings are cut short rather than failing. + */ +export function uwtFlattenEvent( + event: UwtTelemetryObservedEvent +): UwtDiagnosticsField[] { + const fields: UwtDiagnosticsField[] = []; + const seen = new WeakSet(); + + const visit = (value: unknown, path: string, depth: number): void => { + if (fields.length >= MAX_FIELDS) { + return; + } + if (value === null || typeof value !== 'object') { + if (value !== undefined) { + push(fields, path, String(value)); + } + return; + } + if (seen.has(value)) { + push(fields, path, '[Circular]'); + return; + } + if (depth >= MAX_DEPTH) { + push(fields, path, '[Too deep]'); + return; + } + seen.add(value); + + const entries: [string, unknown][] = Array.isArray(value) + ? value.map((v, i) => [`[${i}]`, v]) + : Object.entries(value).map(([k, v]) => [path ? `.${k}` : k, v]); + + if (entries.length === 0) { + // Still addressable, so `exists` can find an empty object or array. + push(fields, path, ''); + return; + } + for (const [suffix, child] of entries) { + visit(child, path + suffix, depth + 1); + } + }; + + visit(event.payload, '', 0); + + const { payload: _payload, ...envelope } = event; + visit(envelope, '', 0); + return fields; +} + +function push(fields: UwtDiagnosticsField[], path: string, value: string) { + fields.push({ + path, + pattern: path.replace(/\[\d+\]/g, '[]'), + value: + value.length > MAX_VALUE_CHARS ? value.slice(0, MAX_VALUE_CHARS) : value + }); +} + +/** + * Whether an event's fields satisfy every active filter. + * + * - Free text: case-insensitive, matched against every value and path. + * - A field filter's path matches a field's exact path or its pattern, so + * `steps[].name` covers every step and `steps[2].name` only the third. + * With several matching fields, one is enough. + * - Everything is combined with AND. + */ +export function uwtMatchesFilters( + fields: readonly UwtDiagnosticsField[], + filters: UwtDiagnosticsFilterState +): boolean { + const text = filters.text.trim().toLowerCase(); + if ( + text && + !fields.some( + (f) => + f.value.toLowerCase().includes(text) || + f.path.toLowerCase().includes(text) + ) + ) { + return false; + } + return filters.fields.every((filter) => matchesField(fields, filter)); +} + +function matchesField( + fields: readonly UwtDiagnosticsField[], + filter: UwtDiagnosticsFieldFilter +): boolean { + const path = filter.path.trim().toLowerCase(); + const value = filter.value.trim().toLowerCase(); + const atPath = fields.filter( + (f) => f.path.toLowerCase() === path || f.pattern.toLowerCase() === path + ); + + switch (filter.operator) { + case 'exists': + return atPath.length > 0; + case 'missing': + return atPath.length === 0; + case 'equals': + return atPath.some((f) => f.value.toLowerCase() === value); + case 'not-contains': + return !atPath.some((f) => f.value.toLowerCase().includes(value)); + case 'contains': + default: + return atPath.some((f) => f.value.toLowerCase().includes(value)); + } +} + +const OPERATOR_TEXT: Record = { + contains: 'contains', + equals: 'equals', + 'not-contains': 'does not contain', + exists: 'exists', + missing: 'is missing' +}; + +/** A filter as a short sentence, for chips and screen readers. */ +export function uwtDescribeFilter(filter: UwtDiagnosticsFieldFilter): string { + const op = OPERATOR_TEXT[filter.operator]; + return filter.operator === 'exists' || filter.operator === 'missing' + ? `${filter.path} ${op}` + : `${filter.path} ${op} "${filter.value}"`; +} + +/** Whether an operator compares against a value. */ +export function uwtOperatorNeedsValue( + operator: UwtDiagnosticsFilterOperator +): boolean { + return operator !== 'exists' && operator !== 'missing'; +} + +/** + * Field paths to suggest, from everything captured so far: envelope fields + * first, then the rest by how often they occur. + */ +export function uwtKnownPaths( + fieldSets: readonly (readonly UwtDiagnosticsField[])[] +): string[] { + const envelope = [ + 'kind', + 'eventType', + 'destination', + 'origin.application', + 'capturedAt' + ]; + const counts = new Map(); + for (const fields of fieldSets) { + for (const f of new Set(fields.map((x) => x.pattern))) { + if ( + !envelope.includes(f) && + f !== 'sequence' && + f !== 'origin.forwarded' + ) { + counts.set(f, (counts.get(f) ?? 0) + 1); + } + } + } + const rest = [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([path]) => path); + return [...envelope, ...rest]; +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-internal.util/uwt-diagnostics-internal.util.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-internal.util/uwt-diagnostics-internal.util.ts new file mode 100644 index 0000000..141f770 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-internal.util/uwt-diagnostics-internal.util.ts @@ -0,0 +1,53 @@ +import { isPlatformBrowser } from '@angular/common'; +import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; + +/** + * A local copy of `@absaoss-cps/ngx-ui-watchtower`'s fail-open helpers — + * `uwtSafe`/`uwtSafeVoid`/`uwtIsBrowser`/`uwtIsDevMode`. + * + * Not imported from the main entry point for the same reason as + * `@absaoss-cps/ngx-ui-watchtower/rum`'s copy: ng-packagr fixes each entry point's `rootDir` + * to its own `src`, and the main entry keeps these internal on purpose. + * See `rum/src/lib/uwt-rum-internal.util`. + */ + +/** Runs `fn`, returning `fallback` instead of ever throwing. */ +export function uwtSafe(operation: string, fn: () => T, fallback: T): T { + try { + return fn(); + } catch (error) { + reportSuppressed(operation, error); + return fallback; + } +} + +/** Void-returning form of {@link uwtSafe}. */ +export function uwtSafeVoid(operation: string, fn: () => void): void { + uwtSafe(operation, fn, undefined); +} + +/** `isDevMode()` throws outside an Angular context; this never does. */ +export function uwtIsDevMode(): boolean { + try { + return isDevMode(); + } catch { + return false; + } +} + +/** Whether this runs in a browser. Needs an injection context. */ +export function uwtIsBrowser(): boolean { + return isPlatformBrowser(inject(PLATFORM_ID)); +} + +function reportSuppressed(operation: string, error: unknown): void { + if (!uwtIsDevMode()) { + return; + } + try { + // eslint-disable-next-line no-console + console.error(`[ngx-ui-watchtower] diagnostics.${operation} failed`, error); + } catch { + // A patched/throwing console must never escape suppression. + } +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.html b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.html new file mode 100644 index 0000000..f155d11 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.html @@ -0,0 +1,37 @@ +
+
+ + + {{ + copied() === 'copied' + ? 'Payload copied' + : copied() === 'failed' + ? 'Copying failed' + : '' + }} +
+ @if (truncated()) { +

+ Large payload: showing the first {{ shownChars }} characters. The download + contains all of it. +

+ } +
{{ shown() }}
+
diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.scss b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.scss new file mode 100644 index 0000000..09c41fb --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.scss @@ -0,0 +1,41 @@ +:host { + display: block; +} + +.uwt-diagnostics-json { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.uwt-diagnostics-json__actions { + display: flex; + justify-content: flex-end; +} + +.uwt-diagnostics-json__notice { + margin: 0; + font-size: 0.8125rem; + color: var(--cps-state-warn); +} + +.uwt-diagnostics-json__code { + margin: 0; + padding: 0.75rem; + max-height: 24rem; + overflow: auto; + background: var(--cps-surface-muted); + color: var(--cps-text-primary); + border: 1px solid var(--cps-border-color); + border-radius: var(--cps-radius-sm); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.75rem; + line-height: 1.5; + white-space: pre; + tab-size: 2; + + &:focus-visible { + outline: 2px solid var(--cps-ring-color); + outline-offset: 2px; + } +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.spec.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.spec.ts new file mode 100644 index 0000000..11c1afc --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.spec.ts @@ -0,0 +1,90 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { UwtDiagnosticsJsonComponent } from './uwt-diagnostics-json.component'; + +describe('UwtDiagnosticsJsonComponent', () => { + let fixture: ComponentFixture; + const pre = () => fixture.nativeElement.querySelector('pre') as HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UwtDiagnosticsJsonComponent, NoopAnimationsModule] + }).compileComponents(); + fixture = TestBed.createComponent(UwtDiagnosticsJsonComponent); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('should show the payload as indented JSON in a named, focusable region', () => { + fixture.componentRef.setInput('value', { a: { b: 1 } }); + fixture.componentRef.setInput('label', 'Payload of event #3'); + fixture.detectChanges(); + + expect(JSON.parse(pre().textContent ?? '')).toEqual({ a: { b: 1 } }); + expect(pre().textContent).toContain('\n "a"'); + expect(pre().getAttribute('aria-label')).toBe('Payload of event #3'); + expect(pre().getAttribute('tabindex')).toBe('0'); + }); + + it('should truncate a huge payload in view, and say so', () => { + fixture.componentRef.setInput('value', { big: 'x'.repeat(100_000) }); + fixture.componentRef.setInput('maxChars', 1000); + fixture.detectChanges(); + + expect(pre().textContent!.length).toBe(64 * 1024); + expect(fixture.nativeElement.textContent).toContain( + 'showing the first 65,536 characters' + ); + expect(fixture.nativeElement.textContent).toContain( + 'The download contains all of it.' + ); + }); + + it('should measure the limit in characters, not bytes', () => { + // 600 Cyrillic letters: about 600 characters of JSON, but 1,200+ bytes. + fixture.componentRef.setInput('value', { text: 'ж'.repeat(600) }); + fixture.componentRef.setInput('maxChars', 1000); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).not.toContain('Large payload'); + expect(pre().textContent).toContain('ж'.repeat(600)); + }); + + it('should not cut a truncated view through the middle of a surrogate pair', () => { + const before = '{\n "big": "'; + // Lines the first emoji up so the cut would fall inside it. + const padding = 'x'.repeat(64 * 1024 - 1 - before.length); + fixture.componentRef.setInput('value', { + big: padding + '😀'.repeat(10) + }); + fixture.componentRef.setInput('maxChars', 1000); + fixture.detectChanges(); + + const shown = pre().textContent!; + expect(shown.length).toBe(64 * 1024 - 1); + expect(shown.endsWith('x')).toBe(true); + }); + + it('should render a circular value instead of failing', () => { + const value: Record = { a: 1 }; + value.self = value; + fixture.componentRef.setInput('value', value); + fixture.detectChanges(); + expect(pre().textContent).toContain('"[Circular]"'); + }); + + it('should copy the full JSON', async () => { + const writeText = jest.fn(() => Promise.resolve()); + Object.assign(navigator, { clipboard: { writeText } }); + fixture.componentRef.setInput('value', { a: 1 }); + fixture.detectChanges(); + + await ( + fixture.componentInstance as unknown as { copy(): Promise } + ).copy(); + fixture.detectChanges(); + + expect(writeText).toHaveBeenCalledWith('{\n "a": 1\n}'); + expect(fixture.nativeElement.textContent).toContain('Payload copied'); + }); +}); diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.ts new file mode 100644 index 0000000..a4ac7d3 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.ts @@ -0,0 +1,82 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + signal +} from '@angular/core'; +import { CpsButtonComponent } from 'cps-ui-kit'; +import { uwtSafeStringify } from '../uwt-diagnostics-export/uwt-diagnostics-export'; +import { UWT_DEFAULT_DIAGNOSTICS_CONFIG } from '../uwt-diagnostics-store/uwt-diagnostics-store'; + +/** Characters shown of a payload too large to show in full. */ +const TRUNCATED_VIEW_CHARS = 64 * 1024; + +/** + * The first `length` characters of `text`, one fewer if the cut would split + * a surrogate pair — half an emoji renders as a replacement character. + */ +function cutAt(text: string, length: number): string { + const last = text.charCodeAt(length - 1); + const isHighSurrogate = last >= 0xd800 && last <= 0xdbff; + return text.slice(0, isHighSurrogate ? length - 1 : length); +} + +/** + * A payload as indented JSON, with a copy button. + * + * The only piece of the popup not taken from cps-ui-kit, which has no code + * or JSON display. It uses the kit's colour and radius tokens. + */ +@Component({ + selector: 'uwt-diagnostics-json', + imports: [CpsButtonComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './uwt-diagnostics-json.component.html', + styleUrl: './uwt-diagnostics-json.component.scss' +}) +export class UwtDiagnosticsJsonComponent { + /** What to show. */ + readonly value = input.required(); + + /** Accessible name of the scrollable block. */ + readonly label = input('Event payload'); + + /** JSON length, in characters, above which the view is truncated. */ + readonly maxChars = input( + UWT_DEFAULT_DIAGNOSTICS_CONFIG.maxPayloadCharsInView + ); + + /** How much a truncated view shows, as the notice words it. */ + protected readonly shownChars = TRUNCATED_VIEW_CHARS.toLocaleString('en-US'); + + protected readonly copied = signal<'idle' | 'copied' | 'failed'>('idle'); + + protected readonly full = computed(() => { + try { + return uwtSafeStringify(this.value(), 2) ?? 'undefined'; + } catch (error) { + return `Couldn't render this payload: ${ + error instanceof Error ? error.message : String(error) + }`; + } + }); + + protected readonly truncated = computed( + () => this.full().length > this.maxChars() + ); + + protected readonly shown = computed(() => + this.truncated() ? cutAt(this.full(), TRUNCATED_VIEW_CHARS) : this.full() + ); + + protected async copy(): Promise { + try { + await navigator.clipboard.writeText(this.full()); + this.copied.set('copied'); + } catch { + this.copied.set('failed'); + } + setTimeout(() => this.copied.set('idle'), 2000); + } +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-columns.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-columns.ts new file mode 100644 index 0000000..a2efc2a --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-columns.ts @@ -0,0 +1,199 @@ +import { UwtTelemetryObservedEvent } from '@absaoss-cps/ngx-ui-watchtower'; +import { + UwtDiagnosticsEntry, + UwtDiagnosticsSectionId +} from '../uwt-diagnostics.models/uwt-diagnostics.models'; + +/** One column of a section's table. */ +export interface UwtDiagnosticsColumn { + key: string; + header: string; + /** Hidden on narrow screens. */ + secondary?: boolean; + value: (event: UwtTelemetryObservedEvent) => string; +} + +/** One table row: display text, plus the entry for the detail view. */ +export interface UwtDiagnosticsRow { + sequence: number; + time: string; + cells: Record; + forwardedFrom?: string; + entry: UwtDiagnosticsEntry; +} + +/** Reads a field as text, whatever the payload's declared type. */ +function text(payload: unknown, key: string): string { + if (payload === null || typeof payload !== 'object') { + return ''; + } + const value = (payload as Record)[key]; + return value === undefined || value === null ? '' : String(value); +} + +function metadataSummary(payload: unknown): string { + if (payload === null || typeof payload !== 'object') { + return ''; + } + const metadata = (payload as Record).metadata; + if (metadata === null || typeof metadata !== 'object') { + return ''; + } + const pairs = Object.entries(metadata as Record); + const shown = pairs + .slice(0, 3) + .map(([k, v]) => `${k}=${String(v)}`) + .join(', '); + return pairs.length > 3 ? `${shown}, …` : shown; +} + +function ms(value: string): string { + return value === '' ? '' : `${value} ms`; +} + +function eventTypeOf(event: UwtTelemetryObservedEvent): string { + return 'eventType' in event ? event.eventType : ''; +} + +export const UWT_DIAGNOSTICS_COLUMNS: Record< + UwtDiagnosticsSectionId, + readonly UwtDiagnosticsColumn[] +> = { + bi: [ + { + key: 'name', + header: 'Event', + value: (e) => + e.kind === 'unknown' + ? `${eventTypeOf(e)} (unrecognised type)` + : text(e.payload, 'eventName') + }, + { key: 'type', header: 'Event type', secondary: true, value: eventTypeOf }, + { + key: 'scenario', + header: 'Scenario id', + secondary: true, + value: (e) => text(e.payload, 'scenarioId') + }, + { + key: 'feature', + header: 'Feature', + secondary: true, + value: (e) => text(e.payload, 'feature') + }, + { + key: 'metadata', + header: 'Metadata', + value: (e) => metadataSummary(e.payload) + } + ], + scenario: [ + { + key: 'name', + header: 'Scenario', + value: (e) => text(e.payload, 'scenarioName') + }, + { + key: 'kind', + header: 'Event', + value: (e) => (e.kind === 'scenario-step' ? 'step' : 'record') + }, + { + key: 'status', + header: 'Status', + value: (e) => + e.kind === 'scenario-step' + ? [text(e.payload, 'name'), text(e.payload, 'status')] + .filter(Boolean) + .join(' · ') + : text(e.payload, 'status') + }, + { + key: 'duration', + header: 'Duration', + value: (e) => + ms(text(e.payload, e.kind === 'scenario-step' ? 'stepDelta' : 'delta')) + }, + { + key: 'steps', + header: 'Steps', + secondary: true, + value: (e) => text(e.payload, 'stepCount') + }, + { + key: 'id', + header: 'Scenario id', + secondary: true, + value: (e) => text(e.payload, 'scenarioId') + } + ], + logging: [ + { + key: 'level', + header: 'Level', + value: (e) => + e.kind === 'error' ? 'error → RUM' : text(e.payload, 'level') + }, + { + key: 'logger', + header: 'Logger', + value: (e) => text(e.payload, 'logger') + }, + { + key: 'context', + header: 'Context', + secondary: true, + value: (e) => text(e.payload, 'context') + }, + { + key: 'message', + header: 'Message', + value: (e) => + e.kind === 'error' + ? `${text(e.payload, 'name')}: ${text(e.payload, 'message')}` + : text(e.payload, 'message') + }, + { + key: 'correlation', + header: 'Correlation id', + secondary: true, + value: (e) => text(e.payload, 'correlationId') + } + ] +}; + +/** `HH:MM:SS.mmm` in local time — the precision that orders a burst. */ +export function uwtDiagnosticsTime(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return iso; + } + const pad = (n: number, width = 2) => String(n).padStart(width, '0'); + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad( + date.getSeconds() + )}.${pad(date.getMilliseconds(), 3)}`; +} + +export function uwtDiagnosticsRow( + entry: UwtDiagnosticsEntry, + columns: readonly UwtDiagnosticsColumn[] +): UwtDiagnosticsRow { + const { event } = entry; + const cells: Record = {}; + for (const column of columns) { + try { + cells[column.key] = column.value(event); + } catch { + cells[column.key] = ''; + } + } + return { + sequence: event.sequence, + time: uwtDiagnosticsTime(event.capturedAt), + cells, + forwardedFrom: event.origin.forwarded + ? (event.origin.application ?? 'another app') + : undefined, + entry + }; +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.html b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.html new file mode 100644 index 0000000..2167f7a --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.html @@ -0,0 +1,161 @@ + +
+
+ + +
+ + + + +
+
+ @if (unseenNew() > 0) { +
+ {{ unseenNew() }} new on the first page. + + +
+ } + + + Time + @for (column of columns(); track column.key) { + + {{ column.header }} + + } + + + + + {{ row.time }} + @if (isFresh(row.sequence)) { + + } + + @for (column of columns(); track column.key) { + + @if (column.key === columns()[0].key && row.forwardedFrom) { + ↪ {{ row.forwardedFrom }} + } + {{ + row.cells[column.key] + }} + + } + + + +
+ + @if (row.entry.renderError) { +

+ Couldn't prepare this event for display: + {{ row.entry.renderError }} +

+ } + + +
+
+
+
+
diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.scss b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.scss new file mode 100644 index 0000000..13af3b9 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.scss @@ -0,0 +1,108 @@ +:host { + display: block; + container-type: inline-size; +} + +.uwt-diagnostics-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-block: 0.5rem; +} + +.uwt-diagnostics-section__tools { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.5rem 1rem; +} + +.uwt-diagnostics-section__filters { + flex: 1 1 24rem; +} + +.uwt-diagnostics-section__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.uwt-diagnostics-section__notice { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--cps-text-secondary); +} + +.uwt-diagnostics-section__time { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.uwt-diagnostics-section__cell { + display: inline-block; + max-width: 28rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} + +.uwt-diagnostics-section__forwarded { + margin-inline-end: 0.375rem; + color: var(--cps-text-secondary); + font-size: 0.75rem; +} + +// Level is always written out; colour only supports it. +.uwt-diagnostics-section__level--warn { + color: var(--cps-state-warn); + font-weight: 600; +} + +.uwt-diagnostics-section__level--error { + color: var(--cps-state-error); + font-weight: 600; +} + +.uwt-diagnostics-section__detail { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem 0.25rem; +} + +.uwt-diagnostics-section__meta { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1.25rem; + margin: 0; + font-size: 0.8125rem; + + div { + display: flex; + gap: 0.375rem; + } + + dt { + color: var(--cps-text-secondary); + } + + dd { + margin: 0; + color: var(--cps-text-primary); + } +} + +.uwt-diagnostics-section__error { + margin: 0; + color: var(--cps-state-error); + font-size: 0.8125rem; +} + +@container (max-width: 48rem) { + .uwt-diagnostics-section__secondary { + display: none; + } +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.spec.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.spec.ts new file mode 100644 index 0000000..00371b6 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.spec.ts @@ -0,0 +1,126 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { UwtTelemetryMonitor } from '@absaoss-cps/ngx-ui-watchtower'; +import { UwtDiagnosticsExporter } from '../uwt-diagnostics-export/uwt-diagnostics-exporter'; +import { + UWT_DIAGNOSTICS_FLUSH_MS, + UwtDiagnosticsStore +} from '../uwt-diagnostics-store/uwt-diagnostics-store'; +import { UwtDiagnosticsSectionComponent } from './uwt-diagnostics-section.component'; + +/** Rows per page, as the section's table is configured. */ +const PAGE = 25; + +describe('UwtDiagnosticsSectionComponent', () => { + let fixture: ComponentFixture; + let store: UwtDiagnosticsStore; + let monitor: UwtTelemetryMonitor; + + const el = () => fixture.nativeElement as HTMLElement; + const shownRows = () => + el().querySelectorAll('td.uwt-diagnostics-section__time').length; + const pageReport = () => + el().querySelector('.p-paginator-current')?.textContent?.trim(); + const ui = () => + fixture.componentInstance as unknown as { + first(): number; + onPage(state: { first?: number }): void; + }; + + /** Publishes BI events, every tenth one named `needle`. */ + function publish(count: number): void { + for (let i = 0; i < count; i++) { + monitor.publish({ + kind: 'bi', + eventType: 'com.uwt.bi', + payload: { + eventName: i % 10 === 0 ? 'needle' : `hay-${i}`, + eventTime: '2026-01-01T00:00:00.000Z', + application: 'app' + }, + destination: 'sink', + origin: { forwarded: false } + }); + } + jest.advanceTimersByTime(UWT_DIAGNOSTICS_FLUSH_MS); + fixture.detectChanges(); + } + + function goToPage(page: number): void { + ui().onPage({ first: (page - 1) * PAGE }); + fixture.detectChanges(); + } + + beforeEach(async () => { + jest.useFakeTimers(); + await TestBed.configureTestingModule({ + imports: [UwtDiagnosticsSectionComponent, NoopAnimationsModule], + providers: [UwtDiagnosticsStore, UwtDiagnosticsExporter] + }).compileComponents(); + monitor = TestBed.inject(UwtTelemetryMonitor); + store = TestBed.inject(UwtDiagnosticsStore); + fixture = TestBed.createComponent(UwtDiagnosticsSectionComponent); + fixture.componentRef.setInput('section', 'bi'); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + jest.useRealTimers(); + }); + + describe('paging', () => { + it('should show the page the reader chose', () => { + publish(100); + goToPage(4); + + expect(ui().first()).toBe(75); + expect(shownRows()).toBe(PAGE); + }); + + it('should stay on the page while new events arrive', () => { + publish(100); + goToPage(2); + + publish(1); + + expect(ui().first()).toBe(PAGE); + }); + + it('should go back to the first page when a filter leaves fewer rows than the page starts at', () => { + publish(100); + goToPage(4); + + store.setText('bi', 'needle'); + fixture.detectChanges(); + + expect(ui().first()).toBe(0); + expect(shownRows()).toBe(10); + expect(pageReport()).toBe('1 - 10 of 10'); + }); + + it('should keep the page when a filter still reaches it', () => { + publish(100); + goToPage(2); + + store.setText('bi', 'hay'); + fixture.detectChanges(); + + expect(ui().first()).toBe(PAGE); + expect(shownRows()).toBe(PAGE); + }); + + it('should go back to the first page when the section is cleared', () => { + publish(100); + goToPage(3); + + store.clear(); + fixture.detectChanges(); + expect(ui().first()).toBe(0); + + publish(1); + expect(shownRows()).toBe(1); + expect(pageReport()).toBe('1 - 1 of 1'); + }); + }); +}); diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.ts new file mode 100644 index 0000000..100ab73 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.ts @@ -0,0 +1,168 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + linkedSignal, + signal +} from '@angular/core'; +import { + CpsChipComponent, + CpsButtonComponent, + CpsExpansionPanelComponent, + CpsIconType, + CpsTableComponent +} from 'cps-ui-kit'; +import { UwtDiagnosticsSectionId } from '../uwt-diagnostics.models/uwt-diagnostics.models'; +import { + UWT_DEFAULT_DIAGNOSTICS_CONFIG, + UwtDiagnosticsStore +} from '../uwt-diagnostics-store/uwt-diagnostics-store'; +import { UwtDiagnosticsJsonComponent } from '../uwt-diagnostics-json/uwt-diagnostics-json.component'; +import { UwtDiagnosticsFilterBarComponent } from '../uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component'; +import { UwtDiagnosticsExporter } from '../uwt-diagnostics-export/uwt-diagnostics-exporter'; +import { + UWT_DIAGNOSTICS_COLUMNS, + uwtDiagnosticsRow, + uwtDiagnosticsTime +} from './uwt-diagnostics-columns'; + +const SECTION_TEXT: Record< + UwtDiagnosticsSectionId, + { title: string; icon: CpsIconType; empty: string } +> = { + bi: { + title: 'BI telemetry', + icon: 'graph', + empty: 'No BI events since this window opened.' + }, + scenario: { + title: 'Scenario telemetry', + icon: 'measurement', + empty: 'No scenario events since this window opened.' + }, + logging: { + title: 'Logging', + icon: 'book', + empty: 'No log records since this window opened.' + } +}; + +/** + * One section of the popup: an expander holding the section's own filters, + * its download and copy actions, and a paginated table of its events. + */ +@Component({ + selector: 'uwt-diagnostics-section', + imports: [ + CpsExpansionPanelComponent, + CpsTableComponent, + CpsChipComponent, + CpsButtonComponent, + UwtDiagnosticsJsonComponent, + UwtDiagnosticsFilterBarComponent + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './uwt-diagnostics-section.component.html', + styleUrl: './uwt-diagnostics-section.component.scss' +}) +export class UwtDiagnosticsSectionComponent { + protected readonly store = inject(UwtDiagnosticsStore); + private readonly exporter = inject(UwtDiagnosticsExporter); + + readonly section = input.required(); + /** JSON length, in characters, above which a payload is shown truncated. */ + readonly maxPayloadChars = input( + UWT_DEFAULT_DIAGNOSTICS_CONFIG.maxPayloadCharsInView + ); + + protected readonly copyState = signal<'idle' | 'copied' | 'failed'>('idle'); + + protected readonly text = computed(() => SECTION_TEXT[this.section()]); + protected readonly columns = computed( + () => UWT_DIAGNOSTICS_COLUMNS[this.section()] + ); + + protected readonly rows = computed(() => { + const columns = this.columns(); + return this.store + .filtered() + [this.section()].map((entry) => uwtDiagnosticsRow(entry, columns)); + }); + + /** + * The paginator's offset. Kept while it still lands on a row; otherwise — + * a filter or a clear shrinking the rows past it — back to the first page, + * where the newest events are. The table won't do this itself: PrimeNG + * steps back one page at most, so page four filtered down to one page + * would show an empty table. + */ + protected readonly first = linkedSignal({ + source: this.rows, + computation: (rows, previous) => + previous && previous.value < rows.length ? previous.value : 0 + }); + + private readonly captured = computed( + () => this.store.entries()[this.section()] + ); + + /** The expander title — the kit's header takes plain text only. */ + protected readonly title = computed(() => { + const { title } = this.text(); + const captured = this.captured(); + if (captured.length === 0) { + return `${title} · no events yet`; + } + const count = this.store.isFiltering(this.section()) + ? `${this.rows().length} of ${captured.length}` + : `${captured.length}`; + const last = uwtDiagnosticsTime(captured[0].event.capturedAt).slice(0, 8); + const dropped = this.store.dropped()[this.section()]; + return [ + title, + count, + `last ${last}`, + dropped ? `oldest ${dropped} dropped` : '' + ] + .filter(Boolean) + .join(' · '); + }); + + protected readonly emptyMessage = computed(() => + this.captured().length > 0 + ? 'No events match the filters.' + : this.text().empty + ); + + /** Fresh arrivals that a reader past page one cannot see. */ + protected readonly unseenNew = computed(() => + this.first() > 0 + ? this.rows().filter((r) => this.store.fresh().has(r.sequence)).length + : 0 + ); + + protected onPage(state: { first?: number }): void { + this.first.set(state?.first ?? 0); + } + + protected goToNewest(): void { + this.first.set(0); + } + + protected download(): void { + this.exporter.download(this.section()); + } + + protected async copy(): Promise { + this.copyState.set( + (await this.exporter.copy(this.section())) ? 'copied' : 'failed' + ); + setTimeout(() => this.copyState.set('idle'), 2000); + } + + protected isFresh(sequence: number): boolean { + return this.store.fresh().has(sequence); + } +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-shortcut/uwt-diagnostics-shortcut.spec.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-shortcut/uwt-diagnostics-shortcut.spec.ts new file mode 100644 index 0000000..06214d1 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-shortcut/uwt-diagnostics-shortcut.spec.ts @@ -0,0 +1,104 @@ +import { + UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS, + uwtIsApplePlatform, + uwtMatchesShortcut, + uwtShortcutLabel +} from './uwt-diagnostics-shortcut'; + +type Mods = Partial< + Pick +>; + +function key(code: string, mods: Mods, keyChar = '8', altGraph = false) { + const event = new KeyboardEvent('keydown', { code, key: keyChar, ...mods }); + Object.defineProperty(event, 'getModifierState', { + value: (k: string) => k === 'AltGraph' && altGraph + }); + return event; +} + +const [MAC, WIN] = UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS; +const macMods = { shiftKey: true, altKey: true, metaKey: true }; +const winMods = { ctrlKey: true, altKey: true, shiftKey: true }; + +describe('uwtMatchesShortcut', () => { + it('should match ⇧⌥⌘8, even though Option and Shift change the character', () => { + // On macOS, ⇧⌥8 produces "°" — only the physical key is reliable. + expect(uwtMatchesShortcut(key('Digit8', macMods, '°'), MAC)).toBe(true); + }); + + it('should match Ctrl+Alt+Shift+8', () => { + expect(uwtMatchesShortcut(key('Digit8', winMods, '*'), WIN)).toBe(true); + }); + + it.each([ + ['a modifier missing', { shiftKey: true, altKey: true }], + ['an extra modifier', { ...macMods, ctrlKey: true }], + ['no modifiers', {}] + ])('should not match with %s', (_label, mods) => { + expect(uwtMatchesShortcut(key('Digit8', mods), MAC)).toBe(false); + }); + + it('should not match the numpad 8', () => { + expect(uwtMatchesShortcut(key('Numpad8', macMods), MAC)).toBe(false); + }); + + it('should ignore a held-down repeat', () => { + expect( + uwtMatchesShortcut(key('Digit8', { ...winMods, repeat: true }), WIN) + ).toBe(false); + }); + + it('should not fire while AltGr is producing a character', () => { + // AltGr reports as Ctrl+Alt on many European Windows layouts. + expect(uwtMatchesShortcut(key('Digit8', winMods, '8', true), WIN)).toBe( + false + ); + }); + + it('should match a custom shortcut', () => { + const custom = { code: 'KeyD', ctrl: true, alt: true, shift: true }; + expect(uwtMatchesShortcut(key('KeyD', winMods, 'D'), custom)).toBe(true); + expect(uwtMatchesShortcut(key('Digit8', winMods), custom)).toBe(false); + }); +}); + +describe('uwtShortcutLabel', () => { + it('should show the macOS label on Apple platforms, and the other elsewhere', () => { + expect(uwtShortcutLabel(UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS, true)).toBe( + '⇧⌥⌘8' + ); + expect(uwtShortcutLabel(UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS, false)).toBe( + 'Ctrl+Alt+Shift+8' + ); + }); + + it('should describe a shortcut that has no label', () => { + expect( + uwtShortcutLabel([{ code: 'KeyD', ctrl: true, shift: true }], false) + ).toBe('Ctrl+Shift+D'); + }); + + it('should be undefined with no shortcuts', () => { + expect(uwtShortcutLabel([], false)).toBeUndefined(); + }); +}); + +describe('uwtIsApplePlatform', () => { + it.each([ + ['MacIntel', true], + ['iPad', true], + ['Win32', false], + ['Linux x86_64', false] + ])('should read %s as %s', (platform, expected) => { + expect(uwtIsApplePlatform({ platform } as Navigator)).toBe(expected); + }); + + it('should prefer userAgentData when present', () => { + const nav = { + platform: 'Win32', + userAgentData: { platform: 'macOS' } + } as unknown as Navigator; + expect(uwtIsApplePlatform(nav)).toBe(true); + }); +}); diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-shortcut/uwt-diagnostics-shortcut.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-shortcut/uwt-diagnostics-shortcut.ts new file mode 100644 index 0000000..1d38f5e --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-shortcut/uwt-diagnostics-shortcut.ts @@ -0,0 +1,92 @@ +import { UwtDiagnosticsShortcut } from '../uwt-diagnostics.models/uwt-diagnostics.models'; + +/** + * The default shortcuts: ⇧⌥⌘8 on macOS, Ctrl+Alt+Shift+8 on Windows and + * Linux. + * + * Four keys so it is never pressed by accident, ending in a digit because + * every layout has one. Both are active everywhere — the other platform's + * combination is harmless, and covers external keyboards and remote + * desktops. None is bound by Chrome, Safari, Edge or Firefox; ⌥⌘8 alone is + * macOS Accessibility Zoom, which is why Shift is part of it. + * + * @group Utils + */ +export const UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS: readonly UwtDiagnosticsShortcut[] = + [ + { code: 'Digit8', shift: true, alt: true, meta: true, label: '⇧⌥⌘8' }, + { + code: 'Digit8', + ctrl: true, + alt: true, + shift: true, + label: 'Ctrl+Alt+Shift+8' + } + ]; + +/** + * Whether `event` is exactly `shortcut`. + * + * - The physical key is compared (`code`), never the produced character — + * Option turns 8 into `•` on macOS, and Shift turns it into `*`. + * - Every modifier must match; one left out must be up. + * - Held-down repeats don't count. + * - AltGr is rejected. On many European Windows layouts it reports as + * Ctrl+Alt, and typing an AltGr character must never open the popup. + */ +export function uwtMatchesShortcut( + event: KeyboardEvent, + shortcut: UwtDiagnosticsShortcut +): boolean { + if (event.repeat || event.code !== shortcut.code) { + return false; + } + if (event.getModifierState?.('AltGraph')) { + return false; + } + return ( + event.ctrlKey === !!shortcut.ctrl && + event.altKey === !!shortcut.alt && + event.shiftKey === !!shortcut.shift && + event.metaKey === !!shortcut.meta + ); +} + +/** Whether the platform is Apple's, for choosing which label to show. */ +export function uwtIsApplePlatform(nav: Navigator | undefined): boolean { + if (!nav) { + return false; + } + const platform = + (nav as Navigator & { userAgentData?: { platform?: string } }).userAgentData + ?.platform ?? + nav.platform ?? + ''; + return /mac|iphone|ipad/i.test(platform); +} + +/** + * The shortcut to show people: the one written for this platform when + * there is one, otherwise the first configured. + */ +export function uwtShortcutLabel( + shortcuts: readonly UwtDiagnosticsShortcut[], + apple: boolean +): string | undefined { + const preferred = shortcuts.find((s) => !!s.meta === apple); + const chosen = preferred ?? shortcuts[0]; + return chosen ? (chosen.label ?? describeShortcut(chosen)) : undefined; +} + +function describeShortcut(s: UwtDiagnosticsShortcut): string { + const key = s.code.replace(/^Digit|^Key/, ''); + return [ + s.ctrl && 'Ctrl', + s.alt && 'Alt', + s.shift && 'Shift', + s.meta && 'Meta', + key + ] + .filter(Boolean) + .join('+'); +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-store/uwt-diagnostics-store.spec.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-store/uwt-diagnostics-store.spec.ts new file mode 100644 index 0000000..8978703 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-store/uwt-diagnostics-store.spec.ts @@ -0,0 +1,264 @@ +import { Injector } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + UwtTelemetryMonitor, + UwtTelemetryPublishInput +} from '@absaoss-cps/ngx-ui-watchtower'; +import { UWT_TELEMETRY_DIAGNOSTICS_CONFIG } from '../uwt-diagnostics.models/uwt-diagnostics.models'; +import { + UWT_DIAGNOSTICS_FLUSH_MS, + UwtDiagnosticsStore +} from './uwt-diagnostics-store'; + +const bi = (name: string): UwtTelemetryPublishInput => ({ + kind: 'bi', + eventType: 'com.uwt.bi', + payload: { + eventName: name, + eventTime: '2026-01-01T00:00:00.000Z', + application: 'app' + }, + destination: 'sink', + origin: { forwarded: false } +}); + +const log = (message: string): UwtTelemetryPublishInput => ({ + kind: 'log', + payload: { + timestamp: '2026-01-01T00:00:00.000Z', + level: 'warn', + message, + logger: 'checkout', + application: 'app', + environment: 'test', + version: '1' + }, + destination: 'log-provider', + origin: { forwarded: false } +}); + +describe('UwtDiagnosticsStore', () => { + let monitor: UwtTelemetryMonitor; + let injector: Injector; + + function createStore(max = 500): UwtDiagnosticsStore { + injector = Injector.create({ + providers: [ + UwtDiagnosticsStore, + { + provide: UWT_TELEMETRY_DIAGNOSTICS_CONFIG, + useValue: { maxEventsPerSection: max } + } + ], + parent: TestBed.inject(Injector) + }); + return injector.get(UwtDiagnosticsStore); + } + + const flush = () => jest.advanceTimersByTime(UWT_DIAGNOSTICS_FLUSH_MS); + const names = (store: UwtDiagnosticsStore) => + store + .entries() + .bi.map((e) => (e.event.payload as { eventName: string }).eventName); + + beforeEach(() => { + jest.useFakeTimers(); + TestBed.resetTestingModule(); + monitor = TestBed.inject(UwtTelemetryMonitor); + }); + + function destroy(): void { + const target = injector as unknown as { + destroy(): void; + destroyed: boolean; + }; + if (target && !target.destroyed) { + target.destroy(); + } + } + + afterEach(() => { + destroy(); + jest.useRealTimers(); + }); + + it('should start empty, and ignore everything sent before it existed', () => { + monitor.publish(bi('before')); // nobody observing yet + const store = createStore(); + flush(); + + expect(store.total()).toBe(0); + }); + + it('should batch arrivals into one update per flush', () => { + const store = createStore(); + monitor.publish(bi('a')); + monitor.publish(bi('b')); + + expect(store.total()).toBe(0); // not on screen yet + flush(); + expect(names(store)).toEqual(['b', 'a']); // newest first + }); + + it('should route each kind to its section', () => { + const store = createStore(); + monitor.publish(bi('a')); + monitor.publish(log('careful')); + flush(); + + expect(store.entries().bi).toHaveLength(1); + expect(store.entries().logging).toHaveLength(1); + expect(store.entries().scenario).toHaveLength(0); + }); + + it('should keep at most the configured number per section, counting drops', () => { + const store = createStore(2); + ['a', 'b', 'c', 'd'].forEach((n) => monitor.publish(bi(n))); + monitor.publish(log('kept')); + flush(); + + expect(names(store)).toEqual(['d', 'c']); + expect(store.dropped().bi).toBe(2); + // A noisy section never evicts another. + expect(store.entries().logging).toHaveLength(1); + }); + + it('should clear everything and keep capturing', () => { + const store = createStore(); + monitor.publish(bi('a')); + flush(); + + store.clear(); + expect(store.total()).toBe(0); + + monitor.publish(bi('after')); + flush(); + expect(names(store)).toEqual(['after']); + }); + + it('should freeze the view while paused, but keep capturing', () => { + const store = createStore(); + monitor.publish(bi('a')); + flush(); + + store.setPaused(true); + monitor.publish(bi('b')); + monitor.publish(bi('c')); + flush(); + expect(names(store)).toEqual(['a']); + expect(store.newWhilePaused()).toBe(2); + // The section's download still contains them. + expect(store.sectionEvents('bi')).toHaveLength(3); + + store.setPaused(false); + expect(names(store)).toEqual(['c', 'b', 'a']); + expect(store.newWhilePaused()).toBe(0); + }); + + it('should include not-yet-flushed events in the download without hiding them', () => { + const store = createStore(); + monitor.publish(bi('a')); + + expect(store.sectionEvents('bi')).toHaveLength(1); + expect(store.sectionCount('bi')).toEqual({ captured: 1, droppedOldest: 0 }); + + flush(); + expect(names(store)).toEqual(['a']); + }); + + it("should export only a section's own events, oldest first", () => { + const store = createStore(); + monitor.publish(bi('first')); + monitor.publish(log('a log line')); + monitor.publish(bi('second')); + flush(); + + const events = store.sectionEvents('bi'); + expect(events.map((e) => e.kind)).toEqual(['bi', 'bi']); + expect(events[0].sequence).toBeLessThan(events[1].sequence); + expect(store.sectionEvents('logging')).toHaveLength(1); + expect(store.sectionEvents('scenario')).toHaveLength(0); + }); + + it('should filter each section on its own, ignoring case', () => { + const store = createStore(); + monitor.publish(bi('theme_changed')); + monitor.publish(bi('sidebar_toggled')); + monitor.publish(log('Careful now')); + flush(); + + store.setText('bi', 'THEME'); + expect(store.filtered().bi).toHaveLength(1); + // Other sections are untouched by BI's filter. + expect(store.filtered().logging).toHaveLength(1); + expect(store.isFiltering('bi')).toBe(true); + expect(store.isFiltering('logging')).toBe(false); + + store.addFilter('logging', { + path: 'level', + operator: 'equals', + value: 'error' + }); + expect(store.filtered().logging).toHaveLength(0); + expect(store.filtered().bi).toHaveLength(1); + + store.removeFilter('logging', store.filters().logging.fields[0].id); + expect(store.filtered().logging).toHaveLength(1); + + store.clearFilters('bi'); + expect(store.filtered().bi).toHaveLength(2); + }); + + it('should suggest filter fields from each section separately', () => { + const store = createStore(); + monitor.publish(bi('a')); + monitor.publish(log('b')); + flush(); + + expect(store.knownPaths().logging).toContain('level'); + expect(store.knownPaths().bi).not.toContain('level'); + expect(store.knownPaths().bi).toContain('eventName'); + }); + + it('should mark the latest arrivals as new, briefly', () => { + const store = createStore(); + monitor.publish(bi('a')); + flush(); + + const [entry] = store.entries().bi; + expect(store.fresh().has(entry.event.sequence)).toBe(true); + jest.advanceTimersByTime(3000); + expect(store.fresh().size).toBe(0); + }); + + it('should keep going after an event it cannot prepare', () => { + const store = createStore(); + const payload: Record = { eventName: 'x' }; + Object.defineProperty(payload, 'boom', { + enumerable: true, + get: () => { + throw new Error('bad getter'); + } + }); + // Delivered as-is, bypassing the monitor's copy, to simulate a bad payload. + (store as unknown as { receive(e: unknown): void }).receive({ + ...bi('x'), + payload, + sequence: 99, + capturedAt: '' + }); + monitor.publish(bi('fine')); + flush(); + + expect(store.entries().bi).toHaveLength(2); + expect(store.entries().bi[1].renderError).toBe('bad getter'); + }); + + it('should stop observing when destroyed', () => { + createStore(); + expect(monitor.observed).toBe(true); + + destroy(); + expect(monitor.observed).toBe(false); + }); +}); diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-store/uwt-diagnostics-store.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-store/uwt-diagnostics-store.ts new file mode 100644 index 0000000..84d6442 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-store/uwt-diagnostics-store.ts @@ -0,0 +1,337 @@ +import { + computed, + DestroyRef, + inject, + Injectable, + signal +} from '@angular/core'; +import { + UwtTelemetryMonitor, + UwtTelemetryObservedEvent +} from '@absaoss-cps/ngx-ui-watchtower'; +import { + UWT_TELEMETRY_DIAGNOSTICS_CONFIG, + UwtDiagnosticsEntry, + UwtDiagnosticsFieldFilter, + UwtDiagnosticsFilterState, + UwtDiagnosticsSectionId, + UwtTelemetryDiagnosticsConfig +} from '../uwt-diagnostics.models/uwt-diagnostics.models'; +import { + uwtDiagnosticsSectionOf, + uwtFlattenEvent, + uwtKnownPaths, + uwtMatchesFilters +} from '../uwt-diagnostics-filter/uwt-diagnostics-filter'; +import { uwtSafeStringify } from '../uwt-diagnostics-export/uwt-diagnostics-export'; + +/** Library defaults, applied under whatever the application configures. */ +export const UWT_DEFAULT_DIAGNOSTICS_CONFIG: Omit< + UwtTelemetryDiagnosticsConfig, + 'shortcuts' +> = { + enabled: true, + maxEventsPerSection: 500, + maxPayloadCharsInView: 256 * 1024 +}; + +/** How often arriving events reach the screen, in milliseconds. */ +export const UWT_DIAGNOSTICS_FLUSH_MS = 250; +/** How long a row counts as new, in milliseconds. */ +const FRESH_MS = 3000; + +export const UWT_DIAGNOSTICS_SECTIONS: readonly UwtDiagnosticsSectionId[] = [ + 'bi', + 'scenario', + 'logging' +]; + +type PerSection = Record; + +function perSection(make: () => T): PerSection { + return { bi: make(), scenario: make(), logging: make() }; +} + +/** + * The popup's captured history. Provided by the popup itself, so it exists + * exactly as long as the popup is open: created empty on open, subscribed + * to {@link UwtTelemetryMonitor} for its lifetime, and discarded on close. + * + * Events arrive whenever the application sends them; they are batched and + * reach the screen at most every {@link UWT_DIAGNOSTICS_FLUSH_MS}, so a + * burst causes one re-render rather than hundreds. Each section keeps at + * most `maxEventsPerSection`, newest first, and counts what it dropped. + */ +@Injectable() +export class UwtDiagnosticsStore { + private readonly monitor = inject(UwtTelemetryMonitor); + private readonly config: UwtTelemetryDiagnosticsConfig = { + ...UWT_DEFAULT_DIAGNOSTICS_CONFIG, + shortcuts: [], + ...inject(UWT_TELEMETRY_DIAGNOSTICS_CONFIG, { optional: true }) + }; + + /** Newest first. Written on every flush, paused or not. */ + private readonly buffers = perSection(() => []); + private readonly droppedCount = perSection(() => 0); + private pending: UwtDiagnosticsEntry[] = []; + private flushTimer?: ReturnType; + private freshTimer?: ReturnType; + + /** When capturing began: the popup opening, or the last clear. */ + readonly startedAt = signal(new Date().toISOString()); + + /** What the popup shows: the buffers as of the last unpaused flush. */ + readonly entries = signal>( + perSection(() => []) + ); + + readonly dropped = signal>(perSection(() => 0)); + + /** Freezes what is shown. Capturing carries on underneath. */ + readonly paused = signal(false); + /** Events captured while paused and not yet shown. */ + readonly newWhilePaused = signal(0); + + /** + * Events received per section since opening or the last clear — keeps + * rising after the retention cap, unlike the section lengths. + */ + readonly received = signal>(perSection(() => 0)); + + /** Sequence numbers from the latest flush, for a "new" marker. */ + readonly fresh = signal>(new Set()); + + /** Each section's own search text and field filters. */ + readonly filters = signal>( + perSection(emptyFilters) + ); + + /** Entries matching each section's own filters. */ + readonly filtered = computed>( + () => { + const entries = this.entries(); + const filters = this.filters(); + return perSectionFrom((id) => + isFiltering(filters[id]) + ? entries[id].filter((e) => uwtMatchesFilters(e.fields, filters[id])) + : entries[id] + ); + } + ); + + /** Field paths seen so far in each section, for filter suggestions. */ + readonly knownPaths = computed>(() => { + const entries = this.entries(); + return perSectionFrom((id) => + uwtKnownPaths(entries[id].map((e) => e.fields)) + ); + }); + + readonly total = computed(() => { + const entries = this.entries(); + return UWT_DIAGNOSTICS_SECTIONS.reduce( + (n, id) => n + entries[id].length, + 0 + ); + }); + + constructor() { + const subscription = this.monitor.events$.subscribe((event) => + this.receive(event) + ); + inject(DestroyRef).onDestroy(() => { + subscription.unsubscribe(); + clearTimeout(this.flushTimer); + clearTimeout(this.freshTimer); + }); + } + + /** Empties every section. Capturing continues. */ + clear(): void { + for (const id of UWT_DIAGNOSTICS_SECTIONS) { + this.buffers[id] = []; + this.droppedCount[id] = 0; + } + this.pending = []; + this.startedAt.set(new Date().toISOString()); + this.newWhilePaused.set(0); + this.received.set(perSection(() => 0)); + this.publish(); + } + + setPaused(paused: boolean): void { + this.paused.set(paused); + if (!paused) { + this.newWhilePaused.set(0); + this.publish(); + } + } + + setText(section: UwtDiagnosticsSectionId, text: string): void { + this.updateFilters(section, (f) => ({ ...f, text })); + } + + addFilter( + section: UwtDiagnosticsSectionId, + filter: Omit + ): void { + this.updateFilters(section, (f) => ({ + ...f, + fields: [...f.fields, { ...filter, id: newId() }] + })); + } + + removeFilter(section: UwtDiagnosticsSectionId, id: string): void { + this.updateFilters(section, (f) => ({ + ...f, + fields: f.fields.filter((x) => x.id !== id) + })); + } + + clearFilters(section: UwtDiagnosticsSectionId): void { + this.updateFilters(section, emptyFilters); + } + + /** Whether a section has any filter or search text active. */ + isFiltering(section: UwtDiagnosticsSectionId): boolean { + return isFiltering(this.filters()[section]); + } + + /** + * Every event captured in one section: what is shown, plus anything held + * back while paused or still waiting for the next flush. Oldest first. + */ + sectionEvents(section: UwtDiagnosticsSectionId): UwtTelemetryObservedEvent[] { + return [ + ...this.buffers[section].map((e) => e.event), + ...this.pending.filter((e) => e.section === section).map((e) => e.event) + ].sort((a, b) => a.sequence - b.sequence); + } + + /** One section's totals for its export, counted like {@link sectionEvents}. */ + sectionCount(section: UwtDiagnosticsSectionId): { + captured: number; + droppedOldest: number; + } { + return { + captured: + this.buffers[section].length + + this.pending.filter((e) => e.section === section).length, + droppedOldest: this.droppedCount[section] + }; + } + + private updateFilters( + section: UwtDiagnosticsSectionId, + change: (f: UwtDiagnosticsFilterState) => UwtDiagnosticsFilterState + ): void { + this.filters.update((all) => ({ ...all, [section]: change(all[section]) })); + } + + /** + * Called for every observed event. Never throws: an error here would + * surface asynchronously in the application, since RxJS reports + * subscriber errors on its own. + */ + private receive(event: UwtTelemetryObservedEvent): void { + try { + this.pending.push(this.prepare(event)); + this.flushTimer ??= setTimeout( + () => this.flush(), + UWT_DIAGNOSTICS_FLUSH_MS + ); + } catch { + // Nothing to show for this one; later events are unaffected. + } + } + + private prepare(event: UwtTelemetryObservedEvent): UwtDiagnosticsEntry { + const section = uwtDiagnosticsSectionOf(event.kind); + try { + return { + event, + section, + fields: uwtFlattenEvent(event), + sizeChars: uwtSafeStringify(event.payload).length + }; + } catch (error) { + return { + event, + section, + fields: [], + sizeChars: 0, + renderError: error instanceof Error ? error.message : String(error) + }; + } + } + + private flush(): void { + this.flushTimer = undefined; + const arrived = this.drain(); + if (arrived.length === 0) { + return; + } + this.received.update((counts) => { + const next = { ...counts }; + for (const entry of arrived) { + next[entry.section]++; + } + return next; + }); + if (this.paused()) { + this.newWhilePaused.update((n) => n + arrived.length); + return; + } + this.publish(arrived); + } + + /** Moves pending events into the buffers. Returns what moved. */ + private drain(): UwtDiagnosticsEntry[] { + const arrived = this.pending; + this.pending = []; + const max = Math.max(1, this.config.maxEventsPerSection); + for (const entry of arrived) { + const buffer = this.buffers[entry.section]; + buffer.unshift(entry); + if (buffer.length > max) { + this.droppedCount[entry.section] += buffer.length - max; + buffer.length = max; + } + } + return arrived; + } + + private publish(arrived: readonly UwtDiagnosticsEntry[] = []): void { + this.entries.set(perSectionFrom((id) => [...this.buffers[id]])); + this.dropped.set({ ...this.droppedCount }); + this.fresh.set(new Set(arrived.map((e) => e.event.sequence))); + clearTimeout(this.freshTimer); + if (arrived.length) { + this.freshTimer = setTimeout(() => this.fresh.set(new Set()), FRESH_MS); + } + } +} + +function perSectionFrom( + make: (id: UwtDiagnosticsSectionId) => T +): PerSection { + return { + bi: make('bi'), + scenario: make('scenario'), + logging: make('logging') + }; +} + +function emptyFilters(): UwtDiagnosticsFilterState { + return { text: '', fields: [] }; +} + +function isFiltering(filters: UwtDiagnosticsFilterState): boolean { + return !!filters.text.trim() || filters.fields.length > 0; +} + +let idCounter = 0; +function newId(): string { + return `f${++idCounter}`; +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics.models/uwt-diagnostics.models.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics.models/uwt-diagnostics.models.ts new file mode 100644 index 0000000..6b65d65 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics.models/uwt-diagnostics.models.ts @@ -0,0 +1,141 @@ +import { InjectionToken } from '@angular/core'; +import { UwtTelemetryObservedEvent } from '@absaoss-cps/ngx-ui-watchtower'; + +/** + * The three sections of the diagnostics popup. + * + * @group Types + */ +export type UwtDiagnosticsSectionId = 'bi' | 'scenario' | 'logging'; + +/** + * A key combination that opens the diagnostics popup. + * + * Matched on `KeyboardEvent.code` — the physical key — so it works on every + * keyboard layout, and isn't affected by what Option or Shift produce. + * A modifier left out must be *up*, so extra modifiers never match. + * + * @group Interfaces + */ +export interface UwtDiagnosticsShortcut { + /** `KeyboardEvent.code`, e.g. `'Digit8'` or `'KeyD'`. */ + code: string; + ctrl?: boolean; + alt?: boolean; + shift?: boolean; + meta?: boolean; + + /** How the shortcut is written for people, e.g. `'⇧⌥⌘8'`. */ + label?: string; +} + +/** + * Configuration for {@link provideUwtTelemetryDiagnostics}. + * + * @group Interfaces + */ +export interface UwtTelemetryDiagnosticsConfig { + /** + * Whether the popup can be opened at all. A function is read once, at + * startup — use it to limit access to, say, support staff. + */ + enabled: boolean | (() => boolean); + + /** + * Shortcuts that open the popup. An empty array disables the keyboard, + * leaving {@link UwtTelemetryDiagnosticsService.open} as the only way in. + */ + shortcuts: readonly UwtDiagnosticsShortcut[]; + + /** Events kept per section; the oldest are dropped beyond this. */ + maxEventsPerSection: number; + + /** + * Payloads whose JSON is longer than this many characters are shown + * truncated. The download always contains them in full. + * + * Characters (UTF-16 code units, JavaScript's `length`), not bytes: the + * limit protects rendering, which costs by the length of the text. + */ + maxPayloadCharsInView: number; +} + +/** @group Tokens */ +export const UWT_TELEMETRY_DIAGNOSTICS_CONFIG = + new InjectionToken( + 'UWT_TELEMETRY_DIAGNOSTICS_CONFIG' + ); + +/** A flattened leaf of an event, used for filtering. */ +export interface UwtDiagnosticsField { + /** Exact path, e.g. `steps[2].name`. */ + path: string; + + /** Path with array indexes removed, e.g. `steps[].name`. */ + pattern: string; + + /** The value as text. */ + value: string; +} + +/** One captured event, prepared once for display and filtering. */ +export interface UwtDiagnosticsEntry { + event: UwtTelemetryObservedEvent; + section: UwtDiagnosticsSectionId; + fields: readonly UwtDiagnosticsField[]; + /** Length of the payload's JSON, in characters (UTF-16 code units). */ + sizeChars: number; + /** Set when the event could not be prepared for display. */ + renderError?: string; +} + +/** + * How a field filter compares. + * + * @group Types + */ +export type UwtDiagnosticsFilterOperator = + 'contains' | 'equals' | 'not-contains' | 'exists' | 'missing'; + +/** @group Interfaces */ +export interface UwtDiagnosticsFieldFilter { + id: string; + path: string; + operator: UwtDiagnosticsFilterOperator; + value: string; +} + +/** @group Interfaces */ +export interface UwtDiagnosticsFilterState { + /** Free text, matched against every value and path. */ + text: string; + fields: readonly UwtDiagnosticsFieldFilter[]; +} + +/** + * A downloaded JSON file: one section's captured events. + * + * `events` holds every event captured in that section, oldest first, + * regardless of the filters on screen. `activeFilters` records that + * section's filters, for context; they were not applied to `events`. + * + * @group Interfaces + */ +export interface UwtDiagnosticsExport { + format: 'ngx-ui-watchtower-diagnostics'; + formatVersion: 1; + section: UwtDiagnosticsSectionId; + exportedAt: string; + capture: { startedAt: string; endedAt: string; paused: boolean }; + app: { + application: string; + environment: string; + version: string; + eventNamespace: string; + }; + session: { sessionId?: string; userId?: string }; + userAgent: string; + activeFilters: UwtDiagnosticsFilterState; + count: { captured: number; droppedOldest: number }; + events: UwtTelemetryObservedEvent[]; +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics.providers/uwt-diagnostics.providers.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics.providers/uwt-diagnostics.providers.ts new file mode 100644 index 0000000..0dc0ad3 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics.providers/uwt-diagnostics.providers.ts @@ -0,0 +1,60 @@ +import { + EnvironmentProviders, + inject, + makeEnvironmentProviders, + provideAppInitializer +} from '@angular/core'; +import { + UWT_TELEMETRY_DIAGNOSTICS_CONFIG, + UwtTelemetryDiagnosticsConfig +} from '../uwt-diagnostics.models/uwt-diagnostics.models'; +import { UWT_DEFAULT_DIAGNOSTICS_CONFIG } from '../uwt-diagnostics-store/uwt-diagnostics-store'; +import { UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS } from '../uwt-diagnostics-shortcut/uwt-diagnostics-shortcut'; +import { UwtTelemetryDiagnosticsService } from '../uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service'; + +/** + * Adds the telemetry diagnostics popup: an in-app window showing, live, + * every BI, scenario and log event this app hands to its destinations. + * + * Opens with ⇧⌥⌘8 (macOS) or Ctrl+Alt+Shift+8 (Windows, Linux) by + * default, in every environment. Needs cps-ui-kit set up in the app, as + * for any cps-ui-kit dialog. + * + * @example + * ```typescript + * import { provideUwtTelemetryDiagnostics } from '@absaoss-cps/ngx-ui-watchtower/diagnostics'; + * + * providers: [ + * provideUwtTelemetry({ application: 'my-app', environment, version }), + * provideUwtTelemetryDiagnostics() + * ] + * + * // Or a different key, or access limited to some users: + * provideUwtTelemetryDiagnostics({ + * shortcuts: [{ code: 'KeyD', ctrl: true, alt: true, shift: true }], + * enabled: () => inject(AuthService).isSupportStaff() + * }) + * ``` + * + * @param config overrides merged over the defaults + * @returns providers for the popup and its shortcut + * + * @group Utils + */ +export function provideUwtTelemetryDiagnostics( + config: Partial = {} +): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: UWT_TELEMETRY_DIAGNOSTICS_CONFIG, + useValue: { + ...UWT_DEFAULT_DIAGNOSTICS_CONFIG, + shortcuts: UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS, + ...config + } + }, + provideAppInitializer(() => { + inject(UwtTelemetryDiagnosticsService).install(); + }) + ]); +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.spec.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.spec.ts new file mode 100644 index 0000000..d2c795f --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.spec.ts @@ -0,0 +1,194 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Subject } from 'rxjs'; +import { CpsDialogService } from 'cps-ui-kit'; +import { + UWT_TELEMETRY_DIAGNOSTICS_CONFIG, + UwtTelemetryDiagnosticsConfig +} from '../uwt-diagnostics.models/uwt-diagnostics.models'; +import { UwtDiagnosticsDialogComponent } from '../uwt-diagnostics-dialog/uwt-diagnostics-dialog.component'; +import { UwtTelemetryDiagnosticsService } from './uwt-telemetry-diagnostics.service'; + +/** Stands in for CpsDialogService, recording what was opened. */ +class FakeDialogs { + readonly opened: { component: unknown; config: Record }[] = + []; + + readonly destroyed = new Subject(); + containsFocus = false; + readonly instance = { + containsFocus: () => this.containsFocus, + focusSearch: jest.fn() + }; + + readonly close = jest.fn(() => this.destroyed.next()); + + open(component: unknown, config: Record) { + this.opened.push({ component, config }); + return { + componentInstance: this.instance, + onDestroy: this.destroyed.asObservable(), + close: this.close + }; + } +} + +const SHORTCUT = { + code: 'Digit8', + ctrlKey: true, + altKey: true, + shiftKey: true +}; + +type KeyInit = ConstructorParameters[1]; + +function press(init: KeyInit = SHORTCUT): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + ...init, + bubbles: true, + cancelable: true + }); + document.body.dispatchEvent(event); + return event; +} + +describe('UwtTelemetryDiagnosticsService', () => { + let dialogs: FakeDialogs; + + function create( + config: Partial = {}, + platform = 'browser' + ): UwtTelemetryDiagnosticsService { + TestBed.resetTestingModule(); + dialogs = new FakeDialogs(); + TestBed.configureTestingModule({ + providers: [ + { provide: CpsDialogService, useValue: dialogs }, + { provide: PLATFORM_ID, useValue: platform }, + { provide: UWT_TELEMETRY_DIAGNOSTICS_CONFIG, useValue: config } + ] + }); + return TestBed.inject(UwtTelemetryDiagnosticsService); + } + + afterEach(() => { + TestBed.resetTestingModule(); + jest.restoreAllMocks(); + }); + + it('should open the popup, non-modal and named, on the default shortcut', () => { + create().install(); + + const event = press(); + + expect(dialogs.opened).toHaveLength(1); + expect(dialogs.opened[0].component).toBe(UwtDiagnosticsDialogComponent); + expect(dialogs.opened[0].config).toMatchObject({ + modal: false, + ariaLabel: 'Telemetry diagnostics', + closeOnEscape: true + }); + expect(event.defaultPrevented).toBe(true); + expect(TestBed.inject(UwtTelemetryDiagnosticsService).isOpen()).toBe(true); + }); + + it('should also open on the macOS shortcut', () => { + create().install(); + press({ code: 'Digit8', metaKey: true, altKey: true, shiftKey: true }); + expect(dialogs.opened).toHaveLength(1); + }); + + it('should ignore ordinary typing and near misses', () => { + create().install(); + + press({ code: 'Digit8' }); + press({ code: 'Digit8', ctrlKey: true, shiftKey: true }); + press({ code: 'KeyD', ctrlKey: true, altKey: true, shiftKey: true }); + + expect(dialogs.opened).toHaveLength(0); + }); + + it('should bring focus back to an open popup, and close it from inside', () => { + create().install(); + press(); + + dialogs.containsFocus = false; + press(); + expect(dialogs.instance.focusSearch).toHaveBeenCalledTimes(1); + expect(dialogs.close).not.toHaveBeenCalled(); + + dialogs.containsFocus = true; + press(); + expect(dialogs.close).toHaveBeenCalledTimes(1); + expect(TestBed.inject(UwtTelemetryDiagnosticsService).isOpen()).toBe(false); + }); + + it('should open fresh after the previous popup was destroyed', () => { + create().install(); + press(); + dialogs.destroyed.next(); + + press(); + expect(dialogs.opened).toHaveLength(2); + }); + + it('should use custom shortcuts instead of the defaults', () => { + create({ + shortcuts: [{ code: 'KeyD', ctrl: true, alt: true, shift: true }] + }).install(); + + press(); + expect(dialogs.opened).toHaveLength(0); + press({ code: 'KeyD', ctrlKey: true, altKey: true, shiftKey: true }); + expect(dialogs.opened).toHaveLength(1); + }); + + it('should not listen with no shortcuts, but still open on request', () => { + const service = create({ shortcuts: [] }); + service.install(); + + press(); + expect(dialogs.opened).toHaveLength(0); + + service.open(); + expect(dialogs.opened).toHaveLength(1); + }); + + it.each([ + ['disabled', { enabled: false }], + ['disabled by a function', { enabled: () => false }] + ])('should do nothing when %s', (_label, config) => { + const service = create(config); + service.install(); + + press(); + service.open(); + expect(dialogs.opened).toHaveLength(0); + }); + + it('should treat a throwing enabled() as disabled', () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const service = create({ + enabled: () => { + throw new Error('auth not ready'); + } + }); + service.install(); + service.open(); + expect(dialogs.opened).toHaveLength(0); + }); + + it('should not listen during a server-side render', () => { + create({}, 'server').install(); + press(); + expect(dialogs.opened).toHaveLength(0); + }); + + it('should stop listening when the application is destroyed', () => { + create().install(); + TestBed.resetTestingModule(); + + press(); + expect(dialogs.opened).toHaveLength(0); + }); +}); diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.ts new file mode 100644 index 0000000..343ad9f --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.ts @@ -0,0 +1,167 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { + DestroyRef, + inject, + Injectable, + NgZone, + PLATFORM_ID, + signal +} from '@angular/core'; +import { CpsDialogRef, CpsDialogService } from 'cps-ui-kit'; +import { + UWT_TELEMETRY_DIAGNOSTICS_CONFIG, + UwtTelemetryDiagnosticsConfig +} from '../uwt-diagnostics.models/uwt-diagnostics.models'; +import { UWT_DEFAULT_DIAGNOSTICS_CONFIG } from '../uwt-diagnostics-store/uwt-diagnostics-store'; +import { + UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS, + uwtMatchesShortcut +} from '../uwt-diagnostics-shortcut/uwt-diagnostics-shortcut'; +import { UwtDiagnosticsDialogComponent } from '../uwt-diagnostics-dialog/uwt-diagnostics-dialog.component'; +import { + uwtSafe, + uwtSafeVoid +} from '../uwt-diagnostics-internal.util/uwt-diagnostics-internal.util'; + +/** + * Opens and closes the telemetry diagnostics popup, and listens for its + * keyboard shortcut once {@link provideUwtTelemetryDiagnostics} installs it. + * + * The popup is non-modal: the application stays usable while events + * arrive. With the popup open, the shortcut returns focus to it from the + * application, and closes it when focus is already inside. + * + * @example + * ```typescript + * // From an app's own "Support" menu, when the shortcut is disabled: + * inject(UwtTelemetryDiagnosticsService).open(); + * ``` + * + * @group Services + */ +@Injectable({ providedIn: 'root' }) +export class UwtTelemetryDiagnosticsService { + private readonly dialogs = inject(CpsDialogService); + private readonly document = inject(DOCUMENT); + private readonly zone = inject(NgZone); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + private readonly destroyRef = inject(DestroyRef); + private readonly config: UwtTelemetryDiagnosticsConfig = { + ...UWT_DEFAULT_DIAGNOSTICS_CONFIG, + shortcuts: UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS, + ...inject(UWT_TELEMETRY_DIAGNOSTICS_CONFIG, { optional: true }) + }; + + private readonly enabled = uwtSafe( + 'enabled', + () => + typeof this.config.enabled === 'function' + ? this.config.enabled() + : this.config.enabled, + false + ); + + private ref?: CpsDialogRef; + private installed = false; + + /** Whether the popup is currently open. */ + readonly isOpen = signal(false); + + /** + * Starts listening for the shortcut. Called by + * {@link provideUwtTelemetryDiagnostics}; does nothing on the server, + * when disabled, with no shortcuts, or when already listening. + */ + install(): void { + if ( + this.installed || + !this.isBrowser || + !this.enabled || + this.config.shortcuts.length === 0 + ) { + return; + } + this.installed = true; + + const listener = (event: KeyboardEvent) => + uwtSafeVoid('shortcut', () => { + if (!this.config.shortcuts.some((s) => uwtMatchesShortcut(event, s))) { + return; + } + event.preventDefault(); + this.zone.run(() => this.onShortcut()); + }); + + // Capture phase, so it works even where a component stops propagation. + // Outside Angular's zone, so ordinary typing triggers no change detection. + this.zone.runOutsideAngular(() => + this.document.addEventListener('keydown', listener, true) + ); + this.destroyRef.onDestroy(() => { + this.document.removeEventListener('keydown', listener, true); + this.close(); + }); + } + + /** Opens the popup, or moves focus into it when already open. */ + open(): void { + if (!this.enabled || !this.isBrowser) { + return; + } + if (this.ref) { + this.ref.componentInstance?.focusSearch(); + return; + } + uwtSafeVoid('open', () => { + const ref: CpsDialogRef = + this.dialogs.open(UwtDiagnosticsDialogComponent, { + headerTitle: 'Telemetry diagnostics', + ariaLabel: 'Telemetry diagnostics', + modal: false, + draggable: true, + resizable: true, + maximizable: true, + closeOnEscape: true, + // Docked right at half the screen, so the application stays + // visible and clickable beside it; resizable, and maximizable + // for a closer look. + position: 'right', + width: 'min(64rem, 50vw)', + height: '88vh', + minWidth: 'min(22rem, 96vw)', + autoFocus: '.uwt-diagnostics-search input' + }); + this.ref = ref; + this.isOpen.set(true); + ref.onDestroy.subscribe(() => { + if (this.ref === ref) { + this.ref = undefined; + this.isOpen.set(false); + } + }); + }); + } + + /** Closes the popup, discarding what it captured. */ + close(): void { + uwtSafeVoid('close', () => this.ref?.close()); + } + + /** + * What the shortcut does: open when closed; with the popup open, close + * it if focus is inside, otherwise bring focus back to it. + */ + toggle(): void { + if (!this.ref) { + this.open(); + } else if (this.ref.componentInstance?.containsFocus()) { + this.close(); + } else { + this.ref.componentInstance?.focusSearch(); + } + } + + private onShortcut(): void { + this.toggle(); + } +} diff --git a/projects/ngx-ui-watchtower/diagnostics/src/public-api.ts b/projects/ngx-ui-watchtower/diagnostics/src/public-api.ts new file mode 100644 index 0000000..ac293ba --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/src/public-api.ts @@ -0,0 +1,21 @@ +/* + * Public API Surface of ngx-ui-watchtower/diagnostics + * + * An in-app popup showing, live, every event this app hands to its + * telemetry destinations. Needs cps-ui-kit. + */ + +export { + UWT_TELEMETRY_DIAGNOSTICS_CONFIG, + UwtDiagnosticsExport, + UwtDiagnosticsFieldFilter, + UwtDiagnosticsFilterOperator, + UwtDiagnosticsFilterState, + UwtDiagnosticsSectionId, + UwtDiagnosticsShortcut, + UwtTelemetryDiagnosticsConfig +} from './lib/uwt-diagnostics.models/uwt-diagnostics.models'; +export { UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS } from './lib/uwt-diagnostics-shortcut/uwt-diagnostics-shortcut'; +export { UWT_DEFAULT_DIAGNOSTICS_CONFIG } from './lib/uwt-diagnostics-store/uwt-diagnostics-store'; +export { provideUwtTelemetryDiagnostics } from './lib/uwt-diagnostics.providers/uwt-diagnostics.providers'; +export { UwtTelemetryDiagnosticsService } from './lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service'; diff --git a/projects/ngx-ui-watchtower/diagnostics/tsconfig.lib.json b/projects/ngx-ui-watchtower/diagnostics/tsconfig.lib.json new file mode 100644 index 0000000..04a9759 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/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-diagnostics", + "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/ngx-ui-watchtower/diagnostics/tsconfig.spec.json b/projects/ngx-ui-watchtower/diagnostics/tsconfig.spec.json new file mode 100644 index 0000000..a78ee90 --- /dev/null +++ b/projects/ngx-ui-watchtower/diagnostics/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-diagnostics", + "rootDir": "../../..", + "types": ["jest"], + "resolveJsonModule": true + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/projects/ngx-ui-watchtower/package.json b/projects/ngx-ui-watchtower/package.json index bf8fa70..15de4cd 100644 --- a/projects/ngx-ui-watchtower/package.json +++ b/projects/ngx-ui-watchtower/package.json @@ -5,12 +5,24 @@ "peerDependencies": { "@angular/common": "^22.1.3", "@angular/core": "^22.1.3", + "@angular/animations": "^22.1.3", + "@angular/forms": "^22.1.3", "aws-rum-web": "^3.2.1", + "cps-ui-kit": "^22.0.0", "rxjs": "^7.8.2" }, "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + }, + "@angular/forms": { + "optional": true + }, "aws-rum-web": { "optional": true + }, + "cps-ui-kit": { + "optional": true } }, "repository": { diff --git a/projects/ngx-ui-watchtower/src/lib/models/uwt-telemetry-monitor.models/uwt-telemetry-monitor.models.ts b/projects/ngx-ui-watchtower/src/lib/models/uwt-telemetry-monitor.models/uwt-telemetry-monitor.models.ts new file mode 100644 index 0000000..c04100f --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/models/uwt-telemetry-monitor.models/uwt-telemetry-monitor.models.ts @@ -0,0 +1,125 @@ +import { UwtBIEvent } from '../uwt-bi.models/uwt-bi.models'; +import { UwtLogRecord } from '../uwt-log.models/uwt-log.models'; +import { + UwtScenarioRecord, + UwtScenarioStepEvent +} from '../uwt-scenario.models/uwt-scenario.models'; +import { UwtTelemetryError } from '../uwt-telemetry-common.models/uwt-telemetry-common.models'; + +/** + * Any JSON value — used where a payload's shape is not guaranteed, such as + * an event forwarded from another realm. + * + * @group Types + */ +export type UwtJsonValue = + | string + | number + | boolean + | null + | UwtJsonValue[] + | { [key: string]: UwtJsonValue }; + +/** + * A JSON object — see {@link UwtJsonValue}. + * + * @group Types + */ +export type UwtJsonObject = { [key: string]: UwtJsonValue }; + +/** + * Where an observed event was handed: the telemetry sink, or the + * application's log API provider. + * + * @group Types + */ +export type UwtTelemetryDestination = 'sink' | 'log-provider'; + +/** + * Whether an observed event was produced in this realm, or forwarded from + * another realm through {@link UwtTelemetryBroadcastHost}. + * + * @group Types + */ +export type UwtTelemetryEventOrigin = + { forwarded: false } | { forwarded: true; application?: string }; + +/** + * What an observed event is. + * + * - `bi`, `scenario`, `scenario-step` — custom events sent to the sink. + * - `log` — a record sent to the log API provider. + * - `error` — an error sent to the sink, e.g. a log error mirrored to RUM. + * - `unknown` — a forwarded event whose type this realm does not recognise. + * + * @group Types + */ +export type UwtTelemetryEventKind = + 'bi' | 'scenario' | 'scenario-step' | 'log' | 'error' | 'unknown'; + +interface UwtTelemetryObservedBase { + /** Increases by one per observed event, within one page load. */ + sequence: number; + + /** When the event was handed over, as ISO-8601. */ + capturedAt: string; + + destination: UwtTelemetryDestination; + origin: UwtTelemetryEventOrigin; +} + +/** + * One event handed to a destination, as seen by + * {@link UwtTelemetryMonitor}. + * + * Local payloads keep their library types. A forwarded payload arrived from + * another realm and was only shape-checked there, so it is typed as plain + * JSON — narrow on `origin.forwarded` before relying on its fields. + * + * @group Types + */ +export type UwtTelemetryObservedEvent = + | (UwtTelemetryObservedBase & { + kind: 'bi'; + eventType: string; + payload: UwtBIEvent | UwtJsonObject; + }) + | (UwtTelemetryObservedBase & { + kind: 'scenario'; + eventType: string; + payload: UwtScenarioRecord | UwtJsonObject; + }) + | (UwtTelemetryObservedBase & { + kind: 'scenario-step'; + eventType: string; + payload: UwtScenarioStepEvent | UwtJsonObject; + }) + | (UwtTelemetryObservedBase & { + kind: 'log'; + payload: UwtLogRecord; + }) + | (UwtTelemetryObservedBase & { + kind: 'error'; + payload: UwtTelemetryError; + /** The log event this error was mirrored from, when there is one. */ + relatedSequence?: number; + }) + | (UwtTelemetryObservedBase & { + kind: 'unknown'; + eventType: string; + payload: UwtJsonObject; + }); + +/** Distributes `Omit` over a union, so each member keeps its own fields. */ +type UwtOmitEach = T extends unknown + ? Omit + : never; + +/** + * What a hand-off site passes to {@link UwtTelemetryMonitor.publish} — an + * observed event without the fields the monitor assigns itself. + */ +export type UwtTelemetryPublishInput = UwtOmitEach< + UwtTelemetryObservedEvent, + 'sequence' | 'capturedAt' +>; diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts index 419c407..d162b14 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts @@ -4,6 +4,7 @@ import { UwtTelemetryMetadata } from '../../models/uwt-telemetry-common.models/u import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { UWT_DEFAULT_REDACT_CONFIG } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; import { UwtScenario, UwtScenarioDeps } from './uwt-scenario'; +import { UwtTelemetryMonitor } from '../../services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.service'; /** Captures what the scenario emitted, so a test can assert on it. */ class RecordingSink extends UwtTelemetrySink { @@ -54,6 +55,7 @@ function createDeps(overrides: Partial = {}): { redact: UWT_DEFAULT_REDACT_CONFIG, sink, onSettled: (_id, record) => settled.push(record), + monitor: new UwtTelemetryMonitor(), ...overrides }; return { deps, sink, settled }; diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts index d67057a..f582390 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts @@ -18,6 +18,7 @@ import { UwtTelemetryEventTypes, UwtTelemetryMetadata } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtTelemetryMonitor } from '../../services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.service'; import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { uwtDebugWrite, @@ -62,6 +63,8 @@ export interface UwtScenarioDeps { runOutsideAngular?: (callback: () => T) => T; /** Called once with the scenario's id when it reaches a terminal state. */ onSettled: (scenarioId: string, record: UwtScenarioRecord) => void; + /** Told about every event right after it is handed to the sink. */ + monitor: UwtTelemetryMonitor; } /** The two events a scenario sends: its settled record, and a closed step. */ @@ -829,10 +832,11 @@ export class UwtScenario { /** * Sends a payload to the sink, first logging that exact same object if - * `debugScenario` is on. + * `debugScenario` is on, then telling the monitor it was handed over. * - * The only place either happens, so console output and sink output can - * never drift apart: one object, one call site. + * The only place any of the three happens, so console output, sink output + * and what an observer sees can never drift apart: one object, one call + * site. * * @param emission the payload and which of the two events it is * @param summary a short human-readable prefix for the console line @@ -847,6 +851,12 @@ export class UwtScenario { writeToConsole(summary, eventType, emission.payload) ); this.deps.sink.record(eventType, emission.payload); + this.deps.monitor.publish({ + ...emission, + eventType, + destination: 'sink', + origin: { forwarded: false } + }); } } diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts index d827f3b..1c5ce81 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts @@ -13,6 +13,7 @@ import { uwtEventTypes, UwtTelemetryMetadata } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtTelemetryMonitor } from '../uwt-telemetry-monitor.service/uwt-telemetry-monitor.service'; import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; import { @@ -68,6 +69,7 @@ export class UwtBITelemetryService { ); private readonly sink = inject(UwtTelemetrySink); + private readonly monitor = inject(UwtTelemetryMonitor); private readonly eventTypes = uwtEventTypes(this.identity.eventNamespace); private readonly lastEmittedAt = new Map(); @@ -120,6 +122,13 @@ export class UwtBITelemetryService { ); this.sink.record(eventType, event); + this.monitor.publish({ + kind: 'bi', + eventType, + payload: event, + destination: 'sink', + origin: { forwarded: false } + }); }); } diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts index a6d4609..14f2edf 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts @@ -13,6 +13,7 @@ import { type UwtLogLevel, type UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { UwtTelemetryMonitor } from '../uwt-telemetry-monitor.service/uwt-telemetry-monitor.service'; import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { UWT_LOG_API_PROVIDER, @@ -81,6 +82,7 @@ export class UwtLoggerService implements OnDestroy { /** Enrichment only (`sessionId`/`userId`, optional RUM mirroring). */ private readonly sink = inject(UwtTelemetrySink, { optional: true }); private readonly apiProvider = inject(UWT_LOG_API_PROVIDER); + private readonly monitor = inject(UwtTelemetryMonitor); private readonly document = inject(DOCUMENT); private readonly isBrowser = uwtIsBrowser(); @@ -246,7 +248,7 @@ export class UwtLoggerService implements OnDestroy { uwtDebugWrite('debugLogger', () => writeToConsole(record), record.logger); - this.deliver(record); + const sequence = this.deliver(record); if (level === 'error' && this.logsConfig.mirrorErrorsToRum && this.sink) { const mirrored = @@ -254,16 +256,39 @@ export class UwtLoggerService implements OnDestroy { uwtNormalizeError(new Error(record.message), this.redact); if (mirrored) { this.sink.recordError(mirrored); + this.monitor.publish({ + kind: 'error', + payload: mirrored, + relatedSequence: sequence, + destination: 'sink', + origin: { forwarded: false } + }); } } }); } - /** Guards against a throwing or secretly-async, rejecting provider. */ - private deliver(record: UwtLogRecord): void { - uwtSafeVoidMaybeAsync('logger.deliver', () => - this.apiProvider.send(record) - ); + /** + * Guards against a throwing or secretly-async, rejecting provider, and + * tells the monitor once the record has been handed over. + * + * @returns the monitor's sequence number for the record, when observed + */ + private deliver(record: UwtLogRecord): number | undefined { + let sequence: number | undefined; + uwtSafeVoidMaybeAsync('logger.deliver', () => { + const pending = this.apiProvider.send(record); + // After send, so a provider that throws synchronously is not reported + // as handed over. An asynchronous rejection can't be seen here. + sequence = this.monitor.publish({ + kind: 'log', + payload: record, + destination: 'log-provider', + origin: { forwarded: false } + }); + return pending; + }); + return sequence; } /** Gives the provider its chance to ship whatever it has queued itself. */ diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts index c2129f0..8fd0703 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts @@ -20,6 +20,7 @@ import { } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; import { uwtRedactConfigFor } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; import { UwtScenario } from '../../scenario/uwt-scenario/uwt-scenario'; +import { UwtTelemetryMonitor } from '../uwt-telemetry-monitor.service/uwt-telemetry-monitor.service'; /** * How many scenarios may be in flight before a development-mode warning @@ -68,6 +69,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { ); private readonly sink = inject(UwtTelemetrySink); + private readonly monitor = inject(UwtTelemetryMonitor); private readonly document = inject(DOCUMENT); private readonly isBrowser = uwtIsBrowser(); private readonly zone = inject(NgZone, { optional: true }); @@ -122,6 +124,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { scenarioTelemetryConfig: this.scenarioTelemetryConfig, redact: this.redact, sink: this.sink, + monitor: this.monitor, runOutsideAngular: (callback) => this.zone?.runOutsideAngular(callback) ?? callback(), onSettled: (scenarioId, record) => { diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.integration.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.integration.spec.ts new file mode 100644 index 0000000..5938741 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.integration.spec.ts @@ -0,0 +1,210 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + provideUwtTelemetry, + withLogging, + withScenarios +} from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; +import { + UWT_LOG_API_PROVIDER, + UwtLogApiProvider +} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtTelemetryObservedEvent } from '../../models/uwt-telemetry-monitor.models/uwt-telemetry-monitor.models'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UwtBITelemetryService } from '../uwt-bi-telemetry.service/uwt-bi-telemetry.service'; +import { UwtLoggerService } from '../uwt-logger.service/uwt-logger.service'; +import { UwtScenarioTelemetryService } from '../uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; +import { UwtTelemetryMonitor } from './uwt-telemetry-monitor.service'; + +/** Counts every hand-off, so a test can prove nothing is sent twice. */ +@Injectable() +class CountingSink extends UwtTelemetrySink { + readonly records: { eventType: string; payload: object }[] = []; + readonly errors: UwtTelemetryError[] = []; + throwOnRecord = false; + + record(eventType: string, payload: object, _m?: UwtTelemetryMetadata): void { + if (this.throwOnRecord) { + throw new Error('sink is down'); + } + this.records.push({ eventType, payload }); + } + + recordError(error: UwtTelemetryError): void { + this.errors.push(error); + } + + getSessionId(): string | undefined { + return 'session-1'; + } + + setUserId(): void {} + + getUserId(): string | undefined { + return undefined; + } + + flush(): void {} +} + +@Injectable() +class CountingLogApi implements UwtLogApiProvider { + readonly sent: UwtLogRecord[] = []; + throwOnSend = false; + + send(record: UwtLogRecord): void { + if (this.throwOnSend) { + throw new Error('log backend is down'); + } + this.sent.push(record); + } + + query(): Promise { + return Promise.resolve([]); + } +} + +describe('UwtTelemetryMonitor at the hand-off sites', () => { + let observed: UwtTelemetryObservedEvent[]; + let sink: CountingSink; + let logApi: CountingLogApi; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideUwtTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withScenarios({ defaultTimeoutMs: 0, emitLifecycleEvents: true }), + withLogging({ mirrorErrorsToRum: true }) + ), + CountingSink, + { provide: UwtTelemetrySink, useExisting: CountingSink }, + CountingLogApi, + { provide: UWT_LOG_API_PROVIDER, useExisting: CountingLogApi } + ] + }); + sink = TestBed.inject(CountingSink); + logApi = TestBed.inject(CountingLogApi); + observed = []; + TestBed.inject(UwtTelemetryMonitor).events$.subscribe((e) => + observed.push(e) + ); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('should observe a BI event once, and send it once', () => { + TestBed.inject(UwtBITelemetryService).track('export_clicked', { + format: 'csv' + }); + + expect(sink.records).toHaveLength(1); + expect(observed).toHaveLength(1); + expect(observed[0]).toMatchObject({ + kind: 'bi', + eventType: 'com.uwt.bi', + destination: 'sink', + origin: { forwarded: false }, + payload: { eventName: 'export_clicked', metadata: { format: 'csv' } } + }); + }); + + it('should observe each scenario step and the settled record, once each', () => { + TestBed.inject(UwtScenarioTelemetryService) + .start({ name: 'load' }) + .step('one') + .step('two') + .complete(); + + expect(observed.map((e) => e.kind)).toEqual([ + 'scenario-step', + 'scenario-step', + 'scenario' + ]); + expect(observed.map((e) => 'eventType' in e && e.eventType)).toEqual([ + 'com.uwt.scenario.step', + 'com.uwt.scenario.step', + 'com.uwt.scenario' + ]); + + expect(sink.records).toHaveLength(observed.length); + expect(observed[2].payload).toEqual(sink.records[2].payload); + }); + + it('should observe a log record as handed to the log provider', () => { + TestBed.inject(UwtLoggerService).getLogger('checkout').warn('careful'); + + expect(logApi.sent).toHaveLength(1); + expect(observed).toHaveLength(1); + expect(observed[0]).toMatchObject({ + kind: 'log', + destination: 'log-provider', + payload: { logger: 'checkout', level: 'warn', message: 'careful' } + }); + }); + + it('should observe a mirrored error, linked to the log line it came from', () => { + TestBed.inject(UwtLoggerService) + .getLogger('checkout') + .error('failed', { error: new Error('boom') }); + + expect(sink.errors).toHaveLength(1); + const [log, error] = observed; + expect(log.kind).toBe('log'); + expect(error).toMatchObject({ + kind: 'error', + destination: 'sink', + relatedSequence: log.sequence, + payload: { name: 'Error', message: 'boom' } + }); + }); + + it('should observe nothing for a hand-off that threw', () => { + sink.throwOnRecord = true; + logApi.throwOnSend = true; + + TestBed.inject(UwtBITelemetryService).track('export_clicked'); + TestBed.inject(UwtLoggerService).getLogger('checkout').log('lost'); + + expect(observed).toHaveLength(0); + }); + + it('should send exactly the same number of events whether observed or not', () => { + const run = () => { + TestBed.inject(UwtBITelemetryService).track('a'); + TestBed.inject(UwtScenarioTelemetryService) + .start({ name: 'load' }) + .step('one') + .complete(); + TestBed.inject(UwtLoggerService).getLogger('checkout').log('hi'); + }; + + run(); + const observedCounts = [sink.records.length, logApi.sent.length]; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideUwtTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withScenarios({ defaultTimeoutMs: 0, emitLifecycleEvents: true }) + ), + CountingSink, + { provide: UwtTelemetrySink, useExisting: CountingSink }, + CountingLogApi, + { provide: UWT_LOG_API_PROVIDER, useExisting: CountingLogApi } + ] + }); + sink = TestBed.inject(CountingSink); + logApi = TestBed.inject(CountingLogApi); + run(); + + expect([sink.records.length, logApi.sent.length]).toEqual(observedCounts); + }); +}); diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.service.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.service.spec.ts new file mode 100644 index 0000000..78baff6 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.service.spec.ts @@ -0,0 +1,118 @@ +import { isDevMode } from '@angular/core'; +import { UwtBIEvent } from '../../models/uwt-bi.models/uwt-bi.models'; +import { UwtTelemetryObservedEvent } from '../../models/uwt-telemetry-monitor.models/uwt-telemetry-monitor.models'; +import * as safeUtil from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UwtTelemetryMonitor } from './uwt-telemetry-monitor.service'; + +jest.mock('@angular/core', () => ({ + ...jest.requireActual('@angular/core'), + isDevMode: jest.fn(() => false) +})); + +function biEvent(): UwtBIEvent { + return { + eventName: 'export_clicked', + eventTime: '2026-01-01T00:00:00.000Z', + metadata: { format: 'csv' }, + application: 'test-app' + }; +} + +describe('UwtTelemetryMonitor', () => { + let monitor: UwtTelemetryMonitor; + let received: UwtTelemetryObservedEvent[]; + + beforeEach(() => { + monitor = new UwtTelemetryMonitor(); + received = []; + }); + + afterEach(() => { + monitor.ngOnDestroy(); + jest.restoreAllMocks(); + }); + + function publishBI(payload = biEvent()): number | undefined { + return monitor.publish({ + kind: 'bi', + eventType: 'com.uwt.bi', + payload, + destination: 'sink', + origin: { forwarded: false } + }); + } + + it('should do nothing, and copy nothing, while nobody is subscribed', () => { + const clone = jest.spyOn(safeUtil, 'uwtDeepClone'); + + expect(monitor.observed).toBe(false); + expect(publishBI()).toBeUndefined(); + expect(clone).not.toHaveBeenCalled(); + }); + + it('should deliver an event to a subscriber, with the envelope filled in', () => { + monitor.events$.subscribe((e) => received.push(e)); + + const sequence = publishBI(); + + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ + kind: 'bi', + eventType: 'com.uwt.bi', + destination: 'sink', + origin: { forwarded: false }, + sequence + }); + expect(received[0].capturedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + ); + }); + + it('should hand a subscriber a copy, isolated from the object that was sent', () => { + monitor.events$.subscribe((e) => received.push(e)); + const sent = biEvent(); + + publishBI(sent); + const observed = received[0].payload as UwtBIEvent; + + expect(observed).toEqual(sent); + expect(observed).not.toBe(sent); + + observed.metadata!.format = 'changed-by-observer'; + sent.metadata!.format = 'changed-by-sender'; + expect(sent.metadata!.format).toBe('changed-by-sender'); + expect(observed.metadata!.format).toBe('changed-by-observer'); + }); + + it('should number events in increasing order', () => { + monitor.events$.subscribe((e) => received.push(e)); + + publishBI(); + publishBI(); + publishBI(); + + const [a, b, c] = received.map((e) => e.sequence); + expect(b).toBe(a + 1); + expect(c).toBe(b + 1); + }); + + it('should stop observing once the last subscriber leaves', () => { + const subscription = monitor.events$.subscribe((e) => received.push(e)); + subscription.unsubscribe(); + + expect(monitor.observed).toBe(false); + expect(publishBI()).toBeUndefined(); + expect(received).toHaveLength(0); + }); + + it('should never throw into the hand-off site, even when copying fails', () => { + (isDevMode as jest.Mock).mockReturnValue(false); + monitor.events$.subscribe((e) => received.push(e)); + jest.spyOn(safeUtil, 'uwtDeepClone').mockImplementation(() => { + throw new Error('clone failed'); + }); + + expect(() => publishBI()).not.toThrow(); + expect(received).toHaveLength(0); + }); +}); diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.service.ts new file mode 100644 index 0000000..2c2d257 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.service.ts @@ -0,0 +1,81 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { + UwtTelemetryObservedEvent, + UwtTelemetryPublishInput +} from '../../models/uwt-telemetry-monitor.models/uwt-telemetry-monitor.models'; +import { + uwtDeepClone, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; + +/** + * A read-only view of every event this library hands to a destination — + * the telemetry sink or the application's log API provider. + * + * Each hand-off site publishes here immediately *after* the hand-off, with + * the very object it just sent. Nothing is sent from here: this is an + * in-memory stream for observers such as the diagnostics popup, never a + * second delivery path, so observing it cannot cause an event to be sent + * twice. + * + * Costs one check per event while nobody subscribes. A subscriber receives + * a deep copy of each payload, so it can neither change what was sent nor + * see a live object change after the fact. + * + * "Handed over" is not "delivered": a sink may still drop an event, for + * example when RUM samples a session out or reaches its event limit. + * + * @example + * ```typescript + * inject(UwtTelemetryMonitor) + * .events$.pipe(filter((e) => e.kind === 'scenario')) + * .subscribe((e) => console.table(e.payload)); + * ``` + * + * @group Services + */ +@Injectable({ providedIn: 'root' }) +export class UwtTelemetryMonitor implements OnDestroy { + private readonly subject = new Subject(); + private sequence = 0; + + /** Every observed event, from the moment of subscription onward. */ + readonly events$: Observable = + this.subject.asObservable(); + + /** Whether anything is currently subscribed to {@link events$}. */ + get observed(): boolean { + return this.subject.observed; + } + + /** + * Records one hand-off. Called by the library's own hand-off sites; an + * application has no reason to call it. + * + * @param input the event just handed over, and where it went + * @returns the sequence number assigned, or `undefined` when nobody is + * subscribed and the event was not recorded + */ + publish(input: UwtTelemetryPublishInput): number | undefined { + if (!this.subject.observed) { + return undefined; + } + + const sequence = ++this.sequence; + uwtSafeVoid('monitor.publish', () => { + this.subject.next({ + ...input, + payload: uwtDeepClone(input.payload), + sequence, + capturedAt: new Date().toISOString() + } as UwtTelemetryObservedEvent); + }); + return sequence; + } + + /** @inheritdoc */ + ngOnDestroy(): void { + this.subject.complete(); + } +} diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts index 684d1b5..c87dc4a 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts @@ -11,9 +11,19 @@ import { uwtIsBroadcastMessage } from './uwt-broadcast.messages'; import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { uwtClassifyTelemetryEvent } from '../../utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; import { UwtBroadcastLogApiProvider } from '../../providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; +import { UwtTelemetryMonitor } from '../../services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.service'; +import { + UwtJsonObject, + UwtTelemetryEventOrigin +} from '../../models/uwt-telemetry-monitor.models/uwt-telemetry-monitor.models'; import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; import { UWT_LOG_API_PROVIDER, UwtLogQuery @@ -25,6 +35,16 @@ import { */ const hostsInThisRealm = new Map(); +/** + * A forwarded error-level log record, kept for exactly one message in case + * the next is its `mirrorErrorsToRum` copy. + */ +interface UwtForwardedErrorLog { + application: string; + expected: UwtTelemetryError; + sequence: number; +} + /** * Receives telemetry forwarded by follower realms and records it through this * realm's sink. @@ -63,6 +83,7 @@ const hostsInThisRealm = new Map(); @Injectable() export class UwtTelemetryBroadcastHost implements OnDestroy { private readonly sink = inject(UwtTelemetrySink); + private readonly monitor = inject(UwtTelemetryMonitor); private readonly logApi = inject(UWT_LOG_API_PROVIDER); private readonly connection: UwtBroadcastConnection = uwtConnectBroadcastChannel('broadcastHost'); @@ -83,6 +104,9 @@ export class UwtTelemetryBroadcastHost implements OnDestroy { private isLeader = false; private releaseLeadership: () => void = () => undefined; + /** The last message, when it was an error-level log record. */ + private lastErrorLog?: UwtForwardedErrorLog; + constructor() { this.warnIfDuplicateInThisRealm(); if (this.forwardsItsOwnRealm()) { @@ -126,13 +150,35 @@ export class UwtTelemetryBroadcastHost implements OnDestroy { this._received++; } + // A mirror arrives straight after its log record, so the candidate + // lives for one message only. + const errorLog = this.lastErrorLog; + this.lastErrorLog = undefined; + switch (data.kind) { case 'event': this.sink.record(data.eventType, data.payload, data.metadata); + this.monitor.publish({ + kind: uwtClassifyTelemetryEvent(data.eventType, data.payload).kind, + eventType: data.eventType, + payload: data.payload as UwtJsonObject, + destination: 'sink', + origin: forwardedFrom(data.metadata?.application) + }); this.reannounceIfIdentityChanged(); break; case 'error': this.sink.recordError(data.error, data.metadata); + this.monitor.publish({ + kind: 'error', + payload: data.error, + ...(errorLog && + isMirrorOf(errorLog, data.error, data.metadata) && { + relatedSequence: errorLog.sequence + }), + destination: 'sink', + origin: forwardedFrom(data.metadata?.application) + }); this.reannounceIfIdentityChanged(); break; case 'user': @@ -170,9 +216,29 @@ export class UwtTelemetryBroadcastHost implements OnDestroy { }); } - /** Hands a follower's record to this realm's log API provider. */ + /** + * Hands a follower's record to this realm's log API provider, and tells + * the monitor once it has — as {@link UwtLoggerService} does for its own. + */ private deliverLog(record: UwtLogRecord): void { - uwtSafeVoidMaybeAsync('broadcastHost.log', () => this.logApi.send(record)); + uwtSafeVoidMaybeAsync('broadcastHost.log', () => { + const pending = this.logApi.send(record); + const sequence = this.monitor.publish({ + kind: 'log', + payload: record, + destination: 'log-provider', + origin: forwardedFrom(record.application) + }); + if (record.level === 'error' && sequence !== undefined) { + this.lastErrorLog = { + application: record.application, + // What UwtLoggerService mirrors to RUM for this record. + expected: record.error ?? { name: 'Error', message: record.message }, + sequence + }; + } + return pending; + }); } /** @@ -292,3 +358,32 @@ export class UwtTelemetryBroadcastHost implements OnDestroy { ); } } + +/** + * Whether a forwarded error is the `mirrorErrorsToRum` copy of the error-level + * log record just before it: same realm, same name and message. A mirror + * the logger had to rewrite simply goes unlinked. + */ +function isMirrorOf( + errorLog: UwtForwardedErrorLog, + error: UwtTelemetryError, + metadata: UwtTelemetryMetadata | undefined +): boolean { + return ( + metadata?.application === errorLog.application && + error.name === errorLog.expected.name && + error.message === errorLog.expected.message + ); +} + +/** + * The forwarding realm: the `application` a follower sink stamps onto + * metadata, or a log record's own. + */ +function forwardedFrom( + application: UwtTelemetryMetadata[string] | undefined +): UwtTelemetryEventOrigin { + return typeof application === 'string' + ? { forwarded: true, application } + : { forwarded: true }; +} diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts index 6a59da5..9fdcd3f 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts @@ -35,6 +35,8 @@ import { uwtIsBroadcastMessage } from './uwt-broadcast.messages'; import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UwtTelemetryMonitor } from '../../services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.service'; +import { UwtTelemetryObservedEvent } from '../../models/uwt-telemetry-monitor.models/uwt-telemetry-monitor.models'; /** * Each realm gets its own injector — the shell and every fragment run in a @@ -43,6 +45,7 @@ import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/u function createRealm(providers: unknown[]): Injector { return Injector.create({ providers: [ + UwtTelemetryMonitor, { provide: PLATFORM_ID, useValue: 'browser' }, { provide: DOCUMENT, useValue: document }, { @@ -374,6 +377,69 @@ describe('broadcast telemetry across realms', () => { expect(shellSink.events[0].metadata).toMatchObject({ feature: 'cart' }); }); + it('should show a forwarded event to the shell monitor, marked with its realm', async () => { + const observed: UwtTelemetryObservedEvent[] = []; + shell.get(UwtTelemetryMonitor).events$.subscribe((e) => observed.push(e)); + const fragment = createFragment(); + + fragment + .get(UwtTelemetrySink) + .record('com.uwt.scenario.step', { scenarioName: 'load' }); + fragment.get(UwtTelemetrySink).record('com.uwt.bi', { eventName: 'x' }); + fragment.get(UwtTelemetrySink).record('custom.type', { a: 1 }); + fragment + .get(UwtTelemetrySink) + .recordError({ name: 'TypeError', message: 'boom' }); + await UwtBroadcastChannelStub.settle(); + + expect(observed.map((e) => e.kind)).toEqual([ + 'scenario-step', + 'bi', + 'unknown', + 'error' + ]); + for (const e of observed) { + expect(e.origin).toEqual({ forwarded: true, application: 'realm' }); + expect(e.destination).toBe('sink'); + } + expect(shellSink.events).toHaveLength(3); + expect(shellSink.errors).toHaveLength(1); + }); + + it('should show a fragment scenario once per monitor — local in the fragment, forwarded in the shell', async () => { + const fragment = createFragment(); + const inShell: UwtTelemetryObservedEvent[] = []; + const inFragment: UwtTelemetryObservedEvent[] = []; + shell.get(UwtTelemetryMonitor).events$.subscribe((e) => inShell.push(e)); + fragment + .get(UwtTelemetryMonitor) + .events$.subscribe((e) => inFragment.push(e)); + + fragment + .get(UwtScenarioTelemetryService) + .start({ name: 'load' }) + .complete(); + await UwtBroadcastChannelStub.settle(); + + expect(fragment.get(UwtTelemetryMonitor)).not.toBe( + shell.get(UwtTelemetryMonitor) + ); + expect(inFragment).toHaveLength(1); + expect(inFragment[0]).toMatchObject({ + kind: 'scenario', + origin: { forwarded: false } + }); + expect(inShell).toHaveLength(1); + expect(inShell[0]).toMatchObject({ + kind: 'scenario', + origin: { forwarded: true, application: 'realm' } + }); + + expect( + shellSink.events.filter((e) => e.eventType === 'com.uwt.scenario') + ).toHaveLength(1); + }); + it('should forward handled errors', async () => { const fragment = createFragment(); fragment @@ -612,6 +678,35 @@ describe('broadcast telemetry across realms', () => { expect(fragment.get(RecordingLogApi).records).toHaveLength(0); }); + it('should show a forwarded record once per monitor — local in the fragment, forwarded in the shell', async () => { + const fragment = createFragment(); + const inShell: UwtTelemetryObservedEvent[] = []; + const inFragment: UwtTelemetryObservedEvent[] = []; + shell.get(UwtTelemetryMonitor).events$.subscribe((e) => inShell.push(e)); + fragment + .get(UwtTelemetryMonitor) + .events$.subscribe((e) => inFragment.push(e)); + + fragment.get(UwtLoggerService).getLogger('cart').log('Hello'); + await UwtBroadcastChannelStub.settle(); + + expect(inFragment).toEqual([ + expect.objectContaining({ + kind: 'log', + destination: 'log-provider', + origin: { forwarded: false } + }) + ]); + expect(inShell).toEqual([ + expect.objectContaining({ + kind: 'log', + destination: 'log-provider', + origin: { forwarded: true, application: 'realm' }, + payload: expect.objectContaining({ message: 'Hello' }) + }) + ]); + }); + it('should forward a flush to the shell log provider', async () => { const fragment = createFragment(); fragment.get(UWT_LOG_API_PROVIDER).flush?.(); @@ -702,6 +797,7 @@ describe('broadcast telemetry across realms', () => { it('should fail at injection when the shell binds no log provider', () => { const bareShell = Injector.create({ providers: [ + UwtTelemetryMonitor, { provide: PLATFORM_ID, useValue: 'browser' }, RecordingSink, { provide: UwtTelemetrySink, useExisting: RecordingSink }, @@ -713,6 +809,64 @@ describe('broadcast telemetry across realms', () => { /UWT_LOG_API_PROVIDER/ ); }); + + it('should link a mirrored error to its forwarded log record in the shell monitor', async () => { + const fragment = createRealm([ + UwtBroadcastTelemetrySink, + { provide: UwtTelemetrySink, useExisting: UwtBroadcastTelemetrySink }, + UwtBroadcastLogApiProvider, + { + provide: UWT_LOG_API_PROVIDER, + useExisting: UwtBroadcastLogApiProvider + }, + { + provide: UWT_LOG_CONFIG, + useValue: { + ...UWT_DEFAULT_TELEMETRY_CONFIG.logs, + mirrorErrorsToRum: true + } + } + ]); + const inShell: UwtTelemetryObservedEvent[] = []; + shell.get(UwtTelemetryMonitor).events$.subscribe((e) => inShell.push(e)); + + const logger = fragment.get(UwtLoggerService).getLogger('cart'); + logger.error('Payment failed'); + logger.error('Card declined', { error: new TypeError('declined') }); + await UwtBroadcastChannelStub.settle(); + + expect(inShell.map((e) => e.kind)).toEqual([ + 'log', + 'error', + 'log', + 'error' + ]); + const [firstLog, firstMirror, secondLog, secondMirror] = inShell; + expect(firstMirror).toMatchObject({ + payload: { name: 'Error', message: 'Payment failed' }, + relatedSequence: firstLog.sequence + }); + expect(secondMirror).toMatchObject({ + payload: { name: 'TypeError', message: 'declined' }, + relatedSequence: secondLog.sequence + }); + }); + + it('should not link an error that is not the mirror of the record before it', async () => { + const fragment = createFragment(); + const inShell: UwtTelemetryObservedEvent[] = []; + shell.get(UwtTelemetryMonitor).events$.subscribe((e) => inShell.push(e)); + + fragment.get(UwtLoggerService).getLogger('cart').error('Payment failed'); + fragment + .get(UwtTelemetrySink) + .recordError({ name: 'Error', message: 'Something else' }); + await UwtBroadcastChannelStub.settle(); + + const error = inShell.find((e) => e.kind === 'error'); + expect(error).toBeDefined(); + expect(error).not.toHaveProperty('relatedSequence'); + }); }); describe('robustness', () => { diff --git a/projects/ngx-ui-watchtower/src/public-api.ts b/projects/ngx-ui-watchtower/src/public-api.ts index 566884e..6f809be 100644 --- a/projects/ngx-ui-watchtower/src/public-api.ts +++ b/projects/ngx-ui-watchtower/src/public-api.ts @@ -83,11 +83,21 @@ export type { UwtTelemetryEventTypes, UwtTelemetryMetadata } from './lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +export { + UwtJsonObject, + UwtJsonValue, + UwtTelemetryDestination, + UwtTelemetryEventKind, + UwtTelemetryEventOrigin, + UwtTelemetryObservedEvent, + UwtTelemetryPublishInput +} from './lib/models/uwt-telemetry-monitor.models/uwt-telemetry-monitor.models'; // Services export { UwtBITelemetryService } from './lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service'; export { UwtLoggerService } from './lib/services/uwt-logger.service/uwt-logger.service'; export { UwtScenario } from './lib/scenario/uwt-scenario/uwt-scenario'; +export { UwtTelemetryMonitor } from './lib/services/uwt-telemetry-monitor.service/uwt-telemetry-monitor.service'; export { UwtScenarioTelemetryService } from './lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; // Sinks diff --git a/tsconfig.json b/tsconfig.json index 9e6331e..11d5f81 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,9 @@ ], "@absaoss-cps/ngx-ui-watchtower/rum": [ "./projects/ngx-ui-watchtower/rum/src/public-api.ts" + ], + "@absaoss-cps/ngx-ui-watchtower/diagnostics": [ + "./projects/ngx-ui-watchtower/diagnostics/src/public-api.ts" ] }, "esModuleInterop": true, @@ -42,6 +45,8 @@ { "path": "./projects/ngx-ui-watchtower/tsconfig.lib.json" }, { "path": "./projects/ngx-ui-watchtower/tsconfig.spec.json" }, { "path": "./projects/ngx-ui-watchtower/rum/tsconfig.lib.json" }, - { "path": "./projects/ngx-ui-watchtower/rum/tsconfig.spec.json" } + { "path": "./projects/ngx-ui-watchtower/rum/tsconfig.spec.json" }, + { "path": "./projects/ngx-ui-watchtower/diagnostics/tsconfig.lib.json" }, + { "path": "./projects/ngx-ui-watchtower/diagnostics/tsconfig.spec.json" } ] } From b1824078134d0e48e1f7f34c8c18a7f02a76249d Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 7 Oct 2026 19:57:18 +0200 Subject: [PATCH 02/51] maxChars fix --- projects/ngx-ui-watchtower/DESIGN.md | 12 ++++---- .../uwt-diagnostics-json.component.html | 4 +-- .../uwt-diagnostics-json.component.spec.ts | 28 +++++++++++++++++-- .../uwt-diagnostics-json.component.ts | 20 +++++++++++-- .../uwt-diagnostics.models.ts | 3 +- 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index 49c9f59..8b98dba 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -1972,12 +1972,12 @@ each marked with `↪` and the fragment's name. #### Configuration -| Option | Default | Meaning | -| ----------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `enabled` | `true` | `false`, or a function read once at startup, turns the popup off — shortcut and `open()` alike | -| `shortcuts` | `UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS` | Replaces the defaults; `[]` disables the keyboard | -| `maxEventsPerSection` | `500` | Events kept per section; the oldest are dropped, and the count of dropped ones is shown | -| `maxPayloadCharsInView` | `262144` (256 × 1024) | A payload whose JSON is longer is shown truncated to its first 65,536 characters; downloads stay complete | +| Option | Default | Meaning | +| ----------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `enabled` | `true` | `false`, or a function read once at startup, turns the popup off — shortcut and `open()` alike | +| `shortcuts` | `UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS` | Replaces the defaults; `[]` disables the keyboard | +| `maxEventsPerSection` | `500` | Events kept per section; the oldest are dropped, and the count of dropped ones is shown | +| `maxPayloadCharsInView` | `262144` (256 × 1024) | A payload whose JSON is longer is shown truncated to its first `maxPayloadCharsInView` characters, at most 65,536; downloads stay complete | Characters here are UTF-16 code units (JavaScript's `length`), not bytes: the limit protects rendering, which costs by the length of the text. A cut never diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.html b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.html index f155d11..04def8d 100644 --- a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.html +++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.html @@ -24,8 +24,8 @@ @if (truncated()) {

- Large payload: showing the first {{ shownChars }} characters. The download - contains all of it. + Large payload: showing the first {{ shownChars() }} characters. The + download contains all of it.

}
 {
     fixture.componentRef.setInput('maxChars', 1000);
     fixture.detectChanges();
 
+    expect(pre().textContent!.length).toBe(1000);
+    expect(fixture.nativeElement.textContent).toContain(
+      'showing the first 1,000 characters'
+    );
+    expect(fixture.nativeElement.textContent).toContain(
+      'The download contains all of it.'
+    );
+  });
+
+  it('should show at most 65,536 characters when maxChars is larger', () => {
+    fixture.componentRef.setInput('value', { big: 'x'.repeat(200_000) });
+    fixture.componentRef.setInput('maxChars', 100_000);
+    fixture.detectChanges();
+
     expect(pre().textContent!.length).toBe(64 * 1024);
     expect(fixture.nativeElement.textContent).toContain(
       'showing the first 65,536 characters'
     );
+  });
+
+  it('should show nothing of a payload when maxChars is negative', () => {
+    fixture.componentRef.setInput('value', { big: 'x'.repeat(1000) });
+    fixture.componentRef.setInput('maxChars', -10);
+    fixture.detectChanges();
+
+    expect(pre().textContent).toBe('');
     expect(fixture.nativeElement.textContent).toContain(
-      'The download contains all of it.'
+      'showing the first 0 characters'
     );
   });
 
@@ -53,7 +75,7 @@ describe('UwtDiagnosticsJsonComponent', () => {
   it('should not cut a truncated view through the middle of a surrogate pair', () => {
     const before = '{\n  "big": "';
     // Lines the first emoji up so the cut would fall inside it.
-    const padding = 'x'.repeat(64 * 1024 - 1 - before.length);
+    const padding = 'x'.repeat(1000 - 1 - before.length);
     fixture.componentRef.setInput('value', {
       big: padding + '😀'.repeat(10)
     });
@@ -61,7 +83,7 @@ describe('UwtDiagnosticsJsonComponent', () => {
     fixture.detectChanges();
 
     const shown = pre().textContent!;
-    expect(shown.length).toBe(64 * 1024 - 1);
+    expect(shown.length).toBe(1000 - 1);
     expect(shown.endsWith('x')).toBe(true);
   });
 
diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.ts
index a4ac7d3..f24e7aa 100644
--- a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.ts
+++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.ts
@@ -9,7 +9,10 @@ import { CpsButtonComponent } from 'cps-ui-kit';
 import { uwtSafeStringify } from '../uwt-diagnostics-export/uwt-diagnostics-export';
 import { UWT_DEFAULT_DIAGNOSTICS_CONFIG } from '../uwt-diagnostics-store/uwt-diagnostics-store';
 
-/** Characters shown of a payload too large to show in full. */
+/**
+ * The most characters a truncated view shows, even when `maxChars` allows
+ * more.
+ */
 const TRUNCATED_VIEW_CHARS = 64 * 1024;
 
 /**
@@ -47,8 +50,19 @@ export class UwtDiagnosticsJsonComponent {
     UWT_DEFAULT_DIAGNOSTICS_CONFIG.maxPayloadCharsInView
   );
 
+  /**
+   * How many characters a truncated view shows: `maxChars`, capped at
+   * `TRUNCATED_VIEW_CHARS`. Never negative — `slice` would read a negative
+   * length as "all but the last few".
+   */
+  private readonly viewChars = computed(() =>
+    Math.max(0, Math.min(TRUNCATED_VIEW_CHARS, this.maxChars()))
+  );
+
   /** How much a truncated view shows, as the notice words it. */
-  protected readonly shownChars = TRUNCATED_VIEW_CHARS.toLocaleString('en-US');
+  protected readonly shownChars = computed(() =>
+    this.viewChars().toLocaleString('en-US')
+  );
 
   protected readonly copied = signal<'idle' | 'copied' | 'failed'>('idle');
 
@@ -67,7 +81,7 @@ export class UwtDiagnosticsJsonComponent {
   );
 
   protected readonly shown = computed(() =>
-    this.truncated() ? cutAt(this.full(), TRUNCATED_VIEW_CHARS) : this.full()
+    this.truncated() ? cutAt(this.full(), this.viewChars()) : this.full()
   );
 
   protected async copy(): Promise {
diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics.models/uwt-diagnostics.models.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics.models/uwt-diagnostics.models.ts
index 6b65d65..32bb38c 100644
--- a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics.models/uwt-diagnostics.models.ts
+++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics.models/uwt-diagnostics.models.ts
@@ -52,7 +52,8 @@ export interface UwtTelemetryDiagnosticsConfig {
 
   /**
    * Payloads whose JSON is longer than this many characters are shown
-   * truncated. The download always contains them in full.
+   * truncated to this many, at most 65,536. The download always contains
+   * them in full.
    *
    * Characters (UTF-16 code units, JavaScript's `length`), not bytes: the
    * limit protects rendering, which costs by the length of the text.

From 26ef61a0038040ffb3d160df12dd84064a586636 Mon Sep 17 00:00:00 2001
From: fateeand 
Date: Wed, 4 Nov 2026 10:24:30 +0100
Subject: [PATCH 03/51] fix table and info circle issues

---
 projects/ngx-ui-watchtower/DESIGN.md          | 10 ++++----
 .../uwt-diagnostics-filter-bar.component.html |  1 +
 .../uwt-diagnostics-section.component.html    | 12 +++++-----
 .../uwt-diagnostics-section.component.scss    | 23 +++++++++++++------
 .../uwt-telemetry-diagnostics.service.ts      |  7 +++---
 5 files changed, 33 insertions(+), 20 deletions(-)

diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md
index 04d885a..0a84e17 100644
--- a/projects/ngx-ui-watchtower/DESIGN.md
+++ b/projects/ngx-ui-watchtower/DESIGN.md
@@ -2045,8 +2045,9 @@ match exactly, held-down repeats are ignored, and AltGr — which many
 European Windows layouts report as Ctrl+Alt — never triggers it. ⌥⌘8 alone
 is macOS Accessibility Zoom, which is why Shift is part of it.
 
-The popup is not modal: it docks on the right at half the screen and leaves
-the app usable, so you can watch events arrive. It is draggable, resizable
+The popup is not modal: it docks on the right at half the screen (at least
+40rem, or nearly all of a small screen) and leaves the app usable, so you
+can watch events arrive. It is draggable, resizable
 and maximizable. With it open, the shortcut brings focus back to
 it from the app, and closes it when focus is already inside. Escape closes it
 too.
@@ -2078,8 +2079,9 @@ provideUwtTelemetryDiagnostics({
 #### What it shows
 
 Three sections — BI telemetry, scenario telemetry and logging — listing
-events newest first, from the moment the popup opens. Expand a row for the
-full payload as JSON.
+events newest first, from the moment the popup opens. The table always fits
+the popup: a long value ends in an ellipsis, and hovering shows it in full.
+Expand a row for the full payload as JSON.
 
 Each section has its own tools, which never affect the other two:
 
diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.html b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.html
index e765ec1..2afc31e 100644
--- a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.html
+++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.html
@@ -20,6 +20,7 @@
       (clicked)="builderOpen.set(!builderOpen())">
     
     
     
   
diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.html b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.html
index 2167f7a..42e9045 100644
--- a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.html
+++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.html
@@ -60,9 +60,10 @@
       [rowsPerPageOptions]="[25, 50, 100]"
       [first]="first()"
       (pageChanged)="onPage($event)"
-      [emptyMessage]="emptyMessage()">
+      [emptyMessage]="emptyMessage()"
+      tableStyleClass="uwt-diagnostics-section__table">
       
-        Time
+        Time
         @for (column of columns(); track column.key) {
           
+            "
+            [attr.title]="row.cells[column.key] || null">
             @if (column.key === columns()[0].key && row.forwardedFrom) {
               ↪ {{ row.forwardedFrom }}
             }
-            {{
-              row.cells[column.key]
-            }}
+            {{ row.cells[column.key] }}
           
         }
       
diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.scss b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.scss
index 13af3b9..522a9f7 100644
--- a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.scss
+++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.scss
@@ -35,18 +35,27 @@
   color: var(--cps-text-secondary);
 }
 
-.uwt-diagnostics-section__time {
-  white-space: nowrap;
-  font-variant-numeric: tabular-nums;
+// The table fits the section instead of running past its edge: the columns
+// after Time share the width, and a long value ends in an ellipsis, shown
+// in full in the cell's title and the row's detail.
+:host ::ng-deep .uwt-diagnostics-section__table {
+  width: 100%;
 }
 
-.uwt-diagnostics-section__cell {
-  display: inline-block;
-  max-width: 28rem;
+td {
+  // Lets the table make a cell narrower than its text.
+  max-width: 0;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
-  vertical-align: bottom;
+}
+
+.uwt-diagnostics-section__time {
+  // Only as wide as the time itself.
+  width: 1%;
+  max-width: none;
+  white-space: nowrap;
+  font-variant-numeric: tabular-nums;
 }
 
 .uwt-diagnostics-section__forwarded {
diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.ts b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.ts
index 343ad9f..a10c911 100644
--- a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.ts
+++ b/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.ts
@@ -123,10 +123,11 @@ export class UwtTelemetryDiagnosticsService {
           maximizable: true,
           closeOnEscape: true,
           // Docked right at half the screen, so the application stays
-          // visible and clickable beside it; resizable, and maximizable
-          // for a closer look.
+          // visible and clickable beside it, but never narrower than 40rem
+          // (nearly all of a small screen), so the tables have room;
+          // resizable, and maximizable for a closer look.
           position: 'right',
-          width: 'min(64rem, 50vw)',
+          width: 'clamp(min(40rem, 96vw), 50vw, 64rem)',
           height: '88vh',
           minWidth: 'min(22rem, 96vw)',
           autoFocus: '.uwt-diagnostics-search input'

From 6d0f90e34d82275f3a90471bfa06652988df2cb6 Mon Sep 17 00:00:00 2001
From: fateeand 
Date: Wed, 4 Nov 2026 10:43:53 +0100
Subject: [PATCH 04/51] rename

---
 README.md                                     |  12 +-
 jest.config.js                                |   4 +-
 package.json                                  |   2 +-
 projects/ngx-ui-watchtower/DESIGN.md          |  92 +++++++--------
 projects/ngx-ui-watchtower/README.md          |   6 +-
 .../diagnostics/src/public-api.ts             |  21 ----
 .../ng-package.json                           |   0
 .../uwt-event-inspector.service.spec.ts}      |  28 ++---
 .../uwt-event-inspector.service.ts}           |  50 ++++----
 .../uwt-inspector-dialog.component.html}      |  24 ++--
 .../uwt-inspector-dialog.component.scss}      |  24 ++--
 .../uwt-inspector-dialog.component.spec.ts}   |  32 ++---
 .../uwt-inspector-dialog.component.ts}        |  60 +++++-----
 .../uwt-inspector-export.spec.ts}             |  31 ++---
 .../uwt-inspector-export.ts}                  |  36 +++---
 .../uwt-inspector-exporter.ts}                |  36 +++---
 .../uwt-inspector-filter-bar.component.html}  |  20 ++--
 .../uwt-inspector-filter-bar.component.scss}  |  22 ++--
 ...wt-inspector-filter-bar.component.spec.ts} |  20 ++--
 .../uwt-inspector-filter-bar.component.ts}    |  27 ++---
 .../uwt-inspector-filter.spec.ts}             |  25 ++--
 .../uwt-inspector-filter.ts}                  |  38 +++---
 .../uwt-inspector-internal.util.ts}           |   5 +-
 .../uwt-inspector-json.component.html}        |   8 +-
 .../uwt-inspector-json.component.scss}        |   8 +-
 .../uwt-inspector-json.component.spec.ts}     |  10 +-
 .../uwt-inspector-json.component.ts}          |  16 ++-
 .../uwt-inspector-columns.ts}                 |  30 ++---
 .../uwt-inspector-section.component.html}     |  40 +++----
 .../uwt-inspector-section.component.scss}     |  28 ++---
 .../uwt-inspector-section.component.spec.ts}  |  28 ++---
 .../uwt-inspector-section.component.ts}       |  50 ++++----
 .../uwt-inspector-shortcut.spec.ts}           |  10 +-
 .../uwt-inspector-shortcut.ts}                |  10 +-
 .../uwt-inspector-store.spec.ts}              |  22 ++--
 .../uwt-inspector-store.ts}                   | 111 +++++++++---------
 .../uwt-inspector.models.ts}                  |  48 ++++----
 .../uwt-inspector.providers.ts}               |  32 ++---
 .../event-inspector/src/public-api.ts         |  21 ++++
 .../tsconfig.lib.json                         |   2 +-
 .../tsconfig.spec.json                        |   2 +-
 .../uwt-telemetry-monitor.service.ts          |   2 +-
 tsconfig.json                                 |   8 +-
 43 files changed, 542 insertions(+), 559 deletions(-)
 delete mode 100644 projects/ngx-ui-watchtower/diagnostics/src/public-api.ts
 rename projects/ngx-ui-watchtower/{diagnostics => event-inspector}/ng-package.json (100%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.spec.ts => event-inspector/src/lib/uwt-event-inspector.service/uwt-event-inspector.service.spec.ts} (84%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.ts => event-inspector/src/lib/uwt-event-inspector.service/uwt-event-inspector.service.ts} (73%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.html => event-inspector/src/lib/uwt-inspector-dialog/uwt-inspector-dialog.component.html} (76%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.scss => event-inspector/src/lib/uwt-inspector-dialog/uwt-inspector-dialog.component.scss} (77%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.spec.ts => event-inspector/src/lib/uwt-inspector-dialog/uwt-inspector-dialog.component.spec.ts} (91%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.ts => event-inspector/src/lib/uwt-inspector-dialog/uwt-inspector-dialog.component.ts} (67%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-export.spec.ts => event-inspector/src/lib/uwt-inspector-export/uwt-inspector-export.spec.ts} (83%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-export.ts => event-inspector/src/lib/uwt-inspector-export/uwt-inspector-export.ts} (78%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-export/uwt-diagnostics-exporter.ts => event-inspector/src/lib/uwt-inspector-export/uwt-inspector-exporter.ts} (76%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.html => event-inspector/src/lib/uwt-inspector-filter-bar/uwt-inspector-filter-bar.component.html} (83%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.scss => event-inspector/src/lib/uwt-inspector-filter-bar/uwt-inspector-filter-bar.component.scss} (52%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.spec.ts => event-inspector/src/lib/uwt-inspector-filter-bar/uwt-inspector-filter-bar.component.spec.ts} (81%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-filter-bar/uwt-diagnostics-filter-bar.component.ts => event-inspector/src/lib/uwt-inspector-filter-bar/uwt-inspector-filter-bar.component.ts} (81%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-filter/uwt-diagnostics-filter.spec.ts => event-inspector/src/lib/uwt-inspector-filter/uwt-inspector-filter.spec.ts} (90%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-filter/uwt-diagnostics-filter.ts => event-inspector/src/lib/uwt-inspector-filter/uwt-inspector-filter.ts} (86%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-internal.util/uwt-diagnostics-internal.util.ts => event-inspector/src/lib/uwt-inspector-internal.util/uwt-inspector-internal.util.ts} (93%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.html => event-inspector/src/lib/uwt-inspector-json/uwt-inspector-json.component.html} (82%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.scss => event-inspector/src/lib/uwt-inspector-json/uwt-inspector-json.component.scss} (85%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.spec.ts => event-inspector/src/lib/uwt-inspector-json/uwt-inspector-json.component.spec.ts} (92%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-json/uwt-diagnostics-json.component.ts => event-inspector/src/lib/uwt-inspector-json/uwt-inspector-json.component.ts} (84%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-columns.ts => event-inspector/src/lib/uwt-inspector-section/uwt-inspector-columns.ts} (89%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.html => event-inspector/src/lib/uwt-inspector-section/uwt-inspector-section.component.html} (79%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.scss => event-inspector/src/lib/uwt-inspector-section/uwt-inspector-section.component.scss} (76%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.spec.ts => event-inspector/src/lib/uwt-inspector-section/uwt-inspector-section.component.spec.ts} (78%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-section/uwt-diagnostics-section.component.ts => event-inspector/src/lib/uwt-inspector-section/uwt-inspector-section.component.ts} (73%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-shortcut/uwt-diagnostics-shortcut.spec.ts => event-inspector/src/lib/uwt-inspector-shortcut/uwt-inspector-shortcut.spec.ts} (91%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-shortcut/uwt-diagnostics-shortcut.ts => event-inspector/src/lib/uwt-inspector-shortcut/uwt-inspector-shortcut.ts} (88%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-store/uwt-diagnostics-store.spec.ts => event-inspector/src/lib/uwt-inspector-store/uwt-inspector-store.spec.ts} (93%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics-store/uwt-diagnostics-store.ts => event-inspector/src/lib/uwt-inspector-store/uwt-inspector-store.ts} (73%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics.models/uwt-diagnostics.models.ts => event-inspector/src/lib/uwt-inspector.models/uwt-inspector.models.ts} (72%)
 rename projects/ngx-ui-watchtower/{diagnostics/src/lib/uwt-diagnostics.providers/uwt-diagnostics.providers.ts => event-inspector/src/lib/uwt-inspector.providers/uwt-inspector.providers.ts} (50%)
 create mode 100644 projects/ngx-ui-watchtower/event-inspector/src/public-api.ts
 rename projects/ngx-ui-watchtower/{diagnostics => event-inspector}/tsconfig.lib.json (91%)
 rename projects/ngx-ui-watchtower/{diagnostics => event-inspector}/tsconfig.spec.json (83%)

diff --git a/README.md b/README.md
index 0f3c348..70ffb84 100644
--- a/README.md
+++ b/README.md
@@ -20,12 +20,12 @@ How it is designed: [projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watch
 
 ```text
 projects/ngx-ui-watchtower/
-  src/          main entry point — @absaoss-cps/ngx-ui-watchtower
-  rum/          secondary entry point — @absaoss-cps/ngx-ui-watchtower/rum
-  diagnostics/  secondary entry point — @absaoss-cps/ngx-ui-watchtower/diagnostics
-  README.md     usage guide, published to npm
-  DESIGN.md     full design
-  package.json  the published package's manifest
+  src/              main entry point — @absaoss-cps/ngx-ui-watchtower
+  rum/              secondary entry point — @absaoss-cps/ngx-ui-watchtower/rum
+  event-inspector/  secondary entry point — @absaoss-cps/ngx-ui-watchtower/event-inspector
+  README.md         usage guide, published to npm
+  DESIGN.md         full design
+  package.json      the published package's manifest
 ```
 
 ## Getting started
diff --git a/jest.config.js b/jest.config.js
index 921e2a2..c40547f 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -8,8 +8,8 @@ module.exports = {
       '/projects/ngx-ui-watchtower/src/public-api.ts',
     '^@absaoss-cps/ngx-ui-watchtower/rum$':
       '/projects/ngx-ui-watchtower/rum/src/public-api.ts',
-    '^@absaoss-cps/ngx-ui-watchtower/diagnostics$':
-      '/projects/ngx-ui-watchtower/diagnostics/src/public-api.ts'
+    '^@absaoss-cps/ngx-ui-watchtower/event-inspector$':
+      '/projects/ngx-ui-watchtower/event-inspector/src/public-api.ts'
   },
   transformIgnorePatterns: [
     'node_modules/(?!(.*.mjs$|@angular/common/locales/.*.js$))'
diff --git a/package.json b/package.json
index 1ff0fd9..cb244a0 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
     "lint": "eslint \"**/*.ts\" --max-warnings=0",
     "format": "prettier --write \"**/*.{ts,html}\"",
     "format:check": "prettier --check \"**/*.{ts,html}\"",
-    "typecheck": "tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/diagnostics/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/diagnostics/tsconfig.spec.json --noEmit"
+    "typecheck": "tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/event-inspector/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/event-inspector/tsconfig.spec.json --noEmit"
   },
   "private": true,
   "dependencies": {
diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md
index 0a84e17..12e6325 100644
--- a/projects/ngx-ui-watchtower/DESIGN.md
+++ b/projects/ngx-ui-watchtower/DESIGN.md
@@ -94,19 +94,19 @@ production and stubbed in tests.
 
 Three, each pulling in only what it needs:
 
-| Entry point                                  | Contains                                    | Needs                                                                       |
-| -------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------- |
-| `@absaoss-cps/ngx-ui-watchtower`             | Everything except the two below             | Angular                                                                     |
-| `@absaoss-cps/ngx-ui-watchtower/rum`         | `UwtRumTelemetrySink`, its providers        | `aws-rum-web`, an optional peer                                             |
-| `@absaoss-cps/ngx-ui-watchtower/diagnostics` | The [diagnostics popup](#diagnostics-popup) | `cps-ui-kit` (with `@angular/forms`, `@angular/animations`), optional peers |
+| Entry point                                      | Contains                                | Needs                                                                       |
+| ------------------------------------------------ | --------------------------------------- | --------------------------------------------------------------------------- |
+| `@absaoss-cps/ngx-ui-watchtower`                 | Everything except the two below         | Angular                                                                     |
+| `@absaoss-cps/ngx-ui-watchtower/rum`             | `UwtRumTelemetrySink`, its providers    | `aws-rum-web`, an optional peer                                             |
+| `@absaoss-cps/ngx-ui-watchtower/event-inspector` | The [Event Inspector](#event-inspector) | `cps-ui-kit` (with `@angular/forms`, `@angular/animations`), optional peers |
 
 A bundler resolves every static import in a module graph, and a dynamic
 `import()`'s specifier, whether or not that code path ever runs. Keeping the
 RUM sink and its `await import('aws-rum-web')` out of the main entry is what
 lets an app using only `'broadcast'`/`'noop'` build without `aws-rum-web`
-installed. The popup is split out the same way: it is the only UI in the
-package, and nothing in the main entry — bundle or `.d.ts` — references
-cps-ui-kit.
+installed. The Event Inspector is split out the same way: it is the only UI
+in the package, and nothing in the main entry — bundle or `.d.ts` —
+references cps-ui-kit.
 
 ### Public API
 
@@ -134,13 +134,13 @@ Everything each entry point exports, by role.
 `UwtRumCredentials` (`accessKeyId`, `secretAccessKey`, `sessionToken`,
 `expiration`).
 
-**`@absaoss-cps/ngx-ui-watchtower/diagnostics`:** `provideUwtTelemetryDiagnostics`,
-`UwtTelemetryDiagnosticsService`, `UWT_TELEMETRY_DIAGNOSTICS_CONFIG`,
-`UwtTelemetryDiagnosticsConfig`, `UWT_DEFAULT_DIAGNOSTICS_CONFIG`,
-`UwtDiagnosticsShortcut`, `UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS`,
-`UwtDiagnosticsExport`, `UwtDiagnosticsSectionId`, and the filter types
-`UwtDiagnosticsFilterState`, `UwtDiagnosticsFieldFilter`,
-`UwtDiagnosticsFilterOperator` — see §12, "Diagnostics popup".
+**`@absaoss-cps/ngx-ui-watchtower/event-inspector`:** `provideUwtEventInspector`,
+`UwtEventInspectorService`, `UWT_EVENT_INSPECTOR_CONFIG`,
+`UwtEventInspectorConfig`, `UWT_DEFAULT_INSPECTOR_CONFIG`,
+`UwtInspectorShortcut`, `UWT_DEFAULT_INSPECTOR_SHORTCUTS`,
+`UwtInspectorExport`, `UwtInspectorSectionId`, and the filter types
+`UwtInspectorFilterState`, `UwtInspectorFieldFilter`,
+`UwtInspectorFilterOperator` — see §12, "Event Inspector".
 
 ### Destinations
 
@@ -192,10 +192,10 @@ redacted), deduplicate BI events, time anything, or guard the library's
 calls — every call the library makes into it, `init` included, is wrapped
 fail-open. The one exception is `setUserId`/`getUserId`, which the
 application calls on the sink directly; a destination should not throw
-there. The
-diagnostics monitor observes the hand-off, so the popup works with any
-destination. Log lines are not part of it: they go to the application's
-own log backend through `UWT_LOG_API_PROVIDER`, whatever the destination.
+there. The telemetry monitor observes the hand-off, so the Event Inspector
+works with any destination. Log lines are not part of it: they go to the
+application's own log backend through `UWT_LOG_API_PROVIDER`, whatever the
+destination.
 
 ```ts
 providers: [
@@ -225,14 +225,14 @@ shell's.
   `uwtNormalizeError`, `uwtScrubString` and `uwtRedactConfigFor`.
 - **Secondary entries keep private copies of a few utilities.** ng-packagr
   fixes each entry point's `rootDir` to its own `src`, so
-  `@absaoss-cps/ngx-ui-watchtower/rum` and `@absaoss-cps/ngx-ui-watchtower/diagnostics` can't import the main
+  `@absaoss-cps/ngx-ui-watchtower/rum` and `@absaoss-cps/ngx-ui-watchtower/event-inspector` can't import the main
   entry's internals by relative path and carry small copies of what they
   need.
 - **cps-ui-kit comes from npm.** It is an optional peer of the package and a
   dev dependency of the workspace, so the build and the unit tests resolve it
   from `node_modules` and it is never compiled into the package. The
-  workspace uses cps-ui-kit 21, the lowest version the popup supports, as it
-  uses the lowest supported Angular.
+  workspace uses cps-ui-kit 21, the lowest version the Event Inspector
+  supports, as it uses the lowest supported Angular.
 - **Test doubles live in the specs that use them**, declared inline, never in
   a shared file and never exported. The one browser gap needing a stub is
   `BroadcastChannel`, which jsdom doesn't implement.
@@ -1313,7 +1313,7 @@ Reading through AWS 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.
 
-### The diagnostics popup
+### The Event Inspector
 
 The popup shows exactly what is sent, after redaction — including session
 and user ids — and redacts nothing further, since a popup that hid fields
@@ -1325,7 +1325,7 @@ nothing, sends nothing and grants no remote access.
 Avoid sharing your screen while it is open, and treat downloads as you would
 any file with ids in it; filenames carry only the application name, the
 section and a timestamp
-(`ngx-ui-watchtower-diagnostics--
-.json`). The shortcut prevents accidental opening; it is not access +(`ngx-ui-watchtower-events--
-.json`). The shortcut prevents accidental opening; it is not access control — use `enabled` for that, or leave the provider out. --- @@ -1615,7 +1615,7 @@ convention, for an application that wants the pre-fallback behavior back. becoming stable. - **Bounded.** A 100-item buffer keeps events, page views and errors recorded before the RUM client is ready, then replays them; it never grows past - that if initialization never completes. The diagnostics monitor costs one + that if initialization never completes. The telemetry monitor costs one check per event while nobody watches; the popup keeps at most 500 events per section, updates the screen at most every 250 ms, and releases everything when it closes. @@ -1653,7 +1653,7 @@ How an application uses the library, end to end. npm install @absaoss-cps/ngx-ui-watchtower # optional, only if you use the AWS RUM sink npm install aws-rum-web -# optional, only if you use the diagnostics popup +# optional, only if you use the Event Inspector npm install cps-ui-kit ``` @@ -2009,18 +2009,18 @@ writes to the console, so starting a scenario, opening a step or calling a method after settlement — none of which sends anything — print nothing. To see each step, turn on `emitLifecycleEvents`, which makes steps real events. -### Diagnostics popup +### Event Inspector An in-app window showing, live, every BI event, scenario event and log record your app hands to its telemetry destinations — no DevTools, no backend access: ```ts -import { provideUwtTelemetryDiagnostics } from '@absaoss-cps/ngx-ui-watchtower/diagnostics'; +import { provideUwtEventInspector } from '@absaoss-cps/ngx-ui-watchtower/event-inspector'; providers: [ provideUwtTelemetry({ application: 'my-app', environment, version }), - provideUwtTelemetryDiagnostics() + provideUwtEventInspector() ]; ``` @@ -2056,7 +2056,7 @@ It works in every environment. To change the keys, turn the shortcut off, or limit who can open it: ```ts -provideUwtTelemetryDiagnostics({ +provideUwtEventInspector({ // Your own combination — replaces the defaults. shortcuts: [ { @@ -2069,7 +2069,7 @@ provideUwtTelemetryDiagnostics({ ], // Or no keyboard at all, opening it from your own UI instead: - // shortcuts: [], then inject(UwtTelemetryDiagnosticsService).open(); + // shortcuts: [], then inject(UwtEventInspectorService).open(); // Read once at startup. enabled: () => inject(AuthService).isSupportStaff() @@ -2119,30 +2119,30 @@ each marked with `↪` and the fragment's name. #### Configuration -| Option | Default | Meaning | -| ----------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `enabled` | `true` | `false`, or a function read once at startup, turns the popup off — shortcut and `open()` alike | -| `shortcuts` | `UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS` | Replaces the defaults; `[]` disables the keyboard | -| `maxEventsPerSection` | `500` | Events kept per section; the oldest are dropped, and the count of dropped ones is shown | -| `maxPayloadCharsInView` | `262144` (256 × 1024) | A payload whose JSON is longer is shown truncated to its first `maxPayloadCharsInView` characters, at most 65,536; downloads stay complete | +| Option | Default | Meaning | +| ----------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `enabled` | `true` | `false`, or a function read once at startup, turns the popup off — shortcut and `open()` alike | +| `shortcuts` | `UWT_DEFAULT_INSPECTOR_SHORTCUTS` | Replaces the defaults; `[]` disables the keyboard | +| `maxEventsPerSection` | `500` | Events kept per section; the oldest are dropped, and the count of dropped ones is shown | +| `maxPayloadCharsInView` | `262144` (256 × 1024) | A payload whose JSON is longer is shown truncated to its first `maxPayloadCharsInView` characters, at most 65,536; downloads stay complete | Characters here are UTF-16 code units (JavaScript's `length`), not bytes: the limit protects rendering, which costs by the length of the text. A cut never splits a surrogate pair. -A shortcut is a `UwtDiagnosticsShortcut` — `{ code, ctrl?, alt?, shift?, +A shortcut is a `UwtInspectorShortcut` — `{ code, ctrl?, alt?, shift?, meta?, label? }`, where `code` is a `KeyboardEvent.code` such as `'Digit8'` or `'KeyD'`, and an omitted modifier must be up. Spread -`UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS` to add one rather than replace them. +`UWT_DEFAULT_INSPECTOR_SHORTCUTS` to add one rather than replace them. -`UwtTelemetryDiagnosticsService` opens the popup from code: `open()`, +`UwtEventInspectorService` opens the popup from code: `open()`, `close()`, `toggle()`, and an `isOpen` signal. -A download is a `UwtDiagnosticsExport`: +A download is a `UwtInspectorExport`: ```ts -interface UwtDiagnosticsExport { - format: 'ngx-ui-watchtower-diagnostics'; +interface UwtInspectorExport { + format: 'ngx-ui-watchtower-event-inspector'; formatVersion: 1; section: 'bi' | 'scenario' | 'logging'; exportedAt: string; @@ -2155,7 +2155,7 @@ interface UwtDiagnosticsExport { }; session: { sessionId?: string; userId?: string }; userAgent: string; - activeFilters: UwtDiagnosticsFilterState; // recorded, not applied + activeFilters: UwtInspectorFilterState; // recorded, not applied count: { captured: number; droppedOldest: number }; events: UwtTelemetryObservedEvent[]; // oldest first } @@ -2375,7 +2375,7 @@ error's name instead (`[cart] TypeError`). | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `provideUwtTelemetryRumSink()` | Builds a second AWS client — one visitor becomes two sessions. Listed next to `'broadcast'`, it is a second destination, so bootstrap fails | | `UWT_RUM_CREDENTIALS_PROVIDER` | Nothing in a fragment needs AWS credentials | -| `UWT_LOG_API_PROVIDER` | `'broadcast'` mode already binds it to the forwarding provider. One bound after it wins, and the fragment ships its own logs — the shell, and its diagnostics popup, never see them | +| `UWT_LOG_API_PROVIDER` | `'broadcast'` mode already binds it to the forwarding provider. One bound after it wins, and the fragment ships its own logs — the shell, and its Event Inspector, never see them | | `provideUwtTelemetryBroadcastHost()` | A host receives its own realm's messages too, so beside the forwarding sink it would forward them again, forever. It detects this, warns (`... cannot also host it ...`) and stays inactive | #### A realm that both forwards and hosts @@ -2448,7 +2448,7 @@ With `mirrorErrorsToRum`, each error-level log line leaves a fragment as two messages — the record for the log provider, then its mirrored error for the sink — with no id in common. Each destination still receives it once: the log backend gets the record, RUM gets the error. The host links them for its -monitor anyway, so the shell's diagnostics popup shows the mirror against +monitor anyway, so the shell's Event Inspector shows the mirror against its record as the fragment's own does: an `error` arriving straight after an error-level record from the same realm, with the name and message the logger mirrors for it, gets that record's sequence as `relatedSequence`. Both are diff --git a/projects/ngx-ui-watchtower/README.md b/projects/ngx-ui-watchtower/README.md index 1a8f76d..93804fa 100644 --- a/projects/ngx-ui-watchtower/README.md +++ b/projects/ngx-ui-watchtower/README.md @@ -352,12 +352,12 @@ localStorage.setItem('debugBI', 'true'); localStorage.setItem('debugLogger', 'true'); // or 'checkout,cart' for some loggers ``` -**In the app** — add the diagnostics popup (needs `cps-ui-kit` 21.32+ or 22): +**In the app** — add the Event Inspector (needs `cps-ui-kit` 21.32+ or 22): ```ts -import { provideUwtTelemetryDiagnostics } from '@absaoss-cps/ngx-ui-watchtower/diagnostics'; +import { provideUwtEventInspector } from '@absaoss-cps/ngx-ui-watchtower/event-inspector'; -providers: [provideUwtTelemetryDiagnostics()]; +providers: [provideUwtEventInspector()]; ``` Open it with **⇧⌥⌘8** on macOS or **Ctrl+Alt+Shift+8** on Windows and Linux. diff --git a/projects/ngx-ui-watchtower/diagnostics/src/public-api.ts b/projects/ngx-ui-watchtower/diagnostics/src/public-api.ts deleted file mode 100644 index ac293ba..0000000 --- a/projects/ngx-ui-watchtower/diagnostics/src/public-api.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Public API Surface of ngx-ui-watchtower/diagnostics - * - * An in-app popup showing, live, every event this app hands to its - * telemetry destinations. Needs cps-ui-kit. - */ - -export { - UWT_TELEMETRY_DIAGNOSTICS_CONFIG, - UwtDiagnosticsExport, - UwtDiagnosticsFieldFilter, - UwtDiagnosticsFilterOperator, - UwtDiagnosticsFilterState, - UwtDiagnosticsSectionId, - UwtDiagnosticsShortcut, - UwtTelemetryDiagnosticsConfig -} from './lib/uwt-diagnostics.models/uwt-diagnostics.models'; -export { UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS } from './lib/uwt-diagnostics-shortcut/uwt-diagnostics-shortcut'; -export { UWT_DEFAULT_DIAGNOSTICS_CONFIG } from './lib/uwt-diagnostics-store/uwt-diagnostics-store'; -export { provideUwtTelemetryDiagnostics } from './lib/uwt-diagnostics.providers/uwt-diagnostics.providers'; -export { UwtTelemetryDiagnosticsService } from './lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service'; diff --git a/projects/ngx-ui-watchtower/diagnostics/ng-package.json b/projects/ngx-ui-watchtower/event-inspector/ng-package.json similarity index 100% rename from projects/ngx-ui-watchtower/diagnostics/ng-package.json rename to projects/ngx-ui-watchtower/event-inspector/ng-package.json diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.spec.ts b/projects/ngx-ui-watchtower/event-inspector/src/lib/uwt-event-inspector.service/uwt-event-inspector.service.spec.ts similarity index 84% rename from projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.spec.ts rename to projects/ngx-ui-watchtower/event-inspector/src/lib/uwt-event-inspector.service/uwt-event-inspector.service.spec.ts index d2c795f..ce83904 100644 --- a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.spec.ts +++ b/projects/ngx-ui-watchtower/event-inspector/src/lib/uwt-event-inspector.service/uwt-event-inspector.service.spec.ts @@ -3,11 +3,11 @@ import { TestBed } from '@angular/core/testing'; import { Subject } from 'rxjs'; import { CpsDialogService } from 'cps-ui-kit'; import { - UWT_TELEMETRY_DIAGNOSTICS_CONFIG, - UwtTelemetryDiagnosticsConfig -} from '../uwt-diagnostics.models/uwt-diagnostics.models'; -import { UwtDiagnosticsDialogComponent } from '../uwt-diagnostics-dialog/uwt-diagnostics-dialog.component'; -import { UwtTelemetryDiagnosticsService } from './uwt-telemetry-diagnostics.service'; + UWT_EVENT_INSPECTOR_CONFIG, + UwtEventInspectorConfig +} from '../uwt-inspector.models/uwt-inspector.models'; +import { UwtInspectorDialogComponent } from '../uwt-inspector-dialog/uwt-inspector-dialog.component'; +import { UwtEventInspectorService } from './uwt-event-inspector.service'; /** Stands in for CpsDialogService, recording what was opened. */ class FakeDialogs { @@ -52,23 +52,23 @@ function press(init: KeyInit = SHORTCUT): KeyboardEvent { return event; } -describe('UwtTelemetryDiagnosticsService', () => { +describe('UwtEventInspectorService', () => { let dialogs: FakeDialogs; function create( - config: Partial = {}, + config: Partial = {}, platform = 'browser' - ): UwtTelemetryDiagnosticsService { + ): UwtEventInspectorService { TestBed.resetTestingModule(); dialogs = new FakeDialogs(); TestBed.configureTestingModule({ providers: [ { provide: CpsDialogService, useValue: dialogs }, { provide: PLATFORM_ID, useValue: platform }, - { provide: UWT_TELEMETRY_DIAGNOSTICS_CONFIG, useValue: config } + { provide: UWT_EVENT_INSPECTOR_CONFIG, useValue: config } ] }); - return TestBed.inject(UwtTelemetryDiagnosticsService); + return TestBed.inject(UwtEventInspectorService); } afterEach(() => { @@ -82,14 +82,14 @@ describe('UwtTelemetryDiagnosticsService', () => { const event = press(); expect(dialogs.opened).toHaveLength(1); - expect(dialogs.opened[0].component).toBe(UwtDiagnosticsDialogComponent); + expect(dialogs.opened[0].component).toBe(UwtInspectorDialogComponent); expect(dialogs.opened[0].config).toMatchObject({ modal: false, - ariaLabel: 'Telemetry diagnostics', + ariaLabel: 'Event Inspector', closeOnEscape: true }); expect(event.defaultPrevented).toBe(true); - expect(TestBed.inject(UwtTelemetryDiagnosticsService).isOpen()).toBe(true); + expect(TestBed.inject(UwtEventInspectorService).isOpen()).toBe(true); }); it('should also open on the macOS shortcut', () => { @@ -120,7 +120,7 @@ describe('UwtTelemetryDiagnosticsService', () => { dialogs.containsFocus = true; press(); expect(dialogs.close).toHaveBeenCalledTimes(1); - expect(TestBed.inject(UwtTelemetryDiagnosticsService).isOpen()).toBe(false); + expect(TestBed.inject(UwtEventInspectorService).isOpen()).toBe(false); }); it('should open fresh after the previous popup was destroyed', () => { diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.ts b/projects/ngx-ui-watchtower/event-inspector/src/lib/uwt-event-inspector.service/uwt-event-inspector.service.ts similarity index 73% rename from projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.ts rename to projects/ngx-ui-watchtower/event-inspector/src/lib/uwt-event-inspector.service/uwt-event-inspector.service.ts index a10c911..c3495b5 100644 --- a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-telemetry-diagnostics.service/uwt-telemetry-diagnostics.service.ts +++ b/projects/ngx-ui-watchtower/event-inspector/src/lib/uwt-event-inspector.service/uwt-event-inspector.service.ts @@ -9,23 +9,23 @@ import { } from '@angular/core'; import { CpsDialogRef, CpsDialogService } from 'cps-ui-kit'; import { - UWT_TELEMETRY_DIAGNOSTICS_CONFIG, - UwtTelemetryDiagnosticsConfig -} from '../uwt-diagnostics.models/uwt-diagnostics.models'; -import { UWT_DEFAULT_DIAGNOSTICS_CONFIG } from '../uwt-diagnostics-store/uwt-diagnostics-store'; + UWT_EVENT_INSPECTOR_CONFIG, + UwtEventInspectorConfig +} from '../uwt-inspector.models/uwt-inspector.models'; +import { UWT_DEFAULT_INSPECTOR_CONFIG } from '../uwt-inspector-store/uwt-inspector-store'; import { - UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS, + UWT_DEFAULT_INSPECTOR_SHORTCUTS, uwtMatchesShortcut -} from '../uwt-diagnostics-shortcut/uwt-diagnostics-shortcut'; -import { UwtDiagnosticsDialogComponent } from '../uwt-diagnostics-dialog/uwt-diagnostics-dialog.component'; +} from '../uwt-inspector-shortcut/uwt-inspector-shortcut'; +import { UwtInspectorDialogComponent } from '../uwt-inspector-dialog/uwt-inspector-dialog.component'; import { uwtSafe, uwtSafeVoid -} from '../uwt-diagnostics-internal.util/uwt-diagnostics-internal.util'; +} from '../uwt-inspector-internal.util/uwt-inspector-internal.util'; /** - * Opens and closes the telemetry diagnostics popup, and listens for its - * keyboard shortcut once {@link provideUwtTelemetryDiagnostics} installs it. + * Opens and closes the Event Inspector, and listens for its + * keyboard shortcut once {@link provideUwtEventInspector} installs it. * * The popup is non-modal: the application stays usable while events * arrive. With the popup open, the shortcut returns focus to it from the @@ -34,22 +34,22 @@ import { * @example * ```typescript * // From an app's own "Support" menu, when the shortcut is disabled: - * inject(UwtTelemetryDiagnosticsService).open(); + * inject(UwtEventInspectorService).open(); * ``` * * @group Services */ @Injectable({ providedIn: 'root' }) -export class UwtTelemetryDiagnosticsService { +export class UwtEventInspectorService { private readonly dialogs = inject(CpsDialogService); private readonly document = inject(DOCUMENT); private readonly zone = inject(NgZone); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private readonly destroyRef = inject(DestroyRef); - private readonly config: UwtTelemetryDiagnosticsConfig = { - ...UWT_DEFAULT_DIAGNOSTICS_CONFIG, - shortcuts: UWT_DEFAULT_DIAGNOSTICS_SHORTCUTS, - ...inject(UWT_TELEMETRY_DIAGNOSTICS_CONFIG, { optional: true }) + private readonly config: UwtEventInspectorConfig = { + ...UWT_DEFAULT_INSPECTOR_CONFIG, + shortcuts: UWT_DEFAULT_INSPECTOR_SHORTCUTS, + ...inject(UWT_EVENT_INSPECTOR_CONFIG, { optional: true }) }; private readonly enabled = uwtSafe( @@ -61,7 +61,7 @@ export class UwtTelemetryDiagnosticsService { false ); - private ref?: CpsDialogRef; + private ref?: CpsDialogRef; private installed = false; /** Whether the popup is currently open. */ @@ -69,7 +69,7 @@ export class UwtTelemetryDiagnosticsService { /** * Starts listening for the shortcut. Called by - * {@link provideUwtTelemetryDiagnostics}; does nothing on the server, + * {@link provideUwtEventInspector}; does nothing on the server, * when disabled, with no shortcuts, or when already listening. */ install(): void { @@ -113,10 +113,11 @@ export class UwtTelemetryDiagnosticsService { return; } uwtSafeVoid('open', () => { - const ref: CpsDialogRef = - this.dialogs.open(UwtDiagnosticsDialogComponent, { - headerTitle: 'Telemetry diagnostics', - ariaLabel: 'Telemetry diagnostics', + const ref: CpsDialogRef = this.dialogs.open( + UwtInspectorDialogComponent, + { + headerTitle: 'Event Inspector', + ariaLabel: 'Event Inspector', modal: false, draggable: true, resizable: true, @@ -130,8 +131,9 @@ export class UwtTelemetryDiagnosticsService { width: 'clamp(min(40rem, 96vw), 50vw, 64rem)', height: '88vh', minWidth: 'min(22rem, 96vw)', - autoFocus: '.uwt-diagnostics-search input' - }); + autoFocus: '.uwt-inspector-search input' + } + ); this.ref = ref; this.isOpen.set(true); ref.onDestroy.subscribe(() => { diff --git a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.html b/projects/ngx-ui-watchtower/event-inspector/src/lib/uwt-inspector-dialog/uwt-inspector-dialog.component.html similarity index 76% rename from projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.html rename to projects/ngx-ui-watchtower/event-inspector/src/lib/uwt-inspector-dialog/uwt-inspector-dialog.component.html index f8c839a..ff8a9f7 100644 --- a/projects/ngx-ui-watchtower/diagnostics/src/lib/uwt-diagnostics-dialog/uwt-diagnostics-dialog.component.html +++ b/projects/ngx-ui-watchtower/event-inspector/src/lib/uwt-inspector-dialog/uwt-inspector-dialog.component.html @@ -1,14 +1,14 @@ -
-
-

+

+
+

{{ store.paused() ? 'Paused' : 'Capturing' }} since {{ since() }} · {{ store.total() }} event{{ store.total() === 1 ? '' : 's' }}

-
+
@if (store.paused() && store.newWhilePaused() > 0) { -
+
{{ store.newWhilePaused() }} new event{{ store.newWhilePaused() === 1 ? '' : 's' @@ -43,22 +43,22 @@ } @if (store.total() === 0 && !store.paused()) { -

+

Capturing from now. Use the app to generate events; they appear here as they are sent.

} -
+
@for (section of sections; track section) { - - + }
-