From aad868faa3210de6dafa206d8e2ed23fb5dbbe30 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 11 Nov 2026 13:56:52 +0100 Subject: [PATCH] refactor: move the AWS RUM sink into the core's rum entry point --- .eslintrc.js | 25 + README.md | 1 + jest.config.js | 7 +- package.json | 2 +- projects/ngx-ui-watchtower/DESIGN.md | 21 +- .../public-api.snapshot.json | 1 + .../uwt-rum-credentials.ts | 345 +----- .../uwt-rum-internal.util.spec.ts | 163 +++ .../uwt-rum-internal.util.ts | 120 +- .../uwt-rum-telemetry.sink.ts | 991 +-------------- .../uwt-broadcast-host.service.ts | 4 +- .../uwt-telemetry-safe.util.spec.ts | 14 + .../uwt-telemetry-safe.util.ts | 24 +- projects/ngx-ui-watchtower/tsconfig.lib.json | 3 +- projects/ui-watchtower/README.md | 76 +- projects/ui-watchtower/jest.config.js | 12 +- projects/ui-watchtower/package.json | 4 + .../ui-watchtower/public-api.snapshot.json | 100 +- projects/ui-watchtower/rum/ng-package.json | 6 + .../uwt-rum-credentials.ts | 335 +++++ .../uwt-rum-telemetry.sink.spec.ts | 180 +++ .../uwt-rum-telemetry.sink.ts | 1088 +++++++++++++++++ projects/ui-watchtower/rum/src/public-api.ts | 19 + projects/ui-watchtower/rum/tsconfig.lib.json | 16 + .../sinks/uwt-broadcast/uwt-broadcast-host.ts | 13 +- .../sinks/uwt-broadcast/uwt-broadcast.spec.ts | 342 +++++- .../uwt-telemetry-safe.util.ts | 39 +- projects/ui-watchtower/src/public-api.ts | 16 +- projects/ui-watchtower/tsconfig.spec.json | 7 +- projects/ui-watchtower/type-tests/compat.ts | 60 +- tsconfig.json | 3 + 31 files changed, 2557 insertions(+), 1480 deletions(-) create mode 100644 projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.spec.ts create mode 100644 projects/ui-watchtower/rum/ng-package.json create mode 100644 projects/ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts create mode 100644 projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts create mode 100644 projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts create mode 100644 projects/ui-watchtower/rum/src/public-api.ts create mode 100644 projects/ui-watchtower/rum/tsconfig.lib.json diff --git a/.eslintrc.js b/.eslintrc.js index b699dea..078305c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -68,6 +68,31 @@ module.exports = { ] } }, + { + // The core's AWS RUM entry point: aws-rum-web, and still no framework. + files: ['projects/ui-watchtower/rum/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@angular/*', + 'zone.js', + '@absaoss-cps/ngx-ui-watchtower', + '@absaoss-cps/ngx-ui-watchtower/*', + 'rxjs', + 'rxjs/*' + ], + message: + 'The core is framework-independent, and this entry point loads no other optional peer.' + } + ] + } + ] + } + }, { // The core's RxJS entry point: RxJS, and still no framework. files: ['projects/ui-watchtower/rxjs/**/*.ts'], diff --git a/README.md b/README.md index 3f96ece..72f94fd 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ How it is designed: [projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watch projects/ui-watchtower/ src/ @absaoss-cps/ui-watchtower — the framework-independent engine rxjs/ secondary entry point — @absaoss-cps/ui-watchtower/rxjs + rum/ secondary entry point — @absaoss-cps/ui-watchtower/rum type-tests/ consumer type tests, without a framework README.md usage guide, published to npm package.json the published package's manifest diff --git a/jest.config.js b/jest.config.js index 312acea..5f0a6d6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,6 +7,8 @@ module.exports = { '/projects/ui-watchtower/src/public-api.ts', '^@absaoss-cps/ui-watchtower/rxjs$': '/projects/ui-watchtower/rxjs/src/public-api.ts', + '^@absaoss-cps/ui-watchtower/rum$': + '/projects/ui-watchtower/rum/src/public-api.ts', '^@absaoss-cps/ngx-ui-watchtower$': '/projects/ngx-ui-watchtower/src/public-api.ts', '^@absaoss-cps/ngx-ui-watchtower/rum$': @@ -34,5 +36,8 @@ module.exports = { '!projects/**/public-api.ts', // Compiled by the typecheck, never run. '!projects/**/type-tests/**' - ] + ], + coverageThreshold: { + global: { statements: 90, branches: 90, functions: 90, lines: 90 } + } }; diff --git a/package.json b/package.json index 408532c..5d3309e 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/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", + "typecheck": "tsc -p projects/ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/rxjs/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ui-watchtower/type-tests/tsconfig.json && tsc -p projects/ui-watchtower/type-tests/tsconfig.augmented.json && ng build --configuration=production --project=ui-watchtower && tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.json && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json", "api:check": "node scripts/api-snapshot.mjs", "api:update": "node scripts/api-snapshot.mjs --update" }, diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index 690f281..d4c7623 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -229,10 +229,16 @@ providers: [ 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. - **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. @@ -1608,12 +1614,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 diff --git a/projects/ngx-ui-watchtower/public-api.snapshot.json b/projects/ngx-ui-watchtower/public-api.snapshot.json index dd88499..37205e3 100644 --- a/projects/ngx-ui-watchtower/public-api.snapshot.json +++ b/projects/ngx-ui-watchtower/public-api.snapshot.json @@ -559,6 +559,7 @@ "new (): UwtRumTelemetrySink", "static ɵfac: unknown", "static ɵprov: ɵɵInjectableDeclaration", + "dispose(): void", "flush(beacon?: boolean | undefined): void", "getSessionId(): string | undefined", "getUserId(): string | undefined", diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts index 52d1630..1424f71 100644 --- a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts @@ -1,341 +1,12 @@ import { InjectionToken } from '@angular/core'; - -/** - * Short-lived AWS credentials for the RUM data plane. - * - * @group Interfaces - */ -export interface UwtRumCredentials { - accessKeyId: string; - secretAccessKey: string; - sessionToken: string; - - /** ISO-8601 expiry. Used to schedule a refresh before the credentials lapse. */ - expiration: string; -} - -/** - * Mirrors `aws-rum-web`'s own `AwsCredentialIdentity | AwsCredentialIdentityProvider` - * union — used only by {@link UwtRumAppMonitorConfig.clientBuilder}. - */ -type ClientBuilderCredentials = - | { accessKeyId: string; secretAccessKey: string; sessionToken?: string } - | (() => Promise<{ - accessKeyId: string; - secretAccessKey: string; - sessionToken?: string; - }>); - -/** - * Settings for the RUM app monitor, mapped onto the underlying `aws-rum-web` - * SDK's own `Config`. Every optional field left unset falls back to a - * default: `allowCookies`, `enableXRay`, `endpoint` and `telemetries` fall - * back to this library's own override; every other field falls back to the - * SDK's own default (see `buildRumConfig()`). - * - * Matches the SDK's `Config` field for field, with three omissions: - * - * - `endpointUrl` — always recomputed by the SDK from `endpoint`; setting it - * independently has no effect. - * - `identityPoolId`, `guestRoleArn` — the SDK's own Cognito guest-identity - * credential flow. AWS credentials come from a backend-vended - * `UwtRumCredentialsProvider` instead (see - * `UwtRumTelemetrySink.applyCredentials`). - * - `sessionId`, `userId` — owned at runtime by `UwtRumTelemetrySink`'s - * `setUserId`, `pinUserId` and `startSession`. - * - * None of the field types below are imported from `aws-rum-web`; every type - * here is hand-written. This keeps `aws-rum-web` an optional peer - * dependency for applications that only use the `broadcast` or `noop` sink. - * - * @group Interfaces - */ -export interface UwtRumAppMonitorConfig { - /** CloudWatch RUM app monitor id. */ - applicationId: string; - - /** AWS region hosting the app monitor, e.g. `eu-west-1`. */ - region: string; - - /** Version string reported with every event. */ - applicationVersion: string; - - // ---- Sampling & session ---- - - /** Fraction of sessions recorded, `0`–`1`. Default `1`. */ - sessionSampleRate?: number; - - /** - * Inactivity timeout of a session, in seconds. Default `1800` (30 minutes). - * Each recorded event pushes the expiry back; the next event after it - * starts a new session. - */ - sessionLengthSeconds?: number; - - /** Hard cap on events recorded per session, across all telemetry. Default `200`. */ - sessionEventLimit?: number; - - /** - * Suppresses the `session_start` event the SDK would otherwise emit at the - * start of a session. Default `false`. The library then doesn't record the - * session's start either, so scenario records carry no `sessionElapsed`. - */ - suppressSessionStartEvent?: boolean; - - /** - * Days the user id is kept in the `cwr_u` cookie. Default `30`. A signed-in - * user's id is kept there too, so on a shared device the next visitor starts - * with it until the app calls `setUserId(undefined)` or someone signs in. - * `0` keeps no user cookie at all: anonymous visitors get the nil id, or a - * random one for the rest of the page after a sign-out. - */ - userIdRetentionDays?: number; - - /** - * Attributes attached to every event in the session, seeded at - * construction. Default `{}`. `UwtRumTelemetrySink` merges `application`, - * `environment` and `appVersion` on top of this after construction; those - * three keys always win. - */ - sessionAttributes?: Record; - - /** - * Attributes attached to every event for the lifetime of the app monitor - * client, independent of session. Default unset. - */ - applicationAttributes?: Record; - - // ---- Telemetry collection & tracing ---- - - /** - * Built-in telemetry categories to enable. Defaults to errors, performance - * and same-origin HTTP. The SDK's own default also includes `'replay'` - * (session replay via `rrweb`); this library does not enable it by - * default. - * - * Options the SDK matches with `RegExp.test` or calls as a function — - * `urlsToInclude`, `urlsToExclude`, `addXRayTraceIdHeader` (also a - * boolean), `ignore`, `interactionId` — must hold exactly that. JSON can't - * carry them, and a string there would make the SDK throw each time it - * applies the option, so RUM doesn't start for the page instead (with a - * console warning naming the option). Set them in code, not in a broker's - * answer. - */ - telemetries?: (string | (string | object)[])[]; - - /** - * Custom event plugins to load alongside the built-in telemetry, each - * implementing `aws-rum-web`'s own `Plugin` interface. Default `[]`. - */ - eventPluginsToLoad?: unknown[]; - - /** Whether to emit X-Ray trace ids. Default `true`. */ - enableXRay?: boolean; - - /** Whether to emit W3C trace context ids alongside X-Ray's. Default `false`. */ - enableW3CTraceId?: boolean; - - /** Whether resource-timing events record the resource's URL. Default `true`. */ - recordResourceUrl?: boolean; - - // ---- Dispatch & buffering ---- - - /** Data plane endpoint. Defaults to the regional RUM endpoint. */ - endpoint?: string; - - /** Extra headers sent with every dispatch request. Default unset. */ - headers?: Record; - - /** Free-form label reported alongside dispatched events. Default unset. */ - alias?: string; - - /** Maximum events sent per dispatch request. Default `100`. */ - batchLimit?: number; - - /** Interval between automatic dispatches, in milliseconds. Default `5000`. */ - dispatchInterval?: number; - - /** Maximum events held in the in-memory cache awaiting dispatch. Default `1000`. */ - eventCacheSize?: number; - - /** Maximum not-yet-recorded candidate events cached. Default `10`. */ - candidatesCacheSize?: number; - - /** Retries for a failed dispatch request. Default `2`. */ - retries?: number; - - /** Whether unload-time flushes use `navigator.sendBeacon`. Default `true`. */ - useBeacon?: boolean; - - /** - * Whether dispatch requests are SigV4-signed. Default `true`. Signing is - * active whenever credentials are present — which they are here, via - * `setAwsCredentials` from `UwtRumCredentialsProvider` — independent of - * `identityPoolId`; that field only matters for the SDK's own, separate - * Cognito guest-identity flow, which this library does not use. Leave - * this enabled for broker-supplied credentials: disabling it here still - * sends unsigned requests to the same signed-only endpoint and gets - * rejected. Only turn it off if something in front of dispatch (a proxy, - * a different transport) already signs the request itself. - */ - signing?: boolean; - - /** Whether the request body is gzip-compressed before dispatch. Default `{ enabled: true }`. */ - compressionStrategy?: { enabled: boolean }; - - /** - * Overrides the SDK's own `fetch`-based transport. Default unset — the - * SDK uses the global `fetch`. - */ - fetchFunction?: typeof fetch; - - /** - * Overrides how the SDK builds its data-plane HTTP client. An advanced - * escape hatch — see `aws-rum-web`'s own `ClientBuilder` type for the exact - * signature (`(endpoint: URL, region: string, credentials?, compressionStrategy?) => DataPlaneClient`). - * Default unset. - */ - clientBuilder?: ( - endpoint: URL, - region: string, - credentials?: ClientBuilderCredentials, - compressionStrategy?: { enabled: boolean } - ) => unknown; - - // ---- Cookies & privacy ---- - - /** - * Whether the client may keep the session and user ids in cookies - * (`cwr_s`, `cwr_u`), so they survive a reload. Default `true` — the SDK's - * own default is `false`. Without cookies, every page load starts a new - * session. - */ - allowCookies?: boolean; - - /** - * Overrides for the cookies the client sets. Unset fields fall back to the - * SDK's own defaults (`domain` = current hostname, `path` = `/`, - * `sameSite` = `'Strict'`, `secure` = `true`, `unique` = `false`). - * - * With `unique: false`, every app monitor on the same hostname shares one - * session cookie (`cwr_s`). Set `unique: true` when several apps with their - * own app monitors run on one hostname, so each keeps its own session. The - * user cookie (`cwr_u`) stays shared either way — `aws-rum-web` 3.2.1 - * suffixes only the session cookie. - */ - cookieAttributes?: { - unique?: boolean; - domain?: string; - path?: string; - sameSite?: string; - secure?: boolean; - }; - - // ---- Page tracking ---- - - /** - * Disables the client's own automatic page-view recording on navigation. - * Pair with `UwtRumTelemetrySink.recordPageView()` to record page ids - * yourself. Default `false`. - */ - disableAutoPageView?: boolean; - - /** How the page id is derived from the URL. Default `'PATH'`. */ - pageIdFormat?: 'PATH' | 'HASH' | 'PATH_AND_HASH'; - - /** - * Pages recorded, matched against the page id. Default matches every page. - * `undefined` or `null` means unset; anything else that isn't an array of - * `RegExp`s (a string list from JSON) keeps RUM from starting for the page. - */ - pagesToInclude?: RegExp[]; - - /** - * Pages excluded from recording, matched against the page id. Default `[]`. - * `undefined` or `null` means unset; anything else that isn't an array of - * `RegExp`s (a string list from JSON) keeps RUM from starting for the page, - * rather than record what it should exclude. - */ - pagesToExclude?: RegExp[]; - - /** How long, in milliseconds, a route change waits before being considered complete. Default `100`. */ - routeChangeComplete?: number; - - /** Timeout, in milliseconds, before an incomplete route change is abandoned. Default `10000`. */ - routeChangeTimeout?: number; - - // ---- Advanced / escape hatches ---- - - /** - * Label reported as the SDK's own install method. Default is the SDK's - * internal module-install constant; overriding it is rarely useful outside - * of testing the SDK itself. - */ - client?: string; - - /** Release identifier reported with every event, e.g. a build SHA. Default unset. */ - releaseId?: string; - - /** Logs the SDK's own internal diagnostics to the console. Default `false`. */ - debug?: boolean; - - /** Master switch for the RUM client. Default `true`. */ - enableRumClient?: boolean; -} - -/** - * The payload returned by a {@link UwtRumCredentialsProvider}. - * - * @group Interfaces - */ -export interface UwtRumBootstrap { - /** App monitor settings. */ - config: UwtRumAppMonitorConfig; - - /** - * Credentials for the data plane. Omit when the app monitor is configured for - * unauthenticated access. - */ - credentials?: UwtRumCredentials; -} - -/** - * What the RUM sink needs from the host application to start. - * - * Keeps the telemetry layer free of AWS account details — how the app - * monitor id is discovered and how credentials are vended is entirely the - * application's business. - * - * @example - * ```typescript - * @Injectable({ providedIn: 'root' }) - * export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { - * async load(): Promise { - * const res = await fetch('/rum/init'); - * if (!res.ok) throw new Error(`RUM bootstrap failed: ${res.status}`); - * const { enabled, config, credentials } = await res.json(); - * return enabled ? { config, credentials } : null; - * } - * } - * ``` - * - * @group Interfaces - */ -export interface UwtRumCredentialsProvider { - /** - * Resolves the app monitor settings and a set of credentials. - * - * Called once at startup and again shortly before each credential expiry. - * Returning `null` disables RUM for the rest of the page — the telemetry - * layer stays fully functional, it simply stops shipping. Throw instead when - * the request fails: the sink retries — at startup with a growing, random - * wait for up to five minutes, buffering events until the client starts; - * on refresh every 30 s. - * - * @returns the bootstrap payload, or `null` to disable RUM - */ - load(): Promise; -} +import type { UwtRumCredentialsProvider } from '@absaoss-cps/ui-watchtower/rum'; + +export type { + UwtRumAppMonitorConfig, + UwtRumBootstrap, + UwtRumCredentials, + UwtRumCredentialsProvider +} from '@absaoss-cps/ui-watchtower/rum'; /** * Binds the application's {@link UwtRumCredentialsProvider}. diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.spec.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.spec.ts new file mode 100644 index 0000000..477342b --- /dev/null +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.spec.ts @@ -0,0 +1,163 @@ +import { DOCUMENT } from '@angular/common'; +import { + Injector, + isDevMode, + NgZone, + PLATFORM_ID, + Provider, + runInInjectionContext +} from '@angular/core'; +// The main entry's own copy. The library build can't import it from here: +// ng-packagr compiles each entry point on its own. A spec can, and the two +// copies must behave the same, so this runs both side by side. +import * as main from '../../../../src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import * as rum from './uwt-rum-internal.util'; + +jest.mock('@angular/core', () => ({ + ...jest.requireActual('@angular/core'), + isDevMode: jest.fn(() => true) +})); + +const isDevModeMock = isDevMode as jest.Mock; + +/** What the /rum entry copies from the main entry: every one is checked below. */ +const SHARED = [ + 'uwtBrowserDocument', + 'uwtDevModeReporter', + 'uwtIsBrowser', + 'uwtIsDevMode', + 'uwtRunOutsideZone' +]; + +const copies = [ + ['main entry', main], + ['/rum entry', rum] +] as const; + +/** Runs `fn` where `providers` are all there is to inject. */ +function inContext(providers: Provider[], fn: () => T): T { + return runInInjectionContext(Injector.create({ providers }), fn); +} + +describe("the /rum entry's copies of the main entry's Angular helpers", () => { + beforeEach(() => { + isDevModeMock.mockReset(); + isDevModeMock.mockReturnValue(true); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('should copy exactly the helpers checked here, each also in the main entry', () => { + expect(Object.keys(rum).sort()).toEqual(SHARED); + SHARED.forEach((name) => expect(main).toHaveProperty(name)); + }); + + describe.each(copies)('the %s copy', (_name, copy) => { + describe('uwtIsDevMode', () => { + it.each([[true], [false]])('should report dev mode %s', (devMode) => { + isDevModeMock.mockReturnValue(devMode); + + expect(copy.uwtIsDevMode()).toBe(devMode); + }); + + it('should report false when isDevMode throws, as outside an Angular context', () => { + isDevModeMock.mockImplementation(() => { + throw new Error('no Angular context'); + }); + + expect(copy.uwtIsDevMode()).toBe(false); + }); + }); + + describe('the platform', () => { + const page = {} as Document; + + it.each([ + ['browser', true], + ['server', false] + ])( + 'on the %s: browser check %s, and the page only in a browser', + (platform, browser) => { + const providers = [ + { provide: PLATFORM_ID, useValue: platform }, + { provide: DOCUMENT, useValue: page } + ]; + + expect(inContext(providers, () => copy.uwtIsBrowser())).toBe(browser); + expect(inContext(providers, () => copy.uwtBrowserDocument())).toBe( + browser ? page : null + ); + } + ); + }); + + describe('uwtRunOutsideZone', () => { + const zoneWith = (runOutsideAngular: jest.Mock) => + [{ provide: NgZone, useValue: { runOutsideAngular } }] as Provider[]; + + it("should run a callback through the zone's runOutsideAngular, once, and hand back its result", () => { + const runOutsideAngular = jest.fn((callback: () => unknown) => + callback() + ); + const run = inContext(zoneWith(runOutsideAngular), () => + copy.uwtRunOutsideZone() + ); + const callback = jest.fn(() => 42); + + expect(run(callback)).toBe(42); + expect(runOutsideAngular).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should run a callback that returns nothing once, not a second time', () => { + const run = inContext( + zoneWith(jest.fn((callback: () => unknown) => callback())), + () => copy.uwtRunOutsideZone() + ); + const callback = jest.fn(); + + run(callback); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should call the callback directly when there is no zone', () => { + const run = inContext([], () => copy.uwtRunOutsideZone()); + const callback = jest.fn(() => 7); + + expect(run(callback)).toBe(7); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe('uwtDevModeReporter', () => { + it.each([ + ['error', true, 'error'], + ['warn', true, 'warn'], + ['config', true, 'warn'], + ['error', false, undefined], + ['warn', false, undefined], + ['config', false, 'warn'] + ] as const)( + 'should send a %s report, in dev mode %s, to console.%s', + (level, devMode, method) => { + isDevModeMock.mockReturnValue(devMode); + const error = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const warn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + copy.uwtDevModeReporter(level, 'it failed', { detail: 1 }); + + const line = ['[ngx-ui-watchtower] it failed', { detail: 1 }]; + expect({ error: error.mock.calls, warn: warn.mock.calls }).toEqual({ + error: method === 'error' ? [line] : [], + warn: method === 'warn' ? [line] : [] + }); + } + ); + }); + }); +}); diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.ts index 20321e7..2506033 100644 --- a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.ts +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.ts @@ -1,45 +1,13 @@ -import { isPlatformBrowser } from '@angular/common'; -import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; - -/** - * A local copy of `@absaoss-cps/ngx-ui-watchtower`'s own `uwt-telemetry-safe.util.ts` — - * `uwtSafe`/`uwtSafeVoid`/`uwtIsBrowser`/`uwtIsDevMode`/`uwtUuid`, verbatim. - * - * Not imported from the main entry point: ng-packagr fixes every entry - * point's `rootDir` to its own `src` directory (unconditionally — see - * `initializeTsConfig` in ng-packagr's own `tsconfig.js`), so a secondary - * entry point cannot reach a file physically outside `rum/src` by relative - * path. The main entry deliberately doesn't export these either, - * "Maintaining this package", the fail-open wrappers and id generation stay - * internal so they can change freely without a breaking release, and widening - * the public API just to satisfy this entry point's own plumbing would trade - * away that freedom for every consumer, not just this one. A small, - * verbatim, unexported copy is the cheaper trade. - */ - -/** - * Runs a telemetry operation without letting it affect the application. - * - * Every public entry point in this library routes through this wrapper — - * it never rethrows. In development the caught error is reported to the - * console; in production nothing is printed. - */ -export function uwtSafe(operation: string, fn: () => T, fallback: T): T { - try { - return fn(); - } catch (error) { - reportSuppressed(operation, error); - return fallback; - } -} - -/** - * Void-returning form of {@link uwtSafe}, for the common case of an emit call - * whose result nobody reads. +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { inject, isDevMode, NgZone, PLATFORM_ID } from '@angular/core'; +import type { UwtReporter } from '@absaoss-cps/ui-watchtower'; + +/* + * Small copies of the main entry point's own Angular helpers. Not imported + * from it: ng-packagr fixes every entry point's `rootDir` to its own `src`, + * so this one can't reach the main entry's internals by relative path, and + * the main entry keeps them out of its public API so they can change freely. */ -export function uwtSafeVoid(operation: string, fn: () => void): void { - uwtSafe(operation, fn, undefined); -} /** `isDevMode()` throws outside an Angular context, so this guards that once for every caller. */ export function uwtIsDevMode(): boolean { @@ -59,60 +27,34 @@ export function uwtIsBrowser(): boolean { return isPlatformBrowser(inject(PLATFORM_ID)); } -function reportSuppressed(operation: string, error: unknown): void { - if (!uwtIsDevMode()) { - return; - } - try { - console.error(`[ngx-ui-watchtower] ${operation} failed`, error); - } catch { - // A patched/throwing console must never escape telemetry suppression. - } -} - /** - * Fills a byte array from the best available source. `crypto.getRandomValues` - * works on plain http:// too, where `crypto.randomUUID` doesn't. `Math.random` - * is the last-resort fallback for an environment with no Web Crypto at all. + * The page the RUM sink reads: the document in a browser, nothing in a + * server-side render, where the client never starts. Must be called from an + * injection context. */ -function randomBytes(count: number): Uint8Array { - const bytes = new Uint8Array(count); - const cryptoRef = globalThis.crypto; - - if (typeof cryptoRef?.getRandomValues === 'function') { - return cryptoRef.getRandomValues(bytes); - } - - for (let i = 0; i < count; i++) { - bytes[i] = (Math.random() * 256) | 0; - } - return bytes; +export function uwtBrowserDocument(): Document | null { + const document = inject(DOCUMENT); + return uwtIsBrowser() ? document : null; } -/** Lowercase hex encoding, two characters per byte. */ -function toHex(bytes: Uint8Array): string { - return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +/** + * Runs a callback outside Angular's zone when there is one. Must be called + * from an injection context. + */ +export function uwtRunOutsideZone(): (callback: () => T) => T { + const zone = inject(NgZone, { optional: true }); + return (callback) => (zone ? zone.runOutsideAngular(callback) : callback()); } /** - * Generates a correlation identifier. Prefers `crypto.randomUUID`, falling - * back to formatting random bytes as a v4 UUID. + * How this package reports what telemetry handles silently: to the console, + * prefixed. Failures and dropped data only in development mode; something + * that cannot work as configured (`'config'`) in production too. */ -export function uwtUuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); +export const uwtDevModeReporter: UwtReporter = (level, message, ...details) => { + if (level === 'config') { + console.warn(`[ngx-ui-watchtower] ${message}`, ...details); + } else if (uwtIsDevMode()) { + console[level](`[ngx-ui-watchtower] ${message}`, ...details); } - - const bytes = randomBytes(16); - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 - - const hex = toHex(bytes); - return [ - hex.slice(0, 8), - hex.slice(8, 12), - hex.slice(12, 16), - hex.slice(16, 20), - hex.slice(20) - ].join('-'); -} +}; diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts index 30645ac..f52ae33 100644 --- a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts @@ -1,235 +1,16 @@ -import { DOCUMENT } from '@angular/common'; -import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; -import type { AwsRum, AwsRumConfig } from 'aws-rum-web'; +import { inject, Injectable, OnDestroy } from '@angular/core'; import { UWT_REDACT_CONFIG, UWT_TELEMETRY_ENABLED, - UWT_TELEMETRY_IDENTITY, - UwtTelemetryError, - UwtTelemetryMetadata, - UwtTelemetrySink, - uwtMarkSessionStart, - uwtNormalizeError + UWT_TELEMETRY_IDENTITY } from '@absaoss-cps/ngx-ui-watchtower'; +import { UwtRumTelemetrySink as CoreRumTelemetrySink } from '@absaoss-cps/ui-watchtower/rum'; +import { UWT_RUM_CREDENTIALS_PROVIDER } from '../uwt-rum-credentials/uwt-rum-credentials'; import { - uwtIsBrowser, - uwtIsDevMode, - uwtSafe, - uwtSafeVoid, - uwtUuid + uwtBrowserDocument, + uwtDevModeReporter, + uwtRunOutsideZone } from '../uwt-rum-internal.util/uwt-rum-internal.util'; -import { - UWT_RUM_CREDENTIALS_PROVIDER, - UwtRumBootstrap, - UwtRumCredentials -} from '../uwt-rum-credentials/uwt-rum-credentials'; - -/** Refresh credentials this long before they expire. */ -const CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000; - -/** - * Maximum delay supported by setTimeout (2^31 - 1 ms, ~24.85 days). - * Prevents 32-bit signed integer overflow triggering an immediate execution. - */ -const MAX_TIMEOUT_MS = 2_147_483_647; - -/** - * Retry delay after a scheduled refresh fails or returns no credentials. - * Shorter than the normal refresh-ahead-of-expiry interval. - */ -const CREDENTIAL_RETRY_DELAY_MS = 30 * 1000; - -/** - * Startup retries wait a random time up to a limit that starts at - * {@link INIT_RETRY_BASE_MS} and doubles after each failure, up to - * {@link INIT_RETRY_MAX_MS}. The first retry comes fast, because the client - * captures nothing until it exists; the randomness keeps tabs reloaded - * together from retrying together. - */ -const INIT_RETRY_BASE_MS = 1000; -const INIT_RETRY_MAX_MS = 60 * 1000; - -/** - * How long startup keeps retrying before the sink gives up and runs the page - * without RUM. Bounds the retries when the failure is permanent, such as a - * wrong URL or a 403. - */ -const INIT_RETRY_WINDOW_MS = 5 * 60 * 1000; - -/** - * The RUM client's defaults for `eventCacheSize` and `batchLimit`, which it - * keeps to itself — used only when the app monitor config sets neither. - * Together they bound how many batches the client can hold. - */ -const SDK_DEFAULT_EVENT_CACHE_SIZE = 1000; -const SDK_DEFAULT_BATCH_LIMIT = 100; - -/** - * Events retained while the SDK loads. Bounded so a failed init cannot - * grow the buffer without limit. - */ -const PRE_INIT_BUFFER_LIMIT = 100; - -/** The event the RUM client records each time it starts a session. */ -const SESSION_START_EVENT_TYPE = 'com.amazon.rum.session_start_event'; - -/** - * The RUM client's session cookie: `cwr_s`, or `cwr_s_` with - * `cookieAttributes.unique` (both documented by AWS). - */ -function sessionCookieName(bootstrap: UwtRumBootstrap): string { - const { config } = bootstrap; - return config.cookieAttributes?.unique - ? `cwr_s_${config.applicationId}` - : 'cwr_s'; -} - -/** Metadata keys under this prefix are reserved by the RUM client. */ -const RESERVED_PREFIX = 'aws:'; - -interface BufferedEvent { - kind: 'event'; - eventType: string; - payload: object; - metadata?: UwtTelemetryMetadata; -} - -interface BufferedPageView { - kind: 'pageView'; - pageId: string; -} - -interface BufferedError { - kind: 'error'; - error: UwtTelemetryError; - metadata?: UwtTelemetryMetadata; -} - -/** Anything the pre-init buffer holds. */ -type BufferedItem = BufferedEvent | BufferedPageView | BufferedError; - -/** - * Drops every key whose value is unset: `undefined`, or `null` as a broker's - * JSON spells it. - * - * Used to build the SDK config object from optional consumer fields: a key - * present with value `undefined` or `null` would overwrite the SDK's own - * default for that key, so unset fields must be absent. - */ -function omitUnset(obj: T): Partial { - const result: Partial = {}; - for (const key of Object.keys(obj) as (keyof T)[]) { - if (obj[key] !== undefined && obj[key] !== null) { - result[key] = obj[key]; - } - } - return result; -} - -/** Whether `value` is an array of `RegExp`s only. */ -function isRegExpList(value: unknown): boolean { - return ( - Array.isArray(value) && value.every((entry) => entry instanceof RegExp) - ); -} - -/** A setting's type check, and the type it expects, for the warning. */ -interface UwtSettingCheck { - valid: (value: unknown) => boolean; - expected: string; -} - -const REGEXP_LIST: UwtSettingCheck = { - valid: isRegExpList, - expected: 'an array of RegExp' -}; -const FUNCTION: UwtSettingCheck = { - valid: (value) => typeof value === 'function', - expected: 'a function' -}; - -/** - * Telemetry options the SDK calls as a function or matches with - * `RegExp.test`. JSON can carry neither, and anything else in their place - * makes the SDK throw each time it applies the option — for the URL filters, - * inside its patched `fetch` and XHR, so on every application request. - */ -const TELEMETRY_OPTION_CHECKS = new Map([ - ['urlsToInclude', REGEXP_LIST], - ['urlsToExclude', REGEXP_LIST], - [ - 'addXRayTraceIdHeader', - { - valid: (value) => typeof value === 'boolean' || isRegExpList(value), - expected: 'a boolean or an array of RegExp' - } - ], - ['ignore', FUNCTION], - ['interactionId', FUNCTION] -]); - -/** - * Lists the settings in `config` the SDK can't use, each with the type it - * expects: `pagesToInclude` or `pagesToExclude` that isn't an array of - * `RegExp`s, `telemetries` that isn't an array, and each telemetry option - * failing its {@link TELEMETRY_OPTION_CHECKS} check, as `name.option`. - * `undefined` and `null` count as unset, not as invalid. - */ -function invalidRumSettings(config: UwtRumBootstrap['config']): string[] { - const invalid: string[] = []; - for (const key of ['pagesToInclude', 'pagesToExclude'] as const) { - const value = config[key]; - if (value != null && !REGEXP_LIST.valid(value)) { - invalid.push(`${key} (expected ${REGEXP_LIST.expected})`); - } - } - const telemetries: unknown = config.telemetries; - if (telemetries == null) { - return invalid; - } - if (!Array.isArray(telemetries)) { - // A string would be read as one telemetry per character. - invalid.push('telemetries (expected an array)'); - return invalid; - } - for (const telemetry of telemetries) { - if (!Array.isArray(telemetry)) { - continue; - } - const [name, options] = telemetry; - if (typeof options !== 'object' || options === null) { - continue; - } - for (const [key, value] of Object.entries(options)) { - const check = TELEMETRY_OPTION_CHECKS.get(key); - if (value != null && check && !check.valid(value)) { - invalid.push(`${String(name)}.${key} (expected ${check.expected})`); - } - } - } - return invalid; -} - -/** - * Copies `telemetries` without tuple options that are unset (`undefined` or - * `null`). The SDK merges a telemetry's options over its defaults with - * `Object.assign`, so an explicit unset value would replace a default it - * relies on — `urlsToExclude: null` would throw on every request. - */ -function withoutUnsetOptions( - telemetries: NonNullable -): NonNullable { - return telemetries.map((telemetry) => { - if (!Array.isArray(telemetry)) { - return telemetry; - } - const [name, options, ...rest] = telemetry; - if (typeof options !== 'object' || options === null) { - return telemetry; - } - return [name, omitUnset(options), ...rest]; - }); -} /** * Telemetry sink backed by the AWS CloudWatch RUM web client. @@ -244,754 +25,24 @@ function withoutUnsetOptions( * @group Services */ @Injectable() -export class UwtRumTelemetrySink extends UwtTelemetrySink implements OnDestroy { - private readonly config = inject(UWT_TELEMETRY_IDENTITY); - private readonly redact = inject(UWT_REDACT_CONFIG); - private readonly credentialsProvider = inject(UWT_RUM_CREDENTIALS_PROVIDER); - private readonly document = inject(DOCUMENT); - /** - * False on the server and while telemetry is off: the client never starts — - * no credentials fetch, no SDK import — and every call is a no-op. - */ - private readonly canStart = - uwtIsBrowser() && - (inject(UWT_TELEMETRY_ENABLED, { optional: true }) ?? true); - - private readonly zone = inject(NgZone, { optional: true }); - - private awsRum: AwsRum | null = null; - /** - * The pending broker call: a startup retry before the client exists, the - * next credential refresh after. - */ - private refreshTimer: ReturnType | null = null; - private buffer: BufferedItem[] = []; - private userId?: string; - /** - * Whether a session's start is recorded (`uwtMarkSessionStart`): only when - * the client keeps sessions in its cookie, so a session outlives the page, - * and the app hasn't suppressed session-start events. Set when the client - * starts. - */ - private marksSessionStarts = false; - /** Set while {@link markSessionStart} runs, to stop it calling itself. */ - private markingSession = false; - /** - * Set once the app has called `setUserId`. Until then, the client's id may - * be a signed-in user's, restored from its cookie after a reload, so - * events carry no `signedIn`. - */ - private userKnown = false; - /** A sign-out arrived before the client existed; applied once it starts. */ - private signOutPending = false; - /** The most batches the client can hold, set when it starts. */ - private maxBatches = 1; - /** - * Set once RUM is off for the page — the broker declined, startup gave up, - * or a refresh returned null; distinct from `!awsRum` alone. - */ - private disabled = false; - /** Broker requests that failed at startup so far. */ - private initFailures = 0; - /** When the first of them failed. */ - private initFailedAt?: number; - /** - * The current startup attempt, shared by every caller — - * `provideUwtTelemetryRumSink()`'s `APP_INITIALIZER`, - * {@link ensureInitialized}, and a caller awaiting `init()` directly. A - * retry replaces it, so awaiting `init()` waits for one attempt, not for - * RUM to be running. - */ - private initPromise?: Promise; - /** - * Set once in `ngOnDestroy`. `refreshCredentials()` nulls `refreshTimer` - * before its own `await`, so a destroy during that window would leave a - * new timer nothing could clear. Checked after every await in the - * refresh chain instead. - */ - private destroyed = false; - - /** - * Loads and starts the RUM client. - * - * Fire-and-forget: it must never block bootstrap and never reject into the - * caller. Safe to call more than once; subsequent calls are no-ops. - * - * @returns a promise that resolves once initialization has been attempted - */ - async init(): Promise { - if (!this.canStart) { - return; - } - if (!this.initPromise) { - // Outside Angular's zone: the SDK's dispatch interval and this sink's - // retry and refresh timers would otherwise keep a zone-based app from - // ever becoming stable. - this.initPromise = this.zone - ? this.zone.runOutsideAngular(() => this.performInit()) - : this.performInit(); - } - return this.initPromise; - } - - private async performInit(): Promise { - let bootstrap: UwtRumBootstrap | null; - try { - bootstrap = await this.credentialsProvider.load(); - } catch (error) { - this.retryInit(error); - return; - } - - try { - if (!bootstrap?.config) { - this.disableAwsRum(); - this.buffer = []; - return; - } - - // Fails closed: the page and URL filters and `ignore` decide what is - // *not* recorded, so dropping one would record what the app meant to - // keep out, and passing it on would make the SDK throw. RUM stays off - // for the page instead, as with a broker that returns `null`. - const invalid = invalidRumSettings(bootstrap.config); - if (invalid.length) { - this.disableAwsRum(); - this.buffer = []; - this.reportInvalidSettings(invalid); - return; - } - - // Not retried if this fails: with native ES modules (Angular's default - // build) the browser remembers a failed dynamic import for the page. - const { AwsRum } = await import('aws-rum-web'); - if (this.destroyed) { - return; - } - - const sessionCookie = sessionCookieName(bootstrap); - const hadSession = this.hasCookie(sessionCookie); - - this.awsRum = new AwsRum( - bootstrap.config.applicationId, - bootstrap.config.applicationVersion, - bootstrap.config.region, - this.buildRumConfig(bootstrap) - ); - const { - eventCacheSize = SDK_DEFAULT_EVENT_CACHE_SIZE, - batchLimit = SDK_DEFAULT_BATCH_LIMIT - } = bootstrap.config; - const batches = Math.ceil(eventCacheSize / batchLimit); - // A batchLimit of 0 or a non-number would make this unbounded. - this.maxBatches = Number.isFinite(batches) && batches > 1 ? batches : 1; - - this.marksSessionStarts = - bootstrap.config.allowCookies !== false && - this.document.defaultView?.navigator.cookieEnabled !== false && - bootstrap.config.suppressSessionStartEvent !== true; - this.applyIdentity(); - // The client can start the first session inside its constructor — the - // landing page view, the navigation timing — before the metadata hook - // exists to see its session_start. It writes the session cookie then, - // so a cookie that wasn't there a moment ago means a session that - // started just now. - if ( - this.marksSessionStarts && - !hadSession && - this.hasCookie(sessionCookie) - ) { - this.markSessionStart(); - } - this.applyCredentials(bootstrap.credentials); - this.replayBuffer(); - } catch (error) { - this.disableAwsRum(); - this.buffer = []; - this.reportFailure('RUM init failed, monitoring disabled', error); - } - } - - /** - * After a failed broker request at startup: tries again later, keeping - * events buffered — the failure is usually a passing network or broker - * problem. Gives up after {@link INIT_RETRY_WINDOW_MS}. - */ - private retryInit(error: unknown): void { - if (this.destroyed) { - return; - } - - this.initFailures++; - this.initFailedAt ??= Date.now(); - if (Date.now() - this.initFailedAt >= INIT_RETRY_WINDOW_MS) { - this.disableAwsRum(); - this.buffer = []; - this.reportFailure('RUM init failed, monitoring disabled', error); - return; - } - - if (this.initFailures === 1) { - this.reportFailure('RUM init failed, retrying', error); - } - const limit = Math.min( - INIT_RETRY_BASE_MS * 2 ** (this.initFailures - 1), - INIT_RETRY_MAX_MS - ); - this.scheduleRetry(() => { - this.initPromise = this.performInit(); - return this.initPromise; - }, Math.random() * limit); - } - - /** - * Kicks off `init()` on first real use, for a sink bound directly (e.g. - * `{ provide: UwtTelemetrySink, useClass: UwtRumTelemetrySink }`) rather - * than through `provideUwtTelemetryRumSink()`, which is what normally - * calls `init()` via `APP_INITIALIZER`. `init()` is idempotent and - * memoized, so this is a no-op once already initialized or already in - * flight, and never races with a caller awaiting `init()` directly. - */ - private ensureInitialized(): void { - if (!this.initPromise) { - this.init().catch(() => undefined); - } - } - - /** - * Shared shape behind {@link record}, {@link recordError} and - * {@link recordPageView}: kicks off init, then either buffers `item` - * (pre-init) or hands the live client to `dispatch`, all inside the same - * fail-open guard. - */ - private recordOrBuffer( - operation: string, - item: BufferedItem, - dispatch: (awsRum: AwsRum) => void - ): void { - this.ensureInitialized(); - uwtSafeVoid(operation, () => { - if (!this.canStart || this.disabled) { - return; - } - - const awsRum = this.awsRum; - if (!awsRum) { - this.bufferItem(item); - return; - } - - dispatch(awsRum); +export class UwtRumTelemetrySink + extends CoreRumTelemetrySink + implements OnDestroy +{ + constructor() { + super({ + identity: inject(UWT_TELEMETRY_IDENTITY), + redact: inject(UWT_REDACT_CONFIG), + credentialsProvider: inject(UWT_RUM_CREDENTIALS_PROVIDER), + document: uwtBrowserDocument(), + enabled: inject(UWT_TELEMETRY_ENABLED, { optional: true }) ?? true, + runOutside: uwtRunOutsideZone(), + report: uwtDevModeReporter }); } - /** @inheritdoc */ - record( - eventType: string, - payload: object, - metadata?: UwtTelemetryMetadata - ): void { - this.recordOrBuffer( - 'rum.record', - { kind: 'event', eventType, payload, metadata }, - (awsRum) => - awsRum.recordEvent(eventType, payload, this.sanitize(metadata)) - ); - } - - /** @inheritdoc */ - recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata): void { - this.recordOrBuffer( - 'rum.recordError', - { kind: 'error', error, metadata }, - (awsRum) => awsRum.recordError(this.withOrigin(error, metadata)) - ); - } - - /** @inheritdoc */ - getSessionId(): string | undefined { - return uwtSafe( - 'rum.getSessionId', - () => this.awsRum?.getSessionId(), - undefined - ); - } - - /** - * Associates subsequent events with a user, or with nobody. - * - * Signing in pins the id, and works even before `init()` resolves. - * Signing out starts a fresh session with a new anonymous id (`pinUserId` - * has no inverse), which emits a `session_start` event and re-rolls - * sampling. Before `init()` resolves, a sign-out is applied once the - * client starts. Signing out while nobody is signed in does nothing. - * - * Every event carries `signedIn` in its metadata, so a signed-in user's id - * can be told from an anonymous one. It is absent until the first call, - * so call this as early as possible, e.g. right after the auth library - * initializes. - * - * @param userId the application's own user identifier, or `undefined` - * (or `''`) on sign-out. RUM accepts only a UUID, e.g. MSAL's - * `localAccountId`. - */ - setUserId(userId: string | undefined): void { - uwtSafeVoid('rum.setUserId', () => { - const previous = this.userId; - this.userId = userId; - this.userKnown = true; - - const awsRum = this.awsRum; - if (!awsRum) { - // A sign-in is pinned, a sign-out applied, once the client starts. - if (userId || previous) { - this.signOutPending = !userId; - } - return; - } - - if (userId && userId !== previous) { - // Unless the client already has this id, as after a reload. - if (awsRum.getUserId() !== userId) { - this.sendWaiting(awsRum); - } - awsRum.pinUserId(userId); - } else if (!userId && previous) { - this.sendWaiting(awsRum); - awsRum.startSession({ userId: uwtUuid() }); - } - }); - } - - /** - * Sends everything the client holds, under the current user id. The client - * reads the id when it sends a batch, not when it records an event, so - * events still waiting at a change would otherwise go out under the new id. - * Each `dispatch()` sends one batch, and nothing once the client is empty. - */ - private sendWaiting(awsRum: AwsRum): void { - for (let batch = 0; batch < this.maxBatches; batch++) { - awsRum.dispatch(); - } - } - - /** @inheritdoc */ - getUserId(): string | undefined { - return this.userId; - } - - /** @inheritdoc */ - flush(beacon = false): void { - uwtSafeVoid('rum.flush', () => { - if (!this.awsRum) { - if (this.buffer.length > 0 && uwtIsDevMode()) { - console.warn( - `[ngx-ui-watchtower] ${this.buffer.length} RUM event(s) lost: page unloaded before RUM finished initializing` - ); - } - return; - } - - if (beacon) { - this.awsRum.dispatchBeacon(); - } else { - this.awsRum.dispatch(); - } - }); - } - - /** - * Records a page view. - * - * The RUM client already records a page view on every navigation via - * `history.pushState`, covering Angular's router automatically. Use this - * to report a different page id (e.g. a route template instead of the - * resolved URL); also set `disableAutoPageView: true` in the RUM config - * to avoid recording the navigation twice. Buffered like {@link record} - * when called before `init()` resolves. - * - * @param pageId the page identity, e.g. `/customers/:id` - */ - recordPageView(pageId: string): void { - this.recordOrBuffer( - 'rum.recordPageView', - { kind: 'pageView', pageId }, - (awsRum) => awsRum.recordPageView(pageId) - ); - } - /** @inheritdoc */ ngOnDestroy(): void { - this.destroyed = true; - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - this.refreshTimer = null; - } - this.disableAwsRum(); - } - - /** - * Disables the sink: stops the AWS RUM client's own listeners, plugins, - * and dispatch timer before dropping the reference — clearing `awsRum` - * alone only stops this class from talking to it; the already-constructed - * instance would otherwise keep running orphaned in the page (and, on - * destroy, could duplicate telemetry if the app is bootstrapped again in - * the same one) — and marks the session disabled, so every call site that - * tears the client down does so atomically with the flag `recordOrBuffer` - * checks, instead of each caller having to remember to set both. - */ - private disableAwsRum(): void { - uwtSafeVoid('rum.disable', () => this.awsRum?.disable()); - this.awsRum = null; - this.disabled = true; - } - - /** - * Maps {@link UwtRumAppMonitorConfig} onto the SDK's own `PartialConfig`. - * - * The SDK merges this object over its own defaults with a shallow - * `Object.assign`, so a key present with value `undefined` or `null` - * overwrites the SDK's default instead of falling through to it. - * `allowCookies`, `enableXRay`, `endpoint` and `telemetries` always carry - * this library's own override; every other field goes through - * {@link omitUnset} (or, for `client`, a conditional spread) so an unset - * field is absent and the SDK's own default applies. Unset telemetry options - * are left out for the same reason. `performInit()` has already checked the - * types of `pagesToInclude`/`pagesToExclude`, `telemetries` and the telemetry - * options in {@link TELEMETRY_OPTION_CHECKS} ({@link invalidRumSettings}); - * every other field is passed on as given. - */ - private buildRumConfig(bootstrap: UwtRumBootstrap): AwsRumConfig { - const { config } = bootstrap; - const origin = this.document.defaultView?.location?.origin ?? ''; - - // X-Ray trace header only on same-origin requests; a cross-origin - // request with an unexpected header fails CORS preflight. Anchored at - // both ends (with a path/query/fragment/end-of-string lookahead) so - // `https://example.com` doesn't also match a look-alike host like - // `https://example.com.attacker.example`. - const sameOrigin = new RegExp( - '^' + origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[/?#]|$)' - ); - - return { - allowCookies: config.allowCookies ?? true, - enableXRay: config.enableXRay ?? true, - endpoint: - config.endpoint ?? - `https://dataplane.rum.${config.region}.amazonaws.com`, - telemetries: Array.isArray(config.telemetries) - ? withoutUnsetOptions(config.telemetries) - : (config.telemetries ?? [ - 'errors', - 'performance', - ['http', { addXRayTraceIdHeader: [sameOrigin] }] - ]), - - ...(config.client != null ? { client: config.client } : {}), - - ...omitUnset({ - releaseId: config.releaseId, - clientBuilder: config.clientBuilder, - applicationAttributes: config.applicationAttributes, - alias: config.alias, - headers: config.headers, - cookieAttributes: config.cookieAttributes, - sessionSampleRate: config.sessionSampleRate, - sessionLengthSeconds: config.sessionLengthSeconds, - sessionEventLimit: config.sessionEventLimit, - suppressSessionStartEvent: config.suppressSessionStartEvent, - userIdRetentionDays: config.userIdRetentionDays, - sessionAttributes: config.sessionAttributes, - eventPluginsToLoad: config.eventPluginsToLoad, - enableW3CTraceId: config.enableW3CTraceId, - recordResourceUrl: config.recordResourceUrl, - batchLimit: config.batchLimit, - dispatchInterval: config.dispatchInterval, - eventCacheSize: config.eventCacheSize, - candidatesCacheSize: config.candidatesCacheSize, - retries: config.retries, - useBeacon: config.useBeacon, - signing: config.signing, - compressionStrategy: config.compressionStrategy, - fetchFunction: config.fetchFunction, - pageIdFormat: config.pageIdFormat, - pagesToInclude: config.pagesToInclude, - pagesToExclude: config.pagesToExclude, - routeChangeComplete: config.routeChangeComplete, - routeChangeTimeout: config.routeChangeTimeout, - disableAutoPageView: config.disableAutoPageView, - debug: config.debug, - enableRumClient: config.enableRumClient - }) - } as unknown as AwsRumConfig; - } - - /** - * Attaches application identity once per session rather than once per event. - * - * `addSessionAttributes` covers custom events; the metadata hook stamps - * the same attributes onto the client's own built-in events too. The hook - * also adds `signedIn`, read when each event is recorded. - * - * See {@link UwtRumAppMonitorConfig.sessionAttributes} for how this - * merges with a consumer-supplied base. - * - * It also sees the client's own `session_start` event, recorded - * each time it starts a session, and records that moment as the - * session's start (`uwtMarkSessionStart`) — the client keeps no start time - * itself. Only while {@link marksSessionStarts}. - */ - private applyIdentity(): void { - if (!this.awsRum) { - return; - } - - const attributes = { - application: this.config.application, - environment: this.config.environment, - appVersion: this.config.version - }; - - this.awsRum.addSessionAttributes(attributes); - this.awsRum.setEventMetadataHook((type) => { - if (this.marksSessionStarts && type === SESSION_START_EVENT_TYPE) { - this.markSessionStart(); - } - return this.userKnown - ? { ...attributes, signedIn: !!this.userId } - : attributes; - }); - - if (this.userId) { - this.awsRum.pinUserId(this.userId); - } else if (this.signOutPending) { - this.signOutPending = false; - this.awsRum.startSession({ userId: uwtUuid() }); - } - } - - /** - * Records the current session's start as now. Called right after a session - * started, so it is current and reading its id doesn't start another one — - * except with a session length of 0, where the read starts a new session - * whose session_start calls back in here; the guard stops that recursion. - */ - private markSessionStart(): void { - if (this.markingSession) { - return; - } - this.markingSession = true; - try { - uwtSafeVoid('rum.markSessionStart', () => { - const sessionId = this.awsRum?.getSessionId(); - if (sessionId) { - uwtMarkSessionStart(sessionId); - } - }); - } finally { - this.markingSession = false; - } - } - - /** Whether the page has a cookie with this name; `false` when cookies can't be read. */ - private hasCookie(name: string): boolean { - return uwtSafe( - 'rum.readCookie', - () => - this.document.cookie - .split(';') - .some((cookie) => cookie.trim().split('=')[0] === name), - false - ); - } - - private applyCredentials(credentials?: UwtRumCredentials): void { - if (!this.awsRum || !credentials) { - return; - } - - this.awsRum.setAwsCredentials({ - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken - }); - - this.scheduleRefresh(credentials.expiration); - } - - private scheduleRefresh(expiration: string): void { - const delay = - new Date(expiration).getTime() - Date.now() - CREDENTIAL_REFRESH_SKEW_MS; - - if (!Number.isFinite(delay) || delay <= 0) { - // Avoid tight-looping on an already-expired/unparsable expiration. - this.scheduleRetry(); - return; - } - - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - } - - this.refreshTimer = setTimeout( - () => { - this.refreshCredentials().catch(() => undefined); - }, - Math.min(delay, MAX_TIMEOUT_MS) - ); - } - - private async refreshCredentials(): Promise { - this.refreshTimer = null; - try { - const bootstrap = await this.credentialsProvider.load(); - if (this.destroyed) { - return; - } - - if (!bootstrap) { - // null/undefined disables RUM for the session (see load()'s contract). - this.disableAwsRum(); - return; - } - - if (bootstrap.credentials) { - this.applyCredentials(bootstrap.credentials); - } else { - this.scheduleRetry(); - this.reportFailure( - 'RUM credential refresh', - new Error( - 'bootstrap omitted credentials for an already-authenticated ' + - 'session; keeping existing credentials and retrying' - ) - ); - } - } catch (error) { - if (!this.destroyed) { - this.scheduleRetry(); - this.reportFailure('RUM credential refresh failed', error); - } - } - } - - /** Runs `attempt` (by default, a credential refresh) after `delayMs`. */ - private scheduleRetry( - attempt: () => Promise = () => this.refreshCredentials(), - delayMs = CREDENTIAL_RETRY_DELAY_MS - ): void { - if (this.destroyed) { - return; - } - - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - } - - this.refreshTimer = setTimeout(() => { - this.refreshTimer = null; - attempt().catch(() => undefined); - }, delayMs); - } - - /** - * Reports settings that kept RUM from starting, in every environment: a - * silent production page would hide that RUM is off. Each setting's name and - * expected type are printed, never its value. - * - * @param problems one `name (expected type)` entry per setting - */ - private reportInvalidSettings(problems: string[]): void { - uwtSafeVoid('rum.reportInvalidSettings', () => { - console.warn( - `[ngx-ui-watchtower] RUM not started, settings of the wrong type: ${problems.join(', ')}` - ); - }); - } - - /** - * Reports an init/refresh failure without leaking raw error content into - * the production console. - */ - private reportFailure(operation: string, error: unknown): void { - if (!uwtIsDevMode()) { - return; - } - uwtSafeVoid('rum.reportFailure', () => { - console.warn( - `[ngx-ui-watchtower] ${operation}`, - uwtNormalizeError(error, this.redact) - ); - }); - } - - private bufferItem(item: BufferedItem): void { - this.buffer.push(item); - if (this.buffer.length > PRE_INIT_BUFFER_LIMIT) { - this.buffer.shift(); - } - } - - private replayBuffer(): void { - const pending = this.buffer; - this.buffer = []; - - for (const item of pending) { - uwtSafeVoid('rum.replay', () => { - if (item.kind === 'event') { - this.awsRum?.recordEvent( - item.eventType, - item.payload, - this.sanitize(item.metadata) - ); - } else if (item.kind === 'pageView') { - this.awsRum?.recordPageView(item.pageId); - } else { - this.awsRum?.recordError(this.withOrigin(item.error, item.metadata)); - } - }); - } - } - - /** - * Drops metadata keys under the reserved `aws:` prefix, which the client - * would otherwise discard with a console warning. Also drops `null` - * values, which {@link UwtTelemetryMetadata} permits but the SDK's - * metadata type does not. - */ - private sanitize( - metadata?: UwtTelemetryMetadata - ): Record | undefined { - if (!metadata) { - return undefined; - } - - const result: Record = {}; - for (const [key, value] of Object.entries(metadata)) { - if (key.startsWith(RESERVED_PREFIX) || value === null) { - continue; - } - result[key] = value; - } - - return Object.keys(result).length ? result : undefined; - } - - /** - * Folds a forwarded error's origin into its name. `AwsRum.recordError` - * takes no metadata argument, so `name` is the only field left to carry it. - */ - private withOrigin( - error: UwtTelemetryError, - metadata?: UwtTelemetryMetadata - ): UwtTelemetryError { - const origin = metadata - ? (metadata as Record).application - : undefined; - if (typeof origin !== 'string' || origin === this.config.application) { - return error; - } - return { ...error, name: `[${origin}] ${error.name}` }; + this.dispose(); } } diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts index c0c2813..d0c0e7b 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts @@ -4,7 +4,7 @@ import { UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import { uwtHostReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { uwtDevModeReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; import { uwtBroadcastChannelOptions } from './uwt-broadcast.messages'; /** @@ -54,7 +54,7 @@ export class UwtTelemetryBroadcastHost sink: inject(UwtTelemetrySink), logApi: inject(UWT_LOG_API_PROVIDER), ...uwtBroadcastChannelOptions(), - report: uwtHostReporter + report: uwtDevModeReporter }); } diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts index 0e6af00..eab4c33 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts @@ -1,6 +1,8 @@ import { isDevMode } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import type {} from '@absaoss-cps/ui-watchtower'; import { + uwtRunOutsideZone, uwtSafe, uwtSafeVoid, uwtSafeVoidMaybeAsync @@ -191,3 +193,15 @@ describe('uwtSafeVoidMaybeAsync', () => { expect(unhandled).not.toHaveBeenCalled(); }); }); + +describe('uwtRunOutsideZone', () => { + it('should run a callback that returns nothing once, and hand back what it returns', () => { + const run = TestBed.runInInjectionContext(() => uwtRunOutsideZone()); + const nothing = jest.fn(); + + run(nothing); + + expect(nothing).toHaveBeenCalledTimes(1); + expect(run(() => 42)).toBe(42); + }); +}); diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts index 62ed090..9ea7de3 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts +++ b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts @@ -40,7 +40,7 @@ export function uwtBrowserDocument(): Document | null { */ export function uwtRunOutsideZone(): (callback: () => T) => T { const zone = inject(NgZone, { optional: true }); - return (callback) => zone?.runOutsideAngular(callback) ?? callback(); + return (callback) => (zone ? zone.runOutsideAngular(callback) : callback()); } /** @@ -55,25 +55,15 @@ export function uwtRunInsideZone(): (callback: () => T) => T { /** * How this package reports what telemetry handles silently: to the console, - * prefixed, and only in development mode. Everything the core does on this - * package's behalf is handed it. + * prefixed. Failures and dropped data only in development mode; something + * that cannot work as configured (`'config'`) in production too. Everything + * the core does on this package's behalf is handed it. */ export const uwtDevModeReporter: UwtReporter = (level, message, ...details) => { - if (uwtIsDevMode()) { - console[level](`[ngx-ui-watchtower] ${message}`, ...details); - } -}; - -/** - * {@link uwtDevModeReporter}, except that warnings are printed in production - * too: the broadcast host's warnings about a realm that cannot host always - * were. - */ -export const uwtHostReporter: UwtReporter = (level, message, ...details) => { - if (level === 'warn') { + if (level === 'config') { console.warn(`[ngx-ui-watchtower] ${message}`, ...details); - } else { - uwtDevModeReporter(level, message, ...details); + } else if (uwtIsDevMode()) { + console[level](`[ngx-ui-watchtower] ${message}`, ...details); } }; diff --git a/projects/ngx-ui-watchtower/tsconfig.lib.json b/projects/ngx-ui-watchtower/tsconfig.lib.json index d8e13ec..514b02a 100644 --- a/projects/ngx-ui-watchtower/tsconfig.lib.json +++ b/projects/ngx-ui-watchtower/tsconfig.lib.json @@ -17,7 +17,8 @@ // it first (npm run build does). "paths": { "@absaoss-cps/ui-watchtower": ["../../dist/ui-watchtower"], - "@absaoss-cps/ui-watchtower/rxjs": ["../../dist/ui-watchtower/rxjs"] + "@absaoss-cps/ui-watchtower/rxjs": ["../../dist/ui-watchtower/rxjs"], + "@absaoss-cps/ui-watchtower/rum": ["../../dist/ui-watchtower/rum"] } }, "include": ["src/**/*.ts"], diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md index 7f33136..ec808bf 100644 --- a/projects/ui-watchtower/README.md +++ b/projects/ui-watchtower/README.md @@ -8,14 +8,12 @@ JavaScript or TypeScript application. 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 AWS RUM destination is 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 +npm install aws-rum-web # only for @absaoss-cps/ui-watchtower/rum +npm install rxjs # only for @absaoss-cps/ui-watchtower/rxjs ``` ## Use @@ -71,6 +69,35 @@ 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. +## AWS CloudWatch RUM + +The RUM destination lives in its own entry point, so the main one never +refers to `aws-rum-web`; the SDK is downloaded only once the sink starts. +Your credentials provider supplies the app monitor settings and short-lived +credentials, from your own broker: + +```ts +import { UwtRumTelemetrySink } from '@absaoss-cps/ui-watchtower/rum'; + +const destination = new UwtRumTelemetrySink({ + identity, + credentialsProvider: { + async load() { + const response = await fetch('/rum/init'); + return response.ok ? response.json() : null; // null: RUM off for the page + } + } +}); +destination.init(); // not awaited: never delays the page + +const telemetry = uwtCreateTelemetry({ identity, destination, logApi }); +``` + +Startup is retried with backoff for five minutes, credentials are refreshed +before they expire, and events recorded before the client starts are +buffered. Without a page, as on a server, it never starts. `dispose()` stops +the client. + ## Shell and fragments In a page composed of fragments, each in its own JavaScript realm, the shell @@ -128,16 +155,18 @@ registries: either module path reaches the same ones. ## Seeing what telemetry skips -The engine prints nothing by itself. Hand it a `UwtReporter` where you want -to see what it skipped or dropped, typically in development: +Hand the engine a `UwtReporter` where you want to see what it skipped +(`'error'`) or dropped (`'warn'`), typically in development; +`uwtConsoleReporter` prints both to the console: ```ts -import { UwtReporter } from '@absaoss-cps/ui-watchtower'; - -const report: UwtReporter = (level, message, ...details) => - console[level](`[my-app] ${message}`, ...details); +uwtCreateTelemetry({ ...options, report: uwtConsoleReporter }); ``` +Something that cannot work as configured (`'config'`) — a second broadcast +host, RUM settings of the wrong type — is reported too, and printed to the +console when there is no reporter: it is never silent. + ## RxJS `traceScenario` settles a scenario from an Observable's outcome. It lives in @@ -151,19 +180,20 @@ customers$.pipe(traceScenario(scenario)).subscribe(); ## API -| Group | Exports | -| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| 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` and `UwtNoopTelemetrySink`; `UwtLogApiProvider`, `UwtLogQuery` and `UwtNoopLogApiProvider` | -| Broadcast | `UwtTelemetryBroadcastHost`, `UwtBroadcastTelemetrySink`, `UwtBroadcastLogApiProvider`, `uwtConnectBroadcastChannel`, `UWT_DEFAULT_BROADCAST_CHANNEL` | -| Redaction | `uwtRedactMetadata`, `uwtScrubString`, `uwtNormalizeError`, `uwtRedactConfigFor`, `UwtRedactConfig`, `UWT_DEFAULT_REDACT_CONFIG`, `UWT_REDACTED` | -| Utilities | `uwtClassifyTelemetryEvent`, `uwtIsDebugEnabled`, `uwtMarkSessionStart`, `uwtUuid`, `uwtSafe*`, `UwtReporter` | -| `/rxjs` | `traceScenario`, `UwtTraceScenarioOptions` | +| Group | Exports | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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` and `UwtNoopTelemetrySink`; `UwtLogApiProvider`, `UwtLogQuery` and `UwtNoopLogApiProvider` | +| Broadcast | `UwtTelemetryBroadcastHost`, `UwtBroadcastTelemetrySink`, `UwtBroadcastLogApiProvider`, `uwtConnectBroadcastChannel`, `UWT_DEFAULT_BROADCAST_CHANNEL` | +| Redaction | `uwtRedactMetadata`, `uwtScrubString`, `uwtNormalizeError`, `uwtRedactConfigFor`, `UwtRedactConfig`, `UWT_DEFAULT_REDACT_CONFIG`, `UWT_REDACTED` | +| Utilities | `uwtClassifyTelemetryEvent`, `uwtIsDebugEnabled`, `uwtMarkSessionStart`, `uwtSessionElapsed`, `uwtUuid`, `uwtSafe*`, `uwtReport`, `uwtConsoleReporter`, `UwtReporter` | +| `/rxjs` | `traceScenario`, `UwtTraceScenarioOptions` | +| `/rum` | `UwtRumTelemetrySink` and its options; `UwtRumCredentialsProvider`, `UwtRumBootstrap`, `UwtRumAppMonitorConfig`, `UwtRumCredentials` | ## License diff --git a/projects/ui-watchtower/jest.config.js b/projects/ui-watchtower/jest.config.js index 05504b1..f9fe823 100644 --- a/projects/ui-watchtower/jest.config.js +++ b/projects/ui-watchtower/jest.config.js @@ -7,19 +7,23 @@ module.exports = { displayName: 'ui-watchtower', rootDir: __dirname, - roots: ['/src', '/rxjs'], + roots: ['/src', '/rxjs', '/rum'], testEnvironment: 'node', transform: { '^.+\\.ts$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] }, moduleNameMapper: { '^@absaoss-cps/ui-watchtower$': '/src/public-api.ts', - '^@absaoss-cps/ui-watchtower/rxjs$': '/rxjs/src/public-api.ts' + '^@absaoss-cps/ui-watchtower/rxjs$': '/rxjs/src/public-api.ts', + '^@absaoss-cps/ui-watchtower/rum$': '/rum/src/public-api.ts' }, coverageDirectory: '/../../coverage/ui-watchtower', collectCoverageFrom: [ - '{src,rxjs}/**/*.ts', + '{src,rxjs,rum}/**/*.ts', '!**/*.spec.ts', '!**/public-api.ts' - ] + ], + coverageThreshold: { + global: { statements: 90, branches: 90, functions: 90, lines: 90 } + } }; diff --git a/projects/ui-watchtower/package.json b/projects/ui-watchtower/package.json index 4b08307..1e00897 100644 --- a/projects/ui-watchtower/package.json +++ b/projects/ui-watchtower/package.json @@ -9,9 +9,13 @@ "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 1c0bbed..9108ac3 100644 --- a/projects/ui-watchtower/public-api.snapshot.json +++ b/projects/ui-watchtower/public-api.snapshot.json @@ -127,6 +127,9 @@ "uwtConnectBroadcastChannel": [ "function(options?: UwtBroadcastConnectOptions | undefined): UwtBroadcastConnection" ], + "uwtConsoleReporter": [ + "const: UwtReporter" + ], "uwtCreateTelemetry": [ "function(options: UwtTelemetryOptions): UwtTelemetry" ], @@ -260,8 +263,14 @@ "uwtRedactMetadata": [ "function(value: unknown, config?: UwtRedactConfig | undefined, report?: UwtReporter | undefined): UwtTelemetryMetadata | undefined" ], + "uwtReport": [ + "function(report: UwtReporter | undefined, level: UwtReportLevel, message: string, ...details: unknown[]): void" + ], "UwtReporter": [ - "type = (level: 'warn' | 'error', message: string, ...details: unknown[]) => void" + "type = (level: UwtReportLevel, message: string, ...details: unknown[]) => void" + ], + "UwtReportLevel": [ + "type = 'warn' | 'error' | 'config'" ], "uwtResolveTelemetryConfig": [ "function(overrides?: UwtTelemetryConfigOverrides | undefined): UwtTelemetryConfig" @@ -544,6 +553,95 @@ "function(): string" ] }, + "@absaoss-cps/ui-watchtower/rum": { + "UwtRumAppMonitorConfig": [ + "interface", + "alias?: string | undefined", + "allowCookies?: boolean | undefined", + "applicationAttributes?: Record | undefined", + "applicationId: string", + "applicationVersion: string", + "batchLimit?: number | undefined", + "candidatesCacheSize?: number | undefined", + "client?: string | undefined", + "clientBuilder?: ((endpoint: URL, region: string, credentials?: ClientBuilderCredentials | undefined, compressionStrategy?: { enabled: boolean; } | undefined) => unknown) | undefined", + "compressionStrategy?: { enabled: boolean; } | undefined", + "cookieAttributes?: { unique?: boolean | undefined; domain?: string | undefined; path?: string | undefined; sameSite?: string | undefined; secure?: boolean | undefined; } | undefined", + "debug?: boolean | undefined", + "disableAutoPageView?: boolean | undefined", + "dispatchInterval?: number | undefined", + "enableRumClient?: boolean | undefined", + "enableW3CTraceId?: boolean | undefined", + "enableXRay?: boolean | undefined", + "endpoint?: string | undefined", + "eventCacheSize?: number | undefined", + "eventPluginsToLoad?: unknown[] | undefined", + "fetchFunction?: ((input: RequestInfo | URL, init?: RequestInit | undefined) => Promise) | undefined", + "headers?: Record | undefined", + "pageIdFormat?: 'PATH' | 'HASH' | 'PATH_AND_HASH' | undefined", + "pagesToExclude?: RegExp[] | undefined", + "pagesToInclude?: RegExp[] | undefined", + "recordResourceUrl?: boolean | undefined", + "region: string", + "releaseId?: string | undefined", + "retries?: number | undefined", + "routeChangeComplete?: number | undefined", + "routeChangeTimeout?: number | undefined", + "sessionAttributes?: Record | undefined", + "sessionEventLimit?: number | undefined", + "sessionLengthSeconds?: number | undefined", + "sessionSampleRate?: number | undefined", + "signing?: boolean | undefined", + "suppressSessionStartEvent?: boolean | undefined", + "telemetries?: (string | (string | object)[])[] | undefined", + "useBeacon?: boolean | undefined", + "userIdRetentionDays?: number | undefined" + ], + "UwtRumBootstrap": [ + "interface", + "config: UwtRumAppMonitorConfig", + "credentials?: UwtRumCredentials | undefined" + ], + "UwtRumCredentials": [ + "interface", + "accessKeyId: string", + "expiration: string", + "secretAccessKey: string", + "sessionToken: string" + ], + "UwtRumCredentialsProvider": [ + "interface", + "load(): Promise" + ], + "UwtRumDocument": [ + "interface", + "readonly cookie: string", + "readonly defaultView: { readonly location?: { readonly origin: string; } | undefined; readonly navigator: { readonly cookieEnabled: boolean; }; } | null" + ], + "UwtRumTelemetrySink": [ + "class", + "new (options: UwtRumTelemetrySinkOptions): UwtRumTelemetrySink", + "dispose(): void", + "flush(beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "init(): Promise", + "record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "recordPageView(pageId: string): void", + "setUserId(userId: string | undefined): void" + ], + "UwtRumTelemetrySinkOptions": [ + "interface", + "credentialsProvider: UwtRumCredentialsProvider", + "document?: UwtRumDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "redact?: UwtRedactConfig | undefined", + "report?: UwtReporter | undefined", + "runOutside?: ((callback: () => T) => T) | undefined" + ] + }, "@absaoss-cps/ui-watchtower/rxjs": { "traceScenario": [ "function(scenario: UwtScenario, options?: UwtTraceScenarioOptions | ((value: T) => void | UwtScenarioOutcome) | undefined, report?: UwtReporter | undefined): MonoTypeOperatorFunction" diff --git a/projects/ui-watchtower/rum/ng-package.json b/projects/ui-watchtower/rum/ng-package.json new file mode 100644 index 0000000..e09fb3f --- /dev/null +++ b/projects/ui-watchtower/rum/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts b/projects/ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts new file mode 100644 index 0000000..a86bb3d --- /dev/null +++ b/projects/ui-watchtower/rum/src/lib/uwt-rum-credentials/uwt-rum-credentials.ts @@ -0,0 +1,335 @@ +/** + * Short-lived AWS credentials for the RUM data plane. + * + * @group Interfaces + */ +export interface UwtRumCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + + /** ISO-8601 expiry. Used to schedule a refresh before the credentials lapse. */ + expiration: string; +} + +/** + * Mirrors `aws-rum-web`'s own `AwsCredentialIdentity | AwsCredentialIdentityProvider` + * union — used only by {@link UwtRumAppMonitorConfig.clientBuilder}. + */ +type ClientBuilderCredentials = + | { accessKeyId: string; secretAccessKey: string; sessionToken?: string } + | (() => Promise<{ + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + }>); + +/** + * Settings for the RUM app monitor, mapped onto the underlying `aws-rum-web` + * SDK's own `Config`. Every optional field left unset falls back to a + * default: `allowCookies`, `enableXRay`, `endpoint` and `telemetries` fall + * back to this library's own override; every other field falls back to the + * SDK's own default (see `buildRumConfig()`). + * + * Matches the SDK's `Config` field for field, with three omissions: + * + * - `endpointUrl` — always recomputed by the SDK from `endpoint`; setting it + * independently has no effect. + * - `identityPoolId`, `guestRoleArn` — the SDK's own Cognito guest-identity + * credential flow. AWS credentials come from a backend-vended + * `UwtRumCredentialsProvider` instead (see + * `UwtRumTelemetrySink.applyCredentials`). + * - `sessionId`, `userId` — owned at runtime by `UwtRumTelemetrySink`'s + * `setUserId`, `pinUserId` and `startSession`. + * + * None of the field types below are imported from `aws-rum-web`; every type + * here is hand-written. This keeps `aws-rum-web` an optional peer + * dependency for applications that only use the `broadcast` or `noop` sink. + * + * @group Interfaces + */ +export interface UwtRumAppMonitorConfig { + /** CloudWatch RUM app monitor id. */ + applicationId: string; + + /** AWS region hosting the app monitor, e.g. `eu-west-1`. */ + region: string; + + /** Version string reported with every event. */ + applicationVersion: string; + + // ---- Sampling & session ---- + + /** Fraction of sessions recorded, `0`–`1`. Default `1`. */ + sessionSampleRate?: number; + + /** + * Inactivity timeout of a session, in seconds. Default `1800` (30 minutes). + * Each recorded event pushes the expiry back; the next event after it + * starts a new session. + */ + sessionLengthSeconds?: number; + + /** Hard cap on events recorded per session, across all telemetry. Default `200`. */ + sessionEventLimit?: number; + + /** + * Suppresses the `session_start` event the SDK would otherwise emit at the + * start of a session. Default `false`. The library then doesn't record the + * session's start either, so scenario records carry no `sessionElapsed`. + */ + suppressSessionStartEvent?: boolean; + + /** + * Days the user id is kept in the `cwr_u` cookie. Default `30`. A signed-in + * user's id is kept there too, so on a shared device the next visitor starts + * with it until the app calls `setUserId(undefined)` or someone signs in. + * `0` keeps no user cookie at all: anonymous visitors get the nil id, or a + * random one for the rest of the page after a sign-out. + */ + userIdRetentionDays?: number; + + /** + * Attributes attached to every event in the session, seeded at + * construction. Default `{}`. `UwtRumTelemetrySink` merges `application`, + * `environment` and `appVersion` on top of this after construction; those + * three keys always win. + */ + sessionAttributes?: Record; + + /** + * Attributes attached to every event for the lifetime of the app monitor + * client, independent of session. Default unset. + */ + applicationAttributes?: Record; + + // ---- Telemetry collection & tracing ---- + + /** + * Built-in telemetry categories to enable. Defaults to errors, performance + * and same-origin HTTP. The SDK's own default also includes `'replay'` + * (session replay via `rrweb`); this library does not enable it by + * default. + * + * Options the SDK matches with `RegExp.test` or calls as a function — + * `urlsToInclude`, `urlsToExclude`, `addXRayTraceIdHeader` (also a + * boolean), `ignore`, `interactionId` — must hold exactly that. JSON can't + * carry them, and a string there would make the SDK throw each time it + * applies the option, so RUM doesn't start for the page instead (with a + * console warning naming the option). Set them in code, not in a broker's + * answer. + */ + telemetries?: (string | (string | object)[])[]; + + /** + * Custom event plugins to load alongside the built-in telemetry, each + * implementing `aws-rum-web`'s own `Plugin` interface. Default `[]`. + */ + eventPluginsToLoad?: unknown[]; + + /** Whether to emit X-Ray trace ids. Default `true`. */ + enableXRay?: boolean; + + /** Whether to emit W3C trace context ids alongside X-Ray's. Default `false`. */ + enableW3CTraceId?: boolean; + + /** Whether resource-timing events record the resource's URL. Default `true`. */ + recordResourceUrl?: boolean; + + // ---- Dispatch & buffering ---- + + /** Data plane endpoint. Defaults to the regional RUM endpoint. */ + endpoint?: string; + + /** Extra headers sent with every dispatch request. Default unset. */ + headers?: Record; + + /** Free-form label reported alongside dispatched events. Default unset. */ + alias?: string; + + /** Maximum events sent per dispatch request. Default `100`. */ + batchLimit?: number; + + /** Interval between automatic dispatches, in milliseconds. Default `5000`. */ + dispatchInterval?: number; + + /** Maximum events held in the in-memory cache awaiting dispatch. Default `1000`. */ + eventCacheSize?: number; + + /** Maximum not-yet-recorded candidate events cached. Default `10`. */ + candidatesCacheSize?: number; + + /** Retries for a failed dispatch request. Default `2`. */ + retries?: number; + + /** Whether unload-time flushes use `navigator.sendBeacon`. Default `true`. */ + useBeacon?: boolean; + + /** + * Whether dispatch requests are SigV4-signed. Default `true`. Signing is + * active whenever credentials are present — which they are here, via + * `setAwsCredentials` from `UwtRumCredentialsProvider` — independent of + * `identityPoolId`; that field only matters for the SDK's own, separate + * Cognito guest-identity flow, which this library does not use. Leave + * this enabled for broker-supplied credentials: disabling it here still + * sends unsigned requests to the same signed-only endpoint and gets + * rejected. Only turn it off if something in front of dispatch (a proxy, + * a different transport) already signs the request itself. + */ + signing?: boolean; + + /** Whether the request body is gzip-compressed before dispatch. Default `{ enabled: true }`. */ + compressionStrategy?: { enabled: boolean }; + + /** + * Overrides the SDK's own `fetch`-based transport. Default unset — the + * SDK uses the global `fetch`. + */ + fetchFunction?: typeof fetch; + + /** + * Overrides how the SDK builds its data-plane HTTP client. An advanced + * escape hatch — see `aws-rum-web`'s own `ClientBuilder` type for the exact + * signature (`(endpoint: URL, region: string, credentials?, compressionStrategy?) => DataPlaneClient`). + * Default unset. + */ + clientBuilder?: ( + endpoint: URL, + region: string, + credentials?: ClientBuilderCredentials, + compressionStrategy?: { enabled: boolean } + ) => unknown; + + // ---- Cookies & privacy ---- + + /** + * Whether the client may keep the session and user ids in cookies + * (`cwr_s`, `cwr_u`), so they survive a reload. Default `true` — the SDK's + * own default is `false`. Without cookies, every page load starts a new + * session. + */ + allowCookies?: boolean; + + /** + * Overrides for the cookies the client sets. Unset fields fall back to the + * SDK's own defaults (`domain` = current hostname, `path` = `/`, + * `sameSite` = `'Strict'`, `secure` = `true`, `unique` = `false`). + * + * With `unique: false`, every app monitor on the same hostname shares one + * session cookie (`cwr_s`). Set `unique: true` when several apps with their + * own app monitors run on one hostname, so each keeps its own session. The + * user cookie (`cwr_u`) stays shared either way — `aws-rum-web` 3.2.1 + * suffixes only the session cookie. + */ + cookieAttributes?: { + unique?: boolean; + domain?: string; + path?: string; + sameSite?: string; + secure?: boolean; + }; + + // ---- Page tracking ---- + + /** + * Disables the client's own automatic page-view recording on navigation. + * Pair with `UwtRumTelemetrySink.recordPageView()` to record page ids + * yourself. Default `false`. + */ + disableAutoPageView?: boolean; + + /** How the page id is derived from the URL. Default `'PATH'`. */ + pageIdFormat?: 'PATH' | 'HASH' | 'PATH_AND_HASH'; + + /** + * Pages recorded, matched against the page id. Default matches every page. + * `undefined` or `null` means unset; anything else that isn't an array of + * `RegExp`s (a string list from JSON) keeps RUM from starting for the page. + */ + pagesToInclude?: RegExp[]; + + /** + * Pages excluded from recording, matched against the page id. Default `[]`. + * `undefined` or `null` means unset; anything else that isn't an array of + * `RegExp`s (a string list from JSON) keeps RUM from starting for the page, + * rather than record what it should exclude. + */ + pagesToExclude?: RegExp[]; + + /** How long, in milliseconds, a route change waits before being considered complete. Default `100`. */ + routeChangeComplete?: number; + + /** Timeout, in milliseconds, before an incomplete route change is abandoned. Default `10000`. */ + routeChangeTimeout?: number; + + // ---- Advanced / escape hatches ---- + + /** + * Label reported as the SDK's own install method. Default is the SDK's + * internal module-install constant; overriding it is rarely useful outside + * of testing the SDK itself. + */ + client?: string; + + /** Release identifier reported with every event, e.g. a build SHA. Default unset. */ + releaseId?: string; + + /** Logs the SDK's own internal diagnostics to the console. Default `false`. */ + debug?: boolean; + + /** Master switch for the RUM client. Default `true`. */ + enableRumClient?: boolean; +} + +/** + * The payload returned by a {@link UwtRumCredentialsProvider}. + * + * @group Interfaces + */ +export interface UwtRumBootstrap { + /** App monitor settings. */ + config: UwtRumAppMonitorConfig; + + /** + * Credentials for the data plane. Omit when the app monitor is configured for + * unauthenticated access. + */ + credentials?: UwtRumCredentials; +} + +/** + * What the RUM sink needs from the host application to start. + * + * Keeps the telemetry layer free of AWS account details — how the app + * monitor id is discovered and how credentials are vended is entirely the + * application's business. + * + * @example + * ```typescript + * export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { + * async load(): Promise { + * const res = await fetch('/rum/init'); + * if (!res.ok) throw new Error(`RUM bootstrap failed: ${res.status}`); + * const { enabled, config, credentials } = await res.json(); + * return enabled ? { config, credentials } : null; + * } + * } + * ``` + * + * @group Interfaces + */ +export interface UwtRumCredentialsProvider { + /** + * Resolves the app monitor settings and a set of credentials. + * + * Called once at startup and again shortly before each credential expiry. + * Returning `null` disables RUM for the rest of the page — the telemetry + * layer stays fully functional, it simply stops shipping. Throw instead when + * the request fails: the sink retries — at startup with a growing, random + * wait for up to five minutes, buffering events until the client starts; + * on refresh every 30 s. + * + * @returns the bootstrap payload, or `null` to disable RUM + */ + load(): Promise; +} diff --git a/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts new file mode 100644 index 0000000..98dab01 --- /dev/null +++ b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts @@ -0,0 +1,180 @@ +/** + * @jest-environment jsdom + */ +import { UwtRumBootstrap } from '../uwt-rum-credentials/uwt-rum-credentials'; +import { + UwtRumTelemetrySink, + UwtRumTelemetrySinkOptions +} from './uwt-rum-telemetry.sink'; + +const mockClient = { + recordEvent: jest.fn(), + recordError: jest.fn(), + recordPageView: jest.fn(), + addSessionAttributes: jest.fn(), + setEventMetadataHook: jest.fn(), + setAwsCredentials: jest.fn(), + getSessionId: jest.fn(() => 'session-1'), + getUserId: jest.fn(), + pinUserId: jest.fn(), + startSession: jest.fn(), + dispatch: jest.fn(), + dispatchBeacon: jest.fn(), + disable: jest.fn() +}; +const mockLoads = { count: 0 }; + +jest.mock('aws-rum-web', () => { + mockLoads.count++; + return { + AwsRum: function () { + return mockClient; + } + }; +}); + +/** What a broker returns: settings, and credentials an hour from expiry. */ +function bootstrap( + config: Partial = {} +): UwtRumBootstrap { + return { + config: { + applicationId: 'app-monitor', + applicationVersion: '1.0.0', + region: 'eu-west-1', + ...config + }, + credentials: { + accessKeyId: 'a', + secretAccessKey: 's', + sessionToken: 't', + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString() + } + }; +} + +describe('the core RUM sink', () => { + let load: jest.Mock; + let sinks: UwtRumTelemetrySink[]; + + function sink( + options: Partial = {} + ): UwtRumTelemetrySink { + const created = new UwtRumTelemetrySink({ + identity: { application: 'cart', environment: 'test', version: '1' }, + credentialsProvider: { load }, + ...options + }); + sinks.push(created); + return created; + } + + beforeEach(() => { + jest.clearAllMocks(); + load = jest.fn(async () => bootstrap()); + sinks = []; + }); + + afterEach(() => { + sinks.forEach((created) => created.dispose()); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('should never start without a page: no credentials request, no SDK', async () => { + const serverSink = sink({ document: null }); + const loadsBefore = mockLoads.count; + + await serverSink.init(); + serverSink.record('com.uwt.bi', { eventName: 'x' }); + + expect(load).not.toHaveBeenCalled(); + expect(mockLoads.count).toBe(loadsBefore); + expect(serverSink.getSessionId()).toBeUndefined(); + }); + + it('should never start while disabled', async () => { + await sink({ enabled: false }).init(); + + expect(load).not.toHaveBeenCalled(); + }); + + it('should start outside change detection, then record through the client', async () => { + let outside = 0; + const started = sink({ + runOutside: (callback) => { + outside++; + return callback(); + } + }); + + await started.init(); + started.record('com.uwt.bi', { eventName: 'x' }, { feature: 'cart' }); + + expect(outside).toBe(1); + expect(load).toHaveBeenCalledTimes(1); + expect(mockClient.setAwsCredentials).toHaveBeenCalled(); + expect(mockClient.recordEvent).toHaveBeenCalledWith( + 'com.uwt.bi', + { eventName: 'x' }, + { feature: 'cart' } + ); + expect(started.getSessionId()).toBe('session-1'); + }); + + it('should replay what it was given before the client started', async () => { + const started = sink(); + + started.record('com.uwt.bi', { eventName: 'early' }); + await started.init(); + + expect(mockClient.recordEvent).toHaveBeenCalledWith( + 'com.uwt.bi', + { eventName: 'early' }, + undefined + ); + }); + + it("should report settings that keep RUM from starting as a 'config' problem", async () => { + const report = jest.fn(); + load.mockResolvedValue( + bootstrap({ pagesToInclude: ['not a RegExp'] as unknown as RegExp[] }) + ); + + await sink({ report }).init(); + + expect(mockClient.recordEvent).not.toHaveBeenCalled(); + expect(report).toHaveBeenCalledWith( + 'config', + 'RUM not started, settings of the wrong type: pagesToInclude (expected an array of RegExp)' + ); + }); + + it('should print those settings to the console without a reporter: RUM off is never silent', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + load.mockResolvedValue( + bootstrap({ pagesToInclude: ['not a RegExp'] as unknown as RegExp[] }) + ); + + await sink().init(); + + expect(warn).toHaveBeenCalledWith( + '[ui-watchtower] RUM not started, settings of the wrong type: pagesToInclude (expected an array of RegExp)' + ); + }); + + it('should stop the client, and leave no refresh pending, once disposed', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + const started = sink(); + await started.init(); + expect(jest.getTimerCount()).toBe(1); + + started.dispose(); + started.dispose(); + + expect(mockClient.disable).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(0); + started.record('com.uwt.bi', { eventName: 'late' }); + expect(mockClient.recordEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts new file mode 100644 index 0000000..6d00cce --- /dev/null +++ b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.ts @@ -0,0 +1,1088 @@ +import type { AwsRum, AwsRumConfig } from 'aws-rum-web'; +import { + UWT_DEFAULT_REDACT_CONFIG, + uwtConsoleReporter, + uwtMarkSessionStart, + uwtNormalizeError, + UwtRedactConfig, + uwtReport, + UwtReporter, + uwtSafe, + uwtSafeVoid, + UwtTelemetryError, + UwtTelemetryIdentity, + UwtTelemetryMetadata, + UwtTelemetrySink, + uwtUuid +} from '@absaoss-cps/ui-watchtower'; +import { + UwtRumBootstrap, + UwtRumCredentials, + UwtRumCredentialsProvider +} from '../uwt-rum-credentials/uwt-rum-credentials'; + +/** Refresh credentials this long before they expire. */ +const CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000; + +/** + * Maximum delay supported by setTimeout (2^31 - 1 ms, ~24.85 days). + * Prevents 32-bit signed integer overflow triggering an immediate execution. + */ +const MAX_TIMEOUT_MS = 2_147_483_647; + +/** + * Retry delay after a scheduled refresh fails or returns no credentials. + * Shorter than the normal refresh-ahead-of-expiry interval. + */ +const CREDENTIAL_RETRY_DELAY_MS = 30 * 1000; + +/** + * Startup retries wait a random time up to a limit that starts at + * {@link INIT_RETRY_BASE_MS} and doubles after each failure, up to + * {@link INIT_RETRY_MAX_MS}. The first retry comes fast, because the client + * captures nothing until it exists; the randomness keeps tabs reloaded + * together from retrying together. + */ +const INIT_RETRY_BASE_MS = 1000; +const INIT_RETRY_MAX_MS = 60 * 1000; + +/** + * How long startup keeps retrying before the sink gives up and runs the page + * without RUM. Bounds the retries when the failure is permanent, such as a + * wrong URL or a 403. + */ +const INIT_RETRY_WINDOW_MS = 5 * 60 * 1000; + +/** + * The RUM client's defaults for `eventCacheSize` and `batchLimit`, which it + * keeps to itself — used only when the app monitor config sets neither. + * Together they bound how many batches the client can hold. + */ +const SDK_DEFAULT_EVENT_CACHE_SIZE = 1000; +const SDK_DEFAULT_BATCH_LIMIT = 100; + +/** + * Events retained while the SDK loads. Bounded so a failed init cannot + * grow the buffer without limit. + */ +const PRE_INIT_BUFFER_LIMIT = 100; + +/** The event the RUM client records each time it starts a session. */ +const SESSION_START_EVENT_TYPE = 'com.amazon.rum.session_start_event'; + +/** + * The RUM client's session cookie: `cwr_s`, or `cwr_s_` with + * `cookieAttributes.unique` (both documented by AWS). + */ +function sessionCookieName(bootstrap: UwtRumBootstrap): string { + const { config } = bootstrap; + return config.cookieAttributes?.unique + ? `cwr_s_${config.applicationId}` + : 'cwr_s'; +} + +/** Metadata keys under this prefix are reserved by the RUM client. */ +const RESERVED_PREFIX = 'aws:'; + +interface BufferedEvent { + kind: 'event'; + eventType: string; + payload: object; + metadata?: UwtTelemetryMetadata; +} + +interface BufferedPageView { + kind: 'pageView'; + pageId: string; +} + +interface BufferedError { + kind: 'error'; + error: UwtTelemetryError; + metadata?: UwtTelemetryMetadata; +} + +/** Anything the pre-init buffer holds. */ +type BufferedItem = BufferedEvent | BufferedPageView | BufferedError; + +/** + * Drops every key whose value is unset: `undefined`, or `null` as a broker's + * JSON spells it. + * + * Used to build the SDK config object from optional consumer fields: a key + * present with value `undefined` or `null` would overwrite the SDK's own + * default for that key, so unset fields must be absent. + */ +function omitUnset(obj: T): Partial { + const result: Partial = {}; + for (const key of Object.keys(obj) as (keyof T)[]) { + if (obj[key] !== undefined && obj[key] !== null) { + result[key] = obj[key]; + } + } + return result; +} + +/** Whether `value` is an array of `RegExp`s only. */ +function isRegExpList(value: unknown): boolean { + return ( + Array.isArray(value) && value.every((entry) => entry instanceof RegExp) + ); +} + +/** A setting's type check, and the type it expects, for the warning. */ +interface UwtSettingCheck { + valid: (value: unknown) => boolean; + expected: string; +} + +const REGEXP_LIST: UwtSettingCheck = { + valid: isRegExpList, + expected: 'an array of RegExp' +}; +const FUNCTION: UwtSettingCheck = { + valid: (value) => typeof value === 'function', + expected: 'a function' +}; + +/** + * Telemetry options the SDK calls as a function or matches with + * `RegExp.test`. JSON can carry neither, and anything else in their place + * makes the SDK throw each time it applies the option — for the URL filters, + * inside its patched `fetch` and XHR, so on every application request. + */ +const TELEMETRY_OPTION_CHECKS = new Map([ + ['urlsToInclude', REGEXP_LIST], + ['urlsToExclude', REGEXP_LIST], + [ + 'addXRayTraceIdHeader', + { + valid: (value) => typeof value === 'boolean' || isRegExpList(value), + expected: 'a boolean or an array of RegExp' + } + ], + ['ignore', FUNCTION], + ['interactionId', FUNCTION] +]); + +/** + * Lists the settings in `config` the SDK can't use, each with the type it + * expects: `pagesToInclude` or `pagesToExclude` that isn't an array of + * `RegExp`s, `telemetries` that isn't an array, and each telemetry option + * failing its {@link TELEMETRY_OPTION_CHECKS} check, as `name.option`. + * `undefined` and `null` count as unset, not as invalid. + */ +function invalidRumSettings(config: UwtRumBootstrap['config']): string[] { + const invalid: string[] = []; + for (const key of ['pagesToInclude', 'pagesToExclude'] as const) { + const value = config[key]; + if (value != null && !REGEXP_LIST.valid(value)) { + invalid.push(`${key} (expected ${REGEXP_LIST.expected})`); + } + } + const telemetries: unknown = config.telemetries; + if (telemetries == null) { + return invalid; + } + if (!Array.isArray(telemetries)) { + // A string would be read as one telemetry per character. + invalid.push('telemetries (expected an array)'); + return invalid; + } + for (const telemetry of telemetries) { + if (!Array.isArray(telemetry)) { + continue; + } + const [name, options] = telemetry; + if (typeof options !== 'object' || options === null) { + continue; + } + for (const [key, value] of Object.entries(options)) { + const check = TELEMETRY_OPTION_CHECKS.get(key); + if (value != null && check && !check.valid(value)) { + invalid.push(`${String(name)}.${key} (expected ${check.expected})`); + } + } + } + return invalid; +} + +/** + * Copies `telemetries` without tuple options that are unset (`undefined` or + * `null`). The SDK merges a telemetry's options over its defaults with + * `Object.assign`, so an explicit unset value would replace a default it + * relies on — `urlsToExclude: null` would throw on every request. + */ +function withoutUnsetOptions( + telemetries: NonNullable +): NonNullable { + return telemetries.map((telemetry) => { + if (!Array.isArray(telemetry)) { + return telemetry; + } + const [name, options, ...rest] = telemetry; + if (typeof options !== 'object' || options === null) { + return telemetry; + } + return [name, omitUnset(options), ...rest]; + }); +} + +/** + * The part of a page the RUM sink reads: its cookies, its origin, and whether + * cookies are enabled. A browser `Document` is one. Typed structurally, so + * using the sink needs no DOM typings. + * + * @group Interfaces + */ +export interface UwtRumDocument { + readonly cookie: string; + readonly defaultView: { + readonly location?: { readonly origin: string }; + readonly navigator: { readonly cookieEnabled: boolean }; + } | null; +} + +/** + * What {@link UwtRumTelemetrySink} needs. + * + * @group Interfaces + */ +export interface UwtRumTelemetrySinkOptions { + /** + * The application's identity: attached to every RUM session, and the realm + * a forwarded error is told apart from. + */ + identity: UwtTelemetryIdentity; + /** Where the app monitor settings and credentials come from. */ + credentialsProvider: UwtRumCredentialsProvider; + /** Redaction for the failures it reports. Default {@link UWT_DEFAULT_REDACT_CONFIG}. */ + redact?: UwtRedactConfig; + /** + * `false` never starts the client — no credentials request, no SDK + * download — and every call is a no-op. Default `true`. + */ + enabled?: boolean; + /** + * The page: its cookies and origin. Defaults to the global `document` + * where there is one; without one — as on a server — the client never + * starts. + */ + document?: UwtRumDocument | null; + /** + * Runs the client's startup outside the host framework's change + * detection, so its timers never keep the application from settling: + * Angular passes `NgZone.runOutsideAngular`. + */ + runOutside?: (callback: () => T) => T; + /** + * Receives failures and warnings — and, as `'config'`, settings that keep + * RUM from starting, which go to the console when no reporter is given. + */ + report?: UwtReporter; +} + +/** + * Telemetry sink backed by the AWS CloudWatch RUM web client. + * + * Custom scenario and BI events travel through `recordEvent`, alongside the + * page views, web vitals, JS errors, HTTP failures and resource timing the + * client records on its own. The SDK is imported lazily, so an application + * that never provides this sink never pays for the bundle. Every operation + * is fail-open: if the broker is down, credentials lapse, or the SDK + * throws, the application behaves as if RUM were healthy. + * + * @group Services + */ +export class UwtRumTelemetrySink extends UwtTelemetrySink { + private readonly config: UwtTelemetryIdentity; + private readonly redact: UwtRedactConfig; + private readonly credentialsProvider: UwtRumCredentialsProvider; + private readonly document: UwtRumDocument; + /** + * False without a page (on a server) and while telemetry is off: the + * client never starts — no credentials fetch, no SDK import — and every + * call is a no-op. + */ + private readonly canStart: boolean; + private readonly runOutside: (callback: () => T) => T; + private readonly report?: UwtReporter; + + private awsRum: AwsRum | null = null; + /** + * The pending broker call: a startup retry before the client exists, the + * next credential refresh after. + */ + private refreshTimer: ReturnType | null = null; + private buffer: BufferedItem[] = []; + private userId?: string; + /** + * Whether a session's start is recorded (`uwtMarkSessionStart`): only when + * the client keeps sessions in its cookie, so a session outlives the page, + * and the app hasn't suppressed session-start events. Set when the client + * starts. + */ + private marksSessionStarts = false; + /** Set while {@link markSessionStart} runs, to stop it calling itself. */ + private markingSession = false; + /** + * Set once the app has called `setUserId`. Until then, the client's id may + * be a signed-in user's, restored from its cookie after a reload, so + * events carry no `signedIn`. + */ + private userKnown = false; + /** A sign-out arrived before the client existed; applied once it starts. */ + private signOutPending = false; + /** The most batches the client can hold, set when it starts. */ + private maxBatches = 1; + /** + * Set once RUM is off for the page — the broker declined, startup gave up, + * or a refresh returned null; distinct from `!awsRum` alone. + */ + private disabled = false; + /** Broker requests that failed at startup so far. */ + private initFailures = 0; + /** When the first of them failed. */ + private initFailedAt?: number; + /** + * The current startup attempt, shared by every caller — an application + * starting the sink at bootstrap, {@link ensureInitialized}, and a caller + * awaiting `init()` directly. A + * retry replaces it, so awaiting `init()` waits for one attempt, not for + * RUM to be running. + */ + private initPromise?: Promise; + /** + * Set once in {@link dispose}. `refreshCredentials()` nulls `refreshTimer` + * before its own `await`, so a dispose during that window would leave a + * new timer nothing could clear. Checked after every await in the + * refresh chain instead. + */ + private destroyed = false; + + constructor(options: UwtRumTelemetrySinkOptions) { + super(); + const page = + options.document === undefined + ? (globalThis.document ?? null) + : options.document; + this.config = options.identity; + this.redact = options.redact ?? UWT_DEFAULT_REDACT_CONFIG; + this.credentialsProvider = options.credentialsProvider; + this.canStart = options.enabled !== false && page !== null; + // Never read unless the client starts, which needs a page. + this.document = page ?? { cookie: '', defaultView: null }; + this.runOutside = options.runOutside ?? ((callback) => callback()); + this.report = options.report; + } + + /** + * Loads and starts the RUM client. + * + * Fire-and-forget: it must never block bootstrap and never reject into the + * caller. Safe to call more than once; subsequent calls are no-ops. + * + * @returns a promise that resolves once initialization has been attempted + */ + async init(): Promise { + if (!this.canStart) { + return; + } + if (!this.initPromise) { + // Outside a framework's change detection: the SDK's dispatch interval + // and this sink's retry and refresh timers would otherwise keep a + // zone-based app from ever becoming stable. + this.initPromise = this.runOutside(() => this.performInit()); + } + return this.initPromise; + } + + private async performInit(): Promise { + let bootstrap: UwtRumBootstrap | null; + try { + bootstrap = await this.credentialsProvider.load(); + } catch (error) { + this.retryInit(error); + return; + } + + try { + if (!bootstrap?.config) { + this.disableAwsRum(); + this.buffer = []; + return; + } + + // Fails closed: the page and URL filters and `ignore` decide what is + // *not* recorded, so dropping one would record what the app meant to + // keep out, and passing it on would make the SDK throw. RUM stays off + // for the page instead, as with a broker that returns `null`. + const invalid = invalidRumSettings(bootstrap.config); + if (invalid.length) { + this.disableAwsRum(); + this.buffer = []; + this.reportInvalidSettings(invalid); + return; + } + + // Not retried if this fails: with native ES modules (Angular's default + // build) the browser remembers a failed dynamic import for the page. + const { AwsRum } = await import('aws-rum-web'); + if (this.destroyed) { + return; + } + + const sessionCookie = sessionCookieName(bootstrap); + const hadSession = this.hasCookie(sessionCookie); + + this.awsRum = new AwsRum( + bootstrap.config.applicationId, + bootstrap.config.applicationVersion, + bootstrap.config.region, + this.buildRumConfig(bootstrap) + ); + const { + eventCacheSize = SDK_DEFAULT_EVENT_CACHE_SIZE, + batchLimit = SDK_DEFAULT_BATCH_LIMIT + } = bootstrap.config; + const batches = Math.ceil(eventCacheSize / batchLimit); + // A batchLimit of 0 or a non-number would make this unbounded. + this.maxBatches = Number.isFinite(batches) && batches > 1 ? batches : 1; + + this.marksSessionStarts = + bootstrap.config.allowCookies !== false && + this.document.defaultView?.navigator.cookieEnabled !== false && + bootstrap.config.suppressSessionStartEvent !== true; + this.applyIdentity(); + // The client can start the first session inside its constructor — the + // landing page view, the navigation timing — before the metadata hook + // exists to see its session_start. It writes the session cookie then, + // so a cookie that wasn't there a moment ago means a session that + // started just now. + if ( + this.marksSessionStarts && + !hadSession && + this.hasCookie(sessionCookie) + ) { + this.markSessionStart(); + } + this.applyCredentials(bootstrap.credentials); + this.replayBuffer(); + } catch (error) { + this.disableAwsRum(); + this.buffer = []; + this.reportFailure('RUM init failed, monitoring disabled', error); + } + } + + /** + * After a failed broker request at startup: tries again later, keeping + * events buffered — the failure is usually a passing network or broker + * problem. Gives up after {@link INIT_RETRY_WINDOW_MS}. + */ + private retryInit(error: unknown): void { + if (this.destroyed) { + return; + } + + this.initFailures++; + this.initFailedAt ??= Date.now(); + if (Date.now() - this.initFailedAt >= INIT_RETRY_WINDOW_MS) { + this.disableAwsRum(); + this.buffer = []; + this.reportFailure('RUM init failed, monitoring disabled', error); + return; + } + + if (this.initFailures === 1) { + this.reportFailure('RUM init failed, retrying', error); + } + const limit = Math.min( + INIT_RETRY_BASE_MS * 2 ** (this.initFailures - 1), + INIT_RETRY_MAX_MS + ); + this.scheduleRetry(() => { + this.initPromise = this.performInit(); + return this.initPromise; + }, Math.random() * limit); + } + + /** + * Kicks off `init()` on first real use, for a sink nothing started at + * bootstrap (in Angular, one bound directly rather than through + * `provideUwtTelemetryRumSink()`). `init()` is idempotent and + * memoized, so this is a no-op once already initialized or already in + * flight, and never races with a caller awaiting `init()` directly. + */ + private ensureInitialized(): void { + if (!this.initPromise) { + this.init().catch(() => undefined); + } + } + + /** + * Shared shape behind {@link record}, {@link recordError} and + * {@link recordPageView}: kicks off init, then either buffers `item` + * (pre-init) or hands the live client to `dispatch`, all inside the same + * fail-open guard. + */ + private recordOrBuffer( + operation: string, + item: BufferedItem, + dispatch: (awsRum: AwsRum) => void + ): void { + this.ensureInitialized(); + uwtSafeVoid( + operation, + () => { + if (!this.canStart || this.disabled) { + return; + } + + const awsRum = this.awsRum; + if (!awsRum) { + this.bufferItem(item); + return; + } + + dispatch(awsRum); + }, + this.report + ); + } + + /** @inheritdoc */ + record( + eventType: string, + payload: object, + metadata?: UwtTelemetryMetadata + ): void { + this.recordOrBuffer( + 'rum.record', + { kind: 'event', eventType, payload, metadata }, + (awsRum) => + awsRum.recordEvent(eventType, payload, this.sanitize(metadata)) + ); + } + + /** @inheritdoc */ + recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata): void { + this.recordOrBuffer( + 'rum.recordError', + { kind: 'error', error, metadata }, + (awsRum) => awsRum.recordError(this.withOrigin(error, metadata)) + ); + } + + /** @inheritdoc */ + getSessionId(): string | undefined { + return uwtSafe( + 'rum.getSessionId', + () => this.awsRum?.getSessionId(), + undefined, + this.report + ); + } + + /** + * Associates subsequent events with a user, or with nobody. + * + * Signing in pins the id, and works even before `init()` resolves. + * Signing out starts a fresh session with a new anonymous id (`pinUserId` + * has no inverse), which emits a `session_start` event and re-rolls + * sampling. Before `init()` resolves, a sign-out is applied once the + * client starts. Signing out while nobody is signed in does nothing. + * + * Every event carries `signedIn` in its metadata, so a signed-in user's id + * can be told from an anonymous one. It is absent until the first call, + * so call this as early as possible, e.g. right after the auth library + * initializes. + * + * @param userId the application's own user identifier, or `undefined` + * (or `''`) on sign-out. RUM accepts only a UUID, e.g. MSAL's + * `localAccountId`. + */ + setUserId(userId: string | undefined): void { + uwtSafeVoid( + 'rum.setUserId', + () => { + const previous = this.userId; + this.userId = userId; + this.userKnown = true; + + const awsRum = this.awsRum; + if (!awsRum) { + // A sign-in is pinned, a sign-out applied, once the client starts. + if (userId || previous) { + this.signOutPending = !userId; + } + return; + } + + if (userId && userId !== previous) { + // Unless the client already has this id, as after a reload. + if (awsRum.getUserId() !== userId) { + this.sendWaiting(awsRum); + } + awsRum.pinUserId(userId); + } else if (!userId && previous) { + this.sendWaiting(awsRum); + awsRum.startSession({ userId: uwtUuid() }); + } + }, + this.report + ); + } + + /** + * Sends everything the client holds, under the current user id. The client + * reads the id when it sends a batch, not when it records an event, so + * events still waiting at a change would otherwise go out under the new id. + * Each `dispatch()` sends one batch, and nothing once the client is empty. + */ + private sendWaiting(awsRum: AwsRum): void { + for (let batch = 0; batch < this.maxBatches; batch++) { + awsRum.dispatch(); + } + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(beacon = false): void { + uwtSafeVoid( + 'rum.flush', + () => { + if (!this.awsRum) { + if (this.buffer.length > 0) { + uwtReport( + this.report, + 'warn', + `${this.buffer.length} RUM event(s) lost: page unloaded before RUM finished initializing` + ); + } + return; + } + + if (beacon) { + this.awsRum.dispatchBeacon(); + } else { + this.awsRum.dispatch(); + } + }, + this.report + ); + } + + /** + * Records a page view. + * + * The RUM client already records a page view on every navigation via + * `history.pushState`, covering a single-page router automatically. Use this + * to report a different page id (e.g. a route template instead of the + * resolved URL); also set `disableAutoPageView: true` in the RUM config + * to avoid recording the navigation twice. Buffered like {@link record} + * when called before `init()` resolves. + * + * @param pageId the page identity, e.g. `/customers/:id` + */ + recordPageView(pageId: string): void { + this.recordOrBuffer( + 'rum.recordPageView', + { kind: 'pageView', pageId }, + (awsRum) => awsRum.recordPageView(pageId) + ); + } + + /** + * Stops the client, and any credential refresh or startup retry pending. + * Calling it again does nothing. + */ + dispose(): void { + this.destroyed = true; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.disableAwsRum(); + } + + /** + * Disables the sink: stops the AWS RUM client's own listeners, plugins, + * and dispatch timer before dropping the reference — clearing `awsRum` + * alone only stops this class from talking to it; the already-constructed + * instance would otherwise keep running orphaned in the page (and, on + * dispose, could duplicate telemetry if the app is started again in + * the same one) — and marks the session disabled, so every call site that + * tears the client down does so atomically with the flag `recordOrBuffer` + * checks, instead of each caller having to remember to set both. + */ + private disableAwsRum(): void { + uwtSafeVoid('rum.disable', () => this.awsRum?.disable(), this.report); + this.awsRum = null; + this.disabled = true; + } + + /** + * Maps {@link UwtRumAppMonitorConfig} onto the SDK's own `PartialConfig`. + * + * The SDK merges this object over its own defaults with a shallow + * `Object.assign`, so a key present with value `undefined` or `null` + * overwrites the SDK's default instead of falling through to it. + * `allowCookies`, `enableXRay`, `endpoint` and `telemetries` always carry + * this library's own override; every other field goes through + * {@link omitUnset} (or, for `client`, a conditional spread) so an unset + * field is absent and the SDK's own default applies. Unset telemetry options + * are left out for the same reason. `performInit()` has already checked the + * types of `pagesToInclude`/`pagesToExclude`, `telemetries` and the telemetry + * options in {@link TELEMETRY_OPTION_CHECKS} ({@link invalidRumSettings}); + * every other field is passed on as given. + */ + private buildRumConfig(bootstrap: UwtRumBootstrap): AwsRumConfig { + const { config } = bootstrap; + const origin = this.document.defaultView?.location?.origin ?? ''; + + // X-Ray trace header only on same-origin requests; a cross-origin + // request with an unexpected header fails CORS preflight. Anchored at + // both ends (with a path/query/fragment/end-of-string lookahead) so + // `https://example.com` doesn't also match a look-alike host like + // `https://example.com.attacker.example`. + const sameOrigin = new RegExp( + '^' + origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[/?#]|$)' + ); + + return { + allowCookies: config.allowCookies ?? true, + enableXRay: config.enableXRay ?? true, + endpoint: + config.endpoint ?? + `https://dataplane.rum.${config.region}.amazonaws.com`, + telemetries: Array.isArray(config.telemetries) + ? withoutUnsetOptions(config.telemetries) + : (config.telemetries ?? [ + 'errors', + 'performance', + ['http', { addXRayTraceIdHeader: [sameOrigin] }] + ]), + + ...(config.client != null ? { client: config.client } : {}), + + ...omitUnset({ + releaseId: config.releaseId, + clientBuilder: config.clientBuilder, + applicationAttributes: config.applicationAttributes, + alias: config.alias, + headers: config.headers, + cookieAttributes: config.cookieAttributes, + sessionSampleRate: config.sessionSampleRate, + sessionLengthSeconds: config.sessionLengthSeconds, + sessionEventLimit: config.sessionEventLimit, + suppressSessionStartEvent: config.suppressSessionStartEvent, + userIdRetentionDays: config.userIdRetentionDays, + sessionAttributes: config.sessionAttributes, + eventPluginsToLoad: config.eventPluginsToLoad, + enableW3CTraceId: config.enableW3CTraceId, + recordResourceUrl: config.recordResourceUrl, + batchLimit: config.batchLimit, + dispatchInterval: config.dispatchInterval, + eventCacheSize: config.eventCacheSize, + candidatesCacheSize: config.candidatesCacheSize, + retries: config.retries, + useBeacon: config.useBeacon, + signing: config.signing, + compressionStrategy: config.compressionStrategy, + fetchFunction: config.fetchFunction, + pageIdFormat: config.pageIdFormat, + pagesToInclude: config.pagesToInclude, + pagesToExclude: config.pagesToExclude, + routeChangeComplete: config.routeChangeComplete, + routeChangeTimeout: config.routeChangeTimeout, + disableAutoPageView: config.disableAutoPageView, + debug: config.debug, + enableRumClient: config.enableRumClient + }) + } as unknown as AwsRumConfig; + } + + /** + * Attaches application identity once per session rather than once per event. + * + * `addSessionAttributes` covers custom events; the metadata hook stamps + * the same attributes onto the client's own built-in events too. The hook + * also adds `signedIn`, read when each event is recorded. + * + * See {@link UwtRumAppMonitorConfig.sessionAttributes} for how this + * merges with a consumer-supplied base. + * + * It also sees the client's own `session_start` event, recorded + * each time it starts a session, and records that moment as the + * session's start (`uwtMarkSessionStart`) — the client keeps no start time + * itself. Only while {@link marksSessionStarts}. + */ + private applyIdentity(): void { + if (!this.awsRum) { + return; + } + + const attributes = { + application: this.config.application, + environment: this.config.environment, + appVersion: this.config.version + }; + + this.awsRum.addSessionAttributes(attributes); + this.awsRum.setEventMetadataHook((type) => { + if (this.marksSessionStarts && type === SESSION_START_EVENT_TYPE) { + this.markSessionStart(); + } + return this.userKnown + ? { ...attributes, signedIn: !!this.userId } + : attributes; + }); + + if (this.userId) { + this.awsRum.pinUserId(this.userId); + } else if (this.signOutPending) { + this.signOutPending = false; + this.awsRum.startSession({ userId: uwtUuid() }); + } + } + + /** + * Records the current session's start as now. Called right after a session + * started, so it is current and reading its id doesn't start another one — + * except with a session length of 0, where the read starts a new session + * whose session_start calls back in here; the guard stops that recursion. + */ + private markSessionStart(): void { + if (this.markingSession) { + return; + } + this.markingSession = true; + try { + uwtSafeVoid( + 'rum.markSessionStart', + () => { + const sessionId = this.awsRum?.getSessionId(); + if (sessionId) { + uwtMarkSessionStart(sessionId); + } + }, + this.report + ); + } finally { + this.markingSession = false; + } + } + + /** Whether the page has a cookie with this name; `false` when cookies can't be read. */ + private hasCookie(name: string): boolean { + return uwtSafe( + 'rum.readCookie', + () => + this.document.cookie + .split(';') + .some((cookie) => cookie.trim().split('=')[0] === name), + false, + this.report + ); + } + + private applyCredentials(credentials?: UwtRumCredentials): void { + if (!this.awsRum || !credentials) { + return; + } + + this.awsRum.setAwsCredentials({ + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken + }); + + this.scheduleRefresh(credentials.expiration); + } + + private scheduleRefresh(expiration: string): void { + const delay = + new Date(expiration).getTime() - Date.now() - CREDENTIAL_REFRESH_SKEW_MS; + + if (!Number.isFinite(delay) || delay <= 0) { + // Avoid tight-looping on an already-expired/unparsable expiration. + this.scheduleRetry(); + return; + } + + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout( + () => { + this.refreshCredentials().catch(() => undefined); + }, + Math.min(delay, MAX_TIMEOUT_MS) + ); + } + + private async refreshCredentials(): Promise { + this.refreshTimer = null; + try { + const bootstrap = await this.credentialsProvider.load(); + if (this.destroyed) { + return; + } + + if (!bootstrap) { + // null/undefined disables RUM for the session (see load()'s contract). + this.disableAwsRum(); + return; + } + + if (bootstrap.credentials) { + this.applyCredentials(bootstrap.credentials); + } else { + this.scheduleRetry(); + this.reportFailure( + 'RUM credential refresh', + new Error( + 'bootstrap omitted credentials for an already-authenticated ' + + 'session; keeping existing credentials and retrying' + ) + ); + } + } catch (error) { + if (!this.destroyed) { + this.scheduleRetry(); + this.reportFailure('RUM credential refresh failed', error); + } + } + } + + /** Runs `attempt` (by default, a credential refresh) after `delayMs`. */ + private scheduleRetry( + attempt: () => Promise = () => this.refreshCredentials(), + delayMs = CREDENTIAL_RETRY_DELAY_MS + ): void { + if (this.destroyed) { + return; + } + + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + attempt().catch(() => undefined); + }, delayMs); + } + + /** + * Reports settings that kept RUM from starting, in every environment: a + * silent production page would hide that RUM is off. Each setting's name and + * expected type are printed, never its value. + * + * @param problems one `name (expected type)` entry per setting + */ + private reportInvalidSettings(problems: string[]): void { + uwtReport( + this.report ?? uwtConsoleReporter, + 'config', + `RUM not started, settings of the wrong type: ${problems.join(', ')}` + ); + } + + /** + * Reports an init/refresh failure without leaking raw error content into + * the production console. + */ + private reportFailure(operation: string, error: unknown): void { + if (!this.report) { + return; + } + uwtReport( + this.report, + 'warn', + operation, + uwtNormalizeError(error, this.redact, this.report) + ); + } + + private bufferItem(item: BufferedItem): void { + this.buffer.push(item); + if (this.buffer.length > PRE_INIT_BUFFER_LIMIT) { + this.buffer.shift(); + } + } + + private replayBuffer(): void { + const pending = this.buffer; + this.buffer = []; + + for (const item of pending) { + uwtSafeVoid( + 'rum.replay', + () => { + if (item.kind === 'event') { + this.awsRum?.recordEvent( + item.eventType, + item.payload, + this.sanitize(item.metadata) + ); + } else if (item.kind === 'pageView') { + this.awsRum?.recordPageView(item.pageId); + } else { + this.awsRum?.recordError( + this.withOrigin(item.error, item.metadata) + ); + } + }, + this.report + ); + } + } + + /** + * Drops metadata keys under the reserved `aws:` prefix, which the client + * would otherwise discard with a console warning. Also drops `null` + * values, which {@link UwtTelemetryMetadata} permits but the SDK's + * metadata type does not. + */ + private sanitize( + metadata?: UwtTelemetryMetadata + ): Record | undefined { + if (!metadata) { + return undefined; + } + + const result: Record = {}; + for (const [key, value] of Object.entries(metadata)) { + if (key.startsWith(RESERVED_PREFIX) || value === null) { + continue; + } + result[key] = value; + } + + return Object.keys(result).length ? result : undefined; + } + + /** + * Folds a forwarded error's origin into its name. `AwsRum.recordError` + * takes no metadata argument, so `name` is the only field left to carry it. + */ + private withOrigin( + error: UwtTelemetryError, + metadata?: UwtTelemetryMetadata + ): UwtTelemetryError { + const origin = metadata + ? (metadata as Record).application + : undefined; + if (typeof origin !== 'string' || origin === this.config.application) { + return error; + } + return { ...error, name: `[${origin}] ${error.name}` }; + } +} diff --git a/projects/ui-watchtower/rum/src/public-api.ts b/projects/ui-watchtower/rum/src/public-api.ts new file mode 100644 index 0000000..90bd278 --- /dev/null +++ b/projects/ui-watchtower/rum/src/public-api.ts @@ -0,0 +1,19 @@ +/* + * Public API Surface of ui-watchtower/rum + * + * The AWS CloudWatch RUM destination, in its own entry point so the main one + * never refers to `aws-rum-web`: an optional peer, needed only here, and + * loaded only once the sink starts. + */ + +export { UwtRumTelemetrySink } from './lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink'; +export type { + UwtRumDocument, + UwtRumTelemetrySinkOptions +} from './lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink'; +export type { + UwtRumAppMonitorConfig, + UwtRumBootstrap, + UwtRumCredentials, + UwtRumCredentialsProvider +} from './lib/uwt-rum-credentials/uwt-rum-credentials'; diff --git a/projects/ui-watchtower/rum/tsconfig.lib.json b/projects/ui-watchtower/rum/tsconfig.lib.json new file mode 100644 index 0000000..8b7cf53 --- /dev/null +++ b/projects/ui-watchtower/rum/tsconfig.lib.json @@ -0,0 +1,16 @@ +/* Type-checks the ./rum entry point; ng-packagr builds it with the main tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib-core-rum", + // Includes the main entry point: it is read from source here. + "rootDir": "..", + "lib": ["ES2022", "DOM"], + "declaration": true, + "skipLibCheck": true, + "isolatedModules": false, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"] +} diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.ts index 743baa5..2472f76 100644 --- a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.ts +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.ts @@ -7,6 +7,7 @@ import { import { uwtSessionElapsed } from '../../utils/uwt-session-start.util/uwt-session-start.util'; import { uwtClassifyTelemetryEvent } from '../../utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; import { + uwtConsoleReporter, uwtReport, UwtReporter, uwtSafe, @@ -37,10 +38,6 @@ interface UwtIdentified { */ const hostsInThisRealm = new Map(); -/** Where warnings about a host that cannot work go when no reporter is given. */ -const consoleReporter: UwtReporter = (level, message, ...details) => - console[level](`[ui-watchtower] ${message}`, ...details); - /** * What {@link UwtTelemetryBroadcastHost} needs. * @@ -65,9 +62,9 @@ export interface UwtTelemetryBroadcastHostOptions { */ enabled?: boolean; /** - * Receives failures, and the warnings about a host that cannot work as - * configured. Those warnings go to the console when no reporter is given: - * a misconfigured host is never silent. + * Receives failures, and — as `'config'` — the warnings about a host that + * cannot work as configured. Those go to the console when no reporter is + * given: a misconfigured host is never silent. */ report?: UwtReporter; } @@ -370,7 +367,7 @@ export class UwtTelemetryBroadcastHost { /** A host that cannot work as configured is never silent. */ private warn(message: string): void { - uwtReport(this.report ?? consoleReporter, 'warn', message); + uwtReport(this.report ?? uwtConsoleReporter, 'config', message); } private announceIdentity(): void { diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts index 578f68e..d2d93d3 100644 --- a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts @@ -2,6 +2,13 @@ * @jest-environment jsdom */ import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UWT_TELEMETRY_EVENT_TYPE, + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { uwtMarkSessionStart } from '../../utils/uwt-session-start.util/uwt-session-start.util'; +import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS, UwtBroadcastLogApiProvider @@ -10,7 +17,10 @@ import { UwtNoopLogApiProvider } from '../../providers/uwt-noop-log-api.provider import { UwtNoopTelemetrySink } from '../uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; import { UwtTelemetryBroadcastHost } from './uwt-broadcast-host'; import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; -import { uwtConnectBroadcastChannel } from './uwt-broadcast.messages'; +import { + UWT_DEFAULT_BROADCAST_CHANNEL, + uwtConnectBroadcastChannel +} from './uwt-broadcast.messages'; /** * `BroadcastChannel` for the test: delivers asynchronously to every other @@ -124,6 +134,40 @@ describe('the core broadcast classes', () => { expect(ChannelStub.open.size).toBe(0); }); + it('should send nothing once disposed, though the shared connection stays open for others', async () => { + const shell = new ChannelStub(UWT_DEFAULT_BROADCAST_CHANNEL); + const heard: Record[] = []; + shell.onmessage = ({ data }) => + heard.push(data as Record); + const connection = uwtConnectBroadcastChannel(); + const sink = new UwtBroadcastTelemetrySink({ identity, connection }); + const logApi = new UwtBroadcastLogApiProvider({ connection }); + await settle(); + heard.length = 0; + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + + sink.dispose(); + logApi.dispose(); + sink.record('com.uwt.bi', { eventName: 'x' }); + sink.recordError({ name: 'Error', message: 'boom' }); + sink.setUserId('u-1'); + sink.flush(true); + logApi.send(record('hi')); + logApi.flush(); + const query = logApi.query({}); + + expect(jest.getTimerCount()).toBe(0); + await expect(query).resolves.toEqual([]); + jest.useRealTimers(); + await settle(); + expect(heard).toEqual([]); + expect(connection.isOpen()).toBe(true); + connection.post({ kind: 'flush', beacon: false }); + await settle(); + expect(heard).toEqual([{ kind: 'flush', beacon: false }]); + connection.close(); + }); + it('should open nothing while disabled', async () => { const sink = new UwtBroadcastTelemetrySink({ identity, enabled: false }); const logApi = new UwtBroadcastLogApiProvider({ enabled: false }); @@ -132,6 +176,86 @@ describe('the core broadcast classes', () => { expect(() => sink.record('com.uwt.bi', {})).not.toThrow(); await expect(logApi.query({})).resolves.toEqual([]); }); + + it('should send nothing over a shared connection while disabled, and leave it open', async () => { + const shell = new ChannelStub(UWT_DEFAULT_BROADCAST_CHANNEL); + const heard: unknown[] = []; + shell.onmessage = ({ data }) => heard.push(data); + const connection = uwtConnectBroadcastChannel(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + + const sink = new UwtBroadcastTelemetrySink({ + identity, + connection, + enabled: false + }); + const logApi = new UwtBroadcastLogApiProvider({ + connection, + enabled: false + }); + sink.record('com.uwt.bi', { eventName: 'x' }); + sink.recordError({ name: 'Error', message: 'boom' }); + sink.flush(true); + logApi.send(record('hi')); + const query = logApi.query({}); + + expect(jest.getTimerCount()).toBe(0); + await expect(query).resolves.toEqual([]); + jest.useRealTimers(); + await settle(); + expect(heard).toEqual([]); + sink.dispose(); + logApi.dispose(); + expect(connection.isOpen()).toBe(true); + connection.close(); + }); + }); + + describe('the forwarding sink', () => { + it("should forward with its realm's origin, and adopt the identity the host announces", async () => { + const shell = new ChannelStub(UWT_DEFAULT_BROADCAST_CHANNEL); + const heard: Record[] = []; + shell.onmessage = ({ data }) => + heard.push(data as Record); + const sink = new UwtBroadcastTelemetrySink({ identity }); + + sink.record('com.uwt.bi', { eventName: 'x' }, { feature: 'cart' }); + sink.recordError({ name: 'Error', message: 'boom' }); + sink.setUserId('u-1'); + sink.flush(true); + shell.postMessage({ + kind: 'identity', + sessionId: 'shell-session', + userId: 'shell-user' + }); + shell.postMessage({ kind: 'nonsense' }); + await settle(); + + const origin = { + application: 'cart', + environment: 'test', + appVersion: '1' + }; + expect(heard).toEqual([ + { kind: 'identity-request' }, + { + kind: 'event', + eventType: 'com.uwt.bi', + payload: { eventName: 'x' }, + metadata: { feature: 'cart', ...origin } + }, + { + kind: 'error', + error: { name: 'Error', message: 'boom' }, + metadata: origin + }, + { kind: 'user', userId: 'u-1' }, + { kind: 'flush', beacon: true } + ]); + expect(sink.getSessionId()).toBe('shell-session'); + expect(sink.getUserId()).toBe('shell-user'); + sink.dispose(); + }); }); describe('a log query', () => { @@ -178,7 +302,7 @@ describe('the core broadcast classes', () => { const second = hostOf({ ...shell(), report }); expect(report).toHaveBeenCalledWith( - 'warn', + 'config', expect.stringContaining('a second telemetry host is active') ); first.dispose(); @@ -224,13 +348,225 @@ describe('the core broadcast classes', () => { const third = hostOf({ ...shell(), report }); expect(report).toHaveBeenCalledWith( - 'warn', + 'config', expect.stringContaining('a second telemetry host is active') ); first.dispose(); third.dispose(); }); }); + + describe('the host, relaying a fragment', () => { + /** The shell's real destination; its session and user can move on. */ + class ShellSink extends UwtTelemetrySink { + sessionId?: string = 'shell-session'; + userId?: string = 'shell-user'; + readonly calls = { + record: jest.fn(), + recordError: jest.fn(), + flush: jest.fn(), + setUserId: jest.fn() + }; + + record( + eventType: string, + payload: object, + metadata?: UwtTelemetryMetadata + ): void { + this.calls.record(eventType, payload, metadata); + } + + recordError( + error: UwtTelemetryError, + metadata?: UwtTelemetryMetadata + ): void { + this.calls.recordError(error, metadata); + } + + flush(beacon?: boolean): void { + this.calls.flush(beacon); + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + getUserId(): string | undefined { + return this.userId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + this.calls.setUserId(userId); + } + } + + let sink: ShellSink; + let logApi: { send: jest.Mock; query: jest.Mock; flush: jest.Mock }; + /** A fragment's end of the channel: what it sends, and what it hears. */ + let fragment: { + send: (message: unknown) => void; + heard: Record[]; + }; + const recorded = () => sink.calls.record.mock.calls.map(([, p]) => p); + const heardOf = (kind: string) => + fragment.heard.filter((message) => message.kind === kind); + + beforeEach(() => { + localStorage.clear(); + sink = new ShellSink(); + logApi = { + send: jest.fn(), + query: jest.fn(async () => [record('found')]), + flush: jest.fn() + }; + hostOf({ sink, logApi }); + const channel = new ChannelStub(UWT_DEFAULT_BROADCAST_CHANNEL); + const heard: Record[] = []; + channel.onmessage = ({ data }) => + heard.push(data as Record); + fragment = { send: (message) => channel.postMessage(message), heard }; + }); + + it("should record a forwarded scenario under the shell's session and user, measuring the session again", async () => { + uwtMarkSessionStart('shell-session', Date.now() - 2000); + + fragment.send({ + kind: 'event', + eventType: UWT_TELEMETRY_EVENT_TYPE.scenario, + payload: { + scenarioName: 'checkout', + sessionId: 'stale', + userId: 'stale-user', + sessionElapsed: 5 + }, + metadata: { application: 'cart' } + }); + await settle(); + + const [[eventType, payload, metadata]] = sink.calls.record.mock.calls; + expect(eventType).toBe(UWT_TELEMETRY_EVENT_TYPE.scenario); + expect(payload).toMatchObject({ + scenarioName: 'checkout', + sessionId: 'shell-session', + userId: 'shell-user' + }); + expect(payload.sessionElapsed).toBeGreaterThanOrEqual(2000); + expect(metadata).toEqual({ application: 'cart' }); + }); + + it('should drop an elapsed time it cannot measure, and pass up-to-date or unknown records as they are', async () => { + fragment.send({ + kind: 'event', + eventType: UWT_TELEMETRY_EVENT_TYPE.scenario, + payload: { scenarioName: 'a', sessionId: 'stale', sessionElapsed: 5 } + }); + fragment.send({ + kind: 'event', + eventType: UWT_TELEMETRY_EVENT_TYPE.bi, + payload: { + eventName: 'x', + sessionId: 'shell-session', + userId: 'shell-user' + } + }); + fragment.send({ + kind: 'event', + eventType: 'com.other.event', + payload: { sessionId: 'stale' } + }); + fragment.send({ kind: 'event', eventType: 1 }); + await settle(); + + expect(recorded()).toEqual([ + { scenarioName: 'a', sessionId: 'shell-session', userId: 'shell-user' }, + { eventName: 'x', sessionId: 'shell-session', userId: 'shell-user' }, + { sessionId: 'stale' } + ]); + }); + + it('should relay errors, the user and flushes, and answer identity requests', async () => { + fragment.send({ + kind: 'error', + error: { name: 'TypeError', message: 'boom' }, + metadata: { application: 'cart' } + }); + fragment.send({ kind: 'flush', beacon: true }); + fragment.send({ kind: 'user', userId: 'new-user' }); + fragment.send({ kind: 'identity-request' }); + // Another tab's host: expected on an origin-wide channel, and ignored. + fragment.send({ kind: 'identity', sessionId: 'other-tab' }); + await settle(); + + expect(sink.calls.recordError).toHaveBeenCalledWith( + { name: 'TypeError', message: 'boom' }, + { application: 'cart' } + ); + expect(sink.calls.flush).toHaveBeenCalledWith(true); + expect(sink.calls.setUserId).toHaveBeenCalledWith('new-user'); + expect(heardOf('identity')).toEqual([ + { kind: 'identity', sessionId: 'shell-session', userId: 'new-user' }, + { kind: 'identity', sessionId: 'shell-session', userId: 'new-user' } + ]); + }); + + it("should announce the shell's identity again once its session moves on", async () => { + sink.sessionId = 'renewed'; + + fragment.send({ + kind: 'event', + eventType: 'com.other.event', + payload: {} + }); + await settle(); + + expect(heardOf('identity')).toEqual([ + { kind: 'identity', sessionId: 'renewed', userId: 'shell-user' } + ]); + }); + + it("should deliver forwarded log lines under the shell's identity, and flush the log store", async () => { + fragment.send({ + kind: 'log', + record: { ...record('hi'), sessionId: 'stale' } + }); + fragment.send({ kind: 'log-flush' }); + await settle(); + + expect(logApi.send).toHaveBeenCalledWith({ + ...record('hi'), + sessionId: 'shell-session', + userId: 'shell-user' + }); + expect(logApi.flush).toHaveBeenCalled(); + }); + + it('should answer a log query with [] when the log store throws, rejects or returns no list', async () => { + logApi.query + .mockImplementationOnce(() => { + throw new Error('down'); + }) + .mockRejectedValueOnce(new Error('down')) + .mockReturnValueOnce('not records'); + + for (const id of ['q1', 'q2', 'q3']) { + fragment.send({ kind: 'log-query', id, filter: {} }); + } + await settle(); + + const answers = heardOf('log-query-result'); + expect(answers).toHaveLength(3); + expect(answers).toEqual( + expect.arrayContaining( + ['q1', 'q2', 'q3'].map((id) => ({ + kind: 'log-query-result', + id, + records: [] + })) + ) + ); + }); + }); }); function record(message: string): UwtLogRecord { diff --git a/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts index 139a0b7..0f0aced 100644 --- a/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts +++ b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts @@ -1,16 +1,27 @@ /** - * Where telemetry reports what it otherwise handles silently: an operation - * that threw and was skipped (`'error'`), or data it had to drop or alter - * (`'warn'`). Telemetry never throws into the application, so this is the - * only place such problems show. + * What a report is about: + * + * - `'error'`: an operation threw, and was skipped; + * - `'warn'`: data was dropped or altered, or telemetry degraded; + * - `'config'`: something cannot work as configured, and stays off — worth + * showing in production too. + * + * @group Types + */ +export type UwtReportLevel = 'error' | 'warn' | 'config'; + +/** + * Where telemetry reports what it otherwise handles silently. Telemetry never + * throws into the application, so this is the only place such problems show. * * Called with console-style arguments: an unprefixed message, then any - * details, such as the error. Nothing is reported without one, so pass it - * where problems should be seen, typically in development: + * details, such as the error. Nothing but `'config'` problems is reported + * without one, so pass it where problems should be seen, typically in + * development: * * ```typescript * const report: UwtReporter = (level, message, ...details) => - * console[level](`[my-app] ${message}`, ...details); + * console[level === 'error' ? 'error' : 'warn'](`[my-app] ${message}`, ...details); * ``` * * A reporter that throws is ignored. @@ -18,18 +29,28 @@ * @group Types */ export type UwtReporter = ( - level: 'error' | 'warn', + level: UwtReportLevel, message: string, ...details: unknown[] ) => void; +/** + * Prints to the console, prefixed: where a `'config'` problem goes when no + * reporter is given, as something that cannot work is never silent. + */ +export const uwtConsoleReporter: UwtReporter = (level, message, ...details) => + console[level === 'error' ? 'error' : 'warn']( + `[ui-watchtower] ${message}`, + ...details + ); + /** * Passes a report to `report`, if there is one. A reporter that throws, or * a patched console it writes to, never reaches the caller. */ export function uwtReport( report: UwtReporter | undefined, - level: 'error' | 'warn', + level: UwtReportLevel, message: string, ...details: unknown[] ): void { diff --git a/projects/ui-watchtower/src/public-api.ts b/projects/ui-watchtower/src/public-api.ts index 665096d..ffaa60c 100644 --- a/projects/ui-watchtower/src/public-api.ts +++ b/projects/ui-watchtower/src/public-api.ts @@ -116,13 +116,21 @@ export type { UwtTelemetrySinkEvent } from './lib/utils/uwt-telemetry-event.util export { uwtIsDebugEnabled } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; export type { UwtDebugFlag } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; export { + uwtConsoleReporter, + uwtReport, uwtSafe, uwtSafeVoid, uwtSafeVoidMaybeAsync, uwtUuid } from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -export type { UwtReporter } 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 type { + UwtReporter, + UwtReportLevel +} from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +export { + uwtMarkSessionStart, + uwtSessionElapsed +} from './lib/utils/uwt-session-start.util/uwt-session-start.util'; export { UWT_DEFAULT_REDACT_CONFIG, UWT_REDACTED, @@ -135,7 +143,3 @@ export type { UwtPiiValuePattern, UwtRedactConfig } from './lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; - -// 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'; diff --git a/projects/ui-watchtower/tsconfig.spec.json b/projects/ui-watchtower/tsconfig.spec.json index 4271b51..0f4f15e 100644 --- a/projects/ui-watchtower/tsconfig.spec.json +++ b/projects/ui-watchtower/tsconfig.spec.json @@ -6,5 +6,10 @@ "types": ["jest", "node"], "isolatedModules": false }, - "include": ["src/**/*.spec.ts", "rxjs/**/*.spec.ts", "src/**/*.d.ts"] + "include": [ + "src/**/*.spec.ts", + "rxjs/**/*.spec.ts", + "rum/**/*.spec.ts", + "src/**/*.d.ts" + ] } diff --git a/projects/ui-watchtower/type-tests/compat.ts b/projects/ui-watchtower/type-tests/compat.ts index 47b06d2..f98499b 100644 --- a/projects/ui-watchtower/type-tests/compat.ts +++ b/projects/ui-watchtower/type-tests/compat.ts @@ -6,6 +6,15 @@ * names. */ import { firstValueFrom, Observable, of } from 'rxjs'; +import { + UwtRumAppMonitorConfig, + UwtRumBootstrap, + UwtRumCredentials, + UwtRumCredentialsProvider, + UwtRumDocument, + UwtRumTelemetrySink, + UwtRumTelemetrySinkOptions +} from '@absaoss-cps/ui-watchtower/rum'; import { traceScenario, UwtTraceScenarioOptions @@ -53,6 +62,10 @@ import { uwtRedactConfigFor, uwtRedactMetadata, UwtReporter, + uwtConsoleReporter, + uwtReport, + UwtReportLevel, + uwtSessionElapsed, uwtResolveTelemetryConfig, uwtSafe, uwtSafeVoid, @@ -121,7 +134,7 @@ export class AppLogBackend implements UwtLogApiProvider { /** Where the application wants to see what telemetry handles silently. */ export const report: UwtReporter = (level, message, ...details) => - console[level](`[app] ${message}`, ...details); + console[level === 'error' ? 'error' : 'warn'](`[app] ${message}`, ...details); export const identity: UwtTelemetryIdentity = { application: 'app', @@ -294,3 +307,48 @@ export function composedPage( connection.close(); }; } + +/** RUM credentials from the application's broker. */ +export class AppRumCredentials implements UwtRumCredentialsProvider { + async load(): Promise { + const config: UwtRumAppMonitorConfig = { + applicationId: 'app-monitor', + region: 'eu-west-1', + applicationVersion: '1.0.0' + }; + const credentials: UwtRumCredentials = { + accessKeyId: 'a', + secretAccessKey: 's', + sessionToken: 't', + expiration: '2026-01-01T00:00:00Z' + }; + return { config, credentials }; + } +} + +/** Sending straight to AWS CloudWatch RUM, without a framework. */ +export async function rum(page: UwtRumDocument | null): Promise { + const options: UwtRumTelemetrySinkOptions = { + identity, + credentialsProvider: new AppRumCredentials(), + document: page, + report: uwtConsoleReporter + }; + const destination = new UwtRumTelemetrySink(options); + await destination.init(); + destination.recordPageView('/customers/:id'); + const telemetry = uwtCreateTelemetry({ + identity, + destination, + logApi: new UwtNoopLogApiProvider() + }); + const level: UwtReportLevel = 'config'; + uwtReport( + report, + level, + 'checked', + uwtSessionElapsed(destination.getSessionId()) + ); + telemetry.dispose(); + destination.dispose(); +} diff --git a/tsconfig.json b/tsconfig.json index 524f134..131e2f0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,9 @@ "@absaoss-cps/ui-watchtower/rxjs": [ "./projects/ui-watchtower/rxjs/src/public-api.ts" ], + "@absaoss-cps/ui-watchtower/rum": [ + "./projects/ui-watchtower/rum/src/public-api.ts" + ], "@absaoss-cps/ngx-ui-watchtower": [ "./projects/ngx-ui-watchtower/src/public-api.ts" ],