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
-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 @@
-
+
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 @@
-
+
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 @@
-
+
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 @@
+
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 @@
+
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 @@
+
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 @@
-
-
-
+
+
+
@@ -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 @@
+