diff --git a/.eslintrc.js b/.eslintrc.js index 3e9bea3..b699dea 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -67,6 +67,30 @@ module.exports = { } ] } + }, + { + // 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/README.md b/README.md index ce863f1..4380c7b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ How it is designed: [projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watch ```text projects/ui-watchtower/ src/ @absaoss-cps/ui-watchtower — the framework-independent engine + rxjs/ secondary entry point — @absaoss-cps/ui-watchtower/rxjs type-tests/ consumer type tests, without a framework README.md usage guide, published to npm package.json the published package's manifest diff --git a/jest.config.js b/jest.config.js index 75c5452..312acea 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,6 +5,8 @@ module.exports = { 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/ngx-ui-watchtower$': '/projects/ngx-ui-watchtower/src/public-api.ts', '^@absaoss-cps/ngx-ui-watchtower/rum$': diff --git a/package.json b/package.json index 0a3e17d..3d56980 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint": "eslint \"**/*.ts\" --max-warnings=0", "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/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", + "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/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": "npm run build && node scripts/api-snapshot.mjs", "api:update": "npm run build && node scripts/api-snapshot.mjs --update" }, diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index 6ef47bb..e3964e6 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -89,6 +89,19 @@ 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 for nobody; `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: @@ -198,24 +211,51 @@ providers: [ 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. -- **What the core owns, this package re-exports.** The models and name - registries, the configuration types and defaults, redaction, `UwtScenario` - and the `UwtTelemetrySink` and `UwtLogApiProvider` contracts 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. + 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. +- **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. +- **Both packages share one version and release together.** Release Please + links their versions: a change to either releases both, and the same + release PR moves this package's dependency on the core to `^` the new + version. 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. @@ -1591,12 +1631,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. The core prints nothing by itself: it hands a - suppressed error, or data it had to drop, to the `UwtReporter` it is - given. This package gives it one that writes to the console, prefixed + 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. Even a broken `console.error` - cannot turn a suppressed failure into a throw. + 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 @@ -2166,6 +2209,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/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 480bc35..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 { - UwtLogApiProvider, - UwtLogQuery, - UwtLogRecord -} from '@absaoss-cps/ui-watchtower'; +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.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts index 4fe5f43..0d32998 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 @@ -13,6 +13,7 @@ import { UWT_BROADCAST_CHANNEL } from '../../sinks/uwt-broadcast/uwt-broadcast.m import { UwtNoopTelemetrySink } from '../../sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; import { UWT_DEFAULT_TELEMETRY_CONFIG, + uwtResolveTelemetryConfig, UwtBITelemetryConfig, UwtLogConfig, UwtRedactConfig, @@ -60,7 +61,7 @@ export function withLogging( providers: [ { provide: UWT_LOG_CONFIG, - useValue: { ...UWT_DEFAULT_TELEMETRY_CONFIG.logs, ...config } + useValue: uwtResolveTelemetryConfig({ logging: config }).logging } ] }; @@ -81,7 +82,7 @@ export function withScenarios( providers: [ { provide: UWT_SCENARIO_TELEMETRY_CONFIG, - useValue: { ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario, ...config } + useValue: uwtResolveTelemetryConfig({ scenarios: config }).scenarios } ] }; @@ -102,7 +103,7 @@ export function withBIEvents( providers: [ { provide: UWT_BI_TELEMETRY_CONFIG, - useValue: { ...UWT_DEFAULT_TELEMETRY_CONFIG.bi, ...config } + useValue: uwtResolveTelemetryConfig({ bi: config }).bi } ] }; @@ -123,24 +124,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 } ] }; 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 917d5bc..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,512 +1,66 @@ -import { - defer, - EMPTY, - first, - type Observable, - firstValueFrom, - lastValueFrom, - of, - retry, - Subject, - switchMap, - take, - throwError -} from 'rxjs'; +import { isDevMode } from '@angular/core'; +import { of } from 'rxjs'; import { UWT_DEFAULT_REDACT_CONFIG, UWT_DEFAULT_TELEMETRY_CONFIG, - UwtScenario, - UwtScenarioRecord + UwtNoopTelemetrySink, + UwtScenario } from '@absaoss-cps/ui-watchtower'; -import { UwtNoopTelemetrySink } from '../../sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; 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; + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + isDevModeMock.mockReturnValue(true); }); - 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(); - } - }); + afterEach(() => { + jest.restoreAllMocks(); }); - it('should complete scenario with mapped outcome using function argument', (done) => { - of(['item1', 'item2', 'item3']) - .pipe( - traceScenario(scenario, (items) => ({ - metadata: { itemCount: items.length } - })) - ) - .subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith({ - metadata: { itemCount: 3 } - }); - done(); - } - }); - }); - - it('should complete scenario with mapped outcome using options object', (done) => { - of({ status: 200, data: 'ok' }) - .pipe( - traceScenario(scenario, { - outcome: (res) => ({ - statusCode: res.status, - metadata: { result: res.data } - }) - }) - ) - .subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith({ - statusCode: 200, - metadata: { result: 'ok' } - }); - done(); - } - }); - }); - - it('should complete scenario with no outcome when the stream completes with no emissions', (done) => { - EMPTY.pipe( - traceScenario(scenario, () => ({ metadata: { unreachable: true } })) - ).subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith(undefined); - expect(scenario.fail).not.toHaveBeenCalled(); - done(); - } - }); - }); - - it('should fail scenario when the stream errors and re-throw the error', (done) => { - const error = new Error('network down'); - throwError(() => error) - .pipe(traceScenario(scenario)) - .subscribe({ - error: (err) => { - expect(err).toBe(error); - expect(scenario.fail).toHaveBeenCalledWith({ error }); - expect(scenario.complete).not.toHaveBeenCalled(); - done(); - } - }); - }); + it('should report a throwing outcome mapper on the console, prefixed, in development mode', () => { + const traced = scenario(); - it('should still settle the scenario when the outcome mapper throws', (done) => { - const consoleError = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); + of(1).pipe(traceScenario(traced, throwingMapper)).subscribe(); - of('value') - .pipe( - traceScenario(scenario, () => { - throw new Error('mapper is broken'); - }) - ) - .subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith(undefined); - expect(scenario.fail).not.toHaveBeenCalled(); - expect(consoleError).toHaveBeenCalledWith( - expect.stringContaining('failed'), - expect.any(Error) - ); - consoleError.mockRestore(); - done(); - } - }); - }); - - it('should not leak lastValue/hasValue into a resubscription of the same piped observable', (done) => { - let subscriptionCount = 0; - const source = defer(() => { - subscriptionCount++; - return subscriptionCount === 1 ? of('a') : EMPTY; - }); - - const traced$ = source.pipe( - traceScenario(scenario, (value) => ({ metadata: { value } })) + 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 3b731a1..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,38 +1,13 @@ -import { defer } from 'rxjs'; -import type { MonoTypeOperatorFunction, Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import type { MonoTypeOperatorFunction } from 'rxjs'; import type { UwtScenario, UwtScenarioOutcome } from '@absaoss-cps/ui-watchtower'; -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 { + 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} @@ -60,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.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts index fd95a92..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,11 +1,8 @@ import { inject, Injectable } from '@angular/core'; import { - UwtBIEvent, UwtBIEventDetail, UwtBIEventName, - uwtEventTypes, - uwtNow, - uwtRedactConfigFor, + UwtBITelemetry, UwtTelemetryMetadata, UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; @@ -15,15 +12,7 @@ import { 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 { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; -import { - uwtRedactMetadata, - uwtScrubString -} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { - 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, @@ -60,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. @@ -87,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 271e2cd..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,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable, OnDestroy } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { UWT_REDACTED, @@ -18,7 +18,8 @@ import { withRedaction } from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import * as uwtTelemetryRedactUtil from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +// 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'; /** Captures what the library emitted, so a test can assert on it. */ @@ -853,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 707f063..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,14 +1,10 @@ -import { DOCUMENT } from '@angular/common'; import { inject, Injectable, OnDestroy } from '@angular/core'; import { - UWT_LOG_LEVEL_ORDER, - UwtLogDetail, UwtLogger, UwtLoggerName, - UwtLogLevel, UwtLogQuery, UwtLogRecord, - uwtRedactConfigFor, + UwtLogTelemetry, UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; import { @@ -18,26 +14,11 @@ import { } 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 { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; import { - uwtNormalizeError, - 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. * @@ -77,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(); } /** @@ -158,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); } /** @@ -203,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) - }; + return this.logging.getLogger(name); } - - /** 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) - ); - } - - /** 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 0981901..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,4 +1,4 @@ -import { Injectable, NgZone } from '@angular/core'; +import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { UWT_REDACTED, @@ -18,6 +18,7 @@ import { withRedaction, withScenarios } from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; +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'; @@ -2065,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. @@ -2092,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 ccb9798..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,13 +1,11 @@ -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 { - uwtDeepClone, - uwtRedactConfigFor, UwtScenario, UwtScenarioName, UwtScenarioOptions, UwtScenarioRecord, + UwtScenarioTelemetry, UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; import { @@ -17,24 +15,11 @@ import { } 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 { + uwtBrowserDocument, uwtDevModeReporter, - uwtIsBrowser, - uwtIsDevMode, - uwtSafeVoid + uwtRunOutsideZone } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -/** - * 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 * layer measures. @@ -59,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(); @@ -101,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. @@ -122,48 +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, - runOutside: (callback) => - this.zone?.runOutsideAngular(callback) ?? callback(), - report: uwtDevModeReporter, - 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); } /** @@ -173,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); } /** @@ -189,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); } /** @@ -208,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); } /** @@ -218,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-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 017233a..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, - UwtTelemetrySink -} from '@absaoss-cps/ui-watchtower'; +import { UwtNoopTelemetrySink as CoreNoopTelemetrySink } from '@absaoss-cps/ui-watchtower'; /** * Sink that discards everything. @@ -15,37 +11,4 @@ import { * @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-debug-flag.util/uwt-debug-flag.util.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts deleted file mode 100644 index 89a42e5..0000000 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - UwtDebugFlag, - uwtDebugWrite as coreDebugWrite -} from '@absaoss-cps/ui-watchtower'; -import { uwtDevModeReporter } from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; - -/** - * Writes a debug line to the console, but only when `flag` is on, and never - * at the caller's expense: the core's, reporting a failing write through - * {@link uwtDevModeReporter}. - * - * @param flag the debug switch that has to be on - * @param write performs the console call; never runs when the flag is off - * @param name the name to test against a list-valued flag, e.g. a logger - * name for `debugLogger` - */ -export function uwtDebugWrite( - flag: UwtDebugFlag, - write: () => void, - name?: string -): void { - coreDebugWrite(flag, write, name, uwtDevModeReporter); -} 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 57dacc2..103f03e 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,5 +1,5 @@ -import { isPlatformBrowser } from '@angular/common'; -import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { inject, isDevMode, NgZone, PLATFORM_ID } from '@angular/core'; import { UwtReporter, uwtSafe as coreSafe, @@ -24,6 +24,25 @@ export function uwtIsBrowser(): boolean { return isPlatformBrowser(inject(PLATFORM_ID)); } +/** + * The page telemetry watches: the document in a browser, nothing in a + * server-side render. Must be called from an injection context. + */ +export function uwtBrowserDocument(): Document | null { + const document = inject(DOCUMENT); + return uwtIsBrowser() ? document : null; +} + +/** + * 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. + */ +export function uwtRunOutsideZone(): (callback: () => T) => T { + const zone = inject(NgZone, { optional: true }); + return (callback) => zone?.runOutsideAngular(callback) ?? callback(); +} + /** * How this package reports what telemetry handles silently: to the console, * prefixed, and only in development mode. Everything the core does on this diff --git a/projects/ngx-ui-watchtower/src/public-api.ts b/projects/ngx-ui-watchtower/src/public-api.ts index f9078e4..9bac8c6 100644 --- a/projects/ngx-ui-watchtower/src/public-api.ts +++ b/projects/ngx-ui-watchtower/src/public-api.ts @@ -110,7 +110,7 @@ export { UwtNoopTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-noop-telemet export { traceScenario } from './lib/scenario/uwt-scenario-operators/uwt-scenario-operators'; export { uwtClassifyTelemetryEvent } from '@absaoss-cps/ui-watchtower'; export type { UwtTelemetrySinkEvent } from '@absaoss-cps/ui-watchtower'; -export type { UwtTraceScenarioOptions } from './lib/scenario/uwt-scenario-operators/uwt-scenario-operators'; +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'; diff --git a/projects/ngx-ui-watchtower/tsconfig.lib.json b/projects/ngx-ui-watchtower/tsconfig.lib.json index db304db..d8e13ec 100644 --- a/projects/ngx-ui-watchtower/tsconfig.lib.json +++ b/projects/ngx-ui-watchtower/tsconfig.lib.json @@ -16,7 +16,8 @@ // 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": ["../../dist/ui-watchtower"], + "@absaoss-cps/ui-watchtower/rxjs": ["../../dist/ui-watchtower/rxjs"] } }, "include": ["src/**/*.ts"], diff --git a/projects/ngx-ui-watchtower/type-tests/compat.ts b/projects/ngx-ui-watchtower/type-tests/compat.ts index 97cf617..1b2731b 100644 --- a/projects/ngx-ui-watchtower/type-tests/compat.ts +++ b/projects/ngx-ui-watchtower/type-tests/compat.ts @@ -7,7 +7,7 @@ * registry is augmented here, so every name is a plain string; see * `augmented.ts` for registered names. */ -import { ApplicationConfig, inject, Injectable } from '@angular/core'; +import { ApplicationConfig, inject, Injectable, NgModule } from '@angular/core'; import { Observable, of } from 'rxjs'; import { provideUwtTelemetry, @@ -340,3 +340,27 @@ export interface Everything { 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/ui-watchtower/README.md b/projects/ui-watchtower/README.md index a58f076..24a61f0 100644 --- a/projects/ui-watchtower/README.md +++ b/projects/ui-watchtower/README.md @@ -5,20 +5,72 @@ The framework-independent engine behind scenario health telemetry, business/UX events and application logs, for any JavaScript or TypeScript application. -> **Work in progress.** The engine is moving here from the Angular package in -> stages: the models, scenarios, redaction and contracts are here; the engine -> that wires them together is next. Until it lands, use -> `@absaoss-cps/ngx-ui-watchtower`; it installs this package by itself. - It imports no framework and touches no browser global when imported, so it loads in Node, on a server and in a worker as well as in a browser. +> **Work in progress.** The broadcast and AWS RUM destinations are still +> moving here from the Angular package. + ## Install ```bash npm install @absaoss-cps/ui-watchtower +npm install rxjs # only for @absaoss-cps/ui-watchtower/rxjs +``` + +## Use + +Create one engine per application, with its identity, where telemetry goes +and where logs go: + +```ts +import { + uwtCreateTelemetry, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink +} from '@absaoss-cps/ui-watchtower'; + +const telemetry = uwtCreateTelemetry({ + identity: { application: 'cart', environment: 'prod', version: '1.4.0' }, + destination: new UwtNoopTelemetrySink(), // or your own UwtTelemetrySink + logApi: new UwtNoopLogApiProvider(), // or your own UwtLogApiProvider + logging: { minLevel: 'warn' } // optional overrides, per concern +}); + +// Scenarios: a user journey, its steps and outcome. +const scenario = telemetry.scenarios.start({ name: 'checkout' }); +scenario.step('pay'); +scenario.complete({ metadata: { items: 2 } }); + +// BI events. +telemetry.bi.track('export_clicked', { format: 'csv' }); + +// Logs, by named logger. +telemetry.logging.getLogger('checkout').warn('Slow payment'); + +// When the application, or a fragment, goes away. +telemetry.dispose(); ``` +Every call is fail-open: telemetry never throws into your application. +Configuration errors still throw, when the engine is created. + +| Option | What it does | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `identity` | Application, environment and version, carried by every record | +| `destination` | A `UwtTelemetrySink`: where scenarios, BI events and mirrored errors go | +| `logApi` | A `UwtLogApiProvider`: where logs go, and are read back from | +| `scenarios`, `logging`, `bi`, `redaction` | Overrides of each concern's defaults | +| `enabled` | `false` makes everything inert: nothing is recorded, sent or logged | +| `document` | The page whose lifecycle settles and flushes telemetry; the global one by default, `null` for none | +| `report` | Receives what telemetry skips or drops; nothing is reported without one | +| `runOutside` | Schedules timers outside a framework's change detection | + +`dispose()` stops watching the page, settles scenarios still in flight as +abandoned and flushes; the engine does nothing after that, and leaves no +listener or timer behind. The destination and log store you passed are +yours to close. + ## Declare your names Scenario, step, event and logger names are typed, so a typo is a compile @@ -50,9 +102,8 @@ registries: either module path reaches the same ones. ## Seeing what telemetry skips -Telemetry never throws into your application, and the engine prints nothing -by itself. Hand it a `UwtReporter` where you want to see what it skipped or -dropped, typically in development: +The engine prints nothing by itself. Hand it a `UwtReporter` where you want +to see what it skipped or dropped, typically in development: ```ts import { UwtReporter } from '@absaoss-cps/ui-watchtower'; @@ -61,17 +112,31 @@ const report: UwtReporter = (level, message, ...details) => console[level](`[my-app] ${message}`, ...details); ``` +## RxJS + +`traceScenario` settles a scenario from an Observable's outcome. It lives in +its own entry point, so the main one never loads RxJS: + +```ts +import { traceScenario } from '@absaoss-cps/ui-watchtower/rxjs'; + +customers$.pipe(traceScenario(scenario)).subscribe(); +``` + ## API | Group | Exports | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| Configuration | `UwtTelemetryIdentity`, `UwtScenarioTelemetryConfig`, `UwtLogConfig`, `UwtBITelemetryConfig`, `UWT_DEFAULT_TELEMETRY_CONFIG` | +| Engine | `uwtCreateTelemetry`, `UwtTelemetry`, `UwtTelemetryOptions`, `UwtTelemetryDocument` | +| Concerns | `UwtScenarioTelemetry`, `UwtBITelemetry`, `UwtLogTelemetry` and their options, for an adapter composing them itself | +| Configuration | `UwtTelemetryIdentity`, the per-concern config types, `UWT_DEFAULT_TELEMETRY_CONFIG`, `uwtResolveTelemetryConfig` | | Models | scenario, step, BI event and log record types; `UWT_LOG_LEVEL_ORDER`, `UWT_TELEMETRY_EVENT_TYPE`, `uwtEventTypes` | | Names | `UwtScenarioNames`, `UwtScenarioSteps`, `UwtBIEventNames`, `UwtLoggerNames` and their `Uwt*Name` types | | Scenarios | `UwtScenario` | -| Contracts | `UwtTelemetrySink` (a destination), `UwtLogApiProvider` and `UwtLogQuery` (a log backend) | +| Contracts | `UwtTelemetrySink` and `UwtNoopTelemetrySink`; `UwtLogApiProvider`, `UwtLogQuery` and `UwtNoopLogApiProvider` | | Redaction | `uwtRedactMetadata`, `uwtScrubString`, `uwtNormalizeError`, `uwtRedactConfigFor`, `UwtRedactConfig`, `UWT_DEFAULT_REDACT_CONFIG`, `UWT_REDACTED` | -| Utilities | `uwtClassifyTelemetryEvent`, `uwtIsDebugEnabled`, `uwtMarkSessionStart`, `uwtUuid`, `UwtReporter` | +| Utilities | `uwtClassifyTelemetryEvent`, `uwtIsDebugEnabled`, `uwtMarkSessionStart`, `uwtUuid`, `uwtSafe*`, `UwtReporter` | +| `/rxjs` | `traceScenario`, `UwtTraceScenarioOptions` | ## License diff --git a/projects/ui-watchtower/jest.config.js b/projects/ui-watchtower/jest.config.js index 992afc8..05504b1 100644 --- a/projects/ui-watchtower/jest.config.js +++ b/projects/ui-watchtower/jest.config.js @@ -7,18 +7,19 @@ module.exports = { displayName: 'ui-watchtower', rootDir: __dirname, - roots: ['/src'], + roots: ['/src', '/rxjs'], testEnvironment: 'node', transform: { '^.+\\.ts$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] }, moduleNameMapper: { - '^@absaoss-cps/ui-watchtower$': '/src/public-api.ts' + '^@absaoss-cps/ui-watchtower$': '/src/public-api.ts', + '^@absaoss-cps/ui-watchtower/rxjs$': '/rxjs/src/public-api.ts' }, coverageDirectory: '/../../coverage/ui-watchtower', collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.spec.ts', - '!src/**/public-api.ts' + '{src,rxjs}/**/*.ts', + '!**/*.spec.ts', + '!**/public-api.ts' ] }; diff --git a/projects/ui-watchtower/package.json b/projects/ui-watchtower/package.json index bc739c4..4b08307 100644 --- a/projects/ui-watchtower/package.json +++ b/projects/ui-watchtower/package.json @@ -1,6 +1,6 @@ { "name": "@absaoss-cps/ui-watchtower", - "version": "0.1.0", + "version": "0.4.0", "description": "Framework-independent telemetry engine: application logs, scenario health telemetry and business/UX events, for any JavaScript or TypeScript application.", "repository": { "type": "git", @@ -9,13 +9,9 @@ "homepage": "https://github.com/AbsaOSS/ui-watchtower", "license": "Apache-2.0", "peerDependencies": { - "aws-rum-web": "^3.2.1", "rxjs": "^7.8.2" }, "peerDependenciesMeta": { - "aws-rum-web": { - "optional": true - }, "rxjs": { "optional": true } diff --git a/projects/ui-watchtower/public-api.snapshot.json b/projects/ui-watchtower/public-api.snapshot.json index 1989156..b38bf28 100644 --- a/projects/ui-watchtower/public-api.snapshot.json +++ b/projects/ui-watchtower/public-api.snapshot.json @@ -40,24 +40,36 @@ "UwtBIEventNames": [ "interface" ], + "UwtBITelemetry": [ + "class", + "new (options: UwtBITelemetryOptions): UwtBITelemetry", + "dispose(): void", + "track(eventName: string, metadata?: UwtTelemetryMetadata | undefined, detail?: UwtBIEventDetail | undefined): void" + ], "UwtBITelemetryConfig": [ "interface", "dedupMaxKeys: number", "dedupWindowMs: number", "redact: boolean" ], + "UwtBITelemetryOptions": [ + "interface", + "config: UwtBITelemetryConfig", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "redact: UwtRedactConfig", + "report?: UwtReporter | undefined", + "sink: UwtTelemetrySink" + ], "uwtClassifyTelemetryEvent": [ "function(eventType: string, payload: object): UwtTelemetrySinkEvent" ], + "uwtCreateTelemetry": [ + "function(options: UwtTelemetryOptions): UwtTelemetry" + ], "UwtDebugFlag": [ "type = 'debugLogger' | 'debugScenario' | 'debugBI'" ], - "uwtDebugWrite": [ - "function(flag: UwtDebugFlag, write: () => void, name?: string | undefined, report?: UwtReporter | undefined): void" - ], - "uwtDeepClone": [ - "function(value: T): T" - ], "uwtEventTypes": [ "function(namespace?: string | undefined): UwtTelemetryEventTypes" ], @@ -124,15 +136,46 @@ "userId?: string | undefined", "version: string" ], + "UwtLogTelemetry": [ + "class", + "new (options: UwtLogTelemetryOptions): UwtLogTelemetry", + "dispose(): void", + "getLogger(name: string): UwtLogger", + "query(filter?: UwtLogQuery | undefined): Promise" + ], + "UwtLogTelemetryOptions": [ + "interface", + "config: UwtLogConfig", + "document?: UwtTelemetryDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "logApi: UwtLogApiProvider", + "redact: UwtRedactConfig", + "report?: UwtReporter | undefined", + "sink?: UwtTelemetrySink | null | undefined" + ], "uwtMarkSessionStart": [ "function(sessionId: string, now?: number | undefined): void" ], + "UwtNoopLogApiProvider": [ + "class", + "new (): UwtNoopLogApiProvider", + "query(_filter: UwtLogQuery): Promise", + "send(_record: UwtLogRecord): void" + ], + "UwtNoopTelemetrySink": [ + "class", + "new (): UwtNoopTelemetrySink", + "flush(_beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "record(_eventType: string, _payload: object, _metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(_error: UwtTelemetryError, _metadata?: UwtTelemetryMetadata | undefined): void", + "setUserId(userId: string | undefined): void" + ], "uwtNormalizeError": [ "function(error: unknown, config?: UwtRedactConfig | undefined, report?: UwtReporter | undefined): UwtTelemetryError | undefined" ], - "uwtNow": [ - "function(): number" - ], "UwtPiiValuePattern": [ "type = 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'" ], @@ -157,6 +200,9 @@ "UwtReporter": [ "type = (level: 'warn' | 'error', message: string, ...details: unknown[]) => void" ], + "uwtResolveTelemetryConfig": [ + "function(overrides?: UwtTelemetryConfigOverrides | undefined): UwtTelemetryConfig" + ], "uwtSafe": [ "function(operation: string, fn: () => T, fallback: T, report?: UwtReporter | undefined): T" ], @@ -292,6 +338,17 @@ "UwtScenarioStepStatus": [ "type = 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'" ], + "UwtScenarioTelemetry": [ + "class", + "new (options: UwtScenarioTelemetryOptions): UwtScenarioTelemetry", + "dispose(): void", + "find(scenarioId: string): UwtScenario | undefined", + "findByName(name: string): UwtScenario[]", + "findByNameAndId(name: string, scenarioId: string): UwtScenario | undefined", + "getActive(): UwtScenario[]", + "onSettled(listener: (record: UwtScenarioRecord) => void): () => void", + "start(options: UwtScenarioOptions): UwtScenario" + ], "UwtScenarioTelemetryConfig": [ "interface", "defaultTimeoutMs: number", @@ -301,6 +358,17 @@ "redact: boolean", "userTimings: boolean" ], + "UwtScenarioTelemetryOptions": [ + "interface", + "config: UwtScenarioTelemetryConfig", + "document?: UwtTelemetryDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "redact: UwtRedactConfig", + "report?: UwtReporter | undefined", + "runOutside?: ((callback: () => T) => T) | undefined", + "sink: UwtTelemetrySink" + ], "uwtScrubString": [ "function(value: string, config: UwtRedactConfig, report?: UwtReporter | undefined): string" ], @@ -310,12 +378,42 @@ "UwtStepName": [ "type = string" ], + "UwtTelemetry": [ + "interface", + "dispose(): void", + "flush(beacon?: boolean | undefined): void", + "readonly bi: UwtBITelemetry", + "readonly logging: UwtLogTelemetry", + "readonly scenarios: UwtScenarioTelemetry", + "setUserId(userId: string | undefined): void" + ], "UwtTelemetryAttribution": [ "interface", "application: string", "sessionId?: string | undefined", "userId?: string | undefined" ], + "UwtTelemetryConfig": [ + "interface", + "bi: UwtBITelemetryConfig", + "logging: UwtLogConfig", + "redaction: UwtRedactConfig", + "scenarios: UwtScenarioTelemetryConfig" + ], + "UwtTelemetryConfigOverrides": [ + "interface", + "bi?: Partial | undefined", + "logging?: Partial | undefined", + "redaction?: Partial | undefined", + "scenarios?: Partial | undefined" + ], + "UwtTelemetryDocument": [ + "interface", + "addEventListener(type: 'visibilitychange', listener: () => void): void", + "readonly defaultView: UwtListenable<'pagehide'> | null", + "readonly visibilityState: string", + "removeEventListener(type: 'visibilitychange', listener: () => void): void" + ], "UwtTelemetryError": [ "interface", "message: string", @@ -338,6 +436,20 @@ "UwtTelemetryMetadata": [ "type = { [x: string]: string | number | boolean | null; }" ], + "UwtTelemetryOptions": [ + "interface", + "bi?: Partial | undefined", + "destination: UwtTelemetrySink", + "document?: UwtTelemetryDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "logApi: UwtLogApiProvider", + "logging?: Partial | undefined", + "redaction?: Partial | undefined", + "report?: UwtReporter | undefined", + "runOutside?: ((callback: () => T) => T) | undefined", + "scenarios?: Partial | undefined" + ], "UwtTelemetrySink": [ "abstract class", "new (): UwtTelemetrySink", @@ -354,5 +466,16 @@ "uwtUuid": [ "function(): string" ] + }, + "@absaoss-cps/ui-watchtower/rxjs": { + "traceScenario": [ + "function(scenario: UwtScenario, options?: UwtTraceScenarioOptions | ((value: T) => void | UwtScenarioOutcome) | undefined, report?: UwtReporter | undefined): MonoTypeOperatorFunction" + ], + "UwtTraceScenarioOptions": [ + "interface", + "cancelOutcome?: UwtScenarioOutcome | (() => void | UwtScenarioOutcome) | undefined", + "outcome?: ((value: T) => void | UwtScenarioOutcome) | undefined", + "valueThenTeardown?: 'complete' | 'cancel' | undefined" + ] } } diff --git a/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.ts b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.ts new file mode 100644 index 0000000..196c0e9 --- /dev/null +++ b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.ts @@ -0,0 +1,650 @@ +/** + * @jest-environment jsdom + */ +import { uwtSessionElapsed } from '@absaoss-cps/ui-watchtower'; +import { UwtRumBootstrap } from '../uwt-rum-credentials/uwt-rum-credentials'; +import { + UwtRumTelemetrySink, + UwtRumTelemetrySinkOptions +} from './uwt-rum-telemetry.sink'; + +const mockClient = { + recordEvent: jest.fn(), + recordError: jest.fn(), + recordPageView: jest.fn(), + addSessionAttributes: jest.fn(), + setEventMetadataHook: jest.fn(), + setAwsCredentials: jest.fn(), + getSessionId: jest.fn(), + getUserId: jest.fn(), + pinUserId: jest.fn(), + startSession: jest.fn(), + dispatch: jest.fn(), + dispatchBeacon: jest.fn(), + disable: jest.fn() +}; +/** The arguments of every client built, and what building one does. */ +const mockConstructed: unknown[][] = []; +const mockConstruction: { fail: boolean; then?: () => void } = { fail: false }; + +jest.mock('aws-rum-web', () => ({ + AwsRum: function (...args: unknown[]) { + if (mockConstruction.fail) { + throw new Error('SDK exploded'); + } + mockConstructed.push(args); + mockConstruction.then?.(); + return mockClient; + } +})); + +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; +const SESSION_START = 'com.amazon.rum.session_start_event'; + +/** What a broker returns: settings, and credentials expiring in an hour. */ +function bootstrap( + config: Record = {}, + expiresInMs = HOUR +): UwtRumBootstrap { + return { + config: { + applicationId: 'app-monitor', + applicationVersion: '1.0.0', + region: 'eu-west-1', + ...config + } as UwtRumBootstrap['config'], + credentials: { + accessKeyId: 'a', + secretAccessKey: 's', + sessionToken: 't', + expiration: new Date(Date.now() + expiresInMs).toISOString() + } + }; +} + +/** A page with an origin and its cookies. */ +function page(origin = 'https://shop.example') { + return { + cookie: '', + defaultView: { location: { origin }, navigator: { cookieEnabled: true } } + }; +} + +/** The config the last client was built with. */ +function builtConfig(): Record { + return mockConstructed[mockConstructed.length - 1][3] as Record< + string, + unknown + >; +} + +/** The metadata hook the client was given. */ +function metadataHook(): (type: string) => Record { + return mockClient.setEventMetadataHook.mock.calls[0][0]; +} + +describe('the core RUM sink, in detail', () => { + let load: jest.Mock; + let sinks: UwtRumTelemetrySink[]; + + function sink( + options: Partial = {} + ): UwtRumTelemetrySink { + const created = new UwtRumTelemetrySink({ + identity: { application: 'cart', environment: 'test', version: '1' }, + credentialsProvider: { load }, + ...options + }); + sinks.push(created); + return created; + } + + beforeEach(() => { + Object.values(mockClient).forEach((fn) => fn.mockReset()); + mockClient.getSessionId.mockReturnValue('session-1'); + mockConstructed.length = 0; + mockConstruction.fail = false; + mockConstruction.then = undefined; + load = jest.fn(async () => bootstrap()); + sinks = []; + localStorage.clear(); + }); + + afterEach(() => { + sinks.forEach((created) => created.dispose()); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('settings the SDK cannot use', () => { + it.each([ + [ + { pagesToExclude: 'admin' }, + 'pagesToExclude (expected an array of RegExp)' + ], + [{ telemetries: 'errors' }, 'telemetries (expected an array)'], + [ + { telemetries: [['http', { urlsToExclude: ['/files'] }]] }, + 'http.urlsToExclude (expected an array of RegExp)' + ], + [ + { telemetries: [['http', { addXRayTraceIdHeader: 'yes' }]] }, + 'http.addXRayTraceIdHeader (expected a boolean or an array of RegExp)' + ], + [ + { telemetries: [['errors', { ignore: 'never' }]] }, + 'errors.ignore (expected a function)' + ] + ])('should keep RUM off for %j', async (config, problem) => { + const report = jest.fn(); + load.mockResolvedValue(bootstrap(config)); + const created = sink({ report }); + + await created.init(); + created.record('com.uwt.bi', {}); + + expect(mockConstructed).toHaveLength(0); + expect(report).toHaveBeenCalledWith( + 'config', + `RUM not started, settings of the wrong type: ${problem}` + ); + }); + + it('should accept valid telemetry options, and pass them on without the unset ones', async () => { + const ignore = () => false; + const pattern = /\/api\//; + load.mockResolvedValue( + bootstrap({ + telemetries: [ + 'errors', + ['errors', { ignore }], + [ + 'http', + { + urlsToExclude: [pattern], + addXRayTraceIdHeader: true, + urlsToInclude: null, + interactionId: undefined + } + ], + ['performance', 'not options'] + ] + }) + ); + + await sink().init(); + + expect(builtConfig().telemetries).toEqual([ + 'errors', + ['errors', { ignore }], + ['http', { urlsToExclude: [pattern], addXRayTraceIdHeader: true }], + ['performance', 'not options'] + ]); + }); + }); + + describe('the client config', () => { + it('should trace same-origin requests by default, never a look-alike host', async () => { + await sink({ document: page('https://shop.example') }).init(); + + const http = (builtConfig().telemetries as unknown[])[2] as [ + string, + { addXRayTraceIdHeader: RegExp[] } + ]; + const [sameOrigin] = http[1].addXRayTraceIdHeader; + expect(sameOrigin.test('https://shop.example/api/orders')).toBe(true); + expect(sameOrigin.test('https://shop.example')).toBe(true); + expect(sameOrigin.test('https://shop.example.attacker.example/')).toBe( + false + ); + expect(builtConfig()).toMatchObject({ + allowCookies: true, + enableXRay: true, + endpoint: 'https://dataplane.rum.eu-west-1.amazonaws.com' + }); + }); + + it("should leave unset settings to the SDK's defaults, and pass set ones on", async () => { + load.mockResolvedValue( + bootstrap({ + sessionSampleRate: 0.5, + client: 'my-client', + releaseId: null, + debug: undefined + }) + ); + + await sink().init(); + + expect(builtConfig()).toMatchObject({ + sessionSampleRate: 0.5, + client: 'my-client' + }); + expect(builtConfig()).not.toHaveProperty('releaseId'); + expect(builtConfig()).not.toHaveProperty('debug'); + }); + }); + + describe('startup', () => { + it('should turn RUM off for the page when the broker declines', async () => { + load.mockResolvedValue(null); + const created = sink(); + + created.record('com.uwt.early', {}); + await created.init(); + created.record('com.uwt.late', {}); + + expect(mockConstructed).toHaveLength(0); + expect(created.getSessionId()).toBeUndefined(); + }); + + it('should retry a failing broker with growing delays, then start and replay', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + jest.spyOn(Math, 'random').mockReturnValue(1); + const report = jest.fn(); + load + .mockRejectedValueOnce(new Error('broker down')) + .mockRejectedValueOnce(new Error('still down')); + const created = sink({ report }); + + created.record('com.uwt.early', {}); + await created.init(); + await jest.advanceTimersByTimeAsync(1000); + expect(load).toHaveBeenCalledTimes(2); + await jest.advanceTimersByTimeAsync(2000); + + expect(load).toHaveBeenCalledTimes(3); + expect(mockClient.recordEvent).toHaveBeenCalledWith( + 'com.uwt.early', + {}, + undefined + ); + expect( + report.mock.calls.filter(([, message]) => message.includes('retrying')) + ).toEqual([ + [ + 'warn', + 'RUM init failed, retrying', + expect.objectContaining({ message: 'broker down' }) + ] + ]); + }); + + it('should give up after five minutes of failures, and drop what it held', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + jest.spyOn(Math, 'random').mockReturnValue(1); + const report = jest.fn(); + load.mockRejectedValue(new Error('down')); + const created = sink({ report }); + + created.record('com.uwt.early', {}); + await created.init(); + await jest.advanceTimersByTimeAsync(6 * MINUTE); + const attempts = load.mock.calls.length; + await jest.advanceTimersByTimeAsync(10 * MINUTE); + + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM init failed, monitoring disabled', + expect.objectContaining({ message: 'down' }) + ); + expect(load).toHaveBeenCalledTimes(attempts); + expect(mockConstructed).toHaveLength(0); + }); + + it('should turn RUM off when the client fails to start, reporting only to a reporter', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const report = jest.fn(); + mockConstruction.fail = true; + + await sink({ report }).init(); + await sink().init(); + + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM init failed, monitoring disabled', + expect.objectContaining({ message: 'SDK exploded' }) + ); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should build no client when disposed while starting', async () => { + const created = sink(); + + const starting = created.init(); + created.dispose(); + await starting; + + expect(mockConstructed).toHaveLength(0); + }); + + it('should hold the latest 100 events from before startup', async () => { + const created = sink(); + + for (let i = 0; i < 105; i++) { + created.record(`com.uwt.e${i}`, {}); + } + await created.init(); + + expect(mockClient.recordEvent).toHaveBeenCalledTimes(100); + expect(mockClient.recordEvent.mock.calls[0][0]).toBe('com.uwt.e5'); + }); + }); + + describe('records', () => { + it('should drop reserved and null metadata, and send none when nothing is left', async () => { + const created = sink(); + await created.init(); + + created.record('com.uwt.a', {}, { 'aws:x': 'a', keep: 1, gone: null }); + created.record('com.uwt.b', {}, { 'aws:only': 'a' }); + + expect(mockClient.recordEvent.mock.calls).toEqual([ + ['com.uwt.a', {}, { keep: 1 }], + ['com.uwt.b', {}, undefined] + ]); + }); + + it("should fold a fragment's application into an error's name, not its own", async () => { + const created = sink(); + await created.init(); + const error = { name: 'TypeError', message: 'boom' }; + + created.recordError(error, { application: 'checkout' }); + created.recordError(error, { application: 'cart' }); + created.recordError(error); + + expect(mockClient.recordError.mock.calls).toEqual([ + [{ name: '[checkout] TypeError', message: 'boom' }], + [error], + [error] + ]); + }); + + it('should replay page views and errors recorded before startup', async () => { + const created = sink(); + + created.recordPageView('/customers/:id'); + created.recordError( + { name: 'Error', message: 'early' }, + { application: 'checkout' } + ); + await created.init(); + created.recordPageView('/orders'); + + expect(mockClient.recordPageView.mock.calls).toEqual([ + ['/customers/:id'], + ['/orders'] + ]); + expect(mockClient.recordError).toHaveBeenCalledWith({ + name: '[checkout] Error', + message: 'early' + }); + }); + }); + + describe('the signed-in user', () => { + it('should pin a user signed in before startup, once the client starts', async () => { + const created = sink(); + + created.setUserId('u-1'); + await created.init(); + + expect(created.getUserId()).toBe('u-1'); + expect(mockClient.pinUserId).toHaveBeenCalledWith('u-1'); + }); + + it('should start an anonymous session for a sign-out before startup', async () => { + const created = sink(); + + created.setUserId('u-1'); + created.setUserId(undefined); + await created.init(); + + expect(mockClient.pinUserId).not.toHaveBeenCalled(); + expect(mockClient.startSession).toHaveBeenCalledWith({ + userId: expect.any(String) + }); + }); + + it('should send what the client holds before the user changes, and ignore repeats', async () => { + load.mockResolvedValue( + bootstrap({ eventCacheSize: 300, batchLimit: 100 }) + ); + const created = sink(); + await created.init(); + + created.setUserId('u-1'); + expect(mockClient.dispatch).toHaveBeenCalledTimes(3); + expect(mockClient.pinUserId).toHaveBeenCalledWith('u-1'); + + created.setUserId('u-1'); + expect(mockClient.dispatch).toHaveBeenCalledTimes(3); + + created.setUserId(undefined); + expect(mockClient.dispatch).toHaveBeenCalledTimes(6); + expect(mockClient.startSession).toHaveBeenCalledTimes(1); + + created.setUserId(''); + expect(mockClient.startSession).toHaveBeenCalledTimes(1); + }); + + it('should send one batch when the batch settings are unusable', async () => { + load.mockResolvedValue(bootstrap({ batchLimit: 0 })); + const created = sink(); + await created.init(); + + created.setUserId('u-1'); + + expect(mockClient.dispatch).toHaveBeenCalledTimes(1); + }); + + it('should not send early when the client already has that user, as after a reload', async () => { + mockClient.getUserId.mockReturnValue('u-1'); + const created = sink(); + await created.init(); + + created.setUserId('u-1'); + + expect(mockClient.dispatch).not.toHaveBeenCalled(); + expect(mockClient.pinUserId).toHaveBeenCalledWith('u-1'); + }); + + it('should stamp signedIn on events only once the app has said who is signed in', async () => { + const created = sink(); + await created.init(); + const hook = metadataHook(); + + expect(hook('com.uwt.bi')).toEqual({ + application: 'cart', + environment: 'test', + appVersion: '1' + }); + created.setUserId('u-1'); + expect(hook('com.uwt.bi')).toMatchObject({ signedIn: true }); + created.setUserId(undefined); + expect(hook('com.uwt.bi')).toMatchObject({ signedIn: false }); + }); + }); + + describe('session starts', () => { + it('should record when the client starts a session', async () => { + await sink().init(); + + metadataHook()(SESSION_START); + + expect(uwtSessionElapsed('session-1')).toEqual(expect.any(Number)); + }); + + it('should record nothing when the client keeps no session cookie', async () => { + load.mockResolvedValue(bootstrap({ allowCookies: false })); + await sink().init(); + + metadataHook()(SESSION_START); + + expect(uwtSessionElapsed('session-1')).toBeUndefined(); + }); + + it.each([ + ['the shared cookie', {}, 'cwr_s=1'], + [ + 'its own cookie', + { cookieAttributes: { unique: true } }, + 'cwr_s_app-monitor=1' + ] + ])( + 'should record the session the client starts while starting, in %s', + async (_, config, cookie) => { + const document = page(); + mockConstruction.then = () => { + document.cookie = cookie; + }; + load.mockResolvedValue(bootstrap(config)); + + await sink({ document }).init(); + + expect(uwtSessionElapsed('session-1')).toEqual(expect.any(Number)); + } + ); + + it('should not recurse when reading the session id starts another one', async () => { + await sink().init(); + const hook = metadataHook(); + mockClient.getSessionId.mockImplementationOnce(() => { + hook(SESSION_START); + return 'session-2'; + }); + + hook(SESSION_START); + + expect(mockClient.getSessionId).toHaveBeenCalledTimes(1); + expect(uwtSessionElapsed('session-2')).toEqual(expect.any(Number)); + }); + }); + + describe('flush', () => { + it('should send through the client, by beacon when asked', async () => { + const created = sink(); + await created.init(); + + created.flush(); + created.flush(true); + + expect(mockClient.dispatch).toHaveBeenCalledTimes(1); + expect(mockClient.dispatchBeacon).toHaveBeenCalledTimes(1); + }); + + it('should report events lost when the page goes before the client starts', () => { + load.mockReturnValue(new Promise(() => undefined)); + const report = jest.fn(); + const created = sink({ report }); + + created.flush(true); + expect(report).not.toHaveBeenCalled(); + created.record('com.uwt.early', {}); + created.flush(true); + + expect(report).toHaveBeenCalledWith( + 'warn', + '1 RUM event(s) lost: page unloaded before RUM finished initializing' + ); + }); + }); + + describe('credentials', () => { + beforeEach(() => + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }) + ); + + it('should refresh credentials five minutes before they expire', async () => { + await sink().init(); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + + expect(load).toHaveBeenCalledTimes(2); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should turn RUM off when a refresh is declined', async () => { + const created = sink(); + await created.init(); + load.mockResolvedValueOnce(null); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + created.record('com.uwt.late', {}); + + expect(mockClient.disable).toHaveBeenCalled(); + expect(mockClient.recordEvent).not.toHaveBeenCalled(); + }); + + it('should keep its credentials and retry when a refresh omits them, or fails', async () => { + const report = jest.fn(); + await sink({ report }).init(); + load + .mockResolvedValueOnce({ config: bootstrap().config }) + .mockRejectedValueOnce(new Error('broker down')); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM credential refresh', + expect.objectContaining({ + message: expect.stringContaining('omitted credentials') + }) + ); + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM credential refresh failed', + expect.objectContaining({ message: 'broker down' }) + ); + await jest.advanceTimersByTimeAsync(30 * 1000); + + expect(load).toHaveBeenCalledTimes(4); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should retry soon, not loop, on credentials already expired', async () => { + load.mockResolvedValueOnce(bootstrap({}, -1000)); + await sink().init(); + expect(jest.getTimerCount()).toBe(1); + + await jest.advanceTimersByTimeAsync(30 * 1000); + + expect(load).toHaveBeenCalledTimes(2); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should schedule nothing once disposed during a refresh', async () => { + const created = sink(); + await created.init(); + let answer: (value: UwtRumBootstrap) => void = () => undefined; + load.mockReturnValueOnce(new Promise((resolve) => (answer = resolve))); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + created.dispose(); + answer(bootstrap()); + await jest.advanceTimersByTimeAsync(0); + + expect(jest.getTimerCount()).toBe(0); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(1); + }); + + it('should not retry a failed refresh once disposed', async () => { + const created = sink(); + await created.init(); + let fail: (error: Error) => void = () => undefined; + load.mockReturnValueOnce( + new Promise((_resolve, reject) => (fail = reject)) + ); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + created.dispose(); + fail(new Error('broker down')); + await jest.advanceTimersByTimeAsync(0); + + expect(jest.getTimerCount()).toBe(0); + }); + }); +}); diff --git a/projects/ui-watchtower/rxjs/ng-package.json b/projects/ui-watchtower/rxjs/ng-package.json new file mode 100644 index 0000000..e09fb3f --- /dev/null +++ b/projects/ui-watchtower/rxjs/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.spec.ts b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.spec.ts new file mode 100644 index 0000000..59c3752 --- /dev/null +++ b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.spec.ts @@ -0,0 +1,514 @@ +import { + defer, + EMPTY, + first, + type Observable, + firstValueFrom, + lastValueFrom, + of, + retry, + Subject, + switchMap, + take, + throwError +} from 'rxjs'; +import { + UWT_DEFAULT_REDACT_CONFIG, + UWT_DEFAULT_TELEMETRY_CONFIG, + UwtNoopTelemetrySink, + UwtScenario, + UwtScenarioRecord +} from '@absaoss-cps/ui-watchtower'; +import { traceScenario } from './uwt-scenario-operators'; + +describe('traceScenario operator', () => { + let scenario: jest.Mocked; + + beforeEach(() => { + scenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + }); + + it('should complete scenario on successful stream completion', (done) => { + of(['user1', 'user2']) + .pipe(traceScenario(scenario)) + .subscribe({ + next: (val) => { + expect(val).toEqual(['user1', 'user2']); + }, + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should complete scenario with mapped outcome using function argument', (done) => { + of(['item1', 'item2', 'item3']) + .pipe( + traceScenario(scenario, (items) => ({ + metadata: { itemCount: items.length } + })) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith({ + metadata: { itemCount: 3 } + }); + done(); + } + }); + }); + + it('should complete scenario with mapped outcome using options object', (done) => { + of({ status: 200, data: 'ok' }) + .pipe( + traceScenario(scenario, { + outcome: (res) => ({ + statusCode: res.status, + metadata: { result: res.data } + }) + }) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith({ + statusCode: 200, + metadata: { result: 'ok' } + }); + done(); + } + }); + }); + + it('should complete scenario with no outcome when the stream completes with no emissions', (done) => { + EMPTY.pipe( + traceScenario(scenario, () => ({ metadata: { unreachable: true } })) + ).subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should fail scenario when the stream errors and re-throw the error', (done) => { + const error = new Error('network down'); + throwError(() => error) + .pipe(traceScenario(scenario)) + .subscribe({ + error: (err) => { + expect(err).toBe(error); + expect(scenario.fail).toHaveBeenCalledWith({ error }); + expect(scenario.complete).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should still settle the scenario when the outcome mapper throws', (done) => { + const report = jest.fn(); + + of('value') + .pipe( + traceScenario( + scenario, + () => { + throw new Error('mapper is broken'); + }, + report + ) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + expect(report).toHaveBeenCalledWith( + 'error', + 'scenario.traceOutcome failed', + expect.any(Error) + ); + done(); + } + }); + }); + + it('should not leak lastValue/hasValue into a resubscription of the same piped observable', (done) => { + let subscriptionCount = 0; + const source = defer(() => { + subscriptionCount++; + return subscriptionCount === 1 ? of('a') : EMPTY; + }); + + const traced$ = source.pipe( + traceScenario(scenario, (value) => ({ metadata: { value } })) + ); + + traced$.subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenNthCalledWith(1, { + metadata: { value: 'a' } + }); + + traced$.subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenNthCalledWith(2, undefined); + done(); + } + }); + } + }); + }); + + describe('teardown without a source complete/error', () => { + it('should cancel the scenario on a manual unsubscribe', () => { + const source = new Subject(); + const subscription = source.pipe(traceScenario(scenario)).subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledTimes(1); + expect(scenario.complete).not.toHaveBeenCalled(); + expect(scenario.fail).not.toHaveBeenCalled(); + }); + + it('should cancel a superseded scenario when switchMap moves to the next inner observable', () => { + const supersededScenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + const pendingFirstRequest = new Subject(); + const secondRequest = of('b'); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + (n === 1 ? pendingFirstRequest : secondRequest).pipe( + traceScenario(n === 1 ? supersededScenario : scenario) + ) + ) + ) + .subscribe(); + + trigger.next(1); // subscribes traceScenario(supersededScenario), still pending + trigger.next(2); // switchMap unsubscribes it before it ever settles + + expect(supersededScenario.cancel).toHaveBeenCalledTimes(1); + expect(supersededScenario.complete).not.toHaveBeenCalled(); + expect(supersededScenario.fail).not.toHaveBeenCalled(); + expect(scenario.complete).toHaveBeenCalledWith(undefined); + }); + + it('should cancel with the configured cancelOutcome on unsubscribe', () => { + const source = new Subject(); + const subscription = source + .pipe( + traceScenario(scenario, { cancelOutcome: { reason: 'superseded' } }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); + }); + + it('should accept cancelOutcome as a function, evaluated at unsubscribe time', () => { + const source = new Subject(); + const subscription = source + .pipe( + traceScenario(scenario, { + cancelOutcome: () => ({ reason: 'superseded' }) + }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); + }); + + it('should still cancel, with no outcome, when cancelOutcome throws', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const source = new Subject(); + const subscription = source + .pipe( + traceScenario(scenario, { + cancelOutcome: () => { + throw new Error('cancelOutcome is broken'); + } + }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith(undefined); + consoleError.mockRestore(); + }); + + it('should record a reason for a switchMap-superseded scenario, not just a bare cancel', () => { + const supersededScenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + const pendingFirstRequest = new Subject(); + const secondRequest = of('b'); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + (n === 1 ? pendingFirstRequest : secondRequest).pipe( + traceScenario(n === 1 ? supersededScenario : scenario, { + cancelOutcome: { reason: 'superseded' } + }) + ) + ) + ) + .subscribe(); + + trigger.next(1); + trigger.next(2); + + expect(supersededScenario.cancel).toHaveBeenCalledWith({ + reason: 'superseded' + }); + }); + + it('should not cancel a scenario that already completed normally', (done) => { + of('value') + .pipe(traceScenario(scenario)) + .subscribe({ + complete: () => { + (scenario as unknown as { isSettled: boolean }).isSettled = true; + expect(scenario.cancel).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should not cancel a scenario that already failed', (done) => { + const error = new Error('boom'); + throwError(() => error) + .pipe(traceScenario(scenario)) + .subscribe({ + error: () => { + (scenario as unknown as { isSettled: boolean }).isSettled = true; + expect(scenario.cancel).not.toHaveBeenCalled(); + done(); + } + }); + }); + }); + + describe('with a real scenario', () => { + let settled: UwtScenarioRecord[]; + + const countRows = (rows: string[]) => ({ + metadata: { count: rows.length } + }); + + function createScenario(name = 'load-customers'): UwtScenario { + return new UwtScenario( + { name }, + { + identity: { + application: 'test-app', + environment: 'test', + version: '1.0.0' + }, + scenarioTelemetryConfig: { + ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario + }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink: new UwtNoopTelemetrySink(), + onSettled: (_id, record) => settled.push(record) + } + ); + } + + beforeEach(() => { + settled = []; + }); + + describe('single-value sources', () => { + it('should settle as success with subscribe()', () => { + const real = createScenario(); + + of(['a', 'b']).pipe(traceScenario(real, countRows)).subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with lastValueFrom', async () => { + const real = createScenario(); + + await lastValueFrom( + of(['a', 'b']).pipe(traceScenario(real, countRows)) + ); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with firstValueFrom', async () => { + const real = createScenario(); + + const rows = await firstValueFrom( + of(['a', 'b']).pipe(traceScenario(real, countRows)) + ); + + expect(rows).toEqual(['a', 'b']); + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with take(1) after the operator', () => { + const real = createScenario(); + + of(['a', 'b']) + .pipe(traceScenario(real, countRows), take(1)) + .subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with first() after the operator', () => { + const real = createScenario(); + + of(['a', 'b']) + .pipe(traceScenario(real, countRows), first()) + .subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with take(1) before the operator', () => { + const real = createScenario(); + + of(['a', 'b']) + .pipe(take(1), traceScenario(real, countRows)) + .subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with take(1) before the operator under firstValueFrom', async () => { + const real = createScenario(); + + await firstValueFrom( + of(['a', 'b']).pipe(take(1), traceScenario(real, countRows)) + ); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + }); + + describe('switchMap superseding an inner stream', () => { + it('should abandon a stream superseded before it emitted', () => { + const superseded = createScenario('superseded'); + const latest = createScenario('latest'); + const pendingFirstRequest = new Subject(); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + n === 1 + ? pendingFirstRequest.pipe(traceScenario(superseded)) + : of(['b']).pipe(traceScenario(latest)) + ) + ) + .subscribe(); + + trigger.next(1); + trigger.next(2); + + expect(superseded.status).toBe('abandoned'); + expect(latest.status).toBe('success'); + }); + + it('should settle as success a stream superseded after it emitted', () => { + const superseded = createScenario('superseded'); + const latest = createScenario('latest'); + const firstRequest = new Subject(); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + n === 1 + ? firstRequest.pipe(traceScenario(superseded, countRows)) + : of(['b']).pipe(traceScenario(latest)) + ) + ) + .subscribe(); + + trigger.next(1); + firstRequest.next(['a']); // emitted, but never completed + trigger.next(2); + + expect(superseded.status).toBe('success'); + expect(superseded.toRecord().metadata).toEqual({ count: 1 }); + expect(latest.status).toBe('success'); + }); + }); + + it("should abandon a stream torn down after a value with valueThenTeardown: 'cancel'", () => { + const real = createScenario('poll-status'); + + of(['a'], ['b']) + .pipe(traceScenario(real, { valueThenTeardown: 'cancel' }), take(1)) + .subscribe(); + + expect(real.status).toBe('abandoned'); + }); + + describe('retry placement', () => { + function failOnce(): Observable { + let attempts = 0; + return defer(() => + attempts++ === 0 + ? throwError(() => new Error('transient')) + : of(['a']) + ); + } + + it('should settle as success when retry() comes before the operator', () => { + const real = createScenario(); + + failOnce().pipe(retry(1), traceScenario(real)).subscribe(); + + expect(real.status).toBe('success'); + }); + + it('should settle as failure when retry() comes after the operator, even if the retry succeeds', () => { + const real = createScenario(); + + failOnce().pipe(traceScenario(real), retry(1)).subscribe(); + + // Documented limitation: the first error settles the scenario and + // the operator cannot see the downstream retry. + expect(real.status).toBe('failure'); + }); + }); + }); +}); diff --git a/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.ts b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.ts new file mode 100644 index 0000000..6edb052 --- /dev/null +++ b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.ts @@ -0,0 +1,129 @@ +import { defer } from 'rxjs'; +import type { MonoTypeOperatorFunction, Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { + UwtReporter, + UwtScenario, + UwtScenarioOutcome, + uwtSafe +} from '@absaoss-cps/ui-watchtower'; + +/** + * Options for configuring scenario completion within an RxJS stream. + * + * @group Interfaces + */ +export interface UwtTraceScenarioOptions { + /** + * Optional mapper that returns an outcome (metadata, statusCode, message) + * based on the emitted value when the Observable completes successfully. + */ + outcome?: (value: T) => UwtScenarioOutcome | void; + + /** + * Outcome used when the stream is unsubscribed before completing or + * erroring — e.g. a `switchMap` moving on to a newer value. + */ + cancelOutcome?: UwtScenarioOutcome | (() => UwtScenarioOutcome | void); + + /** + * What a downstream unsubscribe means once a value has been delivered. + * Default `'complete'`, so `firstValueFrom`, `take(1)` and `first()` settle + * as success. Use `'cancel'` for multi-value streams (polling, progress), + * where stopping after a value is abandonment. + */ + valueThenTeardown?: 'complete' | 'cancel'; +} + +/** + * An RxJS pipeable operator that automatically settles a {@link UwtScenario} + * based on the stream's outcome: `complete()` on completion, `fail({ error })` + * on error. + * + * @example + * ```typescript + * this.http.get('/api/customers') + * .pipe( + * traceScenario(scenario, { + * outcome: (customers) => ({ metadata: { count: customers.length } }) + * }) + * ) + * .subscribe(); + * ``` + * + * @param scenario the scenario instance to track + * @param options optional outcome mapper or configuration + * @param report receives what a throwing outcome mapper threw; see + * {@link UwtReporter} + * + * @group Utils + */ +export function traceScenario( + scenario: UwtScenario, + options?: + UwtTraceScenarioOptions | ((value: T) => UwtScenarioOutcome | void), + report?: UwtReporter +): MonoTypeOperatorFunction { + const outcomeMapper = + typeof options === 'function' ? options : options?.outcome; + const cancelOutcome = + typeof options === 'function' ? undefined : options?.cancelOutcome; + const valueThenTeardown = + (typeof options === 'function' ? undefined : options?.valueThenTeardown) ?? + 'complete'; + + return (source: Observable): Observable => + defer(() => { + let lastValue: T | undefined; + let hasValue = false; + + const complete = () => { + const outcome = + hasValue && outcomeMapper + ? uwtSafe( + 'scenario.traceOutcome', + () => outcomeMapper(lastValue as T), + undefined, + report + ) + : undefined; + scenario.complete(outcome || undefined); + }; + + const unsubscribe = () => { + if (scenario.isSettled) { + return; + } + // The consumer got a value and stopped listening, as + // firstValueFrom, take(1) and first() do: treat it as completion. + if (hasValue && valueThenTeardown === 'complete') { + complete(); + return; + } + const resolved = + typeof cancelOutcome === 'function' + ? uwtSafe( + 'scenario.traceCancelOutcome', + cancelOutcome, + undefined, + report + ) + : cancelOutcome; + scenario.cancel(resolved || undefined); + }; + + return source.pipe( + tap({ + next: (value) => { + lastValue = value; + hasValue = true; + }, + complete, + error: (error: unknown) => { + scenario.fail({ error }); + }, + unsubscribe + }) + ); + }); +} diff --git a/projects/ui-watchtower/rxjs/src/public-api.ts b/projects/ui-watchtower/rxjs/src/public-api.ts new file mode 100644 index 0000000..e8e1ca7 --- /dev/null +++ b/projects/ui-watchtower/rxjs/src/public-api.ts @@ -0,0 +1,9 @@ +/* + * Public API Surface of ui-watchtower/rxjs + * + * RxJS integration, in its own entry point so the main one never loads + * RxJS: `rxjs` is an optional peer, needed only here. + */ + +export { traceScenario } from './lib/uwt-scenario-operators/uwt-scenario-operators'; +export type { UwtTraceScenarioOptions } from './lib/uwt-scenario-operators/uwt-scenario-operators'; diff --git a/projects/ui-watchtower/rxjs/tsconfig.lib.json b/projects/ui-watchtower/rxjs/tsconfig.lib.json new file mode 100644 index 0000000..c2d230e --- /dev/null +++ b/projects/ui-watchtower/rxjs/tsconfig.lib.json @@ -0,0 +1,16 @@ +/* Type-checks the ./rxjs entry point; ng-packagr builds it with the main tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib-core-rxjs", + // Includes the main entry point: it is read from source here. + "rootDir": "..", + "lib": ["ES2022", "DOM"], + "declaration": true, + "skipLibCheck": true, + "isolatedModules": false, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"] +} diff --git a/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts b/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts index b9d11f7..3b4b858 100644 --- a/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts +++ b/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts @@ -60,3 +60,67 @@ export const UWT_DEFAULT_TELEMETRY_CONFIG: { bi: UWT_DEFAULT_BI_TELEMETRY_CONFIG, redact: UWT_DEFAULT_REDACT_CONFIG }; + +/** + * Overrides for each concern's configuration: whatever is left out takes the + * library default. + * + * @group Interfaces + */ +export interface UwtTelemetryConfigOverrides { + scenarios?: Partial; + logging?: Partial; + bi?: Partial; + redaction?: Partial; +} + +/** + * Each concern's configuration, complete. + * + * @group Interfaces + */ +export interface UwtTelemetryConfig { + scenarios: UwtScenarioTelemetryConfig; + logging: UwtLogConfig; + bi: UwtBITelemetryConfig; + redaction: UwtRedactConfig; +} + +/** + * Completes each concern's configuration with the library defaults: every + * override over its default, shallowly. Redaction's pattern and transform + * lists are copied into fresh arrays, so callers never share array identity + * with the defaults or each other. + * + * The one place defaults are applied, for the engine and every adapter. + * + * @param overrides what the application sets + * @returns the configuration an engine runs with + * + * @group Utils + */ +export function uwtResolveTelemetryConfig( + overrides: UwtTelemetryConfigOverrides = {} +): UwtTelemetryConfig { + const defaults = UWT_DEFAULT_TELEMETRY_CONFIG; + const redaction = overrides.redaction ?? {}; + return { + scenarios: { ...defaults.scenario, ...overrides.scenarios }, + logging: { ...defaults.logs, ...overrides.logging }, + bi: { ...defaults.bi, ...overrides.bi }, + redaction: { + ...defaults.redact, + ...redaction, + extraKeyPatterns: [ + ...(redaction.extraKeyPatterns ?? defaults.redact.extraKeyPatterns) + ], + extraValuePatterns: [ + ...(redaction.extraValuePatterns ?? defaults.redact.extraValuePatterns) + ], + extraValueTransforms: [ + ...(redaction.extraValueTransforms ?? + defaults.redact.extraValueTransforms) + ] + } + }; +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts new file mode 100644 index 0000000..1e4c881 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts @@ -0,0 +1,254 @@ +import { UwtBITelemetryConfig } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { + UwtBIEvent, + UwtBIEventDetail, + UwtBIEventName +} from '../../models/uwt-bi.models/uwt-bi.models'; +import { + uwtEventTypes, + UwtTelemetryEventTypes, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; +import { + UwtRedactConfig, + uwtRedactConfigFor, + uwtRedactMetadata, + uwtScrubString +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + uwtNow, + UwtReporter, + uwtSafe, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; + +/** + * What {@link UwtBITelemetry} needs. + * + * @group Interfaces + */ +export interface UwtBITelemetryOptions { + /** The application's identity, shared by every concern. */ + identity: UwtTelemetryIdentity; + /** BI event settings, complete; see {@link uwtResolveTelemetryConfig}. */ + config: UwtBITelemetryConfig; + /** + * Redaction settings shared by every concern. `config.redact: false` + * turns their configurable part off for BI events. + */ + redact: UwtRedactConfig; + /** Where events go. */ + sink: UwtTelemetrySink; + /** `false` makes {@link UwtBITelemetry.track} do nothing. Default `true`. */ + enabled?: boolean; + /** Receives what is handled silently; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Business and UX event tracking — feature adoption, interaction analysis, + * funnel steps. + * + * Unlike scenarios, BI events are discrete: no duration, no lifecycle. Event + * names and attributes come entirely from the application. + * + * Repeated identical events within a short window are collapsed, absorbing + * double-fires from a `click` handler also bound to `keydown`, or from a + * user clicking twice. + * + * Console output is off unless the `debugBI` LocalStorage flag is set: + * + * ```js + * localStorage.setItem('debugBI', 'true'); + * ``` + * + * @example + * ```typescript + * telemetry.bi.track('export_clicked', { + * exportType: format, + * source: 'customer-table' + * }); + * ``` + * + * @group Classes + */ +export class UwtBITelemetry { + private readonly identity: UwtTelemetryIdentity; + private readonly config: UwtBITelemetryConfig; + private readonly redact: UwtRedactConfig; + private readonly sink: UwtTelemetrySink; + private readonly enabled: boolean; + private readonly report?: UwtReporter; + + private readonly eventTypes: UwtTelemetryEventTypes; + private readonly lastEmittedAt = new Map(); + + constructor(options: UwtBITelemetryOptions) { + this.identity = options.identity; + this.config = options.config; + this.redact = uwtRedactConfigFor(options.redact, options.config.redact); + this.sink = options.sink; + this.enabled = options.enabled !== false; + this.report = options.report; + this.eventTypes = uwtEventTypes(options.identity.eventNamespace); + } + + /** + * Records a business or UX event. + * + * @param eventName the application's own event name, e.g. `export_clicked`, + * as declared in {@link UwtBIEventNames}. Treat it as a metric dimension: + * keep the cardinality low and never interpolate an identifier into it. + * @param metadata flat attributes describing the interaction + * @param detail optional scenario correlation and feature + */ + track( + eventName: UwtBIEventName, + metadata?: UwtTelemetryMetadata, + detail?: UwtBIEventDetail + ): void { + if (!this.enabled) { + return; + } + uwtSafeVoid( + 'biTelemetry.track', + () => { + if (!eventName) { + return; + } + + const redactedFeature = detail?.feature + ? uwtScrubString(detail.feature, this.redact, this.report) + : undefined; + const redactedMetadata = uwtRedactMetadata( + metadata, + this.redact, + this.report + ); + + if ( + this.isDuplicate(eventName, redactedMetadata, { + ...detail, + feature: redactedFeature + }) + ) { + return; + } + + const event: UwtBIEvent = { + eventName, + eventTime: new Date().toISOString(), + scenarioId: detail?.scenarioId, + feature: redactedFeature, + metadata: redactedMetadata, + application: this.identity.application, + sessionId: uwtSafe( + 'biTelemetry.getSessionId', + () => this.sink.getSessionId(), + undefined, + this.report + ), + userId: uwtSafe( + 'biTelemetry.getUserId', + () => this.sink.getUserId(), + undefined, + this.report + ) + }; + + uwtDebugWrite( + 'debugBI', + () => writeToConsole(eventName, this.eventTypes.bi, event), + undefined, + this.report + ); + + this.sink.record(this.eventTypes.bi, event); + }, + this.report + ); + } + + /** + * Forgets recent events. Tracking still works afterwards: it holds no + * timer, listener or channel, and something may still track while an + * application shuts down. Calling it again does nothing more. + */ + dispose(): void { + this.lastEmittedAt.clear(); + } + + private isDuplicate( + eventName: string, + metadata: UwtTelemetryMetadata | undefined, + detail?: UwtBIEventDetail + ): boolean { + const key = JSON.stringify([ + eventName, + detail?.scenarioId ?? '', + detail?.feature ?? '', + this.metadataKey(metadata) + ]); + const now = uwtNow(); + const last = this.lastEmittedAt.get(key); + + if (last !== undefined && now - last < this.config.dedupWindowMs) { + this.lastEmittedAt.delete(key); + this.lastEmittedAt.set(key, last); + return true; + } + + if (this.lastEmittedAt.size >= this.config.dedupMaxKeys) { + for (const [staleKey, at] of this.lastEmittedAt) { + if (now - at >= this.config.dedupWindowMs) { + this.lastEmittedAt.delete(staleKey); + } + } + + if (this.lastEmittedAt.size >= this.config.dedupMaxKeys) { + const oldestKey = this.lastEmittedAt.keys().next().value; + if (oldestKey !== undefined) { + this.lastEmittedAt.delete(oldestKey); + } + } + } + + this.lastEmittedAt.delete(key); + this.lastEmittedAt.set(key, now); + return false; + } + + /** + * A stable string encoding a flat metadata object's content, for use in + * the dedup key — otherwise two same-named events with different metadata + * would collide and the second would be silently dropped. Keys are sorted + * so property order doesn't affect the result. Callers pass already- + * redacted metadata — a caller's raw values must never end up as a `Map` + * key living in this long-lived object's memory for the page lifetime. + */ + private metadataKey(metadata: UwtTelemetryMetadata | undefined): string { + if (!metadata) { + return ''; + } + return JSON.stringify( + Object.keys(metadata) + .sort() + .map((k) => [k, metadata[k]]) + ); + } +} + +/** + * Prefixed with the application - in a composed + * page every realm writes to the one console. + */ +function writeToConsole( + eventName: string, + eventType: string, + event: UwtBIEvent +): void { + console.log(`[${event.application}][bi] ${eventName} -> ${eventType}`, event); +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts new file mode 100644 index 0000000..38fa65d --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts @@ -0,0 +1,380 @@ +import { UwtLogConfig } from '../../config/uwt-log.config/uwt-log.config'; +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { + UWT_LOG_LEVEL_ORDER, + UwtLogDetail, + UwtLogger, + UwtLoggerName, + UwtLogLevel, + UwtLogRecord +} from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; +import { + uwtNormalizeError, + UwtRedactConfig, + uwtRedactConfigFor, + uwtRedactMetadata, + uwtScrubString +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + UwtReporter, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + uwtListenToPage, + UwtTelemetryDocument, + uwtTelemetryDocument +} from '../uwt-telemetry-page/uwt-telemetry-page'; + +/** Handed out while telemetry is off: every call does nothing. */ +const INERT_LOGGER: UwtLogger = Object.freeze({ + log: () => undefined, + warn: () => undefined, + error: () => undefined +}); + +/** + * What {@link UwtLogTelemetry} needs. + * + * @group Interfaces + */ +export interface UwtLogTelemetryOptions { + /** The application's identity, shared by every concern. */ + identity: UwtTelemetryIdentity; + /** Logging settings, complete; see {@link uwtResolveTelemetryConfig}. */ + config: UwtLogConfig; + /** + * Redaction settings shared by every concern. `config.redact: false` + * turns their configurable part off for log records. + */ + redact: UwtRedactConfig; + /** The application's log store: where records go, and are read back from. */ + logApi: UwtLogApiProvider; + /** + * The telemetry destination, for enrichment only: records carry its + * `sessionId` and `userId`, and `config.mirrorErrorsToRum` reports errors + * to it. Optional. + */ + sink?: UwtTelemetrySink | null; + /** `false` makes every logger and query inert. Default `true`. */ + enabled?: boolean; + /** + * The page whose lifecycle flushes the log store: on `pagehide`, and when + * the page is hidden. Defaults to the global `document` where there is + * one; `null` listens to nothing. + */ + document?: UwtTelemetryDocument | null; + /** Receives what is handled silently; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Structured application logging. + * + * Log records go to the application's {@link UwtLogApiProvider}, **not** to + * the telemetry destination — RUM is a sampled, session-capped analytics + * stream, while logs need full fidelity and their own retention. + * + * Console output is off unless the `debugLogger` LocalStorage flag is set, in + * any environment: + * + * ```js + * localStorage.setItem('debugLogger', 'true'); + * ``` + * + * Every record carries a logger name, so this writes nothing itself — + * {@link getLogger} hands back the {@link UwtLogger} you call. + * + * @example + * ```typescript + * const logger = telemetry.logging.getLogger('checkout'); + * + * try { + * // … + * } catch (error) { + * logger.error('Failed to load customer data', { + * error, + * correlationId: scenario.id + * }); + * } + * ``` + * + * @group Classes + */ +export class UwtLogTelemetry { + private readonly identity: UwtTelemetryIdentity; + private readonly config: UwtLogConfig; + private readonly redact: UwtRedactConfig; + private readonly logApi: UwtLogApiProvider; + private readonly sink: UwtTelemetrySink | null; + private readonly enabled: boolean; + private readonly report?: UwtReporter; + private readonly stopListening: () => void; + private disposed = false; + + /** + * One logger per name, so {@link getLogger} is idempotent. + * + * Unbounded growth is not a concern the way it is for active scenarios: + * names come from the closed {@link UwtLoggerNames} vocabulary, so this + * map is bounded by what the application declared at build time. + */ + private readonly loggers = new Map(); + + constructor(options: UwtLogTelemetryOptions) { + this.identity = options.identity; + this.config = options.config; + this.redact = uwtRedactConfigFor(options.redact, options.config.redact); + this.logApi = options.logApi; + this.sink = options.sink ?? null; + this.enabled = options.enabled !== false; + this.report = options.report; + // Page listeners: only while telemetry is on. + this.stopListening = this.enabled + ? uwtListenToPage( + uwtTelemetryDocument(options.document), + () => this.flushProvider(), + () => this.flushProvider() + ) + : () => undefined; + } + + /** + * Reads records back from the application's log store. Useful for + * pulling one journey together — every line written during a scenario + * shares its id as the correlation id: + * + * @example + * ```typescript + * const lines = await telemetry.logging.query({ correlationId: scenario.id }); + * ``` + * + * Fail-open: a provider that throws or rejects resolves to `[]`. + * + * @param filter narrows what is returned + * @returns the matching records, or `[]` when none can be read + */ + async query(filter: UwtLogQuery = {}): Promise { + if (!this.enabled) { + return []; + } + const pending = uwtSafe( + 'logger.query', + () => this.logApi.query(filter), + undefined, + this.report + ); + + return (await pending?.catch(() => undefined)) ?? []; + } + + /** + * Returns the named logger for one part of the application — the only + * way to write a log record. + * + * The name lands on every record as `logger`, and four things key off + * it: {@link UwtLogConfig.levels} per-logger severity floors, the + * `debugLogger` flag's comma-separated filter, `query({ logger })` + * against the log store, and the console line's prefix. A record + * without one is reachable by none of them, which is why there is no + * unnamed `log`/`warn`/`error` here. + * + * The name is identity, not a label: asking twice returns the very same + * logger, the way a file name always refers to one file. + * + * @param name the logger name, declared in {@link UwtLoggerNames} + * @returns the logger for that name, created once and reused; an inert + * one while telemetry is off + */ + getLogger(name: UwtLoggerName): UwtLogger { + if (!this.enabled) { + return INERT_LOGGER; + } + const cached = uwtSafe( + 'logger.getLogger', + () => this.loggers.get(name), + undefined, + this.report + ); + if (cached) { + return cached; + } + + const logger = this.createLogger(name); + uwtSafeVoid( + 'logger.register', + () => this.loggers.set(name, logger), + this.report + ); + return logger; + } + + /** + * Stops watching the page and gives the log store its last chance to + * ship what it holds. Loggers keep writing to the log store, and + * {@link query} keeps reading it: it is the application's, and logging + * holds no timer, listener or channel to release. That matters when + * something logs while the application shuts down, after this was + * disposed. Calling it again does nothing. + */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.stopListening(); + if (this.enabled) { + this.flushProvider(); + } + } + + /** + * Builds the logger for one name. + * + * Private, and reached only through {@link getLogger}, so every logger + * handed out is registered and named. The name is captured here rather + * than read from each call's detail, which is why a record's `logger` + * cannot disagree with the logger that wrote it. + * + * @param name the logger name stamped onto every record it writes + * @returns a logger bound to that name + */ + private createLogger(name: UwtLoggerName): UwtLogger { + return { + log: (message, detail) => this.emit('log', name, message, detail), + warn: (message, detail) => this.emit('warn', name, message, detail), + error: (message, detail) => this.emit('error', name, message, detail) + }; + } + + /** Severity floor for one logger — its own override, or the global one. */ + private minLevelFor(logger: UwtLoggerName): UwtLogLevel { + const { levels, minLevel } = this.config; + return levels?.[logger] ?? minLevel; + } + + private emit( + level: UwtLogLevel, + logger: UwtLoggerName, + message: string, + detail?: UwtLogDetail + ): void { + uwtSafeVoid( + `logger.${level}`, + () => { + if ( + UWT_LOG_LEVEL_ORDER[level] < + UWT_LOG_LEVEL_ORDER[this.minLevelFor(logger)] + ) { + return; + } + + const record = this.buildRecord(level, logger, message, detail); + + uwtDebugWrite( + 'debugLogger', + () => writeToConsole(record), + record.logger, + this.report + ); + + this.deliver(record); + + if (level === 'error' && this.config.mirrorErrorsToRum && this.sink) { + const mirrored = + record.error ?? + uwtNormalizeError( + new Error(record.message), + this.redact, + this.report + ); + if (mirrored) { + this.sink.recordError(mirrored); + } + } + }, + this.report + ); + } + + /** Guards against a throwing or secretly-async, rejecting provider. */ + private deliver(record: UwtLogRecord): void { + uwtSafeVoidMaybeAsync( + 'logger.deliver', + () => this.logApi.send(record), + this.report + ); + } + + /** Gives the provider its chance to ship whatever it has queued itself. */ + private flushProvider(): void { + uwtSafeVoidMaybeAsync( + 'logger.providerFlush', + () => this.logApi.flush?.(), + this.report + ); + } + + private buildRecord( + level: UwtLogLevel, + logger: UwtLoggerName, + message: string, + detail?: UwtLogDetail + ): UwtLogRecord { + const { identity, redact, report, sink } = this; + + return { + timestamp: new Date().toISOString(), + level, + message: uwtScrubString(String(message ?? ''), redact, report), + logger, + context: detail?.context + ? uwtScrubString(detail.context, redact, report) + : undefined, + metadata: uwtRedactMetadata(detail?.metadata, redact, report), + error: uwtNormalizeError(detail?.error, redact, report), + correlationId: detail?.correlationId + ? uwtScrubString(detail.correlationId, redact, report) + : undefined, + application: identity.application, + environment: identity.environment, + version: identity.version, + ...(sink && { + userId: uwtSafe('getUserId', () => sink.getUserId(), undefined, report), + sessionId: uwtSafe( + 'getSessionId', + () => sink.getSessionId(), + undefined, + report + ) + }) + }; + } +} + +/** + * Prints the record exactly as the transport receives it. + * + * Prefixed with the application - in a composed + * page every realm writes to the one console - then the logger name and + * the context, each when present. + */ +function writeToConsole(record: UwtLogRecord): void { + const scope = [record.logger, record.context] + .filter(Boolean) + .map((part) => `[${part}]`) + .join(''); + const suffix = record.correlationId ? ` (${record.correlationId})` : ''; + + console[record.level]( + `[${record.application}]${scope} ${record.message}${suffix}`, + record + ); +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts new file mode 100644 index 0000000..f75e729 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts @@ -0,0 +1,332 @@ +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UwtScenarioTelemetryConfig } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { + UwtScenarioName, + UwtScenarioOptions, + UwtScenarioRecord +} from '../../models/uwt-scenario.models/uwt-scenario.models'; +import { UwtScenario } from '../../scenario/uwt-scenario/uwt-scenario'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { + UwtRedactConfig, + uwtRedactConfigFor +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + uwtDeepClone, + uwtReport, + UwtReporter, + uwtSafe, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + uwtListenToPage, + UwtTelemetryDocument, + uwtTelemetryDocument +} from '../uwt-telemetry-page/uwt-telemetry-page'; + +/** + * How many scenarios may be in flight before a warning suggests a leak. + * + * Concurrent scenarios are legitimate — several journeys, tabs or panels + * can run at once — so this is a smell threshold, not a limit: nothing is + * dropped or rejected when it is crossed. Set well above any plausible + * real concurrency, so crossing it might mean scenarios with `timeoutMs: 0` or + * long lifecycles are being started and never settled. + */ +const ACTIVE_SCENARIO_WARN_THRESHOLD = 50; + +/** + * What {@link UwtScenarioTelemetry} needs. + * + * @group Interfaces + */ +export interface UwtScenarioTelemetryOptions { + /** The application's identity, shared by every concern. */ + identity: UwtTelemetryIdentity; + /** Scenario settings, complete; see {@link uwtResolveTelemetryConfig}. */ + config: UwtScenarioTelemetryConfig; + /** + * Redaction settings shared by every concern. `config.redact: false` + * turns their configurable part off for scenarios. + */ + redact: UwtRedactConfig; + /** Where settled scenarios and their steps go. */ + sink: UwtTelemetrySink; + /** `false` makes every scenario inert. Default `true`. */ + enabled?: boolean; + /** + * The page whose lifecycle settles and flushes scenarios: in-flight ones + * are settled as abandoned on `pagehide`, and the sink is flushed when the + * page is hidden. Defaults to the global `document` where there is one; + * `null` listens to nothing. + */ + document?: UwtTelemetryDocument | null; + /** + * Runs timer scheduling outside the host framework's change detection, + * when it has one: Angular passes `NgZone.runOutsideAngular`. + */ + runOutside?: (callback: () => T) => T; + /** Receives what is handled silently; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Creates and tracks scenarios — user journeys whose health telemetry + * measures. + * + * Each {@link start} call returns an independent {@link UwtScenario}; there is + * no "current" scenario, so overlapping journeys are tracked separately. + * Scenarios still in flight when the page unloads are settled as + * `abandoned`, and so are those still running when this is disposed. + * + * @example + * ```typescript + * const scenario = telemetry.scenarios.start({ name: 'load-dashboard' }); + * try { + * scenario.step('fetch-widgets'); + * const widgets = await api.widgets(); + * scenario.complete({ metadata: { widgetCount: widgets.length } }); + * } catch (error) { + * scenario.fail({ error }); + * } + * ``` + * + * @group Classes + */ +export class UwtScenarioTelemetry { + private readonly identity: UwtTelemetryIdentity; + private readonly config: UwtScenarioTelemetryConfig; + private readonly redact: UwtRedactConfig; + private readonly sink: UwtTelemetrySink; + private readonly enabled: boolean; + private readonly runOutside?: (callback: () => T) => T; + private readonly report?: UwtReporter; + + private readonly active = new Map(); + private readonly listeners = new Set<(record: UwtScenarioRecord) => void>(); + private readonly stopListening: () => void; + private disposed = false; + + constructor(options: UwtScenarioTelemetryOptions) { + this.identity = options.identity; + this.config = options.config; + this.redact = uwtRedactConfigFor(options.redact, options.config.redact); + this.sink = options.sink; + this.enabled = options.enabled !== false; + this.runOutside = options.runOutside; + this.report = options.report; + // Page listeners: only while telemetry is on. + this.stopListening = this.enabled + ? uwtListenToPage( + uwtTelemetryDocument(options.document), + () => this.flushActive(), + () => this.flushSink() + ) + : () => undefined; + } + + /** + * Starts a new scenario. + * + * @param options the scenario name and optional classification, timeout and + * attributes + * @returns an independent scenario instance; an inert one while telemetry + * is off + */ + start(options: UwtScenarioOptions): UwtScenario { + if (!this.enabled) { + // Same return type, nothing recorded, timed, registered or sent. + return new UwtScenario(options, { + identity: this.identity, + scenarioTelemetryConfig: this.config, + redact: this.redact, + sink: this.sink, + onSettled: () => undefined, + inert: true + }); + } + // Nothing here depends on whether this was disposed: something may still + // start a scenario while an application shuts down, and the destination is + // the caller's. Such a scenario is a normal one, with its own timeout. + const scenario = new UwtScenario(options, { + identity: this.identity, + scenarioTelemetryConfig: this.config, + redact: this.redact, + sink: this.sink, + runOutside: this.runOutside, + report: this.report, + onSettled: (scenarioId, record) => { + this.active.delete(scenarioId); + this.notify(record); + } + }); + + uwtSafeVoid( + 'scenarioTelemetry.register', + () => { + this.active.set(scenario.id, scenario); + if (this.active.size > ACTIVE_SCENARIO_WARN_THRESHOLD) { + uwtReport( + this.report, + 'warn', + `High number of active scenarios (${this.active.size}). Ensure scenarios with timeoutMs: 0 or long lifecycles are settled on destroy.` + ); + } + }, + this.report + ); + + return scenario; + } + + /** + * Looks up a scenario that has not yet settled. + * + * @param scenarioId the id to look up + * @returns the scenario, or `undefined` if unknown or already settled + */ + find(scenarioId: string): UwtScenario | undefined { + return this.active.get(scenarioId); + } + + /** + * Every active scenario with the given name. + * + * `name` is a metric dimension, not a concurrency key - any number of + * scenarios can legitimately share one (two tabs, two dashboard panels, + * two independently-edited rows). Use this to inspect what's currently + * running under a name and decide for yourself, e.g. before starting + * another one. + * + * @param name the scenario name to match + * @returns matching active scenarios, in start order + */ + findByName(name: UwtScenarioName): UwtScenario[] { + return this.getActive().filter((scenario) => scenario.name === name); + } + + /** + * Looks up an active scenario by id, asserting it has the expected name. + * + * The same lookup as {@link find}, with an extra check that its `name` + * matches - a defensive assertion for a caller that already expects a + * specific scenario name at this id, rather than a bare id lookup. + * + * @param name the expected scenario name + * @param scenarioId the id to look up + * @returns the scenario, or `undefined` if not active or its name + * doesn't match + */ + findByNameAndId( + name: UwtScenarioName, + scenarioId: string + ): UwtScenario | undefined { + const scenario = this.find(scenarioId); + return scenario?.name === name ? scenario : undefined; + } + + /** + * Every scenario currently in flight. + * + * @returns the active scenarios, in start order + */ + getActive(): UwtScenario[] { + return [...this.active.values()]; + } + + /** + * Calls `listener` with every scenario record as it settles. + * + * Useful for reacting to outcomes without wrapping every call site — a + * debug overlay, a retry prompt, a test harness asserting on journeys. + * Listeners share one copy of each record, so none of them can change + * what the sink receives; a throwing listener affects no other. + * + * @param listener called once per settled scenario + * @returns stops calling `listener` + */ + onSettled(listener: (record: UwtScenarioRecord) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Stops watching the page, settles every scenario still in flight as + * abandoned and flushes the sink, then forgets the listeners. Nothing else + * changes: a scenario started later is a normal one, with its own timeout. + * Calling it again does nothing. + */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.stopListening(); + if (this.enabled) { + this.flushActive(); + } + this.listeners.clear(); + } + + private notify(record: UwtScenarioRecord): void { + if (!this.listeners.size) { + return; + } + // Copied so a listener can't mutate the same object that's about to be + // (or already was) shipped to the sink. + const copy = uwtSafe( + 'scenarioTelemetry.notify', + () => uwtDeepClone(record), + undefined, + this.report + ); + if (!copy) { + return; + } + for (const listener of [...this.listeners]) { + uwtSafeVoid( + 'scenarioTelemetry.notify', + () => listener(copy), + this.report + ); + } + } + + /** + * Dispatches what the sink holds, leaving running scenarios alone. + * + * Uses the beacon transport since the page may not survive a normal request. + */ + private flushSink(): void { + uwtSafeVoid( + 'scenarioTelemetry.flushSink', + () => this.sink.flush(true), + this.report + ); + } + + /** + * Settles every in-flight scenario as abandoned and asks the sink to send + * what it holds using a transport that survives unload. + * + * Scenario emission is synchronous, so everything settled here reaches the + * sink before the beacon goes out. + */ + private flushActive(): void { + uwtSafeVoid( + 'scenarioTelemetry.flush', + () => { + for (const scenario of this.getActive()) { + scenario.settle('abandoned', { + reason: 'page-hidden', + metadata: { abandonedBy: 'page-hidden' } + }); + } + this.sink.flush(true); + }, + this.report + ); + } +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry-page/uwt-telemetry-page.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry-page/uwt-telemetry-page.ts new file mode 100644 index 0000000..e6d89fa --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry-page/uwt-telemetry-page.ts @@ -0,0 +1,56 @@ +/** Something telemetry can listen to: a page's window, or its document. */ +interface UwtListenable { + addEventListener(type: TEvent, listener: () => void): void; + removeEventListener(type: TEvent, listener: () => void): void; +} + +/** + * The part of a page telemetry watches: whether it is visible, and its + * window's `pagehide`. A browser `Document` is one; so is a test double. + * Typed structurally, so using the engine needs no DOM typings. + * + * @group Interfaces + */ +export interface UwtTelemetryDocument extends UwtListenable<'visibilitychange'> { + readonly visibilityState: string; + readonly defaultView: UwtListenable<'pagehide'> | null; +} + +/** + * The page to watch: the one given, the global `document` where there is one + * when none is, and nothing for `null`. + */ +export function uwtTelemetryDocument( + document: UwtTelemetryDocument | null | undefined +): UwtTelemetryDocument | null { + return document === undefined ? (globalThis.document ?? null) : document; +} + +/** + * Calls `onPageHide` when the page is going away (`pagehide`) and `onHidden` + * when it moves to the background (`visibilitychange` to hidden). + * + * @param document the page, or `null` to listen to nothing + * @returns removes both listeners + */ +export function uwtListenToPage( + document: UwtTelemetryDocument | null, + onPageHide: () => void, + onHidden: () => void +): () => void { + if (!document) { + return () => undefined; + } + const view = document.defaultView; + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + onHidden(); + } + }; + view?.addEventListener('pagehide', onPageHide); + document.addEventListener('visibilitychange', onVisibilityChange); + return () => { + view?.removeEventListener('pagehide', onPageHide); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts new file mode 100644 index 0000000..333ed18 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts @@ -0,0 +1,702 @@ +/** + * @jest-environment jsdom + */ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { UwtScenarioRecord } from '../../models/uwt-scenario.models/uwt-scenario.models'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UWT_REDACTED } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import * as uwtTelemetrySafeUtil from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UwtTelemetryOptions, uwtCreateTelemetry } from './uwt-telemetry'; + +/** Keeps what the engine sends, so a test can assert on it. */ +class RecordingSink extends UwtTelemetrySink { + readonly events: { eventType: string; payload: Record }[] = + []; + + readonly errors: UwtTelemetryError[] = []; + readonly flushes: (boolean | undefined)[] = []; + userId: string | undefined; + + record(eventType: string, payload: object): void { + this.events.push({ + eventType, + payload: payload as Record + }); + } + + recordError(error: UwtTelemetryError, _meta?: UwtTelemetryMetadata): void { + this.errors.push(error); + } + + getSessionId(): string | undefined { + return 'session-1'; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(beacon?: boolean): void { + this.flushes.push(beacon); + } + + ofType(eventType: string): Record[] { + return this.events + .filter((e) => e.eventType === eventType) + .map((e) => e.payload); + } +} + +/** A log store kept in memory. */ +class RecordingLogApi implements UwtLogApiProvider { + readonly records: UwtLogRecord[] = []; + flushCount = 0; + + send(record: UwtLogRecord): void { + this.records.push(record); + } + + query(filter: UwtLogQuery): Promise { + return Promise.resolve( + this.records.filter((r) => !filter.logger || r.logger === filter.logger) + ); + } + + flush(): void { + this.flushCount++; + } +} + +const identity = { + application: 'cart', + environment: 'test', + version: '1.0.0', + eventNamespace: 'com.cart' +}; + +function create(overrides: Partial = {}) { + const sink = new RecordingSink(); + const logApi = new RecordingLogApi(); + const telemetry = uwtCreateTelemetry({ + identity, + destination: sink, + logApi, + scenarios: { defaultTimeoutMs: 0 }, + ...overrides + }); + return { telemetry, sink, logApi }; +} + +describe('uwtCreateTelemetry', () => { + let engines: { dispose(): void }[]; + + beforeEach(() => { + engines = []; + }); + + afterEach(() => { + engines.forEach((engine) => engine.dispose()); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + function engine(overrides: Partial = {}) { + const created = create(overrides); + engines.push(created.telemetry); + return created; + } + + describe('scenarios', () => { + it('should send a settled scenario to the destination, with its steps and identity', () => { + const { telemetry, sink } = engine(); + + telemetry.scenarios.start({ name: 'checkout' }).step('pay').complete(); + + const [record] = sink.ofType('com.cart.scenario'); + expect(record).toMatchObject({ + scenarioName: 'checkout', + status: 'success', + application: 'cart', + sessionId: 'session-1' + }); + expect((record.steps as { name: string }[]).map((s) => s.name)).toEqual([ + 'scenario-start', + 'pay', + 'scenario-end' + ]); + }); + + it('should track what is in flight until it settles', () => { + const { telemetry } = engine(); + + const a = telemetry.scenarios.start({ name: 'checkout' }); + const b = telemetry.scenarios.start({ name: 'search' }); + expect(telemetry.scenarios.getActive()).toEqual([a, b]); + expect(telemetry.scenarios.findByName('checkout')).toEqual([a]); + expect( + telemetry.scenarios.findByNameAndId('search', a.id) + ).toBeUndefined(); + + a.complete(); + expect(telemetry.scenarios.find(a.id)).toBeUndefined(); + expect(telemetry.scenarios.getActive()).toEqual([b]); + }); + + it('should give every settled listener one shared copy, so none can change what is sent', () => { + const { telemetry, sink } = engine(); + const seen: UwtScenarioRecord[] = []; + telemetry.scenarios.onSettled((record) => { + seen.push(record); + record.metadata = { tampered: true }; + }); + telemetry.scenarios.onSettled((record) => seen.push(record)); + + telemetry.scenarios + .start({ name: 'checkout' }) + .complete({ metadata: { real: true } }); + + expect(seen).toHaveLength(2); + expect(seen[0]).toBe(seen[1]); + expect(sink.ofType('com.cart.scenario')[0].metadata).toEqual({ + real: true + }); + }); + + it('should keep calling other listeners when one throws, and report it', () => { + const report = jest.fn(); + const { telemetry } = engine({ report }); + const seen: string[] = []; + telemetry.scenarios.onSettled(() => { + throw new Error('listener broke'); + }); + telemetry.scenarios.onSettled((record) => seen.push(record.scenarioName)); + + expect(() => + telemetry.scenarios.start({ name: 'checkout' }).complete() + ).not.toThrow(); + + expect(seen).toEqual(['checkout']); + expect(report).toHaveBeenCalledWith( + 'error', + 'scenarioTelemetry.notify failed', + expect.any(Error) + ); + }); + + it('should stop calling a listener once it unsubscribes', () => { + const { telemetry } = engine(); + const listener = jest.fn(); + const stop = telemetry.scenarios.onSettled(listener); + + stop(); + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should settle a scenario that outlives its timeout, scheduling the timer through runOutside', () => { + jest.useFakeTimers(); + let outside = 0; + const runOutside = (callback: () => T): T => { + outside++; + return callback(); + }; + const { telemetry, sink } = engine({ + runOutside, + scenarios: { defaultTimeoutMs: 1_000 } + }); + + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + expect(outside).toBe(1); + + jest.advanceTimersByTime(1_000); + + expect(scenario.status).toBe('timeout'); + expect(sink.ofType('com.cart.scenario')[0].status).toBe('timeout'); + }); + }); + + describe('BI events', () => { + it('should send an event, redacted, and collapse an identical one fired straight after', () => { + const { telemetry, sink } = engine(); + + telemetry.bi.track('export_clicked', { token: 'x', format: 'csv' }); + telemetry.bi.track('export_clicked', { token: 'x', format: 'csv' }); + + const events = sink.ofType('com.cart.bi'); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventName: 'export_clicked', + application: 'cart', + metadata: { token: UWT_REDACTED, format: 'csv' } + }); + }); + }); + + describe('logging', () => { + it('should send records at or above the floor, stamped and redacted', async () => { + const { telemetry, logApi } = engine({ + logging: { minLevel: 'warn', levels: { audit: 'log' } } + }); + + telemetry.logging.getLogger('checkout').log('dropped'); + telemetry.logging + .getLogger('checkout') + .warn('Slow', { metadata: { token: 'x' } }); + telemetry.logging.getLogger('audit').log('kept'); + + expect(logApi.records.map((r) => r.message)).toEqual(['Slow', 'kept']); + expect(logApi.records[0]).toMatchObject({ + level: 'warn', + logger: 'checkout', + application: 'cart', + environment: 'test', + version: '1.0.0', + sessionId: 'session-1', + metadata: { token: UWT_REDACTED } + }); + expect(await telemetry.logging.query({ logger: 'audit' })).toHaveLength( + 1 + ); + }); + + it('should hand out the same logger for the same name', () => { + const { telemetry } = engine(); + expect(telemetry.logging.getLogger('checkout')).toBe( + telemetry.logging.getLogger('checkout') + ); + }); + + it('should mirror errors to the destination when asked', () => { + const { telemetry, sink } = engine({ + logging: { mirrorErrorsToRum: true } + }); + + telemetry.logging.getLogger('checkout').error('Payment failed'); + + expect(sink.errors).toEqual([ + expect.objectContaining({ message: 'Payment failed' }) + ]); + }); + + it('should read back nothing, rather than fail, from a store that throws', async () => { + const { telemetry, logApi } = engine(); + jest.spyOn(logApi, 'query').mockImplementation(() => { + throw new Error('store is down'); + }); + + expect(await telemetry.logging.query()).toEqual([]); + }); + }); + + describe('configuration', () => { + it('should apply redaction overrides to every concern', () => { + const { telemetry, sink, logApi } = engine({ + redaction: { extraKeyPatterns: [/^internal/i] } + }); + + telemetry.bi.track('export_clicked', { internalRef: 'x' }); + telemetry.logging + .getLogger('checkout') + .warn('x', { metadata: { internalRef: 'x' } }); + + expect(sink.ofType('com.cart.bi')[0].metadata).toEqual({ + internalRef: UWT_REDACTED + }); + expect(logApi.records[0].metadata).toEqual({ + internalRef: UWT_REDACTED + }); + }); + + it('should turn off configurable redaction for one concern only', () => { + const { telemetry, sink } = engine({ + bi: { redact: false }, + redaction: { scanValuePatterns: ['email'] } + }); + + telemetry.bi.track('export_clicked', { note: 'jane@example.com' }); + telemetry.scenarios + .start({ name: 'checkout', metadata: { note: 'jane@example.com' } }) + .complete(); + + expect(sink.ofType('com.cart.bi')[0].metadata).toEqual({ + note: 'jane@example.com' + }); + expect(sink.ofType('com.cart.scenario')[0].metadata).toEqual({ + note: UWT_REDACTED + }); + }); + + it('should throw when the identity, destination or log store is missing', () => { + expect(() => + uwtCreateTelemetry({} as unknown as UwtTelemetryOptions) + ).toThrow( + '[ui-watchtower] uwtCreateTelemetry needs identity.application, identity.environment, identity.version, destination, logApi' + ); + }); + + it('should throw for an identity without its environment and version, which every record carries', () => { + expect(() => + create({ + identity: { application: 'cart' } as UwtTelemetryOptions['identity'] + }) + ).toThrow( + '[ui-watchtower] uwtCreateTelemetry needs identity.environment, identity.version' + ); + }); + }); + + describe('failures', () => { + it('should never let a broken destination reach the application, and report it', () => { + const report = jest.fn(); + const { telemetry, sink } = engine({ report }); + jest.spyOn(sink, 'record').mockImplementation(() => { + throw new Error('destination is down'); + }); + + expect(() => { + telemetry.scenarios.start({ name: 'checkout' }).complete(); + telemetry.bi.track('export_clicked'); + }).not.toThrow(); + + expect(report).toHaveBeenCalledWith( + 'error', + 'scenario.success failed', + expect.any(Error) + ); + expect(report).toHaveBeenCalledWith( + 'error', + 'biTelemetry.track failed', + expect.any(Error) + ); + }); + + it('should print nothing without a reporter', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const { telemetry, sink } = engine(); + jest.spyOn(sink, 'record').mockImplementation(() => { + throw new Error('destination is down'); + }); + + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(consoleError).not.toHaveBeenCalled(); + }); + }); + + describe('disabled', () => { + it('should record, send, log and watch nothing', async () => { + jest.useFakeTimers(); + const listen = jest.spyOn(document, 'addEventListener'); + const { telemetry, sink, logApi } = engine({ + enabled: false, + scenarios: { defaultTimeoutMs: 1_000 } + }); + + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + scenario.step('pay').complete(); + telemetry.bi.track('export_clicked'); + telemetry.logging.getLogger('checkout').error('x'); + telemetry.setUserId('user-1'); + telemetry.flush(); + + expect(scenario.status).toBeUndefined(); + expect(sink.events).toEqual([]); + expect(sink.userId).toBeUndefined(); + expect(sink.flushes).toEqual([]); + expect(logApi.records).toEqual([]); + expect(await telemetry.logging.query()).toEqual([]); + expect(listen).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + }); + }); + + describe('the page', () => { + function hide(state: 'hidden' | 'visible'): void { + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue(state); + document.dispatchEvent(new Event('visibilitychange')); + } + + it('should settle what is in flight as abandoned, and flush, when the page goes away', () => { + const { telemetry, sink, logApi } = engine(); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + + window.dispatchEvent(new Event('pagehide')); + + expect(scenario.status).toBe('abandoned'); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + + it('should flush, without settling, when the page is hidden', () => { + const { telemetry, sink, logApi } = engine(); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + + hide('hidden'); + + expect(scenario.isSettled).toBe(false); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + + it('should watch no page given null', () => { + const { telemetry, sink } = engine({ document: null }); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + + window.dispatchEvent(new Event('pagehide')); + hide('hidden'); + + expect(scenario.isSettled).toBe(false); + expect(sink.flushes).toEqual([]); + }); + }); + + describe('setUserId and flush', () => { + it('should pass the user to the destination, and flush it and the log store', () => { + const { telemetry, sink, logApi } = engine(); + + telemetry.setUserId('user-1'); + telemetry.flush(true); + + expect(sink.userId).toBe('user-1'); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + }); + + describe('dispose', () => { + it('should settle what is in flight as abandoned, flush once, and stop watching the page', () => { + jest.useFakeTimers(); + const { telemetry, sink, logApi } = engine({ + scenarios: { defaultTimeoutMs: 60_000 } + }); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + expect(jest.getTimerCount()).toBe(1); + + telemetry.dispose(); + telemetry.dispose(); + + expect(scenario.status).toBe('abandoned'); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + expect(jest.getTimerCount()).toBe(0); + + window.dispatchEvent(new Event('pagehide')); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + + it('should change nothing else afterwards: BI events, the user and flushes still reach the destination', () => { + const { telemetry, sink } = engine(); + telemetry.dispose(); + const flushedByDispose = sink.flushes.length; + + telemetry.bi.track('export_clicked'); + telemetry.setUserId('user-1'); + telemetry.flush(false); + + expect(sink.ofType('com.cart.bi')).toEqual([ + expect.objectContaining({ eventName: 'export_clicked' }) + ]); + expect(sink.userId).toBe('user-1'); + expect(sink.flushes.slice(flushedByDispose)).toEqual([false]); + }); + + it('should forget the listeners it had, and watch the page no more', () => { + const listener = jest.fn(); + const { telemetry, sink } = engine(); + telemetry.scenarios.onSettled(listener); + const flushesBefore = sink.flushes.length; + + telemetry.dispose(); + window.dispatchEvent(new Event('pagehide')); + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(sink.flushes).toHaveLength(flushesBefore + 1); // dispose's own + expect(listener).not.toHaveBeenCalled(); + }); + + it('should treat a scenario started afterwards as a normal one: recorded when ended', () => { + const { telemetry, sink } = engine(); + telemetry.dispose(); + + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(sink.ofType('com.cart.scenario')).toEqual([ + expect.objectContaining({ scenarioName: 'checkout', status: 'success' }) + ]); + }); + + it('should time out a scenario started afterwards and never ended, as any other', () => { + jest.useFakeTimers(); + const { telemetry, sink } = engine({ + scenarios: { defaultTimeoutMs: 5_000 } + }); + telemetry.dispose(); + + telemetry.scenarios.start({ name: 'checkout' }); + jest.advanceTimersByTime(5_000); + + expect(sink.ofType('com.cart.scenario')).toEqual([ + expect.objectContaining({ scenarioName: 'checkout', status: 'timeout' }) + ]); + }); + + it("should keep loggers writing to the log store afterwards: it is the application's, and holds no resource", async () => { + const { telemetry, logApi } = engine(); + const logger = telemetry.logging.getLogger('checkout'); + + telemetry.dispose(); + logger.error('late'); + + expect(logApi.records.map((r) => r.message)).toEqual(['late']); + await expect(telemetry.logging.query({})).resolves.toHaveLength(1); + }); + + it('should dispose each concern only once', () => { + const { telemetry, logApi } = engine(); + + telemetry.logging.dispose(); + telemetry.logging.dispose(); + telemetry.scenarios.dispose(); + telemetry.scenarios.dispose(); + + expect(logApi.flushCount).toBe(1); + }); + }); + + describe('BI event details', () => { + it('should drop an event without a name', () => { + const { telemetry, sink } = engine(); + + telemetry.bi.track('' as never); + + expect(sink.events).toEqual([]); + }); + + it('should write events to the console under debugBI', () => { + localStorage.setItem('debugBI', 'true'); + const log = jest.spyOn(console, 'log').mockImplementation(() => {}); + const { telemetry } = engine(); + + telemetry.bi.track('export_clicked'); + + expect(log).toHaveBeenCalledWith( + '[cart][bi] export_clicked -> com.cart.bi', + expect.objectContaining({ eventName: 'export_clicked' }) + ); + localStorage.removeItem('debugBI'); + }); + + it('should forget expired events first, then the oldest, once it remembers dedupMaxKeys', () => { + jest.useFakeTimers(); + const { telemetry, sink } = engine({ + bi: { dedupWindowMs: 1000, dedupMaxKeys: 2 } + }); + const track = (name: string) => telemetry.bi.track(name as never); + + track('a'); + track('b'); + jest.advanceTimersByTime(1001); + track('c'); // a and b have expired: forgotten + track('d'); + track('e'); // nothing expired: c, the oldest, is forgotten + track('d'); // still remembered: a duplicate + track('c'); // forgotten, so recorded again + + expect(sink.events.map((e) => e.payload.eventName)).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'c' + ]); + }); + }); + + describe('logging details', () => { + it('should hand out a logger that does nothing while off', () => { + const { telemetry, logApi } = engine({ enabled: false }); + const logger = telemetry.logging.getLogger('checkout'); + + logger.log('a'); + logger.warn('b'); + logger.error('c'); + + expect(logApi.records).toEqual([]); + }); + + it('should write records to the console under debugLogger, with logger, context and correlation id', () => { + localStorage.setItem('debugLogger', 'true'); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { telemetry } = engine(); + + telemetry.logging + .getLogger('checkout') + .warn('Slow payment', { context: 'pay', correlationId: 'sc-1' }); + + expect(warn).toHaveBeenCalledWith( + '[cart][checkout][pay] Slow payment (sc-1)', + expect.objectContaining({ message: 'Slow payment' }) + ); + localStorage.removeItem('debugLogger'); + }); + }); + + describe('scenario details', () => { + it('should report a suspicious number of scenarios in flight', () => { + const report = jest.fn(); + const { telemetry } = engine({ report }); + + for (let i = 0; i <= 50; i++) { + telemetry.scenarios.start({ name: 'checkout' }); + } + + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('High number of active scenarios (51)') + ); + }); + + it('should call no listener, and report, when a record cannot be copied for them', () => { + const report = jest.fn(); + const listener = jest.fn(); + const { telemetry, sink } = engine({ report }); + telemetry.scenarios.onSettled(listener); + const copy = uwtTelemetrySafeUtil.uwtDeepClone; + jest + .spyOn(uwtTelemetrySafeUtil, 'uwtDeepClone') + .mockImplementation((value) => { + if ((value as { scenarioName?: unknown })?.scenarioName) { + throw new Error('cannot copy'); + } + return copy(value); + }); + + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(listener).not.toHaveBeenCalled(); + expect(sink.events).toHaveLength(1); + expect(report).toHaveBeenCalledWith( + 'error', + 'scenarioTelemetry.notify failed', + expect.any(Error) + ); + }); + }); +}); diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts new file mode 100644 index 0000000..1c24211 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts @@ -0,0 +1,207 @@ +import { + UwtTelemetryConfigOverrides, + UwtTelemetryIdentity, + uwtResolveTelemetryConfig +} from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UwtLogApiProvider } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { + UwtReporter, + uwtSafeVoid, + uwtSafeVoidMaybeAsync +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UwtBITelemetry } from '../uwt-bi-telemetry/uwt-bi-telemetry'; +import { UwtLogTelemetry } from '../uwt-log-telemetry/uwt-log-telemetry'; +import { UwtScenarioTelemetry } from '../uwt-scenario-telemetry/uwt-scenario-telemetry'; +import { + UwtTelemetryDocument, + uwtTelemetryDocument +} from '../uwt-telemetry-page/uwt-telemetry-page'; + +/** + * What {@link uwtCreateTelemetry} needs: who the application is, where + * telemetry and logs go, and anything to change from the defaults. + * + * @group Interfaces + */ +export interface UwtTelemetryOptions extends UwtTelemetryConfigOverrides { + /** The application's identity, carried by every record and event. */ + identity: UwtTelemetryIdentity; + /** + * Where scenarios, BI events and mirrored errors go — exactly one. + * `new UwtNoopTelemetrySink()` sends them nowhere. + */ + destination: UwtTelemetrySink; + /** + * The application's log store. `new UwtNoopLogApiProvider()` keeps no + * logs, said explicitly. + */ + logApi: UwtLogApiProvider; + /** + * `false` makes everything inert: nothing is recorded, redacted, timed, + * sent or logged, and nothing listens to the page. Default `true`. + */ + enabled?: boolean; + /** + * The page whose lifecycle settles and flushes telemetry. Defaults to the + * global `document` where there is one; `null` listens to nothing, as on + * a server. + */ + document?: UwtTelemetryDocument | null; + /** + * Runs timer scheduling outside the host framework's change detection, + * when it has one. + */ + runOutside?: (callback: () => T) => T; + /** + * Receives what telemetry handles silently: failures it skipped, data it + * dropped. Nothing is reported without one; see {@link UwtReporter}. + */ + report?: UwtReporter; +} + +/** + * A configured telemetry engine: one per application, or per fragment in a + * composed page. + * + * @group Interfaces + */ +export interface UwtTelemetry { + /** Scenarios — user journeys and their health. */ + readonly scenarios: UwtScenarioTelemetry; + /** Business and UX events. */ + readonly bi: UwtBITelemetry; + /** Structured application logs. */ + readonly logging: UwtLogTelemetry; + /** + * Attributes telemetry from now on to an application user, or to nobody + * (`undefined`, or `''`). Passed to the destination. + */ + setUserId(userId: string | undefined): void; + /** + * Sends what the destination and the log store hold. + * + * @param beacon use a transport that survives page unload + */ + flush(beacon?: boolean): void; + /** + * Stops watching the page, settles scenarios still in flight as abandoned + * and flushes. No listener of this engine is left, and nothing it had + * scheduled. That is all it changes: something may still run while an + * application shuts down, and the destination and the log store are + * yours, so scenarios, BI events and logs started afterwards work as + * before. Calling it again does nothing. + */ + dispose(): void; +} + +/** + * Creates a telemetry engine: scenarios, BI events and logging, all sending + * to one destination and one log store. Needs no framework. + * + * @example + * ```typescript + * const telemetry = uwtCreateTelemetry({ + * identity: { application: 'cart', environment: 'prod', version: '1.4.0' }, + * destination: new UwtNoopTelemetrySink(), + * logApi: new UwtNoopLogApiProvider(), + * logging: { minLevel: 'warn' } + * }); + * + * const scenario = telemetry.scenarios.start({ name: 'checkout' }); + * scenario.step('pay'); + * scenario.complete(); + * + * telemetry.dispose(); + * ``` + * + * @param options identity, destination, log store and overrides + * @returns the engine + * @throws when the identity (its application, environment and version), the + * destination or the log store is missing + * + * @group Utils + */ +export function uwtCreateTelemetry(options: UwtTelemetryOptions): UwtTelemetry { + assertOptions(options); + const { identity, destination, logApi, report } = options; + const enabled = options.enabled !== false; + const config = uwtResolveTelemetryConfig(options); + const document = uwtTelemetryDocument(options.document); + const shared = { identity, redact: config.redaction, enabled, report }; + + const scenarios = new UwtScenarioTelemetry({ + ...shared, + config: config.scenarios, + sink: destination, + document, + runOutside: options.runOutside + }); + const bi = new UwtBITelemetry({ + ...shared, + config: config.bi, + sink: destination + }); + const logging = new UwtLogTelemetry({ + ...shared, + config: config.logging, + logApi, + sink: destination, + document + }); + + let disposed = false; + + return { + scenarios, + bi, + logging, + setUserId(userId) { + if (enabled) { + uwtSafeVoid( + 'telemetry.setUserId', + () => destination.setUserId(userId), + report + ); + } + }, + flush(beacon) { + if (enabled) { + uwtSafeVoid('telemetry.flush', () => destination.flush(beacon), report); + uwtSafeVoidMaybeAsync( + 'telemetry.logFlush', + () => logApi.flush?.(), + report + ); + } + }, + dispose() { + if (disposed) { + return; + } + disposed = true; + scenarios.dispose(); + logging.dispose(); + bi.dispose(); + } + }; +} + +/** Configuration errors throw: telemetry fails open, its setup does not. */ +function assertOptions(options: UwtTelemetryOptions): void { + const identity = options?.identity; + const missing = [ + !identity?.application && 'identity.application', + // Strings, as every record carries them: a shell's host rejects a + // forwarded log record without them. + typeof identity?.environment !== 'string' && 'identity.environment', + typeof identity?.version !== 'string' && 'identity.version', + typeof options?.destination?.record !== 'function' && 'destination', + typeof options?.logApi?.send !== 'function' && 'logApi' + ].filter(Boolean); + if (missing.length) { + throw new Error( + `[ui-watchtower] uwtCreateTelemetry needs ${missing.join(', ')}` + ); + } +} diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts b/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts rename to projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts diff --git a/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts b/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts new file mode 100644 index 0000000..676b518 --- /dev/null +++ b/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts @@ -0,0 +1,26 @@ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../uwt-log-api.provider/uwt-log-api.provider'; + +/** + * Log API provider that discards every record and finds none. + * + * The explicit way to say an application has no log backend — never a + * default. Mostly for a shell that hosts fragments but logs nothing: the + * broadcast host requires a log provider, so that one can't be forgotten. + * + * Bound in a shell, it discards its fragments' log records too. + * + * @group Services + */ +export class UwtNoopLogApiProvider implements UwtLogApiProvider { + /** @inheritdoc */ + send(_record: UwtLogRecord): void {} + + /** @inheritdoc */ + query(_filter: UwtLogQuery): Promise { + return Promise.resolve([]); + } +} diff --git a/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts new file mode 100644 index 0000000..1685b15 --- /dev/null +++ b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts @@ -0,0 +1,309 @@ +/** + * @jest-environment jsdom + */ +import { UWT_DEFAULT_TELEMETRY_CONFIG } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UwtScenarioTelemetryConfig } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { UwtScenarioRecord } from '../../models/uwt-scenario.models/uwt-scenario.models'; +import { UWT_TELEMETRY_EVENT_TYPE } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UWT_DEFAULT_REDACT_CONFIG } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { UwtScenario, UwtScenarioDeps } from './uwt-scenario'; + +/** Captures what scenarios emitted. */ +class RecordingSink extends UwtTelemetrySink { + readonly events: { eventType: string; payload: Record }[] = + []; + + record(eventType: string, payload: object): void { + this.events.push({ + eventType, + payload: payload as Record + }); + } + + recordError(): void {} + + getSessionId(): string | undefined { + return 'session-1'; + } + + setUserId(): void {} + + getUserId(): string | undefined { + return 'user-1'; + } + + flush(): void {} +} + +const MAX_TIMEOUT_MS = 2_147_483_647; + +describe('UwtScenario, in detail', () => { + let sink: RecordingSink; + let settled: UwtScenarioRecord[]; + + function scenario( + name: string, + config: Partial = {}, + overrides: Partial & { timeoutMs?: number } = {} + ): UwtScenario { + const { timeoutMs, ...deps } = overrides; + return new UwtScenario( + { name, timeoutMs }, + { + identity: { application: 'shop', environment: 'test', version: '1' }, + scenarioTelemetryConfig: { + ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario, + ...config + }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink, + onSettled: (_id, record) => settled.push(record), + ...deps + } + ); + } + + beforeEach(() => { + sink = new RecordingSink(); + settled = []; + localStorage.clear(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('delta', () => { + it('should grow while running, then hold the duration', () => { + const running = scenario('load'); + expect(running.delta).toBeGreaterThanOrEqual(0); + + running.complete(); + + expect(Math.round(running.delta)).toBe(settled[0].delta); + }); + + it('should be 0, with an empty record, for an inert scenario', () => { + const inert = scenario('load', {}, { inert: true }); + + inert.step('fetch'); + inert.complete(); + + expect(inert.delta).toBe(0); + expect(settled).toEqual([]); + expect(inert.toRecord()).toMatchObject({ + scenarioName: 'load', + delta: 0, + stepCount: 0, + steps: [] + }); + }); + }); + + describe('aggregates', () => { + it('should count calls, ignore an end without a start, and count a call still open at the end', () => { + const load = scenario('load'); + + load.aggregateEnd('render'); + load.aggregateStart('fetch'); + load.aggregateStart('fetch'); + load.aggregateEnd('fetch'); + load.aggregateStart('fetch'); + load.complete(); + + expect(settled[0].aggregates).toEqual([ + { name: 'fetch', elapsed: expect.any(Number), callCount: 2 } + ]); + }); + }); + + describe('the record', () => { + it('should carry the outcome: status code, message and reason', () => { + scenario('search').incomplete({ + statusCode: 404, + message: 'no results', + reason: 'empty' + }); + + expect(settled[0]).toMatchObject({ + status: 'incomplete', + statusCode: 404, + message: 'no results', + reason: 'empty' + }); + }); + + it('should flag a scenario with more steps than allowed', () => { + const busy = scenario('busy', { maxSteps: 2 }); + + busy.step('a').step('b').step('c'); + busy.complete(); + + expect(settled[0].exceededStepsLimit).toBe(true); + }); + + it("should keep a step's message and reason, when it ends or fails", () => { + const load = scenario('load'); + + load.step('fetch').endStep({ message: 'cached', reason: 'warm' }); + load.step('parse').failStep(new Error('bad json'), { reason: 'format' }); + load.complete(); + + const steps = settled[0].steps; + expect(steps.find((s) => s.name === 'fetch')).toMatchObject({ + message: 'cached', + reason: 'warm' + }); + expect(steps.find((s) => s.name === 'parse')).toMatchObject({ + status: 'failure', + reason: 'format' + }); + }); + }); + + describe('step events', () => { + it('should send each step as it ends, with the session and user, when enabled', () => { + const load = scenario('load', { emitLifecycleEvents: true }); + + load.step('fetch'); + load.step('render'); + load.complete(); + + const steps = sink.events.filter( + (e) => e.eventType === UWT_TELEMETRY_EVENT_TYPE.scenarioStep + ); + expect(steps.map((e) => e.payload.name)).toContain('fetch'); + expect(steps[0].payload).toMatchObject({ + scenarioName: 'load', + application: 'shop', + sessionId: 'session-1', + userId: 'user-1' + }); + }); + }); + + describe('user timings', () => { + let timings: { + mark: jest.Mock; + measure: jest.Mock; + clearMarks: jest.Mock; + clearMeasures: jest.Mock; + }; + + beforeEach(() => { + timings = { + mark: jest.fn(), + measure: jest.fn(), + clearMarks: jest.fn(), + clearMeasures: jest.fn() + }; + // jsdom has no User Timing API: stand-ins, removed after each test. + for (const [name, fn] of Object.entries(timings)) { + Object.defineProperty(performance, name, { + value: fn, + configurable: true, + writable: true + }); + } + }); + + afterEach(() => { + for (const name of Object.keys(timings)) { + delete (performance as unknown as Record)[name]; + } + }); + + it('should mark and measure each step and the whole scenario, then clear its marks', () => { + const load = new UwtScenario( + { name: 'load', feature: 'customers' }, + { + identity: { application: 'shop', environment: 'test', version: '1' }, + scenarioTelemetryConfig: { + ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario, + userTimings: true + }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink, + onSettled: () => undefined + } + ); + + load.step('fetch'); + load.step('render'); + load.complete(); + + const measured = timings.measure.mock.calls.map(([name]) => name); + expect(measured).toEqual( + expect.arrayContaining(['load [fetch]', 'load (customers)']) + ); + expect(timings.mark).toHaveBeenCalled(); + expect(timings.clearMarks).toHaveBeenCalled(); + }); + + it('should clear the marks of a scenario that never settles, after the fallback delay', () => { + jest.useFakeTimers({ doNotFake: ['performance'] }); + const endless = scenario( + 'endless', + { userTimings: true, markCleanupFallbackMs: 1000 }, + { timeoutMs: 0 } + ); + + endless.step('waiting'); + expect(timings.clearMarks).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1000); + + expect(timings.clearMarks).toHaveBeenCalled(); + expect(endless.isSettled).toBe(false); + }); + }); + + describe('timeouts', () => { + it('should time out after longer than one timer can wait', () => { + jest.useFakeTimers(); + const long = scenario('long', {}, { timeoutMs: MAX_TIMEOUT_MS + 1000 }); + + jest.advanceTimersByTime(MAX_TIMEOUT_MS); + expect(long.isSettled).toBe(false); + jest.advanceTimersByTime(1000); + + expect(long.status).toBe('timeout'); + }); + + it('should schedule its timeout once, though runOutside hands nothing back', () => { + jest.useFakeTimers(); + const outside = jest.fn((callback: () => unknown) => { + callback(); + }); + + scenario( + 'slow', + {}, + { + timeoutMs: 1000, + runOutside: outside as unknown as UwtScenarioDeps['runOutside'] + } + ); + + expect(outside).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(1); + }); + }); + + describe('debug output', () => { + it('should write each settled scenario to the console under debugScenario', () => { + localStorage.setItem('debugScenario', 'true'); + const log = jest.spyOn(console, 'log').mockImplementation(() => {}); + + scenario('checkout').complete(); + + expect(log).toHaveBeenCalledWith( + expect.stringMatching( + /^\[shop\]\[scenario\] checkout success in \d+ms -> / + ), + expect.objectContaining({ scenarioName: 'checkout' }) + ); + }); + }); +}); diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts similarity index 94% rename from projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts rename to projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts index 95fac2c..59fd3e3 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts +++ b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts @@ -1,5 +1,5 @@ import { UwtNoopTelemetrySink } from './uwt-noop-telemetry.sink'; -import { UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; +import { UwtTelemetrySink } from '../uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; describe('UwtNoopTelemetrySink', () => { let sink: UwtTelemetrySink; diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts new file mode 100644 index 0000000..4f13ce7 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts @@ -0,0 +1,50 @@ +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtTelemetrySink } from '../uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; + +/** + * Sink that discards everything. + * + * The explicit way to send telemetry nowhere — never a default. Debug flags, + * logging and scenario mechanics all still work, with no AWS account or + * `aws-rum-web` dependency needed. Angular selects it with + * `provideUwtTelemetrySink('noop')`. + * + * @group Services + */ +export class UwtNoopTelemetrySink extends UwtTelemetrySink { + private userId?: string; + + /** @inheritdoc */ + record( + _eventType: string, + _payload: object, + _metadata?: UwtTelemetryMetadata + ): void {} + + /** @inheritdoc */ + recordError( + _error: UwtTelemetryError, + _metadata?: UwtTelemetryMetadata + ): void {} + + /** @inheritdoc */ + getSessionId(): string | undefined { + return undefined; + } + + /** @inheritdoc */ + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(_beacon?: boolean): void {} +} diff --git a/projects/ui-watchtower/src/public-api.spec.ts b/projects/ui-watchtower/src/public-api.spec.ts index b167832..c7fa8b2 100644 --- a/projects/ui-watchtower/src/public-api.spec.ts +++ b/projects/ui-watchtower/src/public-api.spec.ts @@ -1,4 +1,9 @@ import * as core from './public-api'; +import { + UwtLogRecord, + UwtNoopTelemetrySink, + uwtCreateTelemetry +} from './public-api'; /** * The core must import, and run, where there is no browser: in Node, on a @@ -10,11 +15,40 @@ describe('the core outside a browser', () => { expect(typeof window).toBe('undefined'); expect(typeof document).toBe('undefined'); expect(typeof localStorage).toBe('undefined'); - expect(Object.keys(core)).toContain('uwtMarkSessionStart'); + expect(Object.keys(core)).toContain('uwtCreateTelemetry'); }); it('should keep no session start, and not throw, without storage', () => { expect(() => core.uwtMarkSessionStart('s1', 1_000)).not.toThrow(); expect(core.uwtSessionElapsed('s1', 2_000)).toBeUndefined(); }); + + it('should run scenarios, BI events and logging, and dispose, with no page', async () => { + const sent: { eventType: string; payload: object }[] = []; + const destination = new (class extends UwtNoopTelemetrySink { + override record(eventType: string, payload: object): void { + sent.push({ eventType, payload }); + } + })(); + const records: UwtLogRecord[] = []; + const telemetry = uwtCreateTelemetry({ + identity: { application: 'worker', environment: 'test', version: '1' }, + destination, + logApi: { + send: (record) => records.push(record), + query: async () => records + } + }); + + telemetry.scenarios.start({ name: 'import' }).step('parse').complete(); + telemetry.bi.track('file_imported', { rows: 3 }); + telemetry.logging.getLogger('import').log('Imported'); + + expect(sent.map((e) => e.eventType)).toEqual([ + 'com.uwt.scenario', + 'com.uwt.bi' + ]); + expect(await telemetry.logging.query()).toHaveLength(1); + expect(() => telemetry.dispose()).not.toThrow(); + }); }); diff --git a/projects/ui-watchtower/src/public-api.ts b/projects/ui-watchtower/src/public-api.ts index 585701e..7cf1fbc 100644 --- a/projects/ui-watchtower/src/public-api.ts +++ b/projects/ui-watchtower/src/public-api.ts @@ -5,9 +5,30 @@ * nothing touches a browser global when imported. */ +// The engine +export { uwtCreateTelemetry } from './lib/engine/uwt-telemetry/uwt-telemetry'; +export type { + UwtTelemetry, + UwtTelemetryOptions +} from './lib/engine/uwt-telemetry/uwt-telemetry'; +export { UwtScenarioTelemetry } from './lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry'; +export type { UwtScenarioTelemetryOptions } from './lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry'; +export { UwtBITelemetry } from './lib/engine/uwt-bi-telemetry/uwt-bi-telemetry'; +export type { UwtBITelemetryOptions } from './lib/engine/uwt-bi-telemetry/uwt-bi-telemetry'; +export { UwtLogTelemetry } from './lib/engine/uwt-log-telemetry/uwt-log-telemetry'; +export type { UwtLogTelemetryOptions } from './lib/engine/uwt-log-telemetry/uwt-log-telemetry'; +export type { UwtTelemetryDocument } from './lib/engine/uwt-telemetry-page/uwt-telemetry-page'; + // Configuration -export { UWT_DEFAULT_TELEMETRY_CONFIG } 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, + uwtResolveTelemetryConfig +} from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +export type { + UwtTelemetryConfig, + UwtTelemetryConfigOverrides, + UwtTelemetryIdentity +} from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; export type { UwtBITelemetryConfig } from './lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; export type { UwtLogConfig } from './lib/config/uwt-log.config/uwt-log.config'; export type { UwtScenarioTelemetryConfig } from './lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; @@ -58,19 +79,27 @@ export type { // Scenarios export { UwtScenario } from './lib/scenario/uwt-scenario/uwt-scenario'; -// Contracts a destination or a log backend implements +// Contracts a destination or a log backend implements, and the explicit +// "nowhere" of each export { UwtTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +export { UwtNoopTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; export type { UwtLogApiProvider, UwtLogQuery } from './lib/providers/uwt-log-api.provider/uwt-log-api.provider'; +export { UwtNoopLogApiProvider } from './lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider'; // Utilities an application or a destination needs export { uwtClassifyTelemetryEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; export type { UwtTelemetrySinkEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; export { uwtIsDebugEnabled } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; export type { UwtDebugFlag } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; -export { uwtUuid } from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +export { + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync, + uwtUuid +} from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; export type { UwtReporter } 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 { @@ -89,11 +118,3 @@ export type { // Used by @absaoss-cps/ngx-ui-watchtower while the engine moves into this // package; removed from the public API once that code lives here too. export { uwtSessionElapsed } from './lib/utils/uwt-session-start.util/uwt-session-start.util'; -export { - uwtDeepClone, - uwtNow, - uwtSafe, - uwtSafeVoid, - uwtSafeVoidMaybeAsync -} from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -export { uwtDebugWrite } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; diff --git a/projects/ui-watchtower/tsconfig.spec.json b/projects/ui-watchtower/tsconfig.spec.json index f0716fe..4271b51 100644 --- a/projects/ui-watchtower/tsconfig.spec.json +++ b/projects/ui-watchtower/tsconfig.spec.json @@ -2,9 +2,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/spec-core", - "rootDir": "./src", + "rootDir": ".", "types": ["jest", "node"], "isolatedModules": false }, - "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] + "include": ["src/**/*.spec.ts", "rxjs/**/*.spec.ts", "src/**/*.d.ts"] } diff --git a/projects/ui-watchtower/type-tests/augmented.ts b/projects/ui-watchtower/type-tests/augmented.ts index 67ff10b..d009bdc 100644 --- a/projects/ui-watchtower/type-tests/augmented.ts +++ b/projects/ui-watchtower/type-tests/augmented.ts @@ -9,6 +9,7 @@ * rejected is caught too. */ import { + UwtTelemetry, UwtBIEvent, UwtBIEventName, UwtLogConfig, @@ -114,3 +115,27 @@ export const options: UwtScenarioOptions = { name: 'chekout' }; export const query: UwtLogQuery = { logger: 'chekout' }; // @ts-expect-error not a registered logger export const levels: UwtLogConfig['levels'] = { chekout: 'log' }; + +/** Through the engine: registered names accepted, everything else rejected. */ +export async function engine(telemetry: UwtTelemetry): Promise { + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + telemetry.scenarios.findByName('load-dashboard'); + telemetry.scenarios.findByNameAndId('checkout', scenario.id); + telemetry.scenarios.onSettled((_record) => { + expectType>(); + }); + telemetry.bi.track('export_clicked'); + telemetry.logging.getLogger('checkout').warn('careful'); + await telemetry.logging.query({ logger: 'checkout' }); + + // @ts-expect-error not a registered scenario + telemetry.scenarios.start({ name: 'chekout' }); + // @ts-expect-error not a registered scenario + telemetry.scenarios.findByName('chekout'); + // @ts-expect-error not a registered business event + telemetry.bi.track('export_clikced'); + // @ts-expect-error not a registered logger + telemetry.logging.getLogger('chekout'); + // @ts-expect-error not a registered logger + await telemetry.logging.query({ logger: 'chekout' }); +} diff --git a/projects/ui-watchtower/type-tests/compat.ts b/projects/ui-watchtower/type-tests/compat.ts index 810cfc9..7769c5d 100644 --- a/projects/ui-watchtower/type-tests/compat.ts +++ b/projects/ui-watchtower/type-tests/compat.ts @@ -5,6 +5,11 @@ * here, so every name is a plain string; see `augmented.ts` for registered * names. */ +import { firstValueFrom, Observable, of } from 'rxjs'; +import { + traceScenario, + UwtTraceScenarioOptions +} from '@absaoss-cps/ui-watchtower/rxjs'; import { UWT_DEFAULT_EVENT_NAMESPACE, UWT_DEFAULT_REDACT_CONFIG, @@ -12,8 +17,11 @@ import { UWT_LOG_LEVEL_ORDER, UWT_REDACTED, UWT_TELEMETRY_EVENT_TYPE, + UwtBITelemetry, UwtBITelemetryConfig, + UwtBITelemetryOptions, uwtClassifyTelemetryEvent, + uwtCreateTelemetry, UwtDebugFlag, uwtEventTypes, uwtIsDebugEnabled, @@ -22,20 +30,35 @@ import { UwtLogLevel, UwtLogQuery, UwtLogRecord, + UwtLogTelemetry, + UwtLogTelemetryOptions, uwtMarkSessionStart, uwtNormalizeError, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink, UwtPiiValuePattern, UwtRedactConfig, uwtRedactConfigFor, uwtRedactMetadata, UwtReporter, + uwtResolveTelemetryConfig, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync, UwtScenario, UwtScenarioRecord, + UwtScenarioTelemetry, UwtScenarioTelemetryConfig, + UwtScenarioTelemetryOptions, uwtScrubString, + UwtTelemetry, + UwtTelemetryConfig, + UwtTelemetryConfigOverrides, + UwtTelemetryDocument, UwtTelemetryError, UwtTelemetryIdentity, UwtTelemetryMetadata, + UwtTelemetryOptions, UwtTelemetrySink, UwtTelemetrySinkEvent, uwtUuid @@ -131,3 +154,87 @@ export function utilities(): unknown[] { UWT_REDACTED ]; } + +/** The engine, as a framework-free application creates and uses it. */ +export async function engine( + page: UwtTelemetryDocument | null +): Promise { + const overrides: UwtTelemetryConfigOverrides = { + logging: { minLevel: 'warn' }, + redaction: { scanValuePatterns: ['email'] } + }; + const options: UwtTelemetryOptions = { + identity, + destination: new UwtNoopTelemetrySink(), + logApi: new UwtNoopLogApiProvider(), + document: page, + runOutside: (callback) => callback(), + report, + ...overrides + }; + const telemetry: UwtTelemetry = uwtCreateTelemetry(options); + const config: UwtTelemetryConfig = uwtResolveTelemetryConfig(overrides); + + const scenario: UwtScenario = telemetry.scenarios.start({ name: 'checkout' }); + const stop = telemetry.scenarios.onSettled((record: UwtScenarioRecord) => + report('warn', record.scenarioName) + ); + const traced: UwtTraceScenarioOptions = { + outcome: (count) => ({ metadata: { count } }) + }; + const piped: Observable = of(1).pipe( + traceScenario(scenario, traced, report) + ); + await firstValueFrom(piped); + telemetry.bi.track( + 'export_clicked', + { format: 'csv' }, + { scenarioId: scenario.id } + ); + telemetry.logging.getLogger('checkout').warn('careful'); + const lines: UwtLogRecord[] = await telemetry.logging.query({ limit: 1 }); + telemetry.setUserId('user-1'); + telemetry.flush(true); + stop(); + telemetry.dispose(); + uwtSafeVoid('app.cleanup', () => undefined, report); + uwtSafeVoidMaybeAsync('app.async', async () => undefined, report); + return uwtSafe( + 'app.count', + () => lines.length + config.bi.dedupWindowMs, + 0, + report + ); +} + +/** Each concern on its own, as an adapter composes them. */ +export function concerns( + sink: UwtTelemetrySink, + logApi: UwtLogApiProvider +): unknown[] { + const config = uwtResolveTelemetryConfig(); + const shared = { identity, redact: config.redaction, report }; + const scenarioOptions: UwtScenarioTelemetryOptions = { + ...shared, + config: config.scenarios, + sink, + document: null + }; + const biOptions: UwtBITelemetryOptions = { + ...shared, + config: config.bi, + sink + }; + const logOptions: UwtLogTelemetryOptions = { + ...shared, + config: config.logging, + logApi, + sink, + document: null + }; + return [ + new UwtScenarioTelemetry(scenarioOptions), + new UwtBITelemetry(biOptions), + new UwtLogTelemetry(logOptions) + ]; +} diff --git a/tsconfig.json b/tsconfig.json index 24ff742..524f134 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,9 @@ "@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/ngx-ui-watchtower": [ "./projects/ngx-ui-watchtower/src/public-api.ts" ],