diff --git a/.eslintrc.js b/.eslintrc.js index 84ba0b7..078305c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -39,6 +39,83 @@ module.exports = { jest: false, node: true } + }, + { + // The core is framework-independent, and its main entry point loads + // no optional peer: those get entry points of their own. + files: ['projects/ui-watchtower/src/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@angular/*', + 'zone.js', + '@absaoss-cps/ngx-ui-watchtower', + '@absaoss-cps/ngx-ui-watchtower/*' + ], + message: 'The core is framework-independent.' + }, + { + group: ['rxjs', 'rxjs/*', 'aws-rum-web', 'aws-rum-web/*'], + message: + 'The main entry point loads no optional peer; use an entry point of its own.' + } + ] + } + ] + } + }, + { + // The core's AWS RUM entry point: aws-rum-web, and still no framework. + files: ['projects/ui-watchtower/rum/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@angular/*', + 'zone.js', + '@absaoss-cps/ngx-ui-watchtower', + '@absaoss-cps/ngx-ui-watchtower/*', + 'rxjs', + 'rxjs/*' + ], + message: + 'The core is framework-independent, and this entry point loads no other optional peer.' + } + ] + } + ] + } + }, + { + // The core's RxJS entry point: RxJS, and still no framework. + files: ['projects/ui-watchtower/rxjs/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@angular/*', + 'zone.js', + '@absaoss-cps/ngx-ui-watchtower', + '@absaoss-cps/ngx-ui-watchtower/*', + 'aws-rum-web', + 'aws-rum-web/*' + ], + message: 'The core is framework-independent.' + } + ] + } + ] + } } ] }; diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e74f21c..b2041ed 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -36,6 +36,9 @@ jobs: - name: Build library run: npm run build + - name: Check the public API + run: npm run api:check + test: name: Unit tests runs-on: ubuntu-latest @@ -59,16 +62,76 @@ jobs: - name: Run unit tests with coverage run: npm run test:coverage - - name: Comment coverage on PR + - name: Comment ngx coverage on PR uses: ArtiomTr/jest-coverage-report-action@v2 if: github.event_name == 'pull_request' continue-on-error: true with: - custom-title: Coverage report + custom-title: Ngx coverage report coverage-file: ./coverage/report.json # Tests already ran above; the action only formats the report. skip-step: all + - name: Comment core coverage on PR + uses: ArtiomTr/jest-coverage-report-action@v2 + if: github.event_name == 'pull_request' + continue-on-error: true + with: + custom-title: Core coverage report + coverage-file: ./coverage/ui-watchtower/report.json + # The same file as the base: no comparison with a base branch, + # which would run the Angular package's tests. + base-coverage-file: ./coverage/ui-watchtower/report.json + skip-step: all + + smoke: + name: Packed packages + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 22.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build both packages + run: npm run build + + # Packs both packages and installs them, outside the workspace, into a + # core-only Node and TypeScript consumer and an unchanged Angular one. + - name: Smoke-test the packed packages + run: npm run smoke + + release: + name: Release config + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + # The release check compares bootstrap-sha with the tagged commit. + fetch-tags: true + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 22.x + + # Plain Node: neither needs the workspace's dependencies. + - name: Test the publish script and the release workflow's wiring + run: npm run test:release + + # Replays releases through the Release Please version the release + # workflow runs, on this repository's config. + - name: Check the release config + run: npm run release:check + lint: name: Lint and format runs-on: ubuntu-latest diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 8df3dbd..60c286c 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -4,17 +4,22 @@ on: push: branches: - main + # Retries a failed or partial publish: run it on the release tag, + # e.g. `gh workflow run release-please.yml --ref ngx-ui-watchtower-v0.5.0`. + workflow_dispatch: permissions: {} jobs: release-please: + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: write pull-requests: write outputs: - release_created: ${{ steps.release.outputs['projects/ngx-ui-watchtower--release_created'] }} + core_release_created: ${{ steps.release.outputs['projects/ui-watchtower--release_created'] }} + ngx_release_created: ${{ steps.release.outputs['projects/ngx-ui-watchtower--release_created'] }} steps: - uses: googleapis/release-please-action@v5 id: release @@ -26,7 +31,13 @@ jobs: publish: needs: release-please - if: ${{ needs.release-please.outputs.release_created == 'true' }} + # After a release of either package, or when run by hand to retry. On a + # manual run release-please is skipped, which !cancelled() lets through. + if: >- + ${{ !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.release-please.outputs.core_release_created == 'true' || + needs.release-please.outputs.ngx_release_created == 'true') }} runs-on: ubuntu-latest permissions: # Required for npm trusted publishing (OIDC). No npm token needed. @@ -48,9 +59,10 @@ jobs: - name: Install dependencies run: npm ci - - name: Build library + - name: Build both packages run: npm run build + # The core, then the Angular package: each only if its release tag + # points at this commit and the version isn't on npm yet. - name: Publish to npm - working-directory: dist/ngx-ui-watchtower - run: npm publish + run: node scripts/publish-packages.mjs diff --git a/.gitignore b/.gitignore index dd2bf6e..925e170 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ testem.log # System files .DS_Store Thumbs.db + +# API snapshot output on a mismatch +*.snapshot.json.actual diff --git a/README.md b/README.md index a17a61b..ccff32f 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,55 @@

- - - - Angular UI Watchtower - + UI Watchtower + UI Watchtower

-# Angular UI Watchtower +# UI Watchtower -Source of [`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower), -a telemetry library for Angular apps: scenarios (user end-to-end journeys or specific functional workflows through an app), BI events and -logs, with an optional AWS CloudWatch RUM sink. +Telemetry for frontend applications, in three parts: -How to use the library: [projects/ngx-ui-watchtower/README.md](projects/ngx-ui-watchtower/README.md) -(also the npm page). -How it is designed: [projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watchtower/DESIGN.md). +- **Scenarios** — did a user journey or a workflow succeed, and how long did + it take? +- **BI events** — which features are used? +- **Logs** — what exactly happened? + +Events go to AWS CloudWatch RUM or another destination you choose; logs go +to a backend you provide. + +It comes as two packages, each with its own version: + +| Package | Use it in | Guide | +| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | +| [`@absaoss-cps/ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ui-watchtower) | Any frontend: plain JavaScript or TypeScript, or any framework. Framework adapters are built on it ([how](docs/ADAPTERS.md)). | [README](projects/ui-watchtower/README.md) | +| [`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower) | Angular applications. A thin adapter over `@absaoss-cps/ui-watchtower`: Angular providers, injectable services and Observables around the same engine. | [README](projects/ngx-ui-watchtower/README.md) | + +Each guide is also the package's npm page. How it is designed: +[projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watchtower/DESIGN.md). ## Layout ```text +projects/ui-watchtower/ + src/ @absaoss-cps/ui-watchtower — the framework-independent engine + rxjs/ secondary entry point — @absaoss-cps/ui-watchtower/rxjs + rum/ secondary entry point — @absaoss-cps/ui-watchtower/rum + type-tests/ consumer type tests, without a framework + README.md usage guide, published to npm + package.json the published package's manifest projects/ngx-ui-watchtower/ src/ main entry point — @absaoss-cps/ngx-ui-watchtower rum/ secondary entry point — @absaoss-cps/ngx-ui-watchtower/rum + type-tests/ consumer type tests README.md usage guide, published to npm DESIGN.md full design package.json the published package's manifest +docs/ ADAPTERS.md: how to write a framework adapter +scripts/ the public API snapshot, the packaging smoke test, and publishing ``` +The Angular package depends on the core, never the other way round. Each +package has its own version; a core release also releases the Angular +package, with its dependency moved (see [Releases](#releases)). + ## Getting started Requires Node.js 22 or later. @@ -37,20 +60,51 @@ npm run test npm run build ``` -The package is built to `dist/ngx-ui-watchtower`. +The packages are built to `dist/ui-watchtower` and `dist/ngx-ui-watchtower`, +the core first: the Angular package compiles against the built core, as an +application does. Tests and the typecheck read the core's source. ## Scripts -| Script | What it does | -| ----------------------- | ---------------------------------------------------- | -| `npm run build` | Builds the package (production, partial compilation) | -| `npm run watch` | Rebuilds on change (development) | -| `npm run test` | Runs the unit tests (Jest) | -| `npm run test:coverage` | Runs the unit tests with coverage, as CI does | -| `npm run typecheck` | Type-checks every library and spec tsconfig | -| `npm run lint` | ESLint, no warnings allowed | -| `npm run format` | Formats with Prettier | -| `npm run format:check` | Checks formatting without writing | +| Script | What it does | +| ----------------------- | ---------------------------------------------------------------- | +| `npm run build` | Builds both packages (production), the core first | +| `npm run watch` | Builds the core, then rebuilds the Angular package on change | +| `npm run test` | Runs the unit tests of both packages (Jest), then `test:release` | +| `npm run test:core` | Runs the core's unit tests only: plain Jest, no Angular | +| `npm run test:release` | Tests the publish script and the release workflow's wiring | +| `npm run test:coverage` | Runs the unit tests with coverage, as CI does; fails below 90% | +| `npm run typecheck` | Builds the core, then type-checks every tsconfig and type test | +| `npm run api:check` | Compares the built public APIs with their committed snapshots | +| `npm run api:update` | Writes those snapshots again, for an intended API change | +| `npm run smoke` | Installs the built packages, packed, into throwaway consumers | +| `npm run release:check` | Replays releases through Release Please on the release config | +| `npm run lint` | ESLint, no warnings allowed | +| `npm run format` | Formats with Prettier | +| `npm run format:check` | Checks formatting without writing | + +Four checks guard what consumers depend on: + +- **Type tests** (`type-tests/` in each package) compile a consumer that uses + every public export, and one that augments the name registries: registered + names accepted, others rejected, `string` while nothing is registered. The + Angular package's are compiled by the Angular compiler, the core's by plain + `tsc`. +- **The API snapshots** (`public-api.snapshot.json` in each package) record + every export of the built packages with its members and signatures. After an intended change, run `npm run api:update` and review + the diff. +- **The broadcast wire fixture** + (`projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json`) records + every message a shell and a fragment exchange. Shells and fragments are + deployed separately, so it changes only with a deliberate protocol change: + `UPDATE_WIRE_FIXTURE=1 npm test` records it again. Every pairing of an + Angular or framework-free shell and fragment is held to it. +- **The smoke test** (`scripts/smoke/`) packs both packages and installs the + tarballs, outside this workspace, into a framework-free Node consumer (no + Angular, RxJS or `aws-rum-web` installed), a TypeScript one (the core's + type tests, and a project without the DOM typings), and an unchanged + Angular application, prerendered as a fragment without `aws-rum-web` and as + a shell with `/rum`. It installs from the registry, so it needs a network. ## Before you push @@ -62,6 +116,9 @@ npm run typecheck npm run lint npm run format:check npm run build +npm run api:check +npm run smoke +npm run release:check ``` CI runs the same checks on every pull request and every push to `main`. @@ -80,15 +137,78 @@ Squash-merge pull requests: the title then becomes the commit message on ## Releases Releases are automated with [release-please](https://github.com/googleapis/release-please) -— don't publish by hand. - -1. Every push to `main` updates a release PR. `feat` bumps the minor version, - `fix`, `perf` and `revert` the patch; until 1.0, a breaking change bumps - the minor. Other types don't trigger a release. -2. The release PR holds the version bump and the `CHANGELOG.md` entry. -3. Merging it tags the release, creates the GitHub release and publishes the - package to npm from GitHub Actions through trusted publishing — no npm - token. +— don't publish by hand. Each package has its own version and changelog, and +is released when it changes: + +| A change to | Releases | +| ------------------- | ------------------------------------------------------------------------------------------ | +| The Angular package | The Angular package | +| The core | The core, and a patch of the Angular package that moves its dependency to `^` the new core | +| Both | Each, with its own bump | + +1. Every push to `main` updates one release PR, for every package with + changes. `feat` bumps the minor version, `fix`, `perf` and `revert` the + patch; until 1.0, a breaking change bumps the minor. Other types don't + trigger a release. +2. The release PR holds each released package's version bump and + `CHANGELOG.md` entry. When the core is released, Release Please's + `node-workspace` plugin also moves the Angular package's dependency on the + core and releases it as a patch, so it always depends on the core it was + built against. +3. Merging it tags each release, `ui-watchtower-v` or + `ngx-ui-watchtower-v` (until 0.4.0, the Angular package's tags + were `v`), and creates its GitHub release. GitHub Actions then + publishes what was released, the core before the Angular package, through + trusted publishing — no npm token. + +**A failed publish** stops before the next package, so the Angular package +never depends on a core that isn't on npm. To finish a partial release, run +the workflow again on the release tag: + +```bash +gh workflow run release-please.yml --ref ngx-ui-watchtower-v +``` + +[`scripts/publish-packages.mjs`](scripts/publish-packages.mjs) publishes a +package only if its release tag points at that commit and npm doesn't have +the version yet; any other answer from npm stops it. + +**The core's first release is 0.1.0, and its first publish is by hand.** The +core's `initial-version` in `release-please-config.json` sets that version. +npm sets up trusted publishing only for a package that exists. Before the +first release PR that releases the core is merged, an `@absaoss-cps` owner +publishes the core from that PR's branch: + +```bash +npm login +git fetch origin +git switch release-please--branches--main +npm ci && npm run build +npm publish dist/ui-watchtower +``` + +Then, on npmjs.com, in the package's settings, add the trusted publisher the +Angular package has: GitHub Actions, `AbsaOSS/ui-watchtower`, workflow +`release-please.yml`. After the merge, publishing finds 0.1.0 on npm, skips +it, and publishes the Angular package. If the release PR is merged first, +publishing stops at the core; publish it as above from the +`ui-watchtower-v0.1.0` tag, then run the workflow on the release tag. + +`bootstrap-sha` in `release-please-config.json` is the commit tagged +`v0.4.0`: it keeps what that release shipped out of the first changelogs, +and is read only until both packages have a release. If another `v` release +reaches `main` first, set it to that release's commit. `npm run release:check` +compares the two. + +`npm run release:check` replays releases through the Release Please version +the workflow runs, on this config: + +- the first release, with the core at 0.1.0; +- an Angular-only change releasing only the Angular package; +- a core change also releasing the Angular package, with its dependency moved; +- merging creating the tags publishing looks for. + +It installs Release Please, so it needs a network. ## License diff --git a/angular.json b/angular.json index 9cbcf28..b104002 100644 --- a/angular.json +++ b/angular.json @@ -7,6 +7,29 @@ "analytics": false }, "projects": { + "ui-watchtower": { + "projectType": "library", + "root": "projects/ui-watchtower", + "sourceRoot": "projects/ui-watchtower/src", + "prefix": "uwt", + "architect": { + "build": { + "builder": "@angular/build:ng-packagr", + "options": { + "project": "projects/ui-watchtower/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/ui-watchtower/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/ui-watchtower/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + } + } + }, "ngx-ui-watchtower": { "projectType": "library", "root": "projects/ngx-ui-watchtower", diff --git a/assets/logo/logo-stacked-auto.svg b/assets/logo/logo-stacked-auto.svg index ff6f3a4..ee0fc8f 100644 --- a/assets/logo/logo-stacked-auto.svg +++ b/assets/logo/logo-stacked-auto.svg @@ -1 +1 @@ -Angular UI Watchtower +UI Watchtower diff --git a/assets/logo/logo-stacked-dark.svg b/assets/logo/logo-stacked-dark.svg index 2231b0f..5659080 100644 --- a/assets/logo/logo-stacked-dark.svg +++ b/assets/logo/logo-stacked-dark.svg @@ -1 +1 @@ -Angular UI Watchtower +UI Watchtower diff --git a/assets/logo/logo-stacked-light.svg b/assets/logo/logo-stacked-light.svg index 3191bf6..d4a894e 100644 --- a/assets/logo/logo-stacked-light.svg +++ b/assets/logo/logo-stacked-light.svg @@ -1 +1 @@ -Angular UI Watchtower +UI Watchtower diff --git a/assets/logo/ngx-logo-stacked-auto.svg b/assets/logo/ngx-logo-stacked-auto.svg new file mode 100644 index 0000000..53ea0e8 --- /dev/null +++ b/assets/logo/ngx-logo-stacked-auto.svg @@ -0,0 +1 @@ +Angular UI Watchtower diff --git a/assets/logo/ngx-logo-stacked-dark.svg b/assets/logo/ngx-logo-stacked-dark.svg new file mode 100644 index 0000000..82f8618 --- /dev/null +++ b/assets/logo/ngx-logo-stacked-dark.svg @@ -0,0 +1 @@ +Angular UI Watchtower diff --git a/assets/logo/ngx-logo-stacked-light.svg b/assets/logo/ngx-logo-stacked-light.svg new file mode 100644 index 0000000..ff137bb --- /dev/null +++ b/assets/logo/ngx-logo-stacked-light.svg @@ -0,0 +1 @@ +Angular UI Watchtower diff --git a/docs/ADAPTERS.md b/docs/ADAPTERS.md new file mode 100644 index 0000000..cc660e9 --- /dev/null +++ b/docs/ADAPTERS.md @@ -0,0 +1,127 @@ +# Writing a framework adapter + +`@absaoss-cps/ui-watchtower` works on its own, in any frontend. An adapter +is optional: it only makes the library feel native to one framework. +[`@absaoss-cps/ngx-ui-watchtower`](../projects/ngx-ui-watchtower/README.md), the +Angular adapter, is a complete example. + +## What an adapter is + +An adapter is wiring, never a second engine. It reads the framework's +configuration, creates the core's objects, hands them out the framework's +way (dependency injection, context, hooks) and disposes them when the +framework tears down. Scenarios, BI events, logging, redaction and the +broadcast protocol stay in the core. If an adapter needs a behaviour the +core lacks, add it to the core, where every adapter and every plain +application gets it. + +An adapter needs only the package's public entry points: no deep import, no +private file. + +## Steps + +1. **One engine per application, or per fragment.** Create it with + `uwtCreateTelemetry` at startup, from the framework's configuration, and + share it through the framework's context or dependency injection, never + one per component. If the framework's dependency injection resolves each + concern on its own, as Angular's does, construct `UwtScenarioTelemetry`, + `UwtBITelemetry` and `UwtLogTelemetry` instead, from + `uwtResolveTelemetryConfig(overrides)`, one destination and one log store. +2. **The platform.** Pass `document: null` when rendering on a server, and + `runOutside` if the framework waits for pending timers before it is + stable, as Angular's zone does. +3. **Failures.** Pass a `report`, such as `uwtConsoleReporter` in + development. Telemetry calls never throw; `uwtCreateTelemetry` does, when + the identity (its application, environment or version), destination or + log store is missing. +4. **Reactive APIs.** Adapt `scenarios.onSettled(listener)`, which returns + an unsubscribe function, to the framework's primitive: an Observable, a + signal, a store. +5. **Destinations.** Accept any `UwtTelemetrySink`, and offer the core's: + the RUM sink, started with an unawaited `init()`; for a fragment, the + broadcast sink and log provider sharing one `uwtConnectBroadcastChannel()`; + for a shell, a `UwtTelemetryBroadcastHost`. Pass each the engine's + `enabled`, and the shared connection `open: enabled`. +6. **Teardown.** When the application or fragment is destroyed, dispose the + engine and then what you created for it. Every `dispose()` can be called + more than once. +7. **Names.** Re-export the four registries, so applications declare their + names on your package's module path and reach the same registries: + + ```ts + export type { + UwtBIEventNames, + UwtLoggerNames, + UwtScenarioNames, + UwtScenarioSteps + } from '@absaoss-cps/ui-watchtower'; + ``` + +8. **The dependency.** Depend on `@absaoss-cps/ui-watchtower` with a caret + range, as a dependency rather than a peer, so installing the adapter + installs the core. + +## How the Angular adapter does it + +| Step | In `@absaoss-cps/ngx-ui-watchtower` | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Engine | Three root services, one per concern, each building its core class from injected config: [services/](../projects/ngx-ui-watchtower/src/lib/services/). A missing provider still fails where Angular reports it. | +| Platform | `uwtBrowserDocument()` returns `null` outside a browser (`PLATFORM_ID`); `uwtRunOutsideZone()` wraps `NgZone.runOutsideAngular`: [uwt-telemetry-safe.util.ts](../projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts) | +| Failures | `uwtDevModeReporter`: failures and dropped data in development mode only, configuration problems always, prefixed `[ngx-ui-watchtower]` (same file) | +| Reactive | `settled$` is a `Subject` fed by `onSettled` and completed on destroy: [uwt-scenario-telemetry.service.ts](../projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts); `traceScenario` passes the reporter to the core's `/rxjs` | +| Destinations | `@Injectable()` subclasses of the core's sinks, host and log provider, built from `inject()`. In `'broadcast'` mode one `UwtBroadcastConnectionRef` per injector gives the sink and log provider one channel: [uwt-broadcast.messages.ts](../projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts) | +| Teardown | The scenario and logger services and every destination subclass call `dispose()` from `ngOnDestroy`; the BI service holds nothing to release | +| Names | [public-api.ts](../projects/ngx-ui-watchtower/src/public-api.ts) re-exports the registries, so `declare module '@absaoss-cps/ngx-ui-watchtower'` keeps working | +| Dependency | `"@absaoss-cps/ui-watchtower": "^"` in its `package.json`, moved by Release Please | + +## Testing an adapter + +The core's own tests cover the engine and the protocol. An adapter's tests +cover its wiring: + +- **One engine per scope.** Everything the framework hands out in one + application shares one engine, destination and log store. +- **Teardown.** Destroying the application disposes everything the adapter + created; destroying it twice does nothing more. +- **Off and on the server.** With telemetry off, or rendering on a server, + nothing listens to the page, no channel opens and no RUM client starts. +- **Names.** A type test that declares names on the adapter's module path: + declared names compile, others don't, and plain strings compile while + nothing is declared. See + [type-tests/](../projects/ngx-ui-watchtower/type-tests/). +- **Shells and fragments.** An adapter's fragment and shell must exchange + the recorded messages: + [uwt-broadcast.wire.spec.ts](../projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts) + holds every pairing to `uwt-broadcast.wire.fixture.json`. + +## Adding an adapter to this repository + +An adapter can live anywhere and depend on the published core. To release it +from this repository, with a version of its own; each core release then also +releases it, with its dependency on the core moved: + +1. **The package.** `projects//` with its `package.json` (its own + version, and `^` the core's current version as a dependency), README and + LICENSE, built into `dist/` by `npm run build`, after the core. +2. **Release Please.** Add the package to `release-please-config.json`, with + a `component` and an `initial-version` (e.g. `0.1.0`), its first release. + Release Please adds it to `.release-please-manifest.json` when it + releases. +3. **Publishing.** Add it to `PACKAGES` in + [scripts/publish-packages.mjs](../scripts/publish-packages.mjs), after the + core, with `dependsOn: '@absaoss-cps/ui-watchtower'`. Add its + `--release_created` output to `.github/workflows/release-please.yml`, and + to the publish job's condition. +4. **Checks.** Add it to `PACKAGES` in + [scripts/api-snapshot.mjs](../scripts/api-snapshot.mjs) and run + `npm run api:update`; add a consumer to + [scripts/smoke/smoke.mjs](../scripts/smoke/smoke.mjs). The release + scenarios in [scripts/release-check.mjs](../scripts/release-check.mjs) and + the workflow test in + [scripts/publish-packages.test.mjs](../scripts/publish-packages.test.mjs) + name the core and the Angular package; extend them to the new one. +5. **Docs.** Add a row to the root README's package table. + +Start with `PACKAGES`: `npm run test:release` then fails, in CI too, until +the config (or the manifest), the package's `package.json` and the release +job's outputs all match it. diff --git a/jest.config.js b/jest.config.js index 3a8a9c8..5f0a6d6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,8 +1,14 @@ module.exports = { - roots: ['/projects'], + roots: ['/projects/ngx-ui-watchtower'], coverageDirectory: '/coverage', preset: 'jest-preset-angular', moduleNameMapper: { + '^@absaoss-cps/ui-watchtower$': + '/projects/ui-watchtower/src/public-api.ts', + '^@absaoss-cps/ui-watchtower/rxjs$': + '/projects/ui-watchtower/rxjs/src/public-api.ts', + '^@absaoss-cps/ui-watchtower/rum$': + '/projects/ui-watchtower/rum/src/public-api.ts', '^@absaoss-cps/ngx-ui-watchtower$': '/projects/ngx-ui-watchtower/src/public-api.ts', '^@absaoss-cps/ngx-ui-watchtower/rum$': @@ -27,6 +33,11 @@ module.exports = { collectCoverageFrom: [ 'projects/ngx-ui-watchtower/**/*.ts', '!projects/**/*.spec.ts', - '!projects/**/public-api.ts' - ] + '!projects/**/public-api.ts', + // Compiled by the typecheck, never run. + '!projects/**/type-tests/**' + ], + coverageThreshold: { + global: { statements: 90, branches: 90, functions: 90, lines: 90 } + } }; diff --git a/package-lock.json b/package-lock.json index 8aaef95..66a8b84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "jest-preset-angular": "^17.0.0", "ng-packagr": "~21.0.1", "prettier": "^3.4.2", + "ts-jest": "^29.4.14", "typescript": "~5.9.3" }, "engines": { diff --git a/package.json b/package.json index 0c44ab7..354a0d9 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,20 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "build": "ng build --configuration=production --project=ngx-ui-watchtower", - "watch": "ng build ngx-ui-watchtower --watch --configuration development", - "test": "ng test --project=ngx-ui-watchtower", - "test:coverage": "ng test --project=ngx-ui-watchtower --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json", + "build": "ng build --configuration=production --project=ui-watchtower && ng build --configuration=production --project=ngx-ui-watchtower", + "watch": "ng build ui-watchtower --configuration development && ng build ngx-ui-watchtower --watch --configuration development", + "test": "npm run test:core && ng test --project=ngx-ui-watchtower && npm run test:release", + "test:core": "jest --config projects/ui-watchtower/jest.config.js", + "test:release": "node --test scripts/*.test.mjs", + "test:coverage": "jest --config projects/ui-watchtower/jest.config.js --coverage --coverageReporters=text-summary --coverageReporters=lcov --json --outputFile=./coverage/ui-watchtower/report.json && ng test --project=ngx-ui-watchtower --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json", "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" + "format": "prettier --write \"**/*.{ts,mjs,html}\"", + "format:check": "prettier --check \"**/*.{ts,mjs,html}\"", + "typecheck": "tsc -p projects/ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/rxjs/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ui-watchtower/type-tests/tsconfig.json && tsc -p projects/ui-watchtower/type-tests/tsconfig.augmented.json && ng build --configuration=production --project=ui-watchtower && 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 && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.json && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json", + "api:check": "node scripts/api-snapshot.mjs", + "api:update": "node scripts/api-snapshot.mjs --update", + "smoke": "node scripts/smoke/smoke.mjs", + "release:check": "node scripts/release-check.mjs" }, "private": true, "dependencies": { @@ -46,6 +52,7 @@ "jest-preset-angular": "^17.0.0", "ng-packagr": "~21.0.1", "prettier": "^3.4.2", + "ts-jest": "^29.4.14", "typescript": "~5.9.3" }, "engines": { diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index 0e6eb0d..b3b58a6 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -89,6 +89,20 @@ flowchart TD Everything is wired through Angular DI, so a destination can be swapped in production and stubbed in tests. +The three services are Angular facades over the core's engine. Each holds the +core's class for its concern (`UwtScenarioTelemetry`, `UwtBITelemetry`, +`UwtLogTelemetry`), built from its own injections, so a missing provider still +fails where it always did: the logger needs a log provider but no +destination, scenarios need a destination and no log provider. The services +are root singletons, so each concern exists once per injector, and all of +them share the one destination and log provider. `settled$` is the core's +`onSettled` as an Observable that completes on destroy. It listens to the +core only while subscribed, so no record is copied while nobody is +subscribed; `traceScenario` is the +core's `./rxjs` operator, reporting through this package's dev-mode +reporter. Framework-free applications compose the same three classes with +`uwtCreateTelemetry`. + ### Entry points Two, each pulling in only what it needs: @@ -192,16 +206,60 @@ providers: [ ### Packaging and internals +- **The framework-independent code lives in `@absaoss-cps/ui-watchtower`.** + This package depends on it as a regular dependency, so installing this + package installs it too, and re-exports what it has always exported, under + the same names. The dependency runs one way: the core imports neither + Angular nor this package, which a lint rule enforces. The library build + compiles against the core's built declarations, not its source, so it sees + the core exactly as an application does. An application upgrading from + 0.4 changes nothing. Another framework's adapter needs only the core's + public entry points, as this package does; + `docs/ADAPTERS.md` is a short guide. +- **What the core owns, this package re-exports.** The engine, the models + and name registries, the configuration types and defaults + (`uwtResolveTelemetryConfig` completes every `with*` feature), redaction, + `UwtScenario`, the `UwtTelemetrySink` and `UwtLogApiProvider` contracts and + their no-op implementations are the core's. This package keeps the + injection tokens, the services and providers, and the destinations that + need Angular. `UwtTelemetrySink` is a plain abstract class in the core, not + `@Injectable()`: Angular uses it as the destination's token as before, and + a custom sink still extends it. Every class an application provides stays + `@Injectable()` here — `UwtNoopTelemetrySink` and `UwtNoopLogApiProvider` + are decorated subclasses of the core's — so the published declarations + keep their Angular metadata. - **Every export is explicit.** The barrels list their exports one by one — in a published package each exported name is a compatibility promise. Id generation, the clock, the fail-open wrappers, User Timings and the broadcast plumbing stay internal so they can change freely. Redaction is the exception: a custom sink needs `uwtRedactMetadata`, `uwtNormalizeError`, `uwtScrubString` and `uwtRedactConfigFor`. +- **The RUM sink is the core's too.** Its logic — the lazy `aws-rum-web` + import, startup retries, credential refresh, the pre-init buffer, the + settings check — lives in `@absaoss-cps/ui-watchtower/rum`. This package's + `/rum` keeps `UWT_RUM_CREDENTIALS_PROVIDER`, `provideUwtTelemetryRumSink()` + and `UwtRumTelemetrySink`, an injectable subclass of the core's. - **The secondary entry keeps 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. + internals by relative path and carries small copies of what it needs: its + Angular helpers and the reporter. A spec (`uwt-rum-internal.util.spec.ts`) + runs both copies against the same cases, and fails if their exports or + behaviour differ, so they can't drift apart. +- **What is published is tested as published.** `npm run smoke` installs + both tarballs into consumers outside the workspace: a framework-free one + with neither Angular nor `aws-rum-web` installed, and an Angular + application, prerendered as a fragment without `aws-rum-web` — which then + holds no RUM code — and as a shell with `/rum`, whose RUM client is a chunk + loaded on demand. +- **Each package has its own version.** A change to this package releases + only this package. A core release also releases this package as a patch: + Release Please's `node-workspace` plugin moves its dependency on the core + to `^` the new version in the same release PR, so it always depends on the + core it was built against. The release workflow publishes the core first + and this package only after it, so this package never depends on a core + version npm doesn't have. Publishing skips what npm already has, so running + the workflow again on the release tag completes a partial release. - **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. @@ -264,6 +322,14 @@ scenario.step('fetch-data'); // ok scenario.step('fetch-dat'); // error TS2345 ``` +The registries are declared once, in `@absaoss-cps/ui-watchtower`, and +re-exported here. TypeScript merges an augmentation into a re-exported +interface's declaration, so augmenting `@absaoss-cps/ngx-ui-watchtower` +checks the core's own APIs too: one vocabulary per application. A +framework-free application augments `@absaoss-cps/ui-watchtower` the same +way. The type tests in both packages' `type-tests/` hold each module path to +it. + A typo here would not just produce a wrong number. It would silently start a **second, incomplete** metric series, and the alarm built on the first one would keep reading healthy. Interpolating an id — ``step(`load-${id}`)`` — @@ -1569,10 +1635,15 @@ convention, for an application that wants the pre-fallback behavior back. ## 11. Error handling and performance - **Telemetry never breaks the application.** Every public entry point is - wrapped and never rethrows. In development, a suppressed error is still - reported to `console.error`, so bugs surface; in production it stays - silent. Even a broken `console.error` cannot turn a suppressed failure into - a throw. + wrapped and never rethrows. The core hands a suppressed error (`'error'`), + or data it had to drop (`'warn'`), to the `UwtReporter` it is given. This + package gives it one that writes to the console, prefixed + `[ngx-ui-watchtower]`, in development mode only, so bugs surface in + development and production stays silent. What cannot work as configured + (`'config'`) — a second broadcast host, a host in a forwarding realm, RUM + settings of the wrong type — is printed in production too, as it always + was; without a reporter, the core prints it itself. Even a broken + `console.error` cannot turn a suppressed failure into a throw. - **Fail-open.** A broker outage, expired credentials or an SDK throw all leave the application behaving exactly as with healthy telemetry. A failed broker request at startup is retried with backoff for five minutes. A @@ -1587,15 +1658,16 @@ convention, for an application that wants the pre-fallback behavior back. mark-cleanup fallback, a fragment's log-query timeout, the RUM client's dispatch interval and the sink's retry and refresh timers — are scheduled outside Angular's zone, so telemetry never keeps the application from - becoming stable. + becoming stable. The core takes that hook as `runOutside`; this package + passes `NgZone.runOutsideAngular`. - **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 console report is itself wrapped in a try/catch — an application that has -patched or otherwise broken `console.error` cannot turn a suppressed -telemetry failure into a rethrow, in `uwtSafe`, or into a fresh unhandled -rejection, in `uwtSafeVoidMaybeAsync`'s async path. Tests +The report is itself wrapped in a try/catch — a reporter that throws, or an +application that has patched or otherwise broken `console.error`, cannot +turn a suppressed telemetry failure into a rethrow, in `uwtSafe`, or into a +fresh unhandled rejection, in `uwtSafeVoidMaybeAsync`'s async path. Tests assert both halves, and assert that a sink or transport throwing on every call leaves application code unaffected. @@ -2141,6 +2213,23 @@ providers: [ Code inside a fragment does not change: same services, same calls. One session, one budget, one bundle. +| Realm | Destination | RUM and `UWT_RUM_CREDENTIALS_PROVIDER` | `UWT_LOG_API_PROVIDER` | +| -------------- | --------------------------------------------------- | -------------------------------------- | -------------------------------------- | +| Standalone app | Its own: `provideUwtTelemetryRumSink()`, or another | The app's | The app's | +| Shell | Its own, plus `provideUwtTelemetryBroadcastHost()` | The shell's: the only ones on the page | The shell's, also for fragments' logs | +| Fragment | `provideUwtTelemetrySink('broadcast')` | None: the shell's records for it | Bound by `'broadcast'`, to the shell's | + +Either side may be built without Angular. The protocol — the messages, the +host, the forwarding sink and log provider — lives in +`@absaoss-cps/ui-watchtower`, and this package's classes are injectable +subclasses of the core's. An Angular shell hosts a framework-free fragment, +and a framework-free shell an Angular one, exchanging the same messages: the +wire test holds every pairing to the fixture recorded before the core was +extracted. A fragment wired by `provideUwtTelemetrySink('broadcast')` opens one +channel for its sink and log provider, closed with its injector. The host +takes part — counting toward the duplicate check, electing a leader — only in +a browser with telemetry on, so server-side renders never see each other. + ### Connecting a fragment Two providers, and that is the whole integration: diff --git a/projects/ngx-ui-watchtower/README.md b/projects/ngx-ui-watchtower/README.md index 9d8c910..1831450 100644 --- a/projects/ngx-ui-watchtower/README.md +++ b/projects/ngx-ui-watchtower/README.md @@ -1,8 +1,8 @@

- - - Angular UI Watchtower + + + Angular UI Watchtower

@@ -16,6 +16,12 @@ Telemetry for Angular apps, in three parts: Events go to AWS CloudWatch RUM. Logs go to a backend you provide. +It is a thin Angular adapter over +[`@absaoss-cps/ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ui-watchtower), +the framework-independent engine that does the work, and can be used in any +frontend. This package adds what Angular expects: providers, injectable +services, Observables. + The library's names are prefixed `Uwt`, short for UI Watchtower — `UwtLoggerService`, `provideUwtTelemetry`, `UWT_LOG_API_PROVIDER`. @@ -26,7 +32,15 @@ npm install @absaoss-cps/ngx-ui-watchtower npm install aws-rum-web # only for the AWS RUM sink ``` -Requires Angular 21 or 22 and RxJS 7.8. +Requires Angular 21 or 22 and RxJS 7.8. It installs +`@absaoss-cps/ui-watchtower` with it: there is nothing more to install or +configure. If your application also imports the core directly, don't install +a version this package doesn't accept: npm would keep two copies, and name +declarations and `instanceof` checks would no longer match between them. Let +npm pick one version for both. + +Upgrading from 0.4 needs no code or configuration change: the imports, +providers, services, name declarations and behavior stay the same. ## Setup @@ -200,7 +214,9 @@ declare module '@absaoss-cps/ngx-ui-watchtower' { export {}; ``` -Until you declare any, plain strings are accepted. +Until you declare any, plain strings are accepted. The registries are +declared in `@absaoss-cps/ui-watchtower`, the engine this package builds on, +and re-exported here: augmenting this package, as above, covers both. ## Scenarios @@ -346,7 +362,18 @@ providers: [ ]; ``` +Who owns what: + +| Realm | Destination | RUM and its credentials provider | Log backend | +| -------------- | --------------------------------------------------- | -------------------------------------- | ------------------------------------- | +| Standalone app | Its own: `provideUwtTelemetryRumSink()`, or another | The app's | The app's | +| Shell | Its own, plus `provideUwtTelemetryBroadcastHost()` | The shell's: the only ones on the page | The shell's, also for fragments' logs | +| Fragment | `provideUwtTelemetrySink('broadcast')` | None: the shell's records for it | None: `'broadcast'` forwards them | + Fragment code doesn't change. Give each fragment its own `application` name. +A shell or a fragment needn't be Angular: one built on +[`@absaoss-cps/ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ui-watchtower) +connects the same way. ## Seeing what is sent diff --git a/projects/ngx-ui-watchtower/ng-package.json b/projects/ngx-ui-watchtower/ng-package.json index ff8424f..2e407db 100644 --- a/projects/ngx-ui-watchtower/ng-package.json +++ b/projects/ngx-ui-watchtower/ng-package.json @@ -3,5 +3,6 @@ "dest": "../../dist/ngx-ui-watchtower", "lib": { "entryFile": "src/public-api.ts" - } + }, + "allowedNonPeerDependencies": ["@absaoss-cps/ui-watchtower"] } diff --git a/projects/ngx-ui-watchtower/package.json b/projects/ngx-ui-watchtower/package.json index 604c14b..58bd169 100644 --- a/projects/ngx-ui-watchtower/package.json +++ b/projects/ngx-ui-watchtower/package.json @@ -20,6 +20,7 @@ "homepage": "https://github.com/AbsaOSS/ui-watchtower", "license": "Apache-2.0", "dependencies": { + "@absaoss-cps/ui-watchtower": "^0.1.0", "tslib": "^2.8.1" }, "sideEffects": false, diff --git a/projects/ngx-ui-watchtower/public-api.snapshot.json b/projects/ngx-ui-watchtower/public-api.snapshot.json new file mode 100644 index 0000000..37205e3 --- /dev/null +++ b/projects/ngx-ui-watchtower/public-api.snapshot.json @@ -0,0 +1,574 @@ +{ + "@absaoss-cps/ngx-ui-watchtower": { + "provideUwtTelemetry": [ + "function(identity: UwtTelemetryIdentity, ...features: UwtTelemetryFeature[]): EnvironmentProviders" + ], + "provideUwtTelemetryBroadcastHost": [ + "function(channelName?: string | undefined): EnvironmentProviders" + ], + "provideUwtTelemetryDestination": [ + "function(sink: Type, options?: UwtTelemetryDestinationOptions | undefined): EnvironmentProviders" + ], + "provideUwtTelemetrySink": [ + "function(mode: UwtTelemetryLocalSinkMode, options?: { channelName?: string | undefined; } | undefined): EnvironmentProviders" + ], + "traceScenario": [ + "function(scenario: UwtScenario, options?: UwtTraceScenarioOptions | ((value: T) => void | UwtScenarioOutcome) | undefined): MonoTypeOperatorFunction" + ], + "UWT_BI_TELEMETRY_CONFIG": [ + "const: InjectionToken" + ], + "UWT_BROADCAST_CHANNEL": [ + "const: InjectionToken" + ], + "UWT_DEFAULT_BROADCAST_CHANNEL": [ + "const: 'ngx-ui-watchtower'" + ], + "UWT_DEFAULT_EVENT_NAMESPACE": [ + "const: 'com.uwt'" + ], + "UWT_DEFAULT_REDACT_CONFIG": [ + "const: UwtRedactConfig" + ], + "UWT_DEFAULT_TELEMETRY_CONFIG": [ + "const: { eventNamespace: string; scenario: UwtScenarioTelemetryConfig; logs: UwtLogConfig; bi: UwtBITelemetryConfig; redact: UwtRedactConfig; }" + ], + "UWT_LOG_API_PROVIDER": [ + "const: InjectionToken" + ], + "UWT_LOG_CONFIG": [ + "const: InjectionToken" + ], + "UWT_LOG_LEVEL_ORDER": [ + "const: Record" + ], + "UWT_REDACT_CONFIG": [ + "const: InjectionToken" + ], + "UWT_REDACTED": [ + "const: '[redacted]'" + ], + "UWT_SCENARIO_TELEMETRY_CONFIG": [ + "const: InjectionToken" + ], + "UWT_TELEMETRY_ENABLED": [ + "const: InjectionToken" + ], + "UWT_TELEMETRY_EVENT_TYPE": [ + "const: UwtTelemetryEventTypes" + ], + "UWT_TELEMETRY_IDENTITY": [ + "const: InjectionToken" + ], + "UwtBIEvent": [ + "interface", + "application: string", + "eventName: string", + "eventTime: string", + "feature?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "scenarioId?: string | undefined", + "sessionId?: string | undefined", + "userId?: string | undefined" + ], + "UwtBIEventDetail": [ + "interface", + "feature?: string | undefined", + "scenarioId?: string | undefined" + ], + "UwtBIEventName": [ + "type = string" + ], + "UwtBIEventNames": [ + "interface" + ], + "UwtBITelemetryConfig": [ + "interface", + "dedupMaxKeys: number", + "dedupWindowMs: number", + "redact: boolean" + ], + "UwtBITelemetryService": [ + "class", + "new (): UwtBITelemetryService", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "track(eventName: string, metadata?: UwtTelemetryMetadata | undefined, detail?: UwtBIEventDetail | undefined): void" + ], + "UwtBroadcastLogApiProvider": [ + "class", + "new (): UwtBroadcastLogApiProvider", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "dispose(): void", + "flush(): void", + "ngOnDestroy(): void", + "query(filter: UwtLogQuery): Promise", + "send(record: UwtLogRecord): void" + ], + "UwtBroadcastTelemetrySink": [ + "class", + "new (): UwtBroadcastTelemetrySink", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "dispose(): void", + "flush(beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "ngOnDestroy(): void", + "record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "setUserId(userId: string | undefined): void" + ], + "uwtClassifyTelemetryEvent": [ + "function(eventType: string, payload: object): UwtTelemetrySinkEvent" + ], + "UwtDebugFlag": [ + "type = 'debugLogger' | 'debugScenario' | 'debugBI'" + ], + "uwtEventTypes": [ + "function(namespace?: string | undefined): UwtTelemetryEventTypes" + ], + "uwtIsDebugEnabled": [ + "function(flag: UwtDebugFlag, name?: string | undefined): boolean" + ], + "UwtLogApiProvider": [ + "interface", + "flush?(): void", + "query(filter: UwtLogQuery): Promise", + "send(record: UwtLogRecord): void" + ], + "UwtLogConfig": [ + "interface", + "levels?: Partial> | undefined", + "minLevel: UwtLogLevel", + "mirrorErrorsToRum: boolean", + "redact: boolean" + ], + "UwtLogDetail": [ + "interface", + "context?: string | undefined", + "correlationId?: string | undefined", + "error?: unknown", + "metadata?: UwtTelemetryMetadata | undefined" + ], + "UwtLogger": [ + "interface", + "error(message: string, detail?: UwtLogDetail | undefined): void", + "log(message: string, detail?: UwtLogDetail | undefined): void", + "warn(message: string, detail?: UwtLogDetail | undefined): void" + ], + "UwtLoggerName": [ + "type = string" + ], + "UwtLoggerNames": [ + "interface" + ], + "UwtLoggerService": [ + "class", + "new (): UwtLoggerService", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "getLogger(name: string): UwtLogger", + "ngOnDestroy(): void", + "query(filter?: UwtLogQuery | undefined): Promise" + ], + "UwtLogLevel": [ + "type = 'log' | 'warn' | 'error'" + ], + "UwtLogQuery": [ + "interface", + "correlationId?: string | undefined", + "from?: string | undefined", + "limit?: number | undefined", + "logger?: string | undefined", + "minLevel?: UwtLogLevel | undefined", + "to?: string | undefined" + ], + "UwtLogRecord": [ + "interface", + "application: string", + "context?: string | undefined", + "correlationId?: string | undefined", + "environment: string", + "error?: UwtTelemetryError | undefined", + "level: UwtLogLevel", + "logger?: string | undefined", + "message: string", + "metadata?: UwtTelemetryMetadata | undefined", + "sessionId?: string | undefined", + "timestamp: string", + "userId?: string | undefined", + "version: string" + ], + "uwtMarkSessionStart": [ + "function(sessionId: string, now?: number | undefined): void" + ], + "UwtNoopLogApiProvider": [ + "class", + "new (): UwtNoopLogApiProvider", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "query(_filter: UwtLogQuery): Promise", + "send(_record: UwtLogRecord): void" + ], + "UwtNoopTelemetrySink": [ + "class", + "new (): UwtNoopTelemetrySink", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "flush(_beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "record(_eventType: string, _payload: object, _metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(_error: UwtTelemetryError, _metadata?: UwtTelemetryMetadata | undefined): void", + "setUserId(userId: string | undefined): void" + ], + "uwtNormalizeError": [ + "function(error: unknown, config?: UwtRedactConfig | undefined): UwtTelemetryError | undefined" + ], + "UwtPiiValuePattern": [ + "type = 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'" + ], + "UwtRedactConfig": [ + "interface", + "extraKeyPatterns: RegExp[]", + "extraValuePatterns: RegExp[]", + "extraValueTransforms: ((value: string) => string)[]", + "includeStack: boolean", + "maxKeys: number", + "maxStackLength: number", + "maxStringLength: number", + "scanValuePatterns: UwtPiiValuePattern[]", + "stripUrlQuery: boolean" + ], + "uwtRedactConfigFor": [ + "function(config: UwtRedactConfig, enabled: boolean): UwtRedactConfig" + ], + "uwtRedactMetadata": [ + "function(value: unknown, config?: UwtRedactConfig | undefined): UwtTelemetryMetadata | undefined" + ], + "UwtScenario": [ + "class", + "new (options: UwtScenarioOptions, deps: UwtScenarioDeps): UwtScenario", + "aggregateEnd(name: string): UwtScenario", + "aggregateStart(name: string): UwtScenario", + "cancel(outcome?: UwtScenarioOutcome | undefined): void", + "complete(outcome?: UwtScenarioOutcome | undefined): void", + "endStep(detail?: UwtScenarioStepDetail | undefined): UwtScenario", + "fail(outcome?: UwtScenarioOutcome | undefined): void", + "failStep(error: unknown, detail?: UwtScenarioStepDetail | undefined): UwtScenario", + "get delta: number", + "get id: string", + "get isSettled: boolean", + "get name: string", + "get status: UwtScenarioStatus | undefined", + "incomplete(outcome?: UwtScenarioOutcome | undefined): void", + "setData(metadata: UwtTelemetryMetadata): UwtScenario", + "settle(status: UwtScenarioStatus, outcome?: UwtScenarioOutcome | undefined, error?: unknown): void", + "step(name: string, metadata?: UwtTelemetryMetadata | undefined): UwtScenario", + "toRecord(): UwtScenarioRecord" + ], + "UwtScenarioAggregate": [ + "interface", + "callCount: number", + "elapsed: number", + "name: string" + ], + "UwtScenarioName": [ + "type = string" + ], + "UwtScenarioNames": [ + "interface" + ], + "UwtScenarioOptions": [ + "interface", + "feature?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "operation?: string | undefined", + "parentScenarioId?: string | undefined", + "route?: string | undefined", + "startedAt?: number | undefined", + "timeoutMs?: number | undefined" + ], + "UwtScenarioOutcome": [ + "interface", + "error?: unknown", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "reason?: string | undefined", + "statusCode?: string | number | undefined" + ], + "UwtScenarioRecord": [ + "interface", + "aggregates?: UwtScenarioAggregate[] | undefined", + "application: string", + "delta: number", + "elapsed: number", + "endTime?: string | undefined", + "error?: UwtTelemetryError | undefined", + "exceededStepsLimit?: boolean | undefined", + "feature?: string | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "operation?: string | undefined", + "parentScenarioId?: string | undefined", + "previousStep?: string | undefined", + "reason?: string | undefined", + "route?: string | undefined", + "scenarioId: string", + "scenarioName: string", + "sessionElapsed?: number | undefined", + "sessionId?: string | undefined", + "startTime: string", + "status?: UwtScenarioStatus | undefined", + "statusCode?: string | number | undefined", + "stepCount: number", + "steps: UwtScenarioStep[]", + "userId?: string | undefined" + ], + "UwtScenarioStatus": [ + "type = 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'" + ], + "UwtScenarioStep": [ + "interface", + "elapsed?: number | undefined", + "endOffset?: number | undefined", + "error?: UwtTelemetryError | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "reason?: string | undefined", + "startOffset: number", + "status?: UwtScenarioStatus | undefined", + "stepDelta?: number | undefined" + ], + "UwtScenarioStepDetail": [ + "interface", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "reason?: string | undefined" + ], + "UwtScenarioStepEvent": [ + "interface", + "application: string", + "elapsed?: number | undefined", + "endOffset?: number | undefined", + "error?: UwtTelemetryError | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "reason?: string | undefined", + "scenarioId: string", + "scenarioName: string", + "sessionId?: string | undefined", + "startOffset: number", + "status?: UwtScenarioStatus | undefined", + "stepDelta?: number | undefined", + "userId?: string | undefined" + ], + "UwtScenarioSteps": [ + "interface" + ], + "UwtScenarioStepStatus": [ + "type = 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'" + ], + "UwtScenarioTelemetryConfig": [ + "interface", + "defaultTimeoutMs: number", + "emitLifecycleEvents: boolean", + "markCleanupFallbackMs: number", + "maxSteps: number", + "redact: boolean", + "userTimings: boolean" + ], + "UwtScenarioTelemetryService": [ + "class", + "new (): UwtScenarioTelemetryService", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "find(scenarioId: string): UwtScenario | undefined", + "findByName(name: string): UwtScenario[]", + "findByNameAndId(name: string, scenarioId: string): UwtScenario | undefined", + "getActive(): UwtScenario[]", + "ngOnDestroy(): void", + "readonly settled$: Observable", + "start(options: UwtScenarioOptions): UwtScenario" + ], + "uwtScrubString": [ + "function(value: string, config: UwtRedactConfig): string" + ], + "UwtStepName": [ + "type = string" + ], + "UwtTelemetryAttribution": [ + "interface", + "application: string", + "sessionId?: string | undefined", + "userId?: string | undefined" + ], + "UwtTelemetryBroadcastHost": [ + "class", + "new (): UwtTelemetryBroadcastHost", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "dispose(): void", + "get received: number", + "ngOnDestroy(): void" + ], + "UwtTelemetryDestinationOptions": [ + "interface", + "init?: ((sink: T) => void) | undefined" + ], + "UwtTelemetryError": [ + "interface", + "message: string", + "name: string", + "stack?: string | undefined" + ], + "UwtTelemetryEventTypes": [ + "interface", + "bi: string", + "scenario: string", + "scenarioStep: string" + ], + "UwtTelemetryFeature": [ + "interface", + "providers: Provider[]" + ], + "UwtTelemetryIdentity": [ + "interface", + "application: string", + "environment: string", + "eventNamespace?: string | undefined", + "version: string" + ], + "UwtTelemetryLocalSinkMode": [ + "type = 'broadcast' | 'noop'" + ], + "UwtTelemetryMetadata": [ + "type = { [x: string]: string | number | boolean | null; }" + ], + "UwtTelemetrySink": [ + "abstract class", + "new (): UwtTelemetrySink", + "abstract flush(beacon?: boolean | undefined): void", + "abstract getSessionId(): string | undefined", + "abstract getUserId(): string | undefined", + "abstract record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "abstract recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "abstract setUserId(userId: string | undefined): void" + ], + "UwtTelemetrySinkEvent": [ + "type = { kind: 'scenario'; payload: UwtScenarioRecord; } | { kind: 'scenario-step'; payload: UwtScenarioStepEvent; } | { kind: 'bi'; payload: UwtBIEvent; } | { kind: 'unknown'; payload: object; }" + ], + "UwtTraceScenarioOptions": [ + "interface", + "cancelOutcome?: UwtScenarioOutcome | (() => void | UwtScenarioOutcome) | undefined", + "outcome?: ((value: T) => void | UwtScenarioOutcome) | undefined", + "valueThenTeardown?: 'complete' | 'cancel' | undefined" + ], + "uwtUuid": [ + "function(): string" + ], + "withBIEvents": [ + "function(config?: Partial | undefined): UwtTelemetryFeature" + ], + "withLogging": [ + "function(config?: Partial | undefined): UwtTelemetryFeature" + ], + "withRedaction": [ + "function(config?: Partial | undefined): UwtTelemetryFeature" + ], + "withScenarios": [ + "function(config?: Partial | undefined): UwtTelemetryFeature" + ], + "withTelemetryEnabled": [ + "function(enabled: boolean | (() => boolean)): UwtTelemetryFeature" + ] + }, + "@absaoss-cps/ngx-ui-watchtower/rum": { + "provideUwtTelemetryRumSink": [ + "function(): EnvironmentProviders" + ], + "UWT_RUM_CREDENTIALS_PROVIDER": [ + "const: InjectionToken" + ], + "UwtRumAppMonitorConfig": [ + "interface", + "alias?: string | undefined", + "allowCookies?: boolean | undefined", + "applicationAttributes?: Record | undefined", + "applicationId: string", + "applicationVersion: string", + "batchLimit?: number | undefined", + "candidatesCacheSize?: number | undefined", + "client?: string | undefined", + "clientBuilder?: ((endpoint: URL, region: string, credentials?: ClientBuilderCredentials | undefined, compressionStrategy?: { enabled: boolean; } | undefined) => unknown) | undefined", + "compressionStrategy?: { enabled: boolean; } | undefined", + "cookieAttributes?: { unique?: boolean | undefined; domain?: string | undefined; path?: string | undefined; sameSite?: string | undefined; secure?: boolean | undefined; } | undefined", + "debug?: boolean | undefined", + "disableAutoPageView?: boolean | undefined", + "dispatchInterval?: number | undefined", + "enableRumClient?: boolean | undefined", + "enableW3CTraceId?: boolean | undefined", + "enableXRay?: boolean | undefined", + "endpoint?: string | undefined", + "eventCacheSize?: number | undefined", + "eventPluginsToLoad?: unknown[] | undefined", + "fetchFunction?: ((input: RequestInfo | URL, init?: RequestInit | undefined) => Promise) | undefined", + "headers?: Record | undefined", + "pageIdFormat?: 'PATH' | 'HASH' | 'PATH_AND_HASH' | undefined", + "pagesToExclude?: RegExp[] | undefined", + "pagesToInclude?: RegExp[] | undefined", + "recordResourceUrl?: boolean | undefined", + "region: string", + "releaseId?: string | undefined", + "retries?: number | undefined", + "routeChangeComplete?: number | undefined", + "routeChangeTimeout?: number | undefined", + "sessionAttributes?: Record | undefined", + "sessionEventLimit?: number | undefined", + "sessionLengthSeconds?: number | undefined", + "sessionSampleRate?: number | undefined", + "signing?: boolean | undefined", + "suppressSessionStartEvent?: boolean | undefined", + "telemetries?: (string | (string | object)[])[] | undefined", + "useBeacon?: boolean | undefined", + "userIdRetentionDays?: number | undefined" + ], + "UwtRumBootstrap": [ + "interface", + "config: UwtRumAppMonitorConfig", + "credentials?: UwtRumCredentials | undefined" + ], + "UwtRumCredentials": [ + "interface", + "accessKeyId: string", + "expiration: string", + "secretAccessKey: string", + "sessionToken: string" + ], + "UwtRumCredentialsProvider": [ + "interface", + "load(): Promise" + ], + "UwtRumTelemetrySink": [ + "class", + "new (): UwtRumTelemetrySink", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "dispose(): void", + "flush(beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "init(): Promise", + "ngOnDestroy(): void", + "record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "recordPageView(pageId: string): void", + "setUserId(userId: string | undefined): void" + ] + } +} diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts index 52d1630..1424f71 100644 --- a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts @@ -1,341 +1,12 @@ import { InjectionToken } from '@angular/core'; - -/** - * Short-lived AWS credentials for the RUM data plane. - * - * @group Interfaces - */ -export interface UwtRumCredentials { - accessKeyId: string; - secretAccessKey: string; - sessionToken: string; - - /** ISO-8601 expiry. Used to schedule a refresh before the credentials lapse. */ - expiration: string; -} - -/** - * Mirrors `aws-rum-web`'s own `AwsCredentialIdentity | AwsCredentialIdentityProvider` - * union — used only by {@link UwtRumAppMonitorConfig.clientBuilder}. - */ -type ClientBuilderCredentials = - | { accessKeyId: string; secretAccessKey: string; sessionToken?: string } - | (() => Promise<{ - accessKeyId: string; - secretAccessKey: string; - sessionToken?: string; - }>); - -/** - * Settings for the RUM app monitor, mapped onto the underlying `aws-rum-web` - * SDK's own `Config`. Every optional field left unset falls back to a - * default: `allowCookies`, `enableXRay`, `endpoint` and `telemetries` fall - * back to this library's own override; every other field falls back to the - * SDK's own default (see `buildRumConfig()`). - * - * Matches the SDK's `Config` field for field, with three omissions: - * - * - `endpointUrl` — always recomputed by the SDK from `endpoint`; setting it - * independently has no effect. - * - `identityPoolId`, `guestRoleArn` — the SDK's own Cognito guest-identity - * credential flow. AWS credentials come from a backend-vended - * `UwtRumCredentialsProvider` instead (see - * `UwtRumTelemetrySink.applyCredentials`). - * - `sessionId`, `userId` — owned at runtime by `UwtRumTelemetrySink`'s - * `setUserId`, `pinUserId` and `startSession`. - * - * None of the field types below are imported from `aws-rum-web`; every type - * here is hand-written. This keeps `aws-rum-web` an optional peer - * dependency for applications that only use the `broadcast` or `noop` sink. - * - * @group Interfaces - */ -export interface UwtRumAppMonitorConfig { - /** CloudWatch RUM app monitor id. */ - applicationId: string; - - /** AWS region hosting the app monitor, e.g. `eu-west-1`. */ - region: string; - - /** Version string reported with every event. */ - applicationVersion: string; - - // ---- Sampling & session ---- - - /** Fraction of sessions recorded, `0`–`1`. Default `1`. */ - sessionSampleRate?: number; - - /** - * Inactivity timeout of a session, in seconds. Default `1800` (30 minutes). - * Each recorded event pushes the expiry back; the next event after it - * starts a new session. - */ - sessionLengthSeconds?: number; - - /** Hard cap on events recorded per session, across all telemetry. Default `200`. */ - sessionEventLimit?: number; - - /** - * Suppresses the `session_start` event the SDK would otherwise emit at the - * start of a session. Default `false`. The library then doesn't record the - * session's start either, so scenario records carry no `sessionElapsed`. - */ - suppressSessionStartEvent?: boolean; - - /** - * Days the user id is kept in the `cwr_u` cookie. Default `30`. A signed-in - * user's id is kept there too, so on a shared device the next visitor starts - * with it until the app calls `setUserId(undefined)` or someone signs in. - * `0` keeps no user cookie at all: anonymous visitors get the nil id, or a - * random one for the rest of the page after a sign-out. - */ - userIdRetentionDays?: number; - - /** - * Attributes attached to every event in the session, seeded at - * construction. Default `{}`. `UwtRumTelemetrySink` merges `application`, - * `environment` and `appVersion` on top of this after construction; those - * three keys always win. - */ - sessionAttributes?: Record; - - /** - * Attributes attached to every event for the lifetime of the app monitor - * client, independent of session. Default unset. - */ - applicationAttributes?: Record; - - // ---- Telemetry collection & tracing ---- - - /** - * Built-in telemetry categories to enable. Defaults to errors, performance - * and same-origin HTTP. The SDK's own default also includes `'replay'` - * (session replay via `rrweb`); this library does not enable it by - * default. - * - * Options the SDK matches with `RegExp.test` or calls as a function — - * `urlsToInclude`, `urlsToExclude`, `addXRayTraceIdHeader` (also a - * boolean), `ignore`, `interactionId` — must hold exactly that. JSON can't - * carry them, and a string there would make the SDK throw each time it - * applies the option, so RUM doesn't start for the page instead (with a - * console warning naming the option). Set them in code, not in a broker's - * answer. - */ - telemetries?: (string | (string | object)[])[]; - - /** - * Custom event plugins to load alongside the built-in telemetry, each - * implementing `aws-rum-web`'s own `Plugin` interface. Default `[]`. - */ - eventPluginsToLoad?: unknown[]; - - /** Whether to emit X-Ray trace ids. Default `true`. */ - enableXRay?: boolean; - - /** Whether to emit W3C trace context ids alongside X-Ray's. Default `false`. */ - enableW3CTraceId?: boolean; - - /** Whether resource-timing events record the resource's URL. Default `true`. */ - recordResourceUrl?: boolean; - - // ---- Dispatch & buffering ---- - - /** Data plane endpoint. Defaults to the regional RUM endpoint. */ - endpoint?: string; - - /** Extra headers sent with every dispatch request. Default unset. */ - headers?: Record; - - /** Free-form label reported alongside dispatched events. Default unset. */ - alias?: string; - - /** Maximum events sent per dispatch request. Default `100`. */ - batchLimit?: number; - - /** Interval between automatic dispatches, in milliseconds. Default `5000`. */ - dispatchInterval?: number; - - /** Maximum events held in the in-memory cache awaiting dispatch. Default `1000`. */ - eventCacheSize?: number; - - /** Maximum not-yet-recorded candidate events cached. Default `10`. */ - candidatesCacheSize?: number; - - /** Retries for a failed dispatch request. Default `2`. */ - retries?: number; - - /** Whether unload-time flushes use `navigator.sendBeacon`. Default `true`. */ - useBeacon?: boolean; - - /** - * Whether dispatch requests are SigV4-signed. Default `true`. Signing is - * active whenever credentials are present — which they are here, via - * `setAwsCredentials` from `UwtRumCredentialsProvider` — independent of - * `identityPoolId`; that field only matters for the SDK's own, separate - * Cognito guest-identity flow, which this library does not use. Leave - * this enabled for broker-supplied credentials: disabling it here still - * sends unsigned requests to the same signed-only endpoint and gets - * rejected. Only turn it off if something in front of dispatch (a proxy, - * a different transport) already signs the request itself. - */ - signing?: boolean; - - /** Whether the request body is gzip-compressed before dispatch. Default `{ enabled: true }`. */ - compressionStrategy?: { enabled: boolean }; - - /** - * Overrides the SDK's own `fetch`-based transport. Default unset — the - * SDK uses the global `fetch`. - */ - fetchFunction?: typeof fetch; - - /** - * Overrides how the SDK builds its data-plane HTTP client. An advanced - * escape hatch — see `aws-rum-web`'s own `ClientBuilder` type for the exact - * signature (`(endpoint: URL, region: string, credentials?, compressionStrategy?) => DataPlaneClient`). - * Default unset. - */ - clientBuilder?: ( - endpoint: URL, - region: string, - credentials?: ClientBuilderCredentials, - compressionStrategy?: { enabled: boolean } - ) => unknown; - - // ---- Cookies & privacy ---- - - /** - * Whether the client may keep the session and user ids in cookies - * (`cwr_s`, `cwr_u`), so they survive a reload. Default `true` — the SDK's - * own default is `false`. Without cookies, every page load starts a new - * session. - */ - allowCookies?: boolean; - - /** - * Overrides for the cookies the client sets. Unset fields fall back to the - * SDK's own defaults (`domain` = current hostname, `path` = `/`, - * `sameSite` = `'Strict'`, `secure` = `true`, `unique` = `false`). - * - * With `unique: false`, every app monitor on the same hostname shares one - * session cookie (`cwr_s`). Set `unique: true` when several apps with their - * own app monitors run on one hostname, so each keeps its own session. The - * user cookie (`cwr_u`) stays shared either way — `aws-rum-web` 3.2.1 - * suffixes only the session cookie. - */ - cookieAttributes?: { - unique?: boolean; - domain?: string; - path?: string; - sameSite?: string; - secure?: boolean; - }; - - // ---- Page tracking ---- - - /** - * Disables the client's own automatic page-view recording on navigation. - * Pair with `UwtRumTelemetrySink.recordPageView()` to record page ids - * yourself. Default `false`. - */ - disableAutoPageView?: boolean; - - /** How the page id is derived from the URL. Default `'PATH'`. */ - pageIdFormat?: 'PATH' | 'HASH' | 'PATH_AND_HASH'; - - /** - * Pages recorded, matched against the page id. Default matches every page. - * `undefined` or `null` means unset; anything else that isn't an array of - * `RegExp`s (a string list from JSON) keeps RUM from starting for the page. - */ - pagesToInclude?: RegExp[]; - - /** - * Pages excluded from recording, matched against the page id. Default `[]`. - * `undefined` or `null` means unset; anything else that isn't an array of - * `RegExp`s (a string list from JSON) keeps RUM from starting for the page, - * rather than record what it should exclude. - */ - pagesToExclude?: RegExp[]; - - /** How long, in milliseconds, a route change waits before being considered complete. Default `100`. */ - routeChangeComplete?: number; - - /** Timeout, in milliseconds, before an incomplete route change is abandoned. Default `10000`. */ - routeChangeTimeout?: number; - - // ---- Advanced / escape hatches ---- - - /** - * Label reported as the SDK's own install method. Default is the SDK's - * internal module-install constant; overriding it is rarely useful outside - * of testing the SDK itself. - */ - client?: string; - - /** Release identifier reported with every event, e.g. a build SHA. Default unset. */ - releaseId?: string; - - /** Logs the SDK's own internal diagnostics to the console. Default `false`. */ - debug?: boolean; - - /** Master switch for the RUM client. Default `true`. */ - enableRumClient?: boolean; -} - -/** - * The payload returned by a {@link UwtRumCredentialsProvider}. - * - * @group Interfaces - */ -export interface UwtRumBootstrap { - /** App monitor settings. */ - config: UwtRumAppMonitorConfig; - - /** - * Credentials for the data plane. Omit when the app monitor is configured for - * unauthenticated access. - */ - credentials?: UwtRumCredentials; -} - -/** - * What the RUM sink needs from the host application to start. - * - * Keeps the telemetry layer free of AWS account details — how the app - * monitor id is discovered and how credentials are vended is entirely the - * application's business. - * - * @example - * ```typescript - * @Injectable({ providedIn: 'root' }) - * export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { - * async load(): Promise { - * const res = await fetch('/rum/init'); - * if (!res.ok) throw new Error(`RUM bootstrap failed: ${res.status}`); - * const { enabled, config, credentials } = await res.json(); - * return enabled ? { config, credentials } : null; - * } - * } - * ``` - * - * @group Interfaces - */ -export interface UwtRumCredentialsProvider { - /** - * Resolves the app monitor settings and a set of credentials. - * - * Called once at startup and again shortly before each credential expiry. - * Returning `null` disables RUM for the rest of the page — the telemetry - * layer stays fully functional, it simply stops shipping. Throw instead when - * the request fails: the sink retries — at startup with a growing, random - * wait for up to five minutes, buffering events until the client starts; - * on refresh every 30 s. - * - * @returns the bootstrap payload, or `null` to disable RUM - */ - load(): Promise; -} +import type { UwtRumCredentialsProvider } from '@absaoss-cps/ui-watchtower/rum'; + +export type { + UwtRumAppMonitorConfig, + UwtRumBootstrap, + UwtRumCredentials, + UwtRumCredentialsProvider +} from '@absaoss-cps/ui-watchtower/rum'; /** * Binds the application's {@link UwtRumCredentialsProvider}. diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.spec.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.spec.ts new file mode 100644 index 0000000..477342b --- /dev/null +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.spec.ts @@ -0,0 +1,163 @@ +import { DOCUMENT } from '@angular/common'; +import { + Injector, + isDevMode, + NgZone, + PLATFORM_ID, + Provider, + runInInjectionContext +} from '@angular/core'; +// The main entry's own copy. The library build can't import it from here: +// ng-packagr compiles each entry point on its own. A spec can, and the two +// copies must behave the same, so this runs both side by side. +import * as main from '../../../../src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import * as rum from './uwt-rum-internal.util'; + +jest.mock('@angular/core', () => ({ + ...jest.requireActual('@angular/core'), + isDevMode: jest.fn(() => true) +})); + +const isDevModeMock = isDevMode as jest.Mock; + +/** What the /rum entry copies from the main entry: every one is checked below. */ +const SHARED = [ + 'uwtBrowserDocument', + 'uwtDevModeReporter', + 'uwtIsBrowser', + 'uwtIsDevMode', + 'uwtRunOutsideZone' +]; + +const copies = [ + ['main entry', main], + ['/rum entry', rum] +] as const; + +/** Runs `fn` where `providers` are all there is to inject. */ +function inContext(providers: Provider[], fn: () => T): T { + return runInInjectionContext(Injector.create({ providers }), fn); +} + +describe("the /rum entry's copies of the main entry's Angular helpers", () => { + beforeEach(() => { + isDevModeMock.mockReset(); + isDevModeMock.mockReturnValue(true); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('should copy exactly the helpers checked here, each also in the main entry', () => { + expect(Object.keys(rum).sort()).toEqual(SHARED); + SHARED.forEach((name) => expect(main).toHaveProperty(name)); + }); + + describe.each(copies)('the %s copy', (_name, copy) => { + describe('uwtIsDevMode', () => { + it.each([[true], [false]])('should report dev mode %s', (devMode) => { + isDevModeMock.mockReturnValue(devMode); + + expect(copy.uwtIsDevMode()).toBe(devMode); + }); + + it('should report false when isDevMode throws, as outside an Angular context', () => { + isDevModeMock.mockImplementation(() => { + throw new Error('no Angular context'); + }); + + expect(copy.uwtIsDevMode()).toBe(false); + }); + }); + + describe('the platform', () => { + const page = {} as Document; + + it.each([ + ['browser', true], + ['server', false] + ])( + 'on the %s: browser check %s, and the page only in a browser', + (platform, browser) => { + const providers = [ + { provide: PLATFORM_ID, useValue: platform }, + { provide: DOCUMENT, useValue: page } + ]; + + expect(inContext(providers, () => copy.uwtIsBrowser())).toBe(browser); + expect(inContext(providers, () => copy.uwtBrowserDocument())).toBe( + browser ? page : null + ); + } + ); + }); + + describe('uwtRunOutsideZone', () => { + const zoneWith = (runOutsideAngular: jest.Mock) => + [{ provide: NgZone, useValue: { runOutsideAngular } }] as Provider[]; + + it("should run a callback through the zone's runOutsideAngular, once, and hand back its result", () => { + const runOutsideAngular = jest.fn((callback: () => unknown) => + callback() + ); + const run = inContext(zoneWith(runOutsideAngular), () => + copy.uwtRunOutsideZone() + ); + const callback = jest.fn(() => 42); + + expect(run(callback)).toBe(42); + expect(runOutsideAngular).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should run a callback that returns nothing once, not a second time', () => { + const run = inContext( + zoneWith(jest.fn((callback: () => unknown) => callback())), + () => copy.uwtRunOutsideZone() + ); + const callback = jest.fn(); + + run(callback); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should call the callback directly when there is no zone', () => { + const run = inContext([], () => copy.uwtRunOutsideZone()); + const callback = jest.fn(() => 7); + + expect(run(callback)).toBe(7); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe('uwtDevModeReporter', () => { + it.each([ + ['error', true, 'error'], + ['warn', true, 'warn'], + ['config', true, 'warn'], + ['error', false, undefined], + ['warn', false, undefined], + ['config', false, 'warn'] + ] as const)( + 'should send a %s report, in dev mode %s, to console.%s', + (level, devMode, method) => { + isDevModeMock.mockReturnValue(devMode); + const error = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const warn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + copy.uwtDevModeReporter(level, 'it failed', { detail: 1 }); + + const line = ['[ngx-ui-watchtower] it failed', { detail: 1 }]; + expect({ error: error.mock.calls, warn: warn.mock.calls }).toEqual({ + error: method === 'error' ? [line] : [], + warn: method === 'warn' ? [line] : [] + }); + } + ); + }); + }); +}); diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.ts index 20321e7..2506033 100644 --- a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.ts +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.ts @@ -1,45 +1,13 @@ -import { isPlatformBrowser } from '@angular/common'; -import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; - -/** - * A local copy of `@absaoss-cps/ngx-ui-watchtower`'s own `uwt-telemetry-safe.util.ts` — - * `uwtSafe`/`uwtSafeVoid`/`uwtIsBrowser`/`uwtIsDevMode`/`uwtUuid`, verbatim. - * - * Not imported from the main entry point: ng-packagr fixes every entry - * point's `rootDir` to its own `src` directory (unconditionally — see - * `initializeTsConfig` in ng-packagr's own `tsconfig.js`), so a secondary - * entry point cannot reach a file physically outside `rum/src` by relative - * path. The main entry deliberately doesn't export these either, - * "Maintaining this package", the fail-open wrappers and id generation stay - * internal so they can change freely without a breaking release, and widening - * the public API just to satisfy this entry point's own plumbing would trade - * away that freedom for every consumer, not just this one. A small, - * verbatim, unexported copy is the cheaper trade. - */ - -/** - * Runs a telemetry operation without letting it affect the application. - * - * Every public entry point in this library routes through this wrapper — - * it never rethrows. In development the caught error is reported to the - * console; in production nothing is printed. - */ -export function uwtSafe(operation: string, fn: () => T, fallback: T): T { - try { - return fn(); - } catch (error) { - reportSuppressed(operation, error); - return fallback; - } -} - -/** - * Void-returning form of {@link uwtSafe}, for the common case of an emit call - * whose result nobody reads. +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { inject, isDevMode, NgZone, PLATFORM_ID } from '@angular/core'; +import type { UwtReporter } from '@absaoss-cps/ui-watchtower'; + +/* + * Small copies of the main entry point's own Angular helpers. Not imported + * from it: ng-packagr fixes every entry point's `rootDir` to its own `src`, + * so this one can't reach the main entry's internals by relative path, and + * the main entry keeps them out of its public API so they can change freely. */ -export function uwtSafeVoid(operation: string, fn: () => void): void { - uwtSafe(operation, fn, undefined); -} /** `isDevMode()` throws outside an Angular context, so this guards that once for every caller. */ export function uwtIsDevMode(): boolean { @@ -59,60 +27,34 @@ export function uwtIsBrowser(): boolean { return isPlatformBrowser(inject(PLATFORM_ID)); } -function reportSuppressed(operation: string, error: unknown): void { - if (!uwtIsDevMode()) { - return; - } - try { - console.error(`[ngx-ui-watchtower] ${operation} failed`, error); - } catch { - // A patched/throwing console must never escape telemetry suppression. - } -} - /** - * Fills a byte array from the best available source. `crypto.getRandomValues` - * works on plain http:// too, where `crypto.randomUUID` doesn't. `Math.random` - * is the last-resort fallback for an environment with no Web Crypto at all. + * The page the RUM sink reads: the document in a browser, nothing in a + * server-side render, where the client never starts. Must be called from an + * injection context. */ -function randomBytes(count: number): Uint8Array { - const bytes = new Uint8Array(count); - const cryptoRef = globalThis.crypto; - - if (typeof cryptoRef?.getRandomValues === 'function') { - return cryptoRef.getRandomValues(bytes); - } - - for (let i = 0; i < count; i++) { - bytes[i] = (Math.random() * 256) | 0; - } - return bytes; +export function uwtBrowserDocument(): Document | null { + const document = inject(DOCUMENT); + return uwtIsBrowser() ? document : null; } -/** Lowercase hex encoding, two characters per byte. */ -function toHex(bytes: Uint8Array): string { - return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +/** + * Runs a callback outside Angular's zone when there is one. Must be called + * from an injection context. + */ +export function uwtRunOutsideZone(): (callback: () => T) => T { + const zone = inject(NgZone, { optional: true }); + return (callback) => (zone ? zone.runOutsideAngular(callback) : callback()); } /** - * Generates a correlation identifier. Prefers `crypto.randomUUID`, falling - * back to formatting random bytes as a v4 UUID. + * How this package reports what telemetry handles silently: to the console, + * prefixed. Failures and dropped data only in development mode; something + * that cannot work as configured (`'config'`) in production too. */ -export function uwtUuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); +export const uwtDevModeReporter: UwtReporter = (level, message, ...details) => { + if (level === 'config') { + console.warn(`[ngx-ui-watchtower] ${message}`, ...details); + } else if (uwtIsDevMode()) { + console[level](`[ngx-ui-watchtower] ${message}`, ...details); } - - const bytes = randomBytes(16); - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 - - const hex = toHex(bytes); - return [ - hex.slice(0, 8), - hex.slice(8, 12), - hex.slice(12, 16), - hex.slice(16, 20), - hex.slice(20) - ].join('-'); -} +}; diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts index 2de1462..6a5b32f 100644 --- a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts @@ -7,7 +7,7 @@ import { UWT_TELEMETRY_EVENT_TYPE, UWT_TELEMETRY_IDENTITY } from '@absaoss-cps/ngx-ui-watchtower'; -import { uwtSessionElapsed } from '../../../../src/lib/utils/uwt-session-start.util/uwt-session-start.util'; +import { uwtSessionElapsed } from '@absaoss-cps/ui-watchtower'; import { UWT_RUM_CREDENTIALS_PROVIDER, UwtRumBootstrap diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts index 30645ac..f52ae33 100644 --- a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts @@ -1,235 +1,16 @@ -import { DOCUMENT } from '@angular/common'; -import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; -import type { AwsRum, AwsRumConfig } from 'aws-rum-web'; +import { inject, Injectable, OnDestroy } from '@angular/core'; import { UWT_REDACT_CONFIG, UWT_TELEMETRY_ENABLED, - UWT_TELEMETRY_IDENTITY, - UwtTelemetryError, - UwtTelemetryMetadata, - UwtTelemetrySink, - uwtMarkSessionStart, - uwtNormalizeError + UWT_TELEMETRY_IDENTITY } from '@absaoss-cps/ngx-ui-watchtower'; +import { UwtRumTelemetrySink as CoreRumTelemetrySink } from '@absaoss-cps/ui-watchtower/rum'; +import { UWT_RUM_CREDENTIALS_PROVIDER } from '../uwt-rum-credentials/uwt-rum-credentials'; import { - uwtIsBrowser, - uwtIsDevMode, - uwtSafe, - uwtSafeVoid, - uwtUuid + uwtBrowserDocument, + uwtDevModeReporter, + uwtRunOutsideZone } from '../uwt-rum-internal.util/uwt-rum-internal.util'; -import { - UWT_RUM_CREDENTIALS_PROVIDER, - UwtRumBootstrap, - UwtRumCredentials -} from '../uwt-rum-credentials/uwt-rum-credentials'; - -/** Refresh credentials this long before they expire. */ -const CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000; - -/** - * Maximum delay supported by setTimeout (2^31 - 1 ms, ~24.85 days). - * Prevents 32-bit signed integer overflow triggering an immediate execution. - */ -const MAX_TIMEOUT_MS = 2_147_483_647; - -/** - * Retry delay after a scheduled refresh fails or returns no credentials. - * Shorter than the normal refresh-ahead-of-expiry interval. - */ -const CREDENTIAL_RETRY_DELAY_MS = 30 * 1000; - -/** - * Startup retries wait a random time up to a limit that starts at - * {@link INIT_RETRY_BASE_MS} and doubles after each failure, up to - * {@link INIT_RETRY_MAX_MS}. The first retry comes fast, because the client - * captures nothing until it exists; the randomness keeps tabs reloaded - * together from retrying together. - */ -const INIT_RETRY_BASE_MS = 1000; -const INIT_RETRY_MAX_MS = 60 * 1000; - -/** - * How long startup keeps retrying before the sink gives up and runs the page - * without RUM. Bounds the retries when the failure is permanent, such as a - * wrong URL or a 403. - */ -const INIT_RETRY_WINDOW_MS = 5 * 60 * 1000; - -/** - * The RUM client's defaults for `eventCacheSize` and `batchLimit`, which it - * keeps to itself — used only when the app monitor config sets neither. - * Together they bound how many batches the client can hold. - */ -const SDK_DEFAULT_EVENT_CACHE_SIZE = 1000; -const SDK_DEFAULT_BATCH_LIMIT = 100; - -/** - * Events retained while the SDK loads. Bounded so a failed init cannot - * grow the buffer without limit. - */ -const PRE_INIT_BUFFER_LIMIT = 100; - -/** The event the RUM client records each time it starts a session. */ -const SESSION_START_EVENT_TYPE = 'com.amazon.rum.session_start_event'; - -/** - * The RUM client's session cookie: `cwr_s`, or `cwr_s_` with - * `cookieAttributes.unique` (both documented by AWS). - */ -function sessionCookieName(bootstrap: UwtRumBootstrap): string { - const { config } = bootstrap; - return config.cookieAttributes?.unique - ? `cwr_s_${config.applicationId}` - : 'cwr_s'; -} - -/** Metadata keys under this prefix are reserved by the RUM client. */ -const RESERVED_PREFIX = 'aws:'; - -interface BufferedEvent { - kind: 'event'; - eventType: string; - payload: object; - metadata?: UwtTelemetryMetadata; -} - -interface BufferedPageView { - kind: 'pageView'; - pageId: string; -} - -interface BufferedError { - kind: 'error'; - error: UwtTelemetryError; - metadata?: UwtTelemetryMetadata; -} - -/** Anything the pre-init buffer holds. */ -type BufferedItem = BufferedEvent | BufferedPageView | BufferedError; - -/** - * Drops every key whose value is unset: `undefined`, or `null` as a broker's - * JSON spells it. - * - * Used to build the SDK config object from optional consumer fields: a key - * present with value `undefined` or `null` would overwrite the SDK's own - * default for that key, so unset fields must be absent. - */ -function omitUnset(obj: T): Partial { - const result: Partial = {}; - for (const key of Object.keys(obj) as (keyof T)[]) { - if (obj[key] !== undefined && obj[key] !== null) { - result[key] = obj[key]; - } - } - return result; -} - -/** Whether `value` is an array of `RegExp`s only. */ -function isRegExpList(value: unknown): boolean { - return ( - Array.isArray(value) && value.every((entry) => entry instanceof RegExp) - ); -} - -/** A setting's type check, and the type it expects, for the warning. */ -interface UwtSettingCheck { - valid: (value: unknown) => boolean; - expected: string; -} - -const REGEXP_LIST: UwtSettingCheck = { - valid: isRegExpList, - expected: 'an array of RegExp' -}; -const FUNCTION: UwtSettingCheck = { - valid: (value) => typeof value === 'function', - expected: 'a function' -}; - -/** - * Telemetry options the SDK calls as a function or matches with - * `RegExp.test`. JSON can carry neither, and anything else in their place - * makes the SDK throw each time it applies the option — for the URL filters, - * inside its patched `fetch` and XHR, so on every application request. - */ -const TELEMETRY_OPTION_CHECKS = new Map([ - ['urlsToInclude', REGEXP_LIST], - ['urlsToExclude', REGEXP_LIST], - [ - 'addXRayTraceIdHeader', - { - valid: (value) => typeof value === 'boolean' || isRegExpList(value), - expected: 'a boolean or an array of RegExp' - } - ], - ['ignore', FUNCTION], - ['interactionId', FUNCTION] -]); - -/** - * Lists the settings in `config` the SDK can't use, each with the type it - * expects: `pagesToInclude` or `pagesToExclude` that isn't an array of - * `RegExp`s, `telemetries` that isn't an array, and each telemetry option - * failing its {@link TELEMETRY_OPTION_CHECKS} check, as `name.option`. - * `undefined` and `null` count as unset, not as invalid. - */ -function invalidRumSettings(config: UwtRumBootstrap['config']): string[] { - const invalid: string[] = []; - for (const key of ['pagesToInclude', 'pagesToExclude'] as const) { - const value = config[key]; - if (value != null && !REGEXP_LIST.valid(value)) { - invalid.push(`${key} (expected ${REGEXP_LIST.expected})`); - } - } - const telemetries: unknown = config.telemetries; - if (telemetries == null) { - return invalid; - } - if (!Array.isArray(telemetries)) { - // A string would be read as one telemetry per character. - invalid.push('telemetries (expected an array)'); - return invalid; - } - for (const telemetry of telemetries) { - if (!Array.isArray(telemetry)) { - continue; - } - const [name, options] = telemetry; - if (typeof options !== 'object' || options === null) { - continue; - } - for (const [key, value] of Object.entries(options)) { - const check = TELEMETRY_OPTION_CHECKS.get(key); - if (value != null && check && !check.valid(value)) { - invalid.push(`${String(name)}.${key} (expected ${check.expected})`); - } - } - } - return invalid; -} - -/** - * Copies `telemetries` without tuple options that are unset (`undefined` or - * `null`). The SDK merges a telemetry's options over its defaults with - * `Object.assign`, so an explicit unset value would replace a default it - * relies on — `urlsToExclude: null` would throw on every request. - */ -function withoutUnsetOptions( - telemetries: NonNullable -): NonNullable { - return telemetries.map((telemetry) => { - if (!Array.isArray(telemetry)) { - return telemetry; - } - const [name, options, ...rest] = telemetry; - if (typeof options !== 'object' || options === null) { - return telemetry; - } - return [name, omitUnset(options), ...rest]; - }); -} /** * Telemetry sink backed by the AWS CloudWatch RUM web client. @@ -244,754 +25,24 @@ function withoutUnsetOptions( * @group Services */ @Injectable() -export class UwtRumTelemetrySink extends UwtTelemetrySink implements OnDestroy { - private readonly config = inject(UWT_TELEMETRY_IDENTITY); - private readonly redact = inject(UWT_REDACT_CONFIG); - private readonly credentialsProvider = inject(UWT_RUM_CREDENTIALS_PROVIDER); - private readonly document = inject(DOCUMENT); - /** - * False on the server and while telemetry is off: the client never starts — - * no credentials fetch, no SDK import — and every call is a no-op. - */ - private readonly canStart = - uwtIsBrowser() && - (inject(UWT_TELEMETRY_ENABLED, { optional: true }) ?? true); - - private readonly zone = inject(NgZone, { optional: true }); - - private awsRum: AwsRum | null = null; - /** - * The pending broker call: a startup retry before the client exists, the - * next credential refresh after. - */ - private refreshTimer: ReturnType | null = null; - private buffer: BufferedItem[] = []; - private userId?: string; - /** - * Whether a session's start is recorded (`uwtMarkSessionStart`): only when - * the client keeps sessions in its cookie, so a session outlives the page, - * and the app hasn't suppressed session-start events. Set when the client - * starts. - */ - private marksSessionStarts = false; - /** Set while {@link markSessionStart} runs, to stop it calling itself. */ - private markingSession = false; - /** - * Set once the app has called `setUserId`. Until then, the client's id may - * be a signed-in user's, restored from its cookie after a reload, so - * events carry no `signedIn`. - */ - private userKnown = false; - /** A sign-out arrived before the client existed; applied once it starts. */ - private signOutPending = false; - /** The most batches the client can hold, set when it starts. */ - private maxBatches = 1; - /** - * Set once RUM is off for the page — the broker declined, startup gave up, - * or a refresh returned null; distinct from `!awsRum` alone. - */ - private disabled = false; - /** Broker requests that failed at startup so far. */ - private initFailures = 0; - /** When the first of them failed. */ - private initFailedAt?: number; - /** - * The current startup attempt, shared by every caller — - * `provideUwtTelemetryRumSink()`'s `APP_INITIALIZER`, - * {@link ensureInitialized}, and a caller awaiting `init()` directly. A - * retry replaces it, so awaiting `init()` waits for one attempt, not for - * RUM to be running. - */ - private initPromise?: Promise; - /** - * Set once in `ngOnDestroy`. `refreshCredentials()` nulls `refreshTimer` - * before its own `await`, so a destroy during that window would leave a - * new timer nothing could clear. Checked after every await in the - * refresh chain instead. - */ - private destroyed = false; - - /** - * Loads and starts the RUM client. - * - * Fire-and-forget: it must never block bootstrap and never reject into the - * caller. Safe to call more than once; subsequent calls are no-ops. - * - * @returns a promise that resolves once initialization has been attempted - */ - async init(): Promise { - if (!this.canStart) { - return; - } - if (!this.initPromise) { - // Outside Angular's zone: the SDK's dispatch interval and this sink's - // retry and refresh timers would otherwise keep a zone-based app from - // ever becoming stable. - this.initPromise = this.zone - ? this.zone.runOutsideAngular(() => this.performInit()) - : this.performInit(); - } - return this.initPromise; - } - - private async performInit(): Promise { - let bootstrap: UwtRumBootstrap | null; - try { - bootstrap = await this.credentialsProvider.load(); - } catch (error) { - this.retryInit(error); - return; - } - - try { - if (!bootstrap?.config) { - this.disableAwsRum(); - this.buffer = []; - return; - } - - // Fails closed: the page and URL filters and `ignore` decide what is - // *not* recorded, so dropping one would record what the app meant to - // keep out, and passing it on would make the SDK throw. RUM stays off - // for the page instead, as with a broker that returns `null`. - const invalid = invalidRumSettings(bootstrap.config); - if (invalid.length) { - this.disableAwsRum(); - this.buffer = []; - this.reportInvalidSettings(invalid); - return; - } - - // Not retried if this fails: with native ES modules (Angular's default - // build) the browser remembers a failed dynamic import for the page. - const { AwsRum } = await import('aws-rum-web'); - if (this.destroyed) { - return; - } - - const sessionCookie = sessionCookieName(bootstrap); - const hadSession = this.hasCookie(sessionCookie); - - this.awsRum = new AwsRum( - bootstrap.config.applicationId, - bootstrap.config.applicationVersion, - bootstrap.config.region, - this.buildRumConfig(bootstrap) - ); - const { - eventCacheSize = SDK_DEFAULT_EVENT_CACHE_SIZE, - batchLimit = SDK_DEFAULT_BATCH_LIMIT - } = bootstrap.config; - const batches = Math.ceil(eventCacheSize / batchLimit); - // A batchLimit of 0 or a non-number would make this unbounded. - this.maxBatches = Number.isFinite(batches) && batches > 1 ? batches : 1; - - this.marksSessionStarts = - bootstrap.config.allowCookies !== false && - this.document.defaultView?.navigator.cookieEnabled !== false && - bootstrap.config.suppressSessionStartEvent !== true; - this.applyIdentity(); - // The client can start the first session inside its constructor — the - // landing page view, the navigation timing — before the metadata hook - // exists to see its session_start. It writes the session cookie then, - // so a cookie that wasn't there a moment ago means a session that - // started just now. - if ( - this.marksSessionStarts && - !hadSession && - this.hasCookie(sessionCookie) - ) { - this.markSessionStart(); - } - this.applyCredentials(bootstrap.credentials); - this.replayBuffer(); - } catch (error) { - this.disableAwsRum(); - this.buffer = []; - this.reportFailure('RUM init failed, monitoring disabled', error); - } - } - - /** - * After a failed broker request at startup: tries again later, keeping - * events buffered — the failure is usually a passing network or broker - * problem. Gives up after {@link INIT_RETRY_WINDOW_MS}. - */ - private retryInit(error: unknown): void { - if (this.destroyed) { - return; - } - - this.initFailures++; - this.initFailedAt ??= Date.now(); - if (Date.now() - this.initFailedAt >= INIT_RETRY_WINDOW_MS) { - this.disableAwsRum(); - this.buffer = []; - this.reportFailure('RUM init failed, monitoring disabled', error); - return; - } - - if (this.initFailures === 1) { - this.reportFailure('RUM init failed, retrying', error); - } - const limit = Math.min( - INIT_RETRY_BASE_MS * 2 ** (this.initFailures - 1), - INIT_RETRY_MAX_MS - ); - this.scheduleRetry(() => { - this.initPromise = this.performInit(); - return this.initPromise; - }, Math.random() * limit); - } - - /** - * Kicks off `init()` on first real use, for a sink bound directly (e.g. - * `{ provide: UwtTelemetrySink, useClass: UwtRumTelemetrySink }`) rather - * than through `provideUwtTelemetryRumSink()`, which is what normally - * calls `init()` via `APP_INITIALIZER`. `init()` is idempotent and - * memoized, so this is a no-op once already initialized or already in - * flight, and never races with a caller awaiting `init()` directly. - */ - private ensureInitialized(): void { - if (!this.initPromise) { - this.init().catch(() => undefined); - } - } - - /** - * Shared shape behind {@link record}, {@link recordError} and - * {@link recordPageView}: kicks off init, then either buffers `item` - * (pre-init) or hands the live client to `dispatch`, all inside the same - * fail-open guard. - */ - private recordOrBuffer( - operation: string, - item: BufferedItem, - dispatch: (awsRum: AwsRum) => void - ): void { - this.ensureInitialized(); - uwtSafeVoid(operation, () => { - if (!this.canStart || this.disabled) { - return; - } - - const awsRum = this.awsRum; - if (!awsRum) { - this.bufferItem(item); - return; - } - - dispatch(awsRum); +export class UwtRumTelemetrySink + extends CoreRumTelemetrySink + implements OnDestroy +{ + constructor() { + super({ + identity: inject(UWT_TELEMETRY_IDENTITY), + redact: inject(UWT_REDACT_CONFIG), + credentialsProvider: inject(UWT_RUM_CREDENTIALS_PROVIDER), + document: uwtBrowserDocument(), + enabled: inject(UWT_TELEMETRY_ENABLED, { optional: true }) ?? true, + runOutside: uwtRunOutsideZone(), + report: uwtDevModeReporter }); } - /** @inheritdoc */ - record( - eventType: string, - payload: object, - metadata?: UwtTelemetryMetadata - ): void { - this.recordOrBuffer( - 'rum.record', - { kind: 'event', eventType, payload, metadata }, - (awsRum) => - awsRum.recordEvent(eventType, payload, this.sanitize(metadata)) - ); - } - - /** @inheritdoc */ - recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata): void { - this.recordOrBuffer( - 'rum.recordError', - { kind: 'error', error, metadata }, - (awsRum) => awsRum.recordError(this.withOrigin(error, metadata)) - ); - } - - /** @inheritdoc */ - getSessionId(): string | undefined { - return uwtSafe( - 'rum.getSessionId', - () => this.awsRum?.getSessionId(), - undefined - ); - } - - /** - * Associates subsequent events with a user, or with nobody. - * - * Signing in pins the id, and works even before `init()` resolves. - * Signing out starts a fresh session with a new anonymous id (`pinUserId` - * has no inverse), which emits a `session_start` event and re-rolls - * sampling. Before `init()` resolves, a sign-out is applied once the - * client starts. Signing out while nobody is signed in does nothing. - * - * Every event carries `signedIn` in its metadata, so a signed-in user's id - * can be told from an anonymous one. It is absent until the first call, - * so call this as early as possible, e.g. right after the auth library - * initializes. - * - * @param userId the application's own user identifier, or `undefined` - * (or `''`) on sign-out. RUM accepts only a UUID, e.g. MSAL's - * `localAccountId`. - */ - setUserId(userId: string | undefined): void { - uwtSafeVoid('rum.setUserId', () => { - const previous = this.userId; - this.userId = userId; - this.userKnown = true; - - const awsRum = this.awsRum; - if (!awsRum) { - // A sign-in is pinned, a sign-out applied, once the client starts. - if (userId || previous) { - this.signOutPending = !userId; - } - return; - } - - if (userId && userId !== previous) { - // Unless the client already has this id, as after a reload. - if (awsRum.getUserId() !== userId) { - this.sendWaiting(awsRum); - } - awsRum.pinUserId(userId); - } else if (!userId && previous) { - this.sendWaiting(awsRum); - awsRum.startSession({ userId: uwtUuid() }); - } - }); - } - - /** - * Sends everything the client holds, under the current user id. The client - * reads the id when it sends a batch, not when it records an event, so - * events still waiting at a change would otherwise go out under the new id. - * Each `dispatch()` sends one batch, and nothing once the client is empty. - */ - private sendWaiting(awsRum: AwsRum): void { - for (let batch = 0; batch < this.maxBatches; batch++) { - awsRum.dispatch(); - } - } - - /** @inheritdoc */ - getUserId(): string | undefined { - return this.userId; - } - - /** @inheritdoc */ - flush(beacon = false): void { - uwtSafeVoid('rum.flush', () => { - if (!this.awsRum) { - if (this.buffer.length > 0 && uwtIsDevMode()) { - console.warn( - `[ngx-ui-watchtower] ${this.buffer.length} RUM event(s) lost: page unloaded before RUM finished initializing` - ); - } - return; - } - - if (beacon) { - this.awsRum.dispatchBeacon(); - } else { - this.awsRum.dispatch(); - } - }); - } - - /** - * Records a page view. - * - * The RUM client already records a page view on every navigation via - * `history.pushState`, covering Angular's router automatically. Use this - * to report a different page id (e.g. a route template instead of the - * resolved URL); also set `disableAutoPageView: true` in the RUM config - * to avoid recording the navigation twice. Buffered like {@link record} - * when called before `init()` resolves. - * - * @param pageId the page identity, e.g. `/customers/:id` - */ - recordPageView(pageId: string): void { - this.recordOrBuffer( - 'rum.recordPageView', - { kind: 'pageView', pageId }, - (awsRum) => awsRum.recordPageView(pageId) - ); - } - /** @inheritdoc */ ngOnDestroy(): void { - this.destroyed = true; - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - this.refreshTimer = null; - } - this.disableAwsRum(); - } - - /** - * Disables the sink: stops the AWS RUM client's own listeners, plugins, - * and dispatch timer before dropping the reference — clearing `awsRum` - * alone only stops this class from talking to it; the already-constructed - * instance would otherwise keep running orphaned in the page (and, on - * destroy, could duplicate telemetry if the app is bootstrapped again in - * the same one) — and marks the session disabled, so every call site that - * tears the client down does so atomically with the flag `recordOrBuffer` - * checks, instead of each caller having to remember to set both. - */ - private disableAwsRum(): void { - uwtSafeVoid('rum.disable', () => this.awsRum?.disable()); - this.awsRum = null; - this.disabled = true; - } - - /** - * Maps {@link UwtRumAppMonitorConfig} onto the SDK's own `PartialConfig`. - * - * The SDK merges this object over its own defaults with a shallow - * `Object.assign`, so a key present with value `undefined` or `null` - * overwrites the SDK's default instead of falling through to it. - * `allowCookies`, `enableXRay`, `endpoint` and `telemetries` always carry - * this library's own override; every other field goes through - * {@link omitUnset} (or, for `client`, a conditional spread) so an unset - * field is absent and the SDK's own default applies. Unset telemetry options - * are left out for the same reason. `performInit()` has already checked the - * types of `pagesToInclude`/`pagesToExclude`, `telemetries` and the telemetry - * options in {@link TELEMETRY_OPTION_CHECKS} ({@link invalidRumSettings}); - * every other field is passed on as given. - */ - private buildRumConfig(bootstrap: UwtRumBootstrap): AwsRumConfig { - const { config } = bootstrap; - const origin = this.document.defaultView?.location?.origin ?? ''; - - // X-Ray trace header only on same-origin requests; a cross-origin - // request with an unexpected header fails CORS preflight. Anchored at - // both ends (with a path/query/fragment/end-of-string lookahead) so - // `https://example.com` doesn't also match a look-alike host like - // `https://example.com.attacker.example`. - const sameOrigin = new RegExp( - '^' + origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[/?#]|$)' - ); - - return { - allowCookies: config.allowCookies ?? true, - enableXRay: config.enableXRay ?? true, - endpoint: - config.endpoint ?? - `https://dataplane.rum.${config.region}.amazonaws.com`, - telemetries: Array.isArray(config.telemetries) - ? withoutUnsetOptions(config.telemetries) - : (config.telemetries ?? [ - 'errors', - 'performance', - ['http', { addXRayTraceIdHeader: [sameOrigin] }] - ]), - - ...(config.client != null ? { client: config.client } : {}), - - ...omitUnset({ - releaseId: config.releaseId, - clientBuilder: config.clientBuilder, - applicationAttributes: config.applicationAttributes, - alias: config.alias, - headers: config.headers, - cookieAttributes: config.cookieAttributes, - sessionSampleRate: config.sessionSampleRate, - sessionLengthSeconds: config.sessionLengthSeconds, - sessionEventLimit: config.sessionEventLimit, - suppressSessionStartEvent: config.suppressSessionStartEvent, - userIdRetentionDays: config.userIdRetentionDays, - sessionAttributes: config.sessionAttributes, - eventPluginsToLoad: config.eventPluginsToLoad, - enableW3CTraceId: config.enableW3CTraceId, - recordResourceUrl: config.recordResourceUrl, - batchLimit: config.batchLimit, - dispatchInterval: config.dispatchInterval, - eventCacheSize: config.eventCacheSize, - candidatesCacheSize: config.candidatesCacheSize, - retries: config.retries, - useBeacon: config.useBeacon, - signing: config.signing, - compressionStrategy: config.compressionStrategy, - fetchFunction: config.fetchFunction, - pageIdFormat: config.pageIdFormat, - pagesToInclude: config.pagesToInclude, - pagesToExclude: config.pagesToExclude, - routeChangeComplete: config.routeChangeComplete, - routeChangeTimeout: config.routeChangeTimeout, - disableAutoPageView: config.disableAutoPageView, - debug: config.debug, - enableRumClient: config.enableRumClient - }) - } as unknown as AwsRumConfig; - } - - /** - * Attaches application identity once per session rather than once per event. - * - * `addSessionAttributes` covers custom events; the metadata hook stamps - * the same attributes onto the client's own built-in events too. The hook - * also adds `signedIn`, read when each event is recorded. - * - * See {@link UwtRumAppMonitorConfig.sessionAttributes} for how this - * merges with a consumer-supplied base. - * - * It also sees the client's own `session_start` event, recorded - * each time it starts a session, and records that moment as the - * session's start (`uwtMarkSessionStart`) — the client keeps no start time - * itself. Only while {@link marksSessionStarts}. - */ - private applyIdentity(): void { - if (!this.awsRum) { - return; - } - - const attributes = { - application: this.config.application, - environment: this.config.environment, - appVersion: this.config.version - }; - - this.awsRum.addSessionAttributes(attributes); - this.awsRum.setEventMetadataHook((type) => { - if (this.marksSessionStarts && type === SESSION_START_EVENT_TYPE) { - this.markSessionStart(); - } - return this.userKnown - ? { ...attributes, signedIn: !!this.userId } - : attributes; - }); - - if (this.userId) { - this.awsRum.pinUserId(this.userId); - } else if (this.signOutPending) { - this.signOutPending = false; - this.awsRum.startSession({ userId: uwtUuid() }); - } - } - - /** - * Records the current session's start as now. Called right after a session - * started, so it is current and reading its id doesn't start another one — - * except with a session length of 0, where the read starts a new session - * whose session_start calls back in here; the guard stops that recursion. - */ - private markSessionStart(): void { - if (this.markingSession) { - return; - } - this.markingSession = true; - try { - uwtSafeVoid('rum.markSessionStart', () => { - const sessionId = this.awsRum?.getSessionId(); - if (sessionId) { - uwtMarkSessionStart(sessionId); - } - }); - } finally { - this.markingSession = false; - } - } - - /** Whether the page has a cookie with this name; `false` when cookies can't be read. */ - private hasCookie(name: string): boolean { - return uwtSafe( - 'rum.readCookie', - () => - this.document.cookie - .split(';') - .some((cookie) => cookie.trim().split('=')[0] === name), - false - ); - } - - private applyCredentials(credentials?: UwtRumCredentials): void { - if (!this.awsRum || !credentials) { - return; - } - - this.awsRum.setAwsCredentials({ - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken - }); - - this.scheduleRefresh(credentials.expiration); - } - - private scheduleRefresh(expiration: string): void { - const delay = - new Date(expiration).getTime() - Date.now() - CREDENTIAL_REFRESH_SKEW_MS; - - if (!Number.isFinite(delay) || delay <= 0) { - // Avoid tight-looping on an already-expired/unparsable expiration. - this.scheduleRetry(); - return; - } - - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - } - - this.refreshTimer = setTimeout( - () => { - this.refreshCredentials().catch(() => undefined); - }, - Math.min(delay, MAX_TIMEOUT_MS) - ); - } - - private async refreshCredentials(): Promise { - this.refreshTimer = null; - try { - const bootstrap = await this.credentialsProvider.load(); - if (this.destroyed) { - return; - } - - if (!bootstrap) { - // null/undefined disables RUM for the session (see load()'s contract). - this.disableAwsRum(); - return; - } - - if (bootstrap.credentials) { - this.applyCredentials(bootstrap.credentials); - } else { - this.scheduleRetry(); - this.reportFailure( - 'RUM credential refresh', - new Error( - 'bootstrap omitted credentials for an already-authenticated ' + - 'session; keeping existing credentials and retrying' - ) - ); - } - } catch (error) { - if (!this.destroyed) { - this.scheduleRetry(); - this.reportFailure('RUM credential refresh failed', error); - } - } - } - - /** Runs `attempt` (by default, a credential refresh) after `delayMs`. */ - private scheduleRetry( - attempt: () => Promise = () => this.refreshCredentials(), - delayMs = CREDENTIAL_RETRY_DELAY_MS - ): void { - if (this.destroyed) { - return; - } - - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - } - - this.refreshTimer = setTimeout(() => { - this.refreshTimer = null; - attempt().catch(() => undefined); - }, delayMs); - } - - /** - * Reports settings that kept RUM from starting, in every environment: a - * silent production page would hide that RUM is off. Each setting's name and - * expected type are printed, never its value. - * - * @param problems one `name (expected type)` entry per setting - */ - private reportInvalidSettings(problems: string[]): void { - uwtSafeVoid('rum.reportInvalidSettings', () => { - console.warn( - `[ngx-ui-watchtower] RUM not started, settings of the wrong type: ${problems.join(', ')}` - ); - }); - } - - /** - * Reports an init/refresh failure without leaking raw error content into - * the production console. - */ - private reportFailure(operation: string, error: unknown): void { - if (!uwtIsDevMode()) { - return; - } - uwtSafeVoid('rum.reportFailure', () => { - console.warn( - `[ngx-ui-watchtower] ${operation}`, - uwtNormalizeError(error, this.redact) - ); - }); - } - - private bufferItem(item: BufferedItem): void { - this.buffer.push(item); - if (this.buffer.length > PRE_INIT_BUFFER_LIMIT) { - this.buffer.shift(); - } - } - - private replayBuffer(): void { - const pending = this.buffer; - this.buffer = []; - - for (const item of pending) { - uwtSafeVoid('rum.replay', () => { - if (item.kind === 'event') { - this.awsRum?.recordEvent( - item.eventType, - item.payload, - this.sanitize(item.metadata) - ); - } else if (item.kind === 'pageView') { - this.awsRum?.recordPageView(item.pageId); - } else { - this.awsRum?.recordError(this.withOrigin(item.error, item.metadata)); - } - }); - } - } - - /** - * Drops metadata keys under the reserved `aws:` prefix, which the client - * would otherwise discard with a console warning. Also drops `null` - * values, which {@link UwtTelemetryMetadata} permits but the SDK's - * metadata type does not. - */ - private sanitize( - metadata?: UwtTelemetryMetadata - ): Record | undefined { - if (!metadata) { - return undefined; - } - - const result: Record = {}; - for (const [key, value] of Object.entries(metadata)) { - if (key.startsWith(RESERVED_PREFIX) || value === null) { - continue; - } - result[key] = value; - } - - return Object.keys(result).length ? result : undefined; - } - - /** - * Folds a forwarded error's origin into its name. `AwsRum.recordError` - * takes no metadata argument, so `name` is the only field left to carry it. - */ - private withOrigin( - error: UwtTelemetryError, - metadata?: UwtTelemetryMetadata - ): UwtTelemetryError { - const origin = metadata - ? (metadata as Record).application - : undefined; - if (typeof origin !== 'string' || origin === this.config.application) { - return error; - } - return { ...error, name: `[${origin}] ${error.name}` }; + this.dispose(); } } diff --git a/projects/ngx-ui-watchtower/rum/tsconfig.lib.json b/projects/ngx-ui-watchtower/rum/tsconfig.lib.json index 06ed2d1..9435509 100644 --- a/projects/ngx-ui-watchtower/rum/tsconfig.lib.json +++ b/projects/ngx-ui-watchtower/rum/tsconfig.lib.json @@ -3,7 +3,8 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "../../../out-tsc/lib-rum", - "rootDir": "..", + // Includes the core: tests and typecheck read its source. + "rootDir": "../..", "lib": ["ES2022", "DOM"], "moduleResolution": "bundler", "declaration": true, diff --git a/projects/ngx-ui-watchtower/rum/tsconfig.spec.json b/projects/ngx-ui-watchtower/rum/tsconfig.spec.json index 2e29a92..2677ee6 100644 --- a/projects/ngx-ui-watchtower/rum/tsconfig.spec.json +++ b/projects/ngx-ui-watchtower/rum/tsconfig.spec.json @@ -3,7 +3,8 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "../../../out-tsc/spec-rum", - "rootDir": "..", + // Includes the core: tests and typecheck read its source. + "rootDir": "../..", "types": ["jest"], "resolveJsonModule": true, "isolatedModules": false diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts b/projects/ngx-ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts index b8a2d3b..5f8f8ec 100644 --- a/projects/ngx-ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts +++ b/projects/ngx-ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts @@ -1,39 +1,5 @@ import { InjectionToken } from '@angular/core'; - -/** - * Business and UX event tracking configuration. - * - * @group Interfaces - */ -export interface UwtBITelemetryConfig { - /** Milliseconds within which an identical event is treated as a double-fire. */ - dedupWindowMs: number; - - /** - * Distinct event keys tracked before expired ones are swept. - * - * Keyed per `eventName|scenarioId|feature|metadata`, which - * grows unbounded over a long session without this cap. - */ - dedupMaxKeys: number; - - /** - * Whether redaction runs on BI events. On by default. Turning it off - * skips only the *configurable* PII scrubbing (`extraKeyPatterns`, - * value-pattern scanning, URL-query stripping) — the built-in credential - * denylist, size caps, error normalization and any - * `UwtRedactConfig.extraValueTransforms` still apply; see - * {@link uwtRedactConfigFor}. - */ - redact: boolean; -} - -/** Default BI event tracking settings. */ -export const UWT_DEFAULT_BI_TELEMETRY_CONFIG: UwtBITelemetryConfig = { - dedupWindowMs: 400, - dedupMaxKeys: 100, - redact: true -}; +import type { UwtBITelemetryConfig } from '@absaoss-cps/ui-watchtower'; /** * Resolved BI event configuration. Provided by {@link provideUwtTelemetry}, diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts b/projects/ngx-ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts index a882f06..e47bd36 100644 --- a/projects/ngx-ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts +++ b/projects/ngx-ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts @@ -1,55 +1,5 @@ import { InjectionToken } from '@angular/core'; -import { - UwtLoggerName, - UwtLogLevel -} from '../../models/uwt-log.models/uwt-log.models'; - -/** - * Application logging configuration. - * - * @group Interfaces - */ -export interface UwtLogConfig { - /** Records below this severity are discarded. */ - minLevel: UwtLogLevel; - - /** - * Per-logger overrides of {@link minLevel}, keyed by logger name. Loggers - * without an override use {@link minLevel}. - * - * ```typescript - * withLogging({ minLevel: 'warn', levels: { checkout: 'log' } }) - * ``` - */ - levels?: Partial>; - - /** - * Also report `logger.error` calls to the telemetry sink as RUM errors. Off - * by default; competes for the same session event budget as scenario and - * BI data. - * - * Mirrors `detail.error` when supplied; a message-only call builds a - * synthetic `Error` from the redacted message text instead. - */ - mirrorErrorsToRum: boolean; - - /** - * Whether redaction runs on log records. On by default. Turning it off - * skips only the *configurable* PII scrubbing (`extraKeyPatterns`, - * value-pattern scanning, URL-query stripping) — the built-in credential - * denylist, size caps, error normalization and any - * `UwtRedactConfig.extraValueTransforms` still apply; see - * {@link uwtRedactConfigFor}. - */ - redact: boolean; -} - -/** Default application logging settings. */ -export const UWT_DEFAULT_LOG_CONFIG: UwtLogConfig = { - minLevel: 'log', - mirrorErrorsToRum: false, - redact: true -}; +import type { UwtLogConfig } from '@absaoss-cps/ui-watchtower'; /** * Resolved logging configuration. Provided by {@link provideUwtTelemetry}, diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts b/projects/ngx-ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts index 4214f26..b103368 100644 --- a/projects/ngx-ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts +++ b/projects/ngx-ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts @@ -1,77 +1,5 @@ import { InjectionToken } from '@angular/core'; - -/** - * Scenario telemetry configuration. - * - * @group Interfaces - */ -export interface UwtScenarioTelemetryConfig { - /** - * Milliseconds after which an unsettled scenario auto-settles as `timeout`. - * `0` disables the default; individual scenarios can still opt in. - */ - defaultTimeoutMs: number; - - /** - * Emit an event per lifecycle transition instead of one packed event at - * settlement. Off by default. - * - * Counts against the RUM session's shared `sessionEventLimit` (200 by - * default). Suited to local debugging, not production traffic. - * - * Also governs per-step `debugScenario` console output, because the - * console prints one line per event actually sent and nothing else. - * With this off, a scenario prints a single line at settlement — the - * one record it sends. - */ - emitLifecycleEvents: boolean; - - /** - * Maximum number of steps retained per scenario. Further steps are counted in - * `stepCount` but not stored, bounding the payload of a runaway loop. - */ - maxSteps: number; - - /** - * Mirror scenario and step boundaries into `performance.mark` / `measure`, - * visible on the DevTools Performance track. Off by default. - * - * The `debugScenario` LocalStorage flag switches them on regardless of this - * setting. Marks are cleared when a scenario settles; see - * {@link markCleanupFallbackMs} for scenarios that never do. - */ - userTimings: boolean; - - /** - * How long in milliseconds an unsettled scenario's User Timing marks can - * remain in the Performance buffer before a fallback clears them. - * - * Only applies when `userTimings` is enabled and `timeoutMs: 0`. `0` - * disables the fallback, so marks accumulate for the page's lifetime. - */ - markCleanupFallbackMs: number; - - /** - * Whether redaction runs on scenario records. On by default. Turning it - * off skips only the *configurable* PII scrubbing (`extraKeyPatterns`, - * value-pattern scanning, URL-query stripping) — the built-in credential - * denylist, size caps, error normalization and any - * `UwtRedactConfig.extraValueTransforms` still apply; see - * {@link uwtRedactConfigFor}. - */ - redact: boolean; -} - -/** Default scenario telemetry settings. */ -export const UWT_DEFAULT_SCENARIO_TELEMETRY_CONFIG: UwtScenarioTelemetryConfig = - { - defaultTimeoutMs: 30_000, - emitLifecycleEvents: false, - maxSteps: 50, - userTimings: false, - markCleanupFallbackMs: 300_000, - redact: true - }; +import type { UwtScenarioTelemetryConfig } from '@absaoss-cps/ui-watchtower'; /** * Resolved scenario telemetry configuration. Provided by diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts b/projects/ngx-ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts index d0586d9..38d09fa 100644 --- a/projects/ngx-ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts +++ b/projects/ngx-ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts @@ -1,67 +1,8 @@ import { inject, InjectionToken } from '@angular/core'; -import { - UwtBITelemetryConfig, - UWT_DEFAULT_BI_TELEMETRY_CONFIG -} from '../uwt-bi-telemetry.config/uwt-bi-telemetry.config'; -import { - UwtLogConfig, - UWT_DEFAULT_LOG_CONFIG -} from '../uwt-log.config/uwt-log.config'; -import { - UwtScenarioTelemetryConfig, - UWT_DEFAULT_SCENARIO_TELEMETRY_CONFIG -} from '../uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; -import { UWT_DEFAULT_EVENT_NAMESPACE } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { - UWT_DEFAULT_REDACT_CONFIG, - UwtRedactConfig -} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; - -/** - * The identity every telemetry record and event carries — application name, - * deployment environment and version, plus the event-type namespace. - * - * @group Interfaces - */ -export interface UwtTelemetryIdentity { - /** Application name, e.g. `composition`. Becomes a session attribute. */ - application: string; - - /** Deployment environment, e.g. `dev`, `qa`, `prod`. */ - environment: string; - - /** Application version, e.g. `22.0.0`. */ - version: string; - - /** - * Prefix for the custom event types this library emits — `{namespace}.scenario`, - * `{namespace}.scenario.step` and `{namespace}.bi`. - * - * Defaults to `com.uwt`. - */ - eventNamespace?: string; -} - -/** - * Library defaults for every concern not carried by {@link UwtTelemetryIdentity} - * — applied by {@link provideUwtTelemetry} to anything not overridden through a - * `with*()` feature. - * - * @group Utils - */ -export const UWT_DEFAULT_TELEMETRY_CONFIG: { - eventNamespace: string; - scenario: UwtScenarioTelemetryConfig; - logs: UwtLogConfig; - bi: UwtBITelemetryConfig; - redact: UwtRedactConfig; -} = { - eventNamespace: UWT_DEFAULT_EVENT_NAMESPACE, - scenario: UWT_DEFAULT_SCENARIO_TELEMETRY_CONFIG, - logs: UWT_DEFAULT_LOG_CONFIG, - bi: UWT_DEFAULT_BI_TELEMETRY_CONFIG, - redact: UWT_DEFAULT_REDACT_CONFIG -}; +import type { + UwtRedactConfig, + UwtTelemetryIdentity +} from '@absaoss-cps/ui-watchtower'; /** * The application's identity — shared by every concern; see diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.spec.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.spec.ts index 5208ffb..7d46fd6 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.spec.ts @@ -1,6 +1,6 @@ import { NgZone } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { UwtLogRecord } from '@absaoss-cps/ui-watchtower'; import { UWT_BROADCAST_CHANNEL } from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; import { UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS, diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts index 6e5985a..00dbe08 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts @@ -1,31 +1,13 @@ -import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { Injectable, OnDestroy } from '@angular/core'; +import { UwtBroadcastLogApiProvider as CoreBroadcastLogApiProvider } from '@absaoss-cps/ui-watchtower'; +import { uwtBroadcastConnectionOptions } from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; import { - UwtLogApiProvider, - UwtLogQuery -} from '../uwt-log-api.provider/uwt-log-api.provider'; -import { - uwtSafeVoid, - uwtUuid + uwtDevModeReporter, + uwtRunInsideZone, + uwtRunOutsideZone } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -import { - UwtBroadcastConnection, - uwtConnectBroadcastChannel, - uwtIsBroadcastLogRecord, - uwtIsBroadcastMessage -} from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; - -/** - * How long a follower waits for the host to answer a log query before - * resolving to `[]`, in milliseconds. - */ -export const UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS = 10_000; -/** A log query waiting for the host's answer. */ -interface UwtPendingLogQuery { - resolve: (records: UwtLogRecord[]) => void; - timer: ReturnType; -} +export { UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS } from '@absaoss-cps/ui-watchtower'; /** * Log API provider for a follower realm — the log counterpart of @@ -47,82 +29,20 @@ interface UwtPendingLogQuery { */ @Injectable() export class UwtBroadcastLogApiProvider - implements UwtLogApiProvider, OnDestroy + extends CoreBroadcastLogApiProvider + implements OnDestroy { - private readonly connection: UwtBroadcastConnection = - uwtConnectBroadcastChannel('broadcastLogApi'); - - private readonly pending = new Map(); - - /** Absent outside an application, e.g. a bare `Injector.create`. */ - private readonly zone = inject(NgZone, { optional: true }); - constructor() { - this.connection.onMessage((data) => this.onMessage(data)); - } - - /** @inheritdoc */ - send(record: UwtLogRecord): void { - this.connection.post({ kind: 'log', record }); - } - - /** @inheritdoc */ - query(filter: UwtLogQuery): Promise { - if (!this.connection.isOpen()) { - return Promise.resolve([]); - } - - return new Promise((resolve) => { - const id = uwtUuid(); - const timer = this.outsideZone(() => - setTimeout( - () => this.answer(id, []), - UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS - ) - ); - this.pending.set(id, { resolve, timer }); - this.connection.post({ kind: 'log-query', id, filter }); + super({ + ...uwtBroadcastConnectionOptions(), + runOutside: uwtRunOutsideZone(), + runInside: uwtRunInsideZone(), + report: uwtDevModeReporter }); } - /** @inheritdoc */ - flush(): void { - this.connection.post({ kind: 'log-flush' }); - } - /** Resolves every unanswered query to `[]`, then closes the channel. */ ngOnDestroy(): void { - for (const id of [...this.pending.keys()]) { - this.answer(id, []); - } - this.connection.close(); - } - - private onMessage(data: unknown): void { - uwtSafeVoid('broadcastLogApi.receive', () => { - if (!uwtIsBroadcastMessage(data) || data.kind !== 'log-query-result') { - return; - } - // Other followers' answers arrive here too; answer() ignores them. - this.answer(data.id, data.records.filter(uwtIsBroadcastLogRecord)); - }); - } - - private answer(id: string, records: UwtLogRecord[]): void { - const query = this.pending.get(id); - if (!query) { - return; - } - this.pending.delete(id); - clearTimeout(query.timer); - if (this.zone) { - this.zone.run(() => query.resolve(records)); - } else { - query.resolve(records); - } - } - - private outsideZone(fn: () => T): T { - return this.zone ? this.zone.runOutsideAngular(fn) : fn(); + this.dispose(); } } diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts index a2a50cc..f54f433 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts @@ -1,94 +1,5 @@ import { InjectionToken } from '@angular/core'; -import { - UwtLoggerName, - UwtLogLevel, - UwtLogRecord -} from '../../models/uwt-log.models/uwt-log.models'; - -/** - * Filter accepted by {@link UwtLoggerService.query}. - * - * Every field is optional and they combine with AND. A provider translates - * them however its store requires. - * - * @group Interfaces - */ -export interface UwtLogQuery { - /** - * Pulls one journey's lines together. Pass a {@link UwtScenario.id} to read - * everything logged during that scenario, across services. - */ - correlationId?: string; - - /** Restricts to one named logger. */ - logger?: UwtLoggerName; - - /** Discards records below this severity. */ - minLevel?: UwtLogLevel; - - /** ISO-8601 lower bound on `timestamp`, inclusive. */ - from?: string; - - /** ISO-8601 upper bound on `timestamp`, inclusive. */ - to?: string; - - /** Maximum number of records to return. */ - limit?: number; -} - -/** - * The application's log store, as this library needs to see it. - * - * Where records are kept, how they are authorised and how long they are - * retained are decisions for the consuming application; this is the seam it - * supplies them through. - * - * @example - * ```typescript - * send(record: UwtLogRecord): void { - * this.http.post('/api/logs', record).subscribe({ error: () => undefined }); - * } - * - * query(filter: UwtLogQuery): Promise { - * return firstValueFrom(this.http.get('/api/logs', { params: { ...filter } })); - * } - * ``` - * - * @group Interfaces - */ -export interface UwtLogApiProvider { - /** - * Ships one record. - * - * Called once per record, as it is written. Must be non-blocking and must - * not throw. A provider that batches accumulates its own queue here and - * flushes it via {@link flush}. - * - * @param record the redacted record - */ - send(record: UwtLogRecord): void; - - /** - * Reads records back. - * - * @param filter narrows what is returned; an empty filter means "everything - * this provider is willing to return" - * @returns the matching records - */ - query(filter: UwtLogQuery): Promise; - - /** - * Ships whatever this provider is holding, if it holds anything. - * - * Optional; only needed by a provider that batches in {@link send} instead - * of sending immediately. - * - * Called on `pagehide`, on `visibilitychange` going hidden, and on - * teardown. Must be synchronous and non-blocking. `fetch` with `keepalive` - * or `navigator.sendBeacon` are the usual choices. - */ - flush?(): void; -} +import type { UwtLogApiProvider } from '@absaoss-cps/ui-watchtower'; /** * Binds the application's {@link UwtLogApiProvider}. diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts index 088eaa4..5424240 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts @@ -1,9 +1,5 @@ import { Injectable } from '@angular/core'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; -import { - UwtLogApiProvider, - UwtLogQuery -} from '../uwt-log-api.provider/uwt-log-api.provider'; +import { UwtNoopLogApiProvider as CoreNoopLogApiProvider } from '@absaoss-cps/ui-watchtower'; /** * Log API provider that discards every record and finds none. @@ -27,12 +23,4 @@ import { * @group Services */ @Injectable() -export class UwtNoopLogApiProvider implements UwtLogApiProvider { - /** @inheritdoc */ - send(_record: UwtLogRecord): void {} - - /** @inheritdoc */ - query(_filter: UwtLogQuery): Promise { - return Promise.resolve([]); - } -} +export class UwtNoopLogApiProvider extends CoreNoopLogApiProvider {} diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.spec.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.spec.ts index 740f746..a0cf853 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.spec.ts @@ -1,26 +1,22 @@ -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UWT_DEFAULT_TELEMETRY_CONFIG, + uwtClassifyTelemetryEvent, + UwtLogApiProvider, + UwtLogQuery, + UwtLogRecord, + UwtTelemetryError, + UwtTelemetryMetadata, + UwtTelemetrySink, + UwtTelemetrySinkEvent +} from '@absaoss-cps/ui-watchtower'; import { ApplicationInitStatus, Injectable, PLATFORM_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { UwtLoggerService } from '../../services/uwt-logger.service/uwt-logger.service'; import { UwtScenarioTelemetryService } from '../../services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; import { UwtBITelemetryService } from '../../services/uwt-bi-telemetry.service/uwt-bi-telemetry.service'; -import { - UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { - uwtClassifyTelemetryEvent, - UwtTelemetrySinkEvent -} from '../../utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; import { UwtNoopTelemetrySink } from '../../sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UWT_LOG_API_PROVIDER } from '../uwt-log-api.provider/uwt-log-api.provider'; import { - UWT_LOG_API_PROVIDER, - UwtLogApiProvider, - UwtLogQuery -} from '../uwt-log-api.provider/uwt-log-api.provider'; -import { - UWT_DEFAULT_TELEMETRY_CONFIG, UWT_REDACT_CONFIG, UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts index 298d081..25c7361 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts @@ -9,10 +9,21 @@ import { } from '@angular/core'; import { UwtTelemetryBroadcastHost } from '../../sinks/uwt-broadcast/uwt-broadcast-host.service'; import { UwtBroadcastTelemetrySink } from '../../sinks/uwt-broadcast/uwt-broadcast-telemetry.sink'; -import { UWT_BROADCAST_CHANNEL } from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; +import { + UWT_BROADCAST_CHANNEL, + UwtBroadcastConnectionRef +} from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; import { UwtNoopTelemetrySink } from '../../sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { UwtRedactConfig } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + UWT_DEFAULT_TELEMETRY_CONFIG, + uwtResolveTelemetryConfig, + UwtBITelemetryConfig, + UwtLogConfig, + UwtRedactConfig, + UwtScenarioTelemetryConfig, + UwtTelemetryIdentity, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { UwtBroadcastLogApiProvider } from '../uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; import { UWT_LOG_API_PROVIDER } from '../uwt-log-api.provider/uwt-log-api.provider'; import { uwtSafeVoid } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; @@ -20,22 +31,11 @@ import { UWT_REDACT_CONFIG, UWT_TELEMETRY_ENABLED, uwtTelemetryEnabled, - UWT_TELEMETRY_IDENTITY, - UWT_DEFAULT_TELEMETRY_CONFIG, - UwtTelemetryIdentity + UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; -import { - UWT_BI_TELEMETRY_CONFIG, - UwtBITelemetryConfig -} from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; -import { - UWT_LOG_CONFIG, - UwtLogConfig -} from '../../config/uwt-log.config/uwt-log.config'; -import { - UWT_SCENARIO_TELEMETRY_CONFIG, - UwtScenarioTelemetryConfig -} from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { UWT_BI_TELEMETRY_CONFIG } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; +import { UWT_LOG_CONFIG } from '../../config/uwt-log.config/uwt-log.config'; +import { UWT_SCENARIO_TELEMETRY_CONFIG } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; /** * One optional concern's providers, composed onto {@link provideUwtTelemetry}. @@ -64,7 +64,7 @@ export function withLogging( providers: [ { provide: UWT_LOG_CONFIG, - useValue: { ...UWT_DEFAULT_TELEMETRY_CONFIG.logs, ...config } + useValue: uwtResolveTelemetryConfig({ logging: config }).logging } ] }; @@ -85,7 +85,7 @@ export function withScenarios( providers: [ { provide: UWT_SCENARIO_TELEMETRY_CONFIG, - useValue: { ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario, ...config } + useValue: uwtResolveTelemetryConfig({ scenarios: config }).scenarios } ] }; @@ -106,7 +106,7 @@ export function withBIEvents( providers: [ { provide: UWT_BI_TELEMETRY_CONFIG, - useValue: { ...UWT_DEFAULT_TELEMETRY_CONFIG.bi, ...config } + useValue: uwtResolveTelemetryConfig({ bi: config }).bi } ] }; @@ -127,24 +127,11 @@ export function withBIEvents( export function withRedaction( config: Partial = {} ): UwtTelemetryFeature { - const defaults = UWT_DEFAULT_TELEMETRY_CONFIG.redact; return { providers: [ { provide: UWT_REDACT_CONFIG, - useValue: { - ...defaults, - ...config, - extraKeyPatterns: [ - ...(config.extraKeyPatterns ?? defaults.extraKeyPatterns) - ], - extraValuePatterns: [ - ...(config.extraValuePatterns ?? defaults.extraValuePatterns) - ], - extraValueTransforms: [ - ...(config.extraValueTransforms ?? defaults.extraValueTransforms) - ] - } + useValue: uwtResolveTelemetryConfig({ redaction: config }).redaction } ] }; @@ -392,6 +379,8 @@ export function provideUwtTelemetrySink( switch (mode) { case 'broadcast': return makeEnvironmentProviders([ + // One channel for the realm's sink and log provider. + UwtBroadcastConnectionRef, provideUwtTelemetryDestination(UwtBroadcastTelemetrySink), UwtBroadcastLogApiProvider, { diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-enabled.spec.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-enabled.spec.ts index 3fdc52b..03f43a7 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-enabled.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-enabled.spec.ts @@ -7,9 +7,11 @@ import { PLATFORM_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { UwtTelemetryError } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { UwtScenario } from '../../scenario/uwt-scenario/uwt-scenario'; +import { + UwtScenario, + UwtTelemetryError, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { traceScenario } from '../../scenario/uwt-scenario-operators/uwt-scenario-operators'; import { UwtScenarioTelemetryService } from '../../services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; import { UwtBITelemetryService } from '../../services/uwt-bi-telemetry.service/uwt-bi-telemetry.service'; diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts index 582564a..65d7bf9 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts @@ -1,510 +1,66 @@ +import { isDevMode } from '@angular/core'; +import { of } from 'rxjs'; import { - defer, - EMPTY, - first, - type Observable, - firstValueFrom, - lastValueFrom, - of, - retry, - Subject, - switchMap, - take, - throwError -} from 'rxjs'; -import { UWT_DEFAULT_TELEMETRY_CONFIG } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; -import { UwtScenarioRecord } from '../../models/uwt-scenario.models/uwt-scenario.models'; -import { UwtNoopTelemetrySink } from '../../sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; -import { UWT_DEFAULT_REDACT_CONFIG } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { UwtScenario } from '../uwt-scenario/uwt-scenario'; + UWT_DEFAULT_REDACT_CONFIG, + UWT_DEFAULT_TELEMETRY_CONFIG, + UwtNoopTelemetrySink, + UwtScenario +} from '@absaoss-cps/ui-watchtower'; import { traceScenario } from './uwt-scenario-operators'; -describe('traceScenario operator', () => { - let scenario: jest.Mocked; +jest.mock('@angular/core', () => ({ + ...jest.requireActual('@angular/core'), + isDevMode: jest.fn(() => true) +})); + +const isDevModeMock = isDevMode as jest.Mock; + +/** The operator itself is tested in the core; this is what this package adds. */ +describe('traceScenario, as this package exports it', () => { + const throwingMapper = (): never => { + throw new Error('mapper broke'); + }; + let consoleError: jest.SpyInstance; + + function scenario(): UwtScenario { + return new UwtScenario( + { name: 'load-customers' }, + { + identity: { application: 'app', environment: 'test', version: '1' }, + scenarioTelemetryConfig: { ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink: new UwtNoopTelemetrySink(), + onSettled: () => undefined + } + ); + } beforeEach(() => { - scenario = { - complete: jest.fn(), - fail: jest.fn(), - cancel: jest.fn(), - isSettled: false - } as unknown as jest.Mocked; - }); - - it('should complete scenario on successful stream completion', (done) => { - of(['user1', 'user2']) - .pipe(traceScenario(scenario)) - .subscribe({ - next: (val) => { - expect(val).toEqual(['user1', 'user2']); - }, - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith(undefined); - expect(scenario.fail).not.toHaveBeenCalled(); - done(); - } - }); - }); - - it('should complete scenario with mapped outcome using function argument', (done) => { - of(['item1', 'item2', 'item3']) - .pipe( - traceScenario(scenario, (items) => ({ - metadata: { itemCount: items.length } - })) - ) - .subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith({ - metadata: { itemCount: 3 } - }); - done(); - } - }); - }); - - it('should complete scenario with mapped outcome using options object', (done) => { - of({ status: 200, data: 'ok' }) - .pipe( - traceScenario(scenario, { - outcome: (res) => ({ - statusCode: res.status, - metadata: { result: res.data } - }) - }) - ) - .subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith({ - statusCode: 200, - metadata: { result: 'ok' } - }); - done(); - } - }); - }); - - it('should complete scenario with no outcome when the stream completes with no emissions', (done) => { - EMPTY.pipe( - traceScenario(scenario, () => ({ metadata: { unreachable: true } })) - ).subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith(undefined); - expect(scenario.fail).not.toHaveBeenCalled(); - done(); - } - }); + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + isDevModeMock.mockReturnValue(true); }); - it('should fail scenario when the stream errors and re-throw the error', (done) => { - const error = new Error('network down'); - throwError(() => error) - .pipe(traceScenario(scenario)) - .subscribe({ - error: (err) => { - expect(err).toBe(error); - expect(scenario.fail).toHaveBeenCalledWith({ error }); - expect(scenario.complete).not.toHaveBeenCalled(); - done(); - } - }); + afterEach(() => { + jest.restoreAllMocks(); }); - it('should still settle the scenario when the outcome mapper throws', (done) => { - const consoleError = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); - - of('value') - .pipe( - traceScenario(scenario, () => { - throw new Error('mapper is broken'); - }) - ) - .subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith(undefined); - expect(scenario.fail).not.toHaveBeenCalled(); - expect(consoleError).toHaveBeenCalledWith( - expect.stringContaining('failed'), - expect.any(Error) - ); - consoleError.mockRestore(); - done(); - } - }); - }); + it('should report a throwing outcome mapper on the console, prefixed, in development mode', () => { + const traced = scenario(); - it('should not leak lastValue/hasValue into a resubscription of the same piped observable', (done) => { - let subscriptionCount = 0; - const source = defer(() => { - subscriptionCount++; - return subscriptionCount === 1 ? of('a') : EMPTY; - }); + of(1).pipe(traceScenario(traced, throwingMapper)).subscribe(); - const traced$ = source.pipe( - traceScenario(scenario, (value) => ({ metadata: { value } })) + expect(traced.status).toBe('success'); + expect(consoleError).toHaveBeenCalledWith( + '[ngx-ui-watchtower] scenario.traceOutcome failed', + expect.any(Error) ); - - traced$.subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenNthCalledWith(1, { - metadata: { value: 'a' } - }); - - traced$.subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenNthCalledWith(2, undefined); - done(); - } - }); - } - }); - }); - - describe('teardown without a source complete/error', () => { - it('should cancel the scenario on a manual unsubscribe', () => { - const source = new Subject(); - const subscription = source.pipe(traceScenario(scenario)).subscribe(); - - subscription.unsubscribe(); - - expect(scenario.cancel).toHaveBeenCalledTimes(1); - expect(scenario.complete).not.toHaveBeenCalled(); - expect(scenario.fail).not.toHaveBeenCalled(); - }); - - it('should cancel a superseded scenario when switchMap moves to the next inner observable', () => { - const supersededScenario = { - complete: jest.fn(), - fail: jest.fn(), - cancel: jest.fn(), - isSettled: false - } as unknown as jest.Mocked; - const pendingFirstRequest = new Subject(); - const secondRequest = of('b'); - const trigger = new Subject(); - - trigger - .pipe( - switchMap((n) => - (n === 1 ? pendingFirstRequest : secondRequest).pipe( - traceScenario(n === 1 ? supersededScenario : scenario) - ) - ) - ) - .subscribe(); - - trigger.next(1); // subscribes traceScenario(supersededScenario), still pending - trigger.next(2); // switchMap unsubscribes it before it ever settles - - expect(supersededScenario.cancel).toHaveBeenCalledTimes(1); - expect(supersededScenario.complete).not.toHaveBeenCalled(); - expect(supersededScenario.fail).not.toHaveBeenCalled(); - expect(scenario.complete).toHaveBeenCalledWith(undefined); - }); - - it('should cancel with the configured cancelOutcome on unsubscribe', () => { - const source = new Subject(); - const subscription = source - .pipe( - traceScenario(scenario, { cancelOutcome: { reason: 'superseded' } }) - ) - .subscribe(); - - subscription.unsubscribe(); - - expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); - }); - - it('should accept cancelOutcome as a function, evaluated at unsubscribe time', () => { - const source = new Subject(); - const subscription = source - .pipe( - traceScenario(scenario, { - cancelOutcome: () => ({ reason: 'superseded' }) - }) - ) - .subscribe(); - - subscription.unsubscribe(); - - expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); - }); - - it('should still cancel, with no outcome, when cancelOutcome throws', () => { - const consoleError = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); - const source = new Subject(); - const subscription = source - .pipe( - traceScenario(scenario, { - cancelOutcome: () => { - throw new Error('cancelOutcome is broken'); - } - }) - ) - .subscribe(); - - subscription.unsubscribe(); - - expect(scenario.cancel).toHaveBeenCalledWith(undefined); - consoleError.mockRestore(); - }); - - it('should record a reason for a switchMap-superseded scenario, not just a bare cancel', () => { - const supersededScenario = { - complete: jest.fn(), - fail: jest.fn(), - cancel: jest.fn(), - isSettled: false - } as unknown as jest.Mocked; - const pendingFirstRequest = new Subject(); - const secondRequest = of('b'); - const trigger = new Subject(); - - trigger - .pipe( - switchMap((n) => - (n === 1 ? pendingFirstRequest : secondRequest).pipe( - traceScenario(n === 1 ? supersededScenario : scenario, { - cancelOutcome: { reason: 'superseded' } - }) - ) - ) - ) - .subscribe(); - - trigger.next(1); - trigger.next(2); - - expect(supersededScenario.cancel).toHaveBeenCalledWith({ - reason: 'superseded' - }); - }); - - it('should not cancel a scenario that already completed normally', (done) => { - of('value') - .pipe(traceScenario(scenario)) - .subscribe({ - complete: () => { - (scenario as unknown as { isSettled: boolean }).isSettled = true; - expect(scenario.cancel).not.toHaveBeenCalled(); - done(); - } - }); - }); - - it('should not cancel a scenario that already failed', (done) => { - const error = new Error('boom'); - throwError(() => error) - .pipe(traceScenario(scenario)) - .subscribe({ - error: () => { - (scenario as unknown as { isSettled: boolean }).isSettled = true; - expect(scenario.cancel).not.toHaveBeenCalled(); - done(); - } - }); - }); }); - describe('with a real scenario', () => { - let settled: UwtScenarioRecord[]; - - const countRows = (rows: string[]) => ({ - metadata: { count: rows.length } - }); - - function createScenario(name = 'load-customers'): UwtScenario { - return new UwtScenario( - { name }, - { - identity: { - application: 'test-app', - environment: 'test', - version: '1.0.0' - }, - scenarioTelemetryConfig: { - ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario - }, - redact: UWT_DEFAULT_REDACT_CONFIG, - sink: new UwtNoopTelemetrySink(), - onSettled: (_id, record) => settled.push(record) - } - ); - } - - beforeEach(() => { - settled = []; - }); - - describe('single-value sources', () => { - it('should settle as success with subscribe()', () => { - const real = createScenario(); - - of(['a', 'b']).pipe(traceScenario(real, countRows)).subscribe(); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with lastValueFrom', async () => { - const real = createScenario(); - - await lastValueFrom( - of(['a', 'b']).pipe(traceScenario(real, countRows)) - ); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with firstValueFrom', async () => { - const real = createScenario(); - - const rows = await firstValueFrom( - of(['a', 'b']).pipe(traceScenario(real, countRows)) - ); - - expect(rows).toEqual(['a', 'b']); - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with take(1) after the operator', () => { - const real = createScenario(); - - of(['a', 'b']) - .pipe(traceScenario(real, countRows), take(1)) - .subscribe(); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with first() after the operator', () => { - const real = createScenario(); - - of(['a', 'b']) - .pipe(traceScenario(real, countRows), first()) - .subscribe(); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with take(1) before the operator', () => { - const real = createScenario(); - - of(['a', 'b']) - .pipe(take(1), traceScenario(real, countRows)) - .subscribe(); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with take(1) before the operator under firstValueFrom', async () => { - const real = createScenario(); - - await firstValueFrom( - of(['a', 'b']).pipe(take(1), traceScenario(real, countRows)) - ); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - }); - - describe('switchMap superseding an inner stream', () => { - it('should abandon a stream superseded before it emitted', () => { - const superseded = createScenario('superseded'); - const latest = createScenario('latest'); - const pendingFirstRequest = new Subject(); - const trigger = new Subject(); - - trigger - .pipe( - switchMap((n) => - n === 1 - ? pendingFirstRequest.pipe(traceScenario(superseded)) - : of(['b']).pipe(traceScenario(latest)) - ) - ) - .subscribe(); - - trigger.next(1); - trigger.next(2); - - expect(superseded.status).toBe('abandoned'); - expect(latest.status).toBe('success'); - }); - - it('should settle as success a stream superseded after it emitted', () => { - const superseded = createScenario('superseded'); - const latest = createScenario('latest'); - const firstRequest = new Subject(); - const trigger = new Subject(); - - trigger - .pipe( - switchMap((n) => - n === 1 - ? firstRequest.pipe(traceScenario(superseded, countRows)) - : of(['b']).pipe(traceScenario(latest)) - ) - ) - .subscribe(); - - trigger.next(1); - firstRequest.next(['a']); // emitted, but never completed - trigger.next(2); - - expect(superseded.status).toBe('success'); - expect(superseded.toRecord().metadata).toEqual({ count: 1 }); - expect(latest.status).toBe('success'); - }); - }); - - it("should abandon a stream torn down after a value with valueThenTeardown: 'cancel'", () => { - const real = createScenario('poll-status'); - - of(['a'], ['b']) - .pipe(traceScenario(real, { valueThenTeardown: 'cancel' }), take(1)) - .subscribe(); - - expect(real.status).toBe('abandoned'); - }); - - describe('retry placement', () => { - function failOnce(): Observable { - let attempts = 0; - return defer(() => - attempts++ === 0 - ? throwError(() => new Error('transient')) - : of(['a']) - ); - } - - it('should settle as success when retry() comes before the operator', () => { - const real = createScenario(); - - failOnce().pipe(retry(1), traceScenario(real)).subscribe(); - - expect(real.status).toBe('success'); - }); - - it('should settle as failure when retry() comes after the operator, even if the retry succeeds', () => { - const real = createScenario(); + it('should stay silent in production mode', () => { + isDevModeMock.mockReturnValue(false); - failOnce().pipe(traceScenario(real), retry(1)).subscribe(); + of(1).pipe(traceScenario(scenario(), throwingMapper)).subscribe(); - // Documented limitation: the first error settles the scenario and - // the operator cannot see the downstream retry. - expect(real.status).toBe('failure'); - }); - }); + expect(consoleError).not.toHaveBeenCalled(); }); }); diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts index b9343b0..0303e4f 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts @@ -1,36 +1,13 @@ -import { defer } from 'rxjs'; -import type { MonoTypeOperatorFunction, Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; -import type { UwtScenarioOutcome } from '../../models/uwt-scenario.models/uwt-scenario.models'; -import type { UwtScenario } from '../uwt-scenario/uwt-scenario'; -import { uwtSafe } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; - -/** - * Options for configuring scenario completion within an RxJS stream. - * - * @group Interfaces - */ -export interface UwtTraceScenarioOptions { - /** - * Optional mapper that returns an outcome (metadata, statusCode, message) - * based on the emitted value when the Observable completes successfully. - */ - outcome?: (value: T) => UwtScenarioOutcome | void; - - /** - * Outcome used when the stream is unsubscribed before completing or - * erroring — e.g. a `switchMap` moving on to a newer value. - */ - cancelOutcome?: UwtScenarioOutcome | (() => UwtScenarioOutcome | void); - - /** - * What a downstream unsubscribe means once a value has been delivered. - * Default `'complete'`, so `firstValueFrom`, `take(1)` and `first()` settle - * as success. Use `'cancel'` for multi-value streams (polling, progress), - * where stopping after a value is abandonment. - */ - valueThenTeardown?: 'complete' | 'cancel'; -} +import type { MonoTypeOperatorFunction } from 'rxjs'; +import type { + UwtScenario, + UwtScenarioOutcome +} from '@absaoss-cps/ui-watchtower'; +import { + traceScenario as coreTraceScenario, + UwtTraceScenarioOptions +} from '@absaoss-cps/ui-watchtower/rxjs'; +import { uwtDevModeReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; /** * An RxJS pipeable operator that automatically settles a {@link UwtScenario} @@ -58,60 +35,5 @@ export function traceScenario( options?: UwtTraceScenarioOptions | ((value: T) => UwtScenarioOutcome | void) ): MonoTypeOperatorFunction { - const outcomeMapper = - typeof options === 'function' ? options : options?.outcome; - const cancelOutcome = - typeof options === 'function' ? undefined : options?.cancelOutcome; - const valueThenTeardown = - (typeof options === 'function' ? undefined : options?.valueThenTeardown) ?? - 'complete'; - - return (source: Observable): Observable => - defer(() => { - let lastValue: T | undefined; - let hasValue = false; - - const complete = () => { - const outcome = - hasValue && outcomeMapper - ? uwtSafe( - 'scenario.traceOutcome', - () => outcomeMapper(lastValue as T), - undefined - ) - : undefined; - scenario.complete(outcome || undefined); - }; - - const unsubscribe = () => { - if (scenario.isSettled) { - return; - } - // The consumer got a value and stopped listening, as - // firstValueFrom, take(1) and first() do: treat it as completion. - if (hasValue && valueThenTeardown === 'complete') { - complete(); - return; - } - const resolved = - typeof cancelOutcome === 'function' - ? uwtSafe('scenario.traceCancelOutcome', cancelOutcome, undefined) - : cancelOutcome; - scenario.cancel(resolved || undefined); - }; - - return source.pipe( - tap({ - next: (value) => { - lastValue = value; - hasValue = true; - }, - complete, - error: (error: unknown) => { - scenario.fail({ error }); - }, - unsubscribe - }) - ); - }); + return coreTraceScenario(scenario, options, uwtDevModeReporter); } diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.spec.ts index 3b37314..828db40 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.spec.ts @@ -6,22 +6,20 @@ import { withRedaction, withScenarios } from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; -import { UwtBIEvent } from '../../models/uwt-bi.models/uwt-bi.models'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { - UWT_LOG_API_PROVIDER, + UWT_REDACTED, + UWT_TELEMETRY_EVENT_TYPE, + UwtBIEvent, UwtLogApiProvider, - UwtLogQuery -} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import { UWT_REDACTED } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; + UwtLogQuery, + UwtLogRecord, + UwtTelemetryError, + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { UwtBITelemetryService } from './uwt-bi-telemetry.service'; import { UwtScenarioTelemetryService } from '../uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; -import { - UWT_TELEMETRY_EVENT_TYPE, - UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; /** Captures what the library emitted, so a test can assert on it. */ @Injectable() 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 0aa89ae..a5d9a50 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 @@ -1,31 +1,18 @@ import { inject, Injectable } from '@angular/core'; +import { + UwtBIEventDetail, + UwtBIEventName, + UwtBITelemetry, + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { UWT_REDACT_CONFIG, uwtTelemetryEnabled, UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UWT_BI_TELEMETRY_CONFIG } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; -import type { - UwtBIEvent, - UwtBIEventDetail, - UwtBIEventName -} from '../../models/uwt-bi.models/uwt-bi.models'; -import { - uwtEventTypes, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -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 { - uwtRedactConfigFor, - uwtRedactMetadata, - uwtScrubString -} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { - uwtNow, - uwtSafe, - uwtSafeVoid -} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { uwtDevModeReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; /** * Business and UX event tracking — feature adoption, interaction analysis, @@ -62,18 +49,15 @@ import { */ @Injectable({ providedIn: 'root' }) export class UwtBITelemetryService { - private readonly identity = inject(UWT_TELEMETRY_IDENTITY); - private readonly biConfig = inject(UWT_BI_TELEMETRY_CONFIG); - private readonly redact = uwtRedactConfigFor( - inject(UWT_REDACT_CONFIG), - this.biConfig.redact - ); - - private readonly sink = inject(UwtTelemetrySink); - private readonly enabled = uwtTelemetryEnabled(); - - private readonly eventTypes = uwtEventTypes(this.identity.eventNamespace); - private readonly lastEmittedAt = new Map(); + /** The core's BI telemetry, configured from this injector. */ + private readonly bi = new UwtBITelemetry({ + identity: inject(UWT_TELEMETRY_IDENTITY), + config: inject(UWT_BI_TELEMETRY_CONFIG), + redact: inject(UWT_REDACT_CONFIG), + sink: inject(UwtTelemetrySink), + enabled: uwtTelemetryEnabled(), + report: uwtDevModeReporter + }); /** * Records a business or UX event. @@ -89,123 +73,6 @@ export class UwtBITelemetryService { metadata?: UwtTelemetryMetadata, detail?: UwtBIEventDetail ): void { - if (!this.enabled) { - return; - } - uwtSafeVoid('biTelemetry.track', () => { - if (!eventName) { - return; - } - - const redactedFeature = detail?.feature - ? uwtScrubString(detail.feature, this.redact) - : undefined; - const redactedMetadata = uwtRedactMetadata(metadata, this.redact); - - if ( - this.isDuplicate(eventName, redactedMetadata, { - ...detail, - feature: redactedFeature - }) - ) { - return; - } - - const event: UwtBIEvent = { - eventName, - eventTime: new Date().toISOString(), - scenarioId: detail?.scenarioId, - feature: redactedFeature, - metadata: redactedMetadata, - application: this.identity.application, - sessionId: uwtSafe( - 'biTelemetry.getSessionId', - () => this.sink.getSessionId(), - undefined - ), - userId: uwtSafe( - 'biTelemetry.getUserId', - () => this.sink.getUserId(), - undefined - ) - }; - - uwtDebugWrite('debugBI', () => - writeToConsole(eventName, this.eventTypes.bi, event) - ); - - this.sink.record(this.eventTypes.bi, event); - }); - } - - private isDuplicate( - eventName: string, - metadata: UwtTelemetryMetadata | undefined, - detail?: UwtBIEventDetail - ): boolean { - const key = JSON.stringify([ - eventName, - detail?.scenarioId ?? '', - detail?.feature ?? '', - this.metadataKey(metadata) - ]); - const now = uwtNow(); - const last = this.lastEmittedAt.get(key); - - if (last !== undefined && now - last < this.biConfig.dedupWindowMs) { - this.lastEmittedAt.delete(key); - this.lastEmittedAt.set(key, last); - return true; - } - - if (this.lastEmittedAt.size >= this.biConfig.dedupMaxKeys) { - for (const [staleKey, at] of this.lastEmittedAt) { - if (now - at >= this.biConfig.dedupWindowMs) { - this.lastEmittedAt.delete(staleKey); - } - } - - if (this.lastEmittedAt.size >= this.biConfig.dedupMaxKeys) { - const oldestKey = this.lastEmittedAt.keys().next().value; - if (oldestKey !== undefined) { - this.lastEmittedAt.delete(oldestKey); - } - } - } - - this.lastEmittedAt.delete(key); - this.lastEmittedAt.set(key, now); - return false; - } - - /** - * A stable string encoding a flat metadata object's content, for use in - * the dedup key — otherwise two same-named events with different metadata - * would collide and the second would be silently dropped. Keys are sorted - * so property order doesn't affect the result. Callers pass already- - * redacted metadata — a caller's raw values must never end up as a `Map` - * key living in this root service's memory for the page lifetime. - */ - private metadataKey(metadata: UwtTelemetryMetadata | undefined): string { - if (!metadata) { - return ''; - } - return JSON.stringify( - Object.keys(metadata) - .sort() - .map((k) => [k, metadata[k]]) - ); + this.bi.track(eventName, metadata, detail); } } - -/** - * Prefixed with the application - in a composed - * page every realm writes to the one console. - */ -function writeToConsole( - eventName: string, - eventType: string, - event: UwtBIEvent -): void { - console.log(`[${event.application}][bi] ${eventName} -> ${eventType}`, event); -} diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts index 068d000..91fcf3b 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts @@ -1,29 +1,26 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable, OnDestroy } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { UwtLogConfig } from '../../config/uwt-log.config/uwt-log.config'; +import { + UWT_REDACTED, + UwtLogApiProvider, + UwtLogConfig, + UwtLogDetail, + UwtLogger, + UwtLogQuery, + UwtLogRecord, + UwtTelemetryError, + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { provideUwtTelemetry, withLogging, withRedaction } from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { - UWT_LOG_API_PROVIDER, - UwtLogApiProvider, - UwtLogQuery -} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import * as uwtTelemetryRedactUtil from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { UWT_REDACTED } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +// The logging logic lives in the core, which calls its own redaction module. +import * as uwtTelemetryRedactUtil from '../../../../../ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; import { UwtLoggerService } from './uwt-logger.service'; -import { - UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import type { - UwtLogDetail, - UwtLogger, - UwtLogRecord -} from '../../models/uwt-log.models/uwt-log.models'; /** Captures what the library emitted, so a test can assert on it. */ @Injectable() @@ -857,4 +854,31 @@ describe('UwtLoggerService', () => { expect(() => window.dispatchEvent(new Event('pagehide'))).not.toThrow(); }); }); + + describe('teardown', () => { + it('should still ship a line logged by a service destroyed after the logger service', () => { + // Angular destroys a service before those that inject it, so this one + // logs after UwtLoggerService has been destroyed. + @Injectable({ providedIn: 'root' }) + class ClosingService implements OnDestroy { + private readonly logger = inject(UwtLoggerService).getLogger('closing'); + + ngOnDestroy(): void { + this.logger.log('closing'); + } + } + TestBed.inject(ClosingService); + + TestBed.resetTestingModule(); + + expect(transport.records.map((r) => r.message)).toContain('closing'); + }); + + it('should keep answering queries after the logger service is destroyed', async () => { + logger.log('before'); + service.ngOnDestroy(); + + await expect(service.query({})).resolves.toHaveLength(1); + }); + }); }); 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 62c9885..b568848 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 @@ -1,45 +1,24 @@ -import { DOCUMENT } from '@angular/common'; import { inject, Injectable, OnDestroy } from '@angular/core'; +import { + UwtLogger, + UwtLoggerName, + UwtLogQuery, + UwtLogRecord, + UwtLogTelemetry, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { UWT_REDACT_CONFIG, uwtTelemetryEnabled, UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UWT_LOG_CONFIG } from '../../config/uwt-log.config/uwt-log.config'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { - UWT_LOG_LEVEL_ORDER, - type UwtLogger, - type UwtLoggerName, - type UwtLogDetail, - type UwtLogLevel, - type UwtLogRecord -} from '../../models/uwt-log.models/uwt-log.models'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { - UWT_LOG_API_PROVIDER, - UwtLogQuery -} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; -import { - uwtNormalizeError, - uwtRedactConfigFor, - uwtRedactMetadata, - uwtScrubString -} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { - uwtIsBrowser, - uwtSafe, - uwtSafeVoid, - uwtSafeVoidMaybeAsync + uwtBrowserDocument, + uwtDevModeReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -/** Handed out while telemetry is off: every call does nothing. */ -const INERT_LOGGER: UwtLogger = Object.freeze({ - log: () => undefined, - warn: () => undefined, - error: () => undefined -}); - /** * Structured application logging. * @@ -79,64 +58,22 @@ const INERT_LOGGER: UwtLogger = Object.freeze({ */ @Injectable({ providedIn: 'root' }) export class UwtLoggerService implements OnDestroy { - private readonly identity = inject(UWT_TELEMETRY_IDENTITY); - private readonly logsConfig = inject(UWT_LOG_CONFIG); - private readonly redact = uwtRedactConfigFor( - inject(UWT_REDACT_CONFIG), - this.logsConfig.redact - ); - - /** Enrichment only (`sessionId`/`userId`, optional RUM mirroring). */ - private readonly sink = inject(UwtTelemetrySink, { optional: true }); - private readonly apiProvider = inject(UWT_LOG_API_PROVIDER); - private readonly document = inject(DOCUMENT); - private readonly enabled = uwtTelemetryEnabled(); - - /** Page listeners: only in a browser, and only while telemetry is on. */ - private readonly listensToPage = uwtIsBrowser() && this.enabled; - - private readonly onPageHide = () => this.flushProvider(); - private readonly onVisibilityChange = () => { - if (this.document.visibilityState === 'hidden') { - this.flushProvider(); - } - }; - - /** - * One logger per name, so {@link getLogger} is idempotent. - * - * Unbounded growth is not a concern the way it is for active scenarios: - * names come from the closed {@link UwtLoggerNames} vocabulary, so this - * map is bounded by what the application declared at build time. - */ - private readonly loggers = new Map(); - - constructor() { - if (this.listensToPage) { - this.document.defaultView?.addEventListener('pagehide', this.onPageHide); - - this.document.addEventListener( - 'visibilitychange', - this.onVisibilityChange - ); - } - } + /** The core's logging, configured from this injector. */ + private readonly logging = new UwtLogTelemetry({ + identity: inject(UWT_TELEMETRY_IDENTITY), + config: inject(UWT_LOG_CONFIG), + redact: inject(UWT_REDACT_CONFIG), + // Enrichment only (`sessionId`/`userId`, optional RUM mirroring). + sink: inject(UwtTelemetrySink, { optional: true }), + logApi: inject(UWT_LOG_API_PROVIDER), + document: uwtBrowserDocument(), + enabled: uwtTelemetryEnabled(), + report: uwtDevModeReporter + }); /** @inheritdoc */ ngOnDestroy(): void { - if (this.listensToPage) { - this.document.defaultView?.removeEventListener( - 'pagehide', - this.onPageHide - ); - this.document.removeEventListener( - 'visibilitychange', - this.onVisibilityChange - ); - } - if (this.enabled) { - this.flushProvider(); - } + this.logging.dispose(); } /** @@ -160,17 +97,8 @@ export class UwtLoggerService implements OnDestroy { * @param filter narrows what is returned * @returns the matching records, or `[]` when none can be read */ - async query(filter: UwtLogQuery = {}): Promise { - if (!this.enabled) { - return []; - } - const pending = uwtSafe( - 'logger.query', - () => this.apiProvider.query(filter), - undefined - ); - - return (await pending?.catch(() => undefined)) ?? []; + query(filter: UwtLogQuery = {}): Promise { + return this.logging.query(filter); } /** @@ -205,143 +133,6 @@ export class UwtLoggerService implements OnDestroy { * @returns the logger for that name, created once and reused */ getLogger(name: UwtLoggerName): UwtLogger { - if (!this.enabled) { - return INERT_LOGGER; - } - const cached = uwtSafe( - 'logger.getLogger', - () => this.loggers.get(name), - undefined - ); - if (cached) { - return cached; - } - - const logger = this.createLogger(name); - uwtSafeVoid('logger.register', () => this.loggers.set(name, logger)); - return logger; - } - - /** - * Builds the logger for one name. - * - * Private, and reached only through {@link getLogger}, so every logger - * handed out is registered and named. The name is captured here rather - * than read from each call's detail, which is why a record's `logger` - * cannot disagree with the logger that wrote it. - * - * @param name the logger name stamped onto every record it writes - * @returns a logger bound to that name - */ - private createLogger(name: UwtLoggerName): UwtLogger { - return { - log: (message, detail) => this.emit('log', name, message, detail), - warn: (message, detail) => this.emit('warn', name, message, detail), - error: (message, detail) => this.emit('error', name, message, detail) - }; - } - - /** Severity floor for one logger — its own override, or the global one. */ - private minLevelFor(logger: UwtLoggerName): UwtLogLevel { - const { levels, minLevel } = this.logsConfig; - return levels?.[logger] ?? minLevel; - } - - private emit( - level: UwtLogLevel, - logger: UwtLoggerName, - message: string, - detail?: UwtLogDetail - ): void { - uwtSafeVoid(`logger.${level}`, () => { - if ( - UWT_LOG_LEVEL_ORDER[level] < - UWT_LOG_LEVEL_ORDER[this.minLevelFor(logger)] - ) { - return; - } - - const record = this.buildRecord(level, logger, message, detail); - - uwtDebugWrite('debugLogger', () => writeToConsole(record), record.logger); - - this.deliver(record); - - if (level === 'error' && this.logsConfig.mirrorErrorsToRum && this.sink) { - const mirrored = - record.error ?? - uwtNormalizeError(new Error(record.message), this.redact); - if (mirrored) { - this.sink.recordError(mirrored); - } - } - }); - } - - /** Guards against a throwing or secretly-async, rejecting provider. */ - private deliver(record: UwtLogRecord): void { - uwtSafeVoidMaybeAsync('logger.deliver', () => - this.apiProvider.send(record) - ); + return this.logging.getLogger(name); } - - /** Gives the provider its chance to ship whatever it has queued itself. */ - private flushProvider(): void { - uwtSafeVoidMaybeAsync('logger.providerFlush', () => - this.apiProvider.flush?.() - ); - } - - private buildRecord( - level: UwtLogLevel, - logger: UwtLoggerName, - message: string, - detail?: UwtLogDetail - ): UwtLogRecord { - const redact = this.redact; - const sink = this.sink; - const identity = this.identity; - - return { - timestamp: new Date().toISOString(), - level, - message: uwtScrubString(String(message ?? ''), redact), - logger, - context: detail?.context - ? uwtScrubString(detail.context, redact) - : undefined, - metadata: uwtRedactMetadata(detail?.metadata, redact), - error: uwtNormalizeError(detail?.error, redact), - correlationId: detail?.correlationId - ? uwtScrubString(detail.correlationId, redact) - : undefined, - application: identity.application, - environment: identity.environment, - version: identity.version, - ...(sink && { - userId: uwtSafe('getUserId', () => sink.getUserId(), undefined), - sessionId: uwtSafe('getSessionId', () => sink.getSessionId(), undefined) - }) - }; - } -} - -/** - * Prints the record exactly as the transport receives it. - * - * Prefixed with the application - in a composed - * page every realm writes to the one console - then the logger name and - * the context, each when present. - */ -function writeToConsole(record: UwtLogRecord): void { - const scope = [record.logger, record.context] - .filter(Boolean) - .map((part) => `[${part}]`) - .join(''); - const suffix = record.correlationId ? ` (${record.correlationId})` : ''; - - console[record.level]( - `[${record.application}]${scope} ${record.message}${suffix}`, - record - ); } diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts index 446c4d4..e5e23b0 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts @@ -1,29 +1,26 @@ -import { Injectable, NgZone } from '@angular/core'; +import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { UwtScenarioTelemetryConfig } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { + UWT_REDACTED, + UWT_TELEMETRY_EVENT_TYPE, + UwtLogApiProvider, + UwtLogQuery, + UwtLogRecord, + UwtScenarioRecord, + UwtScenarioStatus, + UwtScenarioTelemetryConfig, + UwtTelemetryError, + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { provideUwtTelemetry, withRedaction, withScenarios } from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; -import { - UwtScenarioRecord, - UwtScenarioStatus -} from '../../models/uwt-scenario.models/uwt-scenario.models'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { - UWT_LOG_API_PROVIDER, - UwtLogApiProvider, - UwtLogQuery -} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import { UWT_REDACTED } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import * as uwtTelemetrySafeUtil from '../../../../../ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { UwtScenarioTelemetryService } from './uwt-scenario-telemetry.service'; -import { - UWT_TELEMETRY_EVENT_TYPE, - UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; /** Captures what the library emitted, so a test can assert on it. */ @Injectable() @@ -2069,6 +2066,62 @@ describe('UwtScenarioTelemetryService', () => { expect(completed).toBe(true); }); + it('should complete a subscriber that arrives after destroy', () => { + service.ngOnDestroy(); + + let completed = false; + service.settled$.subscribe({ complete: () => (completed = true) }); + + expect(completed).toBe(true); + }); + + describe('copies of the record', () => { + let copy: jest.SpyInstance; + + const recordCopies = () => + copy.mock.calls.filter( + ([value]) => + (value as { scenarioName?: unknown } | null)?.scenarioName !== + undefined + ).length; + + beforeEach(() => { + copy = jest.spyOn(uwtTelemetrySafeUtil, 'uwtDeepClone'); + }); + afterEach(() => copy.mockRestore()); + + it('should make none while nobody subscribes', () => { + service.start({ name: 'a' }).complete(); + + expect(recordCopies()).toBe(0); + }); + + it('should make one per record for any number of subscribers, sharing it', () => { + const seen: UwtScenarioRecord[] = []; + service.settled$.subscribe((r) => seen.push(r)); + service.settled$.subscribe((r) => seen.push(r)); + + service.start({ name: 'a' }).complete(); + + expect(recordCopies()).toBe(1); + expect(seen).toHaveLength(2); + expect(seen[0]).toBe(seen[1]); + }); + + it('should stop once the last subscriber leaves', () => { + const first = service.settled$.subscribe(); + const second = service.settled$.subscribe(); + first.unsubscribe(); + + service.start({ name: 'a' }).complete(); + expect(recordCopies()).toBe(1); + + second.unsubscribe(); + service.start({ name: 'b' }).complete(); + expect(recordCopies()).toBe(1); + }); + }); + it('should not let a throwing subscriber break the scenario', () => { // RxJS's Subject.next() already isolates a throwing subscriber, not // uwtSafeVoid — this confirms the guarantee holds either way. @@ -2096,6 +2149,44 @@ describe('UwtScenarioTelemetryService', () => { }); }); + describe('teardown', () => { + it('should still record a scenario that a service starts and ends in its own ngOnDestroy', () => { + // Angular destroys a service before those that inject it, so this one + // starts its scenario after UwtScenarioTelemetryService was destroyed. + @Injectable({ providedIn: 'root' }) + class ClosingService implements OnDestroy { + private readonly scenarios = inject(UwtScenarioTelemetryService); + + ngOnDestroy(): void { + this.scenarios.start({ name: 'closing' }).complete(); + } + } + TestBed.inject(ClosingService); + + TestBed.resetTestingModule(); + + expect(onlyScenarioRecord()).toMatchObject({ + scenarioName: 'closing', + status: 'success' + }); + }); + + it('should still time out a scenario started after destroy and never ended, as before', () => { + jest.useFakeTimers(); + configure({ defaultTimeoutMs: 5_000 }); + + service.ngOnDestroy(); + service.start({ name: 'late' }); + jest.advanceTimersByTime(5_000); + + expect(onlyScenarioRecord()).toMatchObject({ + scenarioName: 'late', + status: 'timeout' + }); + jest.useRealTimers(); + }); + }); + describe('failure isolation', () => { let consoleError: jest.SpyInstance; 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 1fea5a7..9102378 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 @@ -1,38 +1,24 @@ -import { DOCUMENT } from '@angular/common'; -import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; +import { inject, Injectable, OnDestroy } from '@angular/core'; import { Observable, Subject } from 'rxjs'; +import { + UwtScenario, + UwtScenarioName, + UwtScenarioOptions, + UwtScenarioRecord, + UwtScenarioTelemetry, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { UWT_REDACT_CONFIG, uwtTelemetryEnabled, UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UWT_SCENARIO_TELEMETRY_CONFIG } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; -import type { - UwtScenarioName, - UwtScenarioOptions, - UwtScenarioRecord -} from '../../models/uwt-scenario.models/uwt-scenario.models'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { - uwtDeepClone, - uwtIsBrowser, - uwtIsDevMode, - uwtSafeVoid + uwtBrowserDocument, + uwtDevModeReporter, + uwtRunOutsideZone } 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'; - -/** - * How many scenarios may be in flight before a development-mode warning - * suggests a leak. - * - * Concurrent scenarios are legitimate — several journeys, tabs or panels - * can run at once — so this is a smell threshold, not a limit: nothing is - * dropped or rejected when it is crossed. Set well above any plausible - * real concurrency, so crossing it might mean scenarios with `timeoutMs: 0` or - * long lifecycles are being started and never settled. - */ -const ACTIVE_SCENARIO_WARN_THRESHOLD = 50; /** * Creates and tracks scenarios — user journeys whose health this telemetry @@ -58,31 +44,17 @@ const ACTIVE_SCENARIO_WARN_THRESHOLD = 50; */ @Injectable({ providedIn: 'root' }) export class UwtScenarioTelemetryService implements OnDestroy { - private readonly identity = inject(UWT_TELEMETRY_IDENTITY); - private readonly scenarioTelemetryConfig = inject( - UWT_SCENARIO_TELEMETRY_CONFIG - ); - - private readonly redact = uwtRedactConfigFor( - inject(UWT_REDACT_CONFIG), - this.scenarioTelemetryConfig.redact - ); - - private readonly sink = inject(UwtTelemetrySink); - private readonly document = inject(DOCUMENT); - private readonly enabled = uwtTelemetryEnabled(); - - /** Page listeners: only in a browser, and only while telemetry is on. */ - private readonly listensToPage = uwtIsBrowser() && this.enabled; - private readonly zone = inject(NgZone, { optional: true }); - - private readonly active = new Map(); - private readonly onPageHide = () => this.flushActive(); - private readonly onVisibilityChange = () => { - if (this.document.visibilityState === 'hidden') { - this.flushSink(); - } - }; + /** The core's scenario telemetry, configured from this injector. */ + private readonly scenarios = new UwtScenarioTelemetry({ + identity: inject(UWT_TELEMETRY_IDENTITY), + config: inject(UWT_SCENARIO_TELEMETRY_CONFIG), + redact: inject(UWT_REDACT_CONFIG), + sink: inject(UwtTelemetrySink), + document: uwtBrowserDocument(), + enabled: uwtTelemetryEnabled(), + runOutside: uwtRunOutsideZone(), + report: uwtDevModeReporter + }); private readonly _settled$ = new Subject(); @@ -100,18 +72,27 @@ export class UwtScenarioTelemetryService implements OnDestroy { * ``` */ readonly settled$: Observable = - this._settled$.asObservable(); - - constructor() { - if (this.listensToPage) { - this.document.defaultView?.addEventListener('pagehide', this.onPageHide); + new Observable((subscriber) => { + const subscription = this._settled$.subscribe(subscriber); + // Listens to the core only while observed: the core copies each record + // for its listeners, wasted work when nobody reads it. + if (!this.destroyed) { + this.stopListening ??= this.scenarios.onSettled((record) => + this._settled$.next(record) + ); + } + return () => { + subscription.unsubscribe(); + if (!this._settled$.observed) { + this.stopListening?.(); + this.stopListening = undefined; + } + }; + }); - this.document.addEventListener( - 'visibilitychange', - this.onVisibilityChange - ); - } - } + /** Stops the core's listener; set while {@link settled$} has subscribers. */ + private stopListening?: () => void; + private destroyed = false; /** * Starts a new scenario. @@ -121,47 +102,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { * @returns an independent scenario instance */ start(options: UwtScenarioOptions): UwtScenario { - if (!this.enabled) { - // Same return type, nothing recorded, timed, registered or sent. - return new UwtScenario(options, { - identity: this.identity, - scenarioTelemetryConfig: this.scenarioTelemetryConfig, - redact: this.redact, - sink: this.sink, - onSettled: () => undefined, - inert: true - }); - } - const scenario = new UwtScenario(options, { - identity: this.identity, - scenarioTelemetryConfig: this.scenarioTelemetryConfig, - redact: this.redact, - sink: this.sink, - runOutsideAngular: (callback) => - this.zone?.runOutsideAngular(callback) ?? callback(), - onSettled: (scenarioId, record) => { - this.active.delete(scenarioId); - if (!this._settled$.observed) { - return; - } - // Cloned so a settled$ subscriber can't mutate the same object - // that's about to be (or already was) shipped to the sink. - uwtSafeVoid('scenarioTelemetry.notify', () => - this._settled$.next(uwtDeepClone(record)) - ); - } - }); - - uwtSafeVoid('scenarioTelemetry.register', () => { - this.active.set(scenario.id, scenario); - if (uwtIsDevMode() && this.active.size > ACTIVE_SCENARIO_WARN_THRESHOLD) { - console.warn( - `[ngx-ui-watchtower] High number of active scenarios (${this.active.size}). Ensure scenarios with timeoutMs: 0 or long lifecycles are settled on destroy.` - ); - } - }); - - return scenario; + return this.scenarios.start(options); } /** @@ -171,7 +112,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { * @returns the scenario, or `undefined` if unknown or already settled */ find(scenarioId: string): UwtScenario | undefined { - return this.active.get(scenarioId); + return this.scenarios.find(scenarioId); } /** @@ -187,7 +128,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { * @returns matching active scenarios, in start order */ findByName(name: UwtScenarioName): UwtScenario[] { - return this.getActive().filter((scenario) => scenario.name === name); + return this.scenarios.findByName(name); } /** @@ -206,8 +147,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { name: UwtScenarioName, scenarioId: string ): UwtScenario | undefined { - const scenario = this.find(scenarioId); - return scenario?.name === name ? scenario : undefined; + return this.scenarios.findByNameAndId(name, scenarioId); } /** @@ -216,52 +156,13 @@ export class UwtScenarioTelemetryService implements OnDestroy { * @returns the active scenarios, in start order */ getActive(): UwtScenario[] { - return [...this.active.values()]; + return this.scenarios.getActive(); } /** @inheritdoc */ ngOnDestroy(): void { - if (this.listensToPage) { - this.document.defaultView?.removeEventListener( - 'pagehide', - this.onPageHide - ); - this.document.removeEventListener( - 'visibilitychange', - this.onVisibilityChange - ); - } - if (this.enabled) { - this.flushActive(); - } + this.destroyed = true; + this.scenarios.dispose(); this._settled$.complete(); } - - /** - * Dispatches what the sink holds, leaving running scenarios alone. - * - * Uses the beacon transport since the page may not survive a normal request. - */ - private flushSink(): void { - uwtSafeVoid('scenarioTelemetry.flushSink', () => this.sink.flush(true)); - } - - /** - * Settles every in-flight scenario as abandoned and asks the sink to send - * what it holds using a transport that survives unload. - * - * Scenario emission is synchronous, so everything settled here reaches the - * sink before the beacon goes out. - */ - private flushActive(): void { - uwtSafeVoid('scenarioTelemetry.flush', () => { - for (const scenario of this.getActive()) { - scenario.settle('abandoned', { - reason: 'page-hidden', - metadata: { abandonedBy: 'page-hidden' } - }); - } - this.sink.flush(true); - }); - } } diff --git a/projects/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 22a781f..d0c0e7b 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 @@ -1,39 +1,11 @@ import { inject, Injectable, OnDestroy } from '@angular/core'; import { - uwtSafe, - uwtSafeVoid, - uwtSafeVoidMaybeAsync -} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -import { - UwtBroadcastConnection, - uwtConnectBroadcastChannel, - uwtElectBroadcastHostLeader, - uwtIsBroadcastMessage -} from './uwt-broadcast.messages'; -import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { uwtTelemetryEnabled } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; -import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; -import { UwtBroadcastLogApiProvider } from '../../providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; -import { - UWT_LOG_API_PROVIDER, - UwtLogQuery -} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import { uwtClassifyTelemetryEvent } from '../../utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; -import { uwtSessionElapsed } from '../../utils/uwt-session-start.util/uwt-session-start.util'; - -/** The identity fields scenario, BI and log records carry. */ -interface UwtIdentified { - sessionId?: string; - userId?: string; - sessionElapsed?: number; -} - -/** - * Active host count per channel, scoped to this JS realm — distinguishes a - * true duplicate provider from another tab sharing the same channel. - */ -const hostsInThisRealm = new Map(); + UwtTelemetryBroadcastHost as CoreTelemetryBroadcastHost, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { uwtDevModeReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { uwtBroadcastChannelOptions } from './uwt-broadcast.messages'; /** * Receives telemetry forwarded by follower realms and records it through this @@ -73,287 +45,21 @@ const hostsInThisRealm = new Map(); * @group Services */ @Injectable() -export class UwtTelemetryBroadcastHost implements OnDestroy { - private readonly sink = inject(UwtTelemetrySink); - private readonly logApi = inject(UWT_LOG_API_PROVIDER); - private readonly connection: UwtBroadcastConnection = - uwtConnectBroadcastChannel('broadcastHost'); - - /** Number of messages accepted, for tests and diagnostics. */ - private _received = 0; - - /** - * The session and user id last sent in an `identity` message. - * - * Compared against the sink's current values on every follower activity, so - * a late-resolving session id or user id gets announced once it exists. - */ - private lastAnnouncedSessionId?: string; - private lastAnnouncedUserId?: string; - - /** Whether this realm won the leader election; non-leaders stay passive. */ - private isLeader = false; - private releaseLeadership: () => void = () => undefined; - private readonly enabled = uwtTelemetryEnabled(); - +export class UwtTelemetryBroadcastHost + extends CoreTelemetryBroadcastHost + implements OnDestroy +{ constructor() { - if (!this.enabled) { - // Telemetry is off: no listening, no leader election. - return; - } - this.warnIfDuplicateInThisRealm(); - if (this.forwardsItsOwnRealm()) { - this.connection.close(); - return; - } - - this.connection.onMessage((data) => this.onMessage(data)); - this.releaseLeadership = uwtElectBroadcastHostLeader( - this.connection.channelName, - () => { - this.isLeader = true; - this.announceIdentity(); - } - ); - } - - /** How many follower messages have been accepted. */ - get received(): number { - return this._received; + super({ + sink: inject(UwtTelemetrySink), + logApi: inject(UWT_LOG_API_PROVIDER), + ...uwtBroadcastChannelOptions(), + report: uwtDevModeReporter + }); } /** @inheritdoc */ ngOnDestroy(): void { - if (this.enabled) { - this.forgetInThisRealm(); - } - this.releaseLeadership(); - this.connection.close(); - } - - private onMessage(data: unknown): void { - uwtSafeVoid('broadcastHost.receive', () => { - if (!this.isLeader || !uwtIsBroadcastMessage(data)) { - return; - } - - if ( - data.kind !== 'identity' && - data.kind !== 'identity-request' && - data.kind !== 'log-query-result' - ) { - this._received++; - } - - switch (data.kind) { - case 'event': - this.sink.record( - data.eventType, - this.stampEvent(data.eventType, data.payload), - data.metadata - ); - this.reannounceIfIdentityChanged(); - break; - case 'error': - this.sink.recordError(data.error, data.metadata); - this.reannounceIfIdentityChanged(); - break; - case 'user': - this.sink.setUserId(data.userId); - this.announceIdentity(); - break; - case 'flush': - this.sink.flush(data.beacon); - this.reannounceIfIdentityChanged(); - break; - case 'identity-request': - this.announceIdentity(); - break; - case 'identity': - // Another realm's host on this origin-wide channel — expected, - // not a problem. Same-document duplicates are caught by - // warnIfDuplicateInThisRealm() instead. - break; - case 'log': - this.deliverLog(this.withIdentity(data.record)); - this.reannounceIfIdentityChanged(); - break; - case 'log-flush': - uwtSafeVoidMaybeAsync('broadcastHost.logFlush', () => - this.logApi?.flush?.() - ); - break; - case 'log-query': - this.answerLogQuery(data.id, data.filter); - break; - case 'log-query-result': - // Hosts answer queries; they never ask them. - break; - } - }); - } - - /** - * A forwarded scenario or BI record with this realm's identity; any other - * payload as it is. - */ - private stampEvent(eventType: string, payload: object): object { - const { kind } = uwtClassifyTelemetryEvent(eventType, payload); - return kind === 'unknown' - ? payload - : this.withIdentity(payload as UwtIdentified, kind === 'scenario'); - } - - /** - * `record` with this realm's session and user id. A follower stamps the ids - * it was last told, which lag behind whenever they change here — the client - * starting after the follower asked, a session expiring, a sign-out — so its - * record could name a session other than the one it is sent in. With - * `measuresSession`, a changed session's `sessionElapsed` is measured again. - */ - private withIdentity( - record: T, - measuresSession = false - ): T { - const sessionId = this.currentSessionId(); - // '' is no user, as `setUserId('')` signs out. - const userId = this.currentUserId() || undefined; - if (record.sessionId === sessionId && record.userId === userId) { - return record; - } - // An id this realm doesn't have stays `undefined`, - // as in the library's own records. - const stamped: T = { ...record, sessionId, userId }; - if (measuresSession && record.sessionId !== sessionId) { - // The old session's value no longer applies; absent when unknown. - delete stamped.sessionElapsed; - const elapsed = uwtSessionElapsed(sessionId); - if (elapsed !== undefined) { - stamped.sessionElapsed = Math.round(elapsed); - } - } - return stamped; - } - - /** Hands a follower's record to this realm's log API provider. */ - private deliverLog(record: UwtLogRecord): void { - uwtSafeVoidMaybeAsync('broadcastHost.log', () => this.logApi.send(record)); - } - - /** - * Answers a follower's query from this realm's log API provider. Always - * answers — `[]` when there is no provider or it fails — so the follower - * isn't left waiting for its timeout. - */ - private answerLogQuery(id: string, filter: UwtLogQuery): void { - const reply = (records: unknown) => - this.connection.post({ - kind: 'log-query-result', - id, - records: Array.isArray(records) ? records : [] - }); - - const pending = uwtSafe( - 'broadcastHost.logQuery', - () => this.logApi.query(filter), - undefined - ); - - if (!pending) { - reply([]); - return; - } - // Promise.resolve: a provider breaking its contract with a plain value - // still gets an answer out. - Promise.resolve(pending).then(reply, () => reply([])); - } - - /** - * Whether this realm's own sink or log provider forwards over the - * channel. Hosting then would loop: the host receives its own realm's - * messages and hands them straight back to the channel. - */ - private forwardsItsOwnRealm(): boolean { - const forwards = - this.sink instanceof UwtBroadcastTelemetrySink || - this.logApi instanceof UwtBroadcastLogApiProvider; - - if (forwards) { - uwtSafeVoid('broadcastHost.forwardingRealmWarning', () => { - console.warn( - `[ngx-ui-watchtower] this realm forwards its own telemetry on channel "${this.connection.channelName}", so it cannot also host it; the host stays inactive. Provide the host only in the realm with the real sink and log provider` - ); - }); - } - return forwards; - } - - /** - * Warns when another host is already active on the same channel in this - * realm — a real misconfiguration. Not based on the `identity` broadcast, - * which can't distinguish this from a different, legitimate tab. - */ - private warnIfDuplicateInThisRealm(): void { - const channelName = this.connection.channelName; - const activeCount = hostsInThisRealm.get(channelName) ?? 0; - - if (activeCount > 0) { - uwtSafeVoid('broadcastHost.duplicateWarning', () => { - console.warn( - `[ngx-ui-watchtower] a second telemetry host is active on channel "${channelName}" in this document; only one realm should provide it` - ); - }); - } - - hostsInThisRealm.set(channelName, activeCount + 1); - } - - /** Releases this host's slot in {@link hostsInThisRealm}. */ - private forgetInThisRealm(): void { - const channelName = this.connection.channelName; - const activeCount = hostsInThisRealm.get(channelName) ?? 0; - - if (activeCount <= 1) { - hostsInThisRealm.delete(channelName); - } else { - hostsInThisRealm.set(channelName, activeCount - 1); - } - } - - private announceIdentity(): void { - const sessionId = this.currentSessionId(); - const userId = this.currentUserId(); - this.lastAnnouncedSessionId = sessionId; - this.lastAnnouncedUserId = userId; - this.connection.post({ kind: 'identity', sessionId, userId }); - } - - /** - * Re-announces identity when the sink's session id or user id has moved on - * from what followers were last told. - */ - private reannounceIfIdentityChanged(): void { - if ( - this.currentSessionId() !== this.lastAnnouncedSessionId || - this.currentUserId() !== this.lastAnnouncedUserId - ) { - this.announceIdentity(); - } - } - - private currentSessionId(): string | undefined { - return uwtSafe( - 'broadcastHost.getSessionId', - () => this.sink.getSessionId(), - undefined - ); - } - - private currentUserId(): string | undefined { - return uwtSafe( - 'broadcastHost.getUserId', - () => this.sink.getUserId(), - undefined - ); + this.dispose(); } } diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts index dff59f5..9ba3775 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts @@ -1,16 +1,8 @@ import { inject, Injectable, OnDestroy } from '@angular/core'; -import { - UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtBroadcastTelemetrySink as CoreBroadcastTelemetrySink } from '@absaoss-cps/ui-watchtower'; import { UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; -import { uwtSafeVoid } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -import { - UwtBroadcastConnection, - uwtConnectBroadcastChannel, - uwtIsBroadcastMessage -} from './uwt-broadcast.messages'; -import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { uwtDevModeReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { uwtBroadcastConnectionOptions } from './uwt-broadcast.messages'; /** * Telemetry sink for a follower realm — a micro-frontend fragment, a webview @@ -34,99 +26,19 @@ import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/u */ @Injectable() export class UwtBroadcastTelemetrySink - extends UwtTelemetrySink + extends CoreBroadcastTelemetrySink implements OnDestroy { - private readonly connection: UwtBroadcastConnection = - uwtConnectBroadcastChannel('broadcastSink'); - - /** The host's session id, once it has answered. */ - private sessionId?: string; - private userId?: string; - - private readonly config = inject(UWT_TELEMETRY_IDENTITY); - - /** - * This realm's own identity, stamped onto everything it forwards. - * - * The host records through its own RUM client, so without this, forwarded - * events would all be labelled as the host's realm. - */ - private readonly origin = { - application: this.config.application, - environment: this.config.environment, - appVersion: this.config.version - }; - constructor() { - super(); - - this.connection.onMessage((data) => this.onMessage(data)); - this.connection.post({ kind: 'identity-request' }); - } - - /** @inheritdoc */ - record( - eventType: string, - payload: object, - metadata?: UwtTelemetryMetadata - ): void { - this.connection.post({ - kind: 'event', - eventType, - payload, - metadata: { ...metadata, ...this.origin } - }); - } - - /** @inheritdoc */ - recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata): void { - this.connection.post({ - kind: 'error', - error, - metadata: { ...metadata, ...this.origin } + super({ + identity: inject(UWT_TELEMETRY_IDENTITY), + ...uwtBroadcastConnectionOptions(), + report: uwtDevModeReporter }); } - /** - * The host's session id. - * - * Returns `undefined` until the host answers. - * - * @returns the shared session id, when known - */ - getSessionId(): string | undefined { - return this.sessionId; - } - - /** @inheritdoc */ - setUserId(userId: string | undefined): void { - this.userId = userId; - this.connection.post({ kind: 'user', userId }); - } - - /** @inheritdoc */ - getUserId(): string | undefined { - return this.userId; - } - - /** @inheritdoc */ - flush(beacon = false): void { - this.connection.post({ kind: 'flush', beacon }); - } - /** @inheritdoc */ ngOnDestroy(): void { - this.connection.close(); - } - - private onMessage(data: unknown): void { - uwtSafeVoid('broadcastSink.receive', () => { - if (!uwtIsBroadcastMessage(data) || data.kind !== 'identity') { - return; - } - this.sessionId = data.sessionId; - this.userId = data.userId; - }); + this.dispose(); } } diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts index de09f92..8e3f80e 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts @@ -1,25 +1,16 @@ -import { inject, InjectionToken } from '@angular/core'; +import { inject, Injectable, InjectionToken, OnDestroy } from '@angular/core'; import { - UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { - UWT_LOG_LEVEL_ORDER, - UwtLogRecord -} from '../../models/uwt-log.models/uwt-log.models'; -import { UwtLogQuery } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; + UWT_DEFAULT_BROADCAST_CHANNEL, + UwtBroadcastConnection, + uwtConnectBroadcastChannel as coreConnectBroadcastChannel +} from '@absaoss-cps/ui-watchtower'; import { uwtTelemetryEnabled } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { - uwtIsBrowser, - uwtSafeVoid + uwtDevModeReporter, + uwtIsBrowser } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -/** - * Default `BroadcastChannel` name used between telemetry realms. - * - * @group Utils - */ -export const UWT_DEFAULT_BROADCAST_CHANNEL = 'ngx-ui-watchtower'; +export { UWT_DEFAULT_BROADCAST_CHANNEL } from '@absaoss-cps/ui-watchtower'; /** * Overrides the channel name shared by the host and its followers. @@ -34,356 +25,65 @@ export const UWT_BROADCAST_CHANNEL = new InjectionToken( ); /** - * Everything that travels between telemetry realms. - * - * Structured-cloned by `BroadcastChannel`, so every payload must be plain - * data. + * Where this injector's realm connects: the channel name — the + * {@link UWT_BROADCAST_CHANNEL} override, or the default — and whether a + * channel may open at all: only in a browser, while telemetry is on. Must be + * called from an injection context. */ -export type UwtBroadcastMessage = - | { - kind: 'event'; - eventType: string; - payload: object; - metadata?: UwtTelemetryMetadata; - } - | { - kind: 'error'; - error: UwtTelemetryError; - metadata?: UwtTelemetryMetadata; - } - | { kind: 'user'; userId: string | undefined } - | { kind: 'flush'; beacon: boolean } - | { kind: 'identity-request' } - /** The host announcing shared identity. Always sends both fields, even when only one changed. */ - | { kind: 'identity'; sessionId?: string; userId?: string } - /** A follower's log record, for the host to hand to its log API provider. */ - | { kind: 'log'; record: UwtLogRecord } - /** A follower asking the host to flush its log API provider. */ - | { kind: 'log-flush' } - /** A follower reading records back through the host's log API provider. */ - | { kind: 'log-query'; id: string; filter: UwtLogQuery } - /** - * The host's answer to one `log-query`, matched by `id`. The records come - * from the host's backend unchecked; the asking follower keeps only those - * that pass {@link uwtIsBroadcastLogRecord}. - */ - | { kind: 'log-query-result'; id: string; records: unknown[] }; - -/** Every `kind` the union above accepts, for {@link uwtIsBroadcastMessage}. */ -const MESSAGE_KINDS: ReadonlySet = new Set([ - 'event', - 'error', - 'user', - 'flush', - 'identity-request', - 'identity', - 'log', - 'log-flush', - 'log-query', - 'log-query-result' -] as const); - -const LOG_LEVELS: ReadonlySet = new Set( - Object.keys(UWT_LOG_LEVEL_ORDER) -); - -/** Minimal `BroadcastChannel` surface this library relies on. */ -export interface UwtBroadcastChannelLike { - postMessage(message: unknown): void; - close(): void; - onmessage: ((event: { data: unknown }) => void) | null; -} - -/** - * Opens a broadcast channel, when the browser provides the API. - * - * Feature-detected: unavailable under jsdom (tests) and in server-side - * rendering. - */ -export function uwtOpenBroadcastChannel( - name: string -): UwtBroadcastChannelLike | undefined { - const Channel = ( - globalThis as { - BroadcastChannel?: new (name: string) => UwtBroadcastChannelLike; - } - ).BroadcastChannel; - - if (typeof Channel !== 'function') { - return undefined; - } - - try { - return new Channel(name); - } catch { - return undefined; - } -} - -/** Minimal Web Locks API surface this library relies on. */ -interface UwtLockManagerLike { - request(name: string, callback: () => Promise): Promise; -} - -/** - * Elects exactly one leader among same-origin realms on this channel, via - * the Web Locks API — prevents two shell tabs from both recording every - * forwarded message. Fails open (elects immediately) when Web Locks is - * unavailable. - * - * @returns releases the lock so the next queued realm can become leader. - */ -export function uwtElectBroadcastHostLeader( - channelName: string, - onElected: () => void -): () => void { - const locks = (globalThis as { navigator?: { locks?: UwtLockManagerLike } }) - .navigator?.locks; - - if (!locks) { - onElected(); - return () => undefined; - } - - let released = false; - let release: () => void = () => { - released = true; - }; - - const failOpen = () => { - if (!released) { - onElected(); - } +export function uwtBroadcastChannelOptions(): { + channelName: string; + enabled: boolean; +} { + return { + channelName: + inject(UWT_BROADCAST_CHANNEL, { optional: true }) ?? + UWT_DEFAULT_BROADCAST_CHANNEL, + enabled: uwtIsBrowser() && uwtTelemetryEnabled() }; - - try { - locks - .request( - `ngx-ui-watchtower-host:${channelName}`, - () => - new Promise((resolve) => { - if (released) { - resolve(); - return; - } - release = () => { - released = true; - resolve(); - }; - onElected(); - }) - ) - .catch(failOpen); - } catch { - failOpen(); - } - - return () => release(); -} - -/** A realm's live connection to the shared broadcast channel. */ -export interface UwtBroadcastConnection { - /** The channel name actually in use — the injected override, or the default. */ - readonly channelName: string; - - /** Whether a channel is open — false without `BroadcastChannel`, on the server, and once closed. */ - isOpen(): boolean; - - /** Sends a message to every other realm on this channel. No-op if unavailable. */ - post(message: UwtBroadcastMessage): void; - - /** Registers the handler for incoming messages. No-op if the channel could not be opened. */ - onMessage(handler: (data: unknown) => void): void; - - /** Closes the channel and stops any further delivery. Safe to call more than once. */ - close(): void; } /** - * Opens this realm's connection to the shared broadcast channel: resolves the - * channel name, feature-detects `BroadcastChannel`, and wraps every operation - * in the library's fail-open guard. - * - * Must be called from an injection context (a field initializer or a - * constructor). + * Opens this realm's connection to the shared broadcast channel, configured + * from this injector, reporting through the dev-mode reporter. Must be called + * from an injection context (a field initializer or a constructor). */ export function uwtConnectBroadcastChannel( operation: string ): UwtBroadcastConnection { - const channelName = - inject(UWT_BROADCAST_CHANNEL, { optional: true }) ?? - UWT_DEFAULT_BROADCAST_CHANNEL; - // No channel on the server or while telemetry is off: every call is a no-op. - let channel = - uwtIsBrowser() && uwtTelemetryEnabled() - ? uwtOpenBroadcastChannel(channelName) - : undefined; - - return { + const { channelName, enabled } = uwtBroadcastChannelOptions(); + return coreConnectBroadcastChannel({ channelName, - isOpen() { - return channel !== undefined; - }, - post(message) { - uwtSafeVoid(`${operation}.post`, () => channel?.postMessage(message)); - }, - onMessage(handler) { - if (channel) { - channel.onmessage = (event) => handler(event.data); - } - }, - close() { - uwtSafeVoid(`${operation}.close`, () => { - channel?.close(); - channel = undefined; - }); - } - }; + open: enabled, + operation, + report: uwtDevModeReporter + }); } /** - * Whether `value` is a valid `metadata` field: absent, or a non-array - * object whose values are all finite numbers, strings, booleans, or null — - * the same flat-primitives contract `uwtRedactMetadata` enforces on the way - * out. Without this, a same-origin sender (not necessarily this library's - * own sink) could post a nested object on the channel that reaches a - * receiving sink's own sanitizer — e.g. `UwtRumTelemetrySink.sanitize()` — - * unchecked, since that step trusts the shape rather than re-validating it. + * The one connection a realm's broadcast sink and log provider share: + * provided with them by `provideUwtTelemetrySink('broadcast')`, and closed + * with their injector. Internal. */ -function isValidBroadcastMetadata(value: unknown): boolean { - if (value === undefined) { - return true; - } - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return false; - } - return Object.values(value).every( - (v) => - v === null || - typeof v === 'string' || - typeof v === 'boolean' || - (typeof v === 'number' && Number.isFinite(v)) - ); -} - -function isOptionalString(value: unknown): boolean { - return value === undefined || typeof value === 'string'; -} +@Injectable() +export class UwtBroadcastConnectionRef implements OnDestroy { + readonly connection = uwtConnectBroadcastChannel('broadcast'); -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isValidTelemetryError(value: unknown): boolean { - return ( - isRecord(value) && - typeof value.name === 'string' && - typeof value.message === 'string' && - isOptionalString(value.stack) - ); -} - -function isLogLevel(value: unknown): boolean { - return typeof value === 'string' && LOG_LEVELS.has(value); -} - -/** - * Whether `value` has the shape of a {@link UwtLogRecord}: the required - * strings, a known level, optional strings where the record has them, and - * metadata and error held to the same rules as on an `event` or `error` - * message. - */ -export function uwtIsBroadcastLogRecord(value: unknown): value is UwtLogRecord { - return ( - isRecord(value) && - typeof value.timestamp === 'string' && - isLogLevel(value.level) && - typeof value.message === 'string' && - typeof value.application === 'string' && - typeof value.environment === 'string' && - typeof value.version === 'string' && - isOptionalString(value.logger) && - isOptionalString(value.context) && - isOptionalString(value.correlationId) && - isOptionalString(value.userId) && - isOptionalString(value.sessionId) && - isValidBroadcastMetadata(value.metadata) && - (value.error === undefined || isValidTelemetryError(value.error)) - ); -} - -function isValidLogQuery(value: unknown): boolean { - return ( - isRecord(value) && - isOptionalString(value.correlationId) && - isOptionalString(value.logger) && - (value.minLevel === undefined || isLogLevel(value.minLevel)) && - isOptionalString(value.from) && - isOptionalString(value.to) && - (value.limit === undefined || - (typeof value.limit === 'number' && Number.isFinite(value.limit))) - ); + /** @inheritdoc */ + ngOnDestroy(): void { + this.connection.close(); + } } /** - * Narrows an incoming `BroadcastChannel` payload to a telemetry message. - * - * Checks each kind's required fields, not just `kind`, so a same-named - * message from something else on the channel is rejected. + * How a broadcast sink or log provider connects: through its injector's + * shared connection when there is one, on its own otherwise. Must be called + * from an injection context. */ -export function uwtIsBroadcastMessage( - data: unknown -): data is UwtBroadcastMessage { - if (typeof data !== 'object' || data === null) { - return false; - } - - const message = data as Record; - const kind = message.kind as UwtBroadcastMessage['kind']; - if (!MESSAGE_KINDS.has(kind)) { - return false; - } - - switch (kind) { - case 'event': - return ( - typeof message.eventType === 'string' && - typeof message.payload === 'object' && - message.payload !== null && - isValidBroadcastMetadata(message.metadata) - ); - case 'error': - return ( - isValidTelemetryError(message.error) && - isValidBroadcastMetadata(message.metadata) - ); - case 'flush': - return typeof message.beacon === 'boolean'; - case 'user': - // Absent or `undefined` userId both mean "clear it". - return message.userId === undefined || typeof message.userId === 'string'; - case 'identity': - return ( - (message.sessionId === undefined || - typeof message.sessionId === 'string') && - (message.userId === undefined || typeof message.userId === 'string') - ); - case 'identity-request': - case 'log-flush': - return true; - case 'log': - return uwtIsBroadcastLogRecord(message.record); - case 'log-query': - return ( - typeof message.id === 'string' && - message.id !== '' && - isValidLogQuery(message.filter) - ); - case 'log-query-result': - return ( - typeof message.id === 'string' && - message.id !== '' && - Array.isArray(message.records) - ); - } +export function uwtBroadcastConnectionOptions(): + | { connection: UwtBroadcastConnection } + | { channelName: string; enabled: boolean } { + const shared = inject(UwtBroadcastConnectionRef, { optional: true }); + return shared + ? { connection: shared.connection } + : uwtBroadcastChannelOptions(); } 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 bd4adfb..ea28aba 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 @@ -1,15 +1,19 @@ import { + UWT_DEFAULT_TELEMETRY_CONFIG, UWT_TELEMETRY_EVENT_TYPE, + UwtLogApiProvider, + UwtLogQuery, + UwtLogRecord, + uwtMarkSessionStart, UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { DOCUMENT } from '@angular/common'; import { Injectable, Injector, PLATFORM_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { UwtLoggerService } from '../../services/uwt-logger.service/uwt-logger.service'; import { - UWT_DEFAULT_TELEMETRY_CONFIG, UWT_REDACT_CONFIG, UWT_TELEMETRY_ENABLED, UWT_TELEMETRY_IDENTITY @@ -17,11 +21,7 @@ import { import { UWT_LOG_CONFIG } from '../../config/uwt-log.config/uwt-log.config'; import { UWT_SCENARIO_TELEMETRY_CONFIG } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; import { UwtScenarioTelemetryService } from '../../services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; -import { - UWT_LOG_API_PROVIDER, - UwtLogApiProvider, - UwtLogQuery -} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { UwtTelemetryBroadcastHost } from './uwt-broadcast-host.service'; import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; import { @@ -31,12 +31,8 @@ import { import { UWT_BROADCAST_CHANNEL, UWT_DEFAULT_BROADCAST_CHANNEL, - uwtConnectBroadcastChannel, - uwtElectBroadcastHostLeader, - uwtIsBroadcastMessage + uwtConnectBroadcastChannel } from './uwt-broadcast.messages'; -import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { uwtMarkSessionStart } from '../../utils/uwt-session-start.util/uwt-session-start.util'; /** * Each realm gets its own injector — the shell and every fragment run in a @@ -1089,327 +1085,6 @@ describe('broadcast telemetry across realms', () => { }); }); - describe('uwtElectBroadcastHostLeader', () => { - afterEach(() => LockManagerStub.uninstall()); - - it('should elect immediately when the Locks API is unavailable', () => { - const onElected = jest.fn(); - uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); - - expect(onElected).toHaveBeenCalledTimes(1); - }); - - it('should elect once the lock is granted', () => { - LockManagerStub.install(); - const onElected = jest.fn(); - uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); - - expect(onElected).toHaveBeenCalledTimes(1); - }); - - it('should fail open and still elect when request() itself rejects', async () => { - Object.defineProperty(globalThis.navigator, 'locks', { - value: { request: () => Promise.reject(new Error('not-fully-active')) }, - configurable: true - }); - const onElected = jest.fn(); - - uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); - // Let the rejection's microtask settle. - await Promise.resolve(); - await Promise.resolve(); - - expect(onElected).toHaveBeenCalledTimes(1); - }); - - it('should fail open and still elect when request() itself throws synchronously', () => { - Object.defineProperty(globalThis.navigator, 'locks', { - value: { - request: () => { - throw new Error('locks unavailable in this context'); - } - }, - configurable: true - }); - const onElected = jest.fn(); - - expect(() => - uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected) - ).not.toThrow(); - expect(onElected).toHaveBeenCalledTimes(1); - }); - - it('should not elect a requester released while still queued, and should let the next requester through', async () => { - LockManagerStub.install(); - const onElectedHolder = jest.fn(); - const onElectedA = jest.fn(); - const onElectedB = jest.fn(); - - const releaseHolder = uwtElectBroadcastHostLeader( - 'ngx-ui-watchtower', - onElectedHolder - ); - const releaseA = uwtElectBroadcastHostLeader( - 'ngx-ui-watchtower', - onElectedA - ); - uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElectedB); - - // A is released before ever being granted the lock. - releaseA(); - // The current holder releases, so the lock passes down the queue. - releaseHolder(); - for (let i = 0; i < 6; i++) { - await Promise.resolve(); - } - - expect(onElectedA).not.toHaveBeenCalled(); - expect(onElectedB).toHaveBeenCalledTimes(1); - }); - }); - - describe('uwtIsBroadcastMessage', () => { - it.each([null, undefined, 'string', 42, ['array']])( - 'should reject the non-object payload %p', - (data) => { - expect(uwtIsBroadcastMessage(data)).toBe(false); - } - ); - - it('should reject a payload with an unknown kind', () => { - expect(uwtIsBroadcastMessage({ kind: 'other library' })).toBe(false); - }); - - it('should accept a well-formed event message', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'event', - eventType: 'com.uwt.bi', - payload: { eventName: 'x' } - }) - ).toBe(true); - }); - - it.each([ - { eventType: 'com.uwt.bi' }, - { payload: {} }, - { eventType: 1, payload: {} }, - { eventType: 'com.uwt.bi', payload: 'not-an-object' }, - { - eventType: 'com.uwt.bi', - payload: {}, - metadata: { nested: {} } - }, - { - eventType: 'com.uwt.bi', - payload: {}, - metadata: { list: [1, 2] } - }, - { - eventType: 'com.uwt.bi', - payload: {}, - metadata: 'not-an-object' - } - ])('should reject a malformed event message %p', (fields) => { - expect(uwtIsBroadcastMessage({ kind: 'event', ...fields })).toBe(false); - }); - - it('should accept an event message with flat-primitive metadata', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'event', - eventType: 'com.uwt.bi', - payload: {}, - metadata: { count: 3, ok: true, note: 'x', blank: null } - }) - ).toBe(true); - }); - - it('should accept an event message with no metadata', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'event', - eventType: 'com.uwt.bi', - payload: {} - }) - ).toBe(true); - }); - - it('should accept a well-formed error message', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'error', - error: { name: 'Error', message: 'boom' } - }) - ).toBe(true); - }); - - it.each([ - {}, - { error: { name: 'Error' } }, - { error: { message: 'boom' } }, - { error: 'boom' }, - { error: { name: 'Error', message: 'boom', stack: 42 } }, - { - error: { name: 'Error', message: 'boom' }, - metadata: { nested: {} } - } - ])('should reject a malformed error message %p', (fields) => { - expect(uwtIsBroadcastMessage({ kind: 'error', ...fields })).toBe(false); - }); - - it('should accept an error message with flat-primitive metadata', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'error', - error: { name: 'Error', message: 'boom' }, - metadata: { origin: 'checkout' } - }) - ).toBe(true); - }); - - it('should accept an error message with a string stack', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'error', - error: { name: 'Error', message: 'boom', stack: 'at foo.ts:1' } - }) - ).toBe(true); - }); - - it('should accept a well-formed flush message', () => { - expect(uwtIsBroadcastMessage({ kind: 'flush', beacon: true })).toBe(true); - }); - - it('should reject a flush message with a non-boolean beacon', () => { - expect(uwtIsBroadcastMessage({ kind: 'flush', beacon: 'true' })).toBe( - false - ); - }); - - it.each([{ userId: 'u-1' }, { userId: undefined }, {}])( - 'should accept a well-formed user message %p', - (fields) => { - expect(uwtIsBroadcastMessage({ kind: 'user', ...fields })).toBe(true); - } - ); - - it('should reject a user message with a non-string, non-undefined userId', () => { - expect(uwtIsBroadcastMessage({ kind: 'user', userId: 42 })).toBe(false); - }); - - const logRecord = { - timestamp: '2026-01-01T00:00:00.000Z', - level: 'warn', - message: 'Careful', - application: 'cart', - environment: 'test', - version: '1.0.0' - }; - - it('should accept a well-formed log message', () => { - expect(uwtIsBroadcastMessage({ kind: 'log', record: logRecord })).toBe( - true - ); - expect( - uwtIsBroadcastMessage({ - kind: 'log', - record: { - ...logRecord, - logger: 'checkout', - context: 'Cart', - correlationId: 'c-1', - sessionId: 's', - userId: 'u', - metadata: { count: 1 }, - error: { name: 'Error', message: 'boom' } - } - }) - ).toBe(true); - }); - - it.each([ - { level: 'debug' }, - { level: 'toString' }, - { application: undefined }, - { message: 42 }, - { logger: 7 }, - { metadata: { nested: {} } }, - { error: { name: 'Error' } } - ])('should reject a log message whose record has %p', (fields) => { - expect( - uwtIsBroadcastMessage({ - kind: 'log', - record: { ...logRecord, ...fields } - }) - ).toBe(false); - }); - - it('should accept log-flush, log-query and log-query-result messages', () => { - expect(uwtIsBroadcastMessage({ kind: 'log-flush' })).toBe(true); - expect( - uwtIsBroadcastMessage({ - kind: 'log-query', - id: 'q-1', - filter: { correlationId: 'c-1', minLevel: 'warn', limit: 5 } - }) - ).toBe(true); - expect( - uwtIsBroadcastMessage({ - kind: 'log-query-result', - id: 'q-1', - records: [] - }) - ).toBe(true); - }); - - it.each([ - { id: '', filter: {} }, - { id: 'q-1', filter: null }, - { id: 'q-1', filter: { limit: 'ten' } }, - { id: 'q-1', filter: { minLevel: 'verbose' } } - ])('should reject a malformed log-query message %p', (fields) => { - expect(uwtIsBroadcastMessage({ kind: 'log-query', ...fields })).toBe( - false - ); - }); - - it('should reject a log-query-result without a records array', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'log-query-result', - id: 'q-1', - records: 'none' - }) - ).toBe(false); - }); - - it('should accept an identity-request message with no other fields', () => { - expect(uwtIsBroadcastMessage({ kind: 'identity-request' })).toBe(true); - }); - - it.each([ - {}, - { sessionId: 's-1' }, - { userId: 'u-1' }, - { - sessionId: 's-1', - userId: 'u-1' - } - ])('should accept an identity message %p', (fields) => { - expect(uwtIsBroadcastMessage({ kind: 'identity', ...fields })).toBe(true); - }); - - it.each([{ sessionId: 42 }, { userId: 42 }, { sessionId: 42, userId: 42 }])( - 'should reject an identity message with a non-string, non-undefined field %p', - (fields) => { - expect(uwtIsBroadcastMessage({ kind: 'identity', ...fields })).toBe( - false - ); - } - ); - }); - describe('uwtConnectBroadcastChannel', () => { it('should default the channel name to UWT_DEFAULT_BROADCAST_CHANNEL', () => { const connection = TestBed.runInInjectionContext(() => diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json new file mode 100644 index 0000000..116f843 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json @@ -0,0 +1,254 @@ +[ + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session", + "userId": "" + } + }, + { + "from": "fragment", + "message": { + "kind": "identity-request" + } + }, + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session", + "userId": "" + } + }, + { + "from": "fragment", + "message": { + "eventType": "com.uwt.scenario", + "kind": "event", + "metadata": { + "appVersion": "1.0.0", + "application": "realm", + "environment": "test" + }, + "payload": { + "aggregates": "", + "application": "realm", + "delta": "", + "elapsed": "", + "endTime": "", + "feature": "", + "metadata": { + "cart": "c-1", + "items": 2 + }, + "operation": "", + "parentScenarioId": "", + "previousStep": "pay", + "route": "", + "scenarioId": "", + "scenarioName": "checkout", + "sessionId": "shell-session", + "startTime": "", + "status": "success", + "stepCount": 1, + "steps": [ + { + "elapsed": "", + "endOffset": "", + "name": "scenario-start", + "startOffset": "", + "status": "success", + "stepDelta": "" + }, + { + "elapsed": "", + "endOffset": "", + "metadata": { + "method": "card" + }, + "name": "pay", + "startOffset": "", + "status": "success", + "stepDelta": "" + }, + { + "elapsed": "", + "endOffset": "", + "metadata": { + "items": 2 + }, + "name": "scenario-end", + "startOffset": "", + "status": "success", + "stepDelta": "" + } + ], + "userId": "" + } + } + }, + { + "from": "fragment", + "message": { + "eventType": "com.uwt.bi", + "kind": "event", + "metadata": { + "appVersion": "1.0.0", + "application": "realm", + "environment": "test" + }, + "payload": { + "application": "realm", + "eventName": "export_clicked", + "eventTime": "", + "feature": "", + "metadata": { + "format": "csv" + }, + "scenarioId": "", + "sessionId": "shell-session", + "userId": "" + } + } + }, + { + "from": "fragment", + "message": { + "kind": "log", + "record": { + "application": "realm", + "context": "", + "correlationId": "", + "environment": "test", + "error": "", + "level": "warn", + "logger": "checkout", + "message": "Slow payment", + "metadata": { + "ms": 900 + }, + "sessionId": "shell-session", + "timestamp": "", + "userId": "", + "version": "1.0.0" + } + } + }, + { + "from": "fragment", + "message": { + "kind": "log", + "record": { + "application": "realm", + "context": "", + "correlationId": "", + "environment": "test", + "error": { + "message": "Declined", + "name": "Error", + "stack": "" + }, + "level": "error", + "logger": "checkout", + "message": "Payment failed", + "metadata": "", + "sessionId": "shell-session", + "timestamp": "", + "userId": "", + "version": "1.0.0" + } + } + }, + { + "from": "fragment", + "message": { + "kind": "user", + "userId": "user-1" + } + }, + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session", + "userId": "user-1" + } + }, + { + "from": "fragment", + "message": { + "beacon": true, + "kind": "flush" + } + }, + { + "from": "fragment", + "message": { + "kind": "log-flush" + } + }, + { + "from": "fragment", + "message": { + "filter": { + "limit": 1, + "logger": "checkout" + }, + "id": "", + "kind": "log-query" + } + }, + { + "from": "shell", + "message": { + "id": "", + "kind": "log-query-result", + "records": [ + { + "application": "realm", + "environment": "test", + "level": "warn", + "logger": "checkout", + "message": "Slow payment", + "metadata": { + "ms": 900 + }, + "sessionId": "shell-session", + "timestamp": "", + "version": "1.0.0" + } + ] + } + }, + { + "from": "fragment", + "message": { + "eventType": "com.uwt.bi", + "kind": "event", + "metadata": { + "appVersion": "1.0.0", + "application": "realm", + "environment": "test" + }, + "payload": { + "application": "realm", + "eventName": "theme_changed", + "eventTime": "", + "feature": "", + "metadata": "", + "scenarioId": "", + "sessionId": "shell-session", + "userId": "user-1" + } + } + }, + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session-2", + "userId": "user-1" + } + } +] diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts new file mode 100644 index 0000000..2eb2305 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts @@ -0,0 +1,494 @@ +import { DOCUMENT } from '@angular/common'; +import { + createEnvironmentInjector, + EnvironmentInjector, + Injectable, + Injector, + PLATFORM_ID +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { + UwtBroadcastLogApiProvider as CoreBroadcastLogApiProvider, + UwtBroadcastTelemetrySink as CoreBroadcastTelemetrySink, + uwtConnectBroadcastChannel, + uwtCreateTelemetry, + UWT_DEFAULT_TELEMETRY_CONFIG, + UwtLogApiProvider, + UwtLogger, + UwtLogQuery, + UwtLogRecord, + UwtScenario, + UwtScenarioOptions, + UwtTelemetryBroadcastHost as CoreTelemetryBroadcastHost, + UwtTelemetryError, + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; +import { + UWT_REDACT_CONFIG, + UWT_TELEMETRY_IDENTITY +} from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UWT_BI_TELEMETRY_CONFIG } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; +import { UWT_LOG_CONFIG } from '../../config/uwt-log.config/uwt-log.config'; +import { UWT_SCENARIO_TELEMETRY_CONFIG } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtBroadcastLogApiProvider } from '../../providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; +import { + provideUwtTelemetry, + provideUwtTelemetrySink +} from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; +import { UwtBITelemetryService } from '../../services/uwt-bi-telemetry.service/uwt-bi-telemetry.service'; +import { UwtLoggerService } from '../../services/uwt-logger.service/uwt-logger.service'; +import { UwtScenarioTelemetryService } from '../../services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; +import { UwtTelemetryBroadcastHost } from './uwt-broadcast-host.service'; +import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; + +/** + * The broadcast protocol is a contract between realms deployed separately: + * a shell and its fragments may run different versions, and may be built + * with or without Angular. This records every message a shell and a fragment + * exchange through one full session, and checks it against the fixture + * recorded from the implementation before the core was extracted — for an + * Angular or a core-only realm on either side. Record it again only + * deliberately, with `UPDATE_WIRE_FIXTURE=1`. + */ +const FIXTURE = join(__dirname, 'uwt-broadcast.wire.fixture.json'); + +const IDENTITY = { + application: 'realm', + environment: 'test', + version: '1.0.0', + eventNamespace: 'com.uwt' +}; + +/** A message as posted: by which realm, and what it carried. */ +interface Posted { + from: string; + message: unknown; +} + +/** + * `BroadcastChannel` for the test, delivering asynchronously to every other + * instance of the same name, and keeping each message as posted. + */ +class WireChannel { + /** The realm the next channel belongs to. */ + static realm = ''; + static readonly posted: Posted[] = []; + private static readonly channels = new Map(); + + onmessage: ((event: { data: unknown }) => void) | null = null; + private readonly realm = WireChannel.realm; + private closed = false; + + constructor(readonly name: string) { + WireChannel.channels.set(name, [ + ...(WireChannel.channels.get(name) ?? []), + this + ]); + } + + static reset(): void { + WireChannel.posted.length = 0; + WireChannel.channels.clear(); + } + + /** The realms with a channel still open, one entry per channel. */ + static open(): string[] { + return [...WireChannel.channels.values()].flat().map((c) => c.realm); + } + + postMessage(message: unknown): void { + if (this.closed) { + return; + } + WireChannel.posted.push({ from: this.realm, message: plain(message) }); + for (const peer of WireChannel.channels.get(this.name) ?? []) { + if (peer !== this) { + const data = JSON.parse(JSON.stringify(message)); + setTimeout(() => !peer.closed && peer.onmessage?.({ data }), 0); + } + } + } + + close(): void { + this.closed = true; + WireChannel.channels.set( + this.name, + (WireChannel.channels.get(this.name) ?? []).filter((c) => c !== this) + ); + } +} + +/** + * A message as structured clone carries it: keys in a stable order, and a + * key whose value is `undefined` kept, as clone keeps it and JSON doesn't. + */ +function plain(value: unknown): unknown { + if (value === undefined) { + return ''; + } + if (Array.isArray(value)) { + return value.map(plain); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, plain((value as Record)[key])]) + ); + } + return value; +} + +/** + * Fields whose value is a time, an offset or a duration — `eventTime`, + * `endOffset`, `stepDelta`, `sessionElapsed`… — which differ run to run. + */ +const TIMED = /(^|[a-z])(time|offset|delta|elapsed|duration|timestamp)$/i; + +/** A stack trace names files and lines, which differ by machine and by edit. */ +const VARIES = (key: string): boolean => TIMED.test(key) || key === 'stack'; + +/** + * What can differ between runs made comparable: ids numbered by first + * appearance, and times, durations and stack traces by their type. + */ +function stable(posted: Posted[]): Posted[] { + const uuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const ids = new Map(); + const walk = (value: unknown, key = ''): unknown => { + if (VARIES(key) && value !== '') { + return `<${typeof value}>`; + } + if (typeof value === 'string' && uuid.test(value)) { + if (!ids.has(value)) { + ids.set(value, ``); + } + return ids.get(value); + } + if (Array.isArray(value)) { + return value.map((v) => walk(v)); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, walk(v, k)]) + ); + } + return value; + }; + return walk(posted) as Posted[]; +} + +/** The shell's real destination, with a session and user it controls. */ +@Injectable() +class ShellSink extends UwtTelemetrySink { + readonly events: string[] = []; + sessionId: string | undefined = 'shell-session'; + userId?: string; + + record(eventType: string, _payload: object, _meta?: UwtTelemetryMetadata) { + this.events.push(eventType); + } + + recordError(_error: UwtTelemetryError, _meta?: UwtTelemetryMetadata) { + // Recorded on the wire, not here. + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(): void { + // Nothing buffered. + } +} + +/** The shell's log backend, answering queries with what it has. */ +@Injectable() +class ShellLogApi implements UwtLogApiProvider { + readonly records: UwtLogRecord[] = []; + + send(record: UwtLogRecord): void { + this.records.push(record); + } + + query(filter: UwtLogQuery): Promise { + return Promise.resolve( + this.records + .filter((r) => !filter.logger || r.logger === filter.logger) + .slice(0, filter.limit) + ); + } + + flush(): void { + // Nothing buffered. + } +} + +/** A shell: its real destination, and how it goes away. */ +interface Shell { + sink: ShellSink; + destroy(): void; +} + +/** A fragment, whatever it is built with: what its application code calls. */ +interface Fragment { + start(options: UwtScenarioOptions): UwtScenario; + track(eventName: string, metadata?: UwtTelemetryMetadata): void; + getLogger(name: string): UwtLogger; + sink: UwtTelemetrySink; + logApi: UwtLogApiProvider; + destroy(): void; +} + +/** An Angular realm: its own injector, as the shell and each fragment have. */ +function createRealm(providers: unknown[]): Injector { + return Injector.create({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: DOCUMENT, useValue: document }, + { provide: UWT_TELEMETRY_IDENTITY, useValue: IDENTITY }, + { + provide: UWT_SCENARIO_TELEMETRY_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.scenario + }, + { provide: UWT_LOG_CONFIG, useValue: UWT_DEFAULT_TELEMETRY_CONFIG.logs }, + { + provide: UWT_BI_TELEMETRY_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.bi + }, + { + provide: UWT_REDACT_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.redact + }, + UwtLoggerService, + UwtScenarioTelemetryService, + UwtBITelemetryService, + ...(providers as never[]) + ] + }); +} + +/** What application code calls in an Angular fragment. */ +function angularFragmentOf(injector: Injector, destroy: () => void): Fragment { + const scenarios = injector.get(UwtScenarioTelemetryService); + const bi = injector.get(UwtBITelemetryService); + const logs = injector.get(UwtLoggerService); + return { + start: (options) => scenarios.start(options), + track: (eventName, metadata) => bi.track(eventName, metadata), + getLogger: (name) => logs.getLogger(name), + sink: injector.get(UwtTelemetrySink), + logApi: injector.get(UWT_LOG_API_PROVIDER), + destroy + }; +} + +const shells: Record Shell> = { + 'an Angular shell': () => { + const shell = createRealm([ + ShellSink, + { provide: UwtTelemetrySink, useExisting: ShellSink }, + ShellLogApi, + { provide: UWT_LOG_API_PROVIDER, useExisting: ShellLogApi }, + UwtTelemetryBroadcastHost + ]); + const host = shell.get(UwtTelemetryBroadcastHost); + return { sink: shell.get(ShellSink), destroy: () => host.ngOnDestroy() }; + }, + 'a core shell': () => { + const sink = new ShellSink(); + const host = new CoreTelemetryBroadcastHost({ + sink, + logApi: new ShellLogApi() + }); + return { sink, destroy: () => host.dispose() }; + } +}; + +const fragments: Record Fragment> = { + // Each class opens its own channel, as before the extraction. + 'an Angular fragment': () => { + const realm = createRealm([ + UwtBroadcastTelemetrySink, + { provide: UwtTelemetrySink, useExisting: UwtBroadcastTelemetrySink }, + UwtBroadcastLogApiProvider, + { provide: UWT_LOG_API_PROVIDER, useExisting: UwtBroadcastLogApiProvider } + ]); + return angularFragmentOf(realm, () => { + realm.get(UwtBroadcastTelemetrySink).ngOnDestroy(); + realm.get(UwtBroadcastLogApiProvider).ngOnDestroy(); + }); + }, + // Wired by provideUwtTelemetrySink: one channel, shared. + 'an Angular fragment sharing one channel': () => { + const injector = createEnvironmentInjector( + [ + provideUwtTelemetry(IDENTITY), + provideUwtTelemetrySink('broadcast'), + UwtScenarioTelemetryService, + UwtBITelemetryService, + UwtLoggerService + ], + TestBed.inject(EnvironmentInjector) + ); + return angularFragmentOf(injector, () => injector.destroy()); + }, + 'a core-only fragment': () => { + const connection = uwtConnectBroadcastChannel(); + const sink = new CoreBroadcastTelemetrySink({ + identity: IDENTITY, + connection + }); + const logApi = new CoreBroadcastLogApiProvider({ connection }); + const telemetry = uwtCreateTelemetry({ + identity: IDENTITY, + destination: sink, + logApi + }); + return { + start: (options) => telemetry.scenarios.start(options), + track: (eventName, metadata) => telemetry.bi.track(eventName, metadata), + getLogger: (name) => telemetry.logging.getLogger(name), + sink, + logApi, + destroy: () => { + telemetry.dispose(); + sink.dispose(); + logApi.dispose(); + connection.close(); + } + }; + } +}; + +describe('the broadcast wire protocol', () => { + beforeEach(() => { + localStorage.clear(); + WireChannel.reset(); + Object.defineProperty(globalThis, 'BroadcastChannel', { + value: WireChannel, + configurable: true, + writable: true + }); + }); + + afterEach(() => { + delete (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel; + }); + + /** Lets every delivery, and every reply it causes, arrive. */ + async function settle(): Promise { + for (let turn = 0; turn < 6; turn++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + /** A shell and a fragment, each in its own realm. */ + async function connect(shellKind: string, fragmentKind: string) { + WireChannel.realm = 'shell'; + const shell = shells[shellKind](); + WireChannel.realm = 'fragment'; + const fragment = fragments[fragmentKind](); + await settle(); + return { shell, fragment }; + } + + const pairs = [ + ['an Angular shell', 'an Angular fragment'], + ['an Angular shell', 'an Angular fragment sharing one channel'], + ['an Angular shell', 'a core-only fragment'], + ['a core shell', 'an Angular fragment'], + ['a core shell', 'an Angular fragment sharing one channel'], + ['a core shell', 'a core-only fragment'] + ]; + + it.each(pairs)( + 'should carry what %s and %s exchange, unchanged', + async (shellKind, fragmentKind) => { + const { shell, fragment } = await connect(shellKind, fragmentKind); + + // A scenario with a step, a business event, logs and a mirrored error. + const scenario = fragment.start({ + name: 'checkout', + metadata: { cart: 'c-1' } + }); + scenario.step('pay', { method: 'card' }); + scenario.endStep(); + scenario.complete({ metadata: { items: 2 } }); + fragment.track('export_clicked', { format: 'csv' }); + const logger = fragment.getLogger('checkout'); + logger.warn('Slow payment', { metadata: { ms: 900 } }); + logger.error('Payment failed', { error: new Error('Declined') }); + await settle(); + + // A sign-in, flushes, and a query answered by the shell's backend. + fragment.sink.setUserId('user-1'); + await settle(); + fragment.sink.flush(true); + fragment.logApi.flush?.(); + const found = fragment.logApi.query({ logger: 'checkout', limit: 1 }); + await settle(); + expect(await found).toHaveLength(1); + + // The shell's session moves on: the next record is stamped with it. + shell.sink.sessionId = 'shell-session-2'; + fragment.track('theme_changed'); + await settle(); + + shell.destroy(); + // The session, and the host going away, as the fixture recorded them. + const recorded = stable(WireChannel.posted); + fragment.destroy(); + if ( + process.env.UPDATE_WIRE_FIXTURE === '1' && + shellKind === pairs[0][0] && + fragmentKind === pairs[0][1] + ) { + writeFileSync(FIXTURE, JSON.stringify(recorded, null, 2) + '\n'); + } + expect(existsSync(FIXTURE)).toBe(true); + expect(recorded).toEqual(JSON.parse(readFileSync(FIXTURE, 'utf8'))); + expect(WireChannel.open()).toEqual([]); + } + ); + + it.each(Object.keys(fragments))( + 'should send nothing twice, and leave no channel open, when %s is remounted', + async (fragmentKind) => { + const { shell, fragment } = await connect( + 'an Angular shell', + fragmentKind + ); + + fragment.track('export_clicked'); + const pending = fragment.logApi.query({}); + fragment.destroy(); + expect(await pending).toEqual([]); + expect(WireChannel.open()).toEqual(['shell']); + + WireChannel.realm = 'fragment'; + const remounted = fragments[fragmentKind](); + await settle(); + remounted.track('theme_changed'); + await settle(); + + expect(shell.sink.events).toEqual(['com.uwt.bi', 'com.uwt.bi']); + remounted.destroy(); + shell.destroy(); + expect(WireChannel.open()).toEqual([]); + } + ); +}); diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts index 0b6f76d..98a6dea 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts @@ -1,9 +1,5 @@ import { Injectable } from '@angular/core'; -import { - UwtTelemetryError, - UwtTelemetryMetadata -} from '../../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { UwtTelemetrySink } from '../uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UwtNoopTelemetrySink as CoreNoopTelemetrySink } from '@absaoss-cps/ui-watchtower'; /** * Sink that discards everything. @@ -15,37 +11,4 @@ import { UwtTelemetrySink } from '../uwt-telemetry-abstract.sink/uwt-telemetry-a * @group Services */ @Injectable() -export class UwtNoopTelemetrySink extends UwtTelemetrySink { - private userId?: string; - - /** @inheritdoc */ - record( - _eventType: string, - _payload: object, - _metadata?: UwtTelemetryMetadata - ): void {} - - /** @inheritdoc */ - recordError( - _error: UwtTelemetryError, - _metadata?: UwtTelemetryMetadata - ): void {} - - /** @inheritdoc */ - getSessionId(): string | undefined { - return undefined; - } - - /** @inheritdoc */ - setUserId(userId: string | undefined): void { - this.userId = userId; - } - - /** @inheritdoc */ - getUserId(): string | undefined { - return this.userId; - } - - /** @inheritdoc */ - flush(_beacon?: boolean): void {} -} +export class UwtNoopTelemetrySink extends CoreNoopTelemetrySink {} diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts index 5ed74a9..fa3e63b 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts @@ -1,800 +1,100 @@ +import { isDevMode } from '@angular/core'; +import * as core from '@absaoss-cps/ui-watchtower'; +import type {} from '@absaoss-cps/ui-watchtower'; import { - UWT_DEFAULT_REDACT_CONFIG, - UWT_REDACTED, - uwtMergeMetadata, uwtNormalizeError, - uwtRedactConfigFor, uwtRedactMetadata, uwtScrubString } from './uwt-telemetry-redact.util'; -describe('uwtScrubString', () => { - it('should strip the query string and fragment from an absolute URL', () => { - expect( - uwtScrubString( - 'https://api.example.com/customers?token=abc123#section', - UWT_DEFAULT_REDACT_CONFIG - ) - ).toBe('https://api.example.com/customers'); - }); - - it('should strip a URL embedded inside a longer message', () => { - expect( - uwtScrubString( - 'Request to https://api.example.com/v1/users?apiKey=secret failed', - UWT_DEFAULT_REDACT_CONFIG - ) - ).toBe('Request to https://api.example.com/v1/users failed'); - }); - - it('should strip the query string from a root-relative path', () => { - expect( - uwtScrubString('/customers?ssn=123-45-6789', UWT_DEFAULT_REDACT_CONFIG) - ).toBe('/customers'); - }); - - it('should strip a root-relative path embedded inside a longer message', () => { - expect( - uwtScrubString( - 'Request to /api/customers/search?email=john@example.com&ssn=123-45-6789 failed', - UWT_DEFAULT_REDACT_CONFIG - ) - ).toBe('Request to /api/customers/search failed'); - }); - - it('should strip more than one embedded root-relative path in the same string', () => { - expect( - uwtScrubString( - 'compare /a/x?p=1 against /b/y?q=2 now', - UWT_DEFAULT_REDACT_CONFIG - ) - ).toBe('compare /a/x against /b/y now'); - }); - - it('should strip a root-relative path with no space before it', () => { - expect( - uwtScrubString( - 'Redirected to:/dashboard?sessionToken=abc123', - UWT_DEFAULT_REDACT_CONFIG - ) - ).toBe('Redirected to:/dashboard'); - }); - - it('should leave a plain message that merely ends in a question mark intact', () => { - expect( - uwtScrubString('Could not load the data?', UWT_DEFAULT_REDACT_CONFIG) - ).toBe('Could not load the data?'); - }); - - it('should truncate strings beyond the configured cap', () => { - const result = uwtScrubString('x'.repeat(50), { - ...UWT_DEFAULT_REDACT_CONFIG, - maxStringLength: 10 - }); - expect(result).toBe(`${'x'.repeat(10)}…`); - }); - - it('should leave URLs alone when stripping is disabled', () => { - expect( - uwtScrubString('https://example.com/a?b=c', { - ...UWT_DEFAULT_REDACT_CONFIG, - stripUrlQuery: false - }) - ).toBe('https://example.com/a?b=c'); - }); - - describe('value-pattern scanning', () => { - it('should not scan for any value pattern by default', () => { - expect( - uwtScrubString( - 'contact john.smith@example.com, card 4111111111111111, ssn 123-45-6789', - UWT_DEFAULT_REDACT_CONFIG - ) - ).toBe( - 'contact john.smith@example.com, card 4111111111111111, ssn 123-45-6789' - ); - }); - - it('should redact an email address when enabled', () => { - expect( - uwtScrubString('contact john.smith@example.com for help', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['email'] - }) - ).toBe(`contact ${UWT_REDACTED} for help`); - }); - - it('should not match a bare @-handle with no domain as an email', () => { - expect( - uwtScrubString('cc @someuser on this', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['email'] - }) - ).toBe('cc @someuser on this'); - }); - - it('should catch an email embedded in a URL path segment', () => { - expect( - uwtScrubString('/customers/john.smith@example.com', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['email'] - }) - ).toBe(`/customers/${UWT_REDACTED}`); - }); - - it('should redact a Luhn-valid credit card number when enabled', () => { - expect( - uwtScrubString('card on file: 4111111111111111', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['creditCard'] - }) - ).toBe(`card on file: ${UWT_REDACTED}`); - }); - - it('should leave a 16-digit run alone when it fails the Luhn checksum', () => { - expect( - uwtScrubString('order number: 1234567890123456', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['creditCard'] - }) - ).toBe('order number: 1234567890123456'); - }); - - it('should redact a well-formatted SSN when enabled', () => { - expect( - uwtScrubString('ssn on file: 123-45-6789', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['ssn'] - }) - ).toBe(`ssn on file: ${UWT_REDACTED}`); - }); - - it('should not match a bare 9-digit run with no dashes as an SSN', () => { - expect( - uwtScrubString('reference 123456789', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['ssn'] - }) - ).toBe('reference 123456789'); - }); - - it('should redact an IPv4 address when enabled', () => { - expect( - uwtScrubString('client at 192.168.1.1', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['ipv4'] - }) - ).toBe(`client at ${UWT_REDACTED}`); - }); - - it('should not match an out-of-range octet as an IPv4 address', () => { - expect( - uwtScrubString('version 999.999.999.999', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['ipv4'] - }) - ).toBe('version 999.999.999.999'); - }); - - it('should redact a US-shaped phone number when enabled', () => { - expect( - uwtScrubString('call (555) 123-4567', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['phone'] - }) - ).toBe(`call ${UWT_REDACTED}`); - }); - - it('should redact a South African phone number in international format when enabled', () => { - expect( - uwtScrubString('call +27 82 123 4567', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['phone'] - }) - ).toBe(`call ${UWT_REDACTED}`); - }); - - it('should redact a value matching an application-supplied pattern', () => { - expect( - uwtScrubString('internal ref ACC-98765', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValuePatterns: [/ACC-\d+/] - }) - ).toBe(`internal ref ${UWT_REDACTED}`); - }); - - it('should redact every occurrence even when the supplied pattern has no g flag', () => { - expect( - uwtScrubString('ref ACC-111 and also ref ACC-222', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValuePatterns: [/ACC-\d+/] - }) - ).toBe(`ref ${UWT_REDACTED} and also ref ${UWT_REDACTED}`); - }); - - it('should redact correctly across repeated calls reusing the same non-global pattern object', () => { - const pattern = /ACC-\d+/; - const config = { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValuePatterns: [pattern] - }; - - expect(uwtScrubString('ref ACC-111 and also ACC-222', config)).toBe( - `ref ${UWT_REDACTED} and also ${UWT_REDACTED}` - ); - - expect(uwtScrubString('ref ACC-333 and also ACC-444', config)).toBe( - `ref ${UWT_REDACTED} and also ${UWT_REDACTED}` - ); - }); - - it('should not recompile the same non-global pattern into a new RegExp on every call', () => { - const RegExpSpy = jest.spyOn(globalThis, 'RegExp'); - const pattern = /ACC-\d+/; - const config = { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValuePatterns: [pattern] - }; - - uwtScrubString('ref ACC-111', config); - const callsAfterFirst = RegExpSpy.mock.calls.length; - uwtScrubString('ref ACC-222', config); - const callsAfterSecond = RegExpSpy.mock.calls.length; - - expect(callsAfterFirst).toBeGreaterThan(0); - expect(callsAfterSecond).toBe(callsAfterFirst); - - RegExpSpy.mockRestore(); - }); - - it('should fully redact a credit card number regardless of scanValuePatterns order', () => { - const value = 'card on file: 4111111111111111'; - expect( - uwtScrubString(value, { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['phone', 'creditCard'] - }) - ).toBe(`card on file: ${UWT_REDACTED}`); - expect( - uwtScrubString(value, { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['creditCard', 'phone'] - }) - ).toBe(`card on file: ${UWT_REDACTED}`); - }); - - it('should apply multiple enabled patterns to the same string', () => { - expect( - uwtScrubString('email a@b.com or call (555) 123-4567', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['email', 'phone'] - }) - ).toBe(`email ${UWT_REDACTED} or call ${UWT_REDACTED}`); - }); - }); - - describe('extraValueTransforms', () => { - it('should run a custom transform on every string value', () => { - expect( - uwtScrubString('internal-id ACC-12345', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValueTransforms: [ - (value) => value.replace(/ACC-\d+/, UWT_REDACTED) - ] - }) - ).toBe(`internal-id ${UWT_REDACTED}`); - }); - - it('should run regardless of scanValuePatterns/extraValuePatterns being empty', () => { - expect( - uwtScrubString('plain text', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValueTransforms: [() => 'replaced'] - }) - ).toBe('replaced'); - }); - - it('should run multiple transforms in array order', () => { - expect( - uwtScrubString('start', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValueTransforms: [ - (value) => `${value}-a`, - (value) => `${value}-b` - ] - }) - ).toBe('start-a-b'); - }); - - describe('a throwing transform', () => { - let consoleWarn: jest.SpyInstance; - - beforeEach(() => { - consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleWarn.mockRestore(); - }); - - it('should skip it and keep the value from before it, reporting why', () => { - expect( - uwtScrubString('unchanged', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValueTransforms: [ - () => { - throw new Error('broken transform'); - } - ] - }) - ).toBe('unchanged'); - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining('extraValueTransforms'), - expect.any(Error) - ); - }); - - it('should still apply subsequent transforms after it', () => { - expect( - uwtScrubString('start', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValueTransforms: [ - () => { - throw new Error('broken transform'); - }, - (value) => `${value}-ok` - ] - }) - ).toBe('start-ok'); - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining('extraValueTransforms'), - expect.any(Error) - ); - }); - }); - - it('should still cap length after custom transforms run', () => { - expect( - uwtScrubString('short', { - ...UWT_DEFAULT_REDACT_CONFIG, - maxStringLength: 5, - extraValueTransforms: [() => 'a much longer replacement value'] - }) - ).toBe('a muc…'); - }); - }); -}); - -describe('uwtRedactMetadata', () => { - it('should keep primitive values', () => { - expect( - uwtRedactMetadata({ count: 3, name: 'csv', ok: true, empty: null }) - ).toEqual({ - count: 3, - name: 'csv', - ok: true, - empty: null - }); - }); - - it.each([ - 'password', - 'passwd', - 'accessToken', - 'refresh_token', - 'Authorization', - 'clientSecret', - 'apiKey', - 'api_key', - 'Cookie', - 'bearerToken', - 'jwt', - 'signature', - 'sessionKey', - 'ssn' - ])('should redact the sensitive key "%s"', (key) => { - expect(uwtRedactMetadata({ [key]: 'super-secret-value' })).toEqual({ - [key]: UWT_REDACTED - }); - }); - - it('should redact keys matching an application-supplied pattern', () => { - expect( - uwtRedactMetadata( - { customerRef: 'ABC' }, - { ...UWT_DEFAULT_REDACT_CONFIG, extraKeyPatterns: [/customerRef/i] } - ) - ).toEqual({ customerRef: UWT_REDACTED }); - }); - - it('should redact the same key consistently across repeated calls with a global pattern', () => { - const config = { - ...UWT_DEFAULT_REDACT_CONFIG, - extraKeyPatterns: [/internalId/gi] - }; - for (let i = 0; i < 4; i++) { - expect(uwtRedactMetadata({ internalId: 'value' }, config)).toEqual({ - internalId: UWT_REDACTED - }); - } - }); - - it('should drop nested objects rather than serializing them', () => { - expect( - uwtRedactMetadata({ user: { id: 1, email: 'a@b.c' }, safe: 'yes' }) - ).toEqual({ safe: 'yes' }); - }); - - it('should drop arrays, functions, symbols and undefined', () => { - expect( - uwtRedactMetadata({ - rows: [1, 2, 3], - fn: () => undefined, - sym: Symbol('s'), - missing: undefined, - kept: 1 - }) - ).toEqual({ kept: 1 }); - }); - - it('should drop non-finite numbers', () => { - expect( - uwtRedactMetadata({ a: NaN, b: Infinity, c: -Infinity, d: 0 }) - ).toEqual({ d: 0 }); - }); - - it('should scrub URLs inside string values', () => { - expect(uwtRedactMetadata({ url: 'https://x.dev/p?token=1' })).toEqual({ - url: 'https://x.dev/p' - }); - }); - - it('should cap the number of retained keys', () => { - const consoleWarn = jest - .spyOn(console, 'warn') - .mockImplementation(() => {}); - - const input: Record = {}; - for (let i = 0; i < 100; i++) { - input[`k${i}`] = i; - } - const result = uwtRedactMetadata(input, { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 5 - }); - expect(Object.keys(result ?? {})).toHaveLength(5); - - consoleWarn.mockRestore(); - }); +jest.mock('@angular/core', () => ({ + ...jest.requireActual('@angular/core'), + isDevMode: jest.fn(() => true) +})); - describe('truncation warning', () => { - let consoleWarn: jest.SpyInstance; +const isDevModeMock = isDevMode as jest.Mock; - beforeEach(() => { - consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleWarn.mockRestore(); - }); - - it('should warn once when maxKeys actually truncates something', () => { - uwtRedactMetadata( - { a: 1, b: 2, c: 3 }, - { ...UWT_DEFAULT_REDACT_CONFIG, maxKeys: 2 } - ); - - expect(consoleWarn).toHaveBeenCalledTimes(1); - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining('maxKeys') - ); - }); - - it('should stay silent when every key fits under maxKeys', () => { - uwtRedactMetadata( - { a: 1, b: 2 }, - { ...UWT_DEFAULT_REDACT_CONFIG, maxKeys: 2 } - ); - - expect(consoleWarn).not.toHaveBeenCalled(); - }); - }); - - it('should not throw on a cyclic object', () => { - const cyclic: Record = { name: 'root' }; - cyclic.self = cyclic; - expect(uwtRedactMetadata(cyclic)).toEqual({ name: 'root' }); - }); - - it.each([[null], [undefined], ['string'], [42], [[1, 2]]])( - 'should return undefined for the non-object input %p', - (input) => { - expect(uwtRedactMetadata(input)).toBeUndefined(); - } - ); - - it('should return undefined when nothing survives redaction', () => { - expect(uwtRedactMetadata({ nested: { a: 1 } })).toBeUndefined(); - }); -}); - -describe('uwtMergeMetadata', () => { - it('should return target unchanged when incoming is undefined', () => { - const target = { a: 1 }; - expect(uwtMergeMetadata(target, undefined)).toBe(target); - expect(target).toEqual({ a: 1 }); - }); - - it('should merge a disjoint bag in when under maxKeys', () => { - const target = { a: 1 }; - const result = uwtMergeMetadata( - target, - { b: 2 }, - { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 5 - } - ); - expect(result).toEqual({ a: 1, b: 2 }); - }); - - it('should drop a genuinely new key once the combined count reaches maxKeys', () => { - const consoleWarn = jest - .spyOn(console, 'warn') - .mockImplementation(() => {}); - const target = { a: 1 }; - try { - const result = uwtMergeMetadata( - target, - { b: 2 }, - { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 1 - } - ); - expect(result).toEqual({ a: 1 }); - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining('maxKeys') - ); - } finally { - consoleWarn.mockRestore(); - } - }); - - it('should allow updating a key already present even when target is at maxKeys', () => { - const target = { a: 1 }; - const result = uwtMergeMetadata( - target, - { a: 2 }, - { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 1 +/** Redaction itself is tested in the core; this is what this package adds. */ +describe('the redaction functions this package exports', () => { + const throwingTransform = { + ...core.UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + (): string => { + throw new Error('broken transform'); } - ); - expect(result).toEqual({ a: 2 }); - }); - - it('should mutate and return the same target reference', () => { - const target = { a: 1 }; - expect(uwtMergeMetadata(target, { b: 2 })).toBe(target); - }); - - describe('truncation warning', () => { - let consoleWarn: jest.SpyInstance; - - beforeEach(() => { - consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleWarn.mockRestore(); - }); - - it('should warn once when maxKeys actually truncates the combined result', () => { - uwtMergeMetadata( - { a: 1 }, - { b: 2 }, - { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 1 - } - ); - - expect(consoleWarn).toHaveBeenCalledTimes(1); - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining('maxKeys') - ); - }); - - it('should stay silent when the combined bag fits under maxKeys', () => { - uwtMergeMetadata( - { a: 1 }, - { b: 2 }, - { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 2 - } - ); - - expect(consoleWarn).not.toHaveBeenCalled(); - }); - - it('should stay silent when only existing keys are updated at maxKeys', () => { - uwtMergeMetadata( - { a: 1 }, - { a: 2 }, - { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 1 - } - ); - - expect(consoleWarn).not.toHaveBeenCalled(); - }); - }); -}); + ] + }; + let consoleWarn: jest.SpyInstance; -describe('uwtNormalizeError', () => { - it('should reduce an Error to name, message and stack', () => { - const result = uwtNormalizeError(new TypeError('boom')); - expect(result?.name).toBe('TypeError'); - expect(result?.message).toBe('boom'); - expect(typeof result?.stack).toBe('string'); + beforeEach(() => { + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + isDevModeMock.mockReturnValue(true); }); - it.each([ - ['empty', ''], - ['missing', undefined] - ])('should fall back to the generic label when name is %s', (_case, name) => { - const error = new Error('boom'); - (error as { name: unknown }).name = name; - expect(uwtNormalizeError(error)?.name).toBe('Error'); + afterEach(() => { + jest.restoreAllMocks(); }); - it('should omit the stack when capture is disabled', () => { - const result = uwtNormalizeError(new Error('boom'), { - ...UWT_DEFAULT_REDACT_CONFIG, - includeStack: false - }); - expect(result?.stack).toBeUndefined(); - }); - - it('should cap the stack length', () => { - const error = new Error('boom'); - error.stack = 'y'.repeat(5000); - const result = uwtNormalizeError(error, { - ...UWT_DEFAULT_REDACT_CONFIG, - maxStackLength: 100 - }); - expect(result?.stack?.length).toBe(101); - }); + it('should return what the core returns', () => { + const config = { ...core.UWT_DEFAULT_REDACT_CONFIG, maxKeys: 2 }; + const metadata = { url: 'https://x.dev/p?token=1', password: 'x', n: 1 }; + const error = new Error('at https://x.dev/?q=1'); - it('should scrub URLs out of the error message', () => { - const result = uwtNormalizeError( - new Error('GET https://api.dev/me?access_token=xyz returned 401') + expect(uwtScrubString('https://x.dev/p?q=1', config)).toBe( + core.uwtScrubString('https://x.dev/p?q=1', config) ); - expect(result?.message).toBe('GET https://api.dev/me returned 401'); - }); - - it('should accept a thrown string', () => { - expect(uwtNormalizeError('plain failure')).toEqual({ - name: 'Error', - message: 'plain failure' - }); - }); - - it('should extract name and message from an HttpErrorResponse-shaped object', () => { - const httpError = { - name: 'HttpErrorResponse', - message: 'Http failure response for /api/customers: 404 Not Found', - status: 404, - statusText: 'Not Found', - url: '/api/customers', - ok: false, - error: { secret: 'raw response body — must never appear in output' } - }; - - const result = uwtNormalizeError(httpError); - - expect(result).toEqual({ - name: 'HttpErrorResponse', - message: 'Http failure response for /api/customers: 404 Not Found' - }); - expect(JSON.stringify(result)).not.toContain('raw response body'); - }); - - it('should fall back to a generic name when the HTTP-error-shaped object has none', () => { - const result = uwtNormalizeError({ - message: 'Http failure response for /api/x: 500 Internal Server Error', - status: 500, - statusText: 'Internal Server Error' - }); - - expect(result?.name).toBe('HttpErrorResponse'); - }); - - it('should not treat an arbitrary object with a status-shaped key as an HTTP error', () => { - expect( - uwtNormalizeError({ status: 404, message: 'unrelated object' }) - ).toEqual({ - name: 'UnknownError', - message: UWT_REDACTED - }); - }); - - it('should report the type of a thrown object without serializing it', () => { - expect(uwtNormalizeError({ password: 'hunter2' })).toEqual({ - name: 'UnknownError', - message: UWT_REDACTED - }); - }); - - it.each([[null], [undefined]])('should return undefined for %p', (input) => { - expect(uwtNormalizeError(input)).toBeUndefined(); - }); -}); - -describe('uwtRedactConfigFor', () => { - it('should return the same config unchanged when enabled', () => { - expect(uwtRedactConfigFor(UWT_DEFAULT_REDACT_CONFIG, true)).toBe( - UWT_DEFAULT_REDACT_CONFIG + expect(uwtRedactMetadata(metadata, config)).toEqual( + core.uwtRedactMetadata(metadata, config) ); - }); - - it('should turn off PII scrubbing when disabled', () => { - const config = uwtRedactConfigFor( - { - ...UWT_DEFAULT_REDACT_CONFIG, - extraKeyPatterns: [/x-internal/], - scanValuePatterns: ['email'], - extraValuePatterns: [/secret-\d+/] - }, - false + expect(uwtRedactMetadata(metadata)).toEqual( + core.uwtRedactMetadata(metadata) ); - - expect(config.extraKeyPatterns).toEqual([]); - expect(config.stripUrlQuery).toBe(false); - expect(config.scanValuePatterns).toEqual([]); - expect(config.extraValuePatterns).toEqual([]); + expect(uwtNormalizeError(error)).toEqual(core.uwtNormalizeError(error)); }); - it('should keep size caps, error normalization inputs, and extraValueTransforms when disabled', () => { - const transform = (value: string) => value; - const config = uwtRedactConfigFor( + it('should warn on the console, prefixed, in development mode', () => { + uwtScrubString('unchanged', throwingTransform); + uwtRedactMetadata( + { a: 1, b: 2, c: 3 }, { - ...UWT_DEFAULT_REDACT_CONFIG, - maxStringLength: 10, - maxKeys: 5, - maxStackLength: 100, - includeStack: true, - extraValueTransforms: [transform] - }, - false + ...core.UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 2 + } ); - - expect(config.maxStringLength).toBe(10); - expect(config.maxKeys).toBe(5); - expect(config.maxStackLength).toBe(100); - expect(config.includeStack).toBe(true); - expect(config.extraValueTransforms).toEqual([transform]); - }); - - it('should still redact a built-in denylisted key when disabled — it is a safety floor, not a privacy opt-in', () => { - const disabled = uwtRedactConfigFor(UWT_DEFAULT_REDACT_CONFIG, false); - - expect(uwtRedactMetadata({ password: 'hunter2' }, disabled)).toEqual({ - password: UWT_REDACTED - }); - }); - - it('should not redact an extraKeyPatterns-only key when disabled', () => { - const disabled = uwtRedactConfigFor( - { ...UWT_DEFAULT_REDACT_CONFIG, extraKeyPatterns: [/x-internal/] }, - false + // An error's message and its stack are each scrubbed. + uwtNormalizeError(new Error('boom'), throwingTransform); + + expect(consoleWarn.mock.calls).toEqual([ + [ + '[ngx-ui-watchtower] An extraValueTransforms function threw and was skipped', + expect.any(Error) + ], + [ + '[ngx-ui-watchtower] Metadata exceeded maxKeys (2) and was truncated. Attributes past the limit were dropped silently in production.' + ], + [ + '[ngx-ui-watchtower] An extraValueTransforms function threw and was skipped', + expect.any(Error) + ], + [ + '[ngx-ui-watchtower] An extraValueTransforms function threw and was skipped', + expect.any(Error) + ] + ]); + }); + + it('should stay silent in production mode', () => { + isDevModeMock.mockReturnValue(false); + + uwtScrubString('unchanged', throwingTransform); + uwtRedactMetadata( + { a: 1, b: 2, c: 3 }, + { + ...core.UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 2 + } ); - expect(uwtRedactMetadata({ 'x-internal-id': 'abc' }, disabled)).toEqual({ - 'x-internal-id': 'abc' - }); + expect(consoleWarn).not.toHaveBeenCalled(); }); }); diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts index d5097b3..3a8c662 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts +++ b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts @@ -1,264 +1,18 @@ import { + UWT_DEFAULT_REDACT_CONFIG, + uwtNormalizeError as coreNormalizeError, + UwtRedactConfig, + uwtRedactMetadata as coreRedactMetadata, + uwtScrubString as coreScrubString, UwtTelemetryError, UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { uwtIsDevMode } from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +} from '@absaoss-cps/ui-watchtower'; +import { uwtDevModeReporter } from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -/** - * Named, built-in value-content PII shapes {@link UwtRedactConfig.scanValuePatterns} - * can enable. - * - * @group Types - */ -export type UwtPiiValuePattern = - 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'; - -/** - * Tunables governing what telemetry is allowed to carry off the device. - * - * @group Interfaces - */ -export interface UwtRedactConfig { - /** - * Additional key patterns to redact, merged with the built-in denylist. - * Matched against the attribute key, case-insensitively. - */ - extraKeyPatterns: RegExp[]; - - /** Maximum length of any single string value before truncation. */ - maxStringLength: number; - - /** Maximum number of attributes retained on one payload. */ - maxKeys: number; - - /** Maximum length of a captured stack trace. */ - maxStackLength: number; - - /** Whether stack traces are captured at all. */ - includeStack: boolean; - - /** Whether URL query strings and fragments are stripped from string values. */ - stripUrlQuery: boolean; - - /** - * Value-content PII shapes to scan for, in addition to the key denylist. - * Off by default. Heuristic regexes, not certified detectors — `'phone'` - * is the noisiest; `'creditCard'` also requires a Luhn match. - */ - scanValuePatterns: UwtPiiValuePattern[]; - - /** - * Additional value-content regexes, merged with `scanValuePatterns` — the - * content counterpart to `extraKeyPatterns`. - */ - extraValuePatterns: RegExp[]; - - /** - * Application-supplied functions run on every string value, after all - * pattern-based scrubbing — an escape hatch for logic no regex can - * express. Runs independently of `scanValuePatterns`/`extraValuePatterns`. - * A throwing function is skipped (logged in dev mode), not fatal. - */ - extraValueTransforms: ((value: string) => string)[]; -} - -/** Keys whose values never leave the browser, matched case-insensitively (`Authorization`, `access_token`, `apiKey`, …). */ -const DENYLIST = - /pass(word|wd)?|secret|token|auth|credential|cookie|api[-_]?key|bearer|jwt|signature|session[-_]?key|ssn/i; - -/** Absolute http(s) URLs appearing anywhere inside a string. */ -const URL_PATTERN = /https?:\/\/[^\s"'<>]+/gi; - -/** - * A root-relative path with a query string or fragment. The lookbehind - * rejects a `/` that's mid-word (so this doesn't re-match inside an - * already-scrubbed URL) without requiring whitespace before the path — - * punctuation like `:` counts as a boundary too. - */ -const PATH_WITH_QUERY = /(?, - RegExp -> = { - // Common email shape, not full RFC 5322. - email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, - // Matches only the dashed `123-45-6789` format; a bare 9-digit run isn't - // reliably an SSN. - ssn: /\b\d{3}-\d{2}-\d{4}\b/g, - ipv4: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, - // NANP (US/Canada): 3-3-4 digits, optional `1`/`+1` country code, - // South Africa (27), Botswana (267), Ghana (233), Kenya - // (254), Mauritius (230), Mozambique (258), Namibia (264), Seychelles - // (248), Tanzania (255), Uganda (256), Zambia (260). - phone: - /(?:(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}|(?:\+|00)(?:27|267|233|254|230|258|264|248|255|256|260)(?:[-.\s]?\d){7,9})\b/g -}; - -/** - * Fixed order for {@link UwtRedactConfig.scanValuePatterns}, independent of - * the consumer's array order. `creditCard` must run before `phone`: phone - * has no leading `\b` and can match a trailing substring of a card number, - * fragmenting and leaking part of it if it ran first. - */ -const VALUE_PATTERN_ORDER: UwtPiiValuePattern[] = [ - 'email', - 'creditCard', - 'ssn', - 'ipv4', - 'phone' -]; - -/** - * Payment card number length, per ISO/IEC 7812 — 13 digits at the short - * end (older Visa) through 19 at the long (Maestro and co.). - */ -const CARD_MIN_DIGITS = 13; -const CARD_MAX_DIGITS = 19; - -/** - * Candidate digit runs, allowing the separators people actually type. - * Built from the bounds above so the pattern and the length check below - * cannot drift apart. - */ -const CREDIT_CARD_CANDIDATE = new RegExp( - `\\b(?:\\d[ -]?){${CARD_MIN_DIGITS},${CARD_MAX_DIGITS}}\\b`, - 'g' -); - -/** The standard Luhn checksum — tells a real card number from an unrelated digit run of the same length. */ -function isLuhnValid(digits: string): boolean { - let sum = 0; - let alternate = false; - - for (let i = digits.length - 1; i >= 0; i--) { - let n = digits.charCodeAt(i) - 48; // '0' - if (alternate) { - n *= 2; - if (n > 9) { - n -= 9; - } - } - sum += n; - alternate = !alternate; - } - - return digits.length > 0 && sum % 10 === 0; -} - -/** Redacts credit-card-shaped digit runs that also pass a Luhn checksum. */ -function scrubCreditCards(value: string): string { - return value.replace(CREDIT_CARD_CANDIDATE, (match) => { - const digits = match.replace(/\D/g, ''); - return digits.length >= CARD_MIN_DIGITS && - digits.length <= CARD_MAX_DIGITS && - isLuhnValid(digits) - ? UWT_REDACTED - : match; - }); -} - -/** - * Memoizes the global copy `ensureGlobal` builds for a non-global pattern — - * config-level regexes are stable across many calls, so without this a - * fresh `RegExp` would be compiled on every scrubbed string, for the life - * of the page. Safe to reuse: every use is via `.replace()`, which resets - * `lastIndex` itself before scanning. - */ -const globalPatternCache = new WeakMap(); - -/** Ensures a pattern has the `g` flag, so `.replace()` catches every occurrence, not just the first. */ -function ensureGlobal(pattern: RegExp): RegExp { - if (pattern.global) { - return pattern; - } - - let global = globalPatternCache.get(pattern); - if (!global) { - global = new RegExp(pattern.source, `${pattern.flags}g`); - globalPatternCache.set(pattern, global); - } - return global; -} - -/** - * Default redaction settings. Conservative; widen as needed. - * - * @group Utils - */ -export const UWT_DEFAULT_REDACT_CONFIG: UwtRedactConfig = { - extraKeyPatterns: [], - maxStringLength: 1024, - maxKeys: 50, - maxStackLength: 2048, - includeStack: true, - stripUrlQuery: true, - scanValuePatterns: [], - extraValuePatterns: [], - extraValueTransforms: [] -}; - -/** - * Effective redact config for a concern's own `redact: boolean` toggle - * (`UwtLogConfig.redact`, `UwtScenarioTelemetryConfig.redact`, `UwtBITelemetryConfig.redact`). - * Disabling it skips only the configurable scrubbing — the built-in - * credential denylist, size caps, error normalization and - * `extraValueTransforms` stay on regardless. - * - * @param config the concern's resolved `UWT_REDACT_CONFIG` - * @param enabled the concern's own `redact` setting - * @returns `config` unchanged when enabled, otherwise with configurable - * scrubbing turned off - * - * @group Utils - */ -export function uwtRedactConfigFor( - config: UwtRedactConfig, - enabled: boolean -): UwtRedactConfig { - if (enabled) { - return config; - } - - return { - ...config, - extraKeyPatterns: [], - stripUrlQuery: false, - scanValuePatterns: [], - extraValuePatterns: [] - }; -} - -function isDenied(key: string, config: UwtRedactConfig): boolean { - if (DENYLIST.test(key)) { - return true; - } - return config.extraKeyPatterns.some((pattern) => { - // A global/sticky pattern is stateful: .test() advances lastIndex, so - // reset it to avoid alternating matches across calls. - pattern.lastIndex = 0; - return pattern.test(key); - }); -} - -/** Reduces a URL to `origin + pathname` — tokens and identifiers tend to live in the query/fragment being dropped. */ -function scrubUrl(url: string): string { - try { - const parsed = new URL(url); - return `${parsed.origin}${parsed.pathname}`; - } catch { - const cut = url.search(/[?#]/); - return cut === -1 ? url : url.slice(0, cut); - } -} /** * Strips URL query strings/fragments, scans for opted-in PII shapes, runs @@ -272,46 +26,7 @@ function scrubUrl(url: string): string { * @group Utils */ export function uwtScrubString(value: string, config: UwtRedactConfig): string { - let result = value; - - if (config.stripUrlQuery) { - result = result.replace(URL_PATTERN, (match) => scrubUrl(match)); - result = result.replace(PATH_WITH_QUERY, (match) => scrubUrl(match)); - } - - if (config.scanValuePatterns.length || config.extraValuePatterns.length) { - for (const name of VALUE_PATTERN_ORDER) { - if (!config.scanValuePatterns.includes(name)) { - continue; - } - result = - name === 'creditCard' - ? scrubCreditCards(result) - : result.replace(VALUE_PATTERNS[name], UWT_REDACTED); - } - for (const pattern of config.extraValuePatterns) { - result = result.replace(ensureGlobal(pattern), UWT_REDACTED); - } - } - - for (const transform of config.extraValueTransforms) { - try { - result = transform(result); - } catch (error) { - if (uwtIsDevMode()) { - console.warn( - '[ngx-ui-watchtower] An extraValueTransforms function threw and was skipped', - error - ); - } - } - } - - if (result.length > config.maxStringLength) { - result = `${result.slice(0, config.maxStringLength)}…`; - } - - return result; + return coreScrubString(value, config, uwtDevModeReporter); } /** @@ -331,98 +46,7 @@ export function uwtRedactMetadata( value: unknown, config: UwtRedactConfig = UWT_DEFAULT_REDACT_CONFIG ): UwtTelemetryMetadata | undefined { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return undefined; - } - - const result: UwtTelemetryMetadata = {}; - let kept = 0; - let truncated = false; - - for (const [key, raw] of Object.entries(value)) { - if (kept >= config.maxKeys) { - truncated = true; - break; - } - - if (isDenied(key, config)) { - result[key] = UWT_REDACTED; - kept++; - continue; - } - - if (raw === null || typeof raw === 'boolean') { - result[key] = raw; - kept++; - } else if (typeof raw === 'number') { - if (Number.isFinite(raw)) { - result[key] = raw; - kept++; - } - } else if (typeof raw === 'string') { - result[key] = uwtScrubString(raw, config); - kept++; - } - } - - if (truncated && uwtIsDevMode()) { - console.warn( - `[ngx-ui-watchtower] Metadata exceeded maxKeys (${config.maxKeys}) and was truncated. Attributes past the limit were dropped silently in production.` - ); - } - - return kept > 0 ? result : undefined; -} - -/** - * Merges an already-redacted attribute bag into an existing one, - * re-applying `maxKeys` to the *combined* result. `uwtRedactMetadata` only - * bounds the bag passed to it in isolation — merging two independently - * capped bags without this can exceed the configured limit. A key already - * present in `target` is always updated (a replacement, not growth); only - * a genuinely new key counts against the cap, and is dropped once it's - * reached (warns once in dev mode, mirroring `uwtRedactMetadata`). - * - * Mutates and returns `target`. - * - * @param target the accumulator merged into, and returned - * @param incoming the bag being merged in, normally the output of {@link uwtRedactMetadata} - * @param config redaction settings - * @returns `target`, with `incoming` merged in - * - * @group Utils - */ -export function uwtMergeMetadata( - target: UwtTelemetryMetadata, - incoming: UwtTelemetryMetadata | undefined, - config: UwtRedactConfig = UWT_DEFAULT_REDACT_CONFIG -): UwtTelemetryMetadata { - if (!incoming) { - return target; - } - - let keyCount = Object.keys(target).length; - let truncated = false; - - for (const [key, value] of Object.entries(incoming)) { - const isNewKey = !(key in target); - if (isNewKey && keyCount >= config.maxKeys) { - truncated = true; - continue; - } - if (isNewKey) { - keyCount++; - } - target[key] = value; - } - - if (truncated && uwtIsDevMode()) { - console.warn( - `[ngx-ui-watchtower] Combined metadata exceeded maxKeys (${config.maxKeys}) and was truncated. Attributes past the limit were dropped silently in production.` - ); - } - - return target; + return coreRedactMetadata(value, config, uwtDevModeReporter); } /** @@ -440,55 +64,5 @@ export function uwtNormalizeError( error: unknown, config: UwtRedactConfig = UWT_DEFAULT_REDACT_CONFIG ): UwtTelemetryError | undefined { - if (error === null || error === undefined) { - return undefined; - } - - if (error instanceof Error) { - const normalized: UwtTelemetryError = { - name: error.name || 'Error', - message: uwtScrubString(error.message ?? '', config) - }; - - if (config.includeStack && typeof error.stack === 'string') { - normalized.stack = uwtScrubString(error.stack, { - ...config, - maxStringLength: config.maxStackLength - }); - } - - return normalized; - } - - if (typeof error === 'string') { - return { name: 'Error', message: uwtScrubString(error, config) }; - } - - if (isHttpErrorShaped(error)) { - // HttpErrorResponse doesn't extend Error, so instanceof above misses it. - return { - name: typeof error.name === 'string' ? error.name : 'HttpErrorResponse', - message: uwtScrubString(error.message, config) - }; - } - - return { - name: typeof error === 'object' ? 'UnknownError' : typeof error, - message: UWT_REDACTED - }; -} - -/** Duck-types an `HttpErrorResponse`-shaped object, avoiding an `@angular/common/http` import (and its runtime) just for this check. */ -function isHttpErrorShaped( - error: unknown -): error is { name?: unknown; message: string; status: number } { - if (error === null || typeof error !== 'object') { - return false; - } - const candidate = error as Record; - return ( - typeof candidate.message === 'string' && - typeof candidate.status === 'number' && - ('statusText' in candidate || 'url' in candidate) - ); + return coreNormalizeError(error, config, uwtDevModeReporter); } diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts index 78adfbb..eab4c33 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts @@ -1,13 +1,11 @@ import { isDevMode } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type {} from '@absaoss-cps/ui-watchtower'; import { - uwtDeepClone, - uwtElapsedNow, - uwtEpochToPerf, - uwtNow, + uwtRunOutsideZone, uwtSafe, uwtSafeVoid, - uwtSafeVoidMaybeAsync, - uwtUuid + uwtSafeVoidMaybeAsync } from './uwt-telemetry-safe.util'; jest.mock('@angular/core', () => ({ @@ -196,170 +194,14 @@ describe('uwtSafeVoidMaybeAsync', () => { }); }); -describe('uwtDeepClone', () => { - it('should produce a deep copy that mutation of the original does not affect', () => { - const original = { a: 1, nested: { b: [1, 2, 3] } }; - const clone = uwtDeepClone(original); +describe('uwtRunOutsideZone', () => { + it('should run a callback that returns nothing once, and hand back what it returns', () => { + const run = TestBed.runInInjectionContext(() => uwtRunOutsideZone()); + const nothing = jest.fn(); - original.nested.b.push(4); + run(nothing); - expect(clone).toEqual({ a: 1, nested: { b: [1, 2, 3] } }); - expect(clone).not.toBe(original); - expect(clone.nested).not.toBe(original.nested); - }); - - it('should fall back to a JSON round-trip when structuredClone is unavailable', () => { - const realStructuredClone = globalThis.structuredClone; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).structuredClone = undefined; - - try { - expect(uwtDeepClone({ a: 1, nested: { b: 2 } })).toEqual({ - a: 1, - nested: { b: 2 } - }); - } finally { - globalThis.structuredClone = realStructuredClone; - } - }); - - it('should fall back to a JSON round-trip when structuredClone throws', () => { - const realStructuredClone = globalThis.structuredClone; - globalThis.structuredClone = () => { - throw new Error('cannot clone'); - }; - - try { - expect(uwtDeepClone({ a: 1 })).toEqual({ a: 1 }); - } finally { - globalThis.structuredClone = realStructuredClone; - } - }); -}); - -describe('uwtUuid', () => { - const realCrypto = globalThis.crypto; - - afterEach(() => { - Object.defineProperty(globalThis, 'crypto', { - value: realCrypto, - configurable: true - }); - }); - - const useCrypto = (value: unknown) => - Object.defineProperty(globalThis, 'crypto', { - value, - configurable: true - }); - - it('should produce a UUID-shaped identifier', () => { - expect(uwtUuid()).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - ); - }); - - it('should produce distinct identifiers', () => { - const ids = new Set(Array.from({ length: 200 }, () => uwtUuid())); - expect(ids.size).toBe(200); - }); - - it('should fall back to getRandomValues when randomUUID is unavailable', () => { - useCrypto({ - getRandomValues: (array: Uint8Array) => { - for (let i = 0; i < array.length; i++) { - array[i] = i; - } - return array; - } - }); - - expect(uwtUuid()).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - ); - }); - - it('should fall back to Math.random when the Web Crypto API is absent', () => { - useCrypto(undefined); - - expect(uwtUuid()).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - ); - }); -}); - -describe('uwtNow', () => { - it('should return a number that does not go backwards', () => { - const first = uwtNow(); - const second = uwtNow(); - expect(typeof first).toBe('number'); - expect(second).toBeGreaterThanOrEqual(first); - }); -}); - -describe('uwtElapsedNow', () => { - it('should return a number that does not go backwards', () => { - const first = uwtElapsedNow(); - const second = uwtElapsedNow(); - expect(typeof first).toBe('number'); - expect(second).toBeGreaterThanOrEqual(first); - }); - - it('should read the same object jsdom exposes as globalThis.top', () => { - expect(globalThis.top).toBe(globalThis); - }); -}); - -describe('uwtEpochToPerf', () => { - const realPerformance = globalThis.performance; - - afterEach(() => { - Object.defineProperty(globalThis, 'performance', { - value: realPerformance, - configurable: true - }); - }); - - function usePerformance(timeOrigin: number, now: number) { - Object.defineProperty(globalThis, 'performance', { - value: { timeOrigin, now: () => now }, - configurable: true - }); - } - - it('should convert an epoch timestamp into the performance timeline', () => { - usePerformance(1_000_000, 5_000); - expect(uwtEpochToPerf(1_002_000)).toBe(2_000); - }); - - it('should accept the exact page origin', () => { - usePerformance(1_000_000, 5_000); - expect(uwtEpochToPerf(1_000_000)).toBe(0); - }); - - it('should reject a timestamp from before the page loaded', () => { - usePerformance(1_000_000, 5_000); - expect(uwtEpochToPerf(999_000)).toBeUndefined(); - }); - - it('should reject a timestamp in the future', () => { - usePerformance(1_000_000, 5_000); - expect(uwtEpochToPerf(1_010_000)).toBeUndefined(); - }); - - it.each([[NaN], [Infinity], [-Infinity]])( - 'should reject the non-finite input %p', - (input) => { - usePerformance(1_000_000, 5_000); - expect(uwtEpochToPerf(input)).toBeUndefined(); - } - ); - - it('should return undefined without a usable performance object', () => { - Object.defineProperty(globalThis, 'performance', { - value: undefined, - configurable: true - }); - expect(uwtEpochToPerf(1_000)).toBeUndefined(); + expect(nothing).toHaveBeenCalledTimes(1); + expect(run(() => 42)).toBe(42); }); }); diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts index ffe0dd2..9ea7de3 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts +++ b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts @@ -1,60 +1,10 @@ -import { isPlatformBrowser } from '@angular/common'; -import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; - -/** - * Runs a telemetry operation without letting it affect the application. - * - * Every public entry point in this library routes through this wrapper — - * it never rethrows. In development the caught error is reported to the - * console; in production nothing is printed. - */ -export function uwtSafe(operation: string, fn: () => T, fallback: T): T { - try { - return fn(); - } catch (error) { - reportSuppressed(operation, error); - return fallback; - } -} - -/** - * Void-returning form of {@link uwtSafe}, for the common case of an emit call - * whose result nobody reads. - */ -export function uwtSafeVoid(operation: string, fn: () => void): void { - uwtSafe(operation, fn, undefined); -} - -/** - * Like {@link uwtSafeVoid}, but also catches a rejection from a `void`-typed - * extension point whose real implementation turns out to be `async` — an - * application-supplied provider hook, for instance. TypeScript accepts an - * `async` function wherever `() => void` is expected and discards the - * promise, so a plain `try`/`catch` can't see it reject. This checks for a - * thenable result and attaches a rejection handler. - * - * Not the default for every call: most `uwtSafeVoid` calls wrap this - * library's own synchronous code, where the extra check is pure overhead. - * Use this only for a function whose shape this library merely declares. - */ -export function uwtSafeVoidMaybeAsync(operation: string, fn: () => void): void { - uwtSafe( - operation, - () => { - const result: unknown = fn(); - if ( - result !== null && - typeof result === 'object' && - typeof (result as { then?: unknown }).then === 'function' - ) { - Promise.resolve(result as PromiseLike).catch((error) => - reportSuppressed(operation, error) - ); - } - }, - undefined - ); -} +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { inject, isDevMode, NgZone, PLATFORM_ID } from '@angular/core'; +import { + UwtReporter, + uwtSafe as coreSafe, + uwtSafeVoidMaybeAsync as coreSafeVoidMaybeAsync +} from '@absaoss-cps/ui-watchtower'; /** `isDevMode()` throws outside an Angular context, so this guards that once for every caller. */ export function uwtIsDevMode(): boolean { @@ -75,130 +25,68 @@ export function uwtIsBrowser(): boolean { } /** - * Deep-clones a plain-data value. Prefers `structuredClone`; falls back to - * a JSON round-trip where it's unavailable (e.g. under jsdom) or throws. + * The page telemetry watches: the document in a browser, nothing in a + * server-side render. Must be called from an injection context. */ -export function uwtDeepClone(value: T): T { - if (typeof structuredClone === 'function') { - try { - return structuredClone(value); - } catch { - // fall through to the JSON fallback - } - } - return JSON.parse(JSON.stringify(value)) as T; -} - -function reportSuppressed(operation: string, error: unknown): void { - if (!uwtIsDevMode()) { - return; - } - try { - console.error(`[ngx-ui-watchtower] ${operation} failed`, error); - } catch { - // A patched/throwing console must never escape telemetry suppression. - } +export function uwtBrowserDocument(): Document | null { + const document = inject(DOCUMENT); + return uwtIsBrowser() ? document : null; } /** - * Fills a byte array from the best available source. `crypto.getRandomValues` - * works on plain http:// too, where `crypto.randomUUID` doesn't. `Math.random` - * is the last-resort fallback for an environment with no Web Crypto at all. + * Runs a callback outside Angular's zone when there is one, so telemetry's + * long-lived timers never keep the application from becoming stable. Must + * be called from an injection context. */ -function randomBytes(count: number): Uint8Array { - const bytes = new Uint8Array(count); - const cryptoRef = globalThis.crypto; - - if (typeof cryptoRef?.getRandomValues === 'function') { - return cryptoRef.getRandomValues(bytes); - } - - for (let i = 0; i < count; i++) { - bytes[i] = (Math.random() * 256) | 0; - } - return bytes; +export function uwtRunOutsideZone(): (callback: () => T) => T { + const zone = inject(NgZone, { optional: true }); + return (callback) => (zone ? zone.runOutsideAngular(callback) : callback()); } -/** Lowercase hex encoding, two characters per byte. */ -function toHex(bytes: Uint8Array): string { - return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +/** + * Runs a callback inside Angular's zone when there is one, so what it + * resolves is seen by change detection. Must be called from an injection + * context. + */ +export function uwtRunInsideZone(): (callback: () => T) => T { + const zone = inject(NgZone, { optional: true }); + return (callback) => (zone ? zone.run(callback) : callback()); } /** - * Generates a correlation identifier. Prefers `crypto.randomUUID`, falling - * back to formatting random bytes as a v4 UUID. + * How this package reports what telemetry handles silently: to the console, + * prefixed. Failures and dropped data only in development mode; something + * that cannot work as configured (`'config'`) in production too. Everything + * the core does on this package's behalf is handed it. */ -export function uwtUuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); +export const uwtDevModeReporter: UwtReporter = (level, message, ...details) => { + if (level === 'config') { + console.warn(`[ngx-ui-watchtower] ${message}`, ...details); + } else if (uwtIsDevMode()) { + console[level](`[ngx-ui-watchtower] ${message}`, ...details); } - - const bytes = randomBytes(16); - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 - - const hex = toHex(bytes); - return [ - hex.slice(0, 8), - hex.slice(8, 12), - hex.slice(12, 16), - hex.slice(16, 20), - hex.slice(20) - ].join('-'); -} +}; /** - * Monotonic-ish millisecond clock for duration measurement. - * `performance.now()` is preferred since it's immune to wall-clock - * adjustments. + * Runs a telemetry operation without letting it affect the application: + * the core's fail-open wrapper, reporting through {@link uwtDevModeReporter}. */ -export function uwtNow(): number { - const perf = globalThis.performance; - return typeof perf?.now === 'function' ? perf.now() : Date.now(); +export function uwtSafe(operation: string, fn: () => T, fallback: T): T { + return coreSafe(operation, fn, fallback, uwtDevModeReporter); } /** - * Milliseconds since the *host* page loaded — prefers `top.performance.now()` - * over the local realm's own, the same reasoning `uwt-user-timings-internal.util.ts` - * applies to marks/measures: in a composed page, a fragment runs in its own - * same-origin iframe with a later `timeOrigin`, so a timeline position taken - * from its own `performance` doesn't line up against the shell's. Reading - * `top` always succeeds, even cross-origin; only touching a property on it - * can throw, in which case this falls back to {@link uwtNow}. + * Void-returning form of {@link uwtSafe}, for the common case of an emit call + * whose result nobody reads. */ -export function uwtElapsedNow(): number { - try { - const top = globalThis.top; - if (typeof top?.performance?.now === 'function') { - return top.performance.now(); - } - } catch { - // cross-origin, or sandboxed without `allow-same-origin` - } - return uwtNow(); +export function uwtSafeVoid(operation: string, fn: () => void): void { + coreSafe(operation, fn, undefined, uwtDevModeReporter); } /** - * Converts an epoch timestamp into the `performance.now()` timeline. - * Clamped to the current page's lifetime — a future timestamp, or one from - * before the page loaded, yields `undefined`. + * Like {@link uwtSafeVoid}, but also catches a rejection from a `void`-typed + * extension point whose real implementation turns out to be `async`. */ -export function uwtEpochToPerf(epochMs: number): number | undefined { - if (!Number.isFinite(epochMs)) { - return undefined; - } - - const perf = globalThis.performance; - if (typeof perf?.now !== 'function' || typeof perf.timeOrigin !== 'number') { - return undefined; - } - - const converted = epochMs - perf.timeOrigin; - const now = perf.now(); - - if (converted < 0 || converted > now) { - return undefined; - } - - return converted; +export function uwtSafeVoidMaybeAsync(operation: string, fn: () => void): void { + coreSafeVoidMaybeAsync(operation, fn, uwtDevModeReporter); } diff --git a/projects/ngx-ui-watchtower/src/public-api.ts b/projects/ngx-ui-watchtower/src/public-api.ts index 795967e..e6dec4d 100644 --- a/projects/ngx-ui-watchtower/src/public-api.ts +++ b/projects/ngx-ui-watchtower/src/public-api.ts @@ -7,18 +7,20 @@ // Configuration export { - UWT_DEFAULT_TELEMETRY_CONFIG, UWT_REDACT_CONFIG, UWT_TELEMETRY_ENABLED, UWT_TELEMETRY_IDENTITY } from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; -export type { UwtTelemetryIdentity } from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +export { UWT_DEFAULT_TELEMETRY_CONFIG } from '@absaoss-cps/ui-watchtower'; +export type { + UwtBITelemetryConfig, + UwtLogConfig, + UwtScenarioTelemetryConfig, + UwtTelemetryIdentity +} from '@absaoss-cps/ui-watchtower'; export { UWT_BI_TELEMETRY_CONFIG } from './lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; -export type { UwtBITelemetryConfig } from './lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; export { UWT_LOG_CONFIG } from './lib/config/uwt-log.config/uwt-log.config'; -export type { UwtLogConfig } from './lib/config/uwt-log.config/uwt-log.config'; export { UWT_SCENARIO_TELEMETRY_CONFIG } from './lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; -export type { UwtScenarioTelemetryConfig } from './lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; // Providers export { @@ -35,21 +37,23 @@ export { withScenarios, withTelemetryEnabled } from './lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; -export { - UWT_LOG_API_PROVIDER, +export { UWT_LOG_API_PROVIDER } from './lib/providers/uwt-log-api.provider/uwt-log-api.provider'; +export type { UwtLogApiProvider, UwtLogQuery -} from './lib/providers/uwt-log-api.provider/uwt-log-api.provider'; +} from '@absaoss-cps/ui-watchtower'; export { UwtNoopLogApiProvider } from './lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider'; export { UwtBroadcastLogApiProvider } from './lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; -// Models +// Models, and the name registries an application augments: declared in +// the core and re-exported, so augmenting them on this package's module path +// reaches the same registries the core's own APIs use. export type { UwtBIEvent, UwtBIEventDetail, UwtBIEventName, UwtBIEventNames -} from './lib/models/uwt-bi.models/uwt-bi.models'; +} from '@absaoss-cps/ui-watchtower'; export type { UwtLogger, UwtLoggerName, @@ -57,8 +61,8 @@ export type { UwtLogDetail, UwtLogLevel, UwtLogRecord -} from './lib/models/uwt-log.models/uwt-log.models'; -export { UWT_LOG_LEVEL_ORDER } from './lib/models/uwt-log.models/uwt-log.models'; +} from '@absaoss-cps/ui-watchtower'; +export { UWT_LOG_LEVEL_ORDER } from '@absaoss-cps/ui-watchtower'; export type { UwtScenarioAggregate, UwtScenarioName, @@ -73,51 +77,52 @@ export type { UwtScenarioStepStatus, UwtScenarioSteps, UwtStepName -} from './lib/models/uwt-scenario.models/uwt-scenario.models'; +} from '@absaoss-cps/ui-watchtower'; export { UWT_DEFAULT_EVENT_NAMESPACE, UWT_TELEMETRY_EVENT_TYPE, uwtEventTypes -} from './lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +} from '@absaoss-cps/ui-watchtower'; export type { UwtTelemetryAttribution, UwtTelemetryError, UwtTelemetryEventTypes, UwtTelemetryMetadata -} from './lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +} from '@absaoss-cps/ui-watchtower'; // 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 { UwtScenario } from '@absaoss-cps/ui-watchtower'; export { UwtScenarioTelemetryService } from './lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; // Sinks export { UwtTelemetryBroadcastHost } from './lib/sinks/uwt-broadcast/uwt-broadcast-host.service'; export { UwtBroadcastTelemetrySink } from './lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink'; -export { - UWT_BROADCAST_CHANNEL, - UWT_DEFAULT_BROADCAST_CHANNEL -} from './lib/sinks/uwt-broadcast/uwt-broadcast.messages'; -export { UwtTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +export { UWT_BROADCAST_CHANNEL } from './lib/sinks/uwt-broadcast/uwt-broadcast.messages'; +export { UWT_DEFAULT_BROADCAST_CHANNEL } from '@absaoss-cps/ui-watchtower'; +export { UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; export { UwtNoopTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; // Utilities an application or a custom sink needs export { traceScenario } from './lib/scenario/uwt-scenario-operators/uwt-scenario-operators'; -export { uwtClassifyTelemetryEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; -export type { UwtTelemetrySinkEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; -export type { UwtTraceScenarioOptions } from './lib/scenario/uwt-scenario-operators/uwt-scenario-operators'; -export { uwtIsDebugEnabled } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; -export type { UwtDebugFlag } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; -export { uwtUuid } from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -export { uwtMarkSessionStart } from './lib/utils/uwt-session-start.util/uwt-session-start.util'; +export { uwtClassifyTelemetryEvent } from '@absaoss-cps/ui-watchtower'; +export type { UwtTelemetrySinkEvent } from '@absaoss-cps/ui-watchtower'; +export type { UwtTraceScenarioOptions } from '@absaoss-cps/ui-watchtower/rxjs'; +export { uwtIsDebugEnabled, uwtUuid } from '@absaoss-cps/ui-watchtower'; +export type { UwtDebugFlag } from '@absaoss-cps/ui-watchtower'; +export { uwtMarkSessionStart } from '@absaoss-cps/ui-watchtower'; export { UWT_DEFAULT_REDACT_CONFIG, UWT_REDACTED, + uwtRedactConfigFor +} from '@absaoss-cps/ui-watchtower'; +export type { UwtPiiValuePattern, - UwtRedactConfig, + UwtRedactConfig +} from '@absaoss-cps/ui-watchtower'; +export { uwtNormalizeError, - uwtRedactConfigFor, uwtRedactMetadata, uwtScrubString } from './lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; diff --git a/projects/ngx-ui-watchtower/tsconfig.lib.json b/projects/ngx-ui-watchtower/tsconfig.lib.json index 44d58cd..514b02a 100644 --- a/projects/ngx-ui-watchtower/tsconfig.lib.json +++ b/projects/ngx-ui-watchtower/tsconfig.lib.json @@ -13,7 +13,13 @@ "skipLibCheck": true, "isolatedModules": false, "types": [], - "paths": {} + // The core as published: its built declarations, not its source. Build + // it first (npm run build does). + "paths": { + "@absaoss-cps/ui-watchtower": ["../../dist/ui-watchtower"], + "@absaoss-cps/ui-watchtower/rxjs": ["../../dist/ui-watchtower/rxjs"], + "@absaoss-cps/ui-watchtower/rum": ["../../dist/ui-watchtower/rum"] + } }, "include": ["src/**/*.ts"], "exclude": ["**/*.spec.ts"], diff --git a/projects/ngx-ui-watchtower/tsconfig.spec.json b/projects/ngx-ui-watchtower/tsconfig.spec.json index 16f49dc..0a82408 100644 --- a/projects/ngx-ui-watchtower/tsconfig.spec.json +++ b/projects/ngx-ui-watchtower/tsconfig.spec.json @@ -3,8 +3,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/spec", - "rootDir": "./src", - "types": ["jest"], + // Includes the core: tests and typecheck read its source. + "rootDir": "..", + "types": ["jest", "node"], "resolveJsonModule": true }, "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] diff --git a/projects/ngx-ui-watchtower/type-tests/augmented.ts b/projects/ngx-ui-watchtower/type-tests/augmented.ts new file mode 100644 index 0000000..8a6ef5c --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/augmented.ts @@ -0,0 +1,138 @@ +/** + * Module augmentation exactly as a consumer writes it: the four registries + * augmented on `@absaoss-cps/ngx-ui-watchtower`. Registered names must reach + * every API that takes or returns a name, and anything else must be + * rejected. + * + * Compiled by `npm run typecheck` on its own, never run. An unused + * `@ts-expect-error` fails the compilation, so a name that stops being + * rejected is caught too. + */ +import { inject } from '@angular/core'; +import { ObservedValueOf } from 'rxjs'; +import { + UwtBIEvent, + UwtBIEventName, + UwtBITelemetryService, + UwtLogConfig, + UwtLoggerName, + UwtLoggerService, + UwtLogQuery, + UwtLogRecord, + UwtScenario, + UwtScenarioAggregate, + UwtScenarioName, + UwtScenarioOptions, + UwtScenarioRecord, + UwtScenarioStep, + UwtScenarioStepEvent, + UwtScenarioTelemetryService, + UwtStepName, + withLogging +} from '@absaoss-cps/ngx-ui-watchtower'; + +declare module '@absaoss-cps/ngx-ui-watchtower' { + interface UwtScenarioNames { + checkout: true; + 'load-dashboard': true; + } + interface UwtScenarioSteps { + pay: true; + confirm: true; + } + interface UwtBIEventNames { + export_clicked: true; + } + interface UwtLoggerNames { + checkout: true; + } +} + +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +function expectType(): T | void { + // Checked at compile time only. +} + +// The names are exactly the registered ones. +expectType>(); +expectType>(); +expectType>(); +expectType>(); + +// And they reach every model that carries a name. +expectType>(); +expectType>(); +expectType< + Equals +>(); +expectType< + Equals< + UwtScenarioStep['name'], + UwtStepName | 'scenario-start' | 'scenario-end' + > +>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType< + Equals< + UwtLogConfig['levels'], + Partial> | undefined + > +>(); + +/** Registered names accepted, everything else rejected. */ +export class Usage { + private readonly scenarios = inject(UwtScenarioTelemetryService); + private readonly bi = inject(UwtBITelemetryService); + private readonly logs = inject(UwtLoggerService); + + accepted(): unknown[] { + const scenario = this.scenarios.start({ name: 'checkout' }); + scenario.step('pay').step('confirm').endStep(); + scenario.aggregateStart('pay').aggregateEnd('pay'); + this.bi.track('export_clicked', { format: 'csv' }); + this.logs.getLogger('checkout').warn('Slow payment'); + const settled = this.scenarios.settled$; + expectType< + Equals['scenarioName'], UwtScenarioName> + >(); + return [ + settled, + this.scenarios.findByName('load-dashboard'), + this.scenarios.findByNameAndId('checkout', 'id'), + this.logs.query({ logger: 'checkout' }), + withLogging({ levels: { checkout: 'log' } }) + ]; + } + + rejected(scenario: UwtScenario): void { + // @ts-expect-error not a registered scenario + this.scenarios.start({ name: 'chekout' }); + // @ts-expect-error not a registered scenario + this.scenarios.findByName('chekout'); + // @ts-expect-error not a registered scenario + this.scenarios.findByNameAndId('chekout', 'id'); + // @ts-expect-error not a registered step + scenario.step('pai'); + // @ts-expect-error not a registered step + scenario.aggregateStart('pai'); + // @ts-expect-error not a registered step + scenario.aggregateEnd('pai'); + // @ts-expect-error not a registered business event + this.bi.track('export_clikced'); + // @ts-expect-error not a registered logger + this.logs.getLogger('chekout'); + // @ts-expect-error not a registered logger + this.logs.query({ logger: 'chekout' }); + // @ts-expect-error not a registered logger + withLogging({ levels: { chekout: 'log' } }); + } +} diff --git a/projects/ngx-ui-watchtower/type-tests/compat.ts b/projects/ngx-ui-watchtower/type-tests/compat.ts new file mode 100644 index 0000000..1b2731b --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/compat.ts @@ -0,0 +1,366 @@ +/** + * Every public export of `@absaoss-cps/ngx-ui-watchtower` and its `/rum` + * entry point, used the way an application or an integration uses it. + * + * Compiled by `npm run typecheck`, never run. It must keep compiling + * unchanged: that is what "no consumer change" means for the types. No + * registry is augmented here, so every name is a plain string; see + * `augmented.ts` for registered names. + */ +import { ApplicationConfig, inject, Injectable, NgModule } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { + provideUwtTelemetry, + provideUwtTelemetryBroadcastHost, + provideUwtTelemetryDestination, + provideUwtTelemetrySink, + traceScenario, + UWT_BI_TELEMETRY_CONFIG, + UWT_BROADCAST_CHANNEL, + UWT_DEFAULT_BROADCAST_CHANNEL, + UWT_DEFAULT_EVENT_NAMESPACE, + UWT_DEFAULT_REDACT_CONFIG, + UWT_DEFAULT_TELEMETRY_CONFIG, + UWT_LOG_API_PROVIDER, + UWT_LOG_CONFIG, + UWT_LOG_LEVEL_ORDER, + UWT_REDACT_CONFIG, + UWT_REDACTED, + UWT_SCENARIO_TELEMETRY_CONFIG, + UWT_TELEMETRY_ENABLED, + UWT_TELEMETRY_EVENT_TYPE, + UWT_TELEMETRY_IDENTITY, + UwtBIEvent, + UwtBIEventDetail, + UwtBIEventName, + UwtBIEventNames, + UwtBITelemetryConfig, + UwtBITelemetryService, + UwtBroadcastLogApiProvider, + UwtBroadcastTelemetrySink, + uwtClassifyTelemetryEvent, + UwtDebugFlag, + uwtEventTypes, + uwtIsDebugEnabled, + UwtLogApiProvider, + UwtLogConfig, + UwtLogDetail, + UwtLogger, + UwtLoggerName, + UwtLoggerNames, + UwtLoggerService, + UwtLogLevel, + UwtLogQuery, + UwtLogRecord, + uwtMarkSessionStart, + uwtNormalizeError, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink, + UwtPiiValuePattern, + UwtRedactConfig, + uwtRedactConfigFor, + uwtRedactMetadata, + UwtScenario, + UwtScenarioAggregate, + UwtScenarioName, + UwtScenarioNames, + UwtScenarioOptions, + UwtScenarioOutcome, + UwtScenarioRecord, + UwtScenarioStatus, + UwtScenarioStep, + UwtScenarioStepDetail, + UwtScenarioStepEvent, + UwtScenarioStepStatus, + UwtScenarioSteps, + UwtScenarioTelemetryConfig, + UwtScenarioTelemetryService, + uwtScrubString, + UwtStepName, + UwtTelemetryAttribution, + UwtTelemetryBroadcastHost, + UwtTelemetryDestinationOptions, + UwtTelemetryError, + UwtTelemetryEventTypes, + UwtTelemetryFeature, + UwtTelemetryIdentity, + UwtTelemetryLocalSinkMode, + UwtTelemetryMetadata, + UwtTelemetrySink, + UwtTelemetrySinkEvent, + UwtTraceScenarioOptions, + uwtUuid, + withBIEvents, + withLogging, + withRedaction, + withScenarios, + withTelemetryEnabled +} from '@absaoss-cps/ngx-ui-watchtower'; +import { + provideUwtTelemetryRumSink, + UWT_RUM_CREDENTIALS_PROVIDER, + UwtRumAppMonitorConfig, + UwtRumBootstrap, + UwtRumCredentials, + UwtRumCredentialsProvider, + UwtRumTelemetrySink +} from '@absaoss-cps/ngx-ui-watchtower/rum'; + +/** A destination an application writes itself. */ +@Injectable() +export class AppSink extends UwtTelemetrySink { + record(_type: string, _payload: object, _meta?: UwtTelemetryMetadata): void { + // Sent elsewhere. + } + + recordError(_error: UwtTelemetryError, _meta?: UwtTelemetryMetadata): void { + // Sent elsewhere. + } + + getSessionId(): string | undefined { + return undefined; + } + + setUserId(_userId: string | undefined): void { + // Kept elsewhere. + } + + getUserId(): string | undefined { + return undefined; + } + + flush(_beacon?: boolean): void { + // Nothing buffered. + } +} + +/** A log backend. */ +@Injectable() +export class AppLogBackend implements UwtLogApiProvider { + readonly ranks: number[] = []; + + send(record: UwtLogRecord): void { + const level: UwtLogLevel = record.level; + this.ranks.push(UWT_LOG_LEVEL_ORDER[level]); + } + + query(_filter: UwtLogQuery): Promise { + return Promise.resolve([]); + } + + flush(): void { + // Nothing buffered. + } +} + +/** RUM credentials from the application's broker. */ +@Injectable() +export class AppRumCredentials implements UwtRumCredentialsProvider { + async load(): Promise { + const config: UwtRumAppMonitorConfig = { + applicationId: 'app-monitor', + region: 'eu-west-1', + applicationVersion: '1.0.0' + }; + const credentials: UwtRumCredentials = { + accessKeyId: 'a', + secretAccessKey: 's', + sessionToken: 't', + expiration: '2026-01-01T00:00:00Z' + }; + return { config, credentials }; + } +} + +const identity: UwtTelemetryIdentity = { + application: 'app', + environment: 'prod', + version: '1.0.0' +}; +const logConfig: Partial = { minLevel: 'warn' }; +const scenarioConfig: Partial = { maxSteps: 10 }; +const biConfig: Partial = {}; +const redactConfig: Partial = {}; +const features: UwtTelemetryFeature[] = [ + withLogging(logConfig), + withScenarios(scenarioConfig), + withBIEvents(biConfig), + withRedaction(redactConfig), + withTelemetryEnabled(() => true) +]; +const destination: UwtTelemetryDestinationOptions = { + init: (sink) => sink.flush() +}; +const mode: UwtTelemetryLocalSinkMode = 'broadcast'; + +/** Each way an application wires the library. */ +export const configs: ApplicationConfig[] = [ + { + providers: [ + provideUwtTelemetry(identity, ...features), + provideUwtTelemetryDestination(AppSink, destination), + { provide: UWT_LOG_API_PROVIDER, useClass: AppLogBackend } + ] + }, + { + providers: [ + provideUwtTelemetry(identity), + provideUwtTelemetrySink(mode, { channelName: 'app' }), + provideUwtTelemetrySink('noop') + ] + }, + { + providers: [ + provideUwtTelemetry(identity), + provideUwtTelemetryRumSink(), + { provide: UWT_RUM_CREDENTIALS_PROVIDER, useClass: AppRumCredentials }, + { provide: UWT_LOG_API_PROVIDER, useClass: UwtNoopLogApiProvider }, + provideUwtTelemetryBroadcastHost('app') + ] + }, + { + providers: [ + { provide: UWT_TELEMETRY_IDENTITY, useValue: identity }, + { + provide: UWT_LOG_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.logs + }, + { + provide: UWT_SCENARIO_TELEMETRY_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.scenario + }, + { + provide: UWT_BI_TELEMETRY_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.bi + }, + { provide: UWT_REDACT_CONFIG, useValue: UWT_DEFAULT_REDACT_CONFIG }, + { provide: UWT_TELEMETRY_ENABLED, useValue: true }, + { + provide: UWT_BROADCAST_CHANNEL, + useValue: UWT_DEFAULT_BROADCAST_CHANNEL + } + ] + } +]; + +/** The facades, as application code injects and calls them. */ +export class Checkout { + private readonly scenarios = inject(UwtScenarioTelemetryService); + private readonly bi = inject(UwtBITelemetryService); + private readonly logger: UwtLogger = inject(UwtLoggerService).getLogger('x'); + private readonly logs = inject(UwtLoggerService); + readonly classes = [ + UwtBroadcastTelemetrySink, + UwtNoopTelemetrySink, + UwtRumTelemetrySink, + UwtTelemetryBroadcastHost, + UwtBroadcastLogApiProvider + ]; + + pay(): unknown[] { + const options: UwtScenarioOptions = { name: 'checkout', timeoutMs: 5000 }; + const scenario: UwtScenario = this.scenarios.start(options); + scenario.step('pay', { cart: 'c-1' }).endStep({ metadata: { ok: true } }); + scenario.aggregateStart('validate').aggregateEnd('validate'); + scenario.setData({ items: 2 }); + const detail: UwtScenarioStepDetail = {}; + scenario.failStep(new Error('retry'), detail); + const outcome: UwtScenarioOutcome = { statusCode: 200 }; + const traced: UwtTraceScenarioOptions = { + outcome: (value) => ({ metadata: { value } }), + cancelOutcome: outcome + }; + const settled: Observable = this.scenarios.settled$; + const found: UwtScenario[] = [ + ...this.scenarios.findByName('checkout'), + ...this.scenarios.getActive() + ]; + return [ + settled, + found, + this.scenarios.find('id'), + this.scenarios.findByNameAndId('checkout', 'id'), + of(1).pipe(traceScenario(scenario, traced)) + ]; + } + + track(): Promise { + const name: UwtBIEventName = 'export_clicked'; + const biDetail: UwtBIEventDetail = {}; + this.bi.track(name, { format: 'csv' }, biDetail); + const logDetail: UwtLogDetail = { error: new Error('x') }; + this.logger.warn('careful', logDetail); + return this.logs.query({ logger: 'x', limit: 1 }); + } +} + +/** The utilities an application or a sink calls. */ +export function utilities(): unknown[] { + const types: UwtTelemetryEventTypes = uwtEventTypes( + UWT_DEFAULT_EVENT_NAMESPACE + ); + const event: UwtTelemetrySinkEvent = uwtClassifyTelemetryEvent( + UWT_TELEMETRY_EVENT_TYPE.bi, + {} + ); + const flag: UwtDebugFlag = 'debugScenario'; + const redact: UwtRedactConfig = uwtRedactConfigFor( + UWT_DEFAULT_REDACT_CONFIG, + true + ); + const patterns: UwtPiiValuePattern[] = redact.scanValuePatterns; + uwtMarkSessionStart(uwtUuid()); + return [ + types, + event, + uwtIsDebugEnabled(flag), + uwtScrubString('a@b.c', redact), + uwtRedactMetadata({ email: 'a@b.c' }, redact), + uwtNormalizeError(new Error('x'), redact), + patterns, + UWT_REDACTED + ]; +} + +/** Every remaining type, as an integration names it. */ +export interface Everything { + attribution: UwtTelemetryAttribution; + biEvent: UwtBIEvent; + biNames: UwtBIEventNames; + logger: UwtLoggerName; + loggerNames: UwtLoggerNames; + aggregate: UwtScenarioAggregate; + scenario: UwtScenarioName; + scenarioNames: UwtScenarioNames; + status: UwtScenarioStatus; + step: UwtScenarioStep; + stepEvent: UwtScenarioStepEvent; + stepStatus: UwtScenarioStepStatus; + steps: UwtScenarioSteps; + stepName: UwtStepName; + error: UwtTelemetryError; +} + +/** + * Every injectable class an application may list as a provider in a + * decorator. That each keeps its Angular metadata (`ɵfac`, `ɵprov`) is held + * by the API snapshot; the compiler's own check skips library classes. + */ +@NgModule({ + providers: [ + UwtScenarioTelemetryService, + UwtBITelemetryService, + UwtLoggerService, + UwtNoopTelemetrySink, + UwtNoopLogApiProvider, + UwtBroadcastTelemetrySink, + UwtBroadcastLogApiProvider, + UwtTelemetryBroadcastHost, + UwtRumTelemetrySink, + AppSink, + AppLogBackend, + AppRumCredentials, + { provide: UwtTelemetrySink, useClass: UwtNoopTelemetrySink } + ] +}) +export class AppTelemetryModule {} diff --git a/projects/ngx-ui-watchtower/type-tests/empty-registry.ts b/projects/ngx-ui-watchtower/type-tests/empty-registry.ts new file mode 100644 index 0000000..b2a0838 --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/empty-registry.ts @@ -0,0 +1,28 @@ +/** + * Nothing augmented: every name falls back to `string`, so an application + * that never declares a registry can use any name. + * + * Compiled by `npm run typecheck` with `compat.ts`, never run. It must not + * share a compilation with `augmented.ts`, whose augmentation would apply + * here too. + */ +import { + UwtBIEventName, + UwtLoggerName, + UwtScenarioName, + UwtStepName +} from '@absaoss-cps/ngx-ui-watchtower'; + +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +function expectType(): T | void { + // Checked at compile time only. +} + +expectType>(); +expectType>(); +expectType>(); +expectType>(); diff --git a/projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json b/projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json new file mode 100644 index 0000000..87beb04 --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json @@ -0,0 +1,5 @@ +/* The registries augmented, kept apart from the empty-registry fallback. */ +{ + "extends": "./tsconfig.json", + "files": ["augmented.ts"] +} diff --git a/projects/ngx-ui-watchtower/type-tests/tsconfig.json b/projects/ngx-ui-watchtower/type-tests/tsconfig.json new file mode 100644 index 0000000..6180b30 --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/tsconfig.json @@ -0,0 +1,14 @@ +/* + * Consumer type tests, compiled against the public entry points by the + * Angular compiler (`ngc`), so Angular's own checks apply as in a consumer + * build. `compat.ts` and `empty-registry.ts` share this compilation; the + * augmentation in `augmented.ts` would leak into them, so it has its own. + */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "files": ["compat.ts", "empty-registry.ts"] +} diff --git a/projects/ui-watchtower/LICENSE b/projects/ui-watchtower/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/projects/ui-watchtower/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md new file mode 100644 index 0000000..f272257 --- /dev/null +++ b/projects/ui-watchtower/README.md @@ -0,0 +1,246 @@ +

+ + + + UI Watchtower + +

+ +# ui-watchtower + +Telemetry for any frontend, in three parts: + +- **Scenarios** — did a user journey or a workflow succeed, and how long did + it take? +- **BI events** — which features are used? +- **Logs** — what exactly happened? + +It needs no framework and no adapter: install it and call it directly, from +plain JavaScript or TypeScript or from an application built with any +framework. It imports nothing framework-specific and touches no browser +global when imported, so it also loads in Node, on a server and in a worker. + +Adapters are optional. For Angular there is +[`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower), +which wraps this package in providers and injectable services. Adapters for +other frameworks can be added the same way. + +## Install + +```bash +npm install @absaoss-cps/ui-watchtower +npm install aws-rum-web # only for @absaoss-cps/ui-watchtower/rum +npm install rxjs # only for @absaoss-cps/ui-watchtower/rxjs +``` + +## Use + +Create one engine per application, with its identity, where telemetry goes +and where logs go: + +```ts +import { + uwtCreateTelemetry, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink +} from '@absaoss-cps/ui-watchtower'; + +const telemetry = uwtCreateTelemetry({ + identity: { application: 'cart', environment: 'prod', version: '1.4.0' }, + destination: new UwtNoopTelemetrySink(), // or your own UwtTelemetrySink + logApi: new UwtNoopLogApiProvider(), // or your own UwtLogApiProvider + logging: { minLevel: 'warn' } // optional overrides, per concern +}); + +// Scenarios: a user journey, its steps and outcome. +const scenario = telemetry.scenarios.start({ name: 'checkout' }); +scenario.step('pay'); +scenario.complete({ metadata: { items: 2 } }); + +// BI events. +telemetry.bi.track('export_clicked', { format: 'csv' }); + +// Logs, by named logger. +telemetry.logging.getLogger('checkout').warn('Slow payment'); + +// When the application, or a fragment, goes away. +telemetry.dispose(); +``` + +Every call is fail-open: telemetry never throws into your application. +Configuration errors still throw, when the engine is created. + +| Option | What it does | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `identity` | Application, environment, version and optional `eventNamespace`, carried by every record | +| `destination` | A `UwtTelemetrySink`: where scenarios, BI events and mirrored errors go | +| `logApi` | A `UwtLogApiProvider`: where logs go, and are read back from | +| `scenarios`, `logging`, `bi`, `redaction` | Overrides of each concern's defaults | +| `enabled` | `false` makes everything inert: nothing is recorded, sent or logged | +| `document` | The page whose lifecycle settles and flushes telemetry; the global one by default, `null` for none | +| `report` | Receives what telemetry skips or drops; nothing is reported without one | +| `runOutside` | Schedules timers outside a framework's change detection | + +`dispose()` stops watching the page, settles scenarios still in flight as +abandoned and flushes; no listener is left behind, and nothing the engine had +scheduled. That is all it changes. Something may still run while your +application shuts down, and the destination and log store you passed are +yours, so scenarios, BI events and logs started afterwards work as before. You +close the destination and the log store. + +### Turning telemetry off + +Keep the same calls and pass one switch: + +```ts +const telemetry = uwtCreateTelemetry({ ...options, enabled: false }); +``` + +When off, nothing is recorded, sent or logged, and nothing listens to the +page. Your code doesn't change: `start()` still returns a scenario (inert: +it records nothing and never settles), lookups find nothing, loggers do +nothing and `query()` resolves `[]`. Pass the switch on to what you created +for the engine, so that off it requests no credentials, downloads no SDK and +opens no channel: `enabled` to the RUM sink and the broadcast classes, and +`open` to `uwtConnectBroadcastChannel()`. Off, a sink or log provider sends +nothing even over a shared connection, but only `open: false` keeps the +channel itself from opening. + +## AWS CloudWatch RUM + +The RUM destination lives in its own entry point, so the main one never +refers to `aws-rum-web`; the SDK is downloaded only once the sink starts. +Your credentials provider supplies the app monitor settings and short-lived +credentials, from your own broker: + +```ts +import { UwtRumTelemetrySink } from '@absaoss-cps/ui-watchtower/rum'; + +const destination = new UwtRumTelemetrySink({ + identity, + credentialsProvider: { + async load() { + const response = await fetch('/rum/init'); + return response.ok ? response.json() : null; // null: RUM off for the page + } + } +}); +destination.init(); // not awaited: never delays the page + +const telemetry = uwtCreateTelemetry({ identity, destination, logApi }); +``` + +Startup is retried with backoff for five minutes, credentials are refreshed +before they expire, and events recorded before the client starts are +buffered. Without a page, as on a server, it never starts. `dispose()` stops +the client. + +## Shell and fragments + +In a page composed of fragments, each in its own JavaScript realm, the shell +sends everything and the fragments forward to it over a `BroadcastChannel`, +so a user is one session, not one per fragment: + +```ts +// shell: records what fragments forward, through its own destination and +// log store +const host = new UwtTelemetryBroadcastHost({ sink: destination, logApi }); + +// each fragment: forwards everything, over one channel +const identity = { application: 'cart', environment: 'prod', version: '1.4.0' }; +const connection = uwtConnectBroadcastChannel(); +const telemetry = uwtCreateTelemetry({ + identity, + destination: new UwtBroadcastTelemetrySink({ identity, connection }), + logApi: new UwtBroadcastLogApiProvider({ connection }) +}); +``` + +Each side can use a different framework, or none: they exchange the same +messages. Give each fragment its own `application` name, and the shell's +`eventNamespace`. + +Who owns what: + +| Realm | Destination | RUM client and credentials | Log store | +| -------------- | ----------------------------------------------- | ----------------------------------- | ------------------------------------------------- | +| Standalone app | Its own: `UwtRumTelemetrySink`, or another sink | The app's | The app's | +| Shell | Its own, plus a `UwtTelemetryBroadcastHost` | The shell's: the only one on a page | The shell's, which also stores fragments' records | +| Fragment | `UwtBroadcastTelemetrySink` | None: the shell's records for it | None: `UwtBroadcastLogApiProvider` forwards | + +A fragment reports the shell's session and user. Whoever creates something +disposes it: dispose the engine, then the host, the sink, the log provider +and the connection, when their realm goes away. + +## Declare your names + +Scenario, step, event and logger names are typed, so a typo is a compile +error instead of a broken dashboard. Declare them once: + +```ts +// src/telemetry.schema.ts +declare module '@absaoss-cps/ui-watchtower' { + interface UwtScenarioNames { + 'load-customers': true; + } + interface UwtScenarioSteps { + fetch: true; + } + interface UwtBIEventNames { + export_clicked: true; + } + interface UwtLoggerNames { + checkout: true; + } +} + +export {}; +``` + +Until you declare any, plain strings are accepted. + +## Seeing what telemetry skips + +Hand the engine a `UwtReporter` where you want to see what it skipped +(`'error'`) or dropped (`'warn'`), typically in development; +`uwtConsoleReporter` prints both to the console: + +```ts +uwtCreateTelemetry({ ...options, report: uwtConsoleReporter }); +``` + +Something that cannot work as configured (`'config'`) — a second broadcast +host, RUM settings of the wrong type — is reported too, and printed to the +console when there is no reporter: it is never silent. + +## RxJS + +`traceScenario` settles a scenario from an Observable's outcome. It lives in +its own entry point, so the main one never loads RxJS: + +```ts +import { traceScenario } from '@absaoss-cps/ui-watchtower/rxjs'; + +customers$.pipe(traceScenario(scenario)).subscribe(); +``` + +## API + +| Group | Exports | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Engine | `uwtCreateTelemetry`, `UwtTelemetry`, `UwtTelemetryOptions`, `UwtTelemetryDocument` | +| Concerns | `UwtScenarioTelemetry`, `UwtBITelemetry`, `UwtLogTelemetry` and their options, to compose them yourself | +| Configuration | `UwtTelemetryIdentity`, the per-concern config types, `UWT_DEFAULT_TELEMETRY_CONFIG`, `uwtResolveTelemetryConfig` | +| Models | scenario, step, BI event and log record types; `UWT_LOG_LEVEL_ORDER`, `UWT_TELEMETRY_EVENT_TYPE`, `uwtEventTypes` | +| Names | `UwtScenarioNames`, `UwtScenarioSteps`, `UwtBIEventNames`, `UwtLoggerNames` and their `Uwt*Name` types | +| Scenarios | `UwtScenario` | +| Contracts | `UwtTelemetrySink` and `UwtNoopTelemetrySink`; `UwtLogApiProvider`, `UwtLogQuery` and `UwtNoopLogApiProvider` | +| Broadcast | `UwtTelemetryBroadcastHost`, `UwtBroadcastTelemetrySink`, `UwtBroadcastLogApiProvider`, `uwtConnectBroadcastChannel`, `UWT_DEFAULT_BROADCAST_CHANNEL` | +| Redaction | `uwtRedactMetadata`, `uwtScrubString`, `uwtNormalizeError`, `uwtRedactConfigFor`, `UwtRedactConfig`, `UWT_DEFAULT_REDACT_CONFIG`, `UWT_REDACTED` | +| Utilities | `uwtClassifyTelemetryEvent`, `uwtIsDebugEnabled`, `uwtMarkSessionStart`, `uwtSessionElapsed`, `uwtUuid`, `uwtSafe*`, `uwtReport`, `uwtConsoleReporter`, `UwtReporter` | +| `/rxjs` | `traceScenario`, `UwtTraceScenarioOptions` | +| `/rum` | `UwtRumTelemetrySink` and its options; `UwtRumCredentialsProvider`, `UwtRumBootstrap`, `UwtRumAppMonitorConfig`, `UwtRumCredentials` | + +## License + +Apache-2.0 diff --git a/projects/ui-watchtower/jest.config.js b/projects/ui-watchtower/jest.config.js new file mode 100644 index 0000000..f9fe823 --- /dev/null +++ b/projects/ui-watchtower/jest.config.js @@ -0,0 +1,29 @@ +/** + * Unit tests of the framework-independent core: plain Jest and ts-jest, + * without Angular or zone.js. The environment is Node, so nothing can lean + * on browser globals by accident; a spec that needs them opts into jsdom + * with a `@jest-environment jsdom` docblock. + */ +module.exports = { + displayName: 'ui-watchtower', + rootDir: __dirname, + roots: ['/src', '/rxjs', '/rum'], + testEnvironment: 'node', + transform: { + '^.+\\.ts$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] + }, + moduleNameMapper: { + '^@absaoss-cps/ui-watchtower$': '/src/public-api.ts', + '^@absaoss-cps/ui-watchtower/rxjs$': '/rxjs/src/public-api.ts', + '^@absaoss-cps/ui-watchtower/rum$': '/rum/src/public-api.ts' + }, + coverageDirectory: '/../../coverage/ui-watchtower', + collectCoverageFrom: [ + '{src,rxjs,rum}/**/*.ts', + '!**/*.spec.ts', + '!**/public-api.ts' + ], + coverageThreshold: { + global: { statements: 90, branches: 90, functions: 90, lines: 90 } + } +}; diff --git a/projects/ui-watchtower/ng-package.json b/projects/ui-watchtower/ng-package.json new file mode 100644 index 0000000..6bfafcd --- /dev/null +++ b/projects/ui-watchtower/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/ui-watchtower", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/ui-watchtower/package.json b/projects/ui-watchtower/package.json new file mode 100644 index 0000000..bc739c4 --- /dev/null +++ b/projects/ui-watchtower/package.json @@ -0,0 +1,30 @@ +{ + "name": "@absaoss-cps/ui-watchtower", + "version": "0.1.0", + "description": "Framework-independent telemetry engine: application logs, scenario health telemetry and business/UX events, for any JavaScript or TypeScript application.", + "repository": { + "type": "git", + "url": "https://github.com/AbsaOSS/ui-watchtower" + }, + "homepage": "https://github.com/AbsaOSS/ui-watchtower", + "license": "Apache-2.0", + "peerDependencies": { + "aws-rum-web": "^3.2.1", + "rxjs": "^7.8.2" + }, + "peerDependenciesMeta": { + "aws-rum-web": { + "optional": true + }, + "rxjs": { + "optional": true + } + }, + "dependencies": { + "tslib": "^2.8.1" + }, + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/projects/ui-watchtower/public-api.snapshot.json b/projects/ui-watchtower/public-api.snapshot.json new file mode 100644 index 0000000..9108ac3 --- /dev/null +++ b/projects/ui-watchtower/public-api.snapshot.json @@ -0,0 +1,656 @@ +{ + "@absaoss-cps/ui-watchtower": { + "UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS": [ + "const: 10000" + ], + "UWT_DEFAULT_BROADCAST_CHANNEL": [ + "const: 'ngx-ui-watchtower'" + ], + "UWT_DEFAULT_EVENT_NAMESPACE": [ + "const: 'com.uwt'" + ], + "UWT_DEFAULT_REDACT_CONFIG": [ + "const: UwtRedactConfig" + ], + "UWT_DEFAULT_TELEMETRY_CONFIG": [ + "const: { eventNamespace: string; scenario: UwtScenarioTelemetryConfig; logs: UwtLogConfig; bi: UwtBITelemetryConfig; redact: UwtRedactConfig; }" + ], + "UWT_LOG_LEVEL_ORDER": [ + "const: Record" + ], + "UWT_REDACTED": [ + "const: '[redacted]'" + ], + "UWT_TELEMETRY_EVENT_TYPE": [ + "const: UwtTelemetryEventTypes" + ], + "UwtBIEvent": [ + "interface", + "application: string", + "eventName: string", + "eventTime: string", + "feature?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "scenarioId?: string | undefined", + "sessionId?: string | undefined", + "userId?: string | undefined" + ], + "UwtBIEventDetail": [ + "interface", + "feature?: string | undefined", + "scenarioId?: string | undefined" + ], + "UwtBIEventName": [ + "type = string" + ], + "UwtBIEventNames": [ + "interface" + ], + "UwtBITelemetry": [ + "class", + "new (options: UwtBITelemetryOptions): UwtBITelemetry", + "dispose(): void", + "track(eventName: string, metadata?: UwtTelemetryMetadata | undefined, detail?: UwtBIEventDetail | undefined): void" + ], + "UwtBITelemetryConfig": [ + "interface", + "dedupMaxKeys: number", + "dedupWindowMs: number", + "redact: boolean" + ], + "UwtBITelemetryOptions": [ + "interface", + "config: UwtBITelemetryConfig", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "redact: UwtRedactConfig", + "report?: UwtReporter | undefined", + "sink: UwtTelemetrySink" + ], + "UwtBroadcastConnection": [ + "interface", + "close(): void", + "isOpen(): boolean", + "onMessage(handler: (data: unknown) => void): () => void", + "post(message: UwtBroadcastMessage): void", + "readonly channelName: string" + ], + "UwtBroadcastConnectOptions": [ + "interface", + "channelName?: string | undefined", + "open?: boolean | undefined", + "operation?: string | undefined", + "report?: UwtReporter | undefined" + ], + "UwtBroadcastLogApiProvider": [ + "class", + "new (options?: UwtBroadcastLogApiProviderOptions | undefined): UwtBroadcastLogApiProvider", + "dispose(): void", + "flush(): void", + "query(filter: UwtLogQuery): Promise", + "send(record: UwtLogRecord): void" + ], + "UwtBroadcastLogApiProviderOptions": [ + "interface", + "channelName?: string | undefined", + "connection?: UwtBroadcastConnection | undefined", + "enabled?: boolean | undefined", + "report?: UwtReporter | undefined", + "runInside?: ((callback: () => T) => T) | undefined", + "runOutside?: ((callback: () => T) => T) | undefined" + ], + "UwtBroadcastMessage": [ + "type = { kind: 'event'; eventType: string; payload: object; metadata?: UwtTelemetryMetadata | undefined; } | { kind: 'error'; error: UwtTelemetryError; metadata?: UwtTelemetryMetadata | undefined; } | { kind: 'user'; userId: string | undefined; } | { kind: 'flush'; beacon: boolean; } | { kind: 'identity-request'; } | { kind: 'identity'; sessionId?: string | undefined; userId?: string | undefined; } | { kind: 'log'; record: UwtLogRecord; } | { kind: 'log-flush'; } | { kind: 'log-query'; id: string; filter: UwtLogQuery; } | { kind: 'log-query-result'; id: string; records: unknown[]; }" + ], + "UwtBroadcastTelemetrySink": [ + "class", + "new (options: UwtBroadcastTelemetrySinkOptions): UwtBroadcastTelemetrySink", + "dispose(): void", + "flush(beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "setUserId(userId: string | undefined): void" + ], + "UwtBroadcastTelemetrySinkOptions": [ + "interface", + "channelName?: string | undefined", + "connection?: UwtBroadcastConnection | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "report?: UwtReporter | undefined" + ], + "uwtClassifyTelemetryEvent": [ + "function(eventType: string, payload: object): UwtTelemetrySinkEvent" + ], + "uwtConnectBroadcastChannel": [ + "function(options?: UwtBroadcastConnectOptions | undefined): UwtBroadcastConnection" + ], + "uwtConsoleReporter": [ + "const: UwtReporter" + ], + "uwtCreateTelemetry": [ + "function(options: UwtTelemetryOptions): UwtTelemetry" + ], + "UwtDebugFlag": [ + "type = 'debugLogger' | 'debugScenario' | 'debugBI'" + ], + "uwtEventTypes": [ + "function(namespace?: string | undefined): UwtTelemetryEventTypes" + ], + "uwtIsDebugEnabled": [ + "function(flag: UwtDebugFlag, name?: string | undefined): boolean" + ], + "UwtLogApiProvider": [ + "interface", + "flush?(): void", + "query(filter: UwtLogQuery): Promise", + "send(record: UwtLogRecord): void" + ], + "UwtLogConfig": [ + "interface", + "levels?: Partial> | undefined", + "minLevel: UwtLogLevel", + "mirrorErrorsToRum: boolean", + "redact: boolean" + ], + "UwtLogDetail": [ + "interface", + "context?: string | undefined", + "correlationId?: string | undefined", + "error?: unknown", + "metadata?: UwtTelemetryMetadata | undefined" + ], + "UwtLogger": [ + "interface", + "error(message: string, detail?: UwtLogDetail | undefined): void", + "log(message: string, detail?: UwtLogDetail | undefined): void", + "warn(message: string, detail?: UwtLogDetail | undefined): void" + ], + "UwtLoggerName": [ + "type = string" + ], + "UwtLoggerNames": [ + "interface" + ], + "UwtLogLevel": [ + "type = 'log' | 'warn' | 'error'" + ], + "UwtLogQuery": [ + "interface", + "correlationId?: string | undefined", + "from?: string | undefined", + "limit?: number | undefined", + "logger?: string | undefined", + "minLevel?: UwtLogLevel | undefined", + "to?: string | undefined" + ], + "UwtLogRecord": [ + "interface", + "application: string", + "context?: string | undefined", + "correlationId?: string | undefined", + "environment: string", + "error?: UwtTelemetryError | undefined", + "level: UwtLogLevel", + "logger?: string | undefined", + "message: string", + "metadata?: UwtTelemetryMetadata | undefined", + "sessionId?: string | undefined", + "timestamp: string", + "userId?: string | undefined", + "version: string" + ], + "UwtLogTelemetry": [ + "class", + "new (options: UwtLogTelemetryOptions): UwtLogTelemetry", + "dispose(): void", + "getLogger(name: string): UwtLogger", + "query(filter?: UwtLogQuery | undefined): Promise" + ], + "UwtLogTelemetryOptions": [ + "interface", + "config: UwtLogConfig", + "document?: UwtTelemetryDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "logApi: UwtLogApiProvider", + "redact: UwtRedactConfig", + "report?: UwtReporter | undefined", + "sink?: UwtTelemetrySink | null | undefined" + ], + "uwtMarkSessionStart": [ + "function(sessionId: string, now?: number | undefined): void" + ], + "UwtNoopLogApiProvider": [ + "class", + "new (): UwtNoopLogApiProvider", + "query(_filter: UwtLogQuery): Promise", + "send(_record: UwtLogRecord): void" + ], + "UwtNoopTelemetrySink": [ + "class", + "new (): UwtNoopTelemetrySink", + "flush(_beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "record(_eventType: string, _payload: object, _metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(_error: UwtTelemetryError, _metadata?: UwtTelemetryMetadata | undefined): void", + "setUserId(userId: string | undefined): void" + ], + "uwtNormalizeError": [ + "function(error: unknown, config?: UwtRedactConfig | undefined, report?: UwtReporter | undefined): UwtTelemetryError | undefined" + ], + "UwtPiiValuePattern": [ + "type = 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'" + ], + "UwtRedactConfig": [ + "interface", + "extraKeyPatterns: RegExp[]", + "extraValuePatterns: RegExp[]", + "extraValueTransforms: ((value: string) => string)[]", + "includeStack: boolean", + "maxKeys: number", + "maxStackLength: number", + "maxStringLength: number", + "scanValuePatterns: UwtPiiValuePattern[]", + "stripUrlQuery: boolean" + ], + "uwtRedactConfigFor": [ + "function(config: UwtRedactConfig, enabled: boolean): UwtRedactConfig" + ], + "uwtRedactMetadata": [ + "function(value: unknown, config?: UwtRedactConfig | undefined, report?: UwtReporter | undefined): UwtTelemetryMetadata | undefined" + ], + "uwtReport": [ + "function(report: UwtReporter | undefined, level: UwtReportLevel, message: string, ...details: unknown[]): void" + ], + "UwtReporter": [ + "type = (level: UwtReportLevel, message: string, ...details: unknown[]) => void" + ], + "UwtReportLevel": [ + "type = 'warn' | 'error' | 'config'" + ], + "uwtResolveTelemetryConfig": [ + "function(overrides?: UwtTelemetryConfigOverrides | undefined): UwtTelemetryConfig" + ], + "uwtSafe": [ + "function(operation: string, fn: () => T, fallback: T, report?: UwtReporter | undefined): T" + ], + "uwtSafeVoid": [ + "function(operation: string, fn: () => void, report?: UwtReporter | undefined): void" + ], + "uwtSafeVoidMaybeAsync": [ + "function(operation: string, fn: () => void, report?: UwtReporter | undefined): void" + ], + "UwtScenario": [ + "class", + "new (options: UwtScenarioOptions, deps: UwtScenarioDeps): UwtScenario", + "aggregateEnd(name: string): UwtScenario", + "aggregateStart(name: string): UwtScenario", + "cancel(outcome?: UwtScenarioOutcome | undefined): void", + "complete(outcome?: UwtScenarioOutcome | undefined): void", + "endStep(detail?: UwtScenarioStepDetail | undefined): UwtScenario", + "fail(outcome?: UwtScenarioOutcome | undefined): void", + "failStep(error: unknown, detail?: UwtScenarioStepDetail | undefined): UwtScenario", + "get delta: number", + "get id: string", + "get isSettled: boolean", + "get name: string", + "get status: UwtScenarioStatus | undefined", + "incomplete(outcome?: UwtScenarioOutcome | undefined): void", + "setData(metadata: UwtTelemetryMetadata): UwtScenario", + "settle(status: UwtScenarioStatus, outcome?: UwtScenarioOutcome | undefined, error?: unknown): void", + "step(name: string, metadata?: UwtTelemetryMetadata | undefined): UwtScenario", + "toRecord(): UwtScenarioRecord" + ], + "UwtScenarioAggregate": [ + "interface", + "callCount: number", + "elapsed: number", + "name: string" + ], + "UwtScenarioName": [ + "type = string" + ], + "UwtScenarioNames": [ + "interface" + ], + "UwtScenarioOptions": [ + "interface", + "feature?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "operation?: string | undefined", + "parentScenarioId?: string | undefined", + "route?: string | undefined", + "startedAt?: number | undefined", + "timeoutMs?: number | undefined" + ], + "UwtScenarioOutcome": [ + "interface", + "error?: unknown", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "reason?: string | undefined", + "statusCode?: string | number | undefined" + ], + "UwtScenarioRecord": [ + "interface", + "aggregates?: UwtScenarioAggregate[] | undefined", + "application: string", + "delta: number", + "elapsed: number", + "endTime?: string | undefined", + "error?: UwtTelemetryError | undefined", + "exceededStepsLimit?: boolean | undefined", + "feature?: string | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "operation?: string | undefined", + "parentScenarioId?: string | undefined", + "previousStep?: string | undefined", + "reason?: string | undefined", + "route?: string | undefined", + "scenarioId: string", + "scenarioName: string", + "sessionElapsed?: number | undefined", + "sessionId?: string | undefined", + "startTime: string", + "status?: UwtScenarioStatus | undefined", + "statusCode?: string | number | undefined", + "stepCount: number", + "steps: UwtScenarioStep[]", + "userId?: string | undefined" + ], + "UwtScenarioStatus": [ + "type = 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'" + ], + "UwtScenarioStep": [ + "interface", + "elapsed?: number | undefined", + "endOffset?: number | undefined", + "error?: UwtTelemetryError | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "reason?: string | undefined", + "startOffset: number", + "status?: UwtScenarioStatus | undefined", + "stepDelta?: number | undefined" + ], + "UwtScenarioStepDetail": [ + "interface", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "reason?: string | undefined" + ], + "UwtScenarioStepEvent": [ + "interface", + "application: string", + "elapsed?: number | undefined", + "endOffset?: number | undefined", + "error?: UwtTelemetryError | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "reason?: string | undefined", + "scenarioId: string", + "scenarioName: string", + "sessionId?: string | undefined", + "startOffset: number", + "status?: UwtScenarioStatus | undefined", + "stepDelta?: number | undefined", + "userId?: string | undefined" + ], + "UwtScenarioSteps": [ + "interface" + ], + "UwtScenarioStepStatus": [ + "type = 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'" + ], + "UwtScenarioTelemetry": [ + "class", + "new (options: UwtScenarioTelemetryOptions): UwtScenarioTelemetry", + "dispose(): void", + "find(scenarioId: string): UwtScenario | undefined", + "findByName(name: string): UwtScenario[]", + "findByNameAndId(name: string, scenarioId: string): UwtScenario | undefined", + "getActive(): UwtScenario[]", + "onSettled(listener: (record: UwtScenarioRecord) => void): () => void", + "start(options: UwtScenarioOptions): UwtScenario" + ], + "UwtScenarioTelemetryConfig": [ + "interface", + "defaultTimeoutMs: number", + "emitLifecycleEvents: boolean", + "markCleanupFallbackMs: number", + "maxSteps: number", + "redact: boolean", + "userTimings: boolean" + ], + "UwtScenarioTelemetryOptions": [ + "interface", + "config: UwtScenarioTelemetryConfig", + "document?: UwtTelemetryDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "redact: UwtRedactConfig", + "report?: UwtReporter | undefined", + "runOutside?: ((callback: () => T) => T) | undefined", + "sink: UwtTelemetrySink" + ], + "uwtScrubString": [ + "function(value: string, config: UwtRedactConfig, report?: UwtReporter | undefined): string" + ], + "uwtSessionElapsed": [ + "function(sessionId: string | undefined, now?: number | undefined): number | undefined" + ], + "UwtStepName": [ + "type = string" + ], + "UwtTelemetry": [ + "interface", + "dispose(): void", + "flush(beacon?: boolean | undefined): void", + "readonly bi: UwtBITelemetry", + "readonly logging: UwtLogTelemetry", + "readonly scenarios: UwtScenarioTelemetry", + "setUserId(userId: string | undefined): void" + ], + "UwtTelemetryAttribution": [ + "interface", + "application: string", + "sessionId?: string | undefined", + "userId?: string | undefined" + ], + "UwtTelemetryBroadcastHost": [ + "class", + "new (options: UwtTelemetryBroadcastHostOptions): UwtTelemetryBroadcastHost", + "dispose(): void", + "get received: number" + ], + "UwtTelemetryBroadcastHostOptions": [ + "interface", + "channelName?: string | undefined", + "enabled?: boolean | undefined", + "logApi: UwtLogApiProvider", + "report?: UwtReporter | undefined", + "sink: UwtTelemetrySink" + ], + "UwtTelemetryConfig": [ + "interface", + "bi: UwtBITelemetryConfig", + "logging: UwtLogConfig", + "redaction: UwtRedactConfig", + "scenarios: UwtScenarioTelemetryConfig" + ], + "UwtTelemetryConfigOverrides": [ + "interface", + "bi?: Partial | undefined", + "logging?: Partial | undefined", + "redaction?: Partial | undefined", + "scenarios?: Partial | undefined" + ], + "UwtTelemetryDocument": [ + "interface", + "addEventListener(type: 'visibilitychange', listener: () => void): void", + "readonly defaultView: UwtListenable<'pagehide'> | null", + "readonly visibilityState: string", + "removeEventListener(type: 'visibilitychange', listener: () => void): void" + ], + "UwtTelemetryError": [ + "interface", + "message: string", + "name: string", + "stack?: string | undefined" + ], + "UwtTelemetryEventTypes": [ + "interface", + "bi: string", + "scenario: string", + "scenarioStep: string" + ], + "UwtTelemetryIdentity": [ + "interface", + "application: string", + "environment: string", + "eventNamespace?: string | undefined", + "version: string" + ], + "UwtTelemetryMetadata": [ + "type = { [x: string]: string | number | boolean | null; }" + ], + "UwtTelemetryOptions": [ + "interface", + "bi?: Partial | undefined", + "destination: UwtTelemetrySink", + "document?: UwtTelemetryDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "logApi: UwtLogApiProvider", + "logging?: Partial | undefined", + "redaction?: Partial | undefined", + "report?: UwtReporter | undefined", + "runOutside?: ((callback: () => T) => T) | undefined", + "scenarios?: Partial | undefined" + ], + "UwtTelemetrySink": [ + "abstract class", + "new (): UwtTelemetrySink", + "abstract flush(beacon?: boolean | undefined): void", + "abstract getSessionId(): string | undefined", + "abstract getUserId(): string | undefined", + "abstract record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "abstract recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "abstract setUserId(userId: string | undefined): void" + ], + "UwtTelemetrySinkEvent": [ + "type = { kind: 'scenario'; payload: UwtScenarioRecord; } | { kind: 'scenario-step'; payload: UwtScenarioStepEvent; } | { kind: 'bi'; payload: UwtBIEvent; } | { kind: 'unknown'; payload: object; }" + ], + "uwtUuid": [ + "function(): string" + ] + }, + "@absaoss-cps/ui-watchtower/rum": { + "UwtRumAppMonitorConfig": [ + "interface", + "alias?: string | undefined", + "allowCookies?: boolean | undefined", + "applicationAttributes?: Record | undefined", + "applicationId: string", + "applicationVersion: string", + "batchLimit?: number | undefined", + "candidatesCacheSize?: number | undefined", + "client?: string | undefined", + "clientBuilder?: ((endpoint: URL, region: string, credentials?: ClientBuilderCredentials | undefined, compressionStrategy?: { enabled: boolean; } | undefined) => unknown) | undefined", + "compressionStrategy?: { enabled: boolean; } | undefined", + "cookieAttributes?: { unique?: boolean | undefined; domain?: string | undefined; path?: string | undefined; sameSite?: string | undefined; secure?: boolean | undefined; } | undefined", + "debug?: boolean | undefined", + "disableAutoPageView?: boolean | undefined", + "dispatchInterval?: number | undefined", + "enableRumClient?: boolean | undefined", + "enableW3CTraceId?: boolean | undefined", + "enableXRay?: boolean | undefined", + "endpoint?: string | undefined", + "eventCacheSize?: number | undefined", + "eventPluginsToLoad?: unknown[] | undefined", + "fetchFunction?: ((input: RequestInfo | URL, init?: RequestInit | undefined) => Promise) | undefined", + "headers?: Record | undefined", + "pageIdFormat?: 'PATH' | 'HASH' | 'PATH_AND_HASH' | undefined", + "pagesToExclude?: RegExp[] | undefined", + "pagesToInclude?: RegExp[] | undefined", + "recordResourceUrl?: boolean | undefined", + "region: string", + "releaseId?: string | undefined", + "retries?: number | undefined", + "routeChangeComplete?: number | undefined", + "routeChangeTimeout?: number | undefined", + "sessionAttributes?: Record | undefined", + "sessionEventLimit?: number | undefined", + "sessionLengthSeconds?: number | undefined", + "sessionSampleRate?: number | undefined", + "signing?: boolean | undefined", + "suppressSessionStartEvent?: boolean | undefined", + "telemetries?: (string | (string | object)[])[] | undefined", + "useBeacon?: boolean | undefined", + "userIdRetentionDays?: number | undefined" + ], + "UwtRumBootstrap": [ + "interface", + "config: UwtRumAppMonitorConfig", + "credentials?: UwtRumCredentials | undefined" + ], + "UwtRumCredentials": [ + "interface", + "accessKeyId: string", + "expiration: string", + "secretAccessKey: string", + "sessionToken: string" + ], + "UwtRumCredentialsProvider": [ + "interface", + "load(): Promise" + ], + "UwtRumDocument": [ + "interface", + "readonly cookie: string", + "readonly defaultView: { readonly location?: { readonly origin: string; } | undefined; readonly navigator: { readonly cookieEnabled: boolean; }; } | null" + ], + "UwtRumTelemetrySink": [ + "class", + "new (options: UwtRumTelemetrySinkOptions): UwtRumTelemetrySink", + "dispose(): void", + "flush(beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "init(): Promise", + "record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "recordPageView(pageId: string): void", + "setUserId(userId: string | undefined): void" + ], + "UwtRumTelemetrySinkOptions": [ + "interface", + "credentialsProvider: UwtRumCredentialsProvider", + "document?: UwtRumDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "redact?: UwtRedactConfig | undefined", + "report?: UwtReporter | undefined", + "runOutside?: ((callback: () => T) => T) | undefined" + ] + }, + "@absaoss-cps/ui-watchtower/rxjs": { + "traceScenario": [ + "function(scenario: UwtScenario, options?: UwtTraceScenarioOptions | ((value: T) => void | UwtScenarioOutcome) | undefined, report?: UwtReporter | undefined): MonoTypeOperatorFunction" + ], + "UwtTraceScenarioOptions": [ + "interface", + "cancelOutcome?: UwtScenarioOutcome | (() => void | UwtScenarioOutcome) | undefined", + "outcome?: ((value: T) => void | UwtScenarioOutcome) | undefined", + "valueThenTeardown?: 'complete' | 'cancel' | undefined" + ] + } +} diff --git a/projects/ui-watchtower/rum/ng-package.json b/projects/ui-watchtower/rum/ng-package.json new file mode 100644 index 0000000..e09fb3f --- /dev/null +++ b/projects/ui-watchtower/rum/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts b/projects/ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts new file mode 100644 index 0000000..a86bb3d --- /dev/null +++ b/projects/ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts @@ -0,0 +1,335 @@ +/** + * Short-lived AWS credentials for the RUM data plane. + * + * @group Interfaces + */ +export interface UwtRumCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + + /** ISO-8601 expiry. Used to schedule a refresh before the credentials lapse. */ + expiration: string; +} + +/** + * Mirrors `aws-rum-web`'s own `AwsCredentialIdentity | AwsCredentialIdentityProvider` + * union — used only by {@link UwtRumAppMonitorConfig.clientBuilder}. + */ +type ClientBuilderCredentials = + | { accessKeyId: string; secretAccessKey: string; sessionToken?: string } + | (() => Promise<{ + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + }>); + +/** + * Settings for the RUM app monitor, mapped onto the underlying `aws-rum-web` + * SDK's own `Config`. Every optional field left unset falls back to a + * default: `allowCookies`, `enableXRay`, `endpoint` and `telemetries` fall + * back to this library's own override; every other field falls back to the + * SDK's own default (see `buildRumConfig()`). + * + * Matches the SDK's `Config` field for field, with three omissions: + * + * - `endpointUrl` — always recomputed by the SDK from `endpoint`; setting it + * independently has no effect. + * - `identityPoolId`, `guestRoleArn` — the SDK's own Cognito guest-identity + * credential flow. AWS credentials come from a backend-vended + * `UwtRumCredentialsProvider` instead (see + * `UwtRumTelemetrySink.applyCredentials`). + * - `sessionId`, `userId` — owned at runtime by `UwtRumTelemetrySink`'s + * `setUserId`, `pinUserId` and `startSession`. + * + * None of the field types below are imported from `aws-rum-web`; every type + * here is hand-written. This keeps `aws-rum-web` an optional peer + * dependency for applications that only use the `broadcast` or `noop` sink. + * + * @group Interfaces + */ +export interface UwtRumAppMonitorConfig { + /** CloudWatch RUM app monitor id. */ + applicationId: string; + + /** AWS region hosting the app monitor, e.g. `eu-west-1`. */ + region: string; + + /** Version string reported with every event. */ + applicationVersion: string; + + // ---- Sampling & session ---- + + /** Fraction of sessions recorded, `0`–`1`. Default `1`. */ + sessionSampleRate?: number; + + /** + * Inactivity timeout of a session, in seconds. Default `1800` (30 minutes). + * Each recorded event pushes the expiry back; the next event after it + * starts a new session. + */ + sessionLengthSeconds?: number; + + /** Hard cap on events recorded per session, across all telemetry. Default `200`. */ + sessionEventLimit?: number; + + /** + * Suppresses the `session_start` event the SDK would otherwise emit at the + * start of a session. Default `false`. The library then doesn't record the + * session's start either, so scenario records carry no `sessionElapsed`. + */ + suppressSessionStartEvent?: boolean; + + /** + * Days the user id is kept in the `cwr_u` cookie. Default `30`. A signed-in + * user's id is kept there too, so on a shared device the next visitor starts + * with it until the app calls `setUserId(undefined)` or someone signs in. + * `0` keeps no user cookie at all: anonymous visitors get the nil id, or a + * random one for the rest of the page after a sign-out. + */ + userIdRetentionDays?: number; + + /** + * Attributes attached to every event in the session, seeded at + * construction. Default `{}`. `UwtRumTelemetrySink` merges `application`, + * `environment` and `appVersion` on top of this after construction; those + * three keys always win. + */ + sessionAttributes?: Record; + + /** + * Attributes attached to every event for the lifetime of the app monitor + * client, independent of session. Default unset. + */ + applicationAttributes?: Record; + + // ---- Telemetry collection & tracing ---- + + /** + * Built-in telemetry categories to enable. Defaults to errors, performance + * and same-origin HTTP. The SDK's own default also includes `'replay'` + * (session replay via `rrweb`); this library does not enable it by + * default. + * + * Options the SDK matches with `RegExp.test` or calls as a function — + * `urlsToInclude`, `urlsToExclude`, `addXRayTraceIdHeader` (also a + * boolean), `ignore`, `interactionId` — must hold exactly that. JSON can't + * carry them, and a string there would make the SDK throw each time it + * applies the option, so RUM doesn't start for the page instead (with a + * console warning naming the option). Set them in code, not in a broker's + * answer. + */ + telemetries?: (string | (string | object)[])[]; + + /** + * Custom event plugins to load alongside the built-in telemetry, each + * implementing `aws-rum-web`'s own `Plugin` interface. Default `[]`. + */ + eventPluginsToLoad?: unknown[]; + + /** Whether to emit X-Ray trace ids. Default `true`. */ + enableXRay?: boolean; + + /** Whether to emit W3C trace context ids alongside X-Ray's. Default `false`. */ + enableW3CTraceId?: boolean; + + /** Whether resource-timing events record the resource's URL. Default `true`. */ + recordResourceUrl?: boolean; + + // ---- Dispatch & buffering ---- + + /** Data plane endpoint. Defaults to the regional RUM endpoint. */ + endpoint?: string; + + /** Extra headers sent with every dispatch request. Default unset. */ + headers?: Record; + + /** Free-form label reported alongside dispatched events. Default unset. */ + alias?: string; + + /** Maximum events sent per dispatch request. Default `100`. */ + batchLimit?: number; + + /** Interval between automatic dispatches, in milliseconds. Default `5000`. */ + dispatchInterval?: number; + + /** Maximum events held in the in-memory cache awaiting dispatch. Default `1000`. */ + eventCacheSize?: number; + + /** Maximum not-yet-recorded candidate events cached. Default `10`. */ + candidatesCacheSize?: number; + + /** Retries for a failed dispatch request. Default `2`. */ + retries?: number; + + /** Whether unload-time flushes use `navigator.sendBeacon`. Default `true`. */ + useBeacon?: boolean; + + /** + * Whether dispatch requests are SigV4-signed. Default `true`. Signing is + * active whenever credentials are present — which they are here, via + * `setAwsCredentials` from `UwtRumCredentialsProvider` — independent of + * `identityPoolId`; that field only matters for the SDK's own, separate + * Cognito guest-identity flow, which this library does not use. Leave + * this enabled for broker-supplied credentials: disabling it here still + * sends unsigned requests to the same signed-only endpoint and gets + * rejected. Only turn it off if something in front of dispatch (a proxy, + * a different transport) already signs the request itself. + */ + signing?: boolean; + + /** Whether the request body is gzip-compressed before dispatch. Default `{ enabled: true }`. */ + compressionStrategy?: { enabled: boolean }; + + /** + * Overrides the SDK's own `fetch`-based transport. Default unset — the + * SDK uses the global `fetch`. + */ + fetchFunction?: typeof fetch; + + /** + * Overrides how the SDK builds its data-plane HTTP client. An advanced + * escape hatch — see `aws-rum-web`'s own `ClientBuilder` type for the exact + * signature (`(endpoint: URL, region: string, credentials?, compressionStrategy?) => DataPlaneClient`). + * Default unset. + */ + clientBuilder?: ( + endpoint: URL, + region: string, + credentials?: ClientBuilderCredentials, + compressionStrategy?: { enabled: boolean } + ) => unknown; + + // ---- Cookies & privacy ---- + + /** + * Whether the client may keep the session and user ids in cookies + * (`cwr_s`, `cwr_u`), so they survive a reload. Default `true` — the SDK's + * own default is `false`. Without cookies, every page load starts a new + * session. + */ + allowCookies?: boolean; + + /** + * Overrides for the cookies the client sets. Unset fields fall back to the + * SDK's own defaults (`domain` = current hostname, `path` = `/`, + * `sameSite` = `'Strict'`, `secure` = `true`, `unique` = `false`). + * + * With `unique: false`, every app monitor on the same hostname shares one + * session cookie (`cwr_s`). Set `unique: true` when several apps with their + * own app monitors run on one hostname, so each keeps its own session. The + * user cookie (`cwr_u`) stays shared either way — `aws-rum-web` 3.2.1 + * suffixes only the session cookie. + */ + cookieAttributes?: { + unique?: boolean; + domain?: string; + path?: string; + sameSite?: string; + secure?: boolean; + }; + + // ---- Page tracking ---- + + /** + * Disables the client's own automatic page-view recording on navigation. + * Pair with `UwtRumTelemetrySink.recordPageView()` to record page ids + * yourself. Default `false`. + */ + disableAutoPageView?: boolean; + + /** How the page id is derived from the URL. Default `'PATH'`. */ + pageIdFormat?: 'PATH' | 'HASH' | 'PATH_AND_HASH'; + + /** + * Pages recorded, matched against the page id. Default matches every page. + * `undefined` or `null` means unset; anything else that isn't an array of + * `RegExp`s (a string list from JSON) keeps RUM from starting for the page. + */ + pagesToInclude?: RegExp[]; + + /** + * Pages excluded from recording, matched against the page id. Default `[]`. + * `undefined` or `null` means unset; anything else that isn't an array of + * `RegExp`s (a string list from JSON) keeps RUM from starting for the page, + * rather than record what it should exclude. + */ + pagesToExclude?: RegExp[]; + + /** How long, in milliseconds, a route change waits before being considered complete. Default `100`. */ + routeChangeComplete?: number; + + /** Timeout, in milliseconds, before an incomplete route change is abandoned. Default `10000`. */ + routeChangeTimeout?: number; + + // ---- Advanced / escape hatches ---- + + /** + * Label reported as the SDK's own install method. Default is the SDK's + * internal module-install constant; overriding it is rarely useful outside + * of testing the SDK itself. + */ + client?: string; + + /** Release identifier reported with every event, e.g. a build SHA. Default unset. */ + releaseId?: string; + + /** Logs the SDK's own internal diagnostics to the console. Default `false`. */ + debug?: boolean; + + /** Master switch for the RUM client. Default `true`. */ + enableRumClient?: boolean; +} + +/** + * The payload returned by a {@link UwtRumCredentialsProvider}. + * + * @group Interfaces + */ +export interface UwtRumBootstrap { + /** App monitor settings. */ + config: UwtRumAppMonitorConfig; + + /** + * Credentials for the data plane. Omit when the app monitor is configured for + * unauthenticated access. + */ + credentials?: UwtRumCredentials; +} + +/** + * What the RUM sink needs from the host application to start. + * + * Keeps the telemetry layer free of AWS account details — how the app + * monitor id is discovered and how credentials are vended is entirely the + * application's business. + * + * @example + * ```typescript + * export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { + * async load(): Promise { + * const res = await fetch('/rum/init'); + * if (!res.ok) throw new Error(`RUM bootstrap failed: ${res.status}`); + * const { enabled, config, credentials } = await res.json(); + * return enabled ? { config, credentials } : null; + * } + * } + * ``` + * + * @group Interfaces + */ +export interface UwtRumCredentialsProvider { + /** + * Resolves the app monitor settings and a set of credentials. + * + * Called once at startup and again shortly before each credential expiry. + * Returning `null` disables RUM for the rest of the page — the telemetry + * layer stays fully functional, it simply stops shipping. Throw instead when + * the request fails: the sink retries — at startup with a growing, random + * wait for up to five minutes, buffering events until the client starts; + * on refresh every 30 s. + * + * @returns the bootstrap payload, or `null` to disable RUM + */ + load(): Promise; +} diff --git a/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.ts b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.ts new file mode 100644 index 0000000..196c0e9 --- /dev/null +++ b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.ts @@ -0,0 +1,650 @@ +/** + * @jest-environment jsdom + */ +import { uwtSessionElapsed } from '@absaoss-cps/ui-watchtower'; +import { UwtRumBootstrap } from '../uwt-rum-credentials/uwt-rum-credentials'; +import { + UwtRumTelemetrySink, + UwtRumTelemetrySinkOptions +} from './uwt-rum-telemetry.sink'; + +const mockClient = { + recordEvent: jest.fn(), + recordError: jest.fn(), + recordPageView: jest.fn(), + addSessionAttributes: jest.fn(), + setEventMetadataHook: jest.fn(), + setAwsCredentials: jest.fn(), + getSessionId: jest.fn(), + getUserId: jest.fn(), + pinUserId: jest.fn(), + startSession: jest.fn(), + dispatch: jest.fn(), + dispatchBeacon: jest.fn(), + disable: jest.fn() +}; +/** The arguments of every client built, and what building one does. */ +const mockConstructed: unknown[][] = []; +const mockConstruction: { fail: boolean; then?: () => void } = { fail: false }; + +jest.mock('aws-rum-web', () => ({ + AwsRum: function (...args: unknown[]) { + if (mockConstruction.fail) { + throw new Error('SDK exploded'); + } + mockConstructed.push(args); + mockConstruction.then?.(); + return mockClient; + } +})); + +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; +const SESSION_START = 'com.amazon.rum.session_start_event'; + +/** What a broker returns: settings, and credentials expiring in an hour. */ +function bootstrap( + config: Record = {}, + expiresInMs = HOUR +): UwtRumBootstrap { + return { + config: { + applicationId: 'app-monitor', + applicationVersion: '1.0.0', + region: 'eu-west-1', + ...config + } as UwtRumBootstrap['config'], + credentials: { + accessKeyId: 'a', + secretAccessKey: 's', + sessionToken: 't', + expiration: new Date(Date.now() + expiresInMs).toISOString() + } + }; +} + +/** A page with an origin and its cookies. */ +function page(origin = 'https://shop.example') { + return { + cookie: '', + defaultView: { location: { origin }, navigator: { cookieEnabled: true } } + }; +} + +/** The config the last client was built with. */ +function builtConfig(): Record { + return mockConstructed[mockConstructed.length - 1][3] as Record< + string, + unknown + >; +} + +/** The metadata hook the client was given. */ +function metadataHook(): (type: string) => Record { + return mockClient.setEventMetadataHook.mock.calls[0][0]; +} + +describe('the core RUM sink, in detail', () => { + let load: jest.Mock; + let sinks: UwtRumTelemetrySink[]; + + function sink( + options: Partial = {} + ): UwtRumTelemetrySink { + const created = new UwtRumTelemetrySink({ + identity: { application: 'cart', environment: 'test', version: '1' }, + credentialsProvider: { load }, + ...options + }); + sinks.push(created); + return created; + } + + beforeEach(() => { + Object.values(mockClient).forEach((fn) => fn.mockReset()); + mockClient.getSessionId.mockReturnValue('session-1'); + mockConstructed.length = 0; + mockConstruction.fail = false; + mockConstruction.then = undefined; + load = jest.fn(async () => bootstrap()); + sinks = []; + localStorage.clear(); + }); + + afterEach(() => { + sinks.forEach((created) => created.dispose()); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('settings the SDK cannot use', () => { + it.each([ + [ + { pagesToExclude: 'admin' }, + 'pagesToExclude (expected an array of RegExp)' + ], + [{ telemetries: 'errors' }, 'telemetries (expected an array)'], + [ + { telemetries: [['http', { urlsToExclude: ['/files'] }]] }, + 'http.urlsToExclude (expected an array of RegExp)' + ], + [ + { telemetries: [['http', { addXRayTraceIdHeader: 'yes' }]] }, + 'http.addXRayTraceIdHeader (expected a boolean or an array of RegExp)' + ], + [ + { telemetries: [['errors', { ignore: 'never' }]] }, + 'errors.ignore (expected a function)' + ] + ])('should keep RUM off for %j', async (config, problem) => { + const report = jest.fn(); + load.mockResolvedValue(bootstrap(config)); + const created = sink({ report }); + + await created.init(); + created.record('com.uwt.bi', {}); + + expect(mockConstructed).toHaveLength(0); + expect(report).toHaveBeenCalledWith( + 'config', + `RUM not started, settings of the wrong type: ${problem}` + ); + }); + + it('should accept valid telemetry options, and pass them on without the unset ones', async () => { + const ignore = () => false; + const pattern = /\/api\//; + load.mockResolvedValue( + bootstrap({ + telemetries: [ + 'errors', + ['errors', { ignore }], + [ + 'http', + { + urlsToExclude: [pattern], + addXRayTraceIdHeader: true, + urlsToInclude: null, + interactionId: undefined + } + ], + ['performance', 'not options'] + ] + }) + ); + + await sink().init(); + + expect(builtConfig().telemetries).toEqual([ + 'errors', + ['errors', { ignore }], + ['http', { urlsToExclude: [pattern], addXRayTraceIdHeader: true }], + ['performance', 'not options'] + ]); + }); + }); + + describe('the client config', () => { + it('should trace same-origin requests by default, never a look-alike host', async () => { + await sink({ document: page('https://shop.example') }).init(); + + const http = (builtConfig().telemetries as unknown[])[2] as [ + string, + { addXRayTraceIdHeader: RegExp[] } + ]; + const [sameOrigin] = http[1].addXRayTraceIdHeader; + expect(sameOrigin.test('https://shop.example/api/orders')).toBe(true); + expect(sameOrigin.test('https://shop.example')).toBe(true); + expect(sameOrigin.test('https://shop.example.attacker.example/')).toBe( + false + ); + expect(builtConfig()).toMatchObject({ + allowCookies: true, + enableXRay: true, + endpoint: 'https://dataplane.rum.eu-west-1.amazonaws.com' + }); + }); + + it("should leave unset settings to the SDK's defaults, and pass set ones on", async () => { + load.mockResolvedValue( + bootstrap({ + sessionSampleRate: 0.5, + client: 'my-client', + releaseId: null, + debug: undefined + }) + ); + + await sink().init(); + + expect(builtConfig()).toMatchObject({ + sessionSampleRate: 0.5, + client: 'my-client' + }); + expect(builtConfig()).not.toHaveProperty('releaseId'); + expect(builtConfig()).not.toHaveProperty('debug'); + }); + }); + + describe('startup', () => { + it('should turn RUM off for the page when the broker declines', async () => { + load.mockResolvedValue(null); + const created = sink(); + + created.record('com.uwt.early', {}); + await created.init(); + created.record('com.uwt.late', {}); + + expect(mockConstructed).toHaveLength(0); + expect(created.getSessionId()).toBeUndefined(); + }); + + it('should retry a failing broker with growing delays, then start and replay', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + jest.spyOn(Math, 'random').mockReturnValue(1); + const report = jest.fn(); + load + .mockRejectedValueOnce(new Error('broker down')) + .mockRejectedValueOnce(new Error('still down')); + const created = sink({ report }); + + created.record('com.uwt.early', {}); + await created.init(); + await jest.advanceTimersByTimeAsync(1000); + expect(load).toHaveBeenCalledTimes(2); + await jest.advanceTimersByTimeAsync(2000); + + expect(load).toHaveBeenCalledTimes(3); + expect(mockClient.recordEvent).toHaveBeenCalledWith( + 'com.uwt.early', + {}, + undefined + ); + expect( + report.mock.calls.filter(([, message]) => message.includes('retrying')) + ).toEqual([ + [ + 'warn', + 'RUM init failed, retrying', + expect.objectContaining({ message: 'broker down' }) + ] + ]); + }); + + it('should give up after five minutes of failures, and drop what it held', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + jest.spyOn(Math, 'random').mockReturnValue(1); + const report = jest.fn(); + load.mockRejectedValue(new Error('down')); + const created = sink({ report }); + + created.record('com.uwt.early', {}); + await created.init(); + await jest.advanceTimersByTimeAsync(6 * MINUTE); + const attempts = load.mock.calls.length; + await jest.advanceTimersByTimeAsync(10 * MINUTE); + + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM init failed, monitoring disabled', + expect.objectContaining({ message: 'down' }) + ); + expect(load).toHaveBeenCalledTimes(attempts); + expect(mockConstructed).toHaveLength(0); + }); + + it('should turn RUM off when the client fails to start, reporting only to a reporter', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const report = jest.fn(); + mockConstruction.fail = true; + + await sink({ report }).init(); + await sink().init(); + + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM init failed, monitoring disabled', + expect.objectContaining({ message: 'SDK exploded' }) + ); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should build no client when disposed while starting', async () => { + const created = sink(); + + const starting = created.init(); + created.dispose(); + await starting; + + expect(mockConstructed).toHaveLength(0); + }); + + it('should hold the latest 100 events from before startup', async () => { + const created = sink(); + + for (let i = 0; i < 105; i++) { + created.record(`com.uwt.e${i}`, {}); + } + await created.init(); + + expect(mockClient.recordEvent).toHaveBeenCalledTimes(100); + expect(mockClient.recordEvent.mock.calls[0][0]).toBe('com.uwt.e5'); + }); + }); + + describe('records', () => { + it('should drop reserved and null metadata, and send none when nothing is left', async () => { + const created = sink(); + await created.init(); + + created.record('com.uwt.a', {}, { 'aws:x': 'a', keep: 1, gone: null }); + created.record('com.uwt.b', {}, { 'aws:only': 'a' }); + + expect(mockClient.recordEvent.mock.calls).toEqual([ + ['com.uwt.a', {}, { keep: 1 }], + ['com.uwt.b', {}, undefined] + ]); + }); + + it("should fold a fragment's application into an error's name, not its own", async () => { + const created = sink(); + await created.init(); + const error = { name: 'TypeError', message: 'boom' }; + + created.recordError(error, { application: 'checkout' }); + created.recordError(error, { application: 'cart' }); + created.recordError(error); + + expect(mockClient.recordError.mock.calls).toEqual([ + [{ name: '[checkout] TypeError', message: 'boom' }], + [error], + [error] + ]); + }); + + it('should replay page views and errors recorded before startup', async () => { + const created = sink(); + + created.recordPageView('/customers/:id'); + created.recordError( + { name: 'Error', message: 'early' }, + { application: 'checkout' } + ); + await created.init(); + created.recordPageView('/orders'); + + expect(mockClient.recordPageView.mock.calls).toEqual([ + ['/customers/:id'], + ['/orders'] + ]); + expect(mockClient.recordError).toHaveBeenCalledWith({ + name: '[checkout] Error', + message: 'early' + }); + }); + }); + + describe('the signed-in user', () => { + it('should pin a user signed in before startup, once the client starts', async () => { + const created = sink(); + + created.setUserId('u-1'); + await created.init(); + + expect(created.getUserId()).toBe('u-1'); + expect(mockClient.pinUserId).toHaveBeenCalledWith('u-1'); + }); + + it('should start an anonymous session for a sign-out before startup', async () => { + const created = sink(); + + created.setUserId('u-1'); + created.setUserId(undefined); + await created.init(); + + expect(mockClient.pinUserId).not.toHaveBeenCalled(); + expect(mockClient.startSession).toHaveBeenCalledWith({ + userId: expect.any(String) + }); + }); + + it('should send what the client holds before the user changes, and ignore repeats', async () => { + load.mockResolvedValue( + bootstrap({ eventCacheSize: 300, batchLimit: 100 }) + ); + const created = sink(); + await created.init(); + + created.setUserId('u-1'); + expect(mockClient.dispatch).toHaveBeenCalledTimes(3); + expect(mockClient.pinUserId).toHaveBeenCalledWith('u-1'); + + created.setUserId('u-1'); + expect(mockClient.dispatch).toHaveBeenCalledTimes(3); + + created.setUserId(undefined); + expect(mockClient.dispatch).toHaveBeenCalledTimes(6); + expect(mockClient.startSession).toHaveBeenCalledTimes(1); + + created.setUserId(''); + expect(mockClient.startSession).toHaveBeenCalledTimes(1); + }); + + it('should send one batch when the batch settings are unusable', async () => { + load.mockResolvedValue(bootstrap({ batchLimit: 0 })); + const created = sink(); + await created.init(); + + created.setUserId('u-1'); + + expect(mockClient.dispatch).toHaveBeenCalledTimes(1); + }); + + it('should not send early when the client already has that user, as after a reload', async () => { + mockClient.getUserId.mockReturnValue('u-1'); + const created = sink(); + await created.init(); + + created.setUserId('u-1'); + + expect(mockClient.dispatch).not.toHaveBeenCalled(); + expect(mockClient.pinUserId).toHaveBeenCalledWith('u-1'); + }); + + it('should stamp signedIn on events only once the app has said who is signed in', async () => { + const created = sink(); + await created.init(); + const hook = metadataHook(); + + expect(hook('com.uwt.bi')).toEqual({ + application: 'cart', + environment: 'test', + appVersion: '1' + }); + created.setUserId('u-1'); + expect(hook('com.uwt.bi')).toMatchObject({ signedIn: true }); + created.setUserId(undefined); + expect(hook('com.uwt.bi')).toMatchObject({ signedIn: false }); + }); + }); + + describe('session starts', () => { + it('should record when the client starts a session', async () => { + await sink().init(); + + metadataHook()(SESSION_START); + + expect(uwtSessionElapsed('session-1')).toEqual(expect.any(Number)); + }); + + it('should record nothing when the client keeps no session cookie', async () => { + load.mockResolvedValue(bootstrap({ allowCookies: false })); + await sink().init(); + + metadataHook()(SESSION_START); + + expect(uwtSessionElapsed('session-1')).toBeUndefined(); + }); + + it.each([ + ['the shared cookie', {}, 'cwr_s=1'], + [ + 'its own cookie', + { cookieAttributes: { unique: true } }, + 'cwr_s_app-monitor=1' + ] + ])( + 'should record the session the client starts while starting, in %s', + async (_, config, cookie) => { + const document = page(); + mockConstruction.then = () => { + document.cookie = cookie; + }; + load.mockResolvedValue(bootstrap(config)); + + await sink({ document }).init(); + + expect(uwtSessionElapsed('session-1')).toEqual(expect.any(Number)); + } + ); + + it('should not recurse when reading the session id starts another one', async () => { + await sink().init(); + const hook = metadataHook(); + mockClient.getSessionId.mockImplementationOnce(() => { + hook(SESSION_START); + return 'session-2'; + }); + + hook(SESSION_START); + + expect(mockClient.getSessionId).toHaveBeenCalledTimes(1); + expect(uwtSessionElapsed('session-2')).toEqual(expect.any(Number)); + }); + }); + + describe('flush', () => { + it('should send through the client, by beacon when asked', async () => { + const created = sink(); + await created.init(); + + created.flush(); + created.flush(true); + + expect(mockClient.dispatch).toHaveBeenCalledTimes(1); + expect(mockClient.dispatchBeacon).toHaveBeenCalledTimes(1); + }); + + it('should report events lost when the page goes before the client starts', () => { + load.mockReturnValue(new Promise(() => undefined)); + const report = jest.fn(); + const created = sink({ report }); + + created.flush(true); + expect(report).not.toHaveBeenCalled(); + created.record('com.uwt.early', {}); + created.flush(true); + + expect(report).toHaveBeenCalledWith( + 'warn', + '1 RUM event(s) lost: page unloaded before RUM finished initializing' + ); + }); + }); + + describe('credentials', () => { + beforeEach(() => + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }) + ); + + it('should refresh credentials five minutes before they expire', async () => { + await sink().init(); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + + expect(load).toHaveBeenCalledTimes(2); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should turn RUM off when a refresh is declined', async () => { + const created = sink(); + await created.init(); + load.mockResolvedValueOnce(null); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + created.record('com.uwt.late', {}); + + expect(mockClient.disable).toHaveBeenCalled(); + expect(mockClient.recordEvent).not.toHaveBeenCalled(); + }); + + it('should keep its credentials and retry when a refresh omits them, or fails', async () => { + const report = jest.fn(); + await sink({ report }).init(); + load + .mockResolvedValueOnce({ config: bootstrap().config }) + .mockRejectedValueOnce(new Error('broker down')); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM credential refresh', + expect.objectContaining({ + message: expect.stringContaining('omitted credentials') + }) + ); + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM credential refresh failed', + expect.objectContaining({ message: 'broker down' }) + ); + await jest.advanceTimersByTimeAsync(30 * 1000); + + expect(load).toHaveBeenCalledTimes(4); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should retry soon, not loop, on credentials already expired', async () => { + load.mockResolvedValueOnce(bootstrap({}, -1000)); + await sink().init(); + expect(jest.getTimerCount()).toBe(1); + + await jest.advanceTimersByTimeAsync(30 * 1000); + + expect(load).toHaveBeenCalledTimes(2); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should schedule nothing once disposed during a refresh', async () => { + const created = sink(); + await created.init(); + let answer: (value: UwtRumBootstrap) => void = () => undefined; + load.mockReturnValueOnce(new Promise((resolve) => (answer = resolve))); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + created.dispose(); + answer(bootstrap()); + await jest.advanceTimersByTimeAsync(0); + + expect(jest.getTimerCount()).toBe(0); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(1); + }); + + it('should not retry a failed refresh once disposed', async () => { + const created = sink(); + await created.init(); + let fail: (error: Error) => void = () => undefined; + load.mockReturnValueOnce( + new Promise((_resolve, reject) => (fail = reject)) + ); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + created.dispose(); + fail(new Error('broker down')); + await jest.advanceTimersByTimeAsync(0); + + expect(jest.getTimerCount()).toBe(0); + }); + }); +}); diff --git a/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts new file mode 100644 index 0000000..98dab01 --- /dev/null +++ b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts @@ -0,0 +1,180 @@ +/** + * @jest-environment jsdom + */ +import { UwtRumBootstrap } from '../uwt-rum-credentials/uwt-rum-credentials'; +import { + UwtRumTelemetrySink, + UwtRumTelemetrySinkOptions +} from './uwt-rum-telemetry.sink'; + +const mockClient = { + recordEvent: jest.fn(), + recordError: jest.fn(), + recordPageView: jest.fn(), + addSessionAttributes: jest.fn(), + setEventMetadataHook: jest.fn(), + setAwsCredentials: jest.fn(), + getSessionId: jest.fn(() => 'session-1'), + getUserId: jest.fn(), + pinUserId: jest.fn(), + startSession: jest.fn(), + dispatch: jest.fn(), + dispatchBeacon: jest.fn(), + disable: jest.fn() +}; +const mockLoads = { count: 0 }; + +jest.mock('aws-rum-web', () => { + mockLoads.count++; + return { + AwsRum: function () { + return mockClient; + } + }; +}); + +/** What a broker returns: settings, and credentials an hour from expiry. */ +function bootstrap( + config: Partial = {} +): UwtRumBootstrap { + return { + config: { + applicationId: 'app-monitor', + applicationVersion: '1.0.0', + region: 'eu-west-1', + ...config + }, + credentials: { + accessKeyId: 'a', + secretAccessKey: 's', + sessionToken: 't', + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString() + } + }; +} + +describe('the core RUM sink', () => { + let load: jest.Mock; + let sinks: UwtRumTelemetrySink[]; + + function sink( + options: Partial = {} + ): UwtRumTelemetrySink { + const created = new UwtRumTelemetrySink({ + identity: { application: 'cart', environment: 'test', version: '1' }, + credentialsProvider: { load }, + ...options + }); + sinks.push(created); + return created; + } + + beforeEach(() => { + jest.clearAllMocks(); + load = jest.fn(async () => bootstrap()); + sinks = []; + }); + + afterEach(() => { + sinks.forEach((created) => created.dispose()); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('should never start without a page: no credentials request, no SDK', async () => { + const serverSink = sink({ document: null }); + const loadsBefore = mockLoads.count; + + await serverSink.init(); + serverSink.record('com.uwt.bi', { eventName: 'x' }); + + expect(load).not.toHaveBeenCalled(); + expect(mockLoads.count).toBe(loadsBefore); + expect(serverSink.getSessionId()).toBeUndefined(); + }); + + it('should never start while disabled', async () => { + await sink({ enabled: false }).init(); + + expect(load).not.toHaveBeenCalled(); + }); + + it('should start outside change detection, then record through the client', async () => { + let outside = 0; + const started = sink({ + runOutside: (callback) => { + outside++; + return callback(); + } + }); + + await started.init(); + started.record('com.uwt.bi', { eventName: 'x' }, { feature: 'cart' }); + + expect(outside).toBe(1); + expect(load).toHaveBeenCalledTimes(1); + expect(mockClient.setAwsCredentials).toHaveBeenCalled(); + expect(mockClient.recordEvent).toHaveBeenCalledWith( + 'com.uwt.bi', + { eventName: 'x' }, + { feature: 'cart' } + ); + expect(started.getSessionId()).toBe('session-1'); + }); + + it('should replay what it was given before the client started', async () => { + const started = sink(); + + started.record('com.uwt.bi', { eventName: 'early' }); + await started.init(); + + expect(mockClient.recordEvent).toHaveBeenCalledWith( + 'com.uwt.bi', + { eventName: 'early' }, + undefined + ); + }); + + it("should report settings that keep RUM from starting as a 'config' problem", async () => { + const report = jest.fn(); + load.mockResolvedValue( + bootstrap({ pagesToInclude: ['not a RegExp'] as unknown as RegExp[] }) + ); + + await sink({ report }).init(); + + expect(mockClient.recordEvent).not.toHaveBeenCalled(); + expect(report).toHaveBeenCalledWith( + 'config', + 'RUM not started, settings of the wrong type: pagesToInclude (expected an array of RegExp)' + ); + }); + + it('should print those settings to the console without a reporter: RUM off is never silent', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + load.mockResolvedValue( + bootstrap({ pagesToInclude: ['not a RegExp'] as unknown as RegExp[] }) + ); + + await sink().init(); + + expect(warn).toHaveBeenCalledWith( + '[ui-watchtower] RUM not started, settings of the wrong type: pagesToInclude (expected an array of RegExp)' + ); + }); + + it('should stop the client, and leave no refresh pending, once disposed', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + const started = sink(); + await started.init(); + expect(jest.getTimerCount()).toBe(1); + + started.dispose(); + started.dispose(); + + expect(mockClient.disable).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(0); + started.record('com.uwt.bi', { eventName: 'late' }); + expect(mockClient.recordEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts new file mode 100644 index 0000000..6d00cce --- /dev/null +++ b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts @@ -0,0 +1,1088 @@ +import type { AwsRum, AwsRumConfig } from 'aws-rum-web'; +import { + UWT_DEFAULT_REDACT_CONFIG, + uwtConsoleReporter, + uwtMarkSessionStart, + uwtNormalizeError, + UwtRedactConfig, + uwtReport, + UwtReporter, + uwtSafe, + uwtSafeVoid, + UwtTelemetryError, + UwtTelemetryIdentity, + UwtTelemetryMetadata, + UwtTelemetrySink, + uwtUuid +} from '@absaoss-cps/ui-watchtower'; +import { + UwtRumBootstrap, + UwtRumCredentials, + UwtRumCredentialsProvider +} from '../uwt-rum-credentials/uwt-rum-credentials'; + +/** Refresh credentials this long before they expire. */ +const CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000; + +/** + * Maximum delay supported by setTimeout (2^31 - 1 ms, ~24.85 days). + * Prevents 32-bit signed integer overflow triggering an immediate execution. + */ +const MAX_TIMEOUT_MS = 2_147_483_647; + +/** + * Retry delay after a scheduled refresh fails or returns no credentials. + * Shorter than the normal refresh-ahead-of-expiry interval. + */ +const CREDENTIAL_RETRY_DELAY_MS = 30 * 1000; + +/** + * Startup retries wait a random time up to a limit that starts at + * {@link INIT_RETRY_BASE_MS} and doubles after each failure, up to + * {@link INIT_RETRY_MAX_MS}. The first retry comes fast, because the client + * captures nothing until it exists; the randomness keeps tabs reloaded + * together from retrying together. + */ +const INIT_RETRY_BASE_MS = 1000; +const INIT_RETRY_MAX_MS = 60 * 1000; + +/** + * How long startup keeps retrying before the sink gives up and runs the page + * without RUM. Bounds the retries when the failure is permanent, such as a + * wrong URL or a 403. + */ +const INIT_RETRY_WINDOW_MS = 5 * 60 * 1000; + +/** + * The RUM client's defaults for `eventCacheSize` and `batchLimit`, which it + * keeps to itself — used only when the app monitor config sets neither. + * Together they bound how many batches the client can hold. + */ +const SDK_DEFAULT_EVENT_CACHE_SIZE = 1000; +const SDK_DEFAULT_BATCH_LIMIT = 100; + +/** + * Events retained while the SDK loads. Bounded so a failed init cannot + * grow the buffer without limit. + */ +const PRE_INIT_BUFFER_LIMIT = 100; + +/** The event the RUM client records each time it starts a session. */ +const SESSION_START_EVENT_TYPE = 'com.amazon.rum.session_start_event'; + +/** + * The RUM client's session cookie: `cwr_s`, or `cwr_s_` with + * `cookieAttributes.unique` (both documented by AWS). + */ +function sessionCookieName(bootstrap: UwtRumBootstrap): string { + const { config } = bootstrap; + return config.cookieAttributes?.unique + ? `cwr_s_${config.applicationId}` + : 'cwr_s'; +} + +/** Metadata keys under this prefix are reserved by the RUM client. */ +const RESERVED_PREFIX = 'aws:'; + +interface BufferedEvent { + kind: 'event'; + eventType: string; + payload: object; + metadata?: UwtTelemetryMetadata; +} + +interface BufferedPageView { + kind: 'pageView'; + pageId: string; +} + +interface BufferedError { + kind: 'error'; + error: UwtTelemetryError; + metadata?: UwtTelemetryMetadata; +} + +/** Anything the pre-init buffer holds. */ +type BufferedItem = BufferedEvent | BufferedPageView | BufferedError; + +/** + * Drops every key whose value is unset: `undefined`, or `null` as a broker's + * JSON spells it. + * + * Used to build the SDK config object from optional consumer fields: a key + * present with value `undefined` or `null` would overwrite the SDK's own + * default for that key, so unset fields must be absent. + */ +function omitUnset(obj: T): Partial { + const result: Partial = {}; + for (const key of Object.keys(obj) as (keyof T)[]) { + if (obj[key] !== undefined && obj[key] !== null) { + result[key] = obj[key]; + } + } + return result; +} + +/** Whether `value` is an array of `RegExp`s only. */ +function isRegExpList(value: unknown): boolean { + return ( + Array.isArray(value) && value.every((entry) => entry instanceof RegExp) + ); +} + +/** A setting's type check, and the type it expects, for the warning. */ +interface UwtSettingCheck { + valid: (value: unknown) => boolean; + expected: string; +} + +const REGEXP_LIST: UwtSettingCheck = { + valid: isRegExpList, + expected: 'an array of RegExp' +}; +const FUNCTION: UwtSettingCheck = { + valid: (value) => typeof value === 'function', + expected: 'a function' +}; + +/** + * Telemetry options the SDK calls as a function or matches with + * `RegExp.test`. JSON can carry neither, and anything else in their place + * makes the SDK throw each time it applies the option — for the URL filters, + * inside its patched `fetch` and XHR, so on every application request. + */ +const TELEMETRY_OPTION_CHECKS = new Map([ + ['urlsToInclude', REGEXP_LIST], + ['urlsToExclude', REGEXP_LIST], + [ + 'addXRayTraceIdHeader', + { + valid: (value) => typeof value === 'boolean' || isRegExpList(value), + expected: 'a boolean or an array of RegExp' + } + ], + ['ignore', FUNCTION], + ['interactionId', FUNCTION] +]); + +/** + * Lists the settings in `config` the SDK can't use, each with the type it + * expects: `pagesToInclude` or `pagesToExclude` that isn't an array of + * `RegExp`s, `telemetries` that isn't an array, and each telemetry option + * failing its {@link TELEMETRY_OPTION_CHECKS} check, as `name.option`. + * `undefined` and `null` count as unset, not as invalid. + */ +function invalidRumSettings(config: UwtRumBootstrap['config']): string[] { + const invalid: string[] = []; + for (const key of ['pagesToInclude', 'pagesToExclude'] as const) { + const value = config[key]; + if (value != null && !REGEXP_LIST.valid(value)) { + invalid.push(`${key} (expected ${REGEXP_LIST.expected})`); + } + } + const telemetries: unknown = config.telemetries; + if (telemetries == null) { + return invalid; + } + if (!Array.isArray(telemetries)) { + // A string would be read as one telemetry per character. + invalid.push('telemetries (expected an array)'); + return invalid; + } + for (const telemetry of telemetries) { + if (!Array.isArray(telemetry)) { + continue; + } + const [name, options] = telemetry; + if (typeof options !== 'object' || options === null) { + continue; + } + for (const [key, value] of Object.entries(options)) { + const check = TELEMETRY_OPTION_CHECKS.get(key); + if (value != null && check && !check.valid(value)) { + invalid.push(`${String(name)}.${key} (expected ${check.expected})`); + } + } + } + return invalid; +} + +/** + * Copies `telemetries` without tuple options that are unset (`undefined` or + * `null`). The SDK merges a telemetry's options over its defaults with + * `Object.assign`, so an explicit unset value would replace a default it + * relies on — `urlsToExclude: null` would throw on every request. + */ +function withoutUnsetOptions( + telemetries: NonNullable +): NonNullable { + return telemetries.map((telemetry) => { + if (!Array.isArray(telemetry)) { + return telemetry; + } + const [name, options, ...rest] = telemetry; + if (typeof options !== 'object' || options === null) { + return telemetry; + } + return [name, omitUnset(options), ...rest]; + }); +} + +/** + * The part of a page the RUM sink reads: its cookies, its origin, and whether + * cookies are enabled. A browser `Document` is one. Typed structurally, so + * using the sink needs no DOM typings. + * + * @group Interfaces + */ +export interface UwtRumDocument { + readonly cookie: string; + readonly defaultView: { + readonly location?: { readonly origin: string }; + readonly navigator: { readonly cookieEnabled: boolean }; + } | null; +} + +/** + * What {@link UwtRumTelemetrySink} needs. + * + * @group Interfaces + */ +export interface UwtRumTelemetrySinkOptions { + /** + * The application's identity: attached to every RUM session, and the realm + * a forwarded error is told apart from. + */ + identity: UwtTelemetryIdentity; + /** Where the app monitor settings and credentials come from. */ + credentialsProvider: UwtRumCredentialsProvider; + /** Redaction for the failures it reports. Default {@link UWT_DEFAULT_REDACT_CONFIG}. */ + redact?: UwtRedactConfig; + /** + * `false` never starts the client — no credentials request, no SDK + * download — and every call is a no-op. Default `true`. + */ + enabled?: boolean; + /** + * The page: its cookies and origin. Defaults to the global `document` + * where there is one; without one — as on a server — the client never + * starts. + */ + document?: UwtRumDocument | null; + /** + * Runs the client's startup outside the host framework's change + * detection, so its timers never keep the application from settling: + * Angular passes `NgZone.runOutsideAngular`. + */ + runOutside?: (callback: () => T) => T; + /** + * Receives failures and warnings — and, as `'config'`, settings that keep + * RUM from starting, which go to the console when no reporter is given. + */ + report?: UwtReporter; +} + +/** + * Telemetry sink backed by the AWS CloudWatch RUM web client. + * + * Custom scenario and BI events travel through `recordEvent`, alongside the + * page views, web vitals, JS errors, HTTP failures and resource timing the + * client records on its own. The SDK is imported lazily, so an application + * that never provides this sink never pays for the bundle. Every operation + * is fail-open: if the broker is down, credentials lapse, or the SDK + * throws, the application behaves as if RUM were healthy. + * + * @group Services + */ +export class UwtRumTelemetrySink extends UwtTelemetrySink { + private readonly config: UwtTelemetryIdentity; + private readonly redact: UwtRedactConfig; + private readonly credentialsProvider: UwtRumCredentialsProvider; + private readonly document: UwtRumDocument; + /** + * False without a page (on a server) and while telemetry is off: the + * client never starts — no credentials fetch, no SDK import — and every + * call is a no-op. + */ + private readonly canStart: boolean; + private readonly runOutside: (callback: () => T) => T; + private readonly report?: UwtReporter; + + private awsRum: AwsRum | null = null; + /** + * The pending broker call: a startup retry before the client exists, the + * next credential refresh after. + */ + private refreshTimer: ReturnType | null = null; + private buffer: BufferedItem[] = []; + private userId?: string; + /** + * Whether a session's start is recorded (`uwtMarkSessionStart`): only when + * the client keeps sessions in its cookie, so a session outlives the page, + * and the app hasn't suppressed session-start events. Set when the client + * starts. + */ + private marksSessionStarts = false; + /** Set while {@link markSessionStart} runs, to stop it calling itself. */ + private markingSession = false; + /** + * Set once the app has called `setUserId`. Until then, the client's id may + * be a signed-in user's, restored from its cookie after a reload, so + * events carry no `signedIn`. + */ + private userKnown = false; + /** A sign-out arrived before the client existed; applied once it starts. */ + private signOutPending = false; + /** The most batches the client can hold, set when it starts. */ + private maxBatches = 1; + /** + * Set once RUM is off for the page — the broker declined, startup gave up, + * or a refresh returned null; distinct from `!awsRum` alone. + */ + private disabled = false; + /** Broker requests that failed at startup so far. */ + private initFailures = 0; + /** When the first of them failed. */ + private initFailedAt?: number; + /** + * The current startup attempt, shared by every caller — an application + * starting the sink at bootstrap, {@link ensureInitialized}, and a caller + * awaiting `init()` directly. A + * retry replaces it, so awaiting `init()` waits for one attempt, not for + * RUM to be running. + */ + private initPromise?: Promise; + /** + * Set once in {@link dispose}. `refreshCredentials()` nulls `refreshTimer` + * before its own `await`, so a dispose during that window would leave a + * new timer nothing could clear. Checked after every await in the + * refresh chain instead. + */ + private destroyed = false; + + constructor(options: UwtRumTelemetrySinkOptions) { + super(); + const page = + options.document === undefined + ? (globalThis.document ?? null) + : options.document; + this.config = options.identity; + this.redact = options.redact ?? UWT_DEFAULT_REDACT_CONFIG; + this.credentialsProvider = options.credentialsProvider; + this.canStart = options.enabled !== false && page !== null; + // Never read unless the client starts, which needs a page. + this.document = page ?? { cookie: '', defaultView: null }; + this.runOutside = options.runOutside ?? ((callback) => callback()); + this.report = options.report; + } + + /** + * Loads and starts the RUM client. + * + * Fire-and-forget: it must never block bootstrap and never reject into the + * caller. Safe to call more than once; subsequent calls are no-ops. + * + * @returns a promise that resolves once initialization has been attempted + */ + async init(): Promise { + if (!this.canStart) { + return; + } + if (!this.initPromise) { + // Outside a framework's change detection: the SDK's dispatch interval + // and this sink's retry and refresh timers would otherwise keep a + // zone-based app from ever becoming stable. + this.initPromise = this.runOutside(() => this.performInit()); + } + return this.initPromise; + } + + private async performInit(): Promise { + let bootstrap: UwtRumBootstrap | null; + try { + bootstrap = await this.credentialsProvider.load(); + } catch (error) { + this.retryInit(error); + return; + } + + try { + if (!bootstrap?.config) { + this.disableAwsRum(); + this.buffer = []; + return; + } + + // Fails closed: the page and URL filters and `ignore` decide what is + // *not* recorded, so dropping one would record what the app meant to + // keep out, and passing it on would make the SDK throw. RUM stays off + // for the page instead, as with a broker that returns `null`. + const invalid = invalidRumSettings(bootstrap.config); + if (invalid.length) { + this.disableAwsRum(); + this.buffer = []; + this.reportInvalidSettings(invalid); + return; + } + + // Not retried if this fails: with native ES modules (Angular's default + // build) the browser remembers a failed dynamic import for the page. + const { AwsRum } = await import('aws-rum-web'); + if (this.destroyed) { + return; + } + + const sessionCookie = sessionCookieName(bootstrap); + const hadSession = this.hasCookie(sessionCookie); + + this.awsRum = new AwsRum( + bootstrap.config.applicationId, + bootstrap.config.applicationVersion, + bootstrap.config.region, + this.buildRumConfig(bootstrap) + ); + const { + eventCacheSize = SDK_DEFAULT_EVENT_CACHE_SIZE, + batchLimit = SDK_DEFAULT_BATCH_LIMIT + } = bootstrap.config; + const batches = Math.ceil(eventCacheSize / batchLimit); + // A batchLimit of 0 or a non-number would make this unbounded. + this.maxBatches = Number.isFinite(batches) && batches > 1 ? batches : 1; + + this.marksSessionStarts = + bootstrap.config.allowCookies !== false && + this.document.defaultView?.navigator.cookieEnabled !== false && + bootstrap.config.suppressSessionStartEvent !== true; + this.applyIdentity(); + // The client can start the first session inside its constructor — the + // landing page view, the navigation timing — before the metadata hook + // exists to see its session_start. It writes the session cookie then, + // so a cookie that wasn't there a moment ago means a session that + // started just now. + if ( + this.marksSessionStarts && + !hadSession && + this.hasCookie(sessionCookie) + ) { + this.markSessionStart(); + } + this.applyCredentials(bootstrap.credentials); + this.replayBuffer(); + } catch (error) { + this.disableAwsRum(); + this.buffer = []; + this.reportFailure('RUM init failed, monitoring disabled', error); + } + } + + /** + * After a failed broker request at startup: tries again later, keeping + * events buffered — the failure is usually a passing network or broker + * problem. Gives up after {@link INIT_RETRY_WINDOW_MS}. + */ + private retryInit(error: unknown): void { + if (this.destroyed) { + return; + } + + this.initFailures++; + this.initFailedAt ??= Date.now(); + if (Date.now() - this.initFailedAt >= INIT_RETRY_WINDOW_MS) { + this.disableAwsRum(); + this.buffer = []; + this.reportFailure('RUM init failed, monitoring disabled', error); + return; + } + + if (this.initFailures === 1) { + this.reportFailure('RUM init failed, retrying', error); + } + const limit = Math.min( + INIT_RETRY_BASE_MS * 2 ** (this.initFailures - 1), + INIT_RETRY_MAX_MS + ); + this.scheduleRetry(() => { + this.initPromise = this.performInit(); + return this.initPromise; + }, Math.random() * limit); + } + + /** + * Kicks off `init()` on first real use, for a sink nothing started at + * bootstrap (in Angular, one bound directly rather than through + * `provideUwtTelemetryRumSink()`). `init()` is idempotent and + * memoized, so this is a no-op once already initialized or already in + * flight, and never races with a caller awaiting `init()` directly. + */ + private ensureInitialized(): void { + if (!this.initPromise) { + this.init().catch(() => undefined); + } + } + + /** + * Shared shape behind {@link record}, {@link recordError} and + * {@link recordPageView}: kicks off init, then either buffers `item` + * (pre-init) or hands the live client to `dispatch`, all inside the same + * fail-open guard. + */ + private recordOrBuffer( + operation: string, + item: BufferedItem, + dispatch: (awsRum: AwsRum) => void + ): void { + this.ensureInitialized(); + uwtSafeVoid( + operation, + () => { + if (!this.canStart || this.disabled) { + return; + } + + const awsRum = this.awsRum; + if (!awsRum) { + this.bufferItem(item); + return; + } + + dispatch(awsRum); + }, + this.report + ); + } + + /** @inheritdoc */ + record( + eventType: string, + payload: object, + metadata?: UwtTelemetryMetadata + ): void { + this.recordOrBuffer( + 'rum.record', + { kind: 'event', eventType, payload, metadata }, + (awsRum) => + awsRum.recordEvent(eventType, payload, this.sanitize(metadata)) + ); + } + + /** @inheritdoc */ + recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata): void { + this.recordOrBuffer( + 'rum.recordError', + { kind: 'error', error, metadata }, + (awsRum) => awsRum.recordError(this.withOrigin(error, metadata)) + ); + } + + /** @inheritdoc */ + getSessionId(): string | undefined { + return uwtSafe( + 'rum.getSessionId', + () => this.awsRum?.getSessionId(), + undefined, + this.report + ); + } + + /** + * Associates subsequent events with a user, or with nobody. + * + * Signing in pins the id, and works even before `init()` resolves. + * Signing out starts a fresh session with a new anonymous id (`pinUserId` + * has no inverse), which emits a `session_start` event and re-rolls + * sampling. Before `init()` resolves, a sign-out is applied once the + * client starts. Signing out while nobody is signed in does nothing. + * + * Every event carries `signedIn` in its metadata, so a signed-in user's id + * can be told from an anonymous one. It is absent until the first call, + * so call this as early as possible, e.g. right after the auth library + * initializes. + * + * @param userId the application's own user identifier, or `undefined` + * (or `''`) on sign-out. RUM accepts only a UUID, e.g. MSAL's + * `localAccountId`. + */ + setUserId(userId: string | undefined): void { + uwtSafeVoid( + 'rum.setUserId', + () => { + const previous = this.userId; + this.userId = userId; + this.userKnown = true; + + const awsRum = this.awsRum; + if (!awsRum) { + // A sign-in is pinned, a sign-out applied, once the client starts. + if (userId || previous) { + this.signOutPending = !userId; + } + return; + } + + if (userId && userId !== previous) { + // Unless the client already has this id, as after a reload. + if (awsRum.getUserId() !== userId) { + this.sendWaiting(awsRum); + } + awsRum.pinUserId(userId); + } else if (!userId && previous) { + this.sendWaiting(awsRum); + awsRum.startSession({ userId: uwtUuid() }); + } + }, + this.report + ); + } + + /** + * Sends everything the client holds, under the current user id. The client + * reads the id when it sends a batch, not when it records an event, so + * events still waiting at a change would otherwise go out under the new id. + * Each `dispatch()` sends one batch, and nothing once the client is empty. + */ + private sendWaiting(awsRum: AwsRum): void { + for (let batch = 0; batch < this.maxBatches; batch++) { + awsRum.dispatch(); + } + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(beacon = false): void { + uwtSafeVoid( + 'rum.flush', + () => { + if (!this.awsRum) { + if (this.buffer.length > 0) { + uwtReport( + this.report, + 'warn', + `${this.buffer.length} RUM event(s) lost: page unloaded before RUM finished initializing` + ); + } + return; + } + + if (beacon) { + this.awsRum.dispatchBeacon(); + } else { + this.awsRum.dispatch(); + } + }, + this.report + ); + } + + /** + * Records a page view. + * + * The RUM client already records a page view on every navigation via + * `history.pushState`, covering a single-page router automatically. Use this + * to report a different page id (e.g. a route template instead of the + * resolved URL); also set `disableAutoPageView: true` in the RUM config + * to avoid recording the navigation twice. Buffered like {@link record} + * when called before `init()` resolves. + * + * @param pageId the page identity, e.g. `/customers/:id` + */ + recordPageView(pageId: string): void { + this.recordOrBuffer( + 'rum.recordPageView', + { kind: 'pageView', pageId }, + (awsRum) => awsRum.recordPageView(pageId) + ); + } + + /** + * Stops the client, and any credential refresh or startup retry pending. + * Calling it again does nothing. + */ + dispose(): void { + this.destroyed = true; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.disableAwsRum(); + } + + /** + * Disables the sink: stops the AWS RUM client's own listeners, plugins, + * and dispatch timer before dropping the reference — clearing `awsRum` + * alone only stops this class from talking to it; the already-constructed + * instance would otherwise keep running orphaned in the page (and, on + * dispose, could duplicate telemetry if the app is started again in + * the same one) — and marks the session disabled, so every call site that + * tears the client down does so atomically with the flag `recordOrBuffer` + * checks, instead of each caller having to remember to set both. + */ + private disableAwsRum(): void { + uwtSafeVoid('rum.disable', () => this.awsRum?.disable(), this.report); + this.awsRum = null; + this.disabled = true; + } + + /** + * Maps {@link UwtRumAppMonitorConfig} onto the SDK's own `PartialConfig`. + * + * The SDK merges this object over its own defaults with a shallow + * `Object.assign`, so a key present with value `undefined` or `null` + * overwrites the SDK's default instead of falling through to it. + * `allowCookies`, `enableXRay`, `endpoint` and `telemetries` always carry + * this library's own override; every other field goes through + * {@link omitUnset} (or, for `client`, a conditional spread) so an unset + * field is absent and the SDK's own default applies. Unset telemetry options + * are left out for the same reason. `performInit()` has already checked the + * types of `pagesToInclude`/`pagesToExclude`, `telemetries` and the telemetry + * options in {@link TELEMETRY_OPTION_CHECKS} ({@link invalidRumSettings}); + * every other field is passed on as given. + */ + private buildRumConfig(bootstrap: UwtRumBootstrap): AwsRumConfig { + const { config } = bootstrap; + const origin = this.document.defaultView?.location?.origin ?? ''; + + // X-Ray trace header only on same-origin requests; a cross-origin + // request with an unexpected header fails CORS preflight. Anchored at + // both ends (with a path/query/fragment/end-of-string lookahead) so + // `https://example.com` doesn't also match a look-alike host like + // `https://example.com.attacker.example`. + const sameOrigin = new RegExp( + '^' + origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[/?#]|$)' + ); + + return { + allowCookies: config.allowCookies ?? true, + enableXRay: config.enableXRay ?? true, + endpoint: + config.endpoint ?? + `https://dataplane.rum.${config.region}.amazonaws.com`, + telemetries: Array.isArray(config.telemetries) + ? withoutUnsetOptions(config.telemetries) + : (config.telemetries ?? [ + 'errors', + 'performance', + ['http', { addXRayTraceIdHeader: [sameOrigin] }] + ]), + + ...(config.client != null ? { client: config.client } : {}), + + ...omitUnset({ + releaseId: config.releaseId, + clientBuilder: config.clientBuilder, + applicationAttributes: config.applicationAttributes, + alias: config.alias, + headers: config.headers, + cookieAttributes: config.cookieAttributes, + sessionSampleRate: config.sessionSampleRate, + sessionLengthSeconds: config.sessionLengthSeconds, + sessionEventLimit: config.sessionEventLimit, + suppressSessionStartEvent: config.suppressSessionStartEvent, + userIdRetentionDays: config.userIdRetentionDays, + sessionAttributes: config.sessionAttributes, + eventPluginsToLoad: config.eventPluginsToLoad, + enableW3CTraceId: config.enableW3CTraceId, + recordResourceUrl: config.recordResourceUrl, + batchLimit: config.batchLimit, + dispatchInterval: config.dispatchInterval, + eventCacheSize: config.eventCacheSize, + candidatesCacheSize: config.candidatesCacheSize, + retries: config.retries, + useBeacon: config.useBeacon, + signing: config.signing, + compressionStrategy: config.compressionStrategy, + fetchFunction: config.fetchFunction, + pageIdFormat: config.pageIdFormat, + pagesToInclude: config.pagesToInclude, + pagesToExclude: config.pagesToExclude, + routeChangeComplete: config.routeChangeComplete, + routeChangeTimeout: config.routeChangeTimeout, + disableAutoPageView: config.disableAutoPageView, + debug: config.debug, + enableRumClient: config.enableRumClient + }) + } as unknown as AwsRumConfig; + } + + /** + * Attaches application identity once per session rather than once per event. + * + * `addSessionAttributes` covers custom events; the metadata hook stamps + * the same attributes onto the client's own built-in events too. The hook + * also adds `signedIn`, read when each event is recorded. + * + * See {@link UwtRumAppMonitorConfig.sessionAttributes} for how this + * merges with a consumer-supplied base. + * + * It also sees the client's own `session_start` event, recorded + * each time it starts a session, and records that moment as the + * session's start (`uwtMarkSessionStart`) — the client keeps no start time + * itself. Only while {@link marksSessionStarts}. + */ + private applyIdentity(): void { + if (!this.awsRum) { + return; + } + + const attributes = { + application: this.config.application, + environment: this.config.environment, + appVersion: this.config.version + }; + + this.awsRum.addSessionAttributes(attributes); + this.awsRum.setEventMetadataHook((type) => { + if (this.marksSessionStarts && type === SESSION_START_EVENT_TYPE) { + this.markSessionStart(); + } + return this.userKnown + ? { ...attributes, signedIn: !!this.userId } + : attributes; + }); + + if (this.userId) { + this.awsRum.pinUserId(this.userId); + } else if (this.signOutPending) { + this.signOutPending = false; + this.awsRum.startSession({ userId: uwtUuid() }); + } + } + + /** + * Records the current session's start as now. Called right after a session + * started, so it is current and reading its id doesn't start another one — + * except with a session length of 0, where the read starts a new session + * whose session_start calls back in here; the guard stops that recursion. + */ + private markSessionStart(): void { + if (this.markingSession) { + return; + } + this.markingSession = true; + try { + uwtSafeVoid( + 'rum.markSessionStart', + () => { + const sessionId = this.awsRum?.getSessionId(); + if (sessionId) { + uwtMarkSessionStart(sessionId); + } + }, + this.report + ); + } finally { + this.markingSession = false; + } + } + + /** Whether the page has a cookie with this name; `false` when cookies can't be read. */ + private hasCookie(name: string): boolean { + return uwtSafe( + 'rum.readCookie', + () => + this.document.cookie + .split(';') + .some((cookie) => cookie.trim().split('=')[0] === name), + false, + this.report + ); + } + + private applyCredentials(credentials?: UwtRumCredentials): void { + if (!this.awsRum || !credentials) { + return; + } + + this.awsRum.setAwsCredentials({ + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken + }); + + this.scheduleRefresh(credentials.expiration); + } + + private scheduleRefresh(expiration: string): void { + const delay = + new Date(expiration).getTime() - Date.now() - CREDENTIAL_REFRESH_SKEW_MS; + + if (!Number.isFinite(delay) || delay <= 0) { + // Avoid tight-looping on an already-expired/unparsable expiration. + this.scheduleRetry(); + return; + } + + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout( + () => { + this.refreshCredentials().catch(() => undefined); + }, + Math.min(delay, MAX_TIMEOUT_MS) + ); + } + + private async refreshCredentials(): Promise { + this.refreshTimer = null; + try { + const bootstrap = await this.credentialsProvider.load(); + if (this.destroyed) { + return; + } + + if (!bootstrap) { + // null/undefined disables RUM for the session (see load()'s contract). + this.disableAwsRum(); + return; + } + + if (bootstrap.credentials) { + this.applyCredentials(bootstrap.credentials); + } else { + this.scheduleRetry(); + this.reportFailure( + 'RUM credential refresh', + new Error( + 'bootstrap omitted credentials for an already-authenticated ' + + 'session; keeping existing credentials and retrying' + ) + ); + } + } catch (error) { + if (!this.destroyed) { + this.scheduleRetry(); + this.reportFailure('RUM credential refresh failed', error); + } + } + } + + /** Runs `attempt` (by default, a credential refresh) after `delayMs`. */ + private scheduleRetry( + attempt: () => Promise = () => this.refreshCredentials(), + delayMs = CREDENTIAL_RETRY_DELAY_MS + ): void { + if (this.destroyed) { + return; + } + + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + attempt().catch(() => undefined); + }, delayMs); + } + + /** + * Reports settings that kept RUM from starting, in every environment: a + * silent production page would hide that RUM is off. Each setting's name and + * expected type are printed, never its value. + * + * @param problems one `name (expected type)` entry per setting + */ + private reportInvalidSettings(problems: string[]): void { + uwtReport( + this.report ?? uwtConsoleReporter, + 'config', + `RUM not started, settings of the wrong type: ${problems.join(', ')}` + ); + } + + /** + * Reports an init/refresh failure without leaking raw error content into + * the production console. + */ + private reportFailure(operation: string, error: unknown): void { + if (!this.report) { + return; + } + uwtReport( + this.report, + 'warn', + operation, + uwtNormalizeError(error, this.redact, this.report) + ); + } + + private bufferItem(item: BufferedItem): void { + this.buffer.push(item); + if (this.buffer.length > PRE_INIT_BUFFER_LIMIT) { + this.buffer.shift(); + } + } + + private replayBuffer(): void { + const pending = this.buffer; + this.buffer = []; + + for (const item of pending) { + uwtSafeVoid( + 'rum.replay', + () => { + if (item.kind === 'event') { + this.awsRum?.recordEvent( + item.eventType, + item.payload, + this.sanitize(item.metadata) + ); + } else if (item.kind === 'pageView') { + this.awsRum?.recordPageView(item.pageId); + } else { + this.awsRum?.recordError( + this.withOrigin(item.error, item.metadata) + ); + } + }, + this.report + ); + } + } + + /** + * Drops metadata keys under the reserved `aws:` prefix, which the client + * would otherwise discard with a console warning. Also drops `null` + * values, which {@link UwtTelemetryMetadata} permits but the SDK's + * metadata type does not. + */ + private sanitize( + metadata?: UwtTelemetryMetadata + ): Record | undefined { + if (!metadata) { + return undefined; + } + + const result: Record = {}; + for (const [key, value] of Object.entries(metadata)) { + if (key.startsWith(RESERVED_PREFIX) || value === null) { + continue; + } + result[key] = value; + } + + return Object.keys(result).length ? result : undefined; + } + + /** + * Folds a forwarded error's origin into its name. `AwsRum.recordError` + * takes no metadata argument, so `name` is the only field left to carry it. + */ + private withOrigin( + error: UwtTelemetryError, + metadata?: UwtTelemetryMetadata + ): UwtTelemetryError { + const origin = metadata + ? (metadata as Record).application + : undefined; + if (typeof origin !== 'string' || origin === this.config.application) { + return error; + } + return { ...error, name: `[${origin}] ${error.name}` }; + } +} diff --git a/projects/ui-watchtower/rum/src/public-api.ts b/projects/ui-watchtower/rum/src/public-api.ts new file mode 100644 index 0000000..90bd278 --- /dev/null +++ b/projects/ui-watchtower/rum/src/public-api.ts @@ -0,0 +1,19 @@ +/* + * Public API Surface of ui-watchtower/rum + * + * The AWS CloudWatch RUM destination, in its own entry point so the main one + * never refers to `aws-rum-web`: an optional peer, needed only here, and + * loaded only once the sink starts. + */ + +export { UwtRumTelemetrySink } from './lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink'; +export type { + UwtRumDocument, + UwtRumTelemetrySinkOptions +} from './lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink'; +export type { + UwtRumAppMonitorConfig, + UwtRumBootstrap, + UwtRumCredentials, + UwtRumCredentialsProvider +} from './lib/uwt-rum-credentials/uwt-rum-credentials'; diff --git a/projects/ui-watchtower/rum/tsconfig.lib.json b/projects/ui-watchtower/rum/tsconfig.lib.json new file mode 100644 index 0000000..8b7cf53 --- /dev/null +++ b/projects/ui-watchtower/rum/tsconfig.lib.json @@ -0,0 +1,16 @@ +/* Type-checks the ./rum entry point; ng-packagr builds it with the main tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib-core-rum", + // Includes the main entry point: it is read from source here. + "rootDir": "..", + "lib": ["ES2022", "DOM"], + "declaration": true, + "skipLibCheck": true, + "isolatedModules": false, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"] +} diff --git a/projects/ui-watchtower/rxjs/ng-package.json b/projects/ui-watchtower/rxjs/ng-package.json new file mode 100644 index 0000000..e09fb3f --- /dev/null +++ b/projects/ui-watchtower/rxjs/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/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.spec.ts b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.spec.ts new file mode 100644 index 0000000..59c3752 --- /dev/null +++ b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.spec.ts @@ -0,0 +1,514 @@ +import { + defer, + EMPTY, + first, + type Observable, + firstValueFrom, + lastValueFrom, + of, + retry, + Subject, + switchMap, + take, + throwError +} from 'rxjs'; +import { + UWT_DEFAULT_REDACT_CONFIG, + UWT_DEFAULT_TELEMETRY_CONFIG, + UwtNoopTelemetrySink, + UwtScenario, + UwtScenarioRecord +} from '@absaoss-cps/ui-watchtower'; +import { traceScenario } from './uwt-scenario-operators'; + +describe('traceScenario operator', () => { + let scenario: jest.Mocked; + + beforeEach(() => { + scenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + }); + + it('should complete scenario on successful stream completion', (done) => { + of(['user1', 'user2']) + .pipe(traceScenario(scenario)) + .subscribe({ + next: (val) => { + expect(val).toEqual(['user1', 'user2']); + }, + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should complete scenario with mapped outcome using function argument', (done) => { + of(['item1', 'item2', 'item3']) + .pipe( + traceScenario(scenario, (items) => ({ + metadata: { itemCount: items.length } + })) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith({ + metadata: { itemCount: 3 } + }); + done(); + } + }); + }); + + it('should complete scenario with mapped outcome using options object', (done) => { + of({ status: 200, data: 'ok' }) + .pipe( + traceScenario(scenario, { + outcome: (res) => ({ + statusCode: res.status, + metadata: { result: res.data } + }) + }) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith({ + statusCode: 200, + metadata: { result: 'ok' } + }); + done(); + } + }); + }); + + it('should complete scenario with no outcome when the stream completes with no emissions', (done) => { + EMPTY.pipe( + traceScenario(scenario, () => ({ metadata: { unreachable: true } })) + ).subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should fail scenario when the stream errors and re-throw the error', (done) => { + const error = new Error('network down'); + throwError(() => error) + .pipe(traceScenario(scenario)) + .subscribe({ + error: (err) => { + expect(err).toBe(error); + expect(scenario.fail).toHaveBeenCalledWith({ error }); + expect(scenario.complete).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should still settle the scenario when the outcome mapper throws', (done) => { + const report = jest.fn(); + + of('value') + .pipe( + traceScenario( + scenario, + () => { + throw new Error('mapper is broken'); + }, + report + ) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + expect(report).toHaveBeenCalledWith( + 'error', + 'scenario.traceOutcome failed', + expect.any(Error) + ); + done(); + } + }); + }); + + it('should not leak lastValue/hasValue into a resubscription of the same piped observable', (done) => { + let subscriptionCount = 0; + const source = defer(() => { + subscriptionCount++; + return subscriptionCount === 1 ? of('a') : EMPTY; + }); + + const traced$ = source.pipe( + traceScenario(scenario, (value) => ({ metadata: { value } })) + ); + + traced$.subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenNthCalledWith(1, { + metadata: { value: 'a' } + }); + + traced$.subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenNthCalledWith(2, undefined); + done(); + } + }); + } + }); + }); + + describe('teardown without a source complete/error', () => { + it('should cancel the scenario on a manual unsubscribe', () => { + const source = new Subject(); + const subscription = source.pipe(traceScenario(scenario)).subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledTimes(1); + expect(scenario.complete).not.toHaveBeenCalled(); + expect(scenario.fail).not.toHaveBeenCalled(); + }); + + it('should cancel a superseded scenario when switchMap moves to the next inner observable', () => { + const supersededScenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + const pendingFirstRequest = new Subject(); + const secondRequest = of('b'); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + (n === 1 ? pendingFirstRequest : secondRequest).pipe( + traceScenario(n === 1 ? supersededScenario : scenario) + ) + ) + ) + .subscribe(); + + trigger.next(1); // subscribes traceScenario(supersededScenario), still pending + trigger.next(2); // switchMap unsubscribes it before it ever settles + + expect(supersededScenario.cancel).toHaveBeenCalledTimes(1); + expect(supersededScenario.complete).not.toHaveBeenCalled(); + expect(supersededScenario.fail).not.toHaveBeenCalled(); + expect(scenario.complete).toHaveBeenCalledWith(undefined); + }); + + it('should cancel with the configured cancelOutcome on unsubscribe', () => { + const source = new Subject(); + const subscription = source + .pipe( + traceScenario(scenario, { cancelOutcome: { reason: 'superseded' } }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); + }); + + it('should accept cancelOutcome as a function, evaluated at unsubscribe time', () => { + const source = new Subject(); + const subscription = source + .pipe( + traceScenario(scenario, { + cancelOutcome: () => ({ reason: 'superseded' }) + }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); + }); + + it('should still cancel, with no outcome, when cancelOutcome throws', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const source = new Subject(); + const subscription = source + .pipe( + traceScenario(scenario, { + cancelOutcome: () => { + throw new Error('cancelOutcome is broken'); + } + }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith(undefined); + consoleError.mockRestore(); + }); + + it('should record a reason for a switchMap-superseded scenario, not just a bare cancel', () => { + const supersededScenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + const pendingFirstRequest = new Subject(); + const secondRequest = of('b'); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + (n === 1 ? pendingFirstRequest : secondRequest).pipe( + traceScenario(n === 1 ? supersededScenario : scenario, { + cancelOutcome: { reason: 'superseded' } + }) + ) + ) + ) + .subscribe(); + + trigger.next(1); + trigger.next(2); + + expect(supersededScenario.cancel).toHaveBeenCalledWith({ + reason: 'superseded' + }); + }); + + it('should not cancel a scenario that already completed normally', (done) => { + of('value') + .pipe(traceScenario(scenario)) + .subscribe({ + complete: () => { + (scenario as unknown as { isSettled: boolean }).isSettled = true; + expect(scenario.cancel).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should not cancel a scenario that already failed', (done) => { + const error = new Error('boom'); + throwError(() => error) + .pipe(traceScenario(scenario)) + .subscribe({ + error: () => { + (scenario as unknown as { isSettled: boolean }).isSettled = true; + expect(scenario.cancel).not.toHaveBeenCalled(); + done(); + } + }); + }); + }); + + describe('with a real scenario', () => { + let settled: UwtScenarioRecord[]; + + const countRows = (rows: string[]) => ({ + metadata: { count: rows.length } + }); + + function createScenario(name = 'load-customers'): UwtScenario { + return new UwtScenario( + { name }, + { + identity: { + application: 'test-app', + environment: 'test', + version: '1.0.0' + }, + scenarioTelemetryConfig: { + ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario + }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink: new UwtNoopTelemetrySink(), + onSettled: (_id, record) => settled.push(record) + } + ); + } + + beforeEach(() => { + settled = []; + }); + + describe('single-value sources', () => { + it('should settle as success with subscribe()', () => { + const real = createScenario(); + + of(['a', 'b']).pipe(traceScenario(real, countRows)).subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with lastValueFrom', async () => { + const real = createScenario(); + + await lastValueFrom( + of(['a', 'b']).pipe(traceScenario(real, countRows)) + ); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with firstValueFrom', async () => { + const real = createScenario(); + + const rows = await firstValueFrom( + of(['a', 'b']).pipe(traceScenario(real, countRows)) + ); + + expect(rows).toEqual(['a', 'b']); + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with take(1) after the operator', () => { + const real = createScenario(); + + of(['a', 'b']) + .pipe(traceScenario(real, countRows), take(1)) + .subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with first() after the operator', () => { + const real = createScenario(); + + of(['a', 'b']) + .pipe(traceScenario(real, countRows), first()) + .subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with take(1) before the operator', () => { + const real = createScenario(); + + of(['a', 'b']) + .pipe(take(1), traceScenario(real, countRows)) + .subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with take(1) before the operator under firstValueFrom', async () => { + const real = createScenario(); + + await firstValueFrom( + of(['a', 'b']).pipe(take(1), traceScenario(real, countRows)) + ); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + }); + + describe('switchMap superseding an inner stream', () => { + it('should abandon a stream superseded before it emitted', () => { + const superseded = createScenario('superseded'); + const latest = createScenario('latest'); + const pendingFirstRequest = new Subject(); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + n === 1 + ? pendingFirstRequest.pipe(traceScenario(superseded)) + : of(['b']).pipe(traceScenario(latest)) + ) + ) + .subscribe(); + + trigger.next(1); + trigger.next(2); + + expect(superseded.status).toBe('abandoned'); + expect(latest.status).toBe('success'); + }); + + it('should settle as success a stream superseded after it emitted', () => { + const superseded = createScenario('superseded'); + const latest = createScenario('latest'); + const firstRequest = new Subject(); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + n === 1 + ? firstRequest.pipe(traceScenario(superseded, countRows)) + : of(['b']).pipe(traceScenario(latest)) + ) + ) + .subscribe(); + + trigger.next(1); + firstRequest.next(['a']); // emitted, but never completed + trigger.next(2); + + expect(superseded.status).toBe('success'); + expect(superseded.toRecord().metadata).toEqual({ count: 1 }); + expect(latest.status).toBe('success'); + }); + }); + + it("should abandon a stream torn down after a value with valueThenTeardown: 'cancel'", () => { + const real = createScenario('poll-status'); + + of(['a'], ['b']) + .pipe(traceScenario(real, { valueThenTeardown: 'cancel' }), take(1)) + .subscribe(); + + expect(real.status).toBe('abandoned'); + }); + + describe('retry placement', () => { + function failOnce(): Observable { + let attempts = 0; + return defer(() => + attempts++ === 0 + ? throwError(() => new Error('transient')) + : of(['a']) + ); + } + + it('should settle as success when retry() comes before the operator', () => { + const real = createScenario(); + + failOnce().pipe(retry(1), traceScenario(real)).subscribe(); + + expect(real.status).toBe('success'); + }); + + it('should settle as failure when retry() comes after the operator, even if the retry succeeds', () => { + const real = createScenario(); + + failOnce().pipe(traceScenario(real), retry(1)).subscribe(); + + // Documented limitation: the first error settles the scenario and + // the operator cannot see the downstream retry. + expect(real.status).toBe('failure'); + }); + }); + }); +}); diff --git a/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.ts b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.ts new file mode 100644 index 0000000..6edb052 --- /dev/null +++ b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.ts @@ -0,0 +1,129 @@ +import { defer } from 'rxjs'; +import type { MonoTypeOperatorFunction, Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { + UwtReporter, + UwtScenario, + UwtScenarioOutcome, + uwtSafe +} from '@absaoss-cps/ui-watchtower'; + +/** + * Options for configuring scenario completion within an RxJS stream. + * + * @group Interfaces + */ +export interface UwtTraceScenarioOptions { + /** + * Optional mapper that returns an outcome (metadata, statusCode, message) + * based on the emitted value when the Observable completes successfully. + */ + outcome?: (value: T) => UwtScenarioOutcome | void; + + /** + * Outcome used when the stream is unsubscribed before completing or + * erroring — e.g. a `switchMap` moving on to a newer value. + */ + cancelOutcome?: UwtScenarioOutcome | (() => UwtScenarioOutcome | void); + + /** + * What a downstream unsubscribe means once a value has been delivered. + * Default `'complete'`, so `firstValueFrom`, `take(1)` and `first()` settle + * as success. Use `'cancel'` for multi-value streams (polling, progress), + * where stopping after a value is abandonment. + */ + valueThenTeardown?: 'complete' | 'cancel'; +} + +/** + * An RxJS pipeable operator that automatically settles a {@link UwtScenario} + * based on the stream's outcome: `complete()` on completion, `fail({ error })` + * on error. + * + * @example + * ```typescript + * this.http.get('/api/customers') + * .pipe( + * traceScenario(scenario, { + * outcome: (customers) => ({ metadata: { count: customers.length } }) + * }) + * ) + * .subscribe(); + * ``` + * + * @param scenario the scenario instance to track + * @param options optional outcome mapper or configuration + * @param report receives what a throwing outcome mapper threw; see + * {@link UwtReporter} + * + * @group Utils + */ +export function traceScenario( + scenario: UwtScenario, + options?: + UwtTraceScenarioOptions | ((value: T) => UwtScenarioOutcome | void), + report?: UwtReporter +): MonoTypeOperatorFunction { + const outcomeMapper = + typeof options === 'function' ? options : options?.outcome; + const cancelOutcome = + typeof options === 'function' ? undefined : options?.cancelOutcome; + const valueThenTeardown = + (typeof options === 'function' ? undefined : options?.valueThenTeardown) ?? + 'complete'; + + return (source: Observable): Observable => + defer(() => { + let lastValue: T | undefined; + let hasValue = false; + + const complete = () => { + const outcome = + hasValue && outcomeMapper + ? uwtSafe( + 'scenario.traceOutcome', + () => outcomeMapper(lastValue as T), + undefined, + report + ) + : undefined; + scenario.complete(outcome || undefined); + }; + + const unsubscribe = () => { + if (scenario.isSettled) { + return; + } + // The consumer got a value and stopped listening, as + // firstValueFrom, take(1) and first() do: treat it as completion. + if (hasValue && valueThenTeardown === 'complete') { + complete(); + return; + } + const resolved = + typeof cancelOutcome === 'function' + ? uwtSafe( + 'scenario.traceCancelOutcome', + cancelOutcome, + undefined, + report + ) + : cancelOutcome; + scenario.cancel(resolved || undefined); + }; + + return source.pipe( + tap({ + next: (value) => { + lastValue = value; + hasValue = true; + }, + complete, + error: (error: unknown) => { + scenario.fail({ error }); + }, + unsubscribe + }) + ); + }); +} diff --git a/projects/ui-watchtower/rxjs/src/public-api.ts b/projects/ui-watchtower/rxjs/src/public-api.ts new file mode 100644 index 0000000..e8e1ca7 --- /dev/null +++ b/projects/ui-watchtower/rxjs/src/public-api.ts @@ -0,0 +1,9 @@ +/* + * Public API Surface of ui-watchtower/rxjs + * + * RxJS integration, in its own entry point so the main one never loads + * RxJS: `rxjs` is an optional peer, needed only here. + */ + +export { traceScenario } from './lib/uwt-scenario-operators/uwt-scenario-operators'; +export type { UwtTraceScenarioOptions } from './lib/uwt-scenario-operators/uwt-scenario-operators'; diff --git a/projects/ui-watchtower/rxjs/tsconfig.lib.json b/projects/ui-watchtower/rxjs/tsconfig.lib.json new file mode 100644 index 0000000..c2d230e --- /dev/null +++ b/projects/ui-watchtower/rxjs/tsconfig.lib.json @@ -0,0 +1,16 @@ +/* Type-checks the ./rxjs entry point; ng-packagr builds it with the main tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib-core-rxjs", + // Includes the main entry point: it is read from source here. + "rootDir": "..", + "lib": ["ES2022", "DOM"], + "declaration": true, + "skipLibCheck": true, + "isolatedModules": false, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"] +} diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.spec.ts b/projects/ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.spec.ts rename to projects/ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.spec.ts diff --git a/projects/ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts b/projects/ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts new file mode 100644 index 0000000..788dbf3 --- /dev/null +++ b/projects/ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts @@ -0,0 +1,34 @@ +/** + * Business and UX event tracking configuration. + * + * @group Interfaces + */ +export interface UwtBITelemetryConfig { + /** Milliseconds within which an identical event is treated as a double-fire. */ + dedupWindowMs: number; + + /** + * Distinct event keys tracked before expired ones are swept. + * + * Keyed per `eventName|scenarioId|feature|metadata`, which + * grows unbounded over a long session without this cap. + */ + dedupMaxKeys: number; + + /** + * Whether redaction runs on BI events. On by default. Turning it off + * skips only the *configurable* PII scrubbing (`extraKeyPatterns`, + * value-pattern scanning, URL-query stripping) — the built-in credential + * denylist, size caps, error normalization and any + * `UwtRedactConfig.extraValueTransforms` still apply; see + * {@link uwtRedactConfigFor}. + */ + redact: boolean; +} + +/** Default BI event tracking settings. */ +export const UWT_DEFAULT_BI_TELEMETRY_CONFIG: UwtBITelemetryConfig = { + dedupWindowMs: 400, + dedupMaxKeys: 100, + redact: true +}; diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.spec.ts b/projects/ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.spec.ts rename to projects/ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.spec.ts diff --git a/projects/ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts b/projects/ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts new file mode 100644 index 0000000..38414e4 --- /dev/null +++ b/projects/ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts @@ -0,0 +1,51 @@ +import { + UwtLoggerName, + UwtLogLevel +} from '../../models/uwt-log.models/uwt-log.models'; + +/** + * Application logging configuration. + * + * @group Interfaces + */ +export interface UwtLogConfig { + /** Records below this severity are discarded. */ + minLevel: UwtLogLevel; + + /** + * Per-logger overrides of {@link minLevel}, keyed by logger name. Loggers + * without an override use {@link minLevel}. + * + * ```typescript + * { minLevel: 'warn', levels: { checkout: 'log' } } + * ``` + */ + levels?: Partial>; + + /** + * Also report `logger.error` calls to the telemetry sink as RUM errors. Off + * by default; competes for the same session event budget as scenario and + * BI data. + * + * Mirrors `detail.error` when supplied; a message-only call builds a + * synthetic `Error` from the redacted message text instead. + */ + mirrorErrorsToRum: boolean; + + /** + * Whether redaction runs on log records. On by default. Turning it off + * skips only the *configurable* PII scrubbing (`extraKeyPatterns`, + * value-pattern scanning, URL-query stripping) — the built-in credential + * denylist, size caps, error normalization and any + * `UwtRedactConfig.extraValueTransforms` still apply; see + * {@link uwtRedactConfigFor}. + */ + redact: boolean; +} + +/** Default application logging settings. */ +export const UWT_DEFAULT_LOG_CONFIG: UwtLogConfig = { + minLevel: 'log', + mirrorErrorsToRum: false, + redact: true +}; diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.spec.ts b/projects/ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.spec.ts rename to projects/ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.spec.ts diff --git a/projects/ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts b/projects/ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts new file mode 100644 index 0000000..13d685c --- /dev/null +++ b/projects/ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts @@ -0,0 +1,72 @@ +/** + * Scenario telemetry configuration. + * + * @group Interfaces + */ +export interface UwtScenarioTelemetryConfig { + /** + * Milliseconds after which an unsettled scenario auto-settles as `timeout`. + * `0` disables the default; individual scenarios can still opt in. + */ + defaultTimeoutMs: number; + + /** + * Emit an event per lifecycle transition instead of one packed event at + * settlement. Off by default. + * + * Counts against the RUM session's shared `sessionEventLimit` (200 by + * default). Suited to local debugging, not production traffic. + * + * Also governs per-step `debugScenario` console output, because the + * console prints one line per event actually sent and nothing else. + * With this off, a scenario prints a single line at settlement — the + * one record it sends. + */ + emitLifecycleEvents: boolean; + + /** + * Maximum number of steps retained per scenario. Further steps are counted in + * `stepCount` but not stored, bounding the payload of a runaway loop. + */ + maxSteps: number; + + /** + * Mirror scenario and step boundaries into `performance.mark` / `measure`, + * visible on the DevTools Performance track. Off by default. + * + * The `debugScenario` LocalStorage flag switches them on regardless of this + * setting. Marks are cleared when a scenario settles; see + * {@link markCleanupFallbackMs} for scenarios that never do. + */ + userTimings: boolean; + + /** + * How long in milliseconds an unsettled scenario's User Timing marks can + * remain in the Performance buffer before a fallback clears them. + * + * Only applies when `userTimings` is enabled and `timeoutMs: 0`. `0` + * disables the fallback, so marks accumulate for the page's lifetime. + */ + markCleanupFallbackMs: number; + + /** + * Whether redaction runs on scenario records. On by default. Turning it + * off skips only the *configurable* PII scrubbing (`extraKeyPatterns`, + * value-pattern scanning, URL-query stripping) — the built-in credential + * denylist, size caps, error normalization and any + * `UwtRedactConfig.extraValueTransforms` still apply; see + * {@link uwtRedactConfigFor}. + */ + redact: boolean; +} + +/** Default scenario telemetry settings. */ +export const UWT_DEFAULT_SCENARIO_TELEMETRY_CONFIG: UwtScenarioTelemetryConfig = + { + defaultTimeoutMs: 30_000, + emitLifecycleEvents: false, + maxSteps: 50, + userTimings: false, + markCleanupFallbackMs: 300_000, + redact: true + }; diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.spec.ts b/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.spec.ts rename to projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.spec.ts diff --git a/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts b/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts new file mode 100644 index 0000000..3b4b858 --- /dev/null +++ b/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts @@ -0,0 +1,126 @@ +import { + UwtBITelemetryConfig, + UWT_DEFAULT_BI_TELEMETRY_CONFIG +} from '../uwt-bi-telemetry.config/uwt-bi-telemetry.config'; +import { + UwtLogConfig, + UWT_DEFAULT_LOG_CONFIG +} from '../uwt-log.config/uwt-log.config'; +import { + UwtScenarioTelemetryConfig, + UWT_DEFAULT_SCENARIO_TELEMETRY_CONFIG +} from '../uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { UWT_DEFAULT_EVENT_NAMESPACE } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { + UWT_DEFAULT_REDACT_CONFIG, + UwtRedactConfig +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; + +/** + * The identity every telemetry record and event carries — application name, + * deployment environment and version, plus the event-type namespace. + * + * @group Interfaces + */ +export interface UwtTelemetryIdentity { + /** Application name, e.g. `composition`. Becomes a session attribute. */ + application: string; + + /** Deployment environment, e.g. `dev`, `qa`, `prod`. */ + environment: string; + + /** Application version, e.g. `22.0.0`. */ + version: string; + + /** + * Prefix for the custom event types this library emits — `{namespace}.scenario`, + * `{namespace}.scenario.step` and `{namespace}.bi`. + * + * Defaults to `com.uwt`. + */ + eventNamespace?: string; +} + +/** + * Library defaults for every concern not carried by {@link UwtTelemetryIdentity} + * — applied to anything a configuration leaves out. + * + * @group Utils + */ +export const UWT_DEFAULT_TELEMETRY_CONFIG: { + eventNamespace: string; + scenario: UwtScenarioTelemetryConfig; + logs: UwtLogConfig; + bi: UwtBITelemetryConfig; + redact: UwtRedactConfig; +} = { + eventNamespace: UWT_DEFAULT_EVENT_NAMESPACE, + scenario: UWT_DEFAULT_SCENARIO_TELEMETRY_CONFIG, + logs: UWT_DEFAULT_LOG_CONFIG, + bi: UWT_DEFAULT_BI_TELEMETRY_CONFIG, + redact: UWT_DEFAULT_REDACT_CONFIG +}; + +/** + * Overrides for each concern's configuration: whatever is left out takes the + * library default. + * + * @group Interfaces + */ +export interface UwtTelemetryConfigOverrides { + scenarios?: Partial; + logging?: Partial; + bi?: Partial; + redaction?: Partial; +} + +/** + * Each concern's configuration, complete. + * + * @group Interfaces + */ +export interface UwtTelemetryConfig { + scenarios: UwtScenarioTelemetryConfig; + logging: UwtLogConfig; + bi: UwtBITelemetryConfig; + redaction: UwtRedactConfig; +} + +/** + * Completes each concern's configuration with the library defaults: every + * override over its default, shallowly. Redaction's pattern and transform + * lists are copied into fresh arrays, so callers never share array identity + * with the defaults or each other. + * + * The one place defaults are applied, for the engine and every adapter. + * + * @param overrides what the application sets + * @returns the configuration an engine runs with + * + * @group Utils + */ +export function uwtResolveTelemetryConfig( + overrides: UwtTelemetryConfigOverrides = {} +): UwtTelemetryConfig { + const defaults = UWT_DEFAULT_TELEMETRY_CONFIG; + const redaction = overrides.redaction ?? {}; + return { + scenarios: { ...defaults.scenario, ...overrides.scenarios }, + logging: { ...defaults.logs, ...overrides.logging }, + bi: { ...defaults.bi, ...overrides.bi }, + redaction: { + ...defaults.redact, + ...redaction, + extraKeyPatterns: [ + ...(redaction.extraKeyPatterns ?? defaults.redact.extraKeyPatterns) + ], + extraValuePatterns: [ + ...(redaction.extraValuePatterns ?? defaults.redact.extraValuePatterns) + ], + extraValueTransforms: [ + ...(redaction.extraValueTransforms ?? + defaults.redact.extraValueTransforms) + ] + } + }; +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts new file mode 100644 index 0000000..1e4c881 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts @@ -0,0 +1,254 @@ +import { UwtBITelemetryConfig } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { + UwtBIEvent, + UwtBIEventDetail, + UwtBIEventName +} from '../../models/uwt-bi.models/uwt-bi.models'; +import { + uwtEventTypes, + UwtTelemetryEventTypes, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +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 { + UwtRedactConfig, + uwtRedactConfigFor, + uwtRedactMetadata, + uwtScrubString +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + uwtNow, + UwtReporter, + uwtSafe, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; + +/** + * What {@link UwtBITelemetry} needs. + * + * @group Interfaces + */ +export interface UwtBITelemetryOptions { + /** The application's identity, shared by every concern. */ + identity: UwtTelemetryIdentity; + /** BI event settings, complete; see {@link uwtResolveTelemetryConfig}. */ + config: UwtBITelemetryConfig; + /** + * Redaction settings shared by every concern. `config.redact: false` + * turns their configurable part off for BI events. + */ + redact: UwtRedactConfig; + /** Where events go. */ + sink: UwtTelemetrySink; + /** `false` makes {@link UwtBITelemetry.track} do nothing. Default `true`. */ + enabled?: boolean; + /** Receives what is handled silently; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Business and UX event tracking — feature adoption, interaction analysis, + * funnel steps. + * + * Unlike scenarios, BI events are discrete: no duration, no lifecycle. Event + * names and attributes come entirely from the application. + * + * Repeated identical events within a short window are collapsed, absorbing + * double-fires from a `click` handler also bound to `keydown`, or from a + * user clicking twice. + * + * Console output is off unless the `debugBI` LocalStorage flag is set: + * + * ```js + * localStorage.setItem('debugBI', 'true'); + * ``` + * + * @example + * ```typescript + * telemetry.bi.track('export_clicked', { + * exportType: format, + * source: 'customer-table' + * }); + * ``` + * + * @group Classes + */ +export class UwtBITelemetry { + private readonly identity: UwtTelemetryIdentity; + private readonly config: UwtBITelemetryConfig; + private readonly redact: UwtRedactConfig; + private readonly sink: UwtTelemetrySink; + private readonly enabled: boolean; + private readonly report?: UwtReporter; + + private readonly eventTypes: UwtTelemetryEventTypes; + private readonly lastEmittedAt = new Map(); + + constructor(options: UwtBITelemetryOptions) { + this.identity = options.identity; + this.config = options.config; + this.redact = uwtRedactConfigFor(options.redact, options.config.redact); + this.sink = options.sink; + this.enabled = options.enabled !== false; + this.report = options.report; + this.eventTypes = uwtEventTypes(options.identity.eventNamespace); + } + + /** + * Records a business or UX event. + * + * @param eventName the application's own event name, e.g. `export_clicked`, + * as declared in {@link UwtBIEventNames}. Treat it as a metric dimension: + * keep the cardinality low and never interpolate an identifier into it. + * @param metadata flat attributes describing the interaction + * @param detail optional scenario correlation and feature + */ + track( + eventName: UwtBIEventName, + metadata?: UwtTelemetryMetadata, + detail?: UwtBIEventDetail + ): void { + if (!this.enabled) { + return; + } + uwtSafeVoid( + 'biTelemetry.track', + () => { + if (!eventName) { + return; + } + + const redactedFeature = detail?.feature + ? uwtScrubString(detail.feature, this.redact, this.report) + : undefined; + const redactedMetadata = uwtRedactMetadata( + metadata, + this.redact, + this.report + ); + + if ( + this.isDuplicate(eventName, redactedMetadata, { + ...detail, + feature: redactedFeature + }) + ) { + return; + } + + const event: UwtBIEvent = { + eventName, + eventTime: new Date().toISOString(), + scenarioId: detail?.scenarioId, + feature: redactedFeature, + metadata: redactedMetadata, + application: this.identity.application, + sessionId: uwtSafe( + 'biTelemetry.getSessionId', + () => this.sink.getSessionId(), + undefined, + this.report + ), + userId: uwtSafe( + 'biTelemetry.getUserId', + () => this.sink.getUserId(), + undefined, + this.report + ) + }; + + uwtDebugWrite( + 'debugBI', + () => writeToConsole(eventName, this.eventTypes.bi, event), + undefined, + this.report + ); + + this.sink.record(this.eventTypes.bi, event); + }, + this.report + ); + } + + /** + * Forgets recent events. Tracking still works afterwards: it holds no + * timer, listener or channel, and something may still track while an + * application shuts down. Calling it again does nothing more. + */ + dispose(): void { + this.lastEmittedAt.clear(); + } + + private isDuplicate( + eventName: string, + metadata: UwtTelemetryMetadata | undefined, + detail?: UwtBIEventDetail + ): boolean { + const key = JSON.stringify([ + eventName, + detail?.scenarioId ?? '', + detail?.feature ?? '', + this.metadataKey(metadata) + ]); + const now = uwtNow(); + const last = this.lastEmittedAt.get(key); + + if (last !== undefined && now - last < this.config.dedupWindowMs) { + this.lastEmittedAt.delete(key); + this.lastEmittedAt.set(key, last); + return true; + } + + if (this.lastEmittedAt.size >= this.config.dedupMaxKeys) { + for (const [staleKey, at] of this.lastEmittedAt) { + if (now - at >= this.config.dedupWindowMs) { + this.lastEmittedAt.delete(staleKey); + } + } + + if (this.lastEmittedAt.size >= this.config.dedupMaxKeys) { + const oldestKey = this.lastEmittedAt.keys().next().value; + if (oldestKey !== undefined) { + this.lastEmittedAt.delete(oldestKey); + } + } + } + + this.lastEmittedAt.delete(key); + this.lastEmittedAt.set(key, now); + return false; + } + + /** + * A stable string encoding a flat metadata object's content, for use in + * the dedup key — otherwise two same-named events with different metadata + * would collide and the second would be silently dropped. Keys are sorted + * so property order doesn't affect the result. Callers pass already- + * redacted metadata — a caller's raw values must never end up as a `Map` + * key living in this long-lived object's memory for the page lifetime. + */ + private metadataKey(metadata: UwtTelemetryMetadata | undefined): string { + if (!metadata) { + return ''; + } + return JSON.stringify( + Object.keys(metadata) + .sort() + .map((k) => [k, metadata[k]]) + ); + } +} + +/** + * Prefixed with the application - in a composed + * page every realm writes to the one console. + */ +function writeToConsole( + eventName: string, + eventType: string, + event: UwtBIEvent +): void { + console.log(`[${event.application}][bi] ${eventName} -> ${eventType}`, event); +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts new file mode 100644 index 0000000..38fa65d --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts @@ -0,0 +1,380 @@ +import { UwtLogConfig } from '../../config/uwt-log.config/uwt-log.config'; +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { + UWT_LOG_LEVEL_ORDER, + UwtLogDetail, + UwtLogger, + UwtLoggerName, + UwtLogLevel, + UwtLogRecord +} from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +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 { + uwtNormalizeError, + UwtRedactConfig, + uwtRedactConfigFor, + uwtRedactMetadata, + uwtScrubString +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + UwtReporter, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + uwtListenToPage, + UwtTelemetryDocument, + uwtTelemetryDocument +} from '../uwt-telemetry-page/uwt-telemetry-page'; + +/** Handed out while telemetry is off: every call does nothing. */ +const INERT_LOGGER: UwtLogger = Object.freeze({ + log: () => undefined, + warn: () => undefined, + error: () => undefined +}); + +/** + * What {@link UwtLogTelemetry} needs. + * + * @group Interfaces + */ +export interface UwtLogTelemetryOptions { + /** The application's identity, shared by every concern. */ + identity: UwtTelemetryIdentity; + /** Logging settings, complete; see {@link uwtResolveTelemetryConfig}. */ + config: UwtLogConfig; + /** + * Redaction settings shared by every concern. `config.redact: false` + * turns their configurable part off for log records. + */ + redact: UwtRedactConfig; + /** The application's log store: where records go, and are read back from. */ + logApi: UwtLogApiProvider; + /** + * The telemetry destination, for enrichment only: records carry its + * `sessionId` and `userId`, and `config.mirrorErrorsToRum` reports errors + * to it. Optional. + */ + sink?: UwtTelemetrySink | null; + /** `false` makes every logger and query inert. Default `true`. */ + enabled?: boolean; + /** + * The page whose lifecycle flushes the log store: on `pagehide`, and when + * the page is hidden. Defaults to the global `document` where there is + * one; `null` listens to nothing. + */ + document?: UwtTelemetryDocument | null; + /** Receives what is handled silently; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Structured application logging. + * + * Log records go to the application's {@link UwtLogApiProvider}, **not** to + * the telemetry destination — RUM is a sampled, session-capped analytics + * stream, while logs need full fidelity and their own retention. + * + * Console output is off unless the `debugLogger` LocalStorage flag is set, in + * any environment: + * + * ```js + * localStorage.setItem('debugLogger', 'true'); + * ``` + * + * Every record carries a logger name, so this writes nothing itself — + * {@link getLogger} hands back the {@link UwtLogger} you call. + * + * @example + * ```typescript + * const logger = telemetry.logging.getLogger('checkout'); + * + * try { + * // … + * } catch (error) { + * logger.error('Failed to load customer data', { + * error, + * correlationId: scenario.id + * }); + * } + * ``` + * + * @group Classes + */ +export class UwtLogTelemetry { + private readonly identity: UwtTelemetryIdentity; + private readonly config: UwtLogConfig; + private readonly redact: UwtRedactConfig; + private readonly logApi: UwtLogApiProvider; + private readonly sink: UwtTelemetrySink | null; + private readonly enabled: boolean; + private readonly report?: UwtReporter; + private readonly stopListening: () => void; + private disposed = false; + + /** + * One logger per name, so {@link getLogger} is idempotent. + * + * Unbounded growth is not a concern the way it is for active scenarios: + * names come from the closed {@link UwtLoggerNames} vocabulary, so this + * map is bounded by what the application declared at build time. + */ + private readonly loggers = new Map(); + + constructor(options: UwtLogTelemetryOptions) { + this.identity = options.identity; + this.config = options.config; + this.redact = uwtRedactConfigFor(options.redact, options.config.redact); + this.logApi = options.logApi; + this.sink = options.sink ?? null; + this.enabled = options.enabled !== false; + this.report = options.report; + // Page listeners: only while telemetry is on. + this.stopListening = this.enabled + ? uwtListenToPage( + uwtTelemetryDocument(options.document), + () => this.flushProvider(), + () => this.flushProvider() + ) + : () => undefined; + } + + /** + * Reads records back from the application's log store. Useful for + * pulling one journey together — every line written during a scenario + * shares its id as the correlation id: + * + * @example + * ```typescript + * const lines = await telemetry.logging.query({ correlationId: scenario.id }); + * ``` + * + * Fail-open: a provider that throws or rejects resolves to `[]`. + * + * @param filter narrows what is returned + * @returns the matching records, or `[]` when none can be read + */ + async query(filter: UwtLogQuery = {}): Promise { + if (!this.enabled) { + return []; + } + const pending = uwtSafe( + 'logger.query', + () => this.logApi.query(filter), + undefined, + this.report + ); + + return (await pending?.catch(() => undefined)) ?? []; + } + + /** + * Returns the named logger for one part of the application — the only + * way to write a log record. + * + * The name lands on every record as `logger`, and four things key off + * it: {@link UwtLogConfig.levels} per-logger severity floors, the + * `debugLogger` flag's comma-separated filter, `query({ logger })` + * against the log store, and the console line's prefix. A record + * without one is reachable by none of them, which is why there is no + * unnamed `log`/`warn`/`error` here. + * + * The name is identity, not a label: asking twice returns the very same + * logger, the way a file name always refers to one file. + * + * @param name the logger name, declared in {@link UwtLoggerNames} + * @returns the logger for that name, created once and reused; an inert + * one while telemetry is off + */ + getLogger(name: UwtLoggerName): UwtLogger { + if (!this.enabled) { + return INERT_LOGGER; + } + const cached = uwtSafe( + 'logger.getLogger', + () => this.loggers.get(name), + undefined, + this.report + ); + if (cached) { + return cached; + } + + const logger = this.createLogger(name); + uwtSafeVoid( + 'logger.register', + () => this.loggers.set(name, logger), + this.report + ); + return logger; + } + + /** + * Stops watching the page and gives the log store its last chance to + * ship what it holds. Loggers keep writing to the log store, and + * {@link query} keeps reading it: it is the application's, and logging + * holds no timer, listener or channel to release. That matters when + * something logs while the application shuts down, after this was + * disposed. Calling it again does nothing. + */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.stopListening(); + if (this.enabled) { + this.flushProvider(); + } + } + + /** + * Builds the logger for one name. + * + * Private, and reached only through {@link getLogger}, so every logger + * handed out is registered and named. The name is captured here rather + * than read from each call's detail, which is why a record's `logger` + * cannot disagree with the logger that wrote it. + * + * @param name the logger name stamped onto every record it writes + * @returns a logger bound to that name + */ + private createLogger(name: UwtLoggerName): UwtLogger { + return { + log: (message, detail) => this.emit('log', name, message, detail), + warn: (message, detail) => this.emit('warn', name, message, detail), + error: (message, detail) => this.emit('error', name, message, detail) + }; + } + + /** Severity floor for one logger — its own override, or the global one. */ + private minLevelFor(logger: UwtLoggerName): UwtLogLevel { + const { levels, minLevel } = this.config; + return levels?.[logger] ?? minLevel; + } + + private emit( + level: UwtLogLevel, + logger: UwtLoggerName, + message: string, + detail?: UwtLogDetail + ): void { + uwtSafeVoid( + `logger.${level}`, + () => { + if ( + UWT_LOG_LEVEL_ORDER[level] < + UWT_LOG_LEVEL_ORDER[this.minLevelFor(logger)] + ) { + return; + } + + const record = this.buildRecord(level, logger, message, detail); + + uwtDebugWrite( + 'debugLogger', + () => writeToConsole(record), + record.logger, + this.report + ); + + this.deliver(record); + + if (level === 'error' && this.config.mirrorErrorsToRum && this.sink) { + const mirrored = + record.error ?? + uwtNormalizeError( + new Error(record.message), + this.redact, + this.report + ); + if (mirrored) { + this.sink.recordError(mirrored); + } + } + }, + this.report + ); + } + + /** Guards against a throwing or secretly-async, rejecting provider. */ + private deliver(record: UwtLogRecord): void { + uwtSafeVoidMaybeAsync( + 'logger.deliver', + () => this.logApi.send(record), + this.report + ); + } + + /** Gives the provider its chance to ship whatever it has queued itself. */ + private flushProvider(): void { + uwtSafeVoidMaybeAsync( + 'logger.providerFlush', + () => this.logApi.flush?.(), + this.report + ); + } + + private buildRecord( + level: UwtLogLevel, + logger: UwtLoggerName, + message: string, + detail?: UwtLogDetail + ): UwtLogRecord { + const { identity, redact, report, sink } = this; + + return { + timestamp: new Date().toISOString(), + level, + message: uwtScrubString(String(message ?? ''), redact, report), + logger, + context: detail?.context + ? uwtScrubString(detail.context, redact, report) + : undefined, + metadata: uwtRedactMetadata(detail?.metadata, redact, report), + error: uwtNormalizeError(detail?.error, redact, report), + correlationId: detail?.correlationId + ? uwtScrubString(detail.correlationId, redact, report) + : undefined, + application: identity.application, + environment: identity.environment, + version: identity.version, + ...(sink && { + userId: uwtSafe('getUserId', () => sink.getUserId(), undefined, report), + sessionId: uwtSafe( + 'getSessionId', + () => sink.getSessionId(), + undefined, + report + ) + }) + }; + } +} + +/** + * Prints the record exactly as the transport receives it. + * + * Prefixed with the application - in a composed + * page every realm writes to the one console - then the logger name and + * the context, each when present. + */ +function writeToConsole(record: UwtLogRecord): void { + const scope = [record.logger, record.context] + .filter(Boolean) + .map((part) => `[${part}]`) + .join(''); + const suffix = record.correlationId ? ` (${record.correlationId})` : ''; + + console[record.level]( + `[${record.application}]${scope} ${record.message}${suffix}`, + record + ); +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts new file mode 100644 index 0000000..f75e729 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts @@ -0,0 +1,332 @@ +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UwtScenarioTelemetryConfig } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { + UwtScenarioName, + UwtScenarioOptions, + UwtScenarioRecord +} from '../../models/uwt-scenario.models/uwt-scenario.models'; +import { UwtScenario } from '../../scenario/uwt-scenario/uwt-scenario'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { + UwtRedactConfig, + uwtRedactConfigFor +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + uwtDeepClone, + uwtReport, + UwtReporter, + uwtSafe, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + uwtListenToPage, + UwtTelemetryDocument, + uwtTelemetryDocument +} from '../uwt-telemetry-page/uwt-telemetry-page'; + +/** + * How many scenarios may be in flight before a warning suggests a leak. + * + * Concurrent scenarios are legitimate — several journeys, tabs or panels + * can run at once — so this is a smell threshold, not a limit: nothing is + * dropped or rejected when it is crossed. Set well above any plausible + * real concurrency, so crossing it might mean scenarios with `timeoutMs: 0` or + * long lifecycles are being started and never settled. + */ +const ACTIVE_SCENARIO_WARN_THRESHOLD = 50; + +/** + * What {@link UwtScenarioTelemetry} needs. + * + * @group Interfaces + */ +export interface UwtScenarioTelemetryOptions { + /** The application's identity, shared by every concern. */ + identity: UwtTelemetryIdentity; + /** Scenario settings, complete; see {@link uwtResolveTelemetryConfig}. */ + config: UwtScenarioTelemetryConfig; + /** + * Redaction settings shared by every concern. `config.redact: false` + * turns their configurable part off for scenarios. + */ + redact: UwtRedactConfig; + /** Where settled scenarios and their steps go. */ + sink: UwtTelemetrySink; + /** `false` makes every scenario inert. Default `true`. */ + enabled?: boolean; + /** + * The page whose lifecycle settles and flushes scenarios: in-flight ones + * are settled as abandoned on `pagehide`, and the sink is flushed when the + * page is hidden. Defaults to the global `document` where there is one; + * `null` listens to nothing. + */ + document?: UwtTelemetryDocument | null; + /** + * Runs timer scheduling outside the host framework's change detection, + * when it has one: Angular passes `NgZone.runOutsideAngular`. + */ + runOutside?: (callback: () => T) => T; + /** Receives what is handled silently; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Creates and tracks scenarios — user journeys whose health telemetry + * measures. + * + * Each {@link start} call returns an independent {@link UwtScenario}; there is + * no "current" scenario, so overlapping journeys are tracked separately. + * Scenarios still in flight when the page unloads are settled as + * `abandoned`, and so are those still running when this is disposed. + * + * @example + * ```typescript + * const scenario = telemetry.scenarios.start({ name: 'load-dashboard' }); + * try { + * scenario.step('fetch-widgets'); + * const widgets = await api.widgets(); + * scenario.complete({ metadata: { widgetCount: widgets.length } }); + * } catch (error) { + * scenario.fail({ error }); + * } + * ``` + * + * @group Classes + */ +export class UwtScenarioTelemetry { + private readonly identity: UwtTelemetryIdentity; + private readonly config: UwtScenarioTelemetryConfig; + private readonly redact: UwtRedactConfig; + private readonly sink: UwtTelemetrySink; + private readonly enabled: boolean; + private readonly runOutside?: (callback: () => T) => T; + private readonly report?: UwtReporter; + + private readonly active = new Map(); + private readonly listeners = new Set<(record: UwtScenarioRecord) => void>(); + private readonly stopListening: () => void; + private disposed = false; + + constructor(options: UwtScenarioTelemetryOptions) { + this.identity = options.identity; + this.config = options.config; + this.redact = uwtRedactConfigFor(options.redact, options.config.redact); + this.sink = options.sink; + this.enabled = options.enabled !== false; + this.runOutside = options.runOutside; + this.report = options.report; + // Page listeners: only while telemetry is on. + this.stopListening = this.enabled + ? uwtListenToPage( + uwtTelemetryDocument(options.document), + () => this.flushActive(), + () => this.flushSink() + ) + : () => undefined; + } + + /** + * Starts a new scenario. + * + * @param options the scenario name and optional classification, timeout and + * attributes + * @returns an independent scenario instance; an inert one while telemetry + * is off + */ + start(options: UwtScenarioOptions): UwtScenario { + if (!this.enabled) { + // Same return type, nothing recorded, timed, registered or sent. + return new UwtScenario(options, { + identity: this.identity, + scenarioTelemetryConfig: this.config, + redact: this.redact, + sink: this.sink, + onSettled: () => undefined, + inert: true + }); + } + // Nothing here depends on whether this was disposed: something may still + // start a scenario while an application shuts down, and the destination is + // the caller's. Such a scenario is a normal one, with its own timeout. + const scenario = new UwtScenario(options, { + identity: this.identity, + scenarioTelemetryConfig: this.config, + redact: this.redact, + sink: this.sink, + runOutside: this.runOutside, + report: this.report, + onSettled: (scenarioId, record) => { + this.active.delete(scenarioId); + this.notify(record); + } + }); + + uwtSafeVoid( + 'scenarioTelemetry.register', + () => { + this.active.set(scenario.id, scenario); + if (this.active.size > ACTIVE_SCENARIO_WARN_THRESHOLD) { + uwtReport( + this.report, + 'warn', + `High number of active scenarios (${this.active.size}). Ensure scenarios with timeoutMs: 0 or long lifecycles are settled on destroy.` + ); + } + }, + this.report + ); + + return scenario; + } + + /** + * Looks up a scenario that has not yet settled. + * + * @param scenarioId the id to look up + * @returns the scenario, or `undefined` if unknown or already settled + */ + find(scenarioId: string): UwtScenario | undefined { + return this.active.get(scenarioId); + } + + /** + * Every active scenario with the given name. + * + * `name` is a metric dimension, not a concurrency key - any number of + * scenarios can legitimately share one (two tabs, two dashboard panels, + * two independently-edited rows). Use this to inspect what's currently + * running under a name and decide for yourself, e.g. before starting + * another one. + * + * @param name the scenario name to match + * @returns matching active scenarios, in start order + */ + findByName(name: UwtScenarioName): UwtScenario[] { + return this.getActive().filter((scenario) => scenario.name === name); + } + + /** + * Looks up an active scenario by id, asserting it has the expected name. + * + * The same lookup as {@link find}, with an extra check that its `name` + * matches - a defensive assertion for a caller that already expects a + * specific scenario name at this id, rather than a bare id lookup. + * + * @param name the expected scenario name + * @param scenarioId the id to look up + * @returns the scenario, or `undefined` if not active or its name + * doesn't match + */ + findByNameAndId( + name: UwtScenarioName, + scenarioId: string + ): UwtScenario | undefined { + const scenario = this.find(scenarioId); + return scenario?.name === name ? scenario : undefined; + } + + /** + * Every scenario currently in flight. + * + * @returns the active scenarios, in start order + */ + getActive(): UwtScenario[] { + return [...this.active.values()]; + } + + /** + * Calls `listener` with every scenario record as it settles. + * + * Useful for reacting to outcomes without wrapping every call site — a + * debug overlay, a retry prompt, a test harness asserting on journeys. + * Listeners share one copy of each record, so none of them can change + * what the sink receives; a throwing listener affects no other. + * + * @param listener called once per settled scenario + * @returns stops calling `listener` + */ + onSettled(listener: (record: UwtScenarioRecord) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Stops watching the page, settles every scenario still in flight as + * abandoned and flushes the sink, then forgets the listeners. Nothing else + * changes: a scenario started later is a normal one, with its own timeout. + * Calling it again does nothing. + */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.stopListening(); + if (this.enabled) { + this.flushActive(); + } + this.listeners.clear(); + } + + private notify(record: UwtScenarioRecord): void { + if (!this.listeners.size) { + return; + } + // Copied so a listener can't mutate the same object that's about to be + // (or already was) shipped to the sink. + const copy = uwtSafe( + 'scenarioTelemetry.notify', + () => uwtDeepClone(record), + undefined, + this.report + ); + if (!copy) { + return; + } + for (const listener of [...this.listeners]) { + uwtSafeVoid( + 'scenarioTelemetry.notify', + () => listener(copy), + this.report + ); + } + } + + /** + * Dispatches what the sink holds, leaving running scenarios alone. + * + * Uses the beacon transport since the page may not survive a normal request. + */ + private flushSink(): void { + uwtSafeVoid( + 'scenarioTelemetry.flushSink', + () => this.sink.flush(true), + this.report + ); + } + + /** + * Settles every in-flight scenario as abandoned and asks the sink to send + * what it holds using a transport that survives unload. + * + * Scenario emission is synchronous, so everything settled here reaches the + * sink before the beacon goes out. + */ + private flushActive(): void { + uwtSafeVoid( + 'scenarioTelemetry.flush', + () => { + for (const scenario of this.getActive()) { + scenario.settle('abandoned', { + reason: 'page-hidden', + metadata: { abandonedBy: 'page-hidden' } + }); + } + this.sink.flush(true); + }, + this.report + ); + } +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry-page/uwt-telemetry-page.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry-page/uwt-telemetry-page.ts new file mode 100644 index 0000000..e6d89fa --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry-page/uwt-telemetry-page.ts @@ -0,0 +1,56 @@ +/** Something telemetry can listen to: a page's window, or its document. */ +interface UwtListenable { + addEventListener(type: TEvent, listener: () => void): void; + removeEventListener(type: TEvent, listener: () => void): void; +} + +/** + * The part of a page telemetry watches: whether it is visible, and its + * window's `pagehide`. A browser `Document` is one; so is a test double. + * Typed structurally, so using the engine needs no DOM typings. + * + * @group Interfaces + */ +export interface UwtTelemetryDocument extends UwtListenable<'visibilitychange'> { + readonly visibilityState: string; + readonly defaultView: UwtListenable<'pagehide'> | null; +} + +/** + * The page to watch: the one given, the global `document` where there is one + * when none is, and nothing for `null`. + */ +export function uwtTelemetryDocument( + document: UwtTelemetryDocument | null | undefined +): UwtTelemetryDocument | null { + return document === undefined ? (globalThis.document ?? null) : document; +} + +/** + * Calls `onPageHide` when the page is going away (`pagehide`) and `onHidden` + * when it moves to the background (`visibilitychange` to hidden). + * + * @param document the page, or `null` to listen to nothing + * @returns removes both listeners + */ +export function uwtListenToPage( + document: UwtTelemetryDocument | null, + onPageHide: () => void, + onHidden: () => void +): () => void { + if (!document) { + return () => undefined; + } + const view = document.defaultView; + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + onHidden(); + } + }; + view?.addEventListener('pagehide', onPageHide); + document.addEventListener('visibilitychange', onVisibilityChange); + return () => { + view?.removeEventListener('pagehide', onPageHide); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts new file mode 100644 index 0000000..333ed18 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts @@ -0,0 +1,702 @@ +/** + * @jest-environment jsdom + */ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { UwtScenarioRecord } from '../../models/uwt-scenario.models/uwt-scenario.models'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UWT_REDACTED } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import * as uwtTelemetrySafeUtil from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UwtTelemetryOptions, uwtCreateTelemetry } from './uwt-telemetry'; + +/** Keeps what the engine sends, so a test can assert on it. */ +class RecordingSink extends UwtTelemetrySink { + readonly events: { eventType: string; payload: Record }[] = + []; + + readonly errors: UwtTelemetryError[] = []; + readonly flushes: (boolean | undefined)[] = []; + userId: string | undefined; + + record(eventType: string, payload: object): void { + this.events.push({ + eventType, + payload: payload as Record + }); + } + + recordError(error: UwtTelemetryError, _meta?: UwtTelemetryMetadata): void { + this.errors.push(error); + } + + getSessionId(): string | undefined { + return 'session-1'; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(beacon?: boolean): void { + this.flushes.push(beacon); + } + + ofType(eventType: string): Record[] { + return this.events + .filter((e) => e.eventType === eventType) + .map((e) => e.payload); + } +} + +/** A log store kept in memory. */ +class RecordingLogApi implements UwtLogApiProvider { + readonly records: UwtLogRecord[] = []; + flushCount = 0; + + send(record: UwtLogRecord): void { + this.records.push(record); + } + + query(filter: UwtLogQuery): Promise { + return Promise.resolve( + this.records.filter((r) => !filter.logger || r.logger === filter.logger) + ); + } + + flush(): void { + this.flushCount++; + } +} + +const identity = { + application: 'cart', + environment: 'test', + version: '1.0.0', + eventNamespace: 'com.cart' +}; + +function create(overrides: Partial = {}) { + const sink = new RecordingSink(); + const logApi = new RecordingLogApi(); + const telemetry = uwtCreateTelemetry({ + identity, + destination: sink, + logApi, + scenarios: { defaultTimeoutMs: 0 }, + ...overrides + }); + return { telemetry, sink, logApi }; +} + +describe('uwtCreateTelemetry', () => { + let engines: { dispose(): void }[]; + + beforeEach(() => { + engines = []; + }); + + afterEach(() => { + engines.forEach((engine) => engine.dispose()); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + function engine(overrides: Partial = {}) { + const created = create(overrides); + engines.push(created.telemetry); + return created; + } + + describe('scenarios', () => { + it('should send a settled scenario to the destination, with its steps and identity', () => { + const { telemetry, sink } = engine(); + + telemetry.scenarios.start({ name: 'checkout' }).step('pay').complete(); + + const [record] = sink.ofType('com.cart.scenario'); + expect(record).toMatchObject({ + scenarioName: 'checkout', + status: 'success', + application: 'cart', + sessionId: 'session-1' + }); + expect((record.steps as { name: string }[]).map((s) => s.name)).toEqual([ + 'scenario-start', + 'pay', + 'scenario-end' + ]); + }); + + it('should track what is in flight until it settles', () => { + const { telemetry } = engine(); + + const a = telemetry.scenarios.start({ name: 'checkout' }); + const b = telemetry.scenarios.start({ name: 'search' }); + expect(telemetry.scenarios.getActive()).toEqual([a, b]); + expect(telemetry.scenarios.findByName('checkout')).toEqual([a]); + expect( + telemetry.scenarios.findByNameAndId('search', a.id) + ).toBeUndefined(); + + a.complete(); + expect(telemetry.scenarios.find(a.id)).toBeUndefined(); + expect(telemetry.scenarios.getActive()).toEqual([b]); + }); + + it('should give every settled listener one shared copy, so none can change what is sent', () => { + const { telemetry, sink } = engine(); + const seen: UwtScenarioRecord[] = []; + telemetry.scenarios.onSettled((record) => { + seen.push(record); + record.metadata = { tampered: true }; + }); + telemetry.scenarios.onSettled((record) => seen.push(record)); + + telemetry.scenarios + .start({ name: 'checkout' }) + .complete({ metadata: { real: true } }); + + expect(seen).toHaveLength(2); + expect(seen[0]).toBe(seen[1]); + expect(sink.ofType('com.cart.scenario')[0].metadata).toEqual({ + real: true + }); + }); + + it('should keep calling other listeners when one throws, and report it', () => { + const report = jest.fn(); + const { telemetry } = engine({ report }); + const seen: string[] = []; + telemetry.scenarios.onSettled(() => { + throw new Error('listener broke'); + }); + telemetry.scenarios.onSettled((record) => seen.push(record.scenarioName)); + + expect(() => + telemetry.scenarios.start({ name: 'checkout' }).complete() + ).not.toThrow(); + + expect(seen).toEqual(['checkout']); + expect(report).toHaveBeenCalledWith( + 'error', + 'scenarioTelemetry.notify failed', + expect.any(Error) + ); + }); + + it('should stop calling a listener once it unsubscribes', () => { + const { telemetry } = engine(); + const listener = jest.fn(); + const stop = telemetry.scenarios.onSettled(listener); + + stop(); + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should settle a scenario that outlives its timeout, scheduling the timer through runOutside', () => { + jest.useFakeTimers(); + let outside = 0; + const runOutside = (callback: () => T): T => { + outside++; + return callback(); + }; + const { telemetry, sink } = engine({ + runOutside, + scenarios: { defaultTimeoutMs: 1_000 } + }); + + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + expect(outside).toBe(1); + + jest.advanceTimersByTime(1_000); + + expect(scenario.status).toBe('timeout'); + expect(sink.ofType('com.cart.scenario')[0].status).toBe('timeout'); + }); + }); + + describe('BI events', () => { + it('should send an event, redacted, and collapse an identical one fired straight after', () => { + const { telemetry, sink } = engine(); + + telemetry.bi.track('export_clicked', { token: 'x', format: 'csv' }); + telemetry.bi.track('export_clicked', { token: 'x', format: 'csv' }); + + const events = sink.ofType('com.cart.bi'); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventName: 'export_clicked', + application: 'cart', + metadata: { token: UWT_REDACTED, format: 'csv' } + }); + }); + }); + + describe('logging', () => { + it('should send records at or above the floor, stamped and redacted', async () => { + const { telemetry, logApi } = engine({ + logging: { minLevel: 'warn', levels: { audit: 'log' } } + }); + + telemetry.logging.getLogger('checkout').log('dropped'); + telemetry.logging + .getLogger('checkout') + .warn('Slow', { metadata: { token: 'x' } }); + telemetry.logging.getLogger('audit').log('kept'); + + expect(logApi.records.map((r) => r.message)).toEqual(['Slow', 'kept']); + expect(logApi.records[0]).toMatchObject({ + level: 'warn', + logger: 'checkout', + application: 'cart', + environment: 'test', + version: '1.0.0', + sessionId: 'session-1', + metadata: { token: UWT_REDACTED } + }); + expect(await telemetry.logging.query({ logger: 'audit' })).toHaveLength( + 1 + ); + }); + + it('should hand out the same logger for the same name', () => { + const { telemetry } = engine(); + expect(telemetry.logging.getLogger('checkout')).toBe( + telemetry.logging.getLogger('checkout') + ); + }); + + it('should mirror errors to the destination when asked', () => { + const { telemetry, sink } = engine({ + logging: { mirrorErrorsToRum: true } + }); + + telemetry.logging.getLogger('checkout').error('Payment failed'); + + expect(sink.errors).toEqual([ + expect.objectContaining({ message: 'Payment failed' }) + ]); + }); + + it('should read back nothing, rather than fail, from a store that throws', async () => { + const { telemetry, logApi } = engine(); + jest.spyOn(logApi, 'query').mockImplementation(() => { + throw new Error('store is down'); + }); + + expect(await telemetry.logging.query()).toEqual([]); + }); + }); + + describe('configuration', () => { + it('should apply redaction overrides to every concern', () => { + const { telemetry, sink, logApi } = engine({ + redaction: { extraKeyPatterns: [/^internal/i] } + }); + + telemetry.bi.track('export_clicked', { internalRef: 'x' }); + telemetry.logging + .getLogger('checkout') + .warn('x', { metadata: { internalRef: 'x' } }); + + expect(sink.ofType('com.cart.bi')[0].metadata).toEqual({ + internalRef: UWT_REDACTED + }); + expect(logApi.records[0].metadata).toEqual({ + internalRef: UWT_REDACTED + }); + }); + + it('should turn off configurable redaction for one concern only', () => { + const { telemetry, sink } = engine({ + bi: { redact: false }, + redaction: { scanValuePatterns: ['email'] } + }); + + telemetry.bi.track('export_clicked', { note: 'jane@example.com' }); + telemetry.scenarios + .start({ name: 'checkout', metadata: { note: 'jane@example.com' } }) + .complete(); + + expect(sink.ofType('com.cart.bi')[0].metadata).toEqual({ + note: 'jane@example.com' + }); + expect(sink.ofType('com.cart.scenario')[0].metadata).toEqual({ + note: UWT_REDACTED + }); + }); + + it('should throw when the identity, destination or log store is missing', () => { + expect(() => + uwtCreateTelemetry({} as unknown as UwtTelemetryOptions) + ).toThrow( + '[ui-watchtower] uwtCreateTelemetry needs identity.application, identity.environment, identity.version, destination, logApi' + ); + }); + + it('should throw for an identity without its environment and version, which every record carries', () => { + expect(() => + create({ + identity: { application: 'cart' } as UwtTelemetryOptions['identity'] + }) + ).toThrow( + '[ui-watchtower] uwtCreateTelemetry needs identity.environment, identity.version' + ); + }); + }); + + describe('failures', () => { + it('should never let a broken destination reach the application, and report it', () => { + const report = jest.fn(); + const { telemetry, sink } = engine({ report }); + jest.spyOn(sink, 'record').mockImplementation(() => { + throw new Error('destination is down'); + }); + + expect(() => { + telemetry.scenarios.start({ name: 'checkout' }).complete(); + telemetry.bi.track('export_clicked'); + }).not.toThrow(); + + expect(report).toHaveBeenCalledWith( + 'error', + 'scenario.success failed', + expect.any(Error) + ); + expect(report).toHaveBeenCalledWith( + 'error', + 'biTelemetry.track failed', + expect.any(Error) + ); + }); + + it('should print nothing without a reporter', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const { telemetry, sink } = engine(); + jest.spyOn(sink, 'record').mockImplementation(() => { + throw new Error('destination is down'); + }); + + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(consoleError).not.toHaveBeenCalled(); + }); + }); + + describe('disabled', () => { + it('should record, send, log and watch nothing', async () => { + jest.useFakeTimers(); + const listen = jest.spyOn(document, 'addEventListener'); + const { telemetry, sink, logApi } = engine({ + enabled: false, + scenarios: { defaultTimeoutMs: 1_000 } + }); + + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + scenario.step('pay').complete(); + telemetry.bi.track('export_clicked'); + telemetry.logging.getLogger('checkout').error('x'); + telemetry.setUserId('user-1'); + telemetry.flush(); + + expect(scenario.status).toBeUndefined(); + expect(sink.events).toEqual([]); + expect(sink.userId).toBeUndefined(); + expect(sink.flushes).toEqual([]); + expect(logApi.records).toEqual([]); + expect(await telemetry.logging.query()).toEqual([]); + expect(listen).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + }); + }); + + describe('the page', () => { + function hide(state: 'hidden' | 'visible'): void { + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue(state); + document.dispatchEvent(new Event('visibilitychange')); + } + + it('should settle what is in flight as abandoned, and flush, when the page goes away', () => { + const { telemetry, sink, logApi } = engine(); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + + window.dispatchEvent(new Event('pagehide')); + + expect(scenario.status).toBe('abandoned'); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + + it('should flush, without settling, when the page is hidden', () => { + const { telemetry, sink, logApi } = engine(); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + + hide('hidden'); + + expect(scenario.isSettled).toBe(false); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + + it('should watch no page given null', () => { + const { telemetry, sink } = engine({ document: null }); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + + window.dispatchEvent(new Event('pagehide')); + hide('hidden'); + + expect(scenario.isSettled).toBe(false); + expect(sink.flushes).toEqual([]); + }); + }); + + describe('setUserId and flush', () => { + it('should pass the user to the destination, and flush it and the log store', () => { + const { telemetry, sink, logApi } = engine(); + + telemetry.setUserId('user-1'); + telemetry.flush(true); + + expect(sink.userId).toBe('user-1'); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + }); + + describe('dispose', () => { + it('should settle what is in flight as abandoned, flush once, and stop watching the page', () => { + jest.useFakeTimers(); + const { telemetry, sink, logApi } = engine({ + scenarios: { defaultTimeoutMs: 60_000 } + }); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + expect(jest.getTimerCount()).toBe(1); + + telemetry.dispose(); + telemetry.dispose(); + + expect(scenario.status).toBe('abandoned'); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + expect(jest.getTimerCount()).toBe(0); + + window.dispatchEvent(new Event('pagehide')); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + + it('should change nothing else afterwards: BI events, the user and flushes still reach the destination', () => { + const { telemetry, sink } = engine(); + telemetry.dispose(); + const flushedByDispose = sink.flushes.length; + + telemetry.bi.track('export_clicked'); + telemetry.setUserId('user-1'); + telemetry.flush(false); + + expect(sink.ofType('com.cart.bi')).toEqual([ + expect.objectContaining({ eventName: 'export_clicked' }) + ]); + expect(sink.userId).toBe('user-1'); + expect(sink.flushes.slice(flushedByDispose)).toEqual([false]); + }); + + it('should forget the listeners it had, and watch the page no more', () => { + const listener = jest.fn(); + const { telemetry, sink } = engine(); + telemetry.scenarios.onSettled(listener); + const flushesBefore = sink.flushes.length; + + telemetry.dispose(); + window.dispatchEvent(new Event('pagehide')); + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(sink.flushes).toHaveLength(flushesBefore + 1); // dispose's own + expect(listener).not.toHaveBeenCalled(); + }); + + it('should treat a scenario started afterwards as a normal one: recorded when ended', () => { + const { telemetry, sink } = engine(); + telemetry.dispose(); + + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(sink.ofType('com.cart.scenario')).toEqual([ + expect.objectContaining({ scenarioName: 'checkout', status: 'success' }) + ]); + }); + + it('should time out a scenario started afterwards and never ended, as any other', () => { + jest.useFakeTimers(); + const { telemetry, sink } = engine({ + scenarios: { defaultTimeoutMs: 5_000 } + }); + telemetry.dispose(); + + telemetry.scenarios.start({ name: 'checkout' }); + jest.advanceTimersByTime(5_000); + + expect(sink.ofType('com.cart.scenario')).toEqual([ + expect.objectContaining({ scenarioName: 'checkout', status: 'timeout' }) + ]); + }); + + it("should keep loggers writing to the log store afterwards: it is the application's, and holds no resource", async () => { + const { telemetry, logApi } = engine(); + const logger = telemetry.logging.getLogger('checkout'); + + telemetry.dispose(); + logger.error('late'); + + expect(logApi.records.map((r) => r.message)).toEqual(['late']); + await expect(telemetry.logging.query({})).resolves.toHaveLength(1); + }); + + it('should dispose each concern only once', () => { + const { telemetry, logApi } = engine(); + + telemetry.logging.dispose(); + telemetry.logging.dispose(); + telemetry.scenarios.dispose(); + telemetry.scenarios.dispose(); + + expect(logApi.flushCount).toBe(1); + }); + }); + + describe('BI event details', () => { + it('should drop an event without a name', () => { + const { telemetry, sink } = engine(); + + telemetry.bi.track('' as never); + + expect(sink.events).toEqual([]); + }); + + it('should write events to the console under debugBI', () => { + localStorage.setItem('debugBI', 'true'); + const log = jest.spyOn(console, 'log').mockImplementation(() => {}); + const { telemetry } = engine(); + + telemetry.bi.track('export_clicked'); + + expect(log).toHaveBeenCalledWith( + '[cart][bi] export_clicked -> com.cart.bi', + expect.objectContaining({ eventName: 'export_clicked' }) + ); + localStorage.removeItem('debugBI'); + }); + + it('should forget expired events first, then the oldest, once it remembers dedupMaxKeys', () => { + jest.useFakeTimers(); + const { telemetry, sink } = engine({ + bi: { dedupWindowMs: 1000, dedupMaxKeys: 2 } + }); + const track = (name: string) => telemetry.bi.track(name as never); + + track('a'); + track('b'); + jest.advanceTimersByTime(1001); + track('c'); // a and b have expired: forgotten + track('d'); + track('e'); // nothing expired: c, the oldest, is forgotten + track('d'); // still remembered: a duplicate + track('c'); // forgotten, so recorded again + + expect(sink.events.map((e) => e.payload.eventName)).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'c' + ]); + }); + }); + + describe('logging details', () => { + it('should hand out a logger that does nothing while off', () => { + const { telemetry, logApi } = engine({ enabled: false }); + const logger = telemetry.logging.getLogger('checkout'); + + logger.log('a'); + logger.warn('b'); + logger.error('c'); + + expect(logApi.records).toEqual([]); + }); + + it('should write records to the console under debugLogger, with logger, context and correlation id', () => { + localStorage.setItem('debugLogger', 'true'); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { telemetry } = engine(); + + telemetry.logging + .getLogger('checkout') + .warn('Slow payment', { context: 'pay', correlationId: 'sc-1' }); + + expect(warn).toHaveBeenCalledWith( + '[cart][checkout][pay] Slow payment (sc-1)', + expect.objectContaining({ message: 'Slow payment' }) + ); + localStorage.removeItem('debugLogger'); + }); + }); + + describe('scenario details', () => { + it('should report a suspicious number of scenarios in flight', () => { + const report = jest.fn(); + const { telemetry } = engine({ report }); + + for (let i = 0; i <= 50; i++) { + telemetry.scenarios.start({ name: 'checkout' }); + } + + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('High number of active scenarios (51)') + ); + }); + + it('should call no listener, and report, when a record cannot be copied for them', () => { + const report = jest.fn(); + const listener = jest.fn(); + const { telemetry, sink } = engine({ report }); + telemetry.scenarios.onSettled(listener); + const copy = uwtTelemetrySafeUtil.uwtDeepClone; + jest + .spyOn(uwtTelemetrySafeUtil, 'uwtDeepClone') + .mockImplementation((value) => { + if ((value as { scenarioName?: unknown })?.scenarioName) { + throw new Error('cannot copy'); + } + return copy(value); + }); + + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(listener).not.toHaveBeenCalled(); + expect(sink.events).toHaveLength(1); + expect(report).toHaveBeenCalledWith( + 'error', + 'scenarioTelemetry.notify failed', + expect.any(Error) + ); + }); + }); +}); diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts new file mode 100644 index 0000000..1c24211 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts @@ -0,0 +1,207 @@ +import { + UwtTelemetryConfigOverrides, + UwtTelemetryIdentity, + uwtResolveTelemetryConfig +} from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UwtLogApiProvider } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { + UwtReporter, + uwtSafeVoid, + uwtSafeVoidMaybeAsync +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UwtBITelemetry } from '../uwt-bi-telemetry/uwt-bi-telemetry'; +import { UwtLogTelemetry } from '../uwt-log-telemetry/uwt-log-telemetry'; +import { UwtScenarioTelemetry } from '../uwt-scenario-telemetry/uwt-scenario-telemetry'; +import { + UwtTelemetryDocument, + uwtTelemetryDocument +} from '../uwt-telemetry-page/uwt-telemetry-page'; + +/** + * What {@link uwtCreateTelemetry} needs: who the application is, where + * telemetry and logs go, and anything to change from the defaults. + * + * @group Interfaces + */ +export interface UwtTelemetryOptions extends UwtTelemetryConfigOverrides { + /** The application's identity, carried by every record and event. */ + identity: UwtTelemetryIdentity; + /** + * Where scenarios, BI events and mirrored errors go — exactly one. + * `new UwtNoopTelemetrySink()` sends them nowhere. + */ + destination: UwtTelemetrySink; + /** + * The application's log store. `new UwtNoopLogApiProvider()` keeps no + * logs, said explicitly. + */ + logApi: UwtLogApiProvider; + /** + * `false` makes everything inert: nothing is recorded, redacted, timed, + * sent or logged, and nothing listens to the page. Default `true`. + */ + enabled?: boolean; + /** + * The page whose lifecycle settles and flushes telemetry. Defaults to the + * global `document` where there is one; `null` listens to nothing, as on + * a server. + */ + document?: UwtTelemetryDocument | null; + /** + * Runs timer scheduling outside the host framework's change detection, + * when it has one. + */ + runOutside?: (callback: () => T) => T; + /** + * Receives what telemetry handles silently: failures it skipped, data it + * dropped. Nothing is reported without one; see {@link UwtReporter}. + */ + report?: UwtReporter; +} + +/** + * A configured telemetry engine: one per application, or per fragment in a + * composed page. + * + * @group Interfaces + */ +export interface UwtTelemetry { + /** Scenarios — user journeys and their health. */ + readonly scenarios: UwtScenarioTelemetry; + /** Business and UX events. */ + readonly bi: UwtBITelemetry; + /** Structured application logs. */ + readonly logging: UwtLogTelemetry; + /** + * Attributes telemetry from now on to an application user, or to nobody + * (`undefined`, or `''`). Passed to the destination. + */ + setUserId(userId: string | undefined): void; + /** + * Sends what the destination and the log store hold. + * + * @param beacon use a transport that survives page unload + */ + flush(beacon?: boolean): void; + /** + * Stops watching the page, settles scenarios still in flight as abandoned + * and flushes. No listener of this engine is left, and nothing it had + * scheduled. That is all it changes: something may still run while an + * application shuts down, and the destination and the log store are + * yours, so scenarios, BI events and logs started afterwards work as + * before. Calling it again does nothing. + */ + dispose(): void; +} + +/** + * Creates a telemetry engine: scenarios, BI events and logging, all sending + * to one destination and one log store. Needs no framework. + * + * @example + * ```typescript + * const telemetry = uwtCreateTelemetry({ + * identity: { application: 'cart', environment: 'prod', version: '1.4.0' }, + * destination: new UwtNoopTelemetrySink(), + * logApi: new UwtNoopLogApiProvider(), + * logging: { minLevel: 'warn' } + * }); + * + * const scenario = telemetry.scenarios.start({ name: 'checkout' }); + * scenario.step('pay'); + * scenario.complete(); + * + * telemetry.dispose(); + * ``` + * + * @param options identity, destination, log store and overrides + * @returns the engine + * @throws when the identity (its application, environment and version), the + * destination or the log store is missing + * + * @group Utils + */ +export function uwtCreateTelemetry(options: UwtTelemetryOptions): UwtTelemetry { + assertOptions(options); + const { identity, destination, logApi, report } = options; + const enabled = options.enabled !== false; + const config = uwtResolveTelemetryConfig(options); + const document = uwtTelemetryDocument(options.document); + const shared = { identity, redact: config.redaction, enabled, report }; + + const scenarios = new UwtScenarioTelemetry({ + ...shared, + config: config.scenarios, + sink: destination, + document, + runOutside: options.runOutside + }); + const bi = new UwtBITelemetry({ + ...shared, + config: config.bi, + sink: destination + }); + const logging = new UwtLogTelemetry({ + ...shared, + config: config.logging, + logApi, + sink: destination, + document + }); + + let disposed = false; + + return { + scenarios, + bi, + logging, + setUserId(userId) { + if (enabled) { + uwtSafeVoid( + 'telemetry.setUserId', + () => destination.setUserId(userId), + report + ); + } + }, + flush(beacon) { + if (enabled) { + uwtSafeVoid('telemetry.flush', () => destination.flush(beacon), report); + uwtSafeVoidMaybeAsync( + 'telemetry.logFlush', + () => logApi.flush?.(), + report + ); + } + }, + dispose() { + if (disposed) { + return; + } + disposed = true; + scenarios.dispose(); + logging.dispose(); + bi.dispose(); + } + }; +} + +/** Configuration errors throw: telemetry fails open, its setup does not. */ +function assertOptions(options: UwtTelemetryOptions): void { + const identity = options?.identity; + const missing = [ + !identity?.application && 'identity.application', + // Strings, as every record carries them: a shell's host rejects a + // forwarded log record without them. + typeof identity?.environment !== 'string' && 'identity.environment', + typeof identity?.version !== 'string' && 'identity.version', + typeof options?.destination?.record !== 'function' && 'destination', + typeof options?.logApi?.send !== 'function' && 'logApi' + ].filter(Boolean); + if (missing.length) { + throw new Error( + `[ui-watchtower] uwtCreateTelemetry needs ${missing.join(', ')}` + ); + } +} diff --git a/projects/ngx-ui-watchtower/src/lib/models/uwt-bi.models/uwt-bi.models.ts b/projects/ui-watchtower/src/lib/models/uwt-bi.models/uwt-bi.models.ts similarity index 91% rename from projects/ngx-ui-watchtower/src/lib/models/uwt-bi.models/uwt-bi.models.ts rename to projects/ui-watchtower/src/lib/models/uwt-bi.models/uwt-bi.models.ts index 0567bc3..401c6db 100644 --- a/projects/ngx-ui-watchtower/src/lib/models/uwt-bi.models/uwt-bi.models.ts +++ b/projects/ui-watchtower/src/lib/models/uwt-bi.models/uwt-bi.models.ts @@ -12,10 +12,13 @@ import { * the dashboard reads. Declaring the vocabulary turns that into a compile * error. * + * An Angular application augments `@absaoss-cps/ngx-ui-watchtower` instead, + * which re-exports this registry: either module path reaches the same one. + * * @example * ```typescript * // src/app/telemetry.schema.ts - * declare module '@absaoss-cps/ngx-ui-watchtower' { + * declare module '@absaoss-cps/ui-watchtower' { * interface UwtBIEventNames { * export_clicked: true; * theme_changed: true; diff --git a/projects/ngx-ui-watchtower/src/lib/models/uwt-log.models/uwt-log.models.ts b/projects/ui-watchtower/src/lib/models/uwt-log.models/uwt-log.models.ts similarity index 88% rename from projects/ngx-ui-watchtower/src/lib/models/uwt-log.models/uwt-log.models.ts rename to projects/ui-watchtower/src/lib/models/uwt-log.models/uwt-log.models.ts index 0c09583..d479689 100644 --- a/projects/ngx-ui-watchtower/src/lib/models/uwt-log.models/uwt-log.models.ts +++ b/projects/ui-watchtower/src/lib/models/uwt-log.models/uwt-log.models.ts @@ -12,10 +12,13 @@ import { * selects its per-logger level. Declaring the vocabulary turns a typo'd * name into a compile error instead of a filter that silently never matches. * + * An Angular application augments `@absaoss-cps/ngx-ui-watchtower` instead, + * which re-exports this registry: either module path reaches the same one. + * * @example * ```typescript * // src/app/telemetry.schema.ts - * declare module '@absaoss-cps/ngx-ui-watchtower' { + * declare module '@absaoss-cps/ui-watchtower' { * interface UwtLoggerNames { * checkout: true; * admin: true; @@ -80,7 +83,7 @@ export interface UwtLogDetail { /** * Identifier joining this log line to other telemetry. Pass a * {@link UwtScenario.id} here to tie this line to a scenario, and the - * same id reaches {@link UwtLoggerService.query} to read the journey + * same id reaches the logging `query` to read the journey * back across every logger that wrote during it. */ correlationId?: string; @@ -116,9 +119,8 @@ export interface UwtLogRecord } /** - * The application-facing logging API — what - * {@link UwtLoggerService.getLogger} hands back, so application code holds - * this contract without naming the service. + * The application-facing logging API — what `getLogger` hands back, so + * application code holds this contract without naming what created it. * * An instance *is* its name. The name is fixed when the logger is created * and stamped on every record it writes, so a record's `logger` can never diff --git a/projects/ngx-ui-watchtower/src/lib/models/uwt-scenario.models/uwt-scenario.models.ts b/projects/ui-watchtower/src/lib/models/uwt-scenario.models/uwt-scenario.models.ts similarity index 97% rename from projects/ngx-ui-watchtower/src/lib/models/uwt-scenario.models/uwt-scenario.models.ts rename to projects/ui-watchtower/src/lib/models/uwt-scenario.models/uwt-scenario.models.ts index 43cb5f2..6cd69e3 100644 --- a/projects/ngx-ui-watchtower/src/lib/models/uwt-scenario.models/uwt-scenario.models.ts +++ b/projects/ui-watchtower/src/lib/models/uwt-scenario.models/uwt-scenario.models.ts @@ -11,10 +11,13 @@ import { * Empty in the library. Augment it from your application, and every * `start({ name })` call is checked against it from then on. * + * An Angular application augments `@absaoss-cps/ngx-ui-watchtower` instead, + * which re-exports this registry: either module path reaches the same one. + * * @example * ```typescript * // src/app/telemetry/scenarios.schema.ts - * declare module '@absaoss-cps/ngx-ui-watchtower' { + * declare module '@absaoss-cps/ui-watchtower' { * interface UwtScenarioNames { * 'route-navigation': true; * 'load-dashboard': true; @@ -33,12 +36,12 @@ export interface UwtScenarioNames {} * Registry of this application's step and aggregate names. * * Steps and aggregates share one vocabulary — a name declared for a step is - * also valid passed to `aggregateStart`. + * also valid passed to `aggregateStart`. Augmented like {@link UwtScenarioNames}. * * @example * ```typescript * // src/app/telemetry/scenarios.schema.ts - * declare module '@absaoss-cps/ngx-ui-watchtower' { + * declare module '@absaoss-cps/ui-watchtower' { * interface UwtScenarioSteps { * 'resolve-route': true; * 'activate': true; diff --git a/projects/ngx-ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.spec.ts b/projects/ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.spec.ts rename to projects/ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.spec.ts diff --git a/projects/ngx-ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.ts b/projects/ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.ts rename to projects/ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.ts diff --git a/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts b/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts new file mode 100644 index 0000000..78abce5 --- /dev/null +++ b/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts @@ -0,0 +1,191 @@ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtBroadcastConnection, + uwtConnectBroadcastChannel, + uwtIsBroadcastLogRecord, + uwtIsBroadcastMessage +} from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; +import { + UwtReporter, + uwtSafeVoid, + uwtUuid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../uwt-log-api.provider/uwt-log-api.provider'; + +/** + * How long a follower waits for the host to answer a log query before + * resolving to `[]`, in milliseconds. + * + * @group Utils + */ +export const UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS = 10_000; + +/** A log query waiting for the host's answer. */ +interface UwtPendingLogQuery { + resolve: (records: UwtLogRecord[]) => void; + timer: ReturnType; +} + +/** + * What {@link UwtBroadcastLogApiProvider} needs; all optional. + * + * @group Interfaces + */ +export interface UwtBroadcastLogApiProviderOptions { + /** + * The channel's name; must match the host's. Default + * {@link UWT_DEFAULT_BROADCAST_CHANNEL}. + */ + channelName?: string; + /** + * `false` forwards nothing, and every query resolves to `[]` at once — + * on a server, or while telemetry is off — even over a shared + * {@link connection}. Default `true`. + */ + enabled?: boolean; + /** + * A connection to share with the realm's {@link UwtBroadcastTelemetrySink}, + * instead of opening one of its own. Not used while {@link enabled} is + * `false`. Left open by {@link UwtBroadcastLogApiProvider.dispose}: whoever + * opened it closes it. + */ + connection?: UwtBroadcastConnection; + /** + * Schedules the query timeout outside the host framework's change + * detection: Angular passes `NgZone.runOutsideAngular`. + */ + runOutside?: (callback: () => T) => T; + /** + * Hands a query's answer back inside the host framework's change + * detection: Angular passes `NgZone.run`. + */ + runInside?: (callback: () => T) => T; + /** Receives failures; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Log API provider for a follower realm — the log counterpart of + * {@link UwtBroadcastTelemetrySink}. + * + * Ships nothing itself. Records are forwarded over the `BroadcastChannel` + * to the realm running a {@link UwtTelemetryBroadcastHost}, which hands + * them to its own log provider: one log backend, one set of credentials, + * for the whole composed page. + * + * `query()` is answered by the host's provider too. It resolves to `[]` + * when no host answers within {@link UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS}, + * and at once when no channel could be opened. + * + * @group Services + */ +export class UwtBroadcastLogApiProvider implements UwtLogApiProvider { + private readonly connection: UwtBroadcastConnection; + private readonly ownsConnection: boolean; + private readonly stopListening: () => void; + private readonly runOutside: (callback: () => T) => T; + private readonly runInside: (callback: () => T) => T; + private readonly report?: UwtReporter; + private readonly pending = new Map(); + /** Set by {@link dispose}: nothing is sent or asked after, even on a shared connection. */ + private disposed = false; + + constructor(options: UwtBroadcastLogApiProviderOptions = {}) { + this.report = options.report; + // Off wins over a shared connection: this provider then has a closed one + // of its own, and the shared one stays open for its other users. + const shared = options.enabled === false ? undefined : options.connection; + this.ownsConnection = !shared; + this.connection = + shared ?? + uwtConnectBroadcastChannel({ + channelName: options.channelName, + open: options.enabled !== false, + operation: 'broadcastLogApi', + report: options.report + }); + this.runOutside = options.runOutside ?? ((callback) => callback()); + this.runInside = options.runInside ?? ((callback) => callback()); + this.stopListening = this.connection.onMessage((data) => + this.onMessage(data) + ); + } + + /** @inheritdoc */ + send(record: UwtLogRecord): void { + if (!this.disposed) { + this.connection.post({ kind: 'log', record }); + } + } + + /** @inheritdoc */ + query(filter: UwtLogQuery): Promise { + if (this.disposed || !this.connection.isOpen()) { + return Promise.resolve([]); + } + + return new Promise((resolve) => { + const id = uwtUuid(); + const timer = this.runOutside(() => + setTimeout( + () => this.answer(id, []), + UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS + ) + ); + this.pending.set(id, { resolve, timer }); + this.connection.post({ kind: 'log-query', id, filter }); + }); + } + + /** @inheritdoc */ + flush(): void { + if (!this.disposed) { + this.connection.post({ kind: 'log-flush' }); + } + } + + /** + * Resolves every unanswered query to `[]`, stops listening, and closes + * the channel if this provider opened it. A shared connection stays open + * for its other users, but this provider sends nothing over it any more, + * and every later query resolves to `[]` at once. Calling it again does + * nothing. + */ + dispose(): void { + this.disposed = true; + for (const id of [...this.pending.keys()]) { + this.answer(id, []); + } + this.stopListening(); + if (this.ownsConnection) { + this.connection.close(); + } + } + + private onMessage(data: unknown): void { + uwtSafeVoid( + 'broadcastLogApi.receive', + () => { + if (!uwtIsBroadcastMessage(data) || data.kind !== 'log-query-result') { + return; + } + // Other followers' answers arrive here too; answer() ignores them. + this.answer(data.id, data.records.filter(uwtIsBroadcastLogRecord)); + }, + this.report + ); + } + + private answer(id: string, records: UwtLogRecord[]): void { + const query = this.pending.get(id); + if (!query) { + return; + } + this.pending.delete(id); + clearTimeout(query.timer); + this.runInside(() => query.resolve(records)); + } +} diff --git a/projects/ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts b/projects/ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts new file mode 100644 index 0000000..bb7dcb7 --- /dev/null +++ b/projects/ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts @@ -0,0 +1,93 @@ +import { + UwtLoggerName, + UwtLogLevel, + UwtLogRecord +} from '../../models/uwt-log.models/uwt-log.models'; + +/** + * Filter for reading log records back with `query`. + * + * Every field is optional and they combine with AND. A provider translates + * them however its store requires. + * + * @group Interfaces + */ +export interface UwtLogQuery { + /** + * Pulls one journey's lines together. Pass a {@link UwtScenario.id} to read + * everything logged during that scenario, across services. + */ + correlationId?: string; + + /** Restricts to one named logger. */ + logger?: UwtLoggerName; + + /** Discards records below this severity. */ + minLevel?: UwtLogLevel; + + /** ISO-8601 lower bound on `timestamp`, inclusive. */ + from?: string; + + /** ISO-8601 upper bound on `timestamp`, inclusive. */ + to?: string; + + /** Maximum number of records to return. */ + limit?: number; +} + +/** + * The application's log store, as this library needs to see it. + * + * Where records are kept, how they are authorised and how long they are + * retained are decisions for the consuming application; this is the seam it + * supplies them through. + * + * @example + * ```typescript + * send(record: UwtLogRecord): void { + * fetch('/api/logs', { method: 'POST', body: JSON.stringify(record) }).catch( + * () => undefined + * ); + * } + * + * async query(filter: UwtLogQuery): Promise { + * const params = new URLSearchParams(Object.entries(filter).map(([k, v]) => [k, String(v)])); + * return (await fetch(`/api/logs?${params}`)).json(); + * } + * ``` + * + * @group Interfaces + */ +export interface UwtLogApiProvider { + /** + * Ships one record. + * + * Called once per record, as it is written. Must be non-blocking and must + * not throw. A provider that batches accumulates its own queue here and + * flushes it via {@link flush}. + * + * @param record the redacted record + */ + send(record: UwtLogRecord): void; + + /** + * Reads records back. + * + * @param filter narrows what is returned; an empty filter means "everything + * this provider is willing to return" + * @returns the matching records + */ + query(filter: UwtLogQuery): Promise; + + /** + * Ships whatever this provider is holding, if it holds anything. + * + * Optional; only needed by a provider that batches in {@link send} instead + * of sending immediately. + * + * Called on `pagehide`, on `visibilitychange` going hidden, and on + * teardown. Must be synchronous and non-blocking. `fetch` with `keepalive` + * or `navigator.sendBeacon` are the usual choices. + */ + flush?(): void; +} diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts b/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts rename to projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts diff --git a/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts b/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts new file mode 100644 index 0000000..676b518 --- /dev/null +++ b/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts @@ -0,0 +1,26 @@ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../uwt-log-api.provider/uwt-log-api.provider'; + +/** + * Log API provider that discards every record and finds none. + * + * The explicit way to say an application has no log backend — never a + * default. Mostly for a shell that hosts fragments but logs nothing: the + * broadcast host requires a log provider, so that one can't be forgotten. + * + * Bound in a shell, it discards its fragments' log records too. + * + * @group Services + */ +export class UwtNoopLogApiProvider implements UwtLogApiProvider { + /** @inheritdoc */ + send(_record: UwtLogRecord): void {} + + /** @inheritdoc */ + query(_filter: UwtLogQuery): Promise { + return Promise.resolve([]); + } +} diff --git a/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts new file mode 100644 index 0000000..1685b15 --- /dev/null +++ b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts @@ -0,0 +1,309 @@ +/** + * @jest-environment jsdom + */ +import { UWT_DEFAULT_TELEMETRY_CONFIG } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UwtScenarioTelemetryConfig } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { UwtScenarioRecord } from '../../models/uwt-scenario.models/uwt-scenario.models'; +import { UWT_TELEMETRY_EVENT_TYPE } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +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'; + +/** Captures what scenarios emitted. */ +class RecordingSink extends UwtTelemetrySink { + readonly events: { eventType: string; payload: Record }[] = + []; + + record(eventType: string, payload: object): void { + this.events.push({ + eventType, + payload: payload as Record + }); + } + + recordError(): void {} + + getSessionId(): string | undefined { + return 'session-1'; + } + + setUserId(): void {} + + getUserId(): string | undefined { + return 'user-1'; + } + + flush(): void {} +} + +const MAX_TIMEOUT_MS = 2_147_483_647; + +describe('UwtScenario, in detail', () => { + let sink: RecordingSink; + let settled: UwtScenarioRecord[]; + + function scenario( + name: string, + config: Partial = {}, + overrides: Partial & { timeoutMs?: number } = {} + ): UwtScenario { + const { timeoutMs, ...deps } = overrides; + return new UwtScenario( + { name, timeoutMs }, + { + identity: { application: 'shop', environment: 'test', version: '1' }, + scenarioTelemetryConfig: { + ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario, + ...config + }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink, + onSettled: (_id, record) => settled.push(record), + ...deps + } + ); + } + + beforeEach(() => { + sink = new RecordingSink(); + settled = []; + localStorage.clear(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('delta', () => { + it('should grow while running, then hold the duration', () => { + const running = scenario('load'); + expect(running.delta).toBeGreaterThanOrEqual(0); + + running.complete(); + + expect(Math.round(running.delta)).toBe(settled[0].delta); + }); + + it('should be 0, with an empty record, for an inert scenario', () => { + const inert = scenario('load', {}, { inert: true }); + + inert.step('fetch'); + inert.complete(); + + expect(inert.delta).toBe(0); + expect(settled).toEqual([]); + expect(inert.toRecord()).toMatchObject({ + scenarioName: 'load', + delta: 0, + stepCount: 0, + steps: [] + }); + }); + }); + + describe('aggregates', () => { + it('should count calls, ignore an end without a start, and count a call still open at the end', () => { + const load = scenario('load'); + + load.aggregateEnd('render'); + load.aggregateStart('fetch'); + load.aggregateStart('fetch'); + load.aggregateEnd('fetch'); + load.aggregateStart('fetch'); + load.complete(); + + expect(settled[0].aggregates).toEqual([ + { name: 'fetch', elapsed: expect.any(Number), callCount: 2 } + ]); + }); + }); + + describe('the record', () => { + it('should carry the outcome: status code, message and reason', () => { + scenario('search').incomplete({ + statusCode: 404, + message: 'no results', + reason: 'empty' + }); + + expect(settled[0]).toMatchObject({ + status: 'incomplete', + statusCode: 404, + message: 'no results', + reason: 'empty' + }); + }); + + it('should flag a scenario with more steps than allowed', () => { + const busy = scenario('busy', { maxSteps: 2 }); + + busy.step('a').step('b').step('c'); + busy.complete(); + + expect(settled[0].exceededStepsLimit).toBe(true); + }); + + it("should keep a step's message and reason, when it ends or fails", () => { + const load = scenario('load'); + + load.step('fetch').endStep({ message: 'cached', reason: 'warm' }); + load.step('parse').failStep(new Error('bad json'), { reason: 'format' }); + load.complete(); + + const steps = settled[0].steps; + expect(steps.find((s) => s.name === 'fetch')).toMatchObject({ + message: 'cached', + reason: 'warm' + }); + expect(steps.find((s) => s.name === 'parse')).toMatchObject({ + status: 'failure', + reason: 'format' + }); + }); + }); + + describe('step events', () => { + it('should send each step as it ends, with the session and user, when enabled', () => { + const load = scenario('load', { emitLifecycleEvents: true }); + + load.step('fetch'); + load.step('render'); + load.complete(); + + const steps = sink.events.filter( + (e) => e.eventType === UWT_TELEMETRY_EVENT_TYPE.scenarioStep + ); + expect(steps.map((e) => e.payload.name)).toContain('fetch'); + expect(steps[0].payload).toMatchObject({ + scenarioName: 'load', + application: 'shop', + sessionId: 'session-1', + userId: 'user-1' + }); + }); + }); + + describe('user timings', () => { + let timings: { + mark: jest.Mock; + measure: jest.Mock; + clearMarks: jest.Mock; + clearMeasures: jest.Mock; + }; + + beforeEach(() => { + timings = { + mark: jest.fn(), + measure: jest.fn(), + clearMarks: jest.fn(), + clearMeasures: jest.fn() + }; + // jsdom has no User Timing API: stand-ins, removed after each test. + for (const [name, fn] of Object.entries(timings)) { + Object.defineProperty(performance, name, { + value: fn, + configurable: true, + writable: true + }); + } + }); + + afterEach(() => { + for (const name of Object.keys(timings)) { + delete (performance as unknown as Record)[name]; + } + }); + + it('should mark and measure each step and the whole scenario, then clear its marks', () => { + const load = new UwtScenario( + { name: 'load', feature: 'customers' }, + { + identity: { application: 'shop', environment: 'test', version: '1' }, + scenarioTelemetryConfig: { + ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario, + userTimings: true + }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink, + onSettled: () => undefined + } + ); + + load.step('fetch'); + load.step('render'); + load.complete(); + + const measured = timings.measure.mock.calls.map(([name]) => name); + expect(measured).toEqual( + expect.arrayContaining(['load [fetch]', 'load (customers)']) + ); + expect(timings.mark).toHaveBeenCalled(); + expect(timings.clearMarks).toHaveBeenCalled(); + }); + + it('should clear the marks of a scenario that never settles, after the fallback delay', () => { + jest.useFakeTimers({ doNotFake: ['performance'] }); + const endless = scenario( + 'endless', + { userTimings: true, markCleanupFallbackMs: 1000 }, + { timeoutMs: 0 } + ); + + endless.step('waiting'); + expect(timings.clearMarks).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1000); + + expect(timings.clearMarks).toHaveBeenCalled(); + expect(endless.isSettled).toBe(false); + }); + }); + + describe('timeouts', () => { + it('should time out after longer than one timer can wait', () => { + jest.useFakeTimers(); + const long = scenario('long', {}, { timeoutMs: MAX_TIMEOUT_MS + 1000 }); + + jest.advanceTimersByTime(MAX_TIMEOUT_MS); + expect(long.isSettled).toBe(false); + jest.advanceTimersByTime(1000); + + expect(long.status).toBe('timeout'); + }); + + it('should schedule its timeout once, though runOutside hands nothing back', () => { + jest.useFakeTimers(); + const outside = jest.fn((callback: () => unknown) => { + callback(); + }); + + scenario( + 'slow', + {}, + { + timeoutMs: 1000, + runOutside: outside as unknown as UwtScenarioDeps['runOutside'] + } + ); + + expect(outside).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(1); + }); + }); + + describe('debug output', () => { + it('should write each settled scenario to the console under debugScenario', () => { + localStorage.setItem('debugScenario', 'true'); + const log = jest.spyOn(console, 'log').mockImplementation(() => {}); + + scenario('checkout').complete(); + + expect(log).toHaveBeenCalledWith( + expect.stringMatching( + /^\[shop\]\[scenario\] checkout success in \d+ms -> / + ), + expect.objectContaining({ scenarioName: 'checkout' }) + ); + }); + }); +}); diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts similarity index 99% rename from projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts rename to projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts index 5d7ac0b..bd579d4 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts +++ b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts @@ -1,3 +1,6 @@ +/** + * @jest-environment jsdom + */ import { UWT_DEFAULT_TELEMETRY_CONFIG } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UwtScenarioRecord } from '../../models/uwt-scenario.models/uwt-scenario.models'; import { UwtTelemetryMetadata } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts similarity index 84% rename from projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts rename to projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts index 636547c..6efbae0 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts +++ b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts @@ -38,6 +38,7 @@ import { uwtNow, uwtSafe, uwtSafeVoid, + UwtReporter, uwtUuid } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; import { @@ -48,8 +49,8 @@ import { } from '../../utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util'; /** - * Collaborators a scenario needs. Supplied by - * {@link UwtScenarioTelemetryService}; not part of the public API. + * Collaborators a scenario needs. Supplied by whatever starts scenarios; + * not part of the public API. */ export interface UwtScenarioDeps { /** The application's identity, shared by every telemetry concern. */ @@ -59,8 +60,13 @@ export interface UwtScenarioDeps { /** Redaction settings, shared by every telemetry concern. */ redact: UwtRedactConfig; sink: UwtTelemetrySink; - /** Schedules timers outside Angular's zone when the host provides one. */ - runOutsideAngular?: (callback: () => T) => T; + /** + * Schedules timers outside the host framework's change detection, when it + * has one: Angular passes `NgZone.runOutsideAngular`. + */ + runOutside?: (callback: () => T) => T; + /** Receives what the scenario handles silently; see {@link UwtReporter}. */ + report?: UwtReporter; /** Called once with the scenario's id when it reaches a terminal state. */ onSettled: (scenarioId: string, record: UwtScenarioRecord) => void; /** Telemetry is switched off: the scenario records, times and sends nothing. */ @@ -81,8 +87,8 @@ const MAX_TIMEOUT_MS = 2_147_483_647; /** * A single in-flight user journey or feature execution. * - * Each call to {@link UwtScenarioTelemetryService.start} returns a fresh - * instance, so scenarios can run concurrently without interfering. + * Each call to `start` returns a fresh instance, so scenarios can run + * concurrently without interfering. * * @example * ```typescript @@ -175,17 +181,18 @@ export class UwtScenario { uwtIsDebugEnabled('debugScenario'); this.metadata = uwtSafe( 'scenario.construct', - () => uwtRedactMetadata(options.metadata, deps.redact) ?? {}, - {} + () => uwtRedactMetadata(options.metadata, deps.redact, deps.report) ?? {}, + {}, + deps.report ); this.feature = options.feature - ? uwtScrubString(options.feature, deps.redact) + ? uwtScrubString(options.feature, deps.redact, deps.report) : undefined; this.operation = options.operation - ? uwtScrubString(options.operation, deps.redact) + ? uwtScrubString(options.operation, deps.redact, deps.report) : undefined; this.route = options.route - ? uwtScrubString(options.route, deps.redact) + ? uwtScrubString(options.route, deps.redact, deps.report) : undefined; const backdated = @@ -210,8 +217,8 @@ export class UwtScenario { } /** - * The scenario's unique identifier and correlation id — pass it to - * {@link UwtLoggerService} calls, BI events, and backend requests to + * The scenario's unique identifier and correlation id — pass it to log + * calls, BI events, and backend requests to * reassemble a journey across frontend and backend telemetry. */ get id(): string { @@ -270,7 +277,7 @@ export class UwtScenario { name, startOffset: Math.round(uwtNow() - this.startedAt), metadata: this.openStepIncluded - ? uwtRedactMetadata(metadata, this.deps.redact) + ? uwtRedactMetadata(metadata, this.deps.redact, this.deps.report) : undefined }; @@ -322,9 +329,10 @@ export class UwtScenario { */ setData(metadata: UwtTelemetryMetadata): this { return this.mutate('setData', () => { - const safe = uwtRedactMetadata(metadata, this.deps.redact); + const { redact, report } = this.deps; + const safe = uwtRedactMetadata(metadata, redact, report); if (safe) { - uwtMergeMetadata(this.metadata, safe, this.deps.redact); + uwtMergeMetadata(this.metadata, safe, redact, report); } }); } @@ -444,7 +452,7 @@ export class UwtScenario { application: this.deps.identity.application }; } - const { redact } = this.deps; + const { redact, report } = this.deps; const settled = this.isSettled; const aggregates = this.collectAggregates(); @@ -495,7 +503,7 @@ export class UwtScenario { record.reason = this.settledReason; } if (this.settleError !== undefined) { - record.error = uwtNormalizeError(this.settleError, redact); + record.error = uwtNormalizeError(this.settleError, redact, report); } return record; @@ -521,72 +529,80 @@ export class UwtScenario { if (this.inert) { return; } - uwtSafeVoid(`scenario.${status}`, () => { - if (this.isSettled) { - return; - } - - this.clearTimeout(); - this.clearMarkCleanupTimer(); - this._duration = uwtNow() - this.startedAt; - this._status = status; - - const { message, reason } = outcome ?? {}; - const { redact } = this.deps; - - const resolvedMessage = message - ? uwtScrubString(message, redact) - : undefined; - const resolvedReason = reason - ? uwtScrubString(reason, redact) - : undefined; - const resolvedMetadata = uwtRedactMetadata(outcome?.metadata, redact); - - this.closeOpenStep( - status, - { - message: resolvedMessage, - reason: resolvedReason, - metadata: resolvedMetadata - }, - error, - true - ); - - this.steps.push({ - name: 'scenario-end', - startOffset: Math.round(this._duration), - endOffset: Math.round(this._duration), - stepDelta: 0, - elapsed: Math.round(uwtElapsedNow()), - status, - ...(resolvedMessage && { message: resolvedMessage }), - ...(resolvedReason && { reason: resolvedReason }), - ...(resolvedMetadata && { metadata: resolvedMetadata }), - ...(status === 'failure' && - error !== undefined && { - error: uwtNormalizeError(error, redact) - }) - }); - - this.settleOutcome = outcome; - this.settleError = error; - this.settledMessage = resolvedMessage; - this.settledReason = resolvedReason; - - if (resolvedMetadata) { - uwtMergeMetadata(this.metadata, resolvedMetadata, redact); - } - - this.measureScenarioTiming(); - - const record = this.toRecord(); - this.deps.onSettled(this._id, record); - this.emit( - { kind: 'scenario', payload: record }, - `${status} in ${Math.round(this._duration)}ms` - ); - }); + uwtSafeVoid( + `scenario.${status}`, + () => { + if (this.isSettled) { + return; + } + + this.clearTimeout(); + this.clearMarkCleanupTimer(); + this._duration = uwtNow() - this.startedAt; + this._status = status; + + const { message, reason } = outcome ?? {}; + const { redact, report } = this.deps; + + const resolvedMessage = message + ? uwtScrubString(message, redact, report) + : undefined; + const resolvedReason = reason + ? uwtScrubString(reason, redact, report) + : undefined; + const resolvedMetadata = uwtRedactMetadata( + outcome?.metadata, + redact, + report + ); + + this.closeOpenStep( + status, + { + message: resolvedMessage, + reason: resolvedReason, + metadata: resolvedMetadata + }, + error, + true + ); + + this.steps.push({ + name: 'scenario-end', + startOffset: Math.round(this._duration), + endOffset: Math.round(this._duration), + stepDelta: 0, + elapsed: Math.round(uwtElapsedNow()), + status, + ...(resolvedMessage && { message: resolvedMessage }), + ...(resolvedReason && { reason: resolvedReason }), + ...(resolvedMetadata && { metadata: resolvedMetadata }), + ...(status === 'failure' && + error !== undefined && { + error: uwtNormalizeError(error, redact, report) + }) + }); + + this.settleOutcome = outcome; + this.settleError = error; + this.settledMessage = resolvedMessage; + this.settledReason = resolvedReason; + + if (resolvedMetadata) { + uwtMergeMetadata(this.metadata, resolvedMetadata, redact, report); + } + + this.measureScenarioTiming(); + + const record = this.toRecord(); + this.deps.onSettled(this._id, record); + this.emit( + { kind: 'scenario', payload: record }, + `${status} in ${Math.round(this._duration)}ms` + ); + }, + this.deps.report + ); } /** The active telemetry sink's session id, when it has one. */ @@ -594,7 +610,8 @@ export class UwtScenario { return uwtSafe( 'scenario.getSessionId', () => this.deps.sink.getSessionId(), - undefined + undefined, + this.deps.report ); } @@ -603,7 +620,8 @@ export class UwtScenario { return uwtSafe( 'scenario.getUserId', () => this.deps.sink.getUserId(), - undefined + undefined, + this.deps.report ); } @@ -643,7 +661,7 @@ export class UwtScenario { } this.openStep = undefined; - const { redact } = this.deps; + const { redact, report } = this.deps; step.endOffset = Math.round(uwtNow() - this.startedAt); step.stepDelta = step.endOffset - step.startOffset; step.elapsed = Math.round(uwtElapsedNow()); @@ -652,24 +670,25 @@ export class UwtScenario { if (detail?.message) { step.message = alreadyRedacted ? detail.message - : uwtScrubString(detail.message, redact); + : uwtScrubString(detail.message, redact, report); } if (detail?.reason) { step.reason = alreadyRedacted ? detail.reason - : uwtScrubString(detail.reason, redact); + : uwtScrubString(detail.reason, redact, report); } if (detail?.metadata) { step.metadata = uwtMergeMetadata( step.metadata ?? {}, alreadyRedacted ? detail.metadata - : uwtRedactMetadata(detail.metadata, redact), - redact + : uwtRedactMetadata(detail.metadata, redact, report), + redact, + report ); } if (status === 'failure' && error !== undefined) { - step.error = uwtNormalizeError(error, redact); + step.error = uwtNormalizeError(error, redact, report); } // The two synthetic boundary markers never pass through here, so this @@ -773,7 +792,7 @@ export class UwtScenario { const remainingMs = Math.max(0, timeoutMs - (uwtNow() - this.startedAt)); - this.timeoutHandle = this.runOutsideAngular(() => + this.timeoutHandle = this.runOutside(() => setTimeout( () => { this.timeoutHandle = undefined; @@ -803,8 +822,8 @@ export class UwtScenario { * * This clears marks only — it does not settle the scenario. A scenario * left unsettled this way never fires {@link UwtScenarioDeps.onSettled}, - * so it stays in {@link UwtScenarioTelemetryService}'s active registry for - * the life of the page. + * so it stays among the active scenarios of whatever started it for the + * life of the page. */ private scheduleMarkCleanupFallback(): void { const fallbackMs = this.deps.scenarioTelemetryConfig.markCleanupFallbackMs; @@ -812,7 +831,7 @@ export class UwtScenario { return; } - this.markCleanupTimer = this.runOutsideAngular(() => + this.markCleanupTimer = this.runOutside(() => setTimeout( () => { this.markCleanupTimer = undefined; @@ -828,8 +847,9 @@ export class UwtScenario { this.markCleanupTimer = UwtScenario.clearHandle(this.markCleanupTimer); } - private runOutsideAngular(callback: () => T): T { - return this.deps.runOutsideAngular?.(callback) ?? callback(); + private runOutside(callback: () => T): T { + const { runOutside } = this.deps; + return runOutside ? runOutside(callback) : callback(); } /** @@ -862,12 +882,16 @@ export class UwtScenario { if (this.inert) { return this; } - uwtSafeVoid(`scenario.${operation}`, () => { - if (this.isSettled) { - return; - } - apply(); - }); + uwtSafeVoid( + `scenario.${operation}`, + () => { + if (this.isSettled) { + return; + } + apply(); + }, + this.deps.report + ); return this; } @@ -887,8 +911,11 @@ export class UwtScenario { ? this.eventTypes.scenario : this.eventTypes.scenarioStep; - uwtDebugWrite('debugScenario', () => - writeToConsole(summary, eventType, emission.payload) + uwtDebugWrite( + 'debugScenario', + () => writeToConsole(summary, eventType, emission.payload), + undefined, + this.deps.report ); this.deps.sink.record(eventType, emission.payload); } diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.ts new file mode 100644 index 0000000..2472f76 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.ts @@ -0,0 +1,411 @@ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { UwtBroadcastLogApiProvider } from '../../providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { uwtSessionElapsed } from '../../utils/uwt-session-start.util/uwt-session-start.util'; +import { uwtClassifyTelemetryEvent } from '../../utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; +import { + uwtConsoleReporter, + uwtReport, + UwtReporter, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; +import { + UwtBroadcastConnection, + uwtConnectBroadcastChannel, + uwtElectBroadcastHostLeader, + uwtIsBroadcastMessage +} from './uwt-broadcast.messages'; + +/** The identity fields scenario, BI and log records carry. */ +interface UwtIdentified { + sessionId?: string; + userId?: string; + sessionElapsed?: number; +} + +/** + * Active host count per channel, scoped to this JS realm — distinguishes a + * true duplicate host from another tab sharing the same channel. Realm-wide + * on purpose; only a host that is active in a browser counts, so server-side + * renders never see each other's. + */ +const hostsInThisRealm = new Map(); + +/** + * What {@link UwtTelemetryBroadcastHost} needs. + * + * @group Interfaces + */ +export interface UwtTelemetryBroadcastHostOptions { + /** This realm's real destination, recording what followers forward. */ + sink: UwtTelemetrySink; + /** + * This realm's real log store: it receives followers' records and answers + * their queries. + */ + logApi: UwtLogApiProvider; + /** + * The channel's name; must match the followers'. Default + * {@link UWT_DEFAULT_BROADCAST_CHANNEL}. + */ + channelName?: string; + /** + * `false` hosts nothing — no channel, no election: on a server, or while + * telemetry is off. Default `true`. + */ + enabled?: boolean; + /** + * Receives failures, and — as `'config'` — the warnings about a host that + * cannot work as configured. Those go to the console when no reporter is + * given: a misconfigured host is never silent. + */ + report?: UwtReporter; +} + +/** + * Receives telemetry forwarded by follower realms and records it through this + * realm's sink. + * + * Runs in the shell, the realm with the real sink. Fragments using + * {@link UwtBroadcastTelemetrySink} post their events here, so one AWS client, + * one session and one event budget serve the whole composed page. Their + * scenario, BI and log records are stamped with this realm's current session + * and user, which a fragment's own copy can lag behind. + * + * Fragments' log records arrive the same way, from + * {@link UwtBroadcastLogApiProvider}, and go to this realm's log provider — + * which also answers their `query()` calls. + * + * A realm that forwards its own telemetry cannot also host it: the host + * would receive its own realm's messages and forward them again, forever. + * Such a host warns and stays inactive. + * + * A Web Locks-based election keeps exactly one host active per channel; + * others stay passive. + * + * @group Services + */ +export class UwtTelemetryBroadcastHost { + private readonly sink: UwtTelemetrySink; + private readonly logApi: UwtLogApiProvider; + private readonly connection: UwtBroadcastConnection; + private readonly enabled: boolean; + private readonly report?: UwtReporter; + + /** Number of messages accepted, for tests and diagnostics. */ + private _received = 0; + + /** + * The session and user id last sent in an `identity` message. + * + * Compared against the sink's current values on every follower activity, so + * a late-resolving session id or user id gets announced once it exists. + */ + private lastAnnouncedSessionId?: string; + private lastAnnouncedUserId?: string; + + /** Whether this realm won the leader election; non-leaders stay passive. */ + private isLeader = false; + private releaseLeadership: () => void = () => undefined; + private disposed = false; + + constructor(options: UwtTelemetryBroadcastHostOptions) { + this.sink = options.sink; + this.logApi = options.logApi; + this.enabled = options.enabled !== false; + this.report = options.report; + this.connection = uwtConnectBroadcastChannel({ + channelName: options.channelName, + open: this.enabled, + operation: 'broadcastHost', + report: options.report + }); + + if (!this.enabled) { + // No listening, no leader election. + return; + } + this.warnIfDuplicateInThisRealm(); + if (this.forwardsItsOwnRealm()) { + this.connection.close(); + return; + } + + this.connection.onMessage((data) => this.onMessage(data)); + this.releaseLeadership = uwtElectBroadcastHostLeader( + this.connection.channelName, + () => { + this.isLeader = true; + this.announceIdentity(); + } + ); + } + + /** How many follower messages have been accepted. */ + get received(): number { + return this._received; + } + + /** + * Gives up leadership and closes the channel. Calling it again does + * nothing. + */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + if (this.enabled) { + this.forgetInThisRealm(); + } + this.releaseLeadership(); + this.connection.close(); + } + + private onMessage(data: unknown): void { + uwtSafeVoid( + 'broadcastHost.receive', + () => { + if (!this.isLeader || !uwtIsBroadcastMessage(data)) { + return; + } + + if ( + data.kind !== 'identity' && + data.kind !== 'identity-request' && + data.kind !== 'log-query-result' + ) { + this._received++; + } + + switch (data.kind) { + case 'event': + this.sink.record( + data.eventType, + this.stampEvent(data.eventType, data.payload), + data.metadata + ); + this.reannounceIfIdentityChanged(); + break; + case 'error': + this.sink.recordError(data.error, data.metadata); + this.reannounceIfIdentityChanged(); + break; + case 'user': + this.sink.setUserId(data.userId); + this.announceIdentity(); + break; + case 'flush': + this.sink.flush(data.beacon); + this.reannounceIfIdentityChanged(); + break; + case 'identity-request': + this.announceIdentity(); + break; + case 'identity': + // Another realm's host on this origin-wide channel — expected, + // not a problem. Same-document duplicates are caught by + // warnIfDuplicateInThisRealm() instead. + break; + case 'log': + this.deliverLog(this.withIdentity(data.record)); + this.reannounceIfIdentityChanged(); + break; + case 'log-flush': + uwtSafeVoidMaybeAsync( + 'broadcastHost.logFlush', + () => this.logApi?.flush?.(), + this.report + ); + break; + case 'log-query': + this.answerLogQuery(data.id, data.filter); + break; + case 'log-query-result': + // Hosts answer queries; they never ask them. + break; + } + }, + this.report + ); + } + + /** + * A forwarded scenario or BI record with this realm's identity; any other + * payload as it is. + */ + private stampEvent(eventType: string, payload: object): object { + const { kind } = uwtClassifyTelemetryEvent(eventType, payload); + return kind === 'unknown' + ? payload + : this.withIdentity(payload as UwtIdentified, kind === 'scenario'); + } + + /** + * `record` with this realm's session and user id. A follower stamps the ids + * it was last told, which lag behind whenever they change here — the client + * starting after the follower asked, a session expiring, a sign-out — so its + * record could name a session other than the one it is sent in. With + * `measuresSession`, a changed session's `sessionElapsed` is measured again. + */ + private withIdentity( + record: T, + measuresSession = false + ): T { + const sessionId = this.currentSessionId(); + // '' is no user, as `setUserId('')` signs out. + const userId = this.currentUserId() || undefined; + if (record.sessionId === sessionId && record.userId === userId) { + return record; + } + // An id this realm doesn't have stays `undefined`, + // as in the library's own records. + const stamped: T = { ...record, sessionId, userId }; + if (measuresSession && record.sessionId !== sessionId) { + // The old session's value no longer applies; absent when unknown. + delete stamped.sessionElapsed; + const elapsed = uwtSessionElapsed(sessionId); + if (elapsed !== undefined) { + stamped.sessionElapsed = Math.round(elapsed); + } + } + return stamped; + } + + /** Hands a follower's record to this realm's log API provider. */ + private deliverLog(record: UwtLogRecord): void { + uwtSafeVoidMaybeAsync( + 'broadcastHost.log', + () => this.logApi.send(record), + this.report + ); + } + + /** + * Answers a follower's query from this realm's log API provider. Always + * answers — `[]` when there is no provider or it fails — so the follower + * isn't left waiting for its timeout. + */ + private answerLogQuery(id: string, filter: UwtLogQuery): void { + const reply = (records: unknown) => + this.connection.post({ + kind: 'log-query-result', + id, + records: Array.isArray(records) ? records : [] + }); + + const pending = uwtSafe( + 'broadcastHost.logQuery', + () => this.logApi.query(filter), + undefined, + this.report + ); + + if (!pending) { + reply([]); + return; + } + // Promise.resolve: a provider breaking its contract with a plain value + // still gets an answer out. + Promise.resolve(pending).then(reply, () => reply([])); + } + + /** + * Whether this realm's own sink or log provider forwards over the + * channel. Hosting then would loop: the host receives its own realm's + * messages and hands them straight back to the channel. + */ + private forwardsItsOwnRealm(): boolean { + const forwards = + this.sink instanceof UwtBroadcastTelemetrySink || + this.logApi instanceof UwtBroadcastLogApiProvider; + + if (forwards) { + this.warn( + `this realm forwards its own telemetry on channel "${this.connection.channelName}", so it cannot also host it; the host stays inactive. Provide the host only in the realm with the real sink and log provider` + ); + } + return forwards; + } + + /** + * Warns when another host is already active on the same channel in this + * realm — a real misconfiguration. Not based on the `identity` broadcast, + * which can't distinguish this from a different, legitimate tab. + */ + private warnIfDuplicateInThisRealm(): void { + const channelName = this.connection.channelName; + const activeCount = hostsInThisRealm.get(channelName) ?? 0; + + if (activeCount > 0) { + this.warn( + `a second telemetry host is active on channel "${channelName}" in this document; only one realm should provide it` + ); + } + + hostsInThisRealm.set(channelName, activeCount + 1); + } + + /** Releases this host's slot in {@link hostsInThisRealm}. */ + private forgetInThisRealm(): void { + const channelName = this.connection.channelName; + const activeCount = hostsInThisRealm.get(channelName) ?? 0; + + if (activeCount <= 1) { + hostsInThisRealm.delete(channelName); + } else { + hostsInThisRealm.set(channelName, activeCount - 1); + } + } + + /** A host that cannot work as configured is never silent. */ + private warn(message: string): void { + uwtReport(this.report ?? uwtConsoleReporter, 'config', message); + } + + private announceIdentity(): void { + const sessionId = this.currentSessionId(); + const userId = this.currentUserId(); + this.lastAnnouncedSessionId = sessionId; + this.lastAnnouncedUserId = userId; + this.connection.post({ kind: 'identity', sessionId, userId }); + } + + /** + * Re-announces identity when the sink's session id or user id has moved on + * from what followers were last told. + */ + private reannounceIfIdentityChanged(): void { + if ( + this.currentSessionId() !== this.lastAnnouncedSessionId || + this.currentUserId() !== this.lastAnnouncedUserId + ) { + this.announceIdentity(); + } + } + + private currentSessionId(): string | undefined { + return uwtSafe( + 'broadcastHost.getSessionId', + () => this.sink.getSessionId(), + undefined, + this.report + ); + } + + private currentUserId(): string | undefined { + return uwtSafe( + 'broadcastHost.getUserId', + () => this.sink.getUserId(), + undefined, + this.report + ); + } +} diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts new file mode 100644 index 0000000..fe9f7b5 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts @@ -0,0 +1,191 @@ +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { + UwtReporter, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { + UwtBroadcastConnection, + UwtBroadcastMessage, + uwtConnectBroadcastChannel, + uwtIsBroadcastMessage +} from './uwt-broadcast.messages'; + +/** + * What {@link UwtBroadcastTelemetrySink} needs. + * + * @group Interfaces + */ +export interface UwtBroadcastTelemetrySinkOptions { + /** This realm's identity, stamped onto everything it forwards. */ + identity: UwtTelemetryIdentity; + /** + * The channel's name; must match the host's. Default + * {@link UWT_DEFAULT_BROADCAST_CHANNEL}. + */ + channelName?: string; + /** + * `false` forwards nothing — on a server, or while telemetry is off — + * even over a shared {@link connection}. Default `true`. + */ + enabled?: boolean; + /** + * A connection to share with the realm's + * {@link UwtBroadcastLogApiProvider}, instead of opening one of its own. + * Not used while {@link enabled} is `false`. Left open by + * {@link UwtBroadcastTelemetrySink.dispose}: whoever opened it closes it. + */ + connection?: UwtBroadcastConnection; + /** Receives failures; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Telemetry sink for a follower realm — a micro-frontend fragment, a webview + * panel, any context with its own JavaScript realm but the same origin. + * + * Creates no AWS client of its own. Events are forwarded over a + * `BroadcastChannel` to the realm running a {@link UwtTelemetryBroadcastHost}, + * which records them through the one real sink. Application code sees no + * difference — it uses the same engine and calls the same methods. + * + * @group Services + */ +export class UwtBroadcastTelemetrySink extends UwtTelemetrySink { + private readonly connection: UwtBroadcastConnection; + private readonly ownsConnection: boolean; + private readonly stopListening: () => void; + private readonly report?: UwtReporter; + /** Set by {@link dispose}: nothing is forwarded after, even on a shared connection. */ + private disposed = false; + + /** The host's session id, once it has answered. */ + private sessionId?: string; + private userId?: string; + + /** + * This realm's own identity, stamped onto everything it forwards. + * + * The host records through its own RUM client, so without this, forwarded + * events would all be labelled as the host's realm. + */ + private readonly origin: { + application: string; + environment: string; + appVersion: string; + }; + + constructor(options: UwtBroadcastTelemetrySinkOptions) { + super(); + this.report = options.report; + // Off wins over a shared connection: this sink then has a closed one of + // its own, and the shared one stays open for its other users. + const shared = options.enabled === false ? undefined : options.connection; + this.ownsConnection = !shared; + this.connection = + shared ?? + uwtConnectBroadcastChannel({ + channelName: options.channelName, + open: options.enabled !== false, + operation: 'broadcastSink', + report: options.report + }); + this.origin = { + application: options.identity.application, + environment: options.identity.environment, + appVersion: options.identity.version + }; + + this.stopListening = this.connection.onMessage((data) => + this.onMessage(data) + ); + this.connection.post({ kind: 'identity-request' }); + } + + /** @inheritdoc */ + record( + eventType: string, + payload: object, + metadata?: UwtTelemetryMetadata + ): void { + this.post({ + kind: 'event', + eventType, + payload, + metadata: { ...metadata, ...this.origin } + }); + } + + /** @inheritdoc */ + recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata): void { + this.post({ + kind: 'error', + error, + metadata: { ...metadata, ...this.origin } + }); + } + + /** + * The host's session id. + * + * Returns `undefined` until the host answers. + * + * @returns the shared session id, when known + */ + getSessionId(): string | undefined { + return this.sessionId; + } + + /** @inheritdoc */ + setUserId(userId: string | undefined): void { + this.userId = userId; + this.post({ kind: 'user', userId }); + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(beacon = false): void { + this.post({ kind: 'flush', beacon }); + } + + /** + * Stops listening and forwarding, and closes the channel if this sink + * opened it. A shared connection stays open for its other users, but this + * sink sends nothing over it any more. Calling it again does nothing. + */ + dispose(): void { + this.disposed = true; + this.stopListening(); + if (this.ownsConnection) { + this.connection.close(); + } + } + + private post(message: UwtBroadcastMessage): void { + if (!this.disposed) { + this.connection.post(message); + } + } + + private onMessage(data: unknown): void { + uwtSafeVoid( + 'broadcastSink.receive', + () => { + if (!uwtIsBroadcastMessage(data) || data.kind !== 'identity') { + return; + } + this.sessionId = data.sessionId; + this.userId = data.userId; + }, + this.report + ); + } +} diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.spec.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.spec.ts new file mode 100644 index 0000000..f66da99 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.spec.ts @@ -0,0 +1,369 @@ +/** + * @jest-environment jsdom + */ +import { + uwtElectBroadcastHostLeader, + uwtIsBroadcastMessage +} from './uwt-broadcast.messages'; + +/** Minimal Web Locks API stub: grants each named lock to one requester at a time. */ +class LockManagerStub { + private readonly held = new Set(); + private readonly queues = new Map void)[]>(); + + request(name: string, callback: () => Promise): Promise { + return new Promise((resolve) => { + const grant = () => { + this.held.add(name); + callback().then(() => { + this.held.delete(name); + resolve(); + this.queues.get(name)?.shift()?.(); + }); + }; + + if (this.held.has(name)) { + const queue = this.queues.get(name) ?? []; + queue.push(grant); + this.queues.set(name, queue); + } else { + grant(); + } + }); + } + + /** Installs the stub as `navigator.locks`. */ + static install(): void { + Object.defineProperty(globalThis.navigator, 'locks', { + value: new LockManagerStub(), + configurable: true + }); + } + + static uninstall(): void { + delete (globalThis.navigator as { locks?: unknown }).locks; + } +} + +describe('the broadcast protocol', () => { + describe('uwtElectBroadcastHostLeader', () => { + afterEach(() => LockManagerStub.uninstall()); + + it('should elect immediately when the Locks API is unavailable', () => { + const onElected = jest.fn(); + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should elect once the lock is granted', () => { + LockManagerStub.install(); + const onElected = jest.fn(); + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should fail open and still elect when request() itself rejects', async () => { + Object.defineProperty(globalThis.navigator, 'locks', { + value: { request: () => Promise.reject(new Error('not-fully-active')) }, + configurable: true + }); + const onElected = jest.fn(); + + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); + // Let the rejection's microtask settle. + await Promise.resolve(); + await Promise.resolve(); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should fail open and still elect when request() itself throws synchronously', () => { + Object.defineProperty(globalThis.navigator, 'locks', { + value: { + request: () => { + throw new Error('locks unavailable in this context'); + } + }, + configurable: true + }); + const onElected = jest.fn(); + + expect(() => + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected) + ).not.toThrow(); + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should not elect a requester released while still queued, and should let the next requester through', async () => { + LockManagerStub.install(); + const onElectedHolder = jest.fn(); + const onElectedA = jest.fn(); + const onElectedB = jest.fn(); + + const releaseHolder = uwtElectBroadcastHostLeader( + 'ngx-ui-watchtower', + onElectedHolder + ); + const releaseA = uwtElectBroadcastHostLeader( + 'ngx-ui-watchtower', + onElectedA + ); + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElectedB); + + // A is released before ever being granted the lock. + releaseA(); + // The current holder releases, so the lock passes down the queue. + releaseHolder(); + for (let i = 0; i < 6; i++) { + await Promise.resolve(); + } + + expect(onElectedA).not.toHaveBeenCalled(); + expect(onElectedB).toHaveBeenCalledTimes(1); + }); + }); + + describe('uwtIsBroadcastMessage', () => { + it.each([null, undefined, 'string', 42, ['array']])( + 'should reject the non-object payload %p', + (data) => { + expect(uwtIsBroadcastMessage(data)).toBe(false); + } + ); + + it('should reject a payload with an unknown kind', () => { + expect(uwtIsBroadcastMessage({ kind: 'other library' })).toBe(false); + }); + + it('should accept a well-formed event message', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'event', + eventType: 'com.uwt.bi', + payload: { eventName: 'x' } + }) + ).toBe(true); + }); + + it.each([ + { eventType: 'com.uwt.bi' }, + { payload: {} }, + { eventType: 1, payload: {} }, + { eventType: 'com.uwt.bi', payload: 'not-an-object' }, + { + eventType: 'com.uwt.bi', + payload: {}, + metadata: { nested: {} } + }, + { + eventType: 'com.uwt.bi', + payload: {}, + metadata: { list: [1, 2] } + }, + { + eventType: 'com.uwt.bi', + payload: {}, + metadata: 'not-an-object' + } + ])('should reject a malformed event message %p', (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'event', ...fields })).toBe(false); + }); + + it('should accept an event message with flat-primitive metadata', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'event', + eventType: 'com.uwt.bi', + payload: {}, + metadata: { count: 3, ok: true, note: 'x', blank: null } + }) + ).toBe(true); + }); + + it('should accept an event message with no metadata', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'event', + eventType: 'com.uwt.bi', + payload: {} + }) + ).toBe(true); + }); + + it('should accept a well-formed error message', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom' } + }) + ).toBe(true); + }); + + it.each([ + {}, + { error: { name: 'Error' } }, + { error: { message: 'boom' } }, + { error: 'boom' }, + { error: { name: 'Error', message: 'boom', stack: 42 } }, + { + error: { name: 'Error', message: 'boom' }, + metadata: { nested: {} } + } + ])('should reject a malformed error message %p', (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'error', ...fields })).toBe(false); + }); + + it('should accept an error message with flat-primitive metadata', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom' }, + metadata: { origin: 'checkout' } + }) + ).toBe(true); + }); + + it('should accept an error message with a string stack', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom', stack: 'at foo.ts:1' } + }) + ).toBe(true); + }); + + it('should accept a well-formed flush message', () => { + expect(uwtIsBroadcastMessage({ kind: 'flush', beacon: true })).toBe(true); + }); + + it('should reject a flush message with a non-boolean beacon', () => { + expect(uwtIsBroadcastMessage({ kind: 'flush', beacon: 'true' })).toBe( + false + ); + }); + + it.each([{ userId: 'u-1' }, { userId: undefined }, {}])( + 'should accept a well-formed user message %p', + (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'user', ...fields })).toBe(true); + } + ); + + it('should reject a user message with a non-string, non-undefined userId', () => { + expect(uwtIsBroadcastMessage({ kind: 'user', userId: 42 })).toBe(false); + }); + + const logRecord = { + timestamp: '2026-01-01T00:00:00.000Z', + level: 'warn', + message: 'Careful', + application: 'cart', + environment: 'test', + version: '1.0.0' + }; + + it('should accept a well-formed log message', () => { + expect(uwtIsBroadcastMessage({ kind: 'log', record: logRecord })).toBe( + true + ); + expect( + uwtIsBroadcastMessage({ + kind: 'log', + record: { + ...logRecord, + logger: 'checkout', + context: 'Cart', + correlationId: 'c-1', + sessionId: 's', + userId: 'u', + metadata: { count: 1 }, + error: { name: 'Error', message: 'boom' } + } + }) + ).toBe(true); + }); + + it.each([ + { level: 'debug' }, + { level: 'toString' }, + { application: undefined }, + { message: 42 }, + { logger: 7 }, + { metadata: { nested: {} } }, + { error: { name: 'Error' } } + ])('should reject a log message whose record has %p', (fields) => { + expect( + uwtIsBroadcastMessage({ + kind: 'log', + record: { ...logRecord, ...fields } + }) + ).toBe(false); + }); + + it('should accept log-flush, log-query and log-query-result messages', () => { + expect(uwtIsBroadcastMessage({ kind: 'log-flush' })).toBe(true); + expect( + uwtIsBroadcastMessage({ + kind: 'log-query', + id: 'q-1', + filter: { correlationId: 'c-1', minLevel: 'warn', limit: 5 } + }) + ).toBe(true); + expect( + uwtIsBroadcastMessage({ + kind: 'log-query-result', + id: 'q-1', + records: [] + }) + ).toBe(true); + }); + + it.each([ + { id: '', filter: {} }, + { id: 'q-1', filter: null }, + { id: 'q-1', filter: { limit: 'ten' } }, + { id: 'q-1', filter: { minLevel: 'verbose' } } + ])('should reject a malformed log-query message %p', (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'log-query', ...fields })).toBe( + false + ); + }); + + it('should reject a log-query-result without a records array', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'log-query-result', + id: 'q-1', + records: 'none' + }) + ).toBe(false); + }); + + it('should accept an identity-request message with no other fields', () => { + expect(uwtIsBroadcastMessage({ kind: 'identity-request' })).toBe(true); + }); + + it.each([ + {}, + { sessionId: 's-1' }, + { userId: 'u-1' }, + { + sessionId: 's-1', + userId: 'u-1' + } + ])('should accept an identity message %p', (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'identity', ...fields })).toBe(true); + }); + + it.each([{ sessionId: 42 }, { userId: 42 }, { sessionId: 42, userId: 42 }])( + 'should reject an identity message with a non-string, non-undefined field %p', + (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'identity', ...fields })).toBe( + false + ); + } + ); + }); +}); diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts new file mode 100644 index 0000000..76eb481 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts @@ -0,0 +1,430 @@ +import { + UWT_LOG_LEVEL_ORDER, + UwtLogRecord +} from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtLogQuery } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { + UwtReporter, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; + +/** + * Default `BroadcastChannel` name used between telemetry realms. + * + * Named for the package that introduced it. Kept: realms deployed + * separately, on different versions, must still meet on it. + * + * @group Utils + */ +export const UWT_DEFAULT_BROADCAST_CHANNEL = 'ngx-ui-watchtower'; + +/** + * Everything that travels between telemetry realms. + * + * Structured-cloned by `BroadcastChannel`, so every payload must be plain + * data. + */ +export type UwtBroadcastMessage = + | { + kind: 'event'; + eventType: string; + payload: object; + metadata?: UwtTelemetryMetadata; + } + | { + kind: 'error'; + error: UwtTelemetryError; + metadata?: UwtTelemetryMetadata; + } + | { kind: 'user'; userId: string | undefined } + | { kind: 'flush'; beacon: boolean } + | { kind: 'identity-request' } + /** The host announcing shared identity. Always sends both fields, even when only one changed. */ + | { kind: 'identity'; sessionId?: string; userId?: string } + /** A follower's log record, for the host to hand to its log API provider. */ + | { kind: 'log'; record: UwtLogRecord } + /** A follower asking the host to flush its log API provider. */ + | { kind: 'log-flush' } + /** A follower reading records back through the host's log API provider. */ + | { kind: 'log-query'; id: string; filter: UwtLogQuery } + /** + * The host's answer to one `log-query`, matched by `id`. The records come + * from the host's backend unchecked; the asking follower keeps only those + * that pass {@link uwtIsBroadcastLogRecord}. + */ + | { kind: 'log-query-result'; id: string; records: unknown[] }; + +/** Every `kind` the union above accepts, for {@link uwtIsBroadcastMessage}. */ +const MESSAGE_KINDS: ReadonlySet = new Set([ + 'event', + 'error', + 'user', + 'flush', + 'identity-request', + 'identity', + 'log', + 'log-flush', + 'log-query', + 'log-query-result' +] as const); + +const LOG_LEVELS: ReadonlySet = new Set( + Object.keys(UWT_LOG_LEVEL_ORDER) +); + +/** Minimal `BroadcastChannel` surface this library relies on. */ +export interface UwtBroadcastChannelLike { + postMessage(message: unknown): void; + close(): void; + onmessage: ((event: { data: unknown }) => void) | null; +} + +/** + * Opens a broadcast channel, when the browser provides the API. + * + * Feature-detected: unavailable under jsdom (tests) and in server-side + * rendering. + */ +export function uwtOpenBroadcastChannel( + name: string +): UwtBroadcastChannelLike | undefined { + const Channel = ( + globalThis as { + BroadcastChannel?: new (name: string) => UwtBroadcastChannelLike; + } + ).BroadcastChannel; + + if (typeof Channel !== 'function') { + return undefined; + } + + try { + return new Channel(name); + } catch { + return undefined; + } +} + +/** Minimal Web Locks API surface this library relies on. */ +interface UwtLockManagerLike { + request(name: string, callback: () => Promise): Promise; +} + +/** + * Elects exactly one leader among same-origin realms on this channel, via + * the Web Locks API — prevents two shell tabs from both recording every + * forwarded message. Fails open (elects immediately) when Web Locks is + * unavailable. + * + * @returns releases the lock so the next queued realm can become leader. + */ +export function uwtElectBroadcastHostLeader( + channelName: string, + onElected: () => void +): () => void { + const locks = (globalThis as { navigator?: { locks?: UwtLockManagerLike } }) + .navigator?.locks; + + if (!locks) { + onElected(); + return () => undefined; + } + + let released = false; + let release: () => void = () => { + released = true; + }; + + const failOpen = () => { + if (!released) { + onElected(); + } + }; + + try { + locks + .request( + `ngx-ui-watchtower-host:${channelName}`, + () => + new Promise((resolve) => { + if (released) { + resolve(); + return; + } + release = () => { + released = true; + resolve(); + }; + onElected(); + }) + ) + .catch(failOpen); + } catch { + failOpen(); + } + + return () => release(); +} + +/** + * A realm's live connection to the shared broadcast channel. + * + * @group Interfaces + */ +export interface UwtBroadcastConnection { + /** The channel name in use. */ + readonly channelName: string; + + /** Whether a channel is open — false without `BroadcastChannel`, when not opened, and once closed. */ + isOpen(): boolean; + + /** Sends a message to every other realm on this channel. No-op if unavailable. */ + post(message: UwtBroadcastMessage): void; + + /** + * Adds a handler for incoming messages. No-op if no channel is open. + * + * @returns removes the handler + */ + onMessage(handler: (data: unknown) => void): () => void; + + /** Closes the channel and stops any further delivery. Safe to call more than once. */ + close(): void; +} + +/** + * Where, and whether, to connect. + * + * @group Interfaces + */ +export interface UwtBroadcastConnectOptions { + /** + * The channel's name; every realm on it must use the same one. Default + * {@link UWT_DEFAULT_BROADCAST_CHANNEL}. + */ + channelName?: string; + /** + * `false` opens no channel, so every call is a no-op: on a server, or + * while telemetry is off. Default `true`. + */ + open?: boolean; + /** Names this connection in failure reports. Default `broadcast`. */ + operation?: string; + /** Receives failures; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Opens this realm's connection to the shared broadcast channel: + * feature-detects `BroadcastChannel`, and wraps every operation in the + * library's fail-open guard. One connection can serve several handlers — + * a realm's broadcast sink and log provider share one. + * + * @param options the channel name, and whether to open it at all + * @returns the connection; a no-op one when no channel could be opened + * + * @group Utils + */ +export function uwtConnectBroadcastChannel( + options: UwtBroadcastConnectOptions = {} +): UwtBroadcastConnection { + const channelName = options.channelName ?? UWT_DEFAULT_BROADCAST_CHANNEL; + const operation = options.operation ?? 'broadcast'; + const { report } = options; + const handlers = new Set<(data: unknown) => void>(); + let channel = + options.open === false ? undefined : uwtOpenBroadcastChannel(channelName); + + if (channel) { + channel.onmessage = (event) => { + for (const handler of [...handlers]) { + uwtSafeVoid(`${operation}.receive`, () => handler(event.data), report); + } + }; + } + + return { + channelName, + isOpen() { + return channel !== undefined; + }, + post(message) { + uwtSafeVoid( + `${operation}.post`, + () => channel?.postMessage(message), + report + ); + }, + onMessage(handler) { + if (!channel) { + return () => undefined; + } + handlers.add(handler); + return () => { + handlers.delete(handler); + }; + }, + close() { + uwtSafeVoid( + `${operation}.close`, + () => { + handlers.clear(); + channel?.close(); + channel = undefined; + }, + report + ); + } + }; +} + +/** + * Whether `value` is a valid `metadata` field: absent, or a non-array + * object whose values are all finite numbers, strings, booleans, or null — + * the same flat-primitives contract `uwtRedactMetadata` enforces on the way + * out. Without this, a same-origin sender (not necessarily this library's + * own sink) could post a nested object on the channel that reaches a + * receiving sink's own sanitizer — e.g. `UwtRumTelemetrySink.sanitize()` — + * unchecked, since that step trusts the shape rather than re-validating it. + */ +function isValidBroadcastMetadata(value: unknown): boolean { + if (value === undefined) { + return true; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + return Object.values(value).every( + (v) => + v === null || + typeof v === 'string' || + typeof v === 'boolean' || + (typeof v === 'number' && Number.isFinite(v)) + ); +} + +function isOptionalString(value: unknown): boolean { + return value === undefined || typeof value === 'string'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isValidTelemetryError(value: unknown): boolean { + return ( + isRecord(value) && + typeof value.name === 'string' && + typeof value.message === 'string' && + isOptionalString(value.stack) + ); +} + +function isLogLevel(value: unknown): boolean { + return typeof value === 'string' && LOG_LEVELS.has(value); +} + +/** + * Whether `value` has the shape of a {@link UwtLogRecord}: the required + * strings, a known level, optional strings where the record has them, and + * metadata and error held to the same rules as on an `event` or `error` + * message. + */ +export function uwtIsBroadcastLogRecord(value: unknown): value is UwtLogRecord { + return ( + isRecord(value) && + typeof value.timestamp === 'string' && + isLogLevel(value.level) && + typeof value.message === 'string' && + typeof value.application === 'string' && + typeof value.environment === 'string' && + typeof value.version === 'string' && + isOptionalString(value.logger) && + isOptionalString(value.context) && + isOptionalString(value.correlationId) && + isOptionalString(value.userId) && + isOptionalString(value.sessionId) && + isValidBroadcastMetadata(value.metadata) && + (value.error === undefined || isValidTelemetryError(value.error)) + ); +} + +function isValidLogQuery(value: unknown): boolean { + return ( + isRecord(value) && + isOptionalString(value.correlationId) && + isOptionalString(value.logger) && + (value.minLevel === undefined || isLogLevel(value.minLevel)) && + isOptionalString(value.from) && + isOptionalString(value.to) && + (value.limit === undefined || + (typeof value.limit === 'number' && Number.isFinite(value.limit))) + ); +} + +/** + * Narrows an incoming `BroadcastChannel` payload to a telemetry message. + * + * Checks each kind's required fields, not just `kind`, so a same-named + * message from something else on the channel is rejected. + */ +export function uwtIsBroadcastMessage( + data: unknown +): data is UwtBroadcastMessage { + if (typeof data !== 'object' || data === null) { + return false; + } + + const message = data as Record; + const kind = message.kind as UwtBroadcastMessage['kind']; + if (!MESSAGE_KINDS.has(kind)) { + return false; + } + + switch (kind) { + case 'event': + return ( + typeof message.eventType === 'string' && + typeof message.payload === 'object' && + message.payload !== null && + isValidBroadcastMetadata(message.metadata) + ); + case 'error': + return ( + isValidTelemetryError(message.error) && + isValidBroadcastMetadata(message.metadata) + ); + case 'flush': + return typeof message.beacon === 'boolean'; + case 'user': + // Absent or `undefined` userId both mean "clear it". + return message.userId === undefined || typeof message.userId === 'string'; + case 'identity': + return ( + (message.sessionId === undefined || + typeof message.sessionId === 'string') && + (message.userId === undefined || typeof message.userId === 'string') + ); + case 'identity-request': + case 'log-flush': + return true; + case 'log': + return uwtIsBroadcastLogRecord(message.record); + case 'log-query': + return ( + typeof message.id === 'string' && + message.id !== '' && + isValidLogQuery(message.filter) + ); + case 'log-query-result': + return ( + typeof message.id === 'string' && + message.id !== '' && + Array.isArray(message.records) + ); + } +} diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts new file mode 100644 index 0000000..d2d93d3 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts @@ -0,0 +1,581 @@ +/** + * @jest-environment jsdom + */ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UWT_TELEMETRY_EVENT_TYPE, + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { uwtMarkSessionStart } from '../../utils/uwt-session-start.util/uwt-session-start.util'; +import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { + UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS, + UwtBroadcastLogApiProvider +} from '../../providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; +import { UwtNoopLogApiProvider } from '../../providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider'; +import { UwtNoopTelemetrySink } from '../uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; +import { UwtTelemetryBroadcastHost } from './uwt-broadcast-host'; +import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; +import { + UWT_DEFAULT_BROADCAST_CHANNEL, + uwtConnectBroadcastChannel +} from './uwt-broadcast.messages'; + +/** + * `BroadcastChannel` for the test: delivers asynchronously to every other + * open instance of the same name. + */ +class ChannelStub { + static readonly open = new Set(); + onmessage: ((event: { data: unknown }) => void) | null = null; + + constructor(readonly name: string) { + ChannelStub.open.add(this); + } + + postMessage(message: unknown): void { + for (const peer of ChannelStub.open) { + if (peer !== this && peer.name === this.name) { + const data = JSON.parse(JSON.stringify(message)); + setTimeout(() => peer.onmessage?.({ data }), 0); + } + } + } + + close(): void { + ChannelStub.open.delete(this); + } +} + +async function settle(): Promise { + for (let turn = 0; turn < 5; turn++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +const identity = { application: 'cart', environment: 'test', version: '1' }; + +describe('the core broadcast classes', () => { + let hosts: UwtTelemetryBroadcastHost[]; + + /** A host, disposed after the test whatever happens. */ + function hostOf( + options: ConstructorParameters[0] + ): UwtTelemetryBroadcastHost { + const host = new UwtTelemetryBroadcastHost(options); + hosts.push(host); + return host; + } + + beforeEach(() => { + hosts = []; + Object.defineProperty(globalThis, 'BroadcastChannel', { + value: ChannelStub, + configurable: true, + writable: true + }); + }); + + afterEach(() => { + hosts.forEach((host) => host.dispose()); + ChannelStub.open.clear(); + delete (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel; + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('a shared connection', () => { + it('should serve a sink and a log provider over one channel', async () => { + const connection = uwtConnectBroadcastChannel(); + const sink = new UwtBroadcastTelemetrySink({ identity, connection }); + const logApi = new UwtBroadcastLogApiProvider({ connection }); + const host = hostOf({ + sink: new UwtNoopTelemetrySink(), + logApi: { + send: () => undefined, + query: async () => [record('found')] + } + }); + await settle(); + + expect(ChannelStub.open.size).toBe(2); + const query = logApi.query({}); + await settle(); + expect((await query).map((r) => r.message)).toEqual(['found']); + + sink.flush(); + await settle(); + expect(host.received).toBe(2); + sink.dispose(); + logApi.dispose(); + connection.close(); + }); + + it('should be closed by whoever opened it, not by those it was lent to', () => { + const connection = uwtConnectBroadcastChannel(); + const sink = new UwtBroadcastTelemetrySink({ identity, connection }); + const logApi = new UwtBroadcastLogApiProvider({ connection }); + + sink.dispose(); + logApi.dispose(); + expect(connection.isOpen()).toBe(true); + + connection.close(); + expect(ChannelStub.open.size).toBe(0); + }); + + it('should be opened, and closed, by a sink given none', () => { + const sink = new UwtBroadcastTelemetrySink({ identity }); + expect(ChannelStub.open.size).toBe(1); + + sink.dispose(); + sink.dispose(); + expect(ChannelStub.open.size).toBe(0); + }); + + it('should send nothing once disposed, though the shared connection stays open for others', async () => { + const shell = new ChannelStub(UWT_DEFAULT_BROADCAST_CHANNEL); + const heard: Record[] = []; + shell.onmessage = ({ data }) => + heard.push(data as Record); + const connection = uwtConnectBroadcastChannel(); + const sink = new UwtBroadcastTelemetrySink({ identity, connection }); + const logApi = new UwtBroadcastLogApiProvider({ connection }); + await settle(); + heard.length = 0; + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + + sink.dispose(); + logApi.dispose(); + sink.record('com.uwt.bi', { eventName: 'x' }); + sink.recordError({ name: 'Error', message: 'boom' }); + sink.setUserId('u-1'); + sink.flush(true); + logApi.send(record('hi')); + logApi.flush(); + const query = logApi.query({}); + + expect(jest.getTimerCount()).toBe(0); + await expect(query).resolves.toEqual([]); + jest.useRealTimers(); + await settle(); + expect(heard).toEqual([]); + expect(connection.isOpen()).toBe(true); + connection.post({ kind: 'flush', beacon: false }); + await settle(); + expect(heard).toEqual([{ kind: 'flush', beacon: false }]); + connection.close(); + }); + + it('should open nothing while disabled', async () => { + const sink = new UwtBroadcastTelemetrySink({ identity, enabled: false }); + const logApi = new UwtBroadcastLogApiProvider({ enabled: false }); + + expect(ChannelStub.open.size).toBe(0); + expect(() => sink.record('com.uwt.bi', {})).not.toThrow(); + await expect(logApi.query({})).resolves.toEqual([]); + }); + + it('should send nothing over a shared connection while disabled, and leave it open', async () => { + const shell = new ChannelStub(UWT_DEFAULT_BROADCAST_CHANNEL); + const heard: unknown[] = []; + shell.onmessage = ({ data }) => heard.push(data); + const connection = uwtConnectBroadcastChannel(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + + const sink = new UwtBroadcastTelemetrySink({ + identity, + connection, + enabled: false + }); + const logApi = new UwtBroadcastLogApiProvider({ + connection, + enabled: false + }); + sink.record('com.uwt.bi', { eventName: 'x' }); + sink.recordError({ name: 'Error', message: 'boom' }); + sink.flush(true); + logApi.send(record('hi')); + const query = logApi.query({}); + + expect(jest.getTimerCount()).toBe(0); + await expect(query).resolves.toEqual([]); + jest.useRealTimers(); + await settle(); + expect(heard).toEqual([]); + sink.dispose(); + logApi.dispose(); + expect(connection.isOpen()).toBe(true); + connection.close(); + }); + }); + + describe('the forwarding sink', () => { + it("should forward with its realm's origin, and adopt the identity the host announces", async () => { + const shell = new ChannelStub(UWT_DEFAULT_BROADCAST_CHANNEL); + const heard: Record[] = []; + shell.onmessage = ({ data }) => + heard.push(data as Record); + const sink = new UwtBroadcastTelemetrySink({ identity }); + + sink.record('com.uwt.bi', { eventName: 'x' }, { feature: 'cart' }); + sink.recordError({ name: 'Error', message: 'boom' }); + sink.setUserId('u-1'); + sink.flush(true); + shell.postMessage({ + kind: 'identity', + sessionId: 'shell-session', + userId: 'shell-user' + }); + shell.postMessage({ kind: 'nonsense' }); + await settle(); + + const origin = { + application: 'cart', + environment: 'test', + appVersion: '1' + }; + expect(heard).toEqual([ + { kind: 'identity-request' }, + { + kind: 'event', + eventType: 'com.uwt.bi', + payload: { eventName: 'x' }, + metadata: { feature: 'cart', ...origin } + }, + { + kind: 'error', + error: { name: 'Error', message: 'boom' }, + metadata: origin + }, + { kind: 'user', userId: 'u-1' }, + { kind: 'flush', beacon: true } + ]); + expect(sink.getSessionId()).toBe('shell-session'); + expect(sink.getUserId()).toBe('shell-user'); + sink.dispose(); + }); + }); + + describe('a log query', () => { + it('should resolve to [] when no host answers in time, its timer scheduled through runOutside', async () => { + jest.useFakeTimers(); + let outside = 0; + const logApi = new UwtBroadcastLogApiProvider({ + runOutside: (callback) => { + outside++; + return callback(); + } + }); + + const query = logApi.query({}); + jest.advanceTimersByTime(UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS); + + await expect(query).resolves.toEqual([]); + expect(outside).toBe(1); + expect(jest.getTimerCount()).toBe(0); + logApi.dispose(); + }); + + it('should resolve to [] when the provider is disposed, leaving no timer', async () => { + jest.useFakeTimers(); + const logApi = new UwtBroadcastLogApiProvider(); + + const query = logApi.query({}); + logApi.dispose(); + + await expect(query).resolves.toEqual([]); + expect(jest.getTimerCount()).toBe(0); + }); + }); + + describe('the host', () => { + const shell = () => ({ + sink: new UwtNoopTelemetrySink(), + logApi: new UwtNoopLogApiProvider() + }); + + it('should report a second host in this realm to its reporter', () => { + const report = jest.fn(); + const first = hostOf({ ...shell(), report }); + const second = hostOf({ ...shell(), report }); + + expect(report).toHaveBeenCalledWith( + 'config', + expect.stringContaining('a second telemetry host is active') + ); + first.dispose(); + second.dispose(); + }); + + it('should warn on the console without a reporter: a misconfigured host is never silent', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const forwarding = hostOf({ + sink: new UwtBroadcastTelemetrySink({ identity }), + logApi: new UwtNoopLogApiProvider() + }); + + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/^\[ui-watchtower\] .*cannot also host it/) + ); + forwarding.dispose(); + }); + + it('should neither count nor open anything while disabled', () => { + const report = jest.fn(); + const disabled = hostOf({ + ...shell(), + enabled: false, + report + }); + const active = hostOf({ ...shell(), report }); + + expect(report).not.toHaveBeenCalled(); + expect(ChannelStub.open.size).toBe(1); + disabled.dispose(); + active.dispose(); + }); + + it('should give up its place only once, however often it is disposed', () => { + const report = jest.fn(); + const first = hostOf({ ...shell(), report }); + const second = hostOf({ ...shell(), report }); + second.dispose(); + second.dispose(); + report.mockClear(); + + const third = hostOf({ ...shell(), report }); + + expect(report).toHaveBeenCalledWith( + 'config', + expect.stringContaining('a second telemetry host is active') + ); + first.dispose(); + third.dispose(); + }); + }); + + describe('the host, relaying a fragment', () => { + /** The shell's real destination; its session and user can move on. */ + class ShellSink extends UwtTelemetrySink { + sessionId?: string = 'shell-session'; + userId?: string = 'shell-user'; + readonly calls = { + record: jest.fn(), + recordError: jest.fn(), + flush: jest.fn(), + setUserId: jest.fn() + }; + + record( + eventType: string, + payload: object, + metadata?: UwtTelemetryMetadata + ): void { + this.calls.record(eventType, payload, metadata); + } + + recordError( + error: UwtTelemetryError, + metadata?: UwtTelemetryMetadata + ): void { + this.calls.recordError(error, metadata); + } + + flush(beacon?: boolean): void { + this.calls.flush(beacon); + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + getUserId(): string | undefined { + return this.userId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + this.calls.setUserId(userId); + } + } + + let sink: ShellSink; + let logApi: { send: jest.Mock; query: jest.Mock; flush: jest.Mock }; + /** A fragment's end of the channel: what it sends, and what it hears. */ + let fragment: { + send: (message: unknown) => void; + heard: Record[]; + }; + const recorded = () => sink.calls.record.mock.calls.map(([, p]) => p); + const heardOf = (kind: string) => + fragment.heard.filter((message) => message.kind === kind); + + beforeEach(() => { + localStorage.clear(); + sink = new ShellSink(); + logApi = { + send: jest.fn(), + query: jest.fn(async () => [record('found')]), + flush: jest.fn() + }; + hostOf({ sink, logApi }); + const channel = new ChannelStub(UWT_DEFAULT_BROADCAST_CHANNEL); + const heard: Record[] = []; + channel.onmessage = ({ data }) => + heard.push(data as Record); + fragment = { send: (message) => channel.postMessage(message), heard }; + }); + + it("should record a forwarded scenario under the shell's session and user, measuring the session again", async () => { + uwtMarkSessionStart('shell-session', Date.now() - 2000); + + fragment.send({ + kind: 'event', + eventType: UWT_TELEMETRY_EVENT_TYPE.scenario, + payload: { + scenarioName: 'checkout', + sessionId: 'stale', + userId: 'stale-user', + sessionElapsed: 5 + }, + metadata: { application: 'cart' } + }); + await settle(); + + const [[eventType, payload, metadata]] = sink.calls.record.mock.calls; + expect(eventType).toBe(UWT_TELEMETRY_EVENT_TYPE.scenario); + expect(payload).toMatchObject({ + scenarioName: 'checkout', + sessionId: 'shell-session', + userId: 'shell-user' + }); + expect(payload.sessionElapsed).toBeGreaterThanOrEqual(2000); + expect(metadata).toEqual({ application: 'cart' }); + }); + + it('should drop an elapsed time it cannot measure, and pass up-to-date or unknown records as they are', async () => { + fragment.send({ + kind: 'event', + eventType: UWT_TELEMETRY_EVENT_TYPE.scenario, + payload: { scenarioName: 'a', sessionId: 'stale', sessionElapsed: 5 } + }); + fragment.send({ + kind: 'event', + eventType: UWT_TELEMETRY_EVENT_TYPE.bi, + payload: { + eventName: 'x', + sessionId: 'shell-session', + userId: 'shell-user' + } + }); + fragment.send({ + kind: 'event', + eventType: 'com.other.event', + payload: { sessionId: 'stale' } + }); + fragment.send({ kind: 'event', eventType: 1 }); + await settle(); + + expect(recorded()).toEqual([ + { scenarioName: 'a', sessionId: 'shell-session', userId: 'shell-user' }, + { eventName: 'x', sessionId: 'shell-session', userId: 'shell-user' }, + { sessionId: 'stale' } + ]); + }); + + it('should relay errors, the user and flushes, and answer identity requests', async () => { + fragment.send({ + kind: 'error', + error: { name: 'TypeError', message: 'boom' }, + metadata: { application: 'cart' } + }); + fragment.send({ kind: 'flush', beacon: true }); + fragment.send({ kind: 'user', userId: 'new-user' }); + fragment.send({ kind: 'identity-request' }); + // Another tab's host: expected on an origin-wide channel, and ignored. + fragment.send({ kind: 'identity', sessionId: 'other-tab' }); + await settle(); + + expect(sink.calls.recordError).toHaveBeenCalledWith( + { name: 'TypeError', message: 'boom' }, + { application: 'cart' } + ); + expect(sink.calls.flush).toHaveBeenCalledWith(true); + expect(sink.calls.setUserId).toHaveBeenCalledWith('new-user'); + expect(heardOf('identity')).toEqual([ + { kind: 'identity', sessionId: 'shell-session', userId: 'new-user' }, + { kind: 'identity', sessionId: 'shell-session', userId: 'new-user' } + ]); + }); + + it("should announce the shell's identity again once its session moves on", async () => { + sink.sessionId = 'renewed'; + + fragment.send({ + kind: 'event', + eventType: 'com.other.event', + payload: {} + }); + await settle(); + + expect(heardOf('identity')).toEqual([ + { kind: 'identity', sessionId: 'renewed', userId: 'shell-user' } + ]); + }); + + it("should deliver forwarded log lines under the shell's identity, and flush the log store", async () => { + fragment.send({ + kind: 'log', + record: { ...record('hi'), sessionId: 'stale' } + }); + fragment.send({ kind: 'log-flush' }); + await settle(); + + expect(logApi.send).toHaveBeenCalledWith({ + ...record('hi'), + sessionId: 'shell-session', + userId: 'shell-user' + }); + expect(logApi.flush).toHaveBeenCalled(); + }); + + it('should answer a log query with [] when the log store throws, rejects or returns no list', async () => { + logApi.query + .mockImplementationOnce(() => { + throw new Error('down'); + }) + .mockRejectedValueOnce(new Error('down')) + .mockReturnValueOnce('not records'); + + for (const id of ['q1', 'q2', 'q3']) { + fragment.send({ kind: 'log-query', id, filter: {} }); + } + await settle(); + + const answers = heardOf('log-query-result'); + expect(answers).toHaveLength(3); + expect(answers).toEqual( + expect.arrayContaining( + ['q1', 'q2', 'q3'].map((id) => ({ + kind: 'log-query-result', + id, + records: [] + })) + ) + ); + }); + }); +}); + +function record(message: string): UwtLogRecord { + return { + timestamp: '2026-01-01T00:00:00.000Z', + level: 'log', + message, + application: 'shell', + environment: 'test', + version: '1' + }; +} diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts rename to projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts new file mode 100644 index 0000000..4f13ce7 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts @@ -0,0 +1,50 @@ +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtTelemetrySink } from '../uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; + +/** + * Sink that discards everything. + * + * The explicit way to send telemetry nowhere — never a default. Debug flags, + * logging and scenario mechanics all still work, with no AWS account or + * `aws-rum-web` dependency needed. Angular selects it with + * `provideUwtTelemetrySink('noop')`. + * + * @group Services + */ +export class UwtNoopTelemetrySink extends UwtTelemetrySink { + private userId?: string; + + /** @inheritdoc */ + record( + _eventType: string, + _payload: object, + _metadata?: UwtTelemetryMetadata + ): void {} + + /** @inheritdoc */ + recordError( + _error: UwtTelemetryError, + _metadata?: UwtTelemetryMetadata + ): void {} + + /** @inheritdoc */ + getSessionId(): string | undefined { + return undefined; + } + + /** @inheritdoc */ + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(_beacon?: boolean): void {} +} diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink.ts b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink.ts similarity index 94% rename from projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink.ts rename to projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink.ts index 0e942ea..2a1af0f 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink.ts +++ b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink.ts @@ -1,4 +1,3 @@ -import { Injectable } from '@angular/core'; import { UwtTelemetryError, UwtTelemetryMetadata @@ -11,9 +10,11 @@ import { * integration be replaced or stubbed with no change to the telemetry * services or the applications that use them. * + * A plain class: extend it, or implement it, in any framework. Angular uses + * it as the injection token for the destination. + * * @group Services */ -@Injectable() export abstract class UwtTelemetrySink { /** * Records one custom event. Must be non-throwing and non-blocking. diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.spec.ts b/projects/ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.spec.ts similarity index 98% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.spec.ts rename to projects/ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.spec.ts index 6b344b8..fe23181 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.spec.ts +++ b/projects/ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.spec.ts @@ -1,3 +1,6 @@ +/** + * @jest-environment jsdom + */ import { uwtIsDebugEnabled } from './uwt-debug-flag.util'; describe('uwtIsDebugEnabled', () => { diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts similarity index 90% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts rename to projects/ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts index 7c7d9f2..b4232c5 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts +++ b/projects/ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts @@ -1,4 +1,7 @@ -import { uwtSafeVoid } from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + UwtReporter, + uwtSafeVoid +} from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; /** * LocalStorage keys recognised as telemetry debug switches. @@ -63,16 +66,16 @@ export function uwtIsDebugEnabled(flag: UwtDebugFlag, name?: string): boolean { * @param write performs the console call; never runs when the flag is off * @param name the name to test against a list-valued flag, e.g. a logger * name for `debugLogger` - * - * @group Utils + * @param report receives the error when the write throws */ export function uwtDebugWrite( flag: UwtDebugFlag, write: () => void, - name?: string + name?: string, + report?: UwtReporter ): void { if (!uwtIsDebugEnabled(flag, name)) { return; } - uwtSafeVoid(`${flag}.write`, write); + uwtSafeVoid(`${flag}.write`, write, report); } diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.spec.ts b/projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.spec.ts similarity index 98% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.spec.ts rename to projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.spec.ts index f57add8..fe7dea3 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.spec.ts +++ b/projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.spec.ts @@ -1,3 +1,6 @@ +/** + * @jest-environment jsdom + */ import { uwtMarkSessionStart, uwtSessionElapsed diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts similarity index 95% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts rename to projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts index 27a2915..531ed58 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts +++ b/projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts @@ -8,6 +8,8 @@ * cookie across reloads and tabs. Where storage is blocked, nothing is kept. */ +// Named for the package that introduced it. Kept: stored values outlive a +// deploy, and a shell and its fragments, on different versions, share them. const STORAGE_KEY = 'ngx-ui-watchtower.sessionStarts'; /** diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.spec.ts b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.spec.ts rename to projects/ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.spec.ts diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.ts rename to projects/ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.ts diff --git a/projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts new file mode 100644 index 0000000..26d2de2 --- /dev/null +++ b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts @@ -0,0 +1,816 @@ +import { + UWT_DEFAULT_REDACT_CONFIG, + UWT_REDACTED, + uwtMergeMetadata, + uwtNormalizeError, + uwtRedactConfigFor, + uwtRedactMetadata, + uwtScrubString +} from './uwt-telemetry-redact.util'; + +describe('uwtScrubString', () => { + it('should strip the query string and fragment from an absolute URL', () => { + expect( + uwtScrubString( + 'https://api.example.com/customers?token=abc123#section', + UWT_DEFAULT_REDACT_CONFIG + ) + ).toBe('https://api.example.com/customers'); + }); + + it('should strip a URL embedded inside a longer message', () => { + expect( + uwtScrubString( + 'Request to https://api.example.com/v1/users?apiKey=secret failed', + UWT_DEFAULT_REDACT_CONFIG + ) + ).toBe('Request to https://api.example.com/v1/users failed'); + }); + + it('should strip the query string from a root-relative path', () => { + expect( + uwtScrubString('/customers?ssn=123-45-6789', UWT_DEFAULT_REDACT_CONFIG) + ).toBe('/customers'); + }); + + it('should strip a root-relative path embedded inside a longer message', () => { + expect( + uwtScrubString( + 'Request to /api/customers/search?email=john@example.com&ssn=123-45-6789 failed', + UWT_DEFAULT_REDACT_CONFIG + ) + ).toBe('Request to /api/customers/search failed'); + }); + + it('should strip more than one embedded root-relative path in the same string', () => { + expect( + uwtScrubString( + 'compare /a/x?p=1 against /b/y?q=2 now', + UWT_DEFAULT_REDACT_CONFIG + ) + ).toBe('compare /a/x against /b/y now'); + }); + + it('should strip a root-relative path with no space before it', () => { + expect( + uwtScrubString( + 'Redirected to:/dashboard?sessionToken=abc123', + UWT_DEFAULT_REDACT_CONFIG + ) + ).toBe('Redirected to:/dashboard'); + }); + + it('should leave a plain message that merely ends in a question mark intact', () => { + expect( + uwtScrubString('Could not load the data?', UWT_DEFAULT_REDACT_CONFIG) + ).toBe('Could not load the data?'); + }); + + it('should truncate strings beyond the configured cap', () => { + const result = uwtScrubString('x'.repeat(50), { + ...UWT_DEFAULT_REDACT_CONFIG, + maxStringLength: 10 + }); + expect(result).toBe(`${'x'.repeat(10)}…`); + }); + + it('should leave URLs alone when stripping is disabled', () => { + expect( + uwtScrubString('https://example.com/a?b=c', { + ...UWT_DEFAULT_REDACT_CONFIG, + stripUrlQuery: false + }) + ).toBe('https://example.com/a?b=c'); + }); + + describe('value-pattern scanning', () => { + it('should not scan for any value pattern by default', () => { + expect( + uwtScrubString( + 'contact john.smith@example.com, card 4111111111111111, ssn 123-45-6789', + UWT_DEFAULT_REDACT_CONFIG + ) + ).toBe( + 'contact john.smith@example.com, card 4111111111111111, ssn 123-45-6789' + ); + }); + + it('should redact an email address when enabled', () => { + expect( + uwtScrubString('contact john.smith@example.com for help', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email'] + }) + ).toBe(`contact ${UWT_REDACTED} for help`); + }); + + it('should not match a bare @-handle with no domain as an email', () => { + expect( + uwtScrubString('cc @someuser on this', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email'] + }) + ).toBe('cc @someuser on this'); + }); + + it('should catch an email embedded in a URL path segment', () => { + expect( + uwtScrubString('/customers/john.smith@example.com', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email'] + }) + ).toBe(`/customers/${UWT_REDACTED}`); + }); + + it('should redact a Luhn-valid credit card number when enabled', () => { + expect( + uwtScrubString('card on file: 4111111111111111', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['creditCard'] + }) + ).toBe(`card on file: ${UWT_REDACTED}`); + }); + + it('should leave a 16-digit run alone when it fails the Luhn checksum', () => { + expect( + uwtScrubString('order number: 1234567890123456', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['creditCard'] + }) + ).toBe('order number: 1234567890123456'); + }); + + it('should redact a well-formatted SSN when enabled', () => { + expect( + uwtScrubString('ssn on file: 123-45-6789', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ssn'] + }) + ).toBe(`ssn on file: ${UWT_REDACTED}`); + }); + + it('should not match a bare 9-digit run with no dashes as an SSN', () => { + expect( + uwtScrubString('reference 123456789', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ssn'] + }) + ).toBe('reference 123456789'); + }); + + it('should redact an IPv4 address when enabled', () => { + expect( + uwtScrubString('client at 192.168.1.1', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ipv4'] + }) + ).toBe(`client at ${UWT_REDACTED}`); + }); + + it('should not match an out-of-range octet as an IPv4 address', () => { + expect( + uwtScrubString('version 999.999.999.999', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ipv4'] + }) + ).toBe('version 999.999.999.999'); + }); + + it('should redact a US-shaped phone number when enabled', () => { + expect( + uwtScrubString('call (555) 123-4567', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['phone'] + }) + ).toBe(`call ${UWT_REDACTED}`); + }); + + it('should redact a South African phone number in international format when enabled', () => { + expect( + uwtScrubString('call +27 82 123 4567', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['phone'] + }) + ).toBe(`call ${UWT_REDACTED}`); + }); + + it('should redact a value matching an application-supplied pattern', () => { + expect( + uwtScrubString('internal ref ACC-98765', { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [/ACC-\d+/] + }) + ).toBe(`internal ref ${UWT_REDACTED}`); + }); + + it('should redact every occurrence even when the supplied pattern has no g flag', () => { + expect( + uwtScrubString('ref ACC-111 and also ref ACC-222', { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [/ACC-\d+/] + }) + ).toBe(`ref ${UWT_REDACTED} and also ref ${UWT_REDACTED}`); + }); + + it('should redact correctly across repeated calls reusing the same non-global pattern object', () => { + const pattern = /ACC-\d+/; + const config = { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [pattern] + }; + + expect(uwtScrubString('ref ACC-111 and also ACC-222', config)).toBe( + `ref ${UWT_REDACTED} and also ${UWT_REDACTED}` + ); + + expect(uwtScrubString('ref ACC-333 and also ACC-444', config)).toBe( + `ref ${UWT_REDACTED} and also ${UWT_REDACTED}` + ); + }); + + it('should not recompile the same non-global pattern into a new RegExp on every call', () => { + const RegExpSpy = jest.spyOn(globalThis, 'RegExp'); + const pattern = /ACC-\d+/; + const config = { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [pattern] + }; + + uwtScrubString('ref ACC-111', config); + const callsAfterFirst = RegExpSpy.mock.calls.length; + uwtScrubString('ref ACC-222', config); + const callsAfterSecond = RegExpSpy.mock.calls.length; + + expect(callsAfterFirst).toBeGreaterThan(0); + expect(callsAfterSecond).toBe(callsAfterFirst); + + RegExpSpy.mockRestore(); + }); + + it('should fully redact a credit card number regardless of scanValuePatterns order', () => { + const value = 'card on file: 4111111111111111'; + expect( + uwtScrubString(value, { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['phone', 'creditCard'] + }) + ).toBe(`card on file: ${UWT_REDACTED}`); + expect( + uwtScrubString(value, { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['creditCard', 'phone'] + }) + ).toBe(`card on file: ${UWT_REDACTED}`); + }); + + it('should apply multiple enabled patterns to the same string', () => { + expect( + uwtScrubString('email a@b.com or call (555) 123-4567', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email', 'phone'] + }) + ).toBe(`email ${UWT_REDACTED} or call ${UWT_REDACTED}`); + }); + }); + + describe('extraValueTransforms', () => { + it('should run a custom transform on every string value', () => { + expect( + uwtScrubString('internal-id ACC-12345', { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + (value) => value.replace(/ACC-\d+/, UWT_REDACTED) + ] + }) + ).toBe(`internal-id ${UWT_REDACTED}`); + }); + + it('should run regardless of scanValuePatterns/extraValuePatterns being empty', () => { + expect( + uwtScrubString('plain text', { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [() => 'replaced'] + }) + ).toBe('replaced'); + }); + + it('should run multiple transforms in array order', () => { + expect( + uwtScrubString('start', { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + (value) => `${value}-a`, + (value) => `${value}-b` + ] + }) + ).toBe('start-a-b'); + }); + + describe('a throwing transform', () => { + let report: jest.Mock; + + beforeEach(() => { + report = jest.fn(); + }); + + it('should skip it and keep the value from before it, reporting why', () => { + expect( + uwtScrubString( + 'unchanged', + { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + () => { + throw new Error('broken transform'); + } + ] + }, + report + ) + ).toBe('unchanged'); + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('extraValueTransforms'), + expect.any(Error) + ); + }); + + it('should still apply subsequent transforms after it', () => { + expect( + uwtScrubString( + 'start', + { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + () => { + throw new Error('broken transform'); + }, + (value) => `${value}-ok` + ] + }, + report + ) + ).toBe('start-ok'); + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('extraValueTransforms'), + expect.any(Error) + ); + }); + + it('should print nothing without a reporter', () => { + const consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + try { + expect( + uwtScrubString('unchanged', { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + () => { + throw new Error('broken transform'); + } + ] + }) + ).toBe('unchanged'); + expect(consoleWarn).not.toHaveBeenCalled(); + } finally { + consoleWarn.mockRestore(); + } + }); + }); + + it('should still cap length after custom transforms run', () => { + expect( + uwtScrubString('short', { + ...UWT_DEFAULT_REDACT_CONFIG, + maxStringLength: 5, + extraValueTransforms: [() => 'a much longer replacement value'] + }) + ).toBe('a muc…'); + }); + }); +}); + +describe('uwtRedactMetadata', () => { + it('should keep primitive values', () => { + expect( + uwtRedactMetadata({ count: 3, name: 'csv', ok: true, empty: null }) + ).toEqual({ + count: 3, + name: 'csv', + ok: true, + empty: null + }); + }); + + it.each([ + 'password', + 'passwd', + 'accessToken', + 'refresh_token', + 'Authorization', + 'clientSecret', + 'apiKey', + 'api_key', + 'Cookie', + 'bearerToken', + 'jwt', + 'signature', + 'sessionKey', + 'ssn' + ])('should redact the sensitive key "%s"', (key) => { + expect(uwtRedactMetadata({ [key]: 'super-secret-value' })).toEqual({ + [key]: UWT_REDACTED + }); + }); + + it('should redact keys matching an application-supplied pattern', () => { + expect( + uwtRedactMetadata( + { customerRef: 'ABC' }, + { ...UWT_DEFAULT_REDACT_CONFIG, extraKeyPatterns: [/customerRef/i] } + ) + ).toEqual({ customerRef: UWT_REDACTED }); + }); + + it('should redact the same key consistently across repeated calls with a global pattern', () => { + const config = { + ...UWT_DEFAULT_REDACT_CONFIG, + extraKeyPatterns: [/internalId/gi] + }; + for (let i = 0; i < 4; i++) { + expect(uwtRedactMetadata({ internalId: 'value' }, config)).toEqual({ + internalId: UWT_REDACTED + }); + } + }); + + it('should drop nested objects rather than serializing them', () => { + expect( + uwtRedactMetadata({ user: { id: 1, email: 'a@b.c' }, safe: 'yes' }) + ).toEqual({ safe: 'yes' }); + }); + + it('should drop arrays, functions, symbols and undefined', () => { + expect( + uwtRedactMetadata({ + rows: [1, 2, 3], + fn: () => undefined, + sym: Symbol('s'), + missing: undefined, + kept: 1 + }) + ).toEqual({ kept: 1 }); + }); + + it('should drop non-finite numbers', () => { + expect( + uwtRedactMetadata({ a: NaN, b: Infinity, c: -Infinity, d: 0 }) + ).toEqual({ d: 0 }); + }); + + it('should scrub URLs inside string values', () => { + expect(uwtRedactMetadata({ url: 'https://x.dev/p?token=1' })).toEqual({ + url: 'https://x.dev/p' + }); + }); + + it('should cap the number of retained keys', () => { + const input: Record = {}; + for (let i = 0; i < 100; i++) { + input[`k${i}`] = i; + } + const result = uwtRedactMetadata(input, { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 5 + }); + expect(Object.keys(result ?? {})).toHaveLength(5); + }); + + describe('truncation warning', () => { + let report: jest.Mock; + + beforeEach(() => { + report = jest.fn(); + }); + + it('should warn once when maxKeys actually truncates something', () => { + uwtRedactMetadata( + { a: 1, b: 2, c: 3 }, + { ...UWT_DEFAULT_REDACT_CONFIG, maxKeys: 2 }, + report + ); + + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('maxKeys') + ); + }); + + it('should stay silent when every key fits under maxKeys', () => { + uwtRedactMetadata( + { a: 1, b: 2 }, + { ...UWT_DEFAULT_REDACT_CONFIG, maxKeys: 2 }, + report + ); + + expect(report).not.toHaveBeenCalled(); + }); + }); + + it('should not throw on a cyclic object', () => { + const cyclic: Record = { name: 'root' }; + cyclic.self = cyclic; + expect(uwtRedactMetadata(cyclic)).toEqual({ name: 'root' }); + }); + + it.each([[null], [undefined], ['string'], [42], [[1, 2]]])( + 'should return undefined for the non-object input %p', + (input) => { + expect(uwtRedactMetadata(input)).toBeUndefined(); + } + ); + + it('should return undefined when nothing survives redaction', () => { + expect(uwtRedactMetadata({ nested: { a: 1 } })).toBeUndefined(); + }); +}); + +describe('uwtMergeMetadata', () => { + it('should return target unchanged when incoming is undefined', () => { + const target = { a: 1 }; + expect(uwtMergeMetadata(target, undefined)).toBe(target); + expect(target).toEqual({ a: 1 }); + }); + + it('should merge a disjoint bag in when under maxKeys', () => { + const target = { a: 1 }; + const result = uwtMergeMetadata( + target, + { b: 2 }, + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 5 + } + ); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it('should drop a genuinely new key once the combined count reaches maxKeys', () => { + const report = jest.fn(); + const target = { a: 1 }; + const result = uwtMergeMetadata( + target, + { b: 2 }, + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 1 + }, + report + ); + expect(result).toEqual({ a: 1 }); + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('maxKeys') + ); + }); + + it('should allow updating a key already present even when target is at maxKeys', () => { + const target = { a: 1 }; + const result = uwtMergeMetadata( + target, + { a: 2 }, + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 1 + } + ); + expect(result).toEqual({ a: 2 }); + }); + + it('should mutate and return the same target reference', () => { + const target = { a: 1 }; + expect(uwtMergeMetadata(target, { b: 2 })).toBe(target); + }); + + describe('truncation warning', () => { + let report: jest.Mock; + + beforeEach(() => { + report = jest.fn(); + }); + + it('should warn once when maxKeys actually truncates the combined result', () => { + uwtMergeMetadata( + { a: 1 }, + { b: 2 }, + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 1 + }, + report + ); + + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('maxKeys') + ); + }); + + it('should stay silent when the combined bag fits under maxKeys', () => { + uwtMergeMetadata( + { a: 1 }, + { b: 2 }, + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 2 + }, + report + ); + + expect(report).not.toHaveBeenCalled(); + }); + + it('should stay silent when only existing keys are updated at maxKeys', () => { + uwtMergeMetadata( + { a: 1 }, + { a: 2 }, + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 1 + }, + report + ); + + expect(report).not.toHaveBeenCalled(); + }); + }); +}); + +describe('uwtNormalizeError', () => { + it('should reduce an Error to name, message and stack', () => { + const result = uwtNormalizeError(new TypeError('boom')); + expect(result?.name).toBe('TypeError'); + expect(result?.message).toBe('boom'); + expect(typeof result?.stack).toBe('string'); + }); + + it.each([ + ['empty', ''], + ['missing', undefined] + ])('should fall back to the generic label when name is %s', (_case, name) => { + const error = new Error('boom'); + (error as { name: unknown }).name = name; + expect(uwtNormalizeError(error)?.name).toBe('Error'); + }); + + it('should omit the stack when capture is disabled', () => { + const result = uwtNormalizeError(new Error('boom'), { + ...UWT_DEFAULT_REDACT_CONFIG, + includeStack: false + }); + expect(result?.stack).toBeUndefined(); + }); + + it('should cap the stack length', () => { + const error = new Error('boom'); + error.stack = 'y'.repeat(5000); + const result = uwtNormalizeError(error, { + ...UWT_DEFAULT_REDACT_CONFIG, + maxStackLength: 100 + }); + expect(result?.stack?.length).toBe(101); + }); + + it('should scrub URLs out of the error message', () => { + const result = uwtNormalizeError( + new Error('GET https://api.dev/me?access_token=xyz returned 401') + ); + expect(result?.message).toBe('GET https://api.dev/me returned 401'); + }); + + it('should accept a thrown string', () => { + expect(uwtNormalizeError('plain failure')).toEqual({ + name: 'Error', + message: 'plain failure' + }); + }); + + it('should extract name and message from an HttpErrorResponse-shaped object', () => { + const httpError = { + name: 'HttpErrorResponse', + message: 'Http failure response for /api/customers: 404 Not Found', + status: 404, + statusText: 'Not Found', + url: '/api/customers', + ok: false, + error: { secret: 'raw response body — must never appear in output' } + }; + + const result = uwtNormalizeError(httpError); + + expect(result).toEqual({ + name: 'HttpErrorResponse', + message: 'Http failure response for /api/customers: 404 Not Found' + }); + expect(JSON.stringify(result)).not.toContain('raw response body'); + }); + + it('should fall back to a generic name when the HTTP-error-shaped object has none', () => { + const result = uwtNormalizeError({ + message: 'Http failure response for /api/x: 500 Internal Server Error', + status: 500, + statusText: 'Internal Server Error' + }); + + expect(result?.name).toBe('HttpErrorResponse'); + }); + + it('should not treat an arbitrary object with a status-shaped key as an HTTP error', () => { + expect( + uwtNormalizeError({ status: 404, message: 'unrelated object' }) + ).toEqual({ + name: 'UnknownError', + message: UWT_REDACTED + }); + }); + + it('should report the type of a thrown object without serializing it', () => { + expect(uwtNormalizeError({ password: 'hunter2' })).toEqual({ + name: 'UnknownError', + message: UWT_REDACTED + }); + }); + + it.each([[null], [undefined]])('should return undefined for %p', (input) => { + expect(uwtNormalizeError(input)).toBeUndefined(); + }); +}); + +describe('uwtRedactConfigFor', () => { + it('should return the same config unchanged when enabled', () => { + expect(uwtRedactConfigFor(UWT_DEFAULT_REDACT_CONFIG, true)).toBe( + UWT_DEFAULT_REDACT_CONFIG + ); + }); + + it('should turn off PII scrubbing when disabled', () => { + const config = uwtRedactConfigFor( + { + ...UWT_DEFAULT_REDACT_CONFIG, + extraKeyPatterns: [/x-internal/], + scanValuePatterns: ['email'], + extraValuePatterns: [/secret-\d+/] + }, + false + ); + + expect(config.extraKeyPatterns).toEqual([]); + expect(config.stripUrlQuery).toBe(false); + expect(config.scanValuePatterns).toEqual([]); + expect(config.extraValuePatterns).toEqual([]); + }); + + it('should keep size caps, error normalization inputs, and extraValueTransforms when disabled', () => { + const transform = (value: string) => value; + const config = uwtRedactConfigFor( + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxStringLength: 10, + maxKeys: 5, + maxStackLength: 100, + includeStack: true, + extraValueTransforms: [transform] + }, + false + ); + + expect(config.maxStringLength).toBe(10); + expect(config.maxKeys).toBe(5); + expect(config.maxStackLength).toBe(100); + expect(config.includeStack).toBe(true); + expect(config.extraValueTransforms).toEqual([transform]); + }); + + it('should still redact a built-in denylisted key when disabled — it is a safety floor, not a privacy opt-in', () => { + const disabled = uwtRedactConfigFor(UWT_DEFAULT_REDACT_CONFIG, false); + + expect(uwtRedactMetadata({ password: 'hunter2' }, disabled)).toEqual({ + password: UWT_REDACTED + }); + }); + + it('should not redact an extraKeyPatterns-only key when disabled', () => { + const disabled = uwtRedactConfigFor( + { ...UWT_DEFAULT_REDACT_CONFIG, extraKeyPatterns: [/x-internal/] }, + false + ); + + expect(uwtRedactMetadata({ 'x-internal-id': 'abc' }, disabled)).toEqual({ + 'x-internal-id': 'abc' + }); + }); +}); diff --git a/projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts new file mode 100644 index 0000000..7fc031d --- /dev/null +++ b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts @@ -0,0 +1,516 @@ +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { + uwtReport, + UwtReporter +} from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; + +/** + * Named, built-in value-content PII shapes {@link UwtRedactConfig.scanValuePatterns} + * can enable. + * + * @group Types + */ +export type UwtPiiValuePattern = + 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'; + +/** + * Tunables governing what telemetry is allowed to carry off the device. + * + * @group Interfaces + */ +export interface UwtRedactConfig { + /** + * Additional key patterns to redact, merged with the built-in denylist. + * Matched against the attribute key, case-insensitively. + */ + extraKeyPatterns: RegExp[]; + + /** Maximum length of any single string value before truncation. */ + maxStringLength: number; + + /** Maximum number of attributes retained on one payload. */ + maxKeys: number; + + /** Maximum length of a captured stack trace. */ + maxStackLength: number; + + /** Whether stack traces are captured at all. */ + includeStack: boolean; + + /** Whether URL query strings and fragments are stripped from string values. */ + stripUrlQuery: boolean; + + /** + * Value-content PII shapes to scan for, in addition to the key denylist. + * Off by default. Heuristic regexes, not certified detectors — `'phone'` + * is the noisiest; `'creditCard'` also requires a Luhn match. + */ + scanValuePatterns: UwtPiiValuePattern[]; + + /** + * Additional value-content regexes, merged with `scanValuePatterns` — the + * content counterpart to `extraKeyPatterns`. + */ + extraValuePatterns: RegExp[]; + + /** + * Application-supplied functions run on every string value, after all + * pattern-based scrubbing — an escape hatch for logic no regex can + * express. Runs independently of `scanValuePatterns`/`extraValuePatterns`. + * A throwing function is skipped (logged in dev mode), not fatal. + */ + extraValueTransforms: ((value: string) => string)[]; +} + +/** Keys whose values never leave the browser, matched case-insensitively (`Authorization`, `access_token`, `apiKey`, …). */ +const DENYLIST = + /pass(word|wd)?|secret|token|auth|credential|cookie|api[-_]?key|bearer|jwt|signature|session[-_]?key|ssn/i; + +/** Absolute http(s) URLs appearing anywhere inside a string. */ +const URL_PATTERN = /https?:\/\/[^\s"'<>]+/gi; + +/** + * A root-relative path with a query string or fragment. The lookbehind + * rejects a `/` that's mid-word (so this doesn't re-match inside an + * already-scrubbed URL) without requiring whitespace before the path — + * punctuation like `:` counts as a boundary too. + */ +const PATH_WITH_QUERY = /(?, + RegExp +> = { + // Common email shape, not full RFC 5322. + email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, + // Matches only the dashed `123-45-6789` format; a bare 9-digit run isn't + // reliably an SSN. + ssn: /\b\d{3}-\d{2}-\d{4}\b/g, + ipv4: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, + // NANP (US/Canada): 3-3-4 digits, optional `1`/`+1` country code, + // South Africa (27), Botswana (267), Ghana (233), Kenya + // (254), Mauritius (230), Mozambique (258), Namibia (264), Seychelles + // (248), Tanzania (255), Uganda (256), Zambia (260). + phone: + /(?:(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}|(?:\+|00)(?:27|267|233|254|230|258|264|248|255|256|260)(?:[-.\s]?\d){7,9})\b/g +}; + +/** + * Fixed order for {@link UwtRedactConfig.scanValuePatterns}, independent of + * the consumer's array order. `creditCard` must run before `phone`: phone + * has no leading `\b` and can match a trailing substring of a card number, + * fragmenting and leaking part of it if it ran first. + */ +const VALUE_PATTERN_ORDER: UwtPiiValuePattern[] = [ + 'email', + 'creditCard', + 'ssn', + 'ipv4', + 'phone' +]; + +/** + * Payment card number length, per ISO/IEC 7812 — 13 digits at the short + * end (older Visa) through 19 at the long (Maestro and co.). + */ +const CARD_MIN_DIGITS = 13; +const CARD_MAX_DIGITS = 19; + +/** + * Candidate digit runs, allowing the separators people actually type. + * Built from the bounds above so the pattern and the length check below + * cannot drift apart. + */ +const CREDIT_CARD_CANDIDATE = new RegExp( + `\\b(?:\\d[ -]?){${CARD_MIN_DIGITS},${CARD_MAX_DIGITS}}\\b`, + 'g' +); + +/** The standard Luhn checksum — tells a real card number from an unrelated digit run of the same length. */ +function isLuhnValid(digits: string): boolean { + let sum = 0; + let alternate = false; + + for (let i = digits.length - 1; i >= 0; i--) { + let n = digits.charCodeAt(i) - 48; // '0' + if (alternate) { + n *= 2; + if (n > 9) { + n -= 9; + } + } + sum += n; + alternate = !alternate; + } + + return digits.length > 0 && sum % 10 === 0; +} + +/** Redacts credit-card-shaped digit runs that also pass a Luhn checksum. */ +function scrubCreditCards(value: string): string { + return value.replace(CREDIT_CARD_CANDIDATE, (match) => { + const digits = match.replace(/\D/g, ''); + return digits.length >= CARD_MIN_DIGITS && + digits.length <= CARD_MAX_DIGITS && + isLuhnValid(digits) + ? UWT_REDACTED + : match; + }); +} + +/** + * Memoizes the global copy `ensureGlobal` builds for a non-global pattern — + * config-level regexes are stable across many calls, so without this a + * fresh `RegExp` would be compiled on every scrubbed string, for the life + * of the page. Safe to reuse: every use is via `.replace()`, which resets + * `lastIndex` itself before scanning. + */ +const globalPatternCache = new WeakMap(); + +/** Ensures a pattern has the `g` flag, so `.replace()` catches every occurrence, not just the first. */ +function ensureGlobal(pattern: RegExp): RegExp { + if (pattern.global) { + return pattern; + } + + let global = globalPatternCache.get(pattern); + if (!global) { + global = new RegExp(pattern.source, `${pattern.flags}g`); + globalPatternCache.set(pattern, global); + } + return global; +} + +/** + * Default redaction settings. Conservative; widen as needed. + * + * @group Utils + */ +export const UWT_DEFAULT_REDACT_CONFIG: UwtRedactConfig = { + extraKeyPatterns: [], + maxStringLength: 1024, + maxKeys: 50, + maxStackLength: 2048, + includeStack: true, + stripUrlQuery: true, + scanValuePatterns: [], + extraValuePatterns: [], + extraValueTransforms: [] +}; + +/** + * Effective redact config for a concern's own `redact: boolean` toggle + * (`UwtLogConfig.redact`, `UwtScenarioTelemetryConfig.redact`, `UwtBITelemetryConfig.redact`). + * Disabling it skips only the configurable scrubbing — the built-in + * credential denylist, size caps, error normalization and + * `extraValueTransforms` stay on regardless. + * + * @param config the concern's resolved `UWT_REDACT_CONFIG` + * @param enabled the concern's own `redact` setting + * @returns `config` unchanged when enabled, otherwise with configurable + * scrubbing turned off + * + * @group Utils + */ +export function uwtRedactConfigFor( + config: UwtRedactConfig, + enabled: boolean +): UwtRedactConfig { + if (enabled) { + return config; + } + + return { + ...config, + extraKeyPatterns: [], + stripUrlQuery: false, + scanValuePatterns: [], + extraValuePatterns: [] + }; +} + +function isDenied(key: string, config: UwtRedactConfig): boolean { + if (DENYLIST.test(key)) { + return true; + } + return config.extraKeyPatterns.some((pattern) => { + // A global/sticky pattern is stateful: .test() advances lastIndex, so + // reset it to avoid alternating matches across calls. + pattern.lastIndex = 0; + return pattern.test(key); + }); +} + +/** Reduces a URL to `origin + pathname` — tokens and identifiers tend to live in the query/fragment being dropped. */ +function scrubUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + const cut = url.search(/[?#]/); + return cut === -1 ? url : url.slice(0, cut); + } +} + +/** + * Strips URL query strings/fragments, scans for opted-in PII shapes, runs + * `extraValueTransforms`, then caps length — in that order, so value + * scanning also catches PII embedded in a URL path segment. + * + * @param value the string to clean + * @param config redaction settings + * @param report receives a warning when an `extraValueTransforms` function + * throws and is skipped + * @returns the cleaned, length-capped string + * + * @group Utils + */ +export function uwtScrubString( + value: string, + config: UwtRedactConfig, + report?: UwtReporter +): string { + let result = value; + + if (config.stripUrlQuery) { + result = result.replace(URL_PATTERN, (match) => scrubUrl(match)); + result = result.replace(PATH_WITH_QUERY, (match) => scrubUrl(match)); + } + + if (config.scanValuePatterns.length || config.extraValuePatterns.length) { + for (const name of VALUE_PATTERN_ORDER) { + if (!config.scanValuePatterns.includes(name)) { + continue; + } + result = + name === 'creditCard' + ? scrubCreditCards(result) + : result.replace(VALUE_PATTERNS[name], UWT_REDACTED); + } + for (const pattern of config.extraValuePatterns) { + result = result.replace(ensureGlobal(pattern), UWT_REDACTED); + } + } + + for (const transform of config.extraValueTransforms) { + try { + result = transform(result); + } catch (error) { + uwtReport( + report, + 'warn', + 'An extraValueTransforms function threw and was skipped', + error + ); + } + } + + if (result.length > config.maxStringLength) { + result = `${result.slice(0, config.maxStringLength)}…`; + } + + return result; +} + +/** + * Produces a telemetry-safe attribute bag: flat primitives only (enforcing + * at runtime what {@link UwtTelemetryMetadata} states at compile time), + * denylisted keys redacted, strings scrubbed via {@link uwtScrubString}, + * non-finite numbers and objects/functions/symbols dropped, and the + * attribute count capped (with one warning to `report`). + * + * @param value the candidate attributes + * @param config redaction settings + * @param report receives a warning when attributes are dropped, or a + * transform throws + * @returns a safe attribute bag, or `undefined` when nothing survives + * + * @group Utils + */ +export function uwtRedactMetadata( + value: unknown, + config: UwtRedactConfig = UWT_DEFAULT_REDACT_CONFIG, + report?: UwtReporter +): UwtTelemetryMetadata | undefined { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const result: UwtTelemetryMetadata = {}; + let kept = 0; + let truncated = false; + + for (const [key, raw] of Object.entries(value)) { + if (kept >= config.maxKeys) { + truncated = true; + break; + } + + if (isDenied(key, config)) { + result[key] = UWT_REDACTED; + kept++; + continue; + } + + if (raw === null || typeof raw === 'boolean') { + result[key] = raw; + kept++; + } else if (typeof raw === 'number') { + if (Number.isFinite(raw)) { + result[key] = raw; + kept++; + } + } else if (typeof raw === 'string') { + result[key] = uwtScrubString(raw, config, report); + kept++; + } + } + + if (truncated) { + uwtReport( + report, + 'warn', + `Metadata exceeded maxKeys (${config.maxKeys}) and was truncated. Attributes past the limit were dropped silently in production.` + ); + } + + return kept > 0 ? result : undefined; +} + +/** + * Merges an already-redacted attribute bag into an existing one, + * re-applying `maxKeys` to the *combined* result. `uwtRedactMetadata` only + * bounds the bag passed to it in isolation — merging two independently + * capped bags without this can exceed the configured limit. A key already + * present in `target` is always updated (a replacement, not growth); only + * a genuinely new key counts against the cap, and is dropped once it's + * reached (with one warning to `report`, mirroring `uwtRedactMetadata`). + * + * Mutates and returns `target`. + * + * @param target the accumulator merged into, and returned + * @param incoming the bag being merged in, normally the output of {@link uwtRedactMetadata} + * @param config redaction settings + * @param report receives a warning when attributes are dropped + * @returns `target`, with `incoming` merged in + * + * @group Utils + */ +export function uwtMergeMetadata( + target: UwtTelemetryMetadata, + incoming: UwtTelemetryMetadata | undefined, + config: UwtRedactConfig = UWT_DEFAULT_REDACT_CONFIG, + report?: UwtReporter +): UwtTelemetryMetadata { + if (!incoming) { + return target; + } + + let keyCount = Object.keys(target).length; + let truncated = false; + + for (const [key, value] of Object.entries(incoming)) { + const isNewKey = !(key in target); + if (isNewKey && keyCount >= config.maxKeys) { + truncated = true; + continue; + } + if (isNewKey) { + keyCount++; + } + target[key] = value; + } + + if (truncated) { + uwtReport( + report, + 'warn', + `Combined metadata exceeded maxKeys (${config.maxKeys}) and was truncated. Attributes past the limit were dropped silently in production.` + ); + } + + return target; +} + +/** + * Converts a thrown value into a bounded, telemetry-safe shape — only the + * constructor name, scrubbed message and (optionally) a capped stack; never + * the raw error object. + * + * @param error any thrown value, including non-`Error` values + * @param config redaction settings + * @param report receives a warning when an `extraValueTransforms` function + * throws and is skipped + * @returns the normalized error, or `undefined` when there is nothing to report + * + * @group Utils + */ +export function uwtNormalizeError( + error: unknown, + config: UwtRedactConfig = UWT_DEFAULT_REDACT_CONFIG, + report?: UwtReporter +): UwtTelemetryError | undefined { + if (error === null || error === undefined) { + return undefined; + } + + if (error instanceof Error) { + const normalized: UwtTelemetryError = { + name: error.name || 'Error', + message: uwtScrubString(error.message ?? '', config, report) + }; + + if (config.includeStack && typeof error.stack === 'string') { + normalized.stack = uwtScrubString( + error.stack, + { ...config, maxStringLength: config.maxStackLength }, + report + ); + } + + return normalized; + } + + if (typeof error === 'string') { + return { name: 'Error', message: uwtScrubString(error, config, report) }; + } + + if (isHttpErrorShaped(error)) { + // HttpErrorResponse doesn't extend Error, so instanceof above misses it. + return { + name: typeof error.name === 'string' ? error.name : 'HttpErrorResponse', + message: uwtScrubString(error.message, config, report) + }; + } + + return { + name: typeof error === 'object' ? 'UnknownError' : typeof error, + message: UWT_REDACTED + }; +} + +/** Duck-types an `HttpErrorResponse`-shaped object, Angular's HTTP error, without importing a framework just for this check. */ +function isHttpErrorShaped( + error: unknown +): error is { name?: unknown; message: string; status: number } { + if (error === null || typeof error !== 'object') { + return false; + } + const candidate = error as Record; + return ( + typeof candidate.message === 'string' && + typeof candidate.status === 'number' && + ('statusText' in candidate || 'url' in candidate) + ); +} diff --git a/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts new file mode 100644 index 0000000..1998b57 --- /dev/null +++ b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts @@ -0,0 +1,334 @@ +/** + * @jest-environment jsdom + */ +import { + uwtDeepClone, + uwtElapsedNow, + uwtEpochToPerf, + uwtNow, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync, + uwtUuid +} from './uwt-telemetry-safe.util'; + +describe('uwtSafe', () => { + let report: jest.Mock; + + beforeEach(() => { + report = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return the result when the operation succeeds', () => { + expect(uwtSafe('op', () => 42, 0, report)).toBe(42); + expect(report).not.toHaveBeenCalled(); + }); + + it('should return the fallback instead of throwing', () => { + const result = uwtSafe( + 'op', + () => { + throw new Error('boom'); + }, + 'fallback', + report + ); + expect(result).toBe('fallback'); + }); + + it('should never let a telemetry failure reach the caller', () => { + expect(() => + uwtSafeVoid('op', () => { + throw new Error('boom'); + }) + ).not.toThrow(); + }); + + it('should hand the suppressed error to the reporter, unprefixed', () => { + const error = new Error('boom'); + uwtSafeVoid( + 'scenario.step', + () => { + throw error; + }, + report + ); + expect(report).toHaveBeenCalledWith('error', 'scenario.step failed', error); + }); + + it('should print nothing without a reporter', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + uwtSafeVoid('scenario.step', () => { + throw new Error('boom'); + }); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('should not rethrow when the reporter itself throws', () => { + report.mockImplementation(() => { + throw new Error('reporter is broken'); + }); + expect(() => + uwtSafeVoid( + 'op', + () => { + throw new Error('boom'); + }, + report + ) + ).not.toThrow(); + }); +}); + +describe('uwtSafeVoidMaybeAsync', () => { + let report: jest.Mock; + + beforeEach(() => { + report = jest.fn(); + }); + + it('should behave exactly like uwtSafeVoid for a synchronous function', () => { + expect(() => + uwtSafeVoidMaybeAsync( + 'op', + () => { + throw new Error('boom'); + }, + report + ) + ).not.toThrow(); + expect(report).toHaveBeenCalledWith( + 'error', + 'op failed', + expect.any(Error) + ); + }); + + it('should not throw for a function that returns void', () => { + expect(() => + uwtSafeVoidMaybeAsync('op', () => undefined, report) + ).not.toThrow(); + expect(report).not.toHaveBeenCalled(); + }); + + it('should report a rejection from an async function typed as void', async () => { + const asyncFn = (async () => { + throw new Error('async boom'); + }) as () => void; + + uwtSafeVoidMaybeAsync('logApi.deliver', asyncFn, report); + + await Promise.resolve(); + await Promise.resolve(); + + expect(report).toHaveBeenCalledWith( + 'error', + 'logApi.deliver failed', + expect.any(Error) + ); + }); + + it('should not produce an unhandled rejection for a rejecting async function', async () => { + const asyncFn = (async () => { + throw new Error('unhandled if unguarded'); + }) as () => void; + + expect(() => uwtSafeVoidMaybeAsync('op', asyncFn)).not.toThrow(); + await Promise.resolve(); + await Promise.resolve(); + }); + + it('should not produce a new unhandled rejection when the reporter itself throws', async () => { + const unhandled = jest.fn(); + process.on('unhandledRejection', unhandled); + report.mockImplementation(() => { + throw new Error('reporter is broken'); + }); + const asyncFn = (async () => { + throw new Error('async boom'); + }) as () => void; + + uwtSafeVoidMaybeAsync('op', asyncFn, report); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + process.off('unhandledRejection', unhandled); + expect(report).toHaveBeenCalled(); + expect(unhandled).not.toHaveBeenCalled(); + }); +}); + +describe('uwtDeepClone', () => { + it('should produce a deep copy that mutation of the original does not affect', () => { + const original = { a: 1, nested: { b: [1, 2, 3] } }; + const clone = uwtDeepClone(original); + + original.nested.b.push(4); + + expect(clone).toEqual({ a: 1, nested: { b: [1, 2, 3] } }); + expect(clone).not.toBe(original); + expect(clone.nested).not.toBe(original.nested); + }); + + it('should fall back to a JSON round-trip when structuredClone is unavailable', () => { + const realStructuredClone = globalThis.structuredClone; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).structuredClone = undefined; + + try { + expect(uwtDeepClone({ a: 1, nested: { b: 2 } })).toEqual({ + a: 1, + nested: { b: 2 } + }); + } finally { + globalThis.structuredClone = realStructuredClone; + } + }); + + it('should fall back to a JSON round-trip when structuredClone throws', () => { + const realStructuredClone = globalThis.structuredClone; + globalThis.structuredClone = () => { + throw new Error('cannot clone'); + }; + + try { + expect(uwtDeepClone({ a: 1 })).toEqual({ a: 1 }); + } finally { + globalThis.structuredClone = realStructuredClone; + } + }); +}); + +describe('uwtUuid', () => { + const realCrypto = globalThis.crypto; + + afterEach(() => { + Object.defineProperty(globalThis, 'crypto', { + value: realCrypto, + configurable: true + }); + }); + + const useCrypto = (value: unknown) => + Object.defineProperty(globalThis, 'crypto', { + value, + configurable: true + }); + + it('should produce a UUID-shaped identifier', () => { + expect(uwtUuid()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + }); + + it('should produce distinct identifiers', () => { + const ids = new Set(Array.from({ length: 200 }, () => uwtUuid())); + expect(ids.size).toBe(200); + }); + + it('should fall back to getRandomValues when randomUUID is unavailable', () => { + useCrypto({ + getRandomValues: (array: Uint8Array) => { + for (let i = 0; i < array.length; i++) { + array[i] = i; + } + return array; + } + }); + + expect(uwtUuid()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); + + it('should fall back to Math.random when the Web Crypto API is absent', () => { + useCrypto(undefined); + + expect(uwtUuid()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); +}); + +describe('uwtNow', () => { + it('should return a number that does not go backwards', () => { + const first = uwtNow(); + const second = uwtNow(); + expect(typeof first).toBe('number'); + expect(second).toBeGreaterThanOrEqual(first); + }); +}); + +describe('uwtElapsedNow', () => { + it('should return a number that does not go backwards', () => { + const first = uwtElapsedNow(); + const second = uwtElapsedNow(); + expect(typeof first).toBe('number'); + expect(second).toBeGreaterThanOrEqual(first); + }); + + it('should read the same object jsdom exposes as globalThis.top', () => { + expect(globalThis.top).toBe(globalThis); + }); +}); + +describe('uwtEpochToPerf', () => { + const realPerformance = globalThis.performance; + + afterEach(() => { + Object.defineProperty(globalThis, 'performance', { + value: realPerformance, + configurable: true + }); + }); + + function usePerformance(timeOrigin: number, now: number) { + Object.defineProperty(globalThis, 'performance', { + value: { timeOrigin, now: () => now }, + configurable: true + }); + } + + it('should convert an epoch timestamp into the performance timeline', () => { + usePerformance(1_000_000, 5_000); + expect(uwtEpochToPerf(1_002_000)).toBe(2_000); + }); + + it('should accept the exact page origin', () => { + usePerformance(1_000_000, 5_000); + expect(uwtEpochToPerf(1_000_000)).toBe(0); + }); + + it('should reject a timestamp from before the page loaded', () => { + usePerformance(1_000_000, 5_000); + expect(uwtEpochToPerf(999_000)).toBeUndefined(); + }); + + it('should reject a timestamp in the future', () => { + usePerformance(1_000_000, 5_000); + expect(uwtEpochToPerf(1_010_000)).toBeUndefined(); + }); + + it.each([[NaN], [Infinity], [-Infinity]])( + 'should reject the non-finite input %p', + (input) => { + usePerformance(1_000_000, 5_000); + expect(uwtEpochToPerf(input)).toBeUndefined(); + } + ); + + it('should return undefined without a usable performance object', () => { + Object.defineProperty(globalThis, 'performance', { + value: undefined, + configurable: true + }); + expect(uwtEpochToPerf(1_000)).toBeUndefined(); + }); +}); diff --git a/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts new file mode 100644 index 0000000..0f0aced --- /dev/null +++ b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts @@ -0,0 +1,253 @@ +/** + * What a report is about: + * + * - `'error'`: an operation threw, and was skipped; + * - `'warn'`: data was dropped or altered, or telemetry degraded; + * - `'config'`: something cannot work as configured, and stays off — worth + * showing in production too. + * + * @group Types + */ +export type UwtReportLevel = 'error' | 'warn' | 'config'; + +/** + * Where telemetry reports what it otherwise handles silently. Telemetry never + * throws into the application, so this is the only place such problems show. + * + * Called with console-style arguments: an unprefixed message, then any + * details, such as the error. Nothing but `'config'` problems is reported + * without one, so pass it where problems should be seen, typically in + * development: + * + * ```typescript + * const report: UwtReporter = (level, message, ...details) => + * console[level === 'error' ? 'error' : 'warn'](`[my-app] ${message}`, ...details); + * ``` + * + * A reporter that throws is ignored. + * + * @group Types + */ +export type UwtReporter = ( + level: UwtReportLevel, + message: string, + ...details: unknown[] +) => void; + +/** + * Prints to the console, prefixed: where a `'config'` problem goes when no + * reporter is given, as something that cannot work is never silent. + */ +export const uwtConsoleReporter: UwtReporter = (level, message, ...details) => + console[level === 'error' ? 'error' : 'warn']( + `[ui-watchtower] ${message}`, + ...details + ); + +/** + * Passes a report to `report`, if there is one. A reporter that throws, or + * a patched console it writes to, never reaches the caller. + */ +export function uwtReport( + report: UwtReporter | undefined, + level: UwtReportLevel, + message: string, + ...details: unknown[] +): void { + if (!report) { + return; + } + try { + report(level, message, ...details); + } catch { + // A throwing reporter must never escape telemetry suppression. + } +} + +/** + * Runs a telemetry operation without letting it affect the application. + * + * Every public entry point routes through this wrapper — it never rethrows. + * The caught error goes to `report`, if given. + */ +export function uwtSafe( + operation: string, + fn: () => T, + fallback: T, + report?: UwtReporter +): T { + try { + return fn(); + } catch (error) { + uwtReport(report, 'error', `${operation} failed`, error); + return fallback; + } +} + +/** + * Void-returning form of {@link uwtSafe}, for the common case of an emit call + * whose result nobody reads. + */ +export function uwtSafeVoid( + operation: string, + fn: () => void, + report?: UwtReporter +): void { + uwtSafe(operation, fn, undefined, report); +} + +/** + * Like {@link uwtSafeVoid}, but also catches a rejection from a `void`-typed + * extension point whose real implementation turns out to be `async` — an + * application-supplied provider hook, for instance. TypeScript accepts an + * `async` function wherever `() => void` is expected and discards the + * promise, so a plain `try`/`catch` can't see it reject. This checks for a + * thenable result and attaches a rejection handler. + * + * Not the default for every call: most `uwtSafeVoid` calls wrap this + * library's own synchronous code, where the extra check is pure overhead. + * Use this only for a function whose shape this library merely declares. + */ +export function uwtSafeVoidMaybeAsync( + operation: string, + fn: () => void, + report?: UwtReporter +): void { + uwtSafe( + operation, + () => { + const result: unknown = fn(); + if ( + result !== null && + typeof result === 'object' && + typeof (result as { then?: unknown }).then === 'function' + ) { + Promise.resolve(result as PromiseLike).catch((error) => + uwtReport(report, 'error', `${operation} failed`, error) + ); + } + }, + undefined, + report + ); +} + +/** + * Deep-clones a plain-data value. Prefers `structuredClone`; falls back to + * a JSON round-trip where it's unavailable (e.g. under jsdom) or throws. + */ +export function uwtDeepClone(value: T): T { + if (typeof structuredClone === 'function') { + try { + return structuredClone(value); + } catch { + // fall through to the JSON fallback + } + } + return JSON.parse(JSON.stringify(value)) as T; +} + +/** + * Fills a byte array from the best available source. `crypto.getRandomValues` + * works on plain http:// too, where `crypto.randomUUID` doesn't. `Math.random` + * is the last-resort fallback for an environment with no Web Crypto at all. + */ +function randomBytes(count: number): Uint8Array { + const bytes = new Uint8Array(count); + const cryptoRef = globalThis.crypto; + + if (typeof cryptoRef?.getRandomValues === 'function') { + return cryptoRef.getRandomValues(bytes); + } + + for (let i = 0; i < count; i++) { + bytes[i] = (Math.random() * 256) | 0; + } + return bytes; +} + +/** Lowercase hex encoding, two characters per byte. */ +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Generates a correlation identifier. Prefers `crypto.randomUUID`, falling + * back to formatting random bytes as a v4 UUID. + * + * @group Utils + */ +export function uwtUuid(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + const bytes = randomBytes(16); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + + const hex = toHex(bytes); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20) + ].join('-'); +} + +/** + * Monotonic-ish millisecond clock for duration measurement. + * `performance.now()` is preferred since it's immune to wall-clock + * adjustments. + */ +export function uwtNow(): number { + const perf = globalThis.performance; + return typeof perf?.now === 'function' ? perf.now() : Date.now(); +} + +/** + * Milliseconds since the *host* page loaded — prefers `top.performance.now()` + * over the local realm's own, the same reasoning `uwt-user-timings-internal.util.ts` + * applies to marks/measures: in a composed page, a fragment runs in its own + * same-origin iframe with a later `timeOrigin`, so a timeline position taken + * from its own `performance` doesn't line up against the shell's. Reading + * `top` always succeeds, even cross-origin; only touching a property on it + * can throw, in which case this falls back to {@link uwtNow}. + */ +export function uwtElapsedNow(): number { + try { + const top = globalThis.top; + if (typeof top?.performance?.now === 'function') { + return top.performance.now(); + } + } catch { + // cross-origin, or sandboxed without `allow-same-origin` + } + return uwtNow(); +} + +/** + * Converts an epoch timestamp into the `performance.now()` timeline. + * Clamped to the current page's lifetime — a future timestamp, or one from + * before the page loaded, yields `undefined`. + */ +export function uwtEpochToPerf(epochMs: number): number | undefined { + if (!Number.isFinite(epochMs)) { + return undefined; + } + + const perf = globalThis.performance; + if (typeof perf?.now !== 'function' || typeof perf.timeOrigin !== 'number') { + return undefined; + } + + const converted = epochMs - perf.timeOrigin; + const now = perf.now(); + + if (converted < 0 || converted > now) { + return undefined; + } + + return converted; +} diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.spec.ts b/projects/ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.spec.ts similarity index 99% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.spec.ts rename to projects/ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.spec.ts index e5d3a97..ae49d66 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.spec.ts +++ b/projects/ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.spec.ts @@ -1,3 +1,6 @@ +/** + * @jest-environment jsdom + */ import { uwtClearMarks, uwtMark, diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.ts rename to projects/ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.ts diff --git a/projects/ui-watchtower/src/public-api.spec.ts b/projects/ui-watchtower/src/public-api.spec.ts new file mode 100644 index 0000000..c7fa8b2 --- /dev/null +++ b/projects/ui-watchtower/src/public-api.spec.ts @@ -0,0 +1,54 @@ +import * as core from './public-api'; +import { + UwtLogRecord, + UwtNoopTelemetrySink, + uwtCreateTelemetry +} from './public-api'; + +/** + * The core must import, and run, where there is no browser: in Node, on a + * server, in a worker. This spec runs in Jest's Node environment, with no + * `window`, `document` or `localStorage`. + */ +describe('the core outside a browser', () => { + it('should import without browser globals', () => { + expect(typeof window).toBe('undefined'); + expect(typeof document).toBe('undefined'); + expect(typeof localStorage).toBe('undefined'); + expect(Object.keys(core)).toContain('uwtCreateTelemetry'); + }); + + it('should keep no session start, and not throw, without storage', () => { + expect(() => core.uwtMarkSessionStart('s1', 1_000)).not.toThrow(); + expect(core.uwtSessionElapsed('s1', 2_000)).toBeUndefined(); + }); + + it('should run scenarios, BI events and logging, and dispose, with no page', async () => { + const sent: { eventType: string; payload: object }[] = []; + const destination = new (class extends UwtNoopTelemetrySink { + override record(eventType: string, payload: object): void { + sent.push({ eventType, payload }); + } + })(); + const records: UwtLogRecord[] = []; + const telemetry = uwtCreateTelemetry({ + identity: { application: 'worker', environment: 'test', version: '1' }, + destination, + logApi: { + send: (record) => records.push(record), + query: async () => records + } + }); + + telemetry.scenarios.start({ name: 'import' }).step('parse').complete(); + telemetry.bi.track('file_imported', { rows: 3 }); + telemetry.logging.getLogger('import').log('Imported'); + + expect(sent.map((e) => e.eventType)).toEqual([ + 'com.uwt.scenario', + 'com.uwt.bi' + ]); + expect(await telemetry.logging.query()).toHaveLength(1); + expect(() => telemetry.dispose()).not.toThrow(); + }); +}); diff --git a/projects/ui-watchtower/src/public-api.ts b/projects/ui-watchtower/src/public-api.ts new file mode 100644 index 0000000..ffaa60c --- /dev/null +++ b/projects/ui-watchtower/src/public-api.ts @@ -0,0 +1,145 @@ +/* + * Public API Surface of ui-watchtower + * + * The framework-independent engine. Nothing here imports a framework, and + * nothing touches a browser global when imported. + */ + +// The engine +export { uwtCreateTelemetry } from './lib/engine/uwt-telemetry/uwt-telemetry'; +export type { + UwtTelemetry, + UwtTelemetryOptions +} from './lib/engine/uwt-telemetry/uwt-telemetry'; +export { UwtScenarioTelemetry } from './lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry'; +export type { UwtScenarioTelemetryOptions } from './lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry'; +export { UwtBITelemetry } from './lib/engine/uwt-bi-telemetry/uwt-bi-telemetry'; +export type { UwtBITelemetryOptions } from './lib/engine/uwt-bi-telemetry/uwt-bi-telemetry'; +export { UwtLogTelemetry } from './lib/engine/uwt-log-telemetry/uwt-log-telemetry'; +export type { UwtLogTelemetryOptions } from './lib/engine/uwt-log-telemetry/uwt-log-telemetry'; +export type { UwtTelemetryDocument } from './lib/engine/uwt-telemetry-page/uwt-telemetry-page'; + +// Configuration +export { + UWT_DEFAULT_TELEMETRY_CONFIG, + uwtResolveTelemetryConfig +} from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +export type { + UwtTelemetryConfig, + UwtTelemetryConfigOverrides, + UwtTelemetryIdentity +} from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +export type { UwtBITelemetryConfig } from './lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; +export type { UwtLogConfig } from './lib/config/uwt-log.config/uwt-log.config'; +export type { UwtScenarioTelemetryConfig } from './lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; + +// Models, and the name registries an application augments +export type { + UwtBIEvent, + UwtBIEventDetail, + UwtBIEventName, + UwtBIEventNames +} from './lib/models/uwt-bi.models/uwt-bi.models'; +export type { + UwtLogger, + UwtLoggerName, + UwtLoggerNames, + UwtLogDetail, + UwtLogLevel, + UwtLogRecord +} from './lib/models/uwt-log.models/uwt-log.models'; +export { UWT_LOG_LEVEL_ORDER } from './lib/models/uwt-log.models/uwt-log.models'; +export type { + UwtScenarioAggregate, + UwtScenarioName, + UwtScenarioNames, + UwtScenarioOptions, + UwtScenarioOutcome, + UwtScenarioRecord, + UwtScenarioStatus, + UwtScenarioStep, + UwtScenarioStepDetail, + UwtScenarioStepEvent, + UwtScenarioStepStatus, + UwtScenarioSteps, + UwtStepName +} from './lib/models/uwt-scenario.models/uwt-scenario.models'; +export { + UWT_DEFAULT_EVENT_NAMESPACE, + UWT_TELEMETRY_EVENT_TYPE, + uwtEventTypes +} from './lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +export type { + UwtTelemetryAttribution, + UwtTelemetryError, + UwtTelemetryEventTypes, + UwtTelemetryMetadata +} from './lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models'; + +// Scenarios +export { UwtScenario } from './lib/scenario/uwt-scenario/uwt-scenario'; + +// Contracts a destination or a log backend implements, and the explicit +// "nowhere" of each +export { UwtTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +export { UwtNoopTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; +export type { + UwtLogApiProvider, + UwtLogQuery +} from './lib/providers/uwt-log-api.provider/uwt-log-api.provider'; +export { UwtNoopLogApiProvider } from './lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider'; + +// Shell and fragments: a fragment forwards its telemetry and logs over a +// BroadcastChannel to the shell's host, which records them +export { + UWT_DEFAULT_BROADCAST_CHANNEL, + uwtConnectBroadcastChannel +} from './lib/sinks/uwt-broadcast/uwt-broadcast.messages'; +export type { + UwtBroadcastConnection, + UwtBroadcastConnectOptions, + UwtBroadcastMessage +} from './lib/sinks/uwt-broadcast/uwt-broadcast.messages'; +export { UwtBroadcastTelemetrySink } from './lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink'; +export type { UwtBroadcastTelemetrySinkOptions } from './lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink'; +export { + UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS, + UwtBroadcastLogApiProvider +} from './lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; +export type { UwtBroadcastLogApiProviderOptions } from './lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; +export { UwtTelemetryBroadcastHost } from './lib/sinks/uwt-broadcast/uwt-broadcast-host'; +export type { UwtTelemetryBroadcastHostOptions } from './lib/sinks/uwt-broadcast/uwt-broadcast-host'; + +// Utilities an application or a destination needs +export { uwtClassifyTelemetryEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; +export type { UwtTelemetrySinkEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; +export { uwtIsDebugEnabled } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; +export type { UwtDebugFlag } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; +export { + uwtConsoleReporter, + uwtReport, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync, + uwtUuid +} from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +export type { + UwtReporter, + UwtReportLevel +} from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +export { + uwtMarkSessionStart, + uwtSessionElapsed +} from './lib/utils/uwt-session-start.util/uwt-session-start.util'; +export { + UWT_DEFAULT_REDACT_CONFIG, + UWT_REDACTED, + uwtNormalizeError, + uwtRedactConfigFor, + uwtRedactMetadata, + uwtScrubString +} from './lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +export type { + UwtPiiValuePattern, + UwtRedactConfig +} from './lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; diff --git a/projects/ui-watchtower/tsconfig.lib.json b/projects/ui-watchtower/tsconfig.lib.json new file mode 100644 index 0000000..2e0fb23 --- /dev/null +++ b/projects/ui-watchtower/tsconfig.lib.json @@ -0,0 +1,22 @@ +/* + * The core library: plain TypeScript, built by ng-packagr like the Angular + * package, but with no Angular in it. + */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/lib-core", + "rootDir": "./src", + "lib": ["ES2022", "DOM"], + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "sourceMap": true, + "skipLibCheck": true, + "isolatedModules": false, + "types": [], + "paths": {} + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"] +} diff --git a/projects/ui-watchtower/tsconfig.lib.prod.json b/projects/ui-watchtower/tsconfig.lib.prod.json new file mode 100644 index 0000000..2a2faa8 --- /dev/null +++ b/projects/ui-watchtower/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/projects/ui-watchtower/tsconfig.spec.json b/projects/ui-watchtower/tsconfig.spec.json new file mode 100644 index 0000000..0f4f15e --- /dev/null +++ b/projects/ui-watchtower/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec-core", + "rootDir": ".", + "types": ["jest", "node"], + "isolatedModules": false + }, + "include": [ + "src/**/*.spec.ts", + "rxjs/**/*.spec.ts", + "rum/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/projects/ui-watchtower/type-tests/augmented.ts b/projects/ui-watchtower/type-tests/augmented.ts new file mode 100644 index 0000000..d009bdc --- /dev/null +++ b/projects/ui-watchtower/type-tests/augmented.ts @@ -0,0 +1,141 @@ +/** + * Module augmentation as a core consumer writes it: the four registries + * augmented on `@absaoss-cps/ui-watchtower`, with no framework involved. + * Registered names must reach every API that takes or returns a name, and + * anything else must be rejected. + * + * Compiled by `npm run typecheck` on its own, never run. An unused + * `@ts-expect-error` fails the compilation, so a name that stops being + * rejected is caught too. + */ +import { + UwtTelemetry, + UwtBIEvent, + UwtBIEventName, + UwtLogConfig, + UwtLoggerName, + UwtLogQuery, + UwtLogRecord, + UwtScenario, + UwtScenarioAggregate, + UwtScenarioName, + UwtScenarioOptions, + UwtScenarioRecord, + UwtScenarioStep, + UwtScenarioStepEvent, + UwtStepName, + UwtTelemetrySinkEvent +} from '@absaoss-cps/ui-watchtower'; + +declare module '@absaoss-cps/ui-watchtower' { + interface UwtScenarioNames { + checkout: true; + 'load-dashboard': true; + } + interface UwtScenarioSteps { + pay: true; + confirm: true; + } + interface UwtBIEventNames { + export_clicked: true; + } + interface UwtLoggerNames { + checkout: true; + } +} + +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +function expectType(): T | void { + // Checked at compile time only. +} + +// The names are exactly the registered ones. +expectType>(); +expectType>(); +expectType>(); +expectType>(); + +// And they reach every model that carries a name. +expectType>(); +expectType>(); +expectType< + Equals +>(); +expectType< + Equals< + UwtScenarioStep['name'], + UwtStepName | 'scenario-start' | 'scenario-end' + > +>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType< + Equals< + UwtLogConfig['levels'], + Partial> | undefined + > +>(); +expectType< + Equals< + Extract< + UwtTelemetrySinkEvent, + { kind: 'scenario' } + >['payload']['scenarioName'], + UwtScenarioName + > +>(); + +/** Registered steps accepted. */ +export function accepted(scenario: UwtScenario): void { + scenario.step('pay').step('confirm').endStep(); + scenario.aggregateStart('pay').aggregateEnd('pay'); +} + +/** Everything else rejected. */ +export function rejected(scenario: UwtScenario): void { + // @ts-expect-error not a registered step + scenario.step('pai'); + // @ts-expect-error not a registered step + scenario.aggregateStart('pai'); + // @ts-expect-error not a registered step + scenario.aggregateEnd('pai'); +} + +// @ts-expect-error not a registered scenario +export const options: UwtScenarioOptions = { name: 'chekout' }; +// @ts-expect-error not a registered logger +export const query: UwtLogQuery = { logger: 'chekout' }; +// @ts-expect-error not a registered logger +export const levels: UwtLogConfig['levels'] = { chekout: 'log' }; + +/** Through the engine: registered names accepted, everything else rejected. */ +export async function engine(telemetry: UwtTelemetry): Promise { + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + telemetry.scenarios.findByName('load-dashboard'); + telemetry.scenarios.findByNameAndId('checkout', scenario.id); + telemetry.scenarios.onSettled((_record) => { + expectType>(); + }); + telemetry.bi.track('export_clicked'); + telemetry.logging.getLogger('checkout').warn('careful'); + await telemetry.logging.query({ logger: 'checkout' }); + + // @ts-expect-error not a registered scenario + telemetry.scenarios.start({ name: 'chekout' }); + // @ts-expect-error not a registered scenario + telemetry.scenarios.findByName('chekout'); + // @ts-expect-error not a registered business event + telemetry.bi.track('export_clikced'); + // @ts-expect-error not a registered logger + telemetry.logging.getLogger('chekout'); + // @ts-expect-error not a registered logger + await telemetry.logging.query({ logger: 'chekout' }); +} diff --git a/projects/ui-watchtower/type-tests/compat.ts b/projects/ui-watchtower/type-tests/compat.ts new file mode 100644 index 0000000..f98499b --- /dev/null +++ b/projects/ui-watchtower/type-tests/compat.ts @@ -0,0 +1,354 @@ +/** + * The core's public API used the way a framework-free application, or an + * integration, uses it. Compiled by `npm run typecheck` with plain `tsc`, + * never run: no framework is involved anywhere. No registry is augmented + * here, so every name is a plain string; see `augmented.ts` for registered + * names. + */ +import { firstValueFrom, Observable, of } from 'rxjs'; +import { + UwtRumAppMonitorConfig, + UwtRumBootstrap, + UwtRumCredentials, + UwtRumCredentialsProvider, + UwtRumDocument, + UwtRumTelemetrySink, + UwtRumTelemetrySinkOptions +} from '@absaoss-cps/ui-watchtower/rum'; +import { + traceScenario, + UwtTraceScenarioOptions +} from '@absaoss-cps/ui-watchtower/rxjs'; +import { + UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS, + UWT_DEFAULT_BROADCAST_CHANNEL, + UwtBroadcastConnection, + UwtBroadcastConnectOptions, + UwtBroadcastLogApiProvider, + UwtBroadcastLogApiProviderOptions, + UwtBroadcastMessage, + UwtBroadcastTelemetrySink, + UwtBroadcastTelemetrySinkOptions, + uwtConnectBroadcastChannel, + UwtTelemetryBroadcastHost, + UwtTelemetryBroadcastHostOptions, + UWT_DEFAULT_EVENT_NAMESPACE, + UWT_DEFAULT_REDACT_CONFIG, + UWT_DEFAULT_TELEMETRY_CONFIG, + UWT_LOG_LEVEL_ORDER, + UWT_REDACTED, + UWT_TELEMETRY_EVENT_TYPE, + UwtBITelemetry, + UwtBITelemetryConfig, + UwtBITelemetryOptions, + uwtClassifyTelemetryEvent, + uwtCreateTelemetry, + UwtDebugFlag, + uwtEventTypes, + uwtIsDebugEnabled, + UwtLogApiProvider, + UwtLogConfig, + UwtLogLevel, + UwtLogQuery, + UwtLogRecord, + UwtLogTelemetry, + UwtLogTelemetryOptions, + uwtMarkSessionStart, + uwtNormalizeError, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink, + UwtPiiValuePattern, + UwtRedactConfig, + uwtRedactConfigFor, + uwtRedactMetadata, + UwtReporter, + uwtConsoleReporter, + uwtReport, + UwtReportLevel, + uwtSessionElapsed, + uwtResolveTelemetryConfig, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync, + UwtScenario, + UwtScenarioRecord, + UwtScenarioTelemetry, + UwtScenarioTelemetryConfig, + UwtScenarioTelemetryOptions, + uwtScrubString, + UwtTelemetry, + UwtTelemetryConfig, + UwtTelemetryConfigOverrides, + UwtTelemetryDocument, + UwtTelemetryError, + UwtTelemetryIdentity, + UwtTelemetryMetadata, + UwtTelemetryOptions, + UwtTelemetrySink, + UwtTelemetrySinkEvent, + uwtUuid +} from '@absaoss-cps/ui-watchtower'; + +/** A destination the application writes itself. */ +export class AppSink extends UwtTelemetrySink { + readonly sent: UwtTelemetrySinkEvent[] = []; + + record(type: string, payload: object, _meta?: UwtTelemetryMetadata): void { + this.sent.push(uwtClassifyTelemetryEvent(type, payload)); + } + + recordError(_error: UwtTelemetryError, _meta?: UwtTelemetryMetadata): void { + // Sent elsewhere. + } + + getSessionId(): string | undefined { + return undefined; + } + + setUserId(_userId: string | undefined): void { + // Kept elsewhere. + } + + getUserId(): string | undefined { + return undefined; + } + + flush(_beacon?: boolean): void { + // Nothing buffered. + } +} + +/** A log backend. */ +export class AppLogBackend implements UwtLogApiProvider { + readonly ranks: number[] = []; + + send(record: UwtLogRecord): void { + const level: UwtLogLevel = record.level; + this.ranks.push(UWT_LOG_LEVEL_ORDER[level]); + } + + query(_filter: UwtLogQuery): Promise { + return Promise.resolve([]); + } +} + +/** Where the application wants to see what telemetry handles silently. */ +export const report: UwtReporter = (level, message, ...details) => + console[level === 'error' ? 'error' : 'warn'](`[app] ${message}`, ...details); + +export const identity: UwtTelemetryIdentity = { + application: 'app', + environment: 'prod', + version: '1.0.0', + eventNamespace: UWT_DEFAULT_EVENT_NAMESPACE +}; + +export const configs: [ + UwtScenarioTelemetryConfig, + UwtLogConfig, + UwtBITelemetryConfig, + UwtRedactConfig +] = [ + UWT_DEFAULT_TELEMETRY_CONFIG.scenario, + UWT_DEFAULT_TELEMETRY_CONFIG.logs, + UWT_DEFAULT_TELEMETRY_CONFIG.bi, + UWT_DEFAULT_REDACT_CONFIG +]; + +/** A scenario, as an application handles one. */ +export function journey(scenario: UwtScenario): UwtScenarioRecord { + scenario.step('fetch', { rows: 1 }).endStep(); + scenario.complete({ statusCode: 200 }); + return scenario.toRecord(); +} + +/** The utilities an application or a destination calls. */ +export function utilities(): unknown[] { + const flag: UwtDebugFlag = 'debugScenario'; + const redact = uwtRedactConfigFor(UWT_DEFAULT_REDACT_CONFIG, true); + const patterns: UwtPiiValuePattern[] = redact.scanValuePatterns; + uwtMarkSessionStart(uwtUuid()); + return [ + uwtEventTypes(identity.eventNamespace), + UWT_TELEMETRY_EVENT_TYPE.scenario, + uwtIsDebugEnabled(flag), + uwtScrubString('a@b.c', redact, report), + uwtRedactMetadata({ email: 'a@b.c' }, redact, report), + uwtNormalizeError(new Error('x'), redact, report), + patterns, + UWT_REDACTED + ]; +} + +/** The engine, as a framework-free application creates and uses it. */ +export async function engine( + page: UwtTelemetryDocument | null +): Promise { + const overrides: UwtTelemetryConfigOverrides = { + logging: { minLevel: 'warn' }, + redaction: { scanValuePatterns: ['email'] } + }; + const options: UwtTelemetryOptions = { + identity, + destination: new UwtNoopTelemetrySink(), + logApi: new UwtNoopLogApiProvider(), + document: page, + runOutside: (callback) => callback(), + report, + ...overrides + }; + const telemetry: UwtTelemetry = uwtCreateTelemetry(options); + const config: UwtTelemetryConfig = uwtResolveTelemetryConfig(overrides); + + const scenario: UwtScenario = telemetry.scenarios.start({ name: 'checkout' }); + const stop = telemetry.scenarios.onSettled((record: UwtScenarioRecord) => + report('warn', record.scenarioName) + ); + const traced: UwtTraceScenarioOptions = { + outcome: (count) => ({ metadata: { count } }) + }; + const piped: Observable = of(1).pipe( + traceScenario(scenario, traced, report) + ); + await firstValueFrom(piped); + telemetry.bi.track( + 'export_clicked', + { format: 'csv' }, + { scenarioId: scenario.id } + ); + telemetry.logging.getLogger('checkout').warn('careful'); + const lines: UwtLogRecord[] = await telemetry.logging.query({ limit: 1 }); + telemetry.setUserId('user-1'); + telemetry.flush(true); + stop(); + telemetry.dispose(); + uwtSafeVoid('app.cleanup', () => undefined, report); + uwtSafeVoidMaybeAsync('app.async', async () => undefined, report); + return uwtSafe( + 'app.count', + () => lines.length + config.bi.dedupWindowMs, + 0, + report + ); +} + +/** Each concern on its own, as an adapter composes them. */ +export function concerns( + sink: UwtTelemetrySink, + logApi: UwtLogApiProvider +): unknown[] { + const config = uwtResolveTelemetryConfig(); + const shared = { identity, redact: config.redaction, report }; + const scenarioOptions: UwtScenarioTelemetryOptions = { + ...shared, + config: config.scenarios, + sink, + document: null + }; + const biOptions: UwtBITelemetryOptions = { + ...shared, + config: config.bi, + sink + }; + const logOptions: UwtLogTelemetryOptions = { + ...shared, + config: config.logging, + logApi, + sink, + document: null + }; + return [ + new UwtScenarioTelemetry(scenarioOptions), + new UwtBITelemetry(biOptions), + new UwtLogTelemetry(logOptions) + ]; +} + +/** A composed page without a framework: a fragment, and the shell hosting it. */ +export function composedPage( + shellSink: UwtTelemetrySink, + shellLogs: UwtLogApiProvider +): () => void { + const connectOptions: UwtBroadcastConnectOptions = { + channelName: UWT_DEFAULT_BROADCAST_CHANNEL, + open: true, + report + }; + const connection: UwtBroadcastConnection = + uwtConnectBroadcastChannel(connectOptions); + const sinkOptions: UwtBroadcastTelemetrySinkOptions = { + identity, + connection + }; + const logOptions: UwtBroadcastLogApiProviderOptions = { + connection, + runOutside: (callback) => callback(), + runInside: (callback) => callback() + }; + const fragment = uwtCreateTelemetry({ + identity, + destination: new UwtBroadcastTelemetrySink(sinkOptions), + logApi: new UwtBroadcastLogApiProvider(logOptions) + }); + + const hostOptions: UwtTelemetryBroadcastHostOptions = { + sink: shellSink, + logApi: shellLogs, + report + }; + const host = new UwtTelemetryBroadcastHost(hostOptions); + const flush: UwtBroadcastMessage = { kind: 'flush', beacon: true }; + connection.post(flush); + + return () => { + report('warn', `${host.received} of ${UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS}`); + fragment.dispose(); + host.dispose(); + connection.close(); + }; +} + +/** RUM credentials from the application's broker. */ +export class AppRumCredentials implements UwtRumCredentialsProvider { + async load(): Promise { + const config: UwtRumAppMonitorConfig = { + applicationId: 'app-monitor', + region: 'eu-west-1', + applicationVersion: '1.0.0' + }; + const credentials: UwtRumCredentials = { + accessKeyId: 'a', + secretAccessKey: 's', + sessionToken: 't', + expiration: '2026-01-01T00:00:00Z' + }; + return { config, credentials }; + } +} + +/** Sending straight to AWS CloudWatch RUM, without a framework. */ +export async function rum(page: UwtRumDocument | null): Promise { + const options: UwtRumTelemetrySinkOptions = { + identity, + credentialsProvider: new AppRumCredentials(), + document: page, + report: uwtConsoleReporter + }; + const destination = new UwtRumTelemetrySink(options); + await destination.init(); + destination.recordPageView('/customers/:id'); + const telemetry = uwtCreateTelemetry({ + identity, + destination, + logApi: new UwtNoopLogApiProvider() + }); + const level: UwtReportLevel = 'config'; + uwtReport( + report, + level, + 'checked', + uwtSessionElapsed(destination.getSessionId()) + ); + telemetry.dispose(); + destination.dispose(); +} diff --git a/projects/ui-watchtower/type-tests/empty-registry.ts b/projects/ui-watchtower/type-tests/empty-registry.ts new file mode 100644 index 0000000..adc6175 --- /dev/null +++ b/projects/ui-watchtower/type-tests/empty-registry.ts @@ -0,0 +1,28 @@ +/** + * Nothing augmented: every name falls back to `string`, so an application + * that never declares a registry can use any name. + * + * Compiled by `npm run typecheck` with `compat.ts`, never run. It must not + * share a compilation with `augmented.ts`, whose augmentation would apply + * here too. + */ +import { + UwtBIEventName, + UwtLoggerName, + UwtScenarioName, + UwtStepName +} from '@absaoss-cps/ui-watchtower'; + +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +function expectType(): T | void { + // Checked at compile time only. +} + +expectType>(); +expectType>(); +expectType>(); +expectType>(); diff --git a/projects/ui-watchtower/type-tests/tsconfig.augmented.json b/projects/ui-watchtower/type-tests/tsconfig.augmented.json new file mode 100644 index 0000000..87beb04 --- /dev/null +++ b/projects/ui-watchtower/type-tests/tsconfig.augmented.json @@ -0,0 +1,5 @@ +/* The registries augmented, kept apart from the empty-registry fallback. */ +{ + "extends": "./tsconfig.json", + "files": ["augmented.ts"] +} diff --git a/projects/ui-watchtower/type-tests/tsconfig.json b/projects/ui-watchtower/type-tests/tsconfig.json new file mode 100644 index 0000000..95bb3d2 --- /dev/null +++ b/projects/ui-watchtower/type-tests/tsconfig.json @@ -0,0 +1,14 @@ +/* + * Core consumer type tests, compiled against the public entry point by plain + * `tsc`: no framework. `compat.ts` and `empty-registry.ts` share this + * compilation; the augmentation in `augmented.ts` would leak into them, so + * it has its own. + */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "files": ["compat.ts", "empty-registry.ts"] +} diff --git a/release-please-config.json b/release-please-config.json index 2da4450..677b0c1 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,27 +1,32 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "release-type": "node", - "include-component-in-tag": false, - "bootstrap-sha": "ebd38afab87af29f9ee27ecfa0eea90b81978881", + "bootstrap-sha": "1c05bd23b30ddf8105aab40ece85db32750cd2db", + "bump-minor-pre-major": true, + "changelog-path": "CHANGELOG.md", + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "style", "section": "Styles", "hidden": true }, + { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, + { "type": "refactor", "section": "Code Refactoring", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true } + ], "packages": { + "projects/ui-watchtower": { + "package-name": "@absaoss-cps/ui-watchtower", + "component": "ui-watchtower", + "initial-version": "0.1.0" + }, "projects/ngx-ui-watchtower": { - "release-type": "node", "package-name": "@absaoss-cps/ngx-ui-watchtower", - "changelog-path": "CHANGELOG.md", - "bump-minor-pre-major": true, - "changelog-sections": [ - { "type": "feat", "section": "Features" }, - { "type": "fix", "section": "Bug Fixes" }, - { "type": "perf", "section": "Performance Improvements" }, - { "type": "revert", "section": "Reverts" }, - { "type": "docs", "section": "Documentation", "hidden": true }, - { "type": "style", "section": "Styles", "hidden": true }, - { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, - { "type": "refactor", "section": "Code Refactoring", "hidden": true }, - { "type": "test", "section": "Tests", "hidden": true }, - { "type": "build", "section": "Build System", "hidden": true }, - { "type": "ci", "section": "Continuous Integration", "hidden": true } - ] + "component": "ngx-ui-watchtower" } - } + }, + "plugins": [{ "type": "node-workspace" }] } diff --git a/scripts/api-snapshot.mjs b/scripts/api-snapshot.mjs new file mode 100644 index 0000000..8eff298 --- /dev/null +++ b/scripts/api-snapshot.mjs @@ -0,0 +1,230 @@ +/** + * Snapshots the public API of each built package: every export of every + * entry point, with its kind and its members and signatures as the + * TypeScript checker resolves them from the published declarations. How a + * type is built (inheritance, generics) doesn't show; what a consumer sees + * does. + * + * node scripts/api-snapshot.mjs compare with the committed snapshots + * node scripts/api-snapshot.mjs --update write them again + * + * Run after `npm run build`. Update only for an intended API change, and + * review the diff. + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const PACKAGES = [ + { + dist: 'dist/ui-watchtower', + snapshot: 'projects/ui-watchtower/public-api.snapshot.json' + }, + { + dist: 'dist/ngx-ui-watchtower', + snapshot: 'projects/ngx-ui-watchtower/public-api.snapshot.json' + } +]; +const FORMAT = + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType; + +/** Each entry point's import path and declaration file. */ +function entriesOf(dist) { + const manifest = JSON.parse( + readFileSync(join(ROOT, dist, 'package.json'), 'utf8') + ); + return Object.entries(manifest.exports) + .filter(([, target]) => target.types) + .map(([subpath, target]) => ({ + id: manifest.name + subpath.slice(1), + file: join(ROOT, dist, target.types) + })); +} + +const entries = PACKAGES.flatMap((pkg) => + entriesOf(pkg.dist).map((entry) => ({ ...entry, pkg })) +); +const program = ts.createProgram( + entries.map((entry) => entry.file), + { + noEmit: true, + strict: true, + skipLibCheck: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + types: [], + // The packages' imports of each other resolve to what was built. + paths: Object.fromEntries(entries.map((e) => [e.id, [e.file]])) + } +); +const checker = program.getTypeChecker(); +const has = (symbol, flag) => (symbol.flags & flag) !== 0; +const text = (type) => checker.typeToString(type, undefined, FORMAT); + +function modifiers(symbol) { + const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0]; + return declaration ? ts.getCombinedModifierFlags(declaration) : 0; +} + +/** ``, as declared. */ +function typeParameters(symbol) { + const declaration = symbol.declarations?.find((d) => d.typeParameters); + return declaration + ? `<${declaration.typeParameters.map((p) => p.getText()).join(', ')}>` + : ''; +} + +function signatures(type, kind, prefix) { + return checker + .getSignaturesOfType(type, kind) + .map((s) => prefix + checker.signatureToString(s, undefined, FORMAT)); +} + +/** The public and protected members of `type`, one line each, sorted. */ +function members(type, prefix = '') { + const lines = checker.getPropertiesOfType(type).flatMap((member) => { + const flags = modifiers(member); + if ( + flags & ts.ModifierFlags.Private || + member.name.startsWith('#') || + member.name.startsWith('__#') || + member.name === 'prototype' + ) { + return []; + } + const tags = + prefix + + (flags & ts.ModifierFlags.Protected ? 'protected ' : '') + + (flags & ts.ModifierFlags.Abstract ? 'abstract ' : '') + + (flags & ts.ModifierFlags.Readonly ? 'readonly ' : ''); + const optional = has(member, ts.SymbolFlags.Optional) ? '?' : ''; + const memberType = checker.getTypeOfSymbol(member); + if (has(member, ts.SymbolFlags.Method)) { + // An optional method's type includes `undefined`, which has none. + return signatures( + checker.getNonNullableType(memberType), + ts.SignatureKind.Call, + `${tags}${member.name}${optional}` + ); + } + const accessor = [ + has(member, ts.SymbolFlags.GetAccessor) ? 'get' : '', + has(member, ts.SymbolFlags.SetAccessor) ? 'set' : '' + ] + .filter(Boolean) + .join('/'); + return [ + `${tags}${accessor ? accessor + ' ' : ''}${member.name}${optional}: ${text(memberType)}` + ]; + }); + const indexes = checker + .getIndexInfosOfType(type) + .map( + (info) => + `${prefix}${info.isReadonly ? 'readonly ' : ''}[key: ${text(info.keyType)}]: ${text(info.type)}` + ); + return [...lines, ...indexes].sort(); +} + +/** How one export looks to a consumer: its kind, then its shape. */ +function describe(exported) { + const symbol = has(exported, ts.SymbolFlags.Alias) + ? checker.getAliasedSymbol(exported) + : exported; + const typeOnly = (exported.declarations ?? []).some( + (d) => + ts.isExportSpecifier(d) && (d.isTypeOnly || d.parent.parent.isTypeOnly) + ); + const only = + typeOnly && has(symbol, ts.SymbolFlags.Value) ? 'type-only ' : ''; + const generics = typeParameters(symbol); + if (has(symbol, ts.SymbolFlags.Class)) { + const abstract = modifiers(symbol) & ts.ModifierFlags.Abstract; + const statics = checker.getTypeOfSymbol(symbol); + return [ + `${only}${abstract ? 'abstract ' : ''}class${generics}`, + ...signatures(statics, ts.SignatureKind.Construct, 'new '), + ...members(statics, 'static '), + ...members(checker.getDeclaredTypeOfSymbol(symbol)) + ]; + } + if (has(symbol, ts.SymbolFlags.Interface)) { + const type = checker.getDeclaredTypeOfSymbol(symbol); + return [ + `${only}interface${generics}`, + ...signatures(type, ts.SignatureKind.Call, ''), + ...signatures(type, ts.SignatureKind.Construct, 'new '), + ...members(type) + ]; + } + if (has(symbol, ts.SymbolFlags.TypeAlias)) { + const type = checker.getDeclaredTypeOfSymbol(symbol); + return [ + `type${generics} = ${checker.typeToString(type, undefined, FORMAT | ts.TypeFormatFlags.InTypeAlias)}` + ]; + } + if (has(symbol, ts.SymbolFlags.Function)) { + return signatures( + checker.getTypeOfSymbol(symbol), + ts.SignatureKind.Call, + `${only}function` + ); + } + if (has(symbol, ts.SymbolFlags.Variable)) { + const readonly = + ts.getCombinedNodeFlags(symbol.valueDeclaration) & ts.NodeFlags.Const; + return [ + `${only}${readonly ? 'const' : 'let'}: ${text(checker.getTypeOfSymbol(symbol))}` + ]; + } + throw new Error(`Unexpected export kind: ${exported.name}`); +} + +function snapshotOf(entry) { + const source = program.getSourceFile(entry.file); + const module = source && checker.getSymbolAtLocation(source); + if (!module) { + throw new Error(`Cannot read ${entry.file}; run npm run build first`); + } + return Object.fromEntries( + checker + .getExportsOfModule(module) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((exported) => [exported.name, describe(exported)]) + ); +} + +const update = process.argv.includes('--update'); +let failed = false; +for (const pkg of PACKAGES) { + const actual = + JSON.stringify( + Object.fromEntries( + entries + .filter((entry) => entry.pkg === pkg) + .map((entry) => [entry.id, snapshotOf(entry)]) + ), + null, + 2 + ) + '\n'; + const file = join(ROOT, pkg.snapshot); + const expected = existsSync(file) ? readFileSync(file, 'utf8') : ''; + if (update) { + writeFileSync(file, actual); + console.log(`Wrote ${pkg.snapshot}`); + } else if (actual !== expected) { + failed = true; + writeFileSync(`${file}.actual`, actual); + console.error( + `${pkg.snapshot} differs from the build; see ${pkg.snapshot}.actual.\n` + + 'Run npm run api:update only if the API change is intended.' + ); + } else { + console.log(`${pkg.snapshot} matches the build`); + } +} +process.exitCode = failed ? 1 : 0; diff --git a/scripts/publish-packages.mjs b/scripts/publish-packages.mjs new file mode 100644 index 0000000..dda2b61 --- /dev/null +++ b/scripts/publish-packages.mjs @@ -0,0 +1,198 @@ +// Publishes the packages released at the checked-out commit: the core first, +// then the Angular package that depends on it. Run after `npm run build`; it +// publishes the built dist/ directories, never sources or the private root. +// +// A package is published only if its release tag points at this commit and +// its version isn't on npm yet, so a failed run can simply be run again on +// the release tag. Anything npm reports other than "not found" stops the run +// before any later package: a failed core is never followed by its adapter. +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** In publishing order: each package after those it depends on. */ +export const PACKAGES = [ + { + name: '@absaoss-cps/ui-watchtower', + path: 'projects/ui-watchtower', + component: 'ui-watchtower', + dist: 'dist/ui-watchtower' + }, + { + name: '@absaoss-cps/ngx-ui-watchtower', + path: 'projects/ngx-ui-watchtower', + component: 'ngx-ui-watchtower', + dist: 'dist/ngx-ui-watchtower', + dependsOn: '@absaoss-cps/ui-watchtower' + } +]; + +/** + * @param {object} options + * @param {string} options.root the repository root, holding the built dist/ + * @param {(command: string, args: string[], options?: {cwd?: string, inherit?: boolean}) => + * {status: number | null, stdout: string, stderr: string}} options.run runs a command + * @param {(line: string) => void} options.log prints progress + */ +export function publishPackages({ root, run, log }) { + const releases = PACKAGES.map((pkg) => { + const manifest = readBuiltManifest(root, pkg); + const tag = `${pkg.component}-v${manifest.version}`; + return { ...pkg, manifest, version: manifest.version, tag }; + }); + const head = git(run, ['rev-parse', 'HEAD']).trim(); + const here = releases.filter((release) => tagPointsAt(run, release, head)); + if (here.length === 0) { + throw new Error( + `no release tag points at ${head}: run this on a release tag` + ); + } + here.forEach((release) => checkDependencyRange(release, releases)); + + const onNpm = new Set(); + for (const release of releases) { + const label = `${release.name}@${release.version}`; + if (!here.includes(release)) { + log(`${label}: skipped, not released at this commit`); + continue; + } + if (isPublished(run, release.name, release.version)) { + log(`${label}: skipped, already on npm`); + } else { + checkDependencyOnNpm(run, release, releases, onNpm); + const result = run('npm', ['publish'], { + cwd: join(root, release.dist), + inherit: true + }); + if (result.status !== 0) { + throw new Error(`npm publish ${label} failed (exit ${result.status})`); + } + log(`${label}: published`); + } + onNpm.add(release.name); + } +} + +/** The built manifest, which must be the package's own: never the root's. */ +function readBuiltManifest(root, pkg) { + const manifest = JSON.parse( + readFileSync(join(root, pkg.dist, 'package.json'), 'utf8') + ); + if (manifest.name !== pkg.name || manifest.private) { + throw new Error(`${pkg.dist} is not the publishable ${pkg.name}`); + } + return manifest; +} + +function git(run, args) { + const result = run('git', args); + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${firstLine(result)}`); + } + return result.stdout; +} + +/** + * A lightweight tag is listed as its commit; an annotated one as its tag + * object, and as `^{}` the commit it points at, listed only when asked for. + */ +function tagPointsAt(run, release, head) { + const ref = `refs/tags/${release.tag}`; + const listing = git(run, ['ls-remote', '--tags', 'origin', ref, `${ref}^{}`]); + return listing.split('\n').some((line) => line.split('\t')[0] === head); +} + +/** + * The adapter depends on `^` the core it was built against: Release Please's + * node-workspace plugin moves that range with every core release. + */ +function checkDependencyRange(release, releases) { + if (!release.dependsOn) { + return; + } + const dependency = releases.find((other) => other.name === release.dependsOn); + const range = release.manifest.dependencies?.[dependency.name]; + if (range !== `^${dependency.version}`) { + throw new Error( + `${release.name}@${release.version} depends on ${dependency.name}@${range}, ` + + `not on ^${dependency.version}, the version it was built against` + ); + } +} + +function checkDependencyOnNpm(run, release, releases, onNpm) { + const dependency = releases.find((other) => other.name === release.dependsOn); + if ( + dependency && + !onNpm.has(dependency.name) && + !isPublished(run, dependency.name, dependency.version) + ) { + throw new Error( + `${release.name}@${release.version} needs ` + + `${dependency.name}@${dependency.version}, which is not on npm` + ); + } +} + +/** On npm, or not found there; any other answer is an error, never a guess. */ +function isPublished(run, name, version) { + const spec = `${name}@${version}`; + const result = run('npm', [ + 'view', + spec, + 'version', + '--json', + '--prefer-online' + ]); + const output = parseJson(result.stdout); + if (result.status === 0 && output === version) { + return true; + } + if (result.status !== 0 && output?.error?.code === 'E404') { + return false; + } + const reason = output?.error + ? `${output.error.code}: ${output.error.summary}` + : firstLine(result); + throw new Error(`npm view ${spec} failed: ${reason}`); +} + +function parseJson(text) { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +function firstLine({ status, stdout, stderr }) { + return (stderr || stdout).trim().split('\n')[0] || `exit ${status}`; +} + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); + +function run(command, args, { cwd = ROOT, inherit = false } = {}) { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + stdio: inherit ? 'inherit' : 'pipe' + }); + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? String(result.error ?? '') + }; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + publishPackages({ root: ROOT, run, log: (line) => console.log(line) }); + } catch (error) { + console.error(`publish: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/scripts/publish-packages.test.mjs b/scripts/publish-packages.test.mjs new file mode 100644 index 0000000..57bc418 --- /dev/null +++ b/scripts/publish-packages.test.mjs @@ -0,0 +1,350 @@ +// Tests of the publish script, and of how the release workflow and config +// use it. Offline: git and npm are simulated. `npm run test:release`. +import assert from 'node:assert/strict'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { after, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { PACKAGES, publishPackages } from './publish-packages.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const [CORE, NGX] = PACKAGES.map((pkg) => pkg.name); +const HEAD = 'a1b2c3d'; +const CORE_TAG = 'ui-watchtower-v0.6.0'; +const NGX_TAG = 'ngx-ui-watchtower-v0.5.2'; + +const roots = []; +after(() => + roots.forEach((root) => rmSync(root, { recursive: true, force: true })) +); + +/** + * Each package built at its own version: the core at 0.6.0, the Angular + * package at 0.5.2 on `^0.6.0`. `tags` point at HEAD, `annotated` too as + * annotated tags, `elsewhere` at another commit; the registry holds `onNpm`. `failing` makes a command fail: + * `{ 'view ': '', 'publish ': true }`. + */ +function workspace({ + tags = [CORE_TAG, NGX_TAG], + annotated = [], + elsewhere = [], + onNpm = [], + failing = {}, + range = '^0.6.0', + built = {} +} = {}) { + const root = mkdtempSync(join(tmpdir(), 'uwt-publish-')); + roots.push(root); + const manifests = { + [CORE]: { name: CORE, version: '0.6.0' }, + [NGX]: { name: NGX, version: '0.5.2', dependencies: { [CORE]: range } }, + ...built + }; + for (const pkg of PACKAGES) { + mkdirSync(join(root, pkg.dist), { recursive: true }); + writeFileSync( + join(root, pkg.dist, 'package.json'), + JSON.stringify(manifests[pkg.name]) + ); + } + + const registry = new Set(onNpm); + const published = []; + const commands = []; + const ok = (stdout) => ({ status: 0, stdout, stderr: '' }); + const npmError = (code) => ({ + status: 1, + stdout: JSON.stringify({ + error: { code, summary: `${code} from the registry` } + }), + stderr: '' + }); + const run = (command, args, { cwd } = {}) => { + commands.push([command, ...args].join(' ')); + if (command === 'git' && args[0] === 'rev-parse') { + return ok(`${HEAD}\n`); + } + if (command === 'git' && args[0] === 'ls-remote') { + // As git lists them: a lightweight tag as its commit, an annotated one + // as its tag object and then `^{}` its commit; only the refs asked for. + const asked = args.slice(3); + const tag = asked[0].replace('refs/tags/', '').replace(/\^\{\}$/, ''); + const sha = [...tags, ...annotated].includes(tag) + ? HEAD + : elsewhere.includes(tag) + ? 'f00ba47' + : ''; + const listed = !sha + ? [] + : annotated.includes(tag) + ? [ + ['7a9c0b1', `refs/tags/${tag}`], + [sha, `refs/tags/${tag}^{}`] + ] + : [[sha, `refs/tags/${tag}`]]; + return ok( + listed + .filter(([, ref]) => asked.includes(ref)) + .map(([at, ref]) => `${at}\t${ref}\n`) + .join('') + ); + } + if (command === 'npm' && args[0] === 'view') { + const spec = args[1]; + if (failing[`view ${spec}`]) { + return npmError(failing[`view ${spec}`]); + } + return registry.has(spec) + ? ok(JSON.stringify(spec.split('@').pop())) + : npmError('E404'); + } + if (command === 'npm' && args[0] === 'publish') { + const { name, version } = JSON.parse( + readFileSync(join(cwd, 'package.json'), 'utf8') + ); + if (failing[`publish ${name}`]) { + return { status: 1, stdout: '', stderr: '' }; + } + registry.add(`${name}@${version}`); + published.push(name); + return ok(''); + } + throw new Error(`unexpected command: ${command} ${args.join(' ')}`); + }; + const lines = []; + return { + root, + run, + log: (line) => lines.push(line), + published, + commands, + lines + }; +} + +describe('publishing a release', () => { + it('publishes a first release, when the core has never been on npm: the core first', () => { + const release = workspace(); + + publishPackages(release); + + assert.deepEqual(release.published, [CORE, NGX]); + }); + + it('publishes a release of both packages, the core first', () => { + const release = workspace({ onNpm: [`${CORE}@0.5.0`, `${NGX}@0.5.1`] }); + + publishPackages(release); + + assert.deepEqual(release.published, [CORE, NGX]); + assert.deepEqual(release.lines, [ + `${CORE}@0.6.0: published`, + `${NGX}@0.5.2: published` + ]); + }); + + it('retries a partial release: skips the core already on npm, publishes the Angular package', () => { + const release = workspace({ onNpm: [`${CORE}@0.6.0`] }); + + publishPackages(release); + + assert.deepEqual(release.published, [NGX]); + assert.deepEqual(release.lines, [ + `${CORE}@0.6.0: skipped, already on npm`, + `${NGX}@0.5.2: published` + ]); + }); + + it('can run again after a complete release, publishing nothing', () => { + const release = workspace({ onNpm: [`${CORE}@0.6.0`, `${NGX}@0.5.2`] }); + + publishPackages(release); + + assert.deepEqual(release.published, []); + }); + + it('publishes on annotated release tags, as on lightweight ones', () => { + const release = workspace({ tags: [], annotated: [CORE_TAG, NGX_TAG] }); + + publishPackages(release); + + assert.deepEqual(release.published, [CORE, NGX]); + }); + + it('publishes nothing at a commit no release tag points at', () => { + const release = workspace({ tags: [], elsewhere: [CORE_TAG, NGX_TAG] }); + + assert.throws( + () => publishPackages(release), + /no release tag points at a1b2c3d/ + ); + assert.ok(!release.commands.some((command) => command.startsWith('npm'))); + }); + + it('never publishes the Angular package once the core fails to publish', () => { + const release = workspace({ failing: { [`publish ${CORE}`]: true } }); + + assert.throws( + () => publishPackages(release), + /npm publish @absaoss-cps\/ui-watchtower@0\.6\.0 failed/ + ); + assert.deepEqual(release.published, []); + assert.ok(!release.commands.some((command) => command.includes(NGX))); + }); + + it('stops on any npm answer other than not found, taking it for neither published nor missing', () => { + const release = workspace({ failing: { [`view ${CORE}@0.6.0`]: 'E401' } }); + + assert.throws( + () => publishPackages(release), + /npm view @absaoss-cps\/ui-watchtower@0\.6\.0 failed: E401/ + ); + assert.deepEqual(release.published, []); + }); + + it('publishes only the package released at this commit', () => { + const release = workspace({ + tags: [NGX_TAG], + elsewhere: [CORE_TAG], + onNpm: [`${CORE}@0.6.0`] + }); + + publishPackages(release); + + assert.deepEqual(release.published, [NGX]); + assert.equal( + release.lines[0], + `${CORE}@0.6.0: skipped, not released at this commit` + ); + }); + + it('does not publish the Angular package before the core version it needs is on npm', () => { + const release = workspace({ tags: [NGX_TAG], elsewhere: [CORE_TAG] }); + + assert.throws( + () => publishPackages(release), + /needs @absaoss-cps\/ui-watchtower@0\.6\.0, which is not on npm/ + ); + assert.deepEqual(release.published, []); + }); + + it('publishes nothing when the Angular package does not depend on the core it was built against', () => { + const release = workspace({ range: '^0.5.0' }); + + assert.throws( + () => publishPackages(release), + /depends on @absaoss-cps\/ui-watchtower@\^0\.5\.0/ + ); + assert.deepEqual(release.published, []); + }); + + it('publishes nothing from a directory that is not the built package', () => { + const release = workspace({ + built: { [NGX]: { name: NGX, version: '0.5.2', private: true } } + }); + + assert.throws( + () => publishPackages(release), + /dist\/ngx-ui-watchtower is not the publishable/ + ); + assert.deepEqual(release.published, []); + }); +}); + +describe('the release wiring', () => { + const read = (path) => readFileSync(join(ROOT, path), 'utf8'); + const config = JSON.parse(read('release-please-config.json')); + const workflow = read('.github/workflows/release-please.yml'); + const releaseJob = workflow.slice( + workflow.indexOf('\n release-please:'), + workflow.indexOf('\n publish:') + ); + const publishJob = workflow.slice(workflow.indexOf('\n publish:')); + + it('publishes the packages Release Please releases, under the same names and tags', () => { + const manifest = JSON.parse(read('.release-please-manifest.json')); + const plugins = config.plugins.map((plugin) => plugin.type); + + assert.ok( + plugins.includes('node-workspace'), + "node-workspace moves the adapters' dependency on the core" + ); + assert.ok( + !plugins.includes('linked-versions'), + 'each package versioned on its own' + ); + + for (const pkg of PACKAGES) { + assert.equal(config.packages[pkg.path].component, pkg.component); + assert.equal(config.packages[pkg.path]['package-name'], pkg.name); + assert.equal(JSON.parse(read(`${pkg.path}/package.json`)).name, pkg.name); + assert.ok( + pkg.path in manifest || config.packages[pkg.path]['initial-version'], + `${pkg.path} is in the manifest, or has an initial-version` + ); + assert.match(pkg.dist, /^dist\//); + } + assert.equal(JSON.parse(read('package.json')).private, true); + }); + + it('runs Release Please on pushes, and exposes each package release', () => { + assert.match(releaseJob, /if: github\.event_name == 'push'/); + for (const pkg of PACKAGES) { + assert.ok( + releaseJob.includes( + `steps.release.outputs['${pkg.path}--release_created']` + ) + ); + } + }); + + it('publishes after a release of either package, or when run by hand to retry', () => { + const condition = publishJob.slice( + publishJob.indexOf('if:'), + publishJob.indexOf('runs-on:') + ); + const terms = condition.replace(/\s+/g, ' '); + + assert.match(workflow, /\n {2}workflow_dispatch:/); + assert.ok(terms.includes('!cancelled()')); + assert.ok(terms.includes("github.event_name == 'workflow_dispatch'")); + assert.ok( + terms.includes( + "needs.release-please.outputs.core_release_created == 'true'" + ) + ); + assert.ok( + terms.includes( + "needs.release-please.outputs.ngx_release_created == 'true'" + ) + ); + }); + + it('builds, then publishes through this script, with trusted publishing and no cache', () => { + const steps = [ + 'run: npm ci', + 'run: npm run build', + 'run: node scripts/publish-packages.mjs' + ]; + const positions = steps.map((step) => publishJob.indexOf(step)); + + assert.ok( + positions.every((position) => position > 0), + 'every step is there' + ); + assert.deepEqual( + [...positions].sort((a, b) => a - b), + positions + ); + assert.match(publishJob, /id-token: write/); + assert.match(publishJob, /package-manager-cache: false/); + assert.doesNotMatch(publishJob, /npm publish/); + }); +}); diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs new file mode 100644 index 0000000..b77a2a9 --- /dev/null +++ b/scripts/release-check.mjs @@ -0,0 +1,490 @@ +// Replays releases through Release Please on this repository's release config, +// as the release workflow runs it: what each release PR changes, and which +// releases merging it creates. GitHub is simulated in memory; Release Please +// is installed, at the version the workflow's action bundles, into a +// temporary directory, so this needs a network. `npm run release:check`. +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PACKAGES } from './publish-packages.mjs'; + +/** What googleapis/release-please-action@v5 (5.0.0) bundles: keep in step. */ +const RELEASE_PLEASE = '17.6.0'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const WORK = mkdtempSync(join(tmpdir(), 'uwt-release-check-')); +const [CORE, NGX] = PACKAGES; +const read = (path) => readFileSync(join(ROOT, path), 'utf8'); +const config = JSON.parse(read('release-please-config.json')); +const manifest = JSON.parse(read('.release-please-manifest.json')); + +function check(condition, what) { + if (!condition) { + throw new Error(`release-check: expected ${what}`); + } +} + +/** A repository holding `files` on main, its releases, commits (newest first) and merged PRs. */ +function gitHub({ files, releases = [], commits = [], pulls = [] }, Errors) { + const contents = (path) => { + if (!(path in files)) { + throw new Errors.FileNotFoundError(path); + } + return { + sha: path, + content: '', + parsedContent: files[path], + mode: '100644' + }; + }; + const yieldAll = (items) => + async function* () { + yield* items; + }; + return { + repository: { + owner: 'AbsaOSS', + repo: 'ui-watchtower', + defaultBranch: 'main' + }, + getFileContentsOnBranch: async (path) => contents(path), + getFileJson: async (path) => JSON.parse(contents(path).parsedContent), + releaseIterator: yieldAll(releases), + tagIterator: yieldAll( + releases.map((release) => ({ name: release.tagName, sha: release.sha })) + ), + mergeCommitIterator: yieldAll(commits), + pullRequestIterator: yieldAll(pulls), + findFilesByFilenameAndRef: async () => [], + findFilesByGlobAndRef: async () => [] + }; +} + +const release = (tagName, sha) => ({ + name: tagName, + tagName, + sha, + notes: '', + url: '' +}); +const commit = (sha, message, ...files) => ({ sha, message, files }); +const file = (pkg, name = 'src/index.ts') => `${pkg.path}/${name}`; +const json = (files, path) => JSON.parse(files[path]); + +/** + * The release files with each package at its version in `versions` (by + * component), and a dependent on `^` its dependency's: what a release leaves + * behind. + */ +function filesAt(versions) { + const files = { + 'release-please-config.json': read('release-please-config.json'), + '.release-please-manifest.json': JSON.stringify( + Object.fromEntries( + PACKAGES.map((pkg) => [pkg.path, versions[pkg.component]]) + ) + ) + }; + for (const pkg of PACKAGES) { + const packageJson = JSON.parse(read(`${pkg.path}/package.json`)); + packageJson.version = versions[pkg.component]; + if (pkg.dependsOn) { + const dependency = PACKAGES.find((other) => other.name === pkg.dependsOn); + packageJson.dependencies[pkg.dependsOn] = + `^${versions[dependency.component]}`; + } + files[`${pkg.path}/package.json`] = JSON.stringify(packageJson, null, 2); + } + return files; +} + +/** The minor release after `version`: what a `feat` brings before 1.0. */ +function minorAfter(version) { + const [major, minor] = version.split('.'); + return `${major}.${Number(minor) + 1}.0`; +} + +/** The repository after merging `pullRequest`. */ +function merge(files, pullRequest) { + const merged = { ...files }; + for (const { path, createIfMissing, updater } of pullRequest.updates) { + if (path in merged || createIfMissing) { + merged[path] = updater.updateContent(merged[path]); + } + } + return merged; +} + +/** + * Only keys Release Please defines: in its schema, or read by its config + * parser (the schema leaves a few out, `component` and `package-name` among + * them). A misspelt key is in neither, and would be silently ignored. + */ +function checkConfigKeys(configSchema, parser) { + const parsed = [...parser.matchAll(/config\['([\w-]+)'\]/g)].map( + (match) => match[1] + ); + const known = (options, defined, where) => { + const keys = new Set([...Object.keys(defined), ...parsed]); + const unknown = Object.keys(options).filter((key) => !keys.has(key)); + check(unknown.length === 0, `only known keys ${where}, not ${unknown}`); + }; + known(config, configSchema.properties, 'in the config'); + for (const [path, options] of Object.entries(config.packages)) { + known( + options, + configSchema.definitions.ReleaserConfigOptions.properties, + `for ${path}` + ); + } + const plugins = configSchema.allOf[1].properties.plugins.items.anyOf; + for (const plugin of config.plugins) { + const variant = plugins.find((option) => + option.properties?.type?.enum?.includes(plugin.type) + ); + check(variant, `a plugin Release Please defines, not ${plugin.type}`); + const unknown = Object.keys(plugin).filter( + (key) => !(key in variant.properties) + ); + check( + unknown.length === 0, + `only keys the ${plugin.type} plugin defines, not ${unknown}` + ); + } +} + +async function main() { + writeFileSync(join(WORK, 'package.json'), '{ "private": true }'); + execFileSync( + 'npm', + [ + 'install', + `release-please@${RELEASE_PLEASE}`, + '--no-audit', + '--no-fund', + '--loglevel=error' + ], + { cwd: WORK, stdio: 'inherit' } + ); + const require = createRequire(join(WORK, 'package.json')); + const { + Errors, + Manifest, + configSchema, + setLogger + } = require('release-please'); + const parser = readFileSync( + require.resolve('release-please/build/src/manifest.js'), + 'utf8' + ); + const silent = () => {}; + setLogger({ + error: silent, + warn: silent, + info: silent, + debug: silent, + trace: silent + }); + + const manifestOf = (state) => + Manifest.fromManifest( + gitHub(state, Errors), + 'main', + 'release-please-config.json', + '.release-please-manifest.json' + ); + /** The release PR Release Please would open, if any. */ + const pullRequestFor = async (state) => { + const pullRequests = await (await manifestOf(state)).buildPullRequests(); + check( + pullRequests.length <= 1, + `one release PR at most, got ${pullRequests.length}` + ); + return pullRequests[0]; + }; + /** After the PR: each package's version, and the Angular package's core range. */ + const outcome = (files, pullRequest) => { + const merged = merge(files, pullRequest); + const versions = Object.fromEntries( + PACKAGES.map((pkg) => [ + pkg.component, + json(merged, `${pkg.path}/package.json`).version + ]) + ); + const range = json(merged, `${NGX.path}/package.json`).dependencies[ + CORE.name + ]; + return { merged, versions, range }; + }; + /** A package's changelog entry for `version`: from its heading to the next. */ + const entry = (merged, pkg, version) => { + const changelog = merged[`${pkg.path}/CHANGELOG.md`] ?? ''; + const heading = new RegExp( + `^## \\[?${version.replace(/\./g, '\\.')}\\]?[ (]`, + 'm' + ); + const start = changelog.search(heading); + if (start < 0) { + return ''; + } + const end = changelog.indexOf('\n## ', start + 1); + return changelog.slice(start, end < 0 ? undefined : end); + }; + /** The releases merging `pullRequest` creates, as `path tag sha`. */ + const releasesAfter = async (state, pullRequest) => { + const title = pullRequest.title.toString(); + const releases = await ( + await manifestOf({ + files: merge(state.files, pullRequest), + releases: state.releases, + commits: [commit('m1', title), ...state.commits], + pulls: [ + { + headBranchName: pullRequest.headRefName, + baseBranchName: 'main', + number: 1, + title, + body: pullRequest.body.toString(), + labels: pullRequest.labels, + files: [], + sha: 'm1' + } + ] + }) + ).buildReleases(); + return releases + .map((created) => `${created.path} ${created.tag} ${created.sha}`) + .sort(); + }; + /** The releases expected for `[package, version]` pairs. */ + const tagsOf = (...released) => + released + .map(([pkg, version]) => `${pkg.path} ${pkg.component}-v${version} m1`) + .sort(); + /** Each package released at its version in `versions`, then `changes`. */ + const releasedAt = (versions, ...changes) => ({ + files: filesAt(versions), + releases: PACKAGES.map((pkg) => + release(`${pkg.component}-v${versions[pkg.component]}`, 'r1') + ), + commits: [ + ...changes, + commit( + 'r1', + 'chore: release main', + ...PACKAGES.map((pkg) => file(pkg, 'package.json')) + ) + ] + }); + + checkConfigKeys(configSchema, parser); + check( + PACKAGES.every( + (pkg) => + pkg.path in manifest || config.packages[pkg.path]?.['initial-version'] + ), + 'every package in the manifest, or given an initial-version' + ); + check( + !config.plugins.some((plugin) => plugin.type === 'linked-versions'), + 'independent versions: no linked-versions group' + ); + console.log( + '✔ config: only keys Release Please defines; each package versioned on its own' + ); + + // The first release, replayed from the workspace as it is. The core: its + // initial-version until it has a release of its own. The Angular package: + // its last release tagged v (before its tags named the component) + // at bootstrap-sha, which keeps what that release shipped out of the new + // changelog. While git has that tag, bootstrap-sha must be its commit. + const current = manifest[NGX.path]; + const coreFirst = manifest[CORE.path] + ? minorAfter(manifest[CORE.path]) + : config.packages[CORE.path]['initial-version']; + const ngxNext = minorAfter(current); + const lastTagged = spawnSync( + 'git', + ['rev-parse', '--verify', '--quiet', `v${current}^{commit}`], + { cwd: ROOT, encoding: 'utf8' } + ).stdout?.trim(); + if (lastTagged) { + check( + config['bootstrap-sha'] === lastTagged, + `bootstrap-sha to be ${lastTagged}, the commit tagged v${current}` + ); + } + const first = { + files: { + 'release-please-config.json': read('release-please-config.json'), + '.release-please-manifest.json': read('.release-please-manifest.json'), + [`${CORE.path}/package.json`]: read(`${CORE.path}/package.json`), + [`${NGX.path}/package.json`]: read(`${NGX.path}/package.json`), + [`${NGX.path}/CHANGELOG.md`]: read(`${NGX.path}/CHANGELOG.md`) + }, + releases: [release(`v${current}`, config['bootstrap-sha'])], + commits: [ + commit( + 'c3', + 'feat: add the framework-independent engine', + file(CORE), + file(NGX) + ), + commit('c2', 'fix: flush logs on page hide', file(NGX)), + commit('c1', 'feat: document the workspace', 'README.md'), + commit( + config['bootstrap-sha'], + `chore(main): release ${current}`, + file(NGX, 'package.json') + ), + commit('c0', 'feat: a feature already released', file(NGX)) + ] + }; + const firstPullRequest = await pullRequestFor(first); + check(firstPullRequest, 'a release PR for the first release'); + const firstRelease = outcome(first.files, firstPullRequest); + const coreEntry = entry(firstRelease.merged, CORE, coreFirst); + const ngxEntry = entry(firstRelease.merged, NGX, ngxNext); + check( + firstRelease.versions[CORE.component] === coreFirst && + firstRelease.versions[NGX.component] === ngxNext, + `the core at ${coreFirst} and the Angular package at ${ngxNext}, got ${JSON.stringify(firstRelease.versions)}` + ); + check( + firstRelease.range === `^${coreFirst}`, + `the Angular package on ^${coreFirst}, got ${firstRelease.range}` + ); + const firstManifest = json( + firstRelease.merged, + '.release-please-manifest.json' + ); + check( + firstManifest[CORE.path] === coreFirst && + firstManifest[NGX.path] === ngxNext, + `the manifest at ${coreFirst} and ${ngxNext}, got ${JSON.stringify(firstManifest)}` + ); + check( + coreEntry && ngxEntry, + `a ${coreFirst} entry in the core's changelog and a ${ngxNext} one in the Angular package's` + ); + check( + coreEntry.includes('add the framework-independent engine') && + !coreEntry.includes('flush logs on page hide'), + "only the core's own changes in its changelog" + ); + check( + ngxEntry.includes('flush logs on page hide') && + ![coreEntry, ngxEntry].some((text) => text.includes('already released')), + 'nothing from the release before in the new entries' + ); + const firstTags = await releasesAfter(first, firstPullRequest); + const firstExpected = tagsOf([CORE, coreFirst], [NGX, ngxNext]); + check( + JSON.stringify(firstTags) === JSON.stringify(firstExpected), + `merging it to create ${firstExpected.join(', ')}; got ${firstTags.join(', ')}` + ); + console.log( + `✔ first release: the core at ${coreFirst}, the Angular package ${current} → ${ngxNext} on ^${coreFirst}, two changelogs` + + (lastTagged ? `, from v${current}'s commit` : '') + + `; merging creates ${CORE.component}-v${coreFirst} and ${NGX.component}-v${ngxNext}` + ); + + // Later releases, from packages at versions of their own. + const start = { [CORE.component]: '0.6.0', [NGX.component]: '0.5.1' }; + for (const [what, changes, expected] of [ + [ + 'an Angular-only fix releases only the Angular package', + [commit('d1', 'fix: an Angular-only fix', file(NGX))], + { core: '0.6.0', ngx: '0.5.2', released: [NGX] } + ], + [ + 'a core feature releases the core, and the Angular package to move its dependency', + [commit('d1', 'feat: a core feature', file(CORE))], + { core: '0.7.0', ngx: '0.5.2', released: [CORE, NGX] } + ], + [ + 'a breaking core change before 1.0 does the same', + [commit('d1', 'feat!: drop an option', file(CORE))], + { core: '0.7.0', ngx: '0.5.2', released: [CORE, NGX] } + ], + [ + 'changes to both give each its own bump', + [ + commit('d2', 'feat: an Angular feature', file(NGX)), + commit('d1', 'fix: a core fix', file(CORE)) + ], + { core: '0.6.1', ngx: '0.6.0', released: [CORE, NGX] } + ] + ]) { + const state = releasedAt(start, ...changes); + const pullRequest = await pullRequestFor(state); + check(pullRequest, `a release PR: ${what}`); + const { merged, versions, range } = outcome(state.files, pullRequest); + check( + versions[CORE.component] === expected.core && + versions[NGX.component] === expected.ngx, + `${what}: the core at ${expected.core} and the Angular package at ${expected.ngx}, got ${JSON.stringify(versions)}` + ); + check( + range === `^${expected.core}`, + `${what}: the Angular package on ^${expected.core}, got ${range}` + ); + if (expected.released.includes(CORE)) { + const ngxEntry = entry(merged, NGX, expected.ngx); + check( + ngxEntry.includes( + `${CORE.name} bumped from ^${start[CORE.component]} to ^${expected.core}` + ), + `${what}: the dependency update in the Angular package's changelog, got ${JSON.stringify(ngxEntry)}` + ); + } + const tags = await releasesAfter(state, pullRequest); + const tagsExpected = tagsOf( + ...expected.released.map((pkg) => [ + pkg, + pkg === CORE ? expected.core : expected.ngx + ]) + ); + check( + JSON.stringify(tags) === JSON.stringify(tagsExpected), + `${what}: merging to create ${tagsExpected.join(', ')}; got ${tags.join(', ')}` + ); + const core = + expected.core === start[CORE.component] + ? `core unchanged at ${expected.core}` + : `core ${start[CORE.component]} → ${expected.core}`; + console.log( + `✔ ${what}: ${core}, Angular ${start[NGX.component]} → ${expected.ngx} on ^${expected.core}` + ); + } + + const quiet = releasedAt( + start, + commit('e4', 'docs: explain a setting', file(CORE, 'README.md')), + commit('e3', 'refactor: tidy the engine', file(CORE)), + commit('e2', 'test: cover a case', file(NGX, 'src/a.spec.ts')), + commit('e1', 'feat: a workspace script', 'scripts/tool.mjs') + ); + check( + !(await pullRequestFor(quiet)), + 'no release PR without a user-facing change to a package' + ); + console.log( + '✔ no release from docs, refactors or tests, nor from changes outside the packages' + ); +} + +try { + await main(); + console.log('release-check: ok'); +} catch (error) { + console.error( + error.message.startsWith('release-check:') ? error.message : error + ); + process.exitCode = 1; +} finally { + rmSync(WORK, { recursive: true, force: true }); +} diff --git a/scripts/smoke/angular/angular.json b/scripts/smoke/angular/angular.json new file mode 100644 index 0000000..eec8f64 --- /dev/null +++ b/scripts/smoke/angular/angular.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "cli": { "analytics": false }, + "projects": { + "consumer": { + "projectType": "application", + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "server": "src/main.server.ts", + "outputMode": "static", + "index": "src/index.html", + "outputPath": "dist", + "tsConfig": "tsconfig.json" + } + } + } + } + } +} diff --git a/scripts/smoke/angular/src/app/app.config.server.ts b/scripts/smoke/angular/src/app/app.config.server.ts new file mode 100644 index 0000000..1f253e9 --- /dev/null +++ b/scripts/smoke/angular/src/app/app.config.server.ts @@ -0,0 +1,8 @@ +import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; + +export const config: ApplicationConfig = mergeApplicationConfig(appConfig, { + providers: [provideServerRendering(withRoutes(serverRoutes))] +}); diff --git a/scripts/smoke/angular/src/app/app.routes.server.ts b/scripts/smoke/angular/src/app/app.routes.server.ts new file mode 100644 index 0000000..dae0f3f --- /dev/null +++ b/scripts/smoke/angular/src/app/app.routes.server.ts @@ -0,0 +1,5 @@ +import { RenderMode, ServerRoute } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Prerender } +]; diff --git a/scripts/smoke/angular/src/app/app.routes.ts b/scripts/smoke/angular/src/app/app.routes.ts new file mode 100644 index 0000000..9742bb2 --- /dev/null +++ b/scripts/smoke/angular/src/app/app.routes.ts @@ -0,0 +1,4 @@ +import { Routes } from '@angular/router'; +import { Home } from './home'; + +export const routes: Routes = [{ path: '', component: Home }]; diff --git a/scripts/smoke/angular/src/app/app.ts b/scripts/smoke/angular/src/app/app.ts new file mode 100644 index 0000000..8b1e188 --- /dev/null +++ b/scripts/smoke/angular/src/app/app.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet], + template: '' +}) +export class App {} diff --git a/scripts/smoke/angular/src/app/home.ts b/scripts/smoke/angular/src/app/home.ts new file mode 100644 index 0000000..07b0a19 --- /dev/null +++ b/scripts/smoke/angular/src/app/home.ts @@ -0,0 +1,28 @@ +import { Component, inject } from '@angular/core'; +import { + UwtBITelemetryService, + UwtLoggerService, + UwtScenarioTelemetryService +} from '@absaoss-cps/ngx-ui-watchtower'; + +/** Uses every service while it renders — on the server, when prerendered. */ +@Component({ + selector: 'app-home', + template: '

scenario {{ status }}

' +}) +export class Home { + readonly status: string; + + constructor() { + const scenarios = inject(UwtScenarioTelemetryService); + const scenario = scenarios.start({ name: 'render-home' }); + scenario.step('render'); + inject(UwtBITelemetryService).track('home_rendered', { server: true }); + inject(UwtLoggerService).getLogger('smoke').log('Home rendered'); + scenario.complete(); + this.status = scenario.status ?? 'running'; + + // @ts-expect-error not a registered scenario: the augmentation applies + scenarios.findByName('render-hom'); + } +} diff --git a/scripts/smoke/angular/src/index.html b/scripts/smoke/angular/src/index.html new file mode 100644 index 0000000..6e87903 --- /dev/null +++ b/scripts/smoke/angular/src/index.html @@ -0,0 +1,11 @@ + + + + + ui-watchtower smoke + + + + + + diff --git a/scripts/smoke/angular/src/main.server.ts b/scripts/smoke/angular/src/main.server.ts new file mode 100644 index 0000000..eea9a7e --- /dev/null +++ b/scripts/smoke/angular/src/main.server.ts @@ -0,0 +1,11 @@ +import { + BootstrapContext, + bootstrapApplication +} from '@angular/platform-browser'; +import { App } from './app/app'; +import { config } from './app/app.config.server'; + +const bootstrap = (context: BootstrapContext) => + bootstrapApplication(App, config, context); + +export default bootstrap; diff --git a/scripts/smoke/angular/src/main.ts b/scripts/smoke/angular/src/main.ts new file mode 100644 index 0000000..3b1a614 --- /dev/null +++ b/scripts/smoke/angular/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { App } from './app/app'; +import { appConfig } from './app/app.config'; + +bootstrapApplication(App, appConfig).catch((error) => console.error(error)); diff --git a/scripts/smoke/angular/src/telemetry.schema.ts b/scripts/smoke/angular/src/telemetry.schema.ts new file mode 100644 index 0000000..a3510e2 --- /dev/null +++ b/scripts/smoke/angular/src/telemetry.schema.ts @@ -0,0 +1,17 @@ +/** The application's names, as an existing consumer declares them. */ +declare module '@absaoss-cps/ngx-ui-watchtower' { + interface UwtScenarioNames { + 'render-home': true; + } + interface UwtScenarioSteps { + render: true; + } + interface UwtBIEventNames { + home_rendered: true; + } + interface UwtLoggerNames { + smoke: true; + } +} + +export {}; diff --git a/scripts/smoke/angular/tsconfig.json b/scripts/smoke/angular/tsconfig.json new file mode 100644 index 0000000..8b8c5e7 --- /dev/null +++ b/scripts/smoke/angular/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022", "dom"] + }, + "angularCompilerOptions": { + "strictTemplates": true + }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/smoke/angular/variants/fragment.app.config.ts b/scripts/smoke/angular/variants/fragment.app.config.ts new file mode 100644 index 0000000..ee30ffb --- /dev/null +++ b/scripts/smoke/angular/variants/fragment.app.config.ts @@ -0,0 +1,24 @@ +import { + ApplicationConfig, + provideZonelessChangeDetection +} from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { + provideUwtTelemetry, + provideUwtTelemetrySink +} from '@absaoss-cps/ngx-ui-watchtower'; +import { routes } from './app.routes'; + +/** A fragment: no RUM, and aws-rum-web is not installed. */ +export const appConfig: ApplicationConfig = { + providers: [ + provideZonelessChangeDetection(), + provideRouter(routes), + provideUwtTelemetry({ + application: 'smoke', + environment: 'test', + version: '1.0.0' + }), + provideUwtTelemetrySink('broadcast') + ] +}; diff --git a/scripts/smoke/angular/variants/shell.app.config.ts b/scripts/smoke/angular/variants/shell.app.config.ts new file mode 100644 index 0000000..2607f83 --- /dev/null +++ b/scripts/smoke/angular/variants/shell.app.config.ts @@ -0,0 +1,36 @@ +import { + ApplicationConfig, + provideZonelessChangeDetection +} from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { + provideUwtTelemetry, + provideUwtTelemetryBroadcastHost, + UWT_LOG_API_PROVIDER, + UwtNoopLogApiProvider +} from '@absaoss-cps/ngx-ui-watchtower'; +import { + provideUwtTelemetryRumSink, + UWT_RUM_CREDENTIALS_PROVIDER +} from '@absaoss-cps/ngx-ui-watchtower/rum'; +import { routes } from './app.routes'; + +/** A shell: AWS RUM, hosting its fragments. */ +export const appConfig: ApplicationConfig = { + providers: [ + provideZonelessChangeDetection(), + provideRouter(routes), + provideUwtTelemetry({ + application: 'smoke', + environment: 'test', + version: '1.0.0' + }), + provideUwtTelemetryRumSink(), + { + provide: UWT_RUM_CREDENTIALS_PROVIDER, + useValue: { load: async () => null } + }, + { provide: UWT_LOG_API_PROVIDER, useClass: UwtNoopLogApiProvider }, + provideUwtTelemetryBroadcastHost() + ] +}; diff --git a/scripts/smoke/core-node/consumer.mjs b/scripts/smoke/core-node/consumer.mjs new file mode 100644 index 0000000..a3bfc11 --- /dev/null +++ b/scripts/smoke/core-node/consumer.mjs @@ -0,0 +1,79 @@ +/** + * A framework-free Node application using the installed core: no Angular, + * RxJS or aws-rum-web is installed beside it, and there is no browser. + */ +import assert from 'node:assert/strict'; +import { + UWT_TELEMETRY_EVENT_TYPE, + uwtCreateTelemetry, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; + +assert.equal(typeof globalThis.window, 'undefined'); +assert.equal(typeof globalThis.document, 'undefined'); + +/** The application's own destination. */ +class Recording extends UwtTelemetrySink { + events = []; + + record(eventType, payload) { + this.events.push({ eventType, payload }); + } + + recordError() {} + + getSessionId() { + return 'session-1'; + } + + setUserId() {} + + getUserId() { + return undefined; + } + + flush() {} +} + +const destination = new Recording(); +const records = []; +const telemetry = uwtCreateTelemetry({ + identity: { application: 'worker', environment: 'test', version: '1.0.0' }, + destination, + logApi: { + send: (record) => records.push(record), + query: async () => records + } +}); + +const scenario = telemetry.scenarios.start({ + name: 'import', + metadata: { token: 'secret' } +}); +scenario.step('parse'); +scenario.complete(); +telemetry.bi.track('file_imported', { rows: 3 }); +telemetry.logging.getLogger('import').warn('Slow import'); + +assert.deepEqual( + destination.events.map((event) => event.eventType), + [UWT_TELEMETRY_EVENT_TYPE.scenario, UWT_TELEMETRY_EVENT_TYPE.bi] +); +assert.equal(destination.events[0].payload.status, 'success'); +assert.equal(destination.events[0].payload.metadata.token, '[redacted]'); +assert.equal((await telemetry.logging.query()).length, 1); +telemetry.dispose(); + +// The RUM entry point loads aws-rum-web only once a sink starts, which +// without a page it never does: it is not installed here. +const { UwtRumTelemetrySink } = await import('@absaoss-cps/ui-watchtower/rum'); +const rum = new UwtRumTelemetrySink({ + identity: { application: 'worker', environment: 'test', version: '1.0.0' }, + credentialsProvider: { + load: async () => assert.fail('a sink without a page never starts') + } +}); +await rum.init(); +rum.dispose(); + +console.log('core-only Node consumer: ok'); diff --git a/scripts/smoke/core-ts/nodom.ts b/scripts/smoke/core-ts/nodom.ts new file mode 100644 index 0000000..74df43b --- /dev/null +++ b/scripts/smoke/core-ts/nodom.ts @@ -0,0 +1,17 @@ +/** + * A project without the DOM typings — a Node service, a worker — compiles + * against the core's main entry point: its declarations need none. + */ +import { + uwtCreateTelemetry, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink, + UwtTelemetry +} from '@absaoss-cps/ui-watchtower'; + +export const telemetry: UwtTelemetry = uwtCreateTelemetry({ + identity: { application: 'worker', environment: 'test', version: '1.0.0' }, + destination: new UwtNoopTelemetrySink(), + logApi: new UwtNoopLogApiProvider(), + document: null +}); diff --git a/scripts/smoke/smoke.mjs b/scripts/smoke/smoke.mjs new file mode 100644 index 0000000..a37b9d1 --- /dev/null +++ b/scripts/smoke/smoke.mjs @@ -0,0 +1,410 @@ +/** + * Proves the packed packages work outside this workspace — no path alias, no + * source — installed from their tarballs, as an application installs them. + * + * npm run build && npm run smoke (-- --keep keeps the consumers) + * + * 1. Packs both packages and checks what each tarball holds. + * 2. A core-only Node consumer: no Angular, RxJS or aws-rum-web installed. + * 3. A core-only TypeScript consumer: the core's type tests, and a project + * without the DOM typings. + * 4. An unchanged Angular consumer, built and prerendered as a fragment + * without aws-rum-web installed, then as a shell with /rum; and the + * Angular package's type tests. + * + * The Angular package depends on a core version that may not be published + * yet, so every consumer resolves it to the packed core through `overrides`. + * Installs from the registry and npm's cache; writes nothing in the + * workspace. + */ +import { execFileSync } from 'node:child_process'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = fileURLToPath(new URL('../..', import.meta.url)); +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const WORK = mkdtempSync(join(tmpdir(), 'uwt-smoke-')); +const KEEP = process.argv.includes('--keep'); +const workspace = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); + +/** The workspace's own range for a package, so consumers match what it tests. */ +function rangeOf(name) { + const range = + workspace.dependencies?.[name] ?? workspace.devDependencies?.[name]; + check(range, `the workspace declares ${name}`); + return range; +} + +function check(condition, what) { + if (!condition) { + throw new Error(`smoke: expected ${what}`); + } +} + +function run(command, args, cwd) { + return execFileSync(command, args, { + cwd, + encoding: 'utf8', + env: { ...process.env, NG_CLI_ANALYTICS: 'false', CI: 'true' }, + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 64 * 1024 * 1024 + }); +} + +function step(message) { + console.log(`✔ ${message}`); +} + +/** Packs a built package, keeping its manifest and file list. */ +function pack(dist) { + const [packed] = JSON.parse( + run('npm', ['pack', '--json', '--pack-destination', WORK], join(ROOT, dist)) + ); + return { + ...packed, + tarball: join(WORK, packed.filename), + manifest: JSON.parse(readFileSync(join(ROOT, dist, 'package.json'), 'utf8')) + }; +} + +/** What every published tarball must, and must not, hold. */ +function checkTarball(pkg, optionalPeers) { + const files = new Set(pkg.files.map((file) => file.path)); + for (const required of ['README.md', 'LICENSE', 'package.json']) { + check(files.has(required), `${pkg.name} to ship ${required}`); + } + for (const [subpath, target] of Object.entries(pkg.manifest.exports)) { + if (subpath === './package.json') { + continue; + } + for (const file of [target.types, target.default]) { + check( + files.has(file.replace(/^\.\//, '')), + `${pkg.name} to ship ${file} for ${subpath}` + ); + } + } + for (const file of files) { + check( + !/(^|\/)src\//.test(file) && + !/\.spec\.|tsconfig|ng-package\.json/.test(file) && + !(file.endsWith('.ts') && !file.endsWith('.d.ts')), + `${pkg.name} not to ship ${file}` + ); + } + const { manifest } = pkg; + check(!manifest.private, `${pkg.name} not to be private`); + check( + manifest.publishConfig?.access === 'public', + `${pkg.name} to publish publicly` + ); + check(manifest.license === 'Apache-2.0', `${pkg.name} to carry its license`); + for (const [name, range] of Object.entries({ + ...manifest.dependencies, + ...manifest.peerDependencies + })) { + check( + !/^(file|link|workspace|portal):/.test(range), + `${pkg.name} to depend on ${name} by a published version, not ${range}` + ); + } + for (const peer of optionalPeers) { + check( + manifest.peerDependenciesMeta?.[peer]?.optional === true, + `${pkg.name} to declare ${peer} an optional peer` + ); + } +} + +/** A consumer project in its own directory, outside the workspace. */ +function consumer(name, manifest, template) { + const dir = join(WORK, name); + if (template) { + cpSync(join(HERE, template), dir, { recursive: true }); + } else { + mkdirSync(dir); + } + writeFileSync( + join(dir, 'package.json'), + JSON.stringify( + { + name: `uwt-smoke-${name}`, + version: '0.0.0', + private: true, + ...manifest + }, + null, + 2 + ) + ); + return dir; +} + +function install(dir, ...packages) { + run( + 'npm', + [ + 'install', + '--no-audit', + '--no-fund', + '--prefer-offline', + '--loglevel=error', + ...packages + ], + dir + ); +} + +/** Every installed copy of a package, by its directory. */ +function copiesOf(dir, name) { + const nodes = JSON.parse(run('npm', ['query', `#${name}`], dir)); + return new Set(nodes.map((node) => node.path)).size; +} + +/** Compiles a consumer's copy of a package's type tests. */ +function typeTests(dir, from, compiler, extra) { + cpSync(from, join(dir, 'type-tests'), { + recursive: true, + filter: (path) => !path.endsWith('.json') + }); + const base = { extends: '../tsconfig.json', include: [] }; + const configs = { + 'tsconfig.json': { files: ['compat.ts', 'empty-registry.ts'] }, + 'tsconfig.augmented.json': { files: ['augmented.ts'] } + }; + for (const [file, files] of Object.entries(configs)) { + writeFileSync( + join(dir, 'type-tests', file), + JSON.stringify({ + ...base, + compilerOptions: { noEmit: true, ...extra }, + ...files + }) + ); + run( + join(dir, 'node_modules', '.bin', compiler), + ['-p', `type-tests/${file}`], + dir + ); + } +} + +/** + * Builds the Angular consumer as one variant, prerendering its page; returns + * where the RUM sink and the RUM client (aws-rum-web) ended up. + */ +function buildAngular(dir, variant) { + cpSync( + join(dir, 'variants', `${variant}.app.config.ts`), + join(dir, 'src', 'app', 'app.config.ts') + ); + rmSync(join(dir, 'dist'), { recursive: true, force: true }); + run( + 'node', + [join(dir, 'node_modules', '@angular', 'cli', 'bin', 'ng.js'), 'build'], + dir + ); + const browser = join(dir, 'dist', 'browser'); + const page = readFileSync(join(browser, 'index.html'), 'utf8'); + check( + page.includes('scenario success'), + `the ${variant} page prerendered, its scenario run on the server` + ); + const code = Object.fromEntries( + readdirSync(browser) + .filter((file) => file.endsWith('.js')) + .map((file) => [file, readFileSync(join(browser, file), 'utf8')]) + ); + const files = Object.keys(code); + return { + page, + main: code['main.js'] ?? '', + // The sink's default endpoint, and the client's own API path: each in + // its own package only. + sink: files.filter((file) => code[file].includes('dataplane.rum')), + client: files.filter((file) => code[file].includes('appmonitors')) + }; +} + +try { + // 1. The tarballs. + const core = pack('dist/ui-watchtower'); + const ngx = pack('dist/ngx-ui-watchtower'); + checkTarball(core, ['aws-rum-web', 'rxjs']); + checkTarball(ngx, ['aws-rum-web']); + const coreRange = ngx.manifest.dependencies['@absaoss-cps/ui-watchtower']; + check( + coreRange?.replace(/^[\^~]/, '') === core.version, + `the Angular package to depend on the core it was built against (${core.version}), not ${coreRange}` + ); + check( + !ngx.manifest.peerDependencies['@absaoss-cps/ui-watchtower'], + 'the core to be a dependency of the Angular package, not a peer' + ); + step(`packed ${core.filename} and ${ngx.filename}: contents and manifests`); + + const coreSpec = `file:${core.tarball}`; + const overrides = { '@absaoss-cps/ui-watchtower': coreSpec }; + + // 2. A Node application, the core only. + const node = consumer( + 'core-node', + { + type: 'module', + dependencies: { '@absaoss-cps/ui-watchtower': coreSpec } + }, + 'core-node' + ); + install(node); + for (const absent of ['@angular/core', 'rxjs', 'aws-rum-web']) { + check( + !existsSync(join(node, 'node_modules', absent)), + `the core alone not to install ${absent}` + ); + } + run('node', ['consumer.mjs'], node); + step( + 'core-only Node consumer: no Angular, RxJS or aws-rum-web; runs, disposes' + ); + + // 3. A TypeScript application, the core only. + const ts = consumer( + 'core-ts', + { + dependencies: { + '@absaoss-cps/ui-watchtower': coreSpec, + rxjs: rangeOf('rxjs') + }, + devDependencies: { typescript: rangeOf('typescript') } + }, + 'core-ts' + ); + writeFileSync( + join(ts, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + strict: true, + target: 'ES2022', + module: 'ES2022', + moduleResolution: 'bundler', + lib: ['ES2022', 'DOM'], + types: [], + skipLibCheck: false + } + }) + ); + writeFileSync( + join(ts, 'tsconfig.nodom.json'), + JSON.stringify({ + extends: './tsconfig.json', + compilerOptions: { noEmit: true, lib: ['ES2022'] }, + files: ['nodom.ts'] + }) + ); + install(ts); + typeTests(ts, join(ROOT, 'projects', 'ui-watchtower', 'type-tests'), 'tsc'); + run( + join(ts, 'node_modules', '.bin', 'tsc'), + ['-p', 'tsconfig.nodom.json'], + ts + ); + step( + 'core-only TypeScript consumer: type tests, augmentation, no DOM typings' + ); + + // 4. An Angular application, as it was written before the core existed. + const angular = [ + '@angular/common', + '@angular/compiler', + '@angular/core', + '@angular/platform-browser' + ]; + const app = consumer( + 'angular', + { + dependencies: { + ...Object.fromEntries(angular.map((name) => [name, rangeOf(name)])), + '@angular/platform-server': rangeOf('@angular/core'), + '@angular/router': rangeOf('@angular/core'), + '@angular/ssr': rangeOf('@angular/build'), + '@absaoss-cps/ngx-ui-watchtower': `file:${ngx.tarball}`, + rxjs: rangeOf('rxjs'), + tslib: rangeOf('tslib') + }, + devDependencies: { + '@angular/build': rangeOf('@angular/build'), + '@angular/cli': rangeOf('@angular/cli'), + '@angular/compiler-cli': rangeOf('@angular/compiler-cli'), + typescript: rangeOf('typescript') + }, + overrides + }, + 'angular' + ); + install(app); + check(copiesOf(app, '@angular/core') === 1, 'one copy of Angular'); + check( + copiesOf(app, '@absaoss-cps/ui-watchtower') === 1, + 'one copy of the core, installed with the Angular package' + ); + check( + !existsSync(join(app, 'node_modules', 'aws-rum-web')), + 'aws-rum-web not installed for an application without /rum' + ); + const fragment = buildAngular(app, 'fragment'); + check( + !fragment.sink.length && !fragment.client.length, + 'neither the RUM sink nor the RUM client in a build without /rum' + ); + step( + 'Angular fragment without aws-rum-web: built, prerendered, one Angular, one core' + ); + typeTests( + app, + join(ROOT, 'projects', 'ngx-ui-watchtower', 'type-tests'), + 'ngc' + ); + step( + 'Angular consumer type tests: every export, augmentation, compiled by ngc' + ); + + install(app, `aws-rum-web@${rangeOf('aws-rum-web')}`); + const shell = buildAngular(app, 'shell'); + const [client] = shell.client; + check( + shell.client.length === 1 && client !== 'main.js', + `the RUM client in a chunk of its own, not in main.js: ${shell.client}` + ); + check( + shell.main.includes(`import("./${client}")`) && + !shell.main.includes(`from"./${client}"`) && + !shell.page.includes(client), + 'the RUM client loaded on demand only, never up front' + ); + step( + 'Angular shell with /rum: built, prerendered, aws-rum-web loaded on demand' + ); + + if (KEEP) { + console.log(`smoke: consumers kept in ${WORK}`); + } else { + rmSync(WORK, { recursive: true, force: true }); + } + console.log('smoke: ok'); +} catch (error) { + console.error(error.stdout ?? '', error.stderr ?? ''); + console.error(error.message); + console.error(`smoke: consumers kept in ${WORK}`); + process.exitCode = 1; +} diff --git a/tsconfig.json b/tsconfig.json index 9e6331e..131e2f0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,15 @@ "compilerOptions": { "resolveJsonModule": true, "paths": { + "@absaoss-cps/ui-watchtower": [ + "./projects/ui-watchtower/src/public-api.ts" + ], + "@absaoss-cps/ui-watchtower/rxjs": [ + "./projects/ui-watchtower/rxjs/src/public-api.ts" + ], + "@absaoss-cps/ui-watchtower/rum": [ + "./projects/ui-watchtower/rum/src/public-api.ts" + ], "@absaoss-cps/ngx-ui-watchtower": [ "./projects/ngx-ui-watchtower/src/public-api.ts" ], @@ -39,6 +48,8 @@ }, "files": [], "references": [ + { "path": "./projects/ui-watchtower/tsconfig.lib.json" }, + { "path": "./projects/ui-watchtower/tsconfig.spec.json" }, { "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" },