From e17e1e6c941542982949bcaa0c7b4c4165d6d01a Mon Sep 17 00:00:00 2001 From: fateeand Date: Mon, 9 Nov 2026 17:01:01 +0100 Subject: [PATCH 01/21] test: add API, type and wire-protocol safety nets --- .github/workflows/checks.yml | 3 + .gitignore | 3 + README.md | 39 +- package.json | 8 +- .../public-api.snapshot.json | 572 ++++++++++++++++++ .../uwt-broadcast.wire.fixture.json | 254 ++++++++ .../uwt-broadcast/uwt-broadcast.wire.spec.ts | 324 ++++++++++ projects/ngx-ui-watchtower/tsconfig.spec.json | 2 +- .../ngx-ui-watchtower/type-tests/augmented.ts | 138 +++++ .../ngx-ui-watchtower/type-tests/compat.ts | 342 +++++++++++ .../type-tests/empty-registry.ts | 28 + .../type-tests/tsconfig.augmented.json | 5 + .../type-tests/tsconfig.json | 14 + scripts/api-snapshot.mjs | 226 +++++++ 14 files changed, 1944 insertions(+), 14 deletions(-) create mode 100644 projects/ngx-ui-watchtower/public-api.snapshot.json create mode 100644 projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json create mode 100644 projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts create mode 100644 projects/ngx-ui-watchtower/type-tests/augmented.ts create mode 100644 projects/ngx-ui-watchtower/type-tests/compat.ts create mode 100644 projects/ngx-ui-watchtower/type-tests/empty-registry.ts create mode 100644 projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json create mode 100644 projects/ngx-ui-watchtower/type-tests/tsconfig.json create mode 100644 scripts/api-snapshot.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e74f21c..98f94c7 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -36,6 +36,9 @@ jobs: - name: Build library run: npm run build + - name: Check the public API + run: npm run api:check + test: name: Unit tests runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index dd2bf6e..925e170 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ testem.log # System files .DS_Store Thumbs.db + +# API snapshot output on a mismatch +*.snapshot.json.actual diff --git a/README.md b/README.md index a17a61b..f8f6293 100644 --- a/README.md +++ b/README.md @@ -41,16 +41,34 @@ The package is built to `dist/ngx-ui-watchtower`. ## Scripts -| Script | What it does | -| ----------------------- | ---------------------------------------------------- | -| `npm run build` | Builds the package (production, partial compilation) | -| `npm run watch` | Rebuilds on change (development) | -| `npm run test` | Runs the unit tests (Jest) | -| `npm run test:coverage` | Runs the unit tests with coverage, as CI does | -| `npm run typecheck` | Type-checks every library and spec tsconfig | -| `npm run lint` | ESLint, no warnings allowed | -| `npm run format` | Formats with Prettier | -| `npm run format:check` | Checks formatting without writing | +| Script | What it does | +| ----------------------- | --------------------------------------------------------------- | +| `npm run build` | Builds the package (production, partial compilation) | +| `npm run watch` | Rebuilds on change (development) | +| `npm run test` | Runs the unit tests (Jest) | +| `npm run test:coverage` | Runs the unit tests with coverage, as CI does | +| `npm run typecheck` | Type-checks every library and spec tsconfig, and the type tests | +| `npm run api:check` | Compares the built public API with its committed snapshot | +| `npm run api:update` | Writes that snapshot again, for an intended API change | +| `npm run lint` | ESLint, no warnings allowed | +| `npm run format` | Formats with Prettier | +| `npm run format:check` | Checks formatting without writing | + +Three checks guard what consumers depend on: + +- **Type tests** (`projects/ngx-ui-watchtower/type-tests/`) compile, with the + Angular compiler, a consumer that uses every public export, and one that + augments the name registries: registered names accepted, others rejected, + `string` while nothing is registered. +- **The API snapshot** (`projects/ngx-ui-watchtower/public-api.snapshot.json`) + records every export of the built package with its members and + signatures. After an intended change, run `npm run api:update` and review + the diff. +- **The broadcast wire fixture** + (`projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json`) records + every message a shell and a fragment exchange. Shells and fragments are + deployed separately, so it changes only with a deliberate protocol change: + `UPDATE_WIRE_FIXTURE=1 npm test` records it again. ## Before you push @@ -62,6 +80,7 @@ npm run typecheck npm run lint npm run format:check npm run build +npm run api:check ``` CI runs the same checks on every pull request and every push to `main`. diff --git a/package.json b/package.json index 0c44ab7..076ed36 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,11 @@ "test": "ng test --project=ngx-ui-watchtower", "test:coverage": "ng test --project=ngx-ui-watchtower --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json", "lint": "eslint \"**/*.ts\" --max-warnings=0", - "format": "prettier --write \"**/*.{ts,html}\"", - "format:check": "prettier --check \"**/*.{ts,html}\"", - "typecheck": "tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit" + "format": "prettier --write \"**/*.{ts,mjs,html}\"", + "format:check": "prettier --check \"**/*.{ts,mjs,html}\"", + "typecheck": "tsc -p projects/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" }, "private": true, "dependencies": { diff --git a/projects/ngx-ui-watchtower/public-api.snapshot.json b/projects/ngx-ui-watchtower/public-api.snapshot.json new file mode 100644 index 0000000..b18d4f0 --- /dev/null +++ b/projects/ngx-ui-watchtower/public-api.snapshot.json @@ -0,0 +1,572 @@ +{ + "@absaoss-cps/ngx-ui-watchtower": { + "provideUwtTelemetry": [ + "function(identity: UwtTelemetryIdentity, ...features: UwtTelemetryFeature[]): EnvironmentProviders" + ], + "provideUwtTelemetryBroadcastHost": [ + "function(channelName?: string | undefined): EnvironmentProviders" + ], + "provideUwtTelemetryDestination": [ + "function(sink: Type, options?: UwtTelemetryDestinationOptions | undefined): EnvironmentProviders" + ], + "provideUwtTelemetrySink": [ + "function(mode: UwtTelemetryLocalSinkMode, options?: { channelName?: string | undefined; } | undefined): EnvironmentProviders" + ], + "traceScenario": [ + "function(scenario: UwtScenario, options?: UwtTraceScenarioOptions | ((value: T) => void | UwtScenarioOutcome) | undefined): MonoTypeOperatorFunction" + ], + "UWT_BI_TELEMETRY_CONFIG": [ + "const: InjectionToken" + ], + "UWT_BROADCAST_CHANNEL": [ + "const: InjectionToken" + ], + "UWT_DEFAULT_BROADCAST_CHANNEL": [ + "const: 'ngx-ui-watchtower'" + ], + "UWT_DEFAULT_EVENT_NAMESPACE": [ + "const: 'com.uwt'" + ], + "UWT_DEFAULT_REDACT_CONFIG": [ + "const: UwtRedactConfig" + ], + "UWT_DEFAULT_TELEMETRY_CONFIG": [ + "const: { eventNamespace: string; scenario: UwtScenarioTelemetryConfig; logs: UwtLogConfig; bi: UwtBITelemetryConfig; redact: UwtRedactConfig; }" + ], + "UWT_LOG_API_PROVIDER": [ + "const: InjectionToken" + ], + "UWT_LOG_CONFIG": [ + "const: InjectionToken" + ], + "UWT_LOG_LEVEL_ORDER": [ + "const: Record" + ], + "UWT_REDACT_CONFIG": [ + "const: InjectionToken" + ], + "UWT_REDACTED": [ + "const: '[redacted]'" + ], + "UWT_SCENARIO_TELEMETRY_CONFIG": [ + "const: InjectionToken" + ], + "UWT_TELEMETRY_ENABLED": [ + "const: InjectionToken" + ], + "UWT_TELEMETRY_EVENT_TYPE": [ + "const: UwtTelemetryEventTypes" + ], + "UWT_TELEMETRY_IDENTITY": [ + "const: InjectionToken" + ], + "UwtBIEvent": [ + "interface", + "application: string", + "eventName: string", + "eventTime: string", + "feature?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "scenarioId?: string | undefined", + "sessionId?: string | undefined", + "userId?: string | undefined" + ], + "UwtBIEventDetail": [ + "interface", + "feature?: string | undefined", + "scenarioId?: string | undefined" + ], + "UwtBIEventName": [ + "type = string" + ], + "UwtBIEventNames": [ + "interface" + ], + "UwtBITelemetryConfig": [ + "interface", + "dedupMaxKeys: number", + "dedupWindowMs: number", + "redact: boolean" + ], + "UwtBITelemetryService": [ + "class", + "new (): UwtBITelemetryService", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "track(eventName: string, metadata?: UwtTelemetryMetadata | undefined, detail?: UwtBIEventDetail | undefined): void" + ], + "UwtBroadcastLogApiProvider": [ + "class", + "new (): UwtBroadcastLogApiProvider", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "flush(): void", + "ngOnDestroy(): void", + "query(filter: UwtLogQuery): Promise", + "send(record: UwtLogRecord): void" + ], + "UwtBroadcastTelemetrySink": [ + "class", + "new (): UwtBroadcastTelemetrySink", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "flush(beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "ngOnDestroy(): void", + "record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "setUserId(userId: string | undefined): void" + ], + "uwtClassifyTelemetryEvent": [ + "function(eventType: string, payload: object): UwtTelemetrySinkEvent" + ], + "UwtDebugFlag": [ + "type = 'debugLogger' | 'debugScenario' | 'debugBI'" + ], + "uwtEventTypes": [ + "function(namespace?: string | undefined): UwtTelemetryEventTypes" + ], + "uwtIsDebugEnabled": [ + "function(flag: UwtDebugFlag, name?: string | undefined): boolean" + ], + "UwtLogApiProvider": [ + "interface", + "flush?(): void", + "query(filter: UwtLogQuery): Promise", + "send(record: UwtLogRecord): void" + ], + "UwtLogConfig": [ + "interface", + "levels?: Partial> | undefined", + "minLevel: UwtLogLevel", + "mirrorErrorsToRum: boolean", + "redact: boolean" + ], + "UwtLogDetail": [ + "interface", + "context?: string | undefined", + "correlationId?: string | undefined", + "error?: unknown", + "metadata?: UwtTelemetryMetadata | undefined" + ], + "UwtLogger": [ + "interface", + "error(message: string, detail?: UwtLogDetail | undefined): void", + "log(message: string, detail?: UwtLogDetail | undefined): void", + "warn(message: string, detail?: UwtLogDetail | undefined): void" + ], + "UwtLoggerName": [ + "type = string" + ], + "UwtLoggerNames": [ + "interface" + ], + "UwtLoggerService": [ + "class", + "new (): UwtLoggerService", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "getLogger(name: string): UwtLogger", + "ngOnDestroy(): void", + "query(filter?: UwtLogQuery | undefined): Promise" + ], + "UwtLogLevel": [ + "type = 'log' | 'warn' | 'error'" + ], + "UwtLogQuery": [ + "interface", + "correlationId?: string | undefined", + "from?: string | undefined", + "limit?: number | undefined", + "logger?: string | undefined", + "minLevel?: UwtLogLevel | undefined", + "to?: string | undefined" + ], + "UwtLogRecord": [ + "interface", + "application: string", + "context?: string | undefined", + "correlationId?: string | undefined", + "environment: string", + "error?: UwtTelemetryError | undefined", + "level: UwtLogLevel", + "logger?: string | undefined", + "message: string", + "metadata?: UwtTelemetryMetadata | undefined", + "sessionId?: string | undefined", + "timestamp: string", + "userId?: string | undefined", + "version: string" + ], + "uwtMarkSessionStart": [ + "function(sessionId: string, now?: number | undefined): void" + ], + "UwtNoopLogApiProvider": [ + "class", + "new (): UwtNoopLogApiProvider", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "query(_filter: UwtLogQuery): Promise", + "send(_record: UwtLogRecord): void" + ], + "UwtNoopTelemetrySink": [ + "class", + "new (): UwtNoopTelemetrySink", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "flush(_beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "record(_eventType: string, _payload: object, _metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(_error: UwtTelemetryError, _metadata?: UwtTelemetryMetadata | undefined): void", + "setUserId(userId: string | undefined): void" + ], + "uwtNormalizeError": [ + "function(error: unknown, config?: UwtRedactConfig | undefined): UwtTelemetryError | undefined" + ], + "UwtPiiValuePattern": [ + "type = 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'" + ], + "UwtRedactConfig": [ + "interface", + "extraKeyPatterns: RegExp[]", + "extraValuePatterns: RegExp[]", + "extraValueTransforms: ((value: string) => string)[]", + "includeStack: boolean", + "maxKeys: number", + "maxStackLength: number", + "maxStringLength: number", + "scanValuePatterns: UwtPiiValuePattern[]", + "stripUrlQuery: boolean" + ], + "uwtRedactConfigFor": [ + "function(config: UwtRedactConfig, enabled: boolean): UwtRedactConfig" + ], + "uwtRedactMetadata": [ + "function(value: unknown, config?: UwtRedactConfig | undefined): UwtTelemetryMetadata | undefined" + ], + "UwtScenario": [ + "class", + "new (options: UwtScenarioOptions, deps: UwtScenarioDeps): UwtScenario", + "aggregateEnd(name: string): UwtScenario", + "aggregateStart(name: string): UwtScenario", + "cancel(outcome?: UwtScenarioOutcome | undefined): void", + "complete(outcome?: UwtScenarioOutcome | undefined): void", + "endStep(detail?: UwtScenarioStepDetail | undefined): UwtScenario", + "fail(outcome?: UwtScenarioOutcome | undefined): void", + "failStep(error: unknown, detail?: UwtScenarioStepDetail | undefined): UwtScenario", + "get delta: number", + "get id: string", + "get isSettled: boolean", + "get name: string", + "get status: UwtScenarioStatus | undefined", + "incomplete(outcome?: UwtScenarioOutcome | undefined): void", + "setData(metadata: UwtTelemetryMetadata): UwtScenario", + "settle(status: UwtScenarioStatus, outcome?: UwtScenarioOutcome | undefined, error?: unknown): void", + "step(name: string, metadata?: UwtTelemetryMetadata | undefined): UwtScenario", + "toRecord(): UwtScenarioRecord" + ], + "UwtScenarioAggregate": [ + "interface", + "callCount: number", + "elapsed: number", + "name: string" + ], + "UwtScenarioName": [ + "type = string" + ], + "UwtScenarioNames": [ + "interface" + ], + "UwtScenarioOptions": [ + "interface", + "feature?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "operation?: string | undefined", + "parentScenarioId?: string | undefined", + "route?: string | undefined", + "startedAt?: number | undefined", + "timeoutMs?: number | undefined" + ], + "UwtScenarioOutcome": [ + "interface", + "error?: unknown", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "reason?: string | undefined", + "statusCode?: string | number | undefined" + ], + "UwtScenarioRecord": [ + "interface", + "aggregates?: UwtScenarioAggregate[] | undefined", + "application: string", + "delta: number", + "elapsed: number", + "endTime?: string | undefined", + "error?: UwtTelemetryError | undefined", + "exceededStepsLimit?: boolean | undefined", + "feature?: string | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "operation?: string | undefined", + "parentScenarioId?: string | undefined", + "previousStep?: string | undefined", + "reason?: string | undefined", + "route?: string | undefined", + "scenarioId: string", + "scenarioName: string", + "sessionElapsed?: number | undefined", + "sessionId?: string | undefined", + "startTime: string", + "status?: UwtScenarioStatus | undefined", + "statusCode?: string | number | undefined", + "stepCount: number", + "steps: UwtScenarioStep[]", + "userId?: string | undefined" + ], + "UwtScenarioStatus": [ + "type = 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'" + ], + "UwtScenarioStep": [ + "interface", + "elapsed?: number | undefined", + "endOffset?: number | undefined", + "error?: UwtTelemetryError | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "reason?: string | undefined", + "startOffset: number", + "status?: UwtScenarioStatus | undefined", + "stepDelta?: number | undefined" + ], + "UwtScenarioStepDetail": [ + "interface", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "reason?: string | undefined" + ], + "UwtScenarioStepEvent": [ + "interface", + "application: string", + "elapsed?: number | undefined", + "endOffset?: number | undefined", + "error?: UwtTelemetryError | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "reason?: string | undefined", + "scenarioId: string", + "scenarioName: string", + "sessionId?: string | undefined", + "startOffset: number", + "status?: UwtScenarioStatus | undefined", + "stepDelta?: number | undefined", + "userId?: string | undefined" + ], + "UwtScenarioSteps": [ + "interface" + ], + "UwtScenarioStepStatus": [ + "type = 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'" + ], + "UwtScenarioTelemetryConfig": [ + "interface", + "defaultTimeoutMs: number", + "emitLifecycleEvents: boolean", + "markCleanupFallbackMs: number", + "maxSteps: number", + "redact: boolean", + "userTimings: boolean" + ], + "UwtScenarioTelemetryService": [ + "class", + "new (): UwtScenarioTelemetryService", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "find(scenarioId: string): UwtScenario | undefined", + "findByName(name: string): UwtScenario[]", + "findByNameAndId(name: string, scenarioId: string): UwtScenario | undefined", + "getActive(): UwtScenario[]", + "ngOnDestroy(): void", + "readonly settled$: Observable", + "start(options: UwtScenarioOptions): UwtScenario" + ], + "uwtScrubString": [ + "function(value: string, config: UwtRedactConfig): string" + ], + "UwtStepName": [ + "type = string" + ], + "UwtTelemetryAttribution": [ + "interface", + "application: string", + "sessionId?: string | undefined", + "userId?: string | undefined" + ], + "UwtTelemetryBroadcastHost": [ + "class", + "new (): UwtTelemetryBroadcastHost", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "get received: number", + "ngOnDestroy(): void" + ], + "UwtTelemetryDestinationOptions": [ + "interface", + "init?: ((sink: T) => void) | undefined" + ], + "UwtTelemetryError": [ + "interface", + "message: string", + "name: string", + "stack?: string | undefined" + ], + "UwtTelemetryEventTypes": [ + "interface", + "bi: string", + "scenario: string", + "scenarioStep: string" + ], + "UwtTelemetryFeature": [ + "interface", + "providers: Provider[]" + ], + "UwtTelemetryIdentity": [ + "interface", + "application: string", + "environment: string", + "eventNamespace?: string | undefined", + "version: string" + ], + "UwtTelemetryLocalSinkMode": [ + "type = 'broadcast' | 'noop'" + ], + "UwtTelemetryMetadata": [ + "type = { [x: string]: string | number | boolean | null; }" + ], + "UwtTelemetrySink": [ + "abstract class", + "new (): UwtTelemetrySink", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "abstract flush(beacon?: boolean | undefined): void", + "abstract getSessionId(): string | undefined", + "abstract getUserId(): string | undefined", + "abstract record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "abstract recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "abstract setUserId(userId: string | undefined): void" + ], + "UwtTelemetrySinkEvent": [ + "type = { kind: 'scenario'; payload: UwtScenarioRecord; } | { kind: 'scenario-step'; payload: UwtScenarioStepEvent; } | { kind: 'bi'; payload: UwtBIEvent; } | { kind: 'unknown'; payload: object; }" + ], + "UwtTraceScenarioOptions": [ + "interface", + "cancelOutcome?: UwtScenarioOutcome | (() => void | UwtScenarioOutcome) | undefined", + "outcome?: ((value: T) => void | UwtScenarioOutcome) | undefined", + "valueThenTeardown?: 'complete' | 'cancel' | undefined" + ], + "uwtUuid": [ + "function(): string" + ], + "withBIEvents": [ + "function(config?: Partial | undefined): UwtTelemetryFeature" + ], + "withLogging": [ + "function(config?: Partial | undefined): UwtTelemetryFeature" + ], + "withRedaction": [ + "function(config?: Partial | undefined): UwtTelemetryFeature" + ], + "withScenarios": [ + "function(config?: Partial | undefined): UwtTelemetryFeature" + ], + "withTelemetryEnabled": [ + "function(enabled: boolean | (() => boolean)): UwtTelemetryFeature" + ] + }, + "@absaoss-cps/ngx-ui-watchtower/rum": { + "provideUwtTelemetryRumSink": [ + "function(): EnvironmentProviders" + ], + "UWT_RUM_CREDENTIALS_PROVIDER": [ + "const: InjectionToken" + ], + "UwtRumAppMonitorConfig": [ + "interface", + "alias?: string | undefined", + "allowCookies?: boolean | undefined", + "applicationAttributes?: Record | undefined", + "applicationId: string", + "applicationVersion: string", + "batchLimit?: number | undefined", + "candidatesCacheSize?: number | undefined", + "client?: string | undefined", + "clientBuilder?: ((endpoint: URL, region: string, credentials?: ClientBuilderCredentials | undefined, compressionStrategy?: { enabled: boolean; } | undefined) => unknown) | undefined", + "compressionStrategy?: { enabled: boolean; } | undefined", + "cookieAttributes?: { unique?: boolean | undefined; domain?: string | undefined; path?: string | undefined; sameSite?: string | undefined; secure?: boolean | undefined; } | undefined", + "debug?: boolean | undefined", + "disableAutoPageView?: boolean | undefined", + "dispatchInterval?: number | undefined", + "enableRumClient?: boolean | undefined", + "enableW3CTraceId?: boolean | undefined", + "enableXRay?: boolean | undefined", + "endpoint?: string | undefined", + "eventCacheSize?: number | undefined", + "eventPluginsToLoad?: unknown[] | undefined", + "fetchFunction?: ((input: RequestInfo | URL, init?: RequestInit | undefined) => Promise) | undefined", + "headers?: Record | undefined", + "pageIdFormat?: 'PATH' | 'HASH' | 'PATH_AND_HASH' | undefined", + "pagesToExclude?: RegExp[] | undefined", + "pagesToInclude?: RegExp[] | undefined", + "recordResourceUrl?: boolean | undefined", + "region: string", + "releaseId?: string | undefined", + "retries?: number | undefined", + "routeChangeComplete?: number | undefined", + "routeChangeTimeout?: number | undefined", + "sessionAttributes?: Record | undefined", + "sessionEventLimit?: number | undefined", + "sessionLengthSeconds?: number | undefined", + "sessionSampleRate?: number | undefined", + "signing?: boolean | undefined", + "suppressSessionStartEvent?: boolean | undefined", + "telemetries?: (string | (string | object)[])[] | undefined", + "useBeacon?: boolean | undefined", + "userIdRetentionDays?: number | undefined" + ], + "UwtRumBootstrap": [ + "interface", + "config: UwtRumAppMonitorConfig", + "credentials?: UwtRumCredentials | undefined" + ], + "UwtRumCredentials": [ + "interface", + "accessKeyId: string", + "expiration: string", + "secretAccessKey: string", + "sessionToken: string" + ], + "UwtRumCredentialsProvider": [ + "interface", + "load(): Promise" + ], + "UwtRumTelemetrySink": [ + "class", + "new (): UwtRumTelemetrySink", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "flush(beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "init(): Promise", + "ngOnDestroy(): void", + "record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "recordPageView(pageId: string): void", + "setUserId(userId: string | undefined): void" + ] + } +} diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json new file mode 100644 index 0000000..b86e7c5 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json @@ -0,0 +1,254 @@ +[ + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session", + "userId": "" + } + }, + { + "from": "fragment", + "message": { + "kind": "identity-request" + } + }, + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session", + "userId": "" + } + }, + { + "from": "fragment", + "message": { + "eventType": "com.uwt.scenario", + "kind": "event", + "metadata": { + "appVersion": "1.0.0", + "application": "realm", + "environment": "test" + }, + "payload": { + "aggregates": "", + "application": "realm", + "delta": "", + "elapsed": "", + "endTime": "", + "feature": "", + "metadata": { + "cart": "c-1", + "items": 2 + }, + "operation": "", + "parentScenarioId": "", + "previousStep": "pay", + "route": "", + "scenarioId": "", + "scenarioName": "checkout", + "sessionId": "shell-session", + "startTime": "", + "status": "success", + "stepCount": 1, + "steps": [ + { + "elapsed": "", + "endOffset": "", + "name": "scenario-start", + "startOffset": "", + "status": "success", + "stepDelta": "" + }, + { + "elapsed": "", + "endOffset": "", + "metadata": { + "method": "card" + }, + "name": "pay", + "startOffset": "", + "status": "success", + "stepDelta": "" + }, + { + "elapsed": "", + "endOffset": "", + "metadata": { + "items": 2 + }, + "name": "scenario-end", + "startOffset": "", + "status": "success", + "stepDelta": "" + } + ], + "userId": "" + } + } + }, + { + "from": "fragment", + "message": { + "eventType": "com.uwt.bi", + "kind": "event", + "metadata": { + "appVersion": "1.0.0", + "application": "realm", + "environment": "test" + }, + "payload": { + "application": "realm", + "eventName": "export_clicked", + "eventTime": "", + "feature": "", + "metadata": { + "format": "csv" + }, + "scenarioId": "", + "sessionId": "shell-session", + "userId": "" + } + } + }, + { + "from": "fragment", + "message": { + "kind": "log", + "record": { + "application": "realm", + "context": "", + "correlationId": "", + "environment": "test", + "error": "", + "level": "warn", + "logger": "checkout", + "message": "Slow payment", + "metadata": { + "ms": 900 + }, + "sessionId": "shell-session", + "timestamp": "", + "userId": "", + "version": "1.0.0" + } + } + }, + { + "from": "fragment", + "message": { + "kind": "log", + "record": { + "application": "realm", + "context": "", + "correlationId": "", + "environment": "test", + "error": { + "message": "Declined", + "name": "Error", + "stack": "Error: Declined\n at /Users/madfamily/Documents/ui-watchtower/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts:299:45" + }, + "level": "error", + "logger": "checkout", + "message": "Payment failed", + "metadata": "", + "sessionId": "shell-session", + "timestamp": "", + "userId": "", + "version": "1.0.0" + } + } + }, + { + "from": "fragment", + "message": { + "kind": "user", + "userId": "user-1" + } + }, + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session", + "userId": "user-1" + } + }, + { + "from": "fragment", + "message": { + "beacon": true, + "kind": "flush" + } + }, + { + "from": "fragment", + "message": { + "kind": "log-flush" + } + }, + { + "from": "fragment", + "message": { + "filter": { + "limit": 1, + "logger": "checkout" + }, + "id": "", + "kind": "log-query" + } + }, + { + "from": "shell", + "message": { + "id": "", + "kind": "log-query-result", + "records": [ + { + "application": "realm", + "environment": "test", + "level": "warn", + "logger": "checkout", + "message": "Slow payment", + "metadata": { + "ms": 900 + }, + "sessionId": "shell-session", + "timestamp": "", + "version": "1.0.0" + } + ] + } + }, + { + "from": "fragment", + "message": { + "eventType": "com.uwt.bi", + "kind": "event", + "metadata": { + "appVersion": "1.0.0", + "application": "realm", + "environment": "test" + }, + "payload": { + "application": "realm", + "eventName": "theme_changed", + "eventTime": "", + "feature": "", + "metadata": "", + "scenarioId": "", + "sessionId": "shell-session", + "userId": "user-1" + } + } + }, + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session-2", + "userId": "user-1" + } + } +] diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts new file mode 100644 index 0000000..635afb2 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts @@ -0,0 +1,324 @@ +import { DOCUMENT } from '@angular/common'; +import { Injectable, Injector, PLATFORM_ID } from '@angular/core'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { + UWT_DEFAULT_TELEMETRY_CONFIG, + UWT_REDACT_CONFIG, + UWT_TELEMETRY_IDENTITY +} from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UWT_BI_TELEMETRY_CONFIG } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; +import { UWT_LOG_CONFIG } from '../../config/uwt-log.config/uwt-log.config'; +import { UWT_SCENARIO_TELEMETRY_CONFIG } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UWT_LOG_API_PROVIDER, + UwtLogApiProvider, + UwtLogQuery +} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtBroadcastLogApiProvider } from '../../providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; +import { UwtBITelemetryService } from '../../services/uwt-bi-telemetry.service/uwt-bi-telemetry.service'; +import { UwtLoggerService } from '../../services/uwt-logger.service/uwt-logger.service'; +import { UwtScenarioTelemetryService } from '../../services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; +import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UwtTelemetryBroadcastHost } from './uwt-broadcast-host.service'; +import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; + +/** + * The broadcast protocol is a contract between realms deployed separately: + * a shell and its fragments may run different versions. This records every + * message a shell and a fragment exchange through one full session, and + * checks it against the fixture recorded from the implementation before the + * core was extracted. Record it again only deliberately, with + * `UPDATE_WIRE_FIXTURE=1`. + */ +const FIXTURE = join(__dirname, 'uwt-broadcast.wire.fixture.json'); + +/** A message as posted: by which realm, and what it carried. */ +interface Posted { + from: string; + message: unknown; +} + +/** + * `BroadcastChannel` for the test, delivering asynchronously to every other + * instance of the same name, and keeping each message as posted. + */ +class WireChannel { + /** The realm the next channel belongs to. */ + static realm = ''; + static readonly posted: Posted[] = []; + private static readonly channels = new Map(); + + onmessage: ((event: { data: unknown }) => void) | null = null; + private readonly realm = WireChannel.realm; + private closed = false; + + constructor(readonly name: string) { + WireChannel.channels.set(name, [ + ...(WireChannel.channels.get(name) ?? []), + this + ]); + } + + static reset(): void { + WireChannel.posted.length = 0; + WireChannel.channels.clear(); + } + + postMessage(message: unknown): void { + if (this.closed) { + return; + } + WireChannel.posted.push({ from: this.realm, message: plain(message) }); + for (const peer of WireChannel.channels.get(this.name) ?? []) { + if (peer !== this) { + const data = JSON.parse(JSON.stringify(message)); + setTimeout(() => !peer.closed && peer.onmessage?.({ data }), 0); + } + } + } + + close(): void { + this.closed = true; + WireChannel.channels.set( + this.name, + (WireChannel.channels.get(this.name) ?? []).filter((c) => c !== this) + ); + } +} + +/** + * A message as structured clone carries it: keys in a stable order, and a + * key whose value is `undefined` kept, as clone keeps it and JSON doesn't. + */ +function plain(value: unknown): unknown { + if (value === undefined) { + return ''; + } + if (Array.isArray(value)) { + return value.map(plain); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, plain((value as Record)[key])]) + ); + } + return value; +} + +/** + * Fields whose value is a time, an offset or a duration — `eventTime`, + * `endOffset`, `stepDelta`, `sessionElapsed`… — which differ run to run. + */ +const TIMED = /(^|[a-z])(time|offset|delta|elapsed|duration|timestamp)$/i; + +/** + * What can differ between runs made comparable: ids numbered by first + * appearance, and times and durations by their type. + */ +function stable(posted: Posted[]): Posted[] { + const uuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const ids = new Map(); + const walk = (value: unknown, key = ''): unknown => { + if (TIMED.test(key) && value !== '') { + return `<${typeof value}>`; + } + if (typeof value === 'string' && uuid.test(value)) { + if (!ids.has(value)) { + ids.set(value, ``); + } + return ids.get(value); + } + if (Array.isArray(value)) { + return value.map((v) => walk(v)); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, walk(v, k)]) + ); + } + return value; + }; + return walk(posted) as Posted[]; +} + +/** The shell's real destination, with a session and user it controls. */ +@Injectable() +class ShellSink extends UwtTelemetrySink { + sessionId: string | undefined = 'shell-session'; + userId?: string; + + record(_eventType: string, _payload: object, _meta?: UwtTelemetryMetadata) { + // Recorded on the wire, not here. + } + + recordError(_error: UwtTelemetryError, _meta?: UwtTelemetryMetadata) { + // Recorded on the wire, not here. + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(): void { + // Nothing buffered. + } +} + +/** The shell's log backend, answering queries with what it has. */ +@Injectable() +class ShellLogApi implements UwtLogApiProvider { + readonly records: UwtLogRecord[] = []; + + send(record: UwtLogRecord): void { + this.records.push(record); + } + + query(filter: UwtLogQuery): Promise { + return Promise.resolve( + this.records + .filter((r) => !filter.logger || r.logger === filter.logger) + .slice(0, filter.limit) + ); + } + + flush(): void { + // Nothing buffered. + } +} + +/** A realm: its own injector, as the shell and each fragment have. */ +function createRealm(providers: unknown[]): Injector { + return Injector.create({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: DOCUMENT, useValue: document }, + { + provide: UWT_TELEMETRY_IDENTITY, + useValue: { + application: 'realm', + environment: 'test', + version: '1.0.0', + eventNamespace: 'com.uwt' + } + }, + { + provide: UWT_SCENARIO_TELEMETRY_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.scenario + }, + { provide: UWT_LOG_CONFIG, useValue: UWT_DEFAULT_TELEMETRY_CONFIG.logs }, + { + provide: UWT_BI_TELEMETRY_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.bi + }, + { + provide: UWT_REDACT_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.redact + }, + UwtLoggerService, + UwtScenarioTelemetryService, + UwtBITelemetryService, + ...(providers as never[]) + ] + }); +} + +describe('the broadcast wire protocol', () => { + beforeEach(() => { + localStorage.clear(); + WireChannel.reset(); + Object.defineProperty(globalThis, 'BroadcastChannel', { + value: WireChannel, + configurable: true, + writable: true + }); + }); + + afterEach(() => { + delete (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel; + }); + + /** Lets every delivery, and every reply it causes, arrive. */ + async function settle(): Promise { + for (let turn = 0; turn < 6; turn++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + it('should carry what a shell and a fragment exchange, unchanged', async () => { + WireChannel.realm = 'shell'; + const shell = createRealm([ + ShellSink, + { provide: UwtTelemetrySink, useExisting: ShellSink }, + ShellLogApi, + { provide: UWT_LOG_API_PROVIDER, useExisting: ShellLogApi }, + UwtTelemetryBroadcastHost + ]); + const shellSink = shell.get(ShellSink); + const host = shell.get(UwtTelemetryBroadcastHost); + + WireChannel.realm = 'fragment'; + const fragment = createRealm([ + UwtBroadcastTelemetrySink, + { provide: UwtTelemetrySink, useExisting: UwtBroadcastTelemetrySink }, + UwtBroadcastLogApiProvider, + { provide: UWT_LOG_API_PROVIDER, useExisting: UwtBroadcastLogApiProvider } + ]); + const sink = fragment.get(UwtTelemetrySink); + const logApi = fragment.get(UWT_LOG_API_PROVIDER); + await settle(); + + // A scenario with a step, a business event, logs and a mirrored error. + const scenario = fragment + .get(UwtScenarioTelemetryService) + .start({ name: 'checkout', metadata: { cart: 'c-1' } }); + scenario.step('pay', { method: 'card' }); + scenario.endStep(); + scenario.complete({ metadata: { items: 2 } }); + fragment + .get(UwtBITelemetryService) + .track('export_clicked', { format: 'csv' }); + const logger = fragment.get(UwtLoggerService).getLogger('checkout'); + logger.warn('Slow payment', { metadata: { ms: 900 } }); + logger.error('Payment failed', { error: new Error('Declined') }); + await settle(); + + // A sign-in, flushes, and a query answered by the shell's backend. + sink.setUserId('user-1'); + await settle(); + sink.flush(true); + logApi.flush?.(); + const found = logApi.query({ logger: 'checkout', limit: 1 }); + await settle(); + expect(await found).toHaveLength(1); + + // The shell's session moves on: the next record is stamped with it. + shellSink.sessionId = 'shell-session-2'; + fragment.get(UwtBITelemetryService).track('theme_changed'); + await settle(); + + host.ngOnDestroy(); + const recorded = stable(WireChannel.posted); + if (process.env.UPDATE_WIRE_FIXTURE === '1') { + writeFileSync(FIXTURE, JSON.stringify(recorded, null, 2) + '\n'); + } + expect(existsSync(FIXTURE)).toBe(true); + expect(recorded).toEqual(JSON.parse(readFileSync(FIXTURE, 'utf8'))); + }); +}); diff --git a/projects/ngx-ui-watchtower/tsconfig.spec.json b/projects/ngx-ui-watchtower/tsconfig.spec.json index 16f49dc..1748c35 100644 --- a/projects/ngx-ui-watchtower/tsconfig.spec.json +++ b/projects/ngx-ui-watchtower/tsconfig.spec.json @@ -4,7 +4,7 @@ "compilerOptions": { "outDir": "../../out-tsc/spec", "rootDir": "./src", - "types": ["jest"], + "types": ["jest", "node"], "resolveJsonModule": true }, "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] diff --git a/projects/ngx-ui-watchtower/type-tests/augmented.ts b/projects/ngx-ui-watchtower/type-tests/augmented.ts new file mode 100644 index 0000000..8a6ef5c --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/augmented.ts @@ -0,0 +1,138 @@ +/** + * Module augmentation exactly as a consumer writes it: the four registries + * augmented on `@absaoss-cps/ngx-ui-watchtower`. Registered names must reach + * every API that takes or returns a name, and anything else must be + * rejected. + * + * Compiled by `npm run typecheck` on its own, never run. An unused + * `@ts-expect-error` fails the compilation, so a name that stops being + * rejected is caught too. + */ +import { inject } from '@angular/core'; +import { ObservedValueOf } from 'rxjs'; +import { + UwtBIEvent, + UwtBIEventName, + UwtBITelemetryService, + UwtLogConfig, + UwtLoggerName, + UwtLoggerService, + UwtLogQuery, + UwtLogRecord, + UwtScenario, + UwtScenarioAggregate, + UwtScenarioName, + UwtScenarioOptions, + UwtScenarioRecord, + UwtScenarioStep, + UwtScenarioStepEvent, + UwtScenarioTelemetryService, + UwtStepName, + withLogging +} from '@absaoss-cps/ngx-ui-watchtower'; + +declare module '@absaoss-cps/ngx-ui-watchtower' { + interface UwtScenarioNames { + checkout: true; + 'load-dashboard': true; + } + interface UwtScenarioSteps { + pay: true; + confirm: true; + } + interface UwtBIEventNames { + export_clicked: true; + } + interface UwtLoggerNames { + checkout: true; + } +} + +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +function expectType(): T | void { + // Checked at compile time only. +} + +// The names are exactly the registered ones. +expectType>(); +expectType>(); +expectType>(); +expectType>(); + +// And they reach every model that carries a name. +expectType>(); +expectType>(); +expectType< + Equals +>(); +expectType< + Equals< + UwtScenarioStep['name'], + UwtStepName | 'scenario-start' | 'scenario-end' + > +>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType< + Equals< + UwtLogConfig['levels'], + Partial> | undefined + > +>(); + +/** Registered names accepted, everything else rejected. */ +export class Usage { + private readonly scenarios = inject(UwtScenarioTelemetryService); + private readonly bi = inject(UwtBITelemetryService); + private readonly logs = inject(UwtLoggerService); + + accepted(): unknown[] { + const scenario = this.scenarios.start({ name: 'checkout' }); + scenario.step('pay').step('confirm').endStep(); + scenario.aggregateStart('pay').aggregateEnd('pay'); + this.bi.track('export_clicked', { format: 'csv' }); + this.logs.getLogger('checkout').warn('Slow payment'); + const settled = this.scenarios.settled$; + expectType< + Equals['scenarioName'], UwtScenarioName> + >(); + return [ + settled, + this.scenarios.findByName('load-dashboard'), + this.scenarios.findByNameAndId('checkout', 'id'), + this.logs.query({ logger: 'checkout' }), + withLogging({ levels: { checkout: 'log' } }) + ]; + } + + rejected(scenario: UwtScenario): void { + // @ts-expect-error not a registered scenario + this.scenarios.start({ name: 'chekout' }); + // @ts-expect-error not a registered scenario + this.scenarios.findByName('chekout'); + // @ts-expect-error not a registered scenario + this.scenarios.findByNameAndId('chekout', 'id'); + // @ts-expect-error not a registered step + scenario.step('pai'); + // @ts-expect-error not a registered step + scenario.aggregateStart('pai'); + // @ts-expect-error not a registered step + scenario.aggregateEnd('pai'); + // @ts-expect-error not a registered business event + this.bi.track('export_clikced'); + // @ts-expect-error not a registered logger + this.logs.getLogger('chekout'); + // @ts-expect-error not a registered logger + this.logs.query({ logger: 'chekout' }); + // @ts-expect-error not a registered logger + withLogging({ levels: { chekout: 'log' } }); + } +} diff --git a/projects/ngx-ui-watchtower/type-tests/compat.ts b/projects/ngx-ui-watchtower/type-tests/compat.ts new file mode 100644 index 0000000..97cf617 --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/compat.ts @@ -0,0 +1,342 @@ +/** + * Every public export of `@absaoss-cps/ngx-ui-watchtower` and its `/rum` + * entry point, used the way an application or an integration uses it. + * + * Compiled by `npm run typecheck`, never run. It must keep compiling + * unchanged: that is what "no consumer change" means for the types. No + * registry is augmented here, so every name is a plain string; see + * `augmented.ts` for registered names. + */ +import { ApplicationConfig, inject, Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { + provideUwtTelemetry, + provideUwtTelemetryBroadcastHost, + provideUwtTelemetryDestination, + provideUwtTelemetrySink, + traceScenario, + UWT_BI_TELEMETRY_CONFIG, + UWT_BROADCAST_CHANNEL, + UWT_DEFAULT_BROADCAST_CHANNEL, + UWT_DEFAULT_EVENT_NAMESPACE, + UWT_DEFAULT_REDACT_CONFIG, + UWT_DEFAULT_TELEMETRY_CONFIG, + UWT_LOG_API_PROVIDER, + UWT_LOG_CONFIG, + UWT_LOG_LEVEL_ORDER, + UWT_REDACT_CONFIG, + UWT_REDACTED, + UWT_SCENARIO_TELEMETRY_CONFIG, + UWT_TELEMETRY_ENABLED, + UWT_TELEMETRY_EVENT_TYPE, + UWT_TELEMETRY_IDENTITY, + UwtBIEvent, + UwtBIEventDetail, + UwtBIEventName, + UwtBIEventNames, + UwtBITelemetryConfig, + UwtBITelemetryService, + UwtBroadcastLogApiProvider, + UwtBroadcastTelemetrySink, + uwtClassifyTelemetryEvent, + UwtDebugFlag, + uwtEventTypes, + uwtIsDebugEnabled, + UwtLogApiProvider, + UwtLogConfig, + UwtLogDetail, + UwtLogger, + UwtLoggerName, + UwtLoggerNames, + UwtLoggerService, + UwtLogLevel, + UwtLogQuery, + UwtLogRecord, + uwtMarkSessionStart, + uwtNormalizeError, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink, + UwtPiiValuePattern, + UwtRedactConfig, + uwtRedactConfigFor, + uwtRedactMetadata, + UwtScenario, + UwtScenarioAggregate, + UwtScenarioName, + UwtScenarioNames, + UwtScenarioOptions, + UwtScenarioOutcome, + UwtScenarioRecord, + UwtScenarioStatus, + UwtScenarioStep, + UwtScenarioStepDetail, + UwtScenarioStepEvent, + UwtScenarioStepStatus, + UwtScenarioSteps, + UwtScenarioTelemetryConfig, + UwtScenarioTelemetryService, + uwtScrubString, + UwtStepName, + UwtTelemetryAttribution, + UwtTelemetryBroadcastHost, + UwtTelemetryDestinationOptions, + UwtTelemetryError, + UwtTelemetryEventTypes, + UwtTelemetryFeature, + UwtTelemetryIdentity, + UwtTelemetryLocalSinkMode, + UwtTelemetryMetadata, + UwtTelemetrySink, + UwtTelemetrySinkEvent, + UwtTraceScenarioOptions, + uwtUuid, + withBIEvents, + withLogging, + withRedaction, + withScenarios, + withTelemetryEnabled +} from '@absaoss-cps/ngx-ui-watchtower'; +import { + provideUwtTelemetryRumSink, + UWT_RUM_CREDENTIALS_PROVIDER, + UwtRumAppMonitorConfig, + UwtRumBootstrap, + UwtRumCredentials, + UwtRumCredentialsProvider, + UwtRumTelemetrySink +} from '@absaoss-cps/ngx-ui-watchtower/rum'; + +/** A destination an application writes itself. */ +@Injectable() +export class AppSink extends UwtTelemetrySink { + record(_type: string, _payload: object, _meta?: UwtTelemetryMetadata): void { + // Sent elsewhere. + } + + recordError(_error: UwtTelemetryError, _meta?: UwtTelemetryMetadata): void { + // Sent elsewhere. + } + + getSessionId(): string | undefined { + return undefined; + } + + setUserId(_userId: string | undefined): void { + // Kept elsewhere. + } + + getUserId(): string | undefined { + return undefined; + } + + flush(_beacon?: boolean): void { + // Nothing buffered. + } +} + +/** A log backend. */ +@Injectable() +export class AppLogBackend implements UwtLogApiProvider { + readonly ranks: number[] = []; + + send(record: UwtLogRecord): void { + const level: UwtLogLevel = record.level; + this.ranks.push(UWT_LOG_LEVEL_ORDER[level]); + } + + query(_filter: UwtLogQuery): Promise { + return Promise.resolve([]); + } + + flush(): void { + // Nothing buffered. + } +} + +/** RUM credentials from the application's broker. */ +@Injectable() +export class AppRumCredentials implements UwtRumCredentialsProvider { + async load(): Promise { + const config: UwtRumAppMonitorConfig = { + applicationId: 'app-monitor', + region: 'eu-west-1', + applicationVersion: '1.0.0' + }; + const credentials: UwtRumCredentials = { + accessKeyId: 'a', + secretAccessKey: 's', + sessionToken: 't', + expiration: '2026-01-01T00:00:00Z' + }; + return { config, credentials }; + } +} + +const identity: UwtTelemetryIdentity = { + application: 'app', + environment: 'prod', + version: '1.0.0' +}; +const logConfig: Partial = { minLevel: 'warn' }; +const scenarioConfig: Partial = { maxSteps: 10 }; +const biConfig: Partial = {}; +const redactConfig: Partial = {}; +const features: UwtTelemetryFeature[] = [ + withLogging(logConfig), + withScenarios(scenarioConfig), + withBIEvents(biConfig), + withRedaction(redactConfig), + withTelemetryEnabled(() => true) +]; +const destination: UwtTelemetryDestinationOptions = { + init: (sink) => sink.flush() +}; +const mode: UwtTelemetryLocalSinkMode = 'broadcast'; + +/** Each way an application wires the library. */ +export const configs: ApplicationConfig[] = [ + { + providers: [ + provideUwtTelemetry(identity, ...features), + provideUwtTelemetryDestination(AppSink, destination), + { provide: UWT_LOG_API_PROVIDER, useClass: AppLogBackend } + ] + }, + { + providers: [ + provideUwtTelemetry(identity), + provideUwtTelemetrySink(mode, { channelName: 'app' }), + provideUwtTelemetrySink('noop') + ] + }, + { + providers: [ + provideUwtTelemetry(identity), + provideUwtTelemetryRumSink(), + { provide: UWT_RUM_CREDENTIALS_PROVIDER, useClass: AppRumCredentials }, + { provide: UWT_LOG_API_PROVIDER, useClass: UwtNoopLogApiProvider }, + provideUwtTelemetryBroadcastHost('app') + ] + }, + { + providers: [ + { provide: UWT_TELEMETRY_IDENTITY, useValue: identity }, + { + provide: UWT_LOG_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.logs + }, + { + provide: UWT_SCENARIO_TELEMETRY_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.scenario + }, + { + provide: UWT_BI_TELEMETRY_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.bi + }, + { provide: UWT_REDACT_CONFIG, useValue: UWT_DEFAULT_REDACT_CONFIG }, + { provide: UWT_TELEMETRY_ENABLED, useValue: true }, + { + provide: UWT_BROADCAST_CHANNEL, + useValue: UWT_DEFAULT_BROADCAST_CHANNEL + } + ] + } +]; + +/** The facades, as application code injects and calls them. */ +export class Checkout { + private readonly scenarios = inject(UwtScenarioTelemetryService); + private readonly bi = inject(UwtBITelemetryService); + private readonly logger: UwtLogger = inject(UwtLoggerService).getLogger('x'); + private readonly logs = inject(UwtLoggerService); + readonly classes = [ + UwtBroadcastTelemetrySink, + UwtNoopTelemetrySink, + UwtRumTelemetrySink, + UwtTelemetryBroadcastHost, + UwtBroadcastLogApiProvider + ]; + + pay(): unknown[] { + const options: UwtScenarioOptions = { name: 'checkout', timeoutMs: 5000 }; + const scenario: UwtScenario = this.scenarios.start(options); + scenario.step('pay', { cart: 'c-1' }).endStep({ metadata: { ok: true } }); + scenario.aggregateStart('validate').aggregateEnd('validate'); + scenario.setData({ items: 2 }); + const detail: UwtScenarioStepDetail = {}; + scenario.failStep(new Error('retry'), detail); + const outcome: UwtScenarioOutcome = { statusCode: 200 }; + const traced: UwtTraceScenarioOptions = { + outcome: (value) => ({ metadata: { value } }), + cancelOutcome: outcome + }; + const settled: Observable = this.scenarios.settled$; + const found: UwtScenario[] = [ + ...this.scenarios.findByName('checkout'), + ...this.scenarios.getActive() + ]; + return [ + settled, + found, + this.scenarios.find('id'), + this.scenarios.findByNameAndId('checkout', 'id'), + of(1).pipe(traceScenario(scenario, traced)) + ]; + } + + track(): Promise { + const name: UwtBIEventName = 'export_clicked'; + const biDetail: UwtBIEventDetail = {}; + this.bi.track(name, { format: 'csv' }, biDetail); + const logDetail: UwtLogDetail = { error: new Error('x') }; + this.logger.warn('careful', logDetail); + return this.logs.query({ logger: 'x', limit: 1 }); + } +} + +/** The utilities an application or a sink calls. */ +export function utilities(): unknown[] { + const types: UwtTelemetryEventTypes = uwtEventTypes( + UWT_DEFAULT_EVENT_NAMESPACE + ); + const event: UwtTelemetrySinkEvent = uwtClassifyTelemetryEvent( + UWT_TELEMETRY_EVENT_TYPE.bi, + {} + ); + const flag: UwtDebugFlag = 'debugScenario'; + const redact: UwtRedactConfig = uwtRedactConfigFor( + UWT_DEFAULT_REDACT_CONFIG, + true + ); + const patterns: UwtPiiValuePattern[] = redact.scanValuePatterns; + uwtMarkSessionStart(uwtUuid()); + return [ + types, + event, + uwtIsDebugEnabled(flag), + uwtScrubString('a@b.c', redact), + uwtRedactMetadata({ email: 'a@b.c' }, redact), + uwtNormalizeError(new Error('x'), redact), + patterns, + UWT_REDACTED + ]; +} + +/** Every remaining type, as an integration names it. */ +export interface Everything { + attribution: UwtTelemetryAttribution; + biEvent: UwtBIEvent; + biNames: UwtBIEventNames; + logger: UwtLoggerName; + loggerNames: UwtLoggerNames; + aggregate: UwtScenarioAggregate; + scenario: UwtScenarioName; + scenarioNames: UwtScenarioNames; + status: UwtScenarioStatus; + step: UwtScenarioStep; + stepEvent: UwtScenarioStepEvent; + stepStatus: UwtScenarioStepStatus; + steps: UwtScenarioSteps; + stepName: UwtStepName; + error: UwtTelemetryError; +} diff --git a/projects/ngx-ui-watchtower/type-tests/empty-registry.ts b/projects/ngx-ui-watchtower/type-tests/empty-registry.ts new file mode 100644 index 0000000..b2a0838 --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/empty-registry.ts @@ -0,0 +1,28 @@ +/** + * Nothing augmented: every name falls back to `string`, so an application + * that never declares a registry can use any name. + * + * Compiled by `npm run typecheck` with `compat.ts`, never run. It must not + * share a compilation with `augmented.ts`, whose augmentation would apply + * here too. + */ +import { + UwtBIEventName, + UwtLoggerName, + UwtScenarioName, + UwtStepName +} from '@absaoss-cps/ngx-ui-watchtower'; + +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +function expectType(): T | void { + // Checked at compile time only. +} + +expectType>(); +expectType>(); +expectType>(); +expectType>(); diff --git a/projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json b/projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json new file mode 100644 index 0000000..87beb04 --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json @@ -0,0 +1,5 @@ +/* The registries augmented, kept apart from the empty-registry fallback. */ +{ + "extends": "./tsconfig.json", + "files": ["augmented.ts"] +} diff --git a/projects/ngx-ui-watchtower/type-tests/tsconfig.json b/projects/ngx-ui-watchtower/type-tests/tsconfig.json new file mode 100644 index 0000000..6180b30 --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/tsconfig.json @@ -0,0 +1,14 @@ +/* + * Consumer type tests, compiled against the public entry points by the + * Angular compiler (`ngc`), so Angular's own checks apply as in a consumer + * build. `compat.ts` and `empty-registry.ts` share this compilation; the + * augmentation in `augmented.ts` would leak into them, so it has its own. + */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "files": ["compat.ts", "empty-registry.ts"] +} diff --git a/scripts/api-snapshot.mjs b/scripts/api-snapshot.mjs new file mode 100644 index 0000000..2316d49 --- /dev/null +++ b/scripts/api-snapshot.mjs @@ -0,0 +1,226 @@ +/** + * Snapshots the public API of each built package: every export of every + * entry point, with its kind and its members and signatures as the + * TypeScript checker resolves them from the published declarations. How a + * type is built (inheritance, generics) doesn't show; what a consumer sees + * does. + * + * node scripts/api-snapshot.mjs compare with the committed snapshots + * node scripts/api-snapshot.mjs --update write them again + * + * Run after `npm run build`. Update only for an intended API change, and + * review the diff. + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const PACKAGES = [ + { + dist: 'dist/ngx-ui-watchtower', + snapshot: 'projects/ngx-ui-watchtower/public-api.snapshot.json' + } +]; +const FORMAT = + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType; + +/** Each entry point's import path and declaration file. */ +function entriesOf(dist) { + const manifest = JSON.parse( + readFileSync(join(ROOT, dist, 'package.json'), 'utf8') + ); + return Object.entries(manifest.exports) + .filter(([, target]) => target.types) + .map(([subpath, target]) => ({ + id: manifest.name + subpath.slice(1), + file: join(ROOT, dist, target.types) + })); +} + +const entries = PACKAGES.flatMap((pkg) => + entriesOf(pkg.dist).map((entry) => ({ ...entry, pkg })) +); +const program = ts.createProgram( + entries.map((entry) => entry.file), + { + noEmit: true, + strict: true, + skipLibCheck: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + types: [], + // The packages' imports of each other resolve to what was built. + paths: Object.fromEntries(entries.map((e) => [e.id, [e.file]])) + } +); +const checker = program.getTypeChecker(); +const has = (symbol, flag) => (symbol.flags & flag) !== 0; +const text = (type) => checker.typeToString(type, undefined, FORMAT); + +function modifiers(symbol) { + const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0]; + return declaration ? ts.getCombinedModifierFlags(declaration) : 0; +} + +/** ``, as declared. */ +function typeParameters(symbol) { + const declaration = symbol.declarations?.find((d) => d.typeParameters); + return declaration + ? `<${declaration.typeParameters.map((p) => p.getText()).join(', ')}>` + : ''; +} + +function signatures(type, kind, prefix) { + return checker + .getSignaturesOfType(type, kind) + .map((s) => prefix + checker.signatureToString(s, undefined, FORMAT)); +} + +/** The public and protected members of `type`, one line each, sorted. */ +function members(type, prefix = '') { + const lines = checker.getPropertiesOfType(type).flatMap((member) => { + const flags = modifiers(member); + if ( + flags & ts.ModifierFlags.Private || + member.name.startsWith('#') || + member.name.startsWith('__#') || + member.name === 'prototype' + ) { + return []; + } + const tags = + prefix + + (flags & ts.ModifierFlags.Protected ? 'protected ' : '') + + (flags & ts.ModifierFlags.Abstract ? 'abstract ' : '') + + (flags & ts.ModifierFlags.Readonly ? 'readonly ' : ''); + const optional = has(member, ts.SymbolFlags.Optional) ? '?' : ''; + const memberType = checker.getTypeOfSymbol(member); + if (has(member, ts.SymbolFlags.Method)) { + // An optional method's type includes `undefined`, which has none. + return signatures( + checker.getNonNullableType(memberType), + ts.SignatureKind.Call, + `${tags}${member.name}${optional}` + ); + } + const accessor = [ + has(member, ts.SymbolFlags.GetAccessor) ? 'get' : '', + has(member, ts.SymbolFlags.SetAccessor) ? 'set' : '' + ] + .filter(Boolean) + .join('/'); + return [ + `${tags}${accessor ? accessor + ' ' : ''}${member.name}${optional}: ${text(memberType)}` + ]; + }); + const indexes = checker + .getIndexInfosOfType(type) + .map( + (info) => + `${prefix}${info.isReadonly ? 'readonly ' : ''}[key: ${text(info.keyType)}]: ${text(info.type)}` + ); + return [...lines, ...indexes].sort(); +} + +/** How one export looks to a consumer: its kind, then its shape. */ +function describe(exported) { + const symbol = has(exported, ts.SymbolFlags.Alias) + ? checker.getAliasedSymbol(exported) + : exported; + const typeOnly = (exported.declarations ?? []).some( + (d) => + ts.isExportSpecifier(d) && (d.isTypeOnly || d.parent.parent.isTypeOnly) + ); + const only = + typeOnly && has(symbol, ts.SymbolFlags.Value) ? 'type-only ' : ''; + const generics = typeParameters(symbol); + if (has(symbol, ts.SymbolFlags.Class)) { + const abstract = modifiers(symbol) & ts.ModifierFlags.Abstract; + const statics = checker.getTypeOfSymbol(symbol); + return [ + `${only}${abstract ? 'abstract ' : ''}class${generics}`, + ...signatures(statics, ts.SignatureKind.Construct, 'new '), + ...members(statics, 'static '), + ...members(checker.getDeclaredTypeOfSymbol(symbol)) + ]; + } + if (has(symbol, ts.SymbolFlags.Interface)) { + const type = checker.getDeclaredTypeOfSymbol(symbol); + return [ + `${only}interface${generics}`, + ...signatures(type, ts.SignatureKind.Call, ''), + ...signatures(type, ts.SignatureKind.Construct, 'new '), + ...members(type) + ]; + } + if (has(symbol, ts.SymbolFlags.TypeAlias)) { + const type = checker.getDeclaredTypeOfSymbol(symbol); + return [ + `type${generics} = ${checker.typeToString(type, undefined, FORMAT | ts.TypeFormatFlags.InTypeAlias)}` + ]; + } + if (has(symbol, ts.SymbolFlags.Function)) { + return signatures( + checker.getTypeOfSymbol(symbol), + ts.SignatureKind.Call, + `${only}function` + ); + } + if (has(symbol, ts.SymbolFlags.Variable)) { + const readonly = + ts.getCombinedNodeFlags(symbol.valueDeclaration) & ts.NodeFlags.Const; + return [ + `${only}${readonly ? 'const' : 'let'}: ${text(checker.getTypeOfSymbol(symbol))}` + ]; + } + throw new Error(`Unexpected export kind: ${exported.name}`); +} + +function snapshotOf(entry) { + const source = program.getSourceFile(entry.file); + const module = source && checker.getSymbolAtLocation(source); + if (!module) { + throw new Error(`Cannot read ${entry.file}; run npm run build first`); + } + return Object.fromEntries( + checker + .getExportsOfModule(module) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((exported) => [exported.name, describe(exported)]) + ); +} + +const update = process.argv.includes('--update'); +let failed = false; +for (const pkg of PACKAGES) { + const actual = + JSON.stringify( + Object.fromEntries( + entries + .filter((entry) => entry.pkg === pkg) + .map((entry) => [entry.id, snapshotOf(entry)]) + ), + null, + 2 + ) + '\n'; + const file = join(ROOT, pkg.snapshot); + const expected = existsSync(file) ? readFileSync(file, 'utf8') : ''; + if (update) { + writeFileSync(file, actual); + console.log(`Wrote ${pkg.snapshot}`); + } else if (actual !== expected) { + failed = true; + writeFileSync(`${file}.actual`, actual); + console.error( + `${pkg.snapshot} differs from the build; see ${pkg.snapshot}.actual.\n` + + 'Run npm run api:update only if the API change is intended.' + ); + } else { + console.log(`${pkg.snapshot} matches the build`); + } +} +process.exitCode = failed ? 1 : 0; From 082b200f2560dda9c5160cfb425b2ef2d1d4afbc Mon Sep 17 00:00:00 2001 From: fateeand Date: Mon, 9 Nov 2026 17:16:54 +0100 Subject: [PATCH 02/21] build: add the framework-independent core package --- .eslintrc.js | 28 +++ .github/workflows/checks.yml | 12 ++ README.md | 46 ++-- angular.json | 23 ++ jest.config.js | 4 +- package-lock.json | 1 + package.json | 12 +- projects/ngx-ui-watchtower/DESIGN.md | 7 + projects/ngx-ui-watchtower/README.md | 5 +- projects/ngx-ui-watchtower/ng-package.json | 3 +- projects/ngx-ui-watchtower/package.json | 1 + .../uwt-rum-telemetry.sink.spec.ts | 2 +- .../ngx-ui-watchtower/rum/tsconfig.lib.json | 3 +- .../ngx-ui-watchtower/rum/tsconfig.spec.json | 3 +- .../uwt-scenario/uwt-scenario.spec.ts | 2 +- .../lib/scenario/uwt-scenario/uwt-scenario.ts | 2 +- .../uwt-broadcast-host.service.ts | 2 +- .../sinks/uwt-broadcast/uwt-broadcast.spec.ts | 2 +- projects/ngx-ui-watchtower/src/public-api.ts | 2 +- projects/ngx-ui-watchtower/tsconfig.lib.json | 6 +- projects/ngx-ui-watchtower/tsconfig.spec.json | 3 +- projects/ui-watchtower/LICENSE | 201 ++++++++++++++++++ projects/ui-watchtower/README.md | 29 +++ projects/ui-watchtower/jest.config.js | 24 +++ projects/ui-watchtower/ng-package.json | 7 + projects/ui-watchtower/package.json | 18 ++ .../ui-watchtower/public-api.snapshot.json | 10 + .../uwt-session-start.util.spec.ts | 3 + .../uwt-session-start.util.ts | 0 projects/ui-watchtower/src/public-api.spec.ts | 20 ++ projects/ui-watchtower/src/public-api.ts | 13 ++ projects/ui-watchtower/tsconfig.lib.json | 22 ++ projects/ui-watchtower/tsconfig.lib.prod.json | 9 + projects/ui-watchtower/tsconfig.spec.json | 10 + scripts/api-snapshot.mjs | 4 + tsconfig.json | 5 + 36 files changed, 509 insertions(+), 35 deletions(-) create mode 100644 projects/ui-watchtower/LICENSE create mode 100644 projects/ui-watchtower/README.md create mode 100644 projects/ui-watchtower/jest.config.js create mode 100644 projects/ui-watchtower/ng-package.json create mode 100644 projects/ui-watchtower/package.json create mode 100644 projects/ui-watchtower/public-api.snapshot.json rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/utils/uwt-session-start.util/uwt-session-start.util.spec.ts (98%) rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts (100%) create mode 100644 projects/ui-watchtower/src/public-api.spec.ts create mode 100644 projects/ui-watchtower/src/public-api.ts create mode 100644 projects/ui-watchtower/tsconfig.lib.json create mode 100644 projects/ui-watchtower/tsconfig.lib.prod.json create mode 100644 projects/ui-watchtower/tsconfig.spec.json diff --git a/.eslintrc.js b/.eslintrc.js index 84ba0b7..3e9bea3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -39,6 +39,34 @@ module.exports = { jest: false, node: true } + }, + { + // The core is framework-independent, and its main entry point loads + // no optional peer: those get entry points of their own. + files: ['projects/ui-watchtower/src/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@angular/*', + 'zone.js', + '@absaoss-cps/ngx-ui-watchtower', + '@absaoss-cps/ngx-ui-watchtower/*' + ], + message: 'The core is framework-independent.' + }, + { + group: ['rxjs', 'rxjs/*', 'aws-rum-web', 'aws-rum-web/*'], + message: + 'The main entry point loads no optional peer; use an entry point of its own.' + } + ] + } + ] + } } ] }; diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 98f94c7..280d18f 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -72,6 +72,18 @@ jobs: # Tests already ran above; the action only formats the report. skip-step: all + - name: Comment core coverage on PR + uses: ArtiomTr/jest-coverage-report-action@v2 + if: github.event_name == 'pull_request' + continue-on-error: true + with: + custom-title: Core coverage report + coverage-file: ./coverage/ui-watchtower/report.json + # The same file as the base: no comparison with a base branch, + # which would run the Angular package's tests. + base-coverage-file: ./coverage/ui-watchtower/report.json + skip-step: all + lint: name: Lint and format runs-on: ubuntu-latest diff --git a/README.md b/README.md index f8f6293..399b56a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ Source of [`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower), a telemetry library for Angular apps: scenarios (user end-to-end journeys or specific functional workflows through an app), BI events and -logs, with an optional AWS CloudWatch RUM sink. +logs, with an optional AWS CloudWatch RUM sink. It builds on +[`@absaoss-cps/ui-watchtower`](projects/ui-watchtower/README.md), the +framework-independent engine, which lives here too. How to use the library: [projects/ngx-ui-watchtower/README.md](projects/ngx-ui-watchtower/README.md) (also the npm page). @@ -19,14 +21,22 @@ How it is designed: [projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watch ## Layout ```text +projects/ui-watchtower/ + src/ @absaoss-cps/ui-watchtower — the framework-independent engine + README.md usage guide, published to npm + package.json the published package's manifest projects/ngx-ui-watchtower/ src/ main entry point — @absaoss-cps/ngx-ui-watchtower rum/ secondary entry point — @absaoss-cps/ngx-ui-watchtower/rum + type-tests/ consumer type tests README.md usage guide, published to npm DESIGN.md full design package.json the published package's manifest +scripts/ the public API snapshot ``` +The Angular package depends on the core, never the other way round. + ## Getting started Requires Node.js 22 or later. @@ -37,22 +47,25 @@ npm run test npm run build ``` -The package is built to `dist/ngx-ui-watchtower`. +The packages are built to `dist/ui-watchtower` and `dist/ngx-ui-watchtower`, +the core first: the Angular package compiles against the built core, as an +application does. Tests and the typecheck read the core's source. ## Scripts -| Script | What it does | -| ----------------------- | --------------------------------------------------------------- | -| `npm run build` | Builds the package (production, partial compilation) | -| `npm run watch` | Rebuilds on change (development) | -| `npm run test` | Runs the unit tests (Jest) | -| `npm run test:coverage` | Runs the unit tests with coverage, as CI does | -| `npm run typecheck` | Type-checks every library and spec tsconfig, and the type tests | -| `npm run api:check` | Compares the built public API with its committed snapshot | -| `npm run api:update` | Writes that snapshot again, for an intended API change | -| `npm run lint` | ESLint, no warnings allowed | -| `npm run format` | Formats with Prettier | -| `npm run format:check` | Checks formatting without writing | +| Script | What it does | +| ----------------------- | -------------------------------------------------------------- | +| `npm run build` | Builds both packages (production), the core first | +| `npm run watch` | Builds the core, then rebuilds the Angular package on change | +| `npm run test` | Runs the unit tests of both packages (Jest) | +| `npm run test:core` | Runs the core's unit tests only: plain Jest, no Angular | +| `npm run test:coverage` | Runs the unit tests with coverage, as CI does | +| `npm run typecheck` | Builds the core, then type-checks every tsconfig and type test | +| `npm run api:check` | Compares the built public APIs with their committed snapshots | +| `npm run api:update` | Writes those snapshots again, for an intended API change | +| `npm run lint` | ESLint, no warnings allowed | +| `npm run format` | Formats with Prettier | +| `npm run format:check` | Checks formatting without writing | Three checks guard what consumers depend on: @@ -60,9 +73,8 @@ Three checks guard what consumers depend on: Angular compiler, a consumer that uses every public export, and one that augments the name registries: registered names accepted, others rejected, `string` while nothing is registered. -- **The API snapshot** (`projects/ngx-ui-watchtower/public-api.snapshot.json`) - records every export of the built package with its members and - signatures. After an intended change, run `npm run api:update` and review +- **The API snapshots** (`public-api.snapshot.json` in each package) record + every export of the built packages with its members and signatures. After an intended change, run `npm run api:update` and review the diff. - **The broadcast wire fixture** (`projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json`) records diff --git a/angular.json b/angular.json index 9cbcf28..b104002 100644 --- a/angular.json +++ b/angular.json @@ -7,6 +7,29 @@ "analytics": false }, "projects": { + "ui-watchtower": { + "projectType": "library", + "root": "projects/ui-watchtower", + "sourceRoot": "projects/ui-watchtower/src", + "prefix": "uwt", + "architect": { + "build": { + "builder": "@angular/build:ng-packagr", + "options": { + "project": "projects/ui-watchtower/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/ui-watchtower/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/ui-watchtower/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + } + } + }, "ngx-ui-watchtower": { "projectType": "library", "root": "projects/ngx-ui-watchtower", diff --git a/jest.config.js b/jest.config.js index 3a8a9c8..76484a4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,8 +1,10 @@ module.exports = { - roots: ['/projects'], + roots: ['/projects/ngx-ui-watchtower'], coverageDirectory: '/coverage', preset: 'jest-preset-angular', moduleNameMapper: { + '^@absaoss-cps/ui-watchtower$': + '/projects/ui-watchtower/src/public-api.ts', '^@absaoss-cps/ngx-ui-watchtower$': '/projects/ngx-ui-watchtower/src/public-api.ts', '^@absaoss-cps/ngx-ui-watchtower/rum$': diff --git a/package-lock.json b/package-lock.json index 8aaef95..66a8b84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "jest-preset-angular": "^17.0.0", "ng-packagr": "~21.0.1", "prettier": "^3.4.2", + "ts-jest": "^29.4.14", "typescript": "~5.9.3" }, "engines": { diff --git a/package.json b/package.json index 076ed36..54f5968 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,15 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "build": "ng build --configuration=production --project=ngx-ui-watchtower", - "watch": "ng build ngx-ui-watchtower --watch --configuration development", - "test": "ng test --project=ngx-ui-watchtower", - "test:coverage": "ng test --project=ngx-ui-watchtower --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json", + "build": "ng build --configuration=production --project=ui-watchtower && ng build --configuration=production --project=ngx-ui-watchtower", + "watch": "ng build ui-watchtower --configuration development && ng build ngx-ui-watchtower --watch --configuration development", + "test": "npm run test:core && ng test --project=ngx-ui-watchtower", + "test:core": "jest --config projects/ui-watchtower/jest.config.js", + "test:coverage": "jest --config projects/ui-watchtower/jest.config.js --coverage --coverageReporters=text-summary --coverageReporters=lcov --json --outputFile=./coverage/ui-watchtower/report.json && ng test --project=ngx-ui-watchtower --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json", "lint": "eslint \"**/*.ts\" --max-warnings=0", "format": "prettier --write \"**/*.{ts,mjs,html}\"", "format:check": "prettier --check \"**/*.{ts,mjs,html}\"", - "typecheck": "tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit && 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/tsconfig.spec.json --noEmit && 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" }, @@ -48,6 +49,7 @@ "jest-preset-angular": "^17.0.0", "ng-packagr": "~21.0.1", "prettier": "^3.4.2", + "ts-jest": "^29.4.14", "typescript": "~5.9.3" }, "engines": { diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index 0e6eb0d..83bc522 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -192,6 +192,13 @@ providers: [ ### Packaging and internals +- **The framework-independent code lives in `@absaoss-cps/ui-watchtower`.** + This package depends on it as a regular dependency, so installing this + package installs it too, and re-exports what it has always exported, under + the same names. The dependency runs one way: the core imports neither + Angular nor this package, which a lint rule enforces. The library build + compiles against the core's built declarations, not its source, so it sees + the core exactly as an application does. - **Every export is explicit.** The barrels list their exports one by one — in a published package each exported name is a compatibility promise. Id generation, the clock, the fail-open wrappers, User Timings and the diff --git a/projects/ngx-ui-watchtower/README.md b/projects/ngx-ui-watchtower/README.md index 9d8c910..b3a1864 100644 --- a/projects/ngx-ui-watchtower/README.md +++ b/projects/ngx-ui-watchtower/README.md @@ -26,7 +26,10 @@ npm install @absaoss-cps/ngx-ui-watchtower npm install aws-rum-web # only for the AWS RUM sink ``` -Requires Angular 21 or 22 and RxJS 7.8. +Requires Angular 21 or 22 and RxJS 7.8. It brings +[`@absaoss-cps/ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ui-watchtower), +the framework-independent engine it builds on, with it: there is nothing more +to install or configure. ## Setup diff --git a/projects/ngx-ui-watchtower/ng-package.json b/projects/ngx-ui-watchtower/ng-package.json index ff8424f..2e407db 100644 --- a/projects/ngx-ui-watchtower/ng-package.json +++ b/projects/ngx-ui-watchtower/ng-package.json @@ -3,5 +3,6 @@ "dest": "../../dist/ngx-ui-watchtower", "lib": { "entryFile": "src/public-api.ts" - } + }, + "allowedNonPeerDependencies": ["@absaoss-cps/ui-watchtower"] } diff --git a/projects/ngx-ui-watchtower/package.json b/projects/ngx-ui-watchtower/package.json index 604c14b..11db2d8 100644 --- a/projects/ngx-ui-watchtower/package.json +++ b/projects/ngx-ui-watchtower/package.json @@ -20,6 +20,7 @@ "homepage": "https://github.com/AbsaOSS/ui-watchtower", "license": "Apache-2.0", "dependencies": { + "@absaoss-cps/ui-watchtower": "^0.4.0", "tslib": "^2.8.1" }, "sideEffects": false, diff --git a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts index 2de1462..6a5b32f 100644 --- a/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts +++ b/projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.spec.ts @@ -7,7 +7,7 @@ import { UWT_TELEMETRY_EVENT_TYPE, UWT_TELEMETRY_IDENTITY } from '@absaoss-cps/ngx-ui-watchtower'; -import { uwtSessionElapsed } from '../../../../src/lib/utils/uwt-session-start.util/uwt-session-start.util'; +import { uwtSessionElapsed } from '@absaoss-cps/ui-watchtower'; import { UWT_RUM_CREDENTIALS_PROVIDER, UwtRumBootstrap diff --git a/projects/ngx-ui-watchtower/rum/tsconfig.lib.json b/projects/ngx-ui-watchtower/rum/tsconfig.lib.json index 06ed2d1..9435509 100644 --- a/projects/ngx-ui-watchtower/rum/tsconfig.lib.json +++ b/projects/ngx-ui-watchtower/rum/tsconfig.lib.json @@ -3,7 +3,8 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "../../../out-tsc/lib-rum", - "rootDir": "..", + // Includes the core: tests and typecheck read its source. + "rootDir": "../..", "lib": ["ES2022", "DOM"], "moduleResolution": "bundler", "declaration": true, diff --git a/projects/ngx-ui-watchtower/rum/tsconfig.spec.json b/projects/ngx-ui-watchtower/rum/tsconfig.spec.json index 2e29a92..2677ee6 100644 --- a/projects/ngx-ui-watchtower/rum/tsconfig.spec.json +++ b/projects/ngx-ui-watchtower/rum/tsconfig.spec.json @@ -3,7 +3,8 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "../../../out-tsc/spec-rum", - "rootDir": "..", + // Includes the core: tests and typecheck read its source. + "rootDir": "../..", "types": ["jest"], "resolveJsonModule": true, "isolatedModules": false diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts index 5d7ac0b..1de060c 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts @@ -3,7 +3,7 @@ import { UwtScenarioRecord } from '../../models/uwt-scenario.models/uwt-scenario import { UwtTelemetryMetadata } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { UWT_DEFAULT_REDACT_CONFIG } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { uwtMarkSessionStart } from '../../utils/uwt-session-start.util/uwt-session-start.util'; +import { uwtMarkSessionStart } from '@absaoss-cps/ui-watchtower'; import { UwtScenario, UwtScenarioDeps } from './uwt-scenario'; /** Captures what the scenario emitted, so a test can assert on it. */ diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts index 636547c..0e32b2b 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts @@ -30,7 +30,7 @@ import { uwtRedactMetadata, uwtScrubString } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { uwtSessionElapsed } from '../../utils/uwt-session-start.util/uwt-session-start.util'; +import { uwtSessionElapsed } from '@absaoss-cps/ui-watchtower'; import { uwtDeepClone, uwtElapsedNow, diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts index 22a781f..36a0cd4 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 @@ -20,7 +20,7 @@ import { UwtLogQuery } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { uwtClassifyTelemetryEvent } from '../../utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; -import { uwtSessionElapsed } from '../../utils/uwt-session-start.util/uwt-session-start.util'; +import { uwtSessionElapsed } from '@absaoss-cps/ui-watchtower'; /** The identity fields scenario, BI and log records carry. */ interface UwtIdentified { diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts index bd4adfb..5a1996b 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts @@ -36,7 +36,7 @@ import { uwtIsBroadcastMessage } from './uwt-broadcast.messages'; import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { uwtMarkSessionStart } from '../../utils/uwt-session-start.util/uwt-session-start.util'; +import { uwtMarkSessionStart } from '@absaoss-cps/ui-watchtower'; /** * Each realm gets its own injector — the shell and every fragment run in a diff --git a/projects/ngx-ui-watchtower/src/public-api.ts b/projects/ngx-ui-watchtower/src/public-api.ts index 795967e..5219755 100644 --- a/projects/ngx-ui-watchtower/src/public-api.ts +++ b/projects/ngx-ui-watchtower/src/public-api.ts @@ -110,7 +110,7 @@ export type { UwtTraceScenarioOptions } from './lib/scenario/uwt-scenario-operat export { uwtIsDebugEnabled } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; export type { UwtDebugFlag } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; export { uwtUuid } from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -export { uwtMarkSessionStart } from './lib/utils/uwt-session-start.util/uwt-session-start.util'; +export { uwtMarkSessionStart } from '@absaoss-cps/ui-watchtower'; export { UWT_DEFAULT_REDACT_CONFIG, UWT_REDACTED, diff --git a/projects/ngx-ui-watchtower/tsconfig.lib.json b/projects/ngx-ui-watchtower/tsconfig.lib.json index 44d58cd..db304db 100644 --- a/projects/ngx-ui-watchtower/tsconfig.lib.json +++ b/projects/ngx-ui-watchtower/tsconfig.lib.json @@ -13,7 +13,11 @@ "skipLibCheck": true, "isolatedModules": false, "types": [], - "paths": {} + // The core as published: its built declarations, not its source. Build + // it first (npm run build does). + "paths": { + "@absaoss-cps/ui-watchtower": ["../../dist/ui-watchtower"] + } }, "include": ["src/**/*.ts"], "exclude": ["**/*.spec.ts"], diff --git a/projects/ngx-ui-watchtower/tsconfig.spec.json b/projects/ngx-ui-watchtower/tsconfig.spec.json index 1748c35..0a82408 100644 --- a/projects/ngx-ui-watchtower/tsconfig.spec.json +++ b/projects/ngx-ui-watchtower/tsconfig.spec.json @@ -3,7 +3,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/spec", - "rootDir": "./src", + // Includes the core: tests and typecheck read its source. + "rootDir": "..", "types": ["jest", "node"], "resolveJsonModule": true }, diff --git a/projects/ui-watchtower/LICENSE b/projects/ui-watchtower/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/projects/ui-watchtower/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md new file mode 100644 index 0000000..2ea939a --- /dev/null +++ b/projects/ui-watchtower/README.md @@ -0,0 +1,29 @@ +# ui-watchtower + +The framework-independent engine behind +[`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower): +scenario health telemetry, business/UX events and application logs, for any +JavaScript or TypeScript application. + +> **Work in progress.** The engine is moving here from the Angular package in +> stages. Until it has, use `@absaoss-cps/ngx-ui-watchtower`; it installs this +> package by itself. + +It imports no framework and touches no browser global when imported, so it +loads in Node, on a server and in a worker as well as in a browser. + +## Install + +```bash +npm install @absaoss-cps/ui-watchtower +``` + +## API + +| Export | What it does | +| -------------------------------------- | ---------------------------------------------------------------------- | +| `uwtMarkSessionStart(sessionId, now?)` | Records when a session started, for the destination that owns sessions | + +## License + +Apache-2.0 diff --git a/projects/ui-watchtower/jest.config.js b/projects/ui-watchtower/jest.config.js new file mode 100644 index 0000000..992afc8 --- /dev/null +++ b/projects/ui-watchtower/jest.config.js @@ -0,0 +1,24 @@ +/** + * Unit tests of the framework-independent core: plain Jest and ts-jest, + * without Angular or zone.js. The environment is Node, so nothing can lean + * on browser globals by accident; a spec that needs them opts into jsdom + * with a `@jest-environment jsdom` docblock. + */ +module.exports = { + displayName: 'ui-watchtower', + rootDir: __dirname, + roots: ['/src'], + testEnvironment: 'node', + transform: { + '^.+\\.ts$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] + }, + moduleNameMapper: { + '^@absaoss-cps/ui-watchtower$': '/src/public-api.ts' + }, + coverageDirectory: '/../../coverage/ui-watchtower', + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.spec.ts', + '!src/**/public-api.ts' + ] +}; diff --git a/projects/ui-watchtower/ng-package.json b/projects/ui-watchtower/ng-package.json new file mode 100644 index 0000000..6bfafcd --- /dev/null +++ b/projects/ui-watchtower/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/ui-watchtower", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/ui-watchtower/package.json b/projects/ui-watchtower/package.json new file mode 100644 index 0000000..9056eb2 --- /dev/null +++ b/projects/ui-watchtower/package.json @@ -0,0 +1,18 @@ +{ + "name": "@absaoss-cps/ui-watchtower", + "version": "0.4.0", + "description": "Framework-independent telemetry engine: application logs, scenario health telemetry and business/UX events, for any JavaScript or TypeScript application.", + "repository": { + "type": "git", + "url": "https://github.com/AbsaOSS/ui-watchtower" + }, + "homepage": "https://github.com/AbsaOSS/ui-watchtower", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.1" + }, + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/projects/ui-watchtower/public-api.snapshot.json b/projects/ui-watchtower/public-api.snapshot.json new file mode 100644 index 0000000..48abe79 --- /dev/null +++ b/projects/ui-watchtower/public-api.snapshot.json @@ -0,0 +1,10 @@ +{ + "@absaoss-cps/ui-watchtower": { + "uwtMarkSessionStart": [ + "function(sessionId: string, now?: number | undefined): void" + ], + "uwtSessionElapsed": [ + "function(sessionId: string | undefined, now?: number | undefined): number | undefined" + ] + } +} diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.spec.ts b/projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.spec.ts similarity index 98% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.spec.ts rename to projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.spec.ts index f57add8..fe7dea3 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.spec.ts +++ b/projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.spec.ts @@ -1,3 +1,6 @@ +/** + * @jest-environment jsdom + */ import { uwtMarkSessionStart, uwtSessionElapsed diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts rename to projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts diff --git a/projects/ui-watchtower/src/public-api.spec.ts b/projects/ui-watchtower/src/public-api.spec.ts new file mode 100644 index 0000000..b167832 --- /dev/null +++ b/projects/ui-watchtower/src/public-api.spec.ts @@ -0,0 +1,20 @@ +import * as core from './public-api'; + +/** + * The core must import, and run, where there is no browser: in Node, on a + * server, in a worker. This spec runs in Jest's Node environment, with no + * `window`, `document` or `localStorage`. + */ +describe('the core outside a browser', () => { + it('should import without browser globals', () => { + expect(typeof window).toBe('undefined'); + expect(typeof document).toBe('undefined'); + expect(typeof localStorage).toBe('undefined'); + expect(Object.keys(core)).toContain('uwtMarkSessionStart'); + }); + + it('should keep no session start, and not throw, without storage', () => { + expect(() => core.uwtMarkSessionStart('s1', 1_000)).not.toThrow(); + expect(core.uwtSessionElapsed('s1', 2_000)).toBeUndefined(); + }); +}); diff --git a/projects/ui-watchtower/src/public-api.ts b/projects/ui-watchtower/src/public-api.ts new file mode 100644 index 0000000..abdf86a --- /dev/null +++ b/projects/ui-watchtower/src/public-api.ts @@ -0,0 +1,13 @@ +/* + * Public API Surface of ui-watchtower + * + * The framework-independent engine. Nothing here imports a framework, and + * nothing touches a browser global when imported. + */ + +// Utilities a destination needs +export { uwtMarkSessionStart } from './lib/utils/uwt-session-start.util/uwt-session-start.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.lib.json b/projects/ui-watchtower/tsconfig.lib.json new file mode 100644 index 0000000..2e0fb23 --- /dev/null +++ b/projects/ui-watchtower/tsconfig.lib.json @@ -0,0 +1,22 @@ +/* + * The core library: plain TypeScript, built by ng-packagr like the Angular + * package, but with no Angular in it. + */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/lib-core", + "rootDir": "./src", + "lib": ["ES2022", "DOM"], + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "sourceMap": true, + "skipLibCheck": true, + "isolatedModules": false, + "types": [], + "paths": {} + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"] +} diff --git a/projects/ui-watchtower/tsconfig.lib.prod.json b/projects/ui-watchtower/tsconfig.lib.prod.json new file mode 100644 index 0000000..2a2faa8 --- /dev/null +++ b/projects/ui-watchtower/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/projects/ui-watchtower/tsconfig.spec.json b/projects/ui-watchtower/tsconfig.spec.json new file mode 100644 index 0000000..f0716fe --- /dev/null +++ b/projects/ui-watchtower/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec-core", + "rootDir": "./src", + "types": ["jest", "node"], + "isolatedModules": false + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/scripts/api-snapshot.mjs b/scripts/api-snapshot.mjs index 2316d49..8eff298 100644 --- a/scripts/api-snapshot.mjs +++ b/scripts/api-snapshot.mjs @@ -18,6 +18,10 @@ import ts from 'typescript'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const PACKAGES = [ + { + dist: 'dist/ui-watchtower', + snapshot: 'projects/ui-watchtower/public-api.snapshot.json' + }, { dist: 'dist/ngx-ui-watchtower', snapshot: 'projects/ngx-ui-watchtower/public-api.snapshot.json' diff --git a/tsconfig.json b/tsconfig.json index 9e6331e..24ff742 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,9 @@ "compilerOptions": { "resolveJsonModule": true, "paths": { + "@absaoss-cps/ui-watchtower": [ + "./projects/ui-watchtower/src/public-api.ts" + ], "@absaoss-cps/ngx-ui-watchtower": [ "./projects/ngx-ui-watchtower/src/public-api.ts" ], @@ -39,6 +42,8 @@ }, "files": [], "references": [ + { "path": "./projects/ui-watchtower/tsconfig.lib.json" }, + { "path": "./projects/ui-watchtower/tsconfig.spec.json" }, { "path": "./projects/ngx-ui-watchtower/tsconfig.lib.json" }, { "path": "./projects/ngx-ui-watchtower/tsconfig.spec.json" }, { "path": "./projects/ngx-ui-watchtower/rum/tsconfig.lib.json" }, From e02d8673c0b7bd500f07827a1710f0acc93689d7 Mon Sep 17 00:00:00 2001 From: fateeand Date: Mon, 9 Nov 2026 18:04:52 +0100 Subject: [PATCH 03/21] refactor: move models, redaction and scenarios into the core --- README.md | 10 +- jest.config.js | 4 +- package.json | 2 +- projects/ngx-ui-watchtower/DESIGN.md | 36 +- projects/ngx-ui-watchtower/README.md | 4 +- .../public-api.snapshot.json | 2 - .../uwt-bi-telemetry.config.ts | 36 +- .../config/uwt-log.config/uwt-log.config.ts | 52 +- .../uwt-scenario-telemetry.config.ts | 74 +- .../uwt-telemetry-common.config.ts | 67 +- .../uwt-broadcast-log-api.provider.spec.ts | 2 +- .../uwt-broadcast-log-api.provider.ts | 10 +- .../uwt-log-api.provider.ts | 91 +- .../uwt-noop-log-api.provider.ts | 6 +- .../uwt-telemetry-common.providers.spec.ts | 28 +- .../uwt-telemetry-common.providers.ts | 30 +- .../uwt-telemetry-enabled.spec.ts | 8 +- .../uwt-scenario-operators.spec.ts | 10 +- .../uwt-scenario-operators.ts | 6 +- .../uwt-bi-telemetry.service.spec.ts | 22 +- .../uwt-bi-telemetry.service.ts | 22 +- .../uwt-logger.service.spec.ts | 30 +- .../uwt-logger.service/uwt-logger.service.ts | 26 +- .../uwt-scenario-telemetry.service.spec.ts | 32 +- .../uwt-scenario-telemetry.service.ts | 22 +- .../uwt-broadcast-host.service.ts | 16 +- .../uwt-broadcast-telemetry.sink.ts | 6 +- .../uwt-broadcast/uwt-broadcast.messages.ts | 10 +- .../sinks/uwt-broadcast/uwt-broadcast.spec.ts | 20 +- .../uwt-broadcast.wire.fixture.json | 2 +- .../uwt-broadcast/uwt-broadcast.wire.spec.ts | 27 +- .../uwt-noop-telemetry.sink.spec.ts | 2 +- .../uwt-noop-telemetry.sink.ts | 6 +- .../uwt-debug-flag.util.ts | 73 +- .../uwt-telemetry-redact.util.spec.ts | 848 ++---------------- .../uwt-telemetry-redact.util.ts | 452 +--------- .../uwt-telemetry-safe.util.spec.ts | 176 +--- .../uwt-telemetry-safe.util.ts | 192 +--- projects/ngx-ui-watchtower/src/public-api.ts | 55 +- projects/ui-watchtower/README.md | 59 +- .../ui-watchtower/public-api.snapshot.json | 348 +++++++ .../uwt-bi-telemetry.config.spec.ts | 0 .../uwt-bi-telemetry.config.ts | 34 + .../uwt-log.config/uwt-log.config.spec.ts | 0 .../config/uwt-log.config/uwt-log.config.ts | 51 ++ .../uwt-scenario-telemetry.config.spec.ts | 0 .../uwt-scenario-telemetry.config.ts | 72 ++ .../uwt-telemetry-common.config.spec.ts | 0 .../uwt-telemetry-common.config.ts | 62 ++ .../lib/models/uwt-bi.models/uwt-bi.models.ts | 5 +- .../models/uwt-log.models/uwt-log.models.ts | 12 +- .../uwt-scenario.models.ts | 9 +- .../uwt-telemetry-common.models.spec.ts | 0 .../uwt-telemetry-common.models.ts | 0 .../uwt-log-api.provider.ts | 93 ++ .../uwt-scenario/uwt-scenario.spec.ts | 5 +- .../lib/scenario/uwt-scenario/uwt-scenario.ts | 240 ++--- .../uwt-telemetry-abstract.sink.ts | 5 +- .../uwt-debug-flag.util.spec.ts | 3 + .../uwt-debug-flag.util.ts | 81 ++ .../uwt-session-start.util.ts | 2 + .../uwt-telemetry-event.util.spec.ts | 0 .../uwt-telemetry-event.util.ts | 0 .../uwt-telemetry-redact.util.spec.ts | 816 +++++++++++++++++ .../uwt-telemetry-redact.util.ts | 516 +++++++++++ .../uwt-telemetry-safe.util.spec.ts | 334 +++++++ .../uwt-telemetry-safe.util.ts | 232 +++++ .../uwt-user-timings-internal.util.spec.ts | 3 + .../uwt-user-timings-internal.util.ts | 0 projects/ui-watchtower/src/public-api.ts | 88 +- .../ui-watchtower/type-tests/augmented.ts | 116 +++ projects/ui-watchtower/type-tests/compat.ts | 133 +++ .../type-tests/empty-registry.ts | 28 + .../type-tests/tsconfig.augmented.json | 5 + .../ui-watchtower/type-tests/tsconfig.json | 14 + 75 files changed, 3604 insertions(+), 2279 deletions(-) rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.spec.ts (100%) create mode 100644 projects/ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/config/uwt-log.config/uwt-log.config.spec.ts (100%) create mode 100644 projects/ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.spec.ts (100%) create mode 100644 projects/ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.spec.ts (100%) create mode 100644 projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/models/uwt-bi.models/uwt-bi.models.ts (91%) rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/models/uwt-log.models/uwt-log.models.ts (88%) rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/models/uwt-scenario.models/uwt-scenario.models.ts (97%) rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.spec.ts (100%) rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.ts (100%) create mode 100644 projects/ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts (99%) rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/scenario/uwt-scenario/uwt-scenario.ts (84%) rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink.ts (94%) rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.spec.ts (98%) create mode 100644 projects/ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.spec.ts (100%) rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.ts (100%) create mode 100644 projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts create mode 100644 projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts create mode 100644 projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts create mode 100644 projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.spec.ts (99%) rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.ts (100%) create mode 100644 projects/ui-watchtower/type-tests/augmented.ts create mode 100644 projects/ui-watchtower/type-tests/compat.ts create mode 100644 projects/ui-watchtower/type-tests/empty-registry.ts create mode 100644 projects/ui-watchtower/type-tests/tsconfig.augmented.json create mode 100644 projects/ui-watchtower/type-tests/tsconfig.json diff --git a/README.md b/README.md index 399b56a..9797732 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ How it is designed: [projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watch ```text projects/ui-watchtower/ src/ @absaoss-cps/ui-watchtower — the framework-independent engine + type-tests/ consumer type tests, without a framework README.md usage guide, published to npm package.json the published package's manifest projects/ngx-ui-watchtower/ @@ -69,10 +70,11 @@ application does. Tests and the typecheck read the core's source. Three checks guard what consumers depend on: -- **Type tests** (`projects/ngx-ui-watchtower/type-tests/`) compile, with the - Angular compiler, a consumer that uses every public export, and one that - augments the name registries: registered names accepted, others rejected, - `string` while nothing is registered. +- **Type tests** (`type-tests/` in each package) compile a consumer that uses + every public export, and one that augments the name registries: registered + names accepted, others rejected, `string` while nothing is registered. The + Angular package's are compiled by the Angular compiler, the core's by plain + `tsc`. - **The API snapshots** (`public-api.snapshot.json` in each package) record every export of the built packages with its members and signatures. After an intended change, run `npm run api:update` and review the diff. diff --git a/jest.config.js b/jest.config.js index 76484a4..75c5452 100644 --- a/jest.config.js +++ b/jest.config.js @@ -29,6 +29,8 @@ module.exports = { collectCoverageFrom: [ 'projects/ngx-ui-watchtower/**/*.ts', '!projects/**/*.spec.ts', - '!projects/**/public-api.ts' + '!projects/**/public-api.ts', + // Compiled by the typecheck, never run. + '!projects/**/type-tests/**' ] }; diff --git a/package.json b/package.json index 54f5968..f438134 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint": "eslint \"**/*.ts\" --max-warnings=0", "format": "prettier --write \"**/*.{ts,mjs,html}\"", "format:check": "prettier --check \"**/*.{ts,mjs,html}\"", - "typecheck": "tsc -p projects/ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/tsconfig.spec.json --noEmit && 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/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 83bc522..6ef47bb 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -199,6 +199,13 @@ providers: [ Angular nor this package, which a lint rule enforces. The library build compiles against the core's built declarations, not its source, so it sees the core exactly as an application does. +- **What the core owns, this package re-exports.** The models and name + registries, the configuration types and defaults, redaction, `UwtScenario` + and the `UwtTelemetrySink` and `UwtLogApiProvider` contracts are the + core's. This package keeps the injection tokens, the services and + providers, and the destinations that need Angular. `UwtTelemetrySink` is a + plain abstract class in the core, not `@Injectable()`: Angular uses it as + the destination's token as before, and a custom sink still extends it. - **Every export is explicit.** The barrels list their exports one by one — in a published package each exported name is a compatibility promise. Id generation, the clock, the fail-open wrappers, User Timings and the @@ -271,6 +278,14 @@ scenario.step('fetch-data'); // ok scenario.step('fetch-dat'); // error TS2345 ``` +The registries are declared once, in `@absaoss-cps/ui-watchtower`, and +re-exported here. TypeScript merges an augmentation into a re-exported +interface's declaration, so augmenting `@absaoss-cps/ngx-ui-watchtower` +checks the core's own APIs too: one vocabulary per application. A +framework-free application augments `@absaoss-cps/ui-watchtower` the same +way. The type tests in both packages' `type-tests/` hold each module path to +it. + A typo here would not just produce a wrong number. It would silently start a **second, incomplete** metric series, and the alarm built on the first one would keep reading healthy. Interpolating an id — ``step(`load-${id}`)`` — @@ -1576,10 +1591,12 @@ convention, for an application that wants the pre-fallback behavior back. ## 11. Error handling and performance - **Telemetry never breaks the application.** Every public entry point is - wrapped and never rethrows. In development, a suppressed error is still - reported to `console.error`, so bugs surface; in production it stays - silent. Even a broken `console.error` cannot turn a suppressed failure into - a throw. + wrapped and never rethrows. The core 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 + `[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. - **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 @@ -1594,15 +1611,16 @@ convention, for an application that wants the pre-fallback behavior back. mark-cleanup fallback, a fragment's log-query timeout, the RUM client's dispatch interval and the sink's retry and refresh timers — are scheduled outside Angular's zone, so telemetry never keeps the application from - becoming stable. + becoming stable. The core takes that hook as `runOutside`; this package + passes `NgZone.runOutsideAngular`. - **Bounded.** A 100-item buffer keeps events, page views and errors recorded before the RUM client is ready, then replays them; it never grows past that if initialization never completes. -The console report is itself wrapped in a try/catch — an application that has -patched or otherwise broken `console.error` cannot turn a suppressed -telemetry failure into a rethrow, in `uwtSafe`, or into a fresh unhandled -rejection, in `uwtSafeVoidMaybeAsync`'s async path. Tests +The report is itself wrapped in a try/catch — a reporter that throws, or an +application that has patched or otherwise broken `console.error`, cannot +turn a suppressed telemetry failure into a rethrow, in `uwtSafe`, or into a +fresh unhandled rejection, in `uwtSafeVoidMaybeAsync`'s async path. Tests assert both halves, and assert that a sink or transport throwing on every call leaves application code unaffected. diff --git a/projects/ngx-ui-watchtower/README.md b/projects/ngx-ui-watchtower/README.md index b3a1864..dd7a479 100644 --- a/projects/ngx-ui-watchtower/README.md +++ b/projects/ngx-ui-watchtower/README.md @@ -203,7 +203,9 @@ declare module '@absaoss-cps/ngx-ui-watchtower' { export {}; ``` -Until you declare any, plain strings are accepted. +Until you declare any, plain strings are accepted. The registries are +declared in `@absaoss-cps/ui-watchtower`, the engine this package builds on, +and re-exported here: augmenting this package, as above, covers both. ## Scenarios diff --git a/projects/ngx-ui-watchtower/public-api.snapshot.json b/projects/ngx-ui-watchtower/public-api.snapshot.json index b18d4f0..a47a005 100644 --- a/projects/ngx-ui-watchtower/public-api.snapshot.json +++ b/projects/ngx-ui-watchtower/public-api.snapshot.json @@ -450,8 +450,6 @@ "UwtTelemetrySink": [ "abstract class", "new (): UwtTelemetrySink", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "abstract flush(beacon?: boolean | undefined): void", "abstract getSessionId(): string | undefined", "abstract getUserId(): string | undefined", diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts b/projects/ngx-ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts index b8a2d3b..5f8f8ec 100644 --- a/projects/ngx-ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts +++ b/projects/ngx-ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts @@ -1,39 +1,5 @@ import { InjectionToken } from '@angular/core'; - -/** - * Business and UX event tracking configuration. - * - * @group Interfaces - */ -export interface UwtBITelemetryConfig { - /** Milliseconds within which an identical event is treated as a double-fire. */ - dedupWindowMs: number; - - /** - * Distinct event keys tracked before expired ones are swept. - * - * Keyed per `eventName|scenarioId|feature|metadata`, which - * grows unbounded over a long session without this cap. - */ - dedupMaxKeys: number; - - /** - * Whether redaction runs on BI events. On by default. Turning it off - * skips only the *configurable* PII scrubbing (`extraKeyPatterns`, - * value-pattern scanning, URL-query stripping) — the built-in credential - * denylist, size caps, error normalization and any - * `UwtRedactConfig.extraValueTransforms` still apply; see - * {@link uwtRedactConfigFor}. - */ - redact: boolean; -} - -/** Default BI event tracking settings. */ -export const UWT_DEFAULT_BI_TELEMETRY_CONFIG: UwtBITelemetryConfig = { - dedupWindowMs: 400, - dedupMaxKeys: 100, - redact: true -}; +import type { UwtBITelemetryConfig } from '@absaoss-cps/ui-watchtower'; /** * Resolved BI event configuration. Provided by {@link provideUwtTelemetry}, diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts b/projects/ngx-ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts index a882f06..e47bd36 100644 --- a/projects/ngx-ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts +++ b/projects/ngx-ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts @@ -1,55 +1,5 @@ import { InjectionToken } from '@angular/core'; -import { - UwtLoggerName, - UwtLogLevel -} from '../../models/uwt-log.models/uwt-log.models'; - -/** - * Application logging configuration. - * - * @group Interfaces - */ -export interface UwtLogConfig { - /** Records below this severity are discarded. */ - minLevel: UwtLogLevel; - - /** - * Per-logger overrides of {@link minLevel}, keyed by logger name. Loggers - * without an override use {@link minLevel}. - * - * ```typescript - * withLogging({ minLevel: 'warn', levels: { checkout: 'log' } }) - * ``` - */ - levels?: Partial>; - - /** - * Also report `logger.error` calls to the telemetry sink as RUM errors. Off - * by default; competes for the same session event budget as scenario and - * BI data. - * - * Mirrors `detail.error` when supplied; a message-only call builds a - * synthetic `Error` from the redacted message text instead. - */ - mirrorErrorsToRum: boolean; - - /** - * Whether redaction runs on log records. On by default. Turning it off - * skips only the *configurable* PII scrubbing (`extraKeyPatterns`, - * value-pattern scanning, URL-query stripping) — the built-in credential - * denylist, size caps, error normalization and any - * `UwtRedactConfig.extraValueTransforms` still apply; see - * {@link uwtRedactConfigFor}. - */ - redact: boolean; -} - -/** Default application logging settings. */ -export const UWT_DEFAULT_LOG_CONFIG: UwtLogConfig = { - minLevel: 'log', - mirrorErrorsToRum: false, - redact: true -}; +import type { UwtLogConfig } from '@absaoss-cps/ui-watchtower'; /** * Resolved logging configuration. Provided by {@link provideUwtTelemetry}, diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts b/projects/ngx-ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts index 4214f26..b103368 100644 --- a/projects/ngx-ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts +++ b/projects/ngx-ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts @@ -1,77 +1,5 @@ import { InjectionToken } from '@angular/core'; - -/** - * Scenario telemetry configuration. - * - * @group Interfaces - */ -export interface UwtScenarioTelemetryConfig { - /** - * Milliseconds after which an unsettled scenario auto-settles as `timeout`. - * `0` disables the default; individual scenarios can still opt in. - */ - defaultTimeoutMs: number; - - /** - * Emit an event per lifecycle transition instead of one packed event at - * settlement. Off by default. - * - * Counts against the RUM session's shared `sessionEventLimit` (200 by - * default). Suited to local debugging, not production traffic. - * - * Also governs per-step `debugScenario` console output, because the - * console prints one line per event actually sent and nothing else. - * With this off, a scenario prints a single line at settlement — the - * one record it sends. - */ - emitLifecycleEvents: boolean; - - /** - * Maximum number of steps retained per scenario. Further steps are counted in - * `stepCount` but not stored, bounding the payload of a runaway loop. - */ - maxSteps: number; - - /** - * Mirror scenario and step boundaries into `performance.mark` / `measure`, - * visible on the DevTools Performance track. Off by default. - * - * The `debugScenario` LocalStorage flag switches them on regardless of this - * setting. Marks are cleared when a scenario settles; see - * {@link markCleanupFallbackMs} for scenarios that never do. - */ - userTimings: boolean; - - /** - * How long in milliseconds an unsettled scenario's User Timing marks can - * remain in the Performance buffer before a fallback clears them. - * - * Only applies when `userTimings` is enabled and `timeoutMs: 0`. `0` - * disables the fallback, so marks accumulate for the page's lifetime. - */ - markCleanupFallbackMs: number; - - /** - * Whether redaction runs on scenario records. On by default. Turning it - * off skips only the *configurable* PII scrubbing (`extraKeyPatterns`, - * value-pattern scanning, URL-query stripping) — the built-in credential - * denylist, size caps, error normalization and any - * `UwtRedactConfig.extraValueTransforms` still apply; see - * {@link uwtRedactConfigFor}. - */ - redact: boolean; -} - -/** Default scenario telemetry settings. */ -export const UWT_DEFAULT_SCENARIO_TELEMETRY_CONFIG: UwtScenarioTelemetryConfig = - { - defaultTimeoutMs: 30_000, - emitLifecycleEvents: false, - maxSteps: 50, - userTimings: false, - markCleanupFallbackMs: 300_000, - redact: true - }; +import type { UwtScenarioTelemetryConfig } from '@absaoss-cps/ui-watchtower'; /** * Resolved scenario telemetry configuration. Provided by diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts b/projects/ngx-ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts index d0586d9..38d09fa 100644 --- a/projects/ngx-ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts +++ b/projects/ngx-ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts @@ -1,67 +1,8 @@ import { inject, InjectionToken } from '@angular/core'; -import { - UwtBITelemetryConfig, - UWT_DEFAULT_BI_TELEMETRY_CONFIG -} from '../uwt-bi-telemetry.config/uwt-bi-telemetry.config'; -import { - UwtLogConfig, - UWT_DEFAULT_LOG_CONFIG -} from '../uwt-log.config/uwt-log.config'; -import { - UwtScenarioTelemetryConfig, - UWT_DEFAULT_SCENARIO_TELEMETRY_CONFIG -} from '../uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; -import { UWT_DEFAULT_EVENT_NAMESPACE } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { - UWT_DEFAULT_REDACT_CONFIG, - UwtRedactConfig -} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; - -/** - * The identity every telemetry record and event carries — application name, - * deployment environment and version, plus the event-type namespace. - * - * @group Interfaces - */ -export interface UwtTelemetryIdentity { - /** Application name, e.g. `composition`. Becomes a session attribute. */ - application: string; - - /** Deployment environment, e.g. `dev`, `qa`, `prod`. */ - environment: string; - - /** Application version, e.g. `22.0.0`. */ - version: string; - - /** - * Prefix for the custom event types this library emits — `{namespace}.scenario`, - * `{namespace}.scenario.step` and `{namespace}.bi`. - * - * Defaults to `com.uwt`. - */ - eventNamespace?: string; -} - -/** - * Library defaults for every concern not carried by {@link UwtTelemetryIdentity} - * — applied by {@link provideUwtTelemetry} to anything not overridden through a - * `with*()` feature. - * - * @group Utils - */ -export const UWT_DEFAULT_TELEMETRY_CONFIG: { - eventNamespace: string; - scenario: UwtScenarioTelemetryConfig; - logs: UwtLogConfig; - bi: UwtBITelemetryConfig; - redact: UwtRedactConfig; -} = { - eventNamespace: UWT_DEFAULT_EVENT_NAMESPACE, - scenario: UWT_DEFAULT_SCENARIO_TELEMETRY_CONFIG, - logs: UWT_DEFAULT_LOG_CONFIG, - bi: UWT_DEFAULT_BI_TELEMETRY_CONFIG, - redact: UWT_DEFAULT_REDACT_CONFIG -}; +import type { + UwtRedactConfig, + UwtTelemetryIdentity +} from '@absaoss-cps/ui-watchtower'; /** * The application's identity — shared by every concern; see diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.spec.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.spec.ts index 5208ffb..7d46fd6 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.spec.ts @@ -1,6 +1,6 @@ import { NgZone } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { UwtLogRecord } from '@absaoss-cps/ui-watchtower'; import { UWT_BROADCAST_CHANNEL } from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; import { UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS, diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts index 6e5985a..db8cda5 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts @@ -1,13 +1,11 @@ import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; import { UwtLogApiProvider, - UwtLogQuery -} from '../uwt-log-api.provider/uwt-log-api.provider'; -import { - uwtSafeVoid, + UwtLogQuery, + UwtLogRecord, uwtUuid -} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +} from '@absaoss-cps/ui-watchtower'; +import { uwtSafeVoid } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; import { UwtBroadcastConnection, uwtConnectBroadcastChannel, diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts index a2a50cc..f54f433 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts @@ -1,94 +1,5 @@ import { InjectionToken } from '@angular/core'; -import { - UwtLoggerName, - UwtLogLevel, - UwtLogRecord -} from '../../models/uwt-log.models/uwt-log.models'; - -/** - * Filter accepted by {@link UwtLoggerService.query}. - * - * Every field is optional and they combine with AND. A provider translates - * them however its store requires. - * - * @group Interfaces - */ -export interface UwtLogQuery { - /** - * Pulls one journey's lines together. Pass a {@link UwtScenario.id} to read - * everything logged during that scenario, across services. - */ - correlationId?: string; - - /** Restricts to one named logger. */ - logger?: UwtLoggerName; - - /** Discards records below this severity. */ - minLevel?: UwtLogLevel; - - /** ISO-8601 lower bound on `timestamp`, inclusive. */ - from?: string; - - /** ISO-8601 upper bound on `timestamp`, inclusive. */ - to?: string; - - /** Maximum number of records to return. */ - limit?: number; -} - -/** - * The application's log store, as this library needs to see it. - * - * Where records are kept, how they are authorised and how long they are - * retained are decisions for the consuming application; this is the seam it - * supplies them through. - * - * @example - * ```typescript - * send(record: UwtLogRecord): void { - * this.http.post('/api/logs', record).subscribe({ error: () => undefined }); - * } - * - * query(filter: UwtLogQuery): Promise { - * return firstValueFrom(this.http.get('/api/logs', { params: { ...filter } })); - * } - * ``` - * - * @group Interfaces - */ -export interface UwtLogApiProvider { - /** - * Ships one record. - * - * Called once per record, as it is written. Must be non-blocking and must - * not throw. A provider that batches accumulates its own queue here and - * flushes it via {@link flush}. - * - * @param record the redacted record - */ - send(record: UwtLogRecord): void; - - /** - * Reads records back. - * - * @param filter narrows what is returned; an empty filter means "everything - * this provider is willing to return" - * @returns the matching records - */ - query(filter: UwtLogQuery): Promise; - - /** - * Ships whatever this provider is holding, if it holds anything. - * - * Optional; only needed by a provider that batches in {@link send} instead - * of sending immediately. - * - * Called on `pagehide`, on `visibilitychange` going hidden, and on - * teardown. Must be synchronous and non-blocking. `fetch` with `keepalive` - * or `navigator.sendBeacon` are the usual choices. - */ - flush?(): void; -} +import type { UwtLogApiProvider } from '@absaoss-cps/ui-watchtower'; /** * Binds the application's {@link UwtLogApiProvider}. diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts index 088eaa4..480bc35 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts @@ -1,9 +1,9 @@ import { Injectable } from '@angular/core'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; import { UwtLogApiProvider, - UwtLogQuery -} from '../uwt-log-api.provider/uwt-log-api.provider'; + UwtLogQuery, + UwtLogRecord +} from '@absaoss-cps/ui-watchtower'; /** * Log API provider that discards every record and finds none. diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.spec.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.spec.ts index 740f746..a0cf853 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.spec.ts @@ -1,26 +1,22 @@ -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UWT_DEFAULT_TELEMETRY_CONFIG, + uwtClassifyTelemetryEvent, + UwtLogApiProvider, + UwtLogQuery, + UwtLogRecord, + UwtTelemetryError, + UwtTelemetryMetadata, + UwtTelemetrySink, + UwtTelemetrySinkEvent +} from '@absaoss-cps/ui-watchtower'; import { ApplicationInitStatus, Injectable, PLATFORM_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { UwtLoggerService } from '../../services/uwt-logger.service/uwt-logger.service'; import { UwtScenarioTelemetryService } from '../../services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; import { UwtBITelemetryService } from '../../services/uwt-bi-telemetry.service/uwt-bi-telemetry.service'; -import { - UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { - uwtClassifyTelemetryEvent, - UwtTelemetrySinkEvent -} from '../../utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; import { UwtNoopTelemetrySink } from '../../sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UWT_LOG_API_PROVIDER } from '../uwt-log-api.provider/uwt-log-api.provider'; import { - UWT_LOG_API_PROVIDER, - UwtLogApiProvider, - UwtLogQuery -} from '../uwt-log-api.provider/uwt-log-api.provider'; -import { - UWT_DEFAULT_TELEMETRY_CONFIG, UWT_REDACT_CONFIG, UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts index 298d081..4fe5f43 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts @@ -11,8 +11,15 @@ import { UwtTelemetryBroadcastHost } from '../../sinks/uwt-broadcast/uwt-broadca import { UwtBroadcastTelemetrySink } from '../../sinks/uwt-broadcast/uwt-broadcast-telemetry.sink'; import { UWT_BROADCAST_CHANNEL } from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; import { UwtNoopTelemetrySink } from '../../sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { UwtRedactConfig } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + UWT_DEFAULT_TELEMETRY_CONFIG, + UwtBITelemetryConfig, + UwtLogConfig, + UwtRedactConfig, + UwtScenarioTelemetryConfig, + UwtTelemetryIdentity, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { UwtBroadcastLogApiProvider } from '../uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; import { UWT_LOG_API_PROVIDER } from '../uwt-log-api.provider/uwt-log-api.provider'; import { uwtSafeVoid } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; @@ -20,22 +27,11 @@ import { UWT_REDACT_CONFIG, UWT_TELEMETRY_ENABLED, uwtTelemetryEnabled, - UWT_TELEMETRY_IDENTITY, - UWT_DEFAULT_TELEMETRY_CONFIG, - UwtTelemetryIdentity + UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; -import { - UWT_BI_TELEMETRY_CONFIG, - UwtBITelemetryConfig -} from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; -import { - UWT_LOG_CONFIG, - UwtLogConfig -} from '../../config/uwt-log.config/uwt-log.config'; -import { - UWT_SCENARIO_TELEMETRY_CONFIG, - UwtScenarioTelemetryConfig -} from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { UWT_BI_TELEMETRY_CONFIG } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; +import { UWT_LOG_CONFIG } from '../../config/uwt-log.config/uwt-log.config'; +import { UWT_SCENARIO_TELEMETRY_CONFIG } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; /** * One optional concern's providers, composed onto {@link provideUwtTelemetry}. diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-enabled.spec.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-enabled.spec.ts index 3fdc52b..03f43a7 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-enabled.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-enabled.spec.ts @@ -7,9 +7,11 @@ import { PLATFORM_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { UwtTelemetryError } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { UwtScenario } from '../../scenario/uwt-scenario/uwt-scenario'; +import { + UwtScenario, + UwtTelemetryError, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { traceScenario } from '../../scenario/uwt-scenario-operators/uwt-scenario-operators'; import { UwtScenarioTelemetryService } from '../../services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; import { UwtBITelemetryService } from '../../services/uwt-bi-telemetry.service/uwt-bi-telemetry.service'; diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts index 582564a..917d5bc 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts @@ -12,11 +12,13 @@ import { take, throwError } from 'rxjs'; -import { UWT_DEFAULT_TELEMETRY_CONFIG } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; -import { UwtScenarioRecord } from '../../models/uwt-scenario.models/uwt-scenario.models'; +import { + UWT_DEFAULT_REDACT_CONFIG, + UWT_DEFAULT_TELEMETRY_CONFIG, + UwtScenario, + UwtScenarioRecord +} from '@absaoss-cps/ui-watchtower'; import { UwtNoopTelemetrySink } from '../../sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; -import { UWT_DEFAULT_REDACT_CONFIG } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { UwtScenario } from '../uwt-scenario/uwt-scenario'; import { traceScenario } from './uwt-scenario-operators'; describe('traceScenario operator', () => { diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts index b9343b0..3b731a1 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts @@ -1,8 +1,10 @@ import { defer } from 'rxjs'; import type { MonoTypeOperatorFunction, Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; -import type { UwtScenarioOutcome } from '../../models/uwt-scenario.models/uwt-scenario.models'; -import type { UwtScenario } from '../uwt-scenario/uwt-scenario'; +import type { + UwtScenario, + UwtScenarioOutcome +} from '@absaoss-cps/ui-watchtower'; import { uwtSafe } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; /** diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.spec.ts index 3b37314..828db40 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.spec.ts @@ -6,22 +6,20 @@ import { withRedaction, withScenarios } from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; -import { UwtBIEvent } from '../../models/uwt-bi.models/uwt-bi.models'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { - UWT_LOG_API_PROVIDER, + UWT_REDACTED, + UWT_TELEMETRY_EVENT_TYPE, + UwtBIEvent, UwtLogApiProvider, - UwtLogQuery -} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import { UWT_REDACTED } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; + UwtLogQuery, + UwtLogRecord, + UwtTelemetryError, + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { UwtBITelemetryService } from './uwt-bi-telemetry.service'; import { UwtScenarioTelemetryService } from '../uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; -import { - UWT_TELEMETRY_EVENT_TYPE, - UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; /** Captures what the library emitted, so a test can assert on it. */ @Injectable() diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts index 0aa89ae..fd95a92 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts @@ -1,28 +1,26 @@ import { inject, Injectable } from '@angular/core'; +import { + UwtBIEvent, + UwtBIEventDetail, + UwtBIEventName, + uwtEventTypes, + uwtNow, + uwtRedactConfigFor, + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { UWT_REDACT_CONFIG, uwtTelemetryEnabled, UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UWT_BI_TELEMETRY_CONFIG } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; -import type { - UwtBIEvent, - UwtBIEventDetail, - UwtBIEventName -} from '../../models/uwt-bi.models/uwt-bi.models'; -import { - uwtEventTypes, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; import { - uwtRedactConfigFor, uwtRedactMetadata, uwtScrubString } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; import { - uwtNow, uwtSafe, uwtSafeVoid } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts index 068d000..271e2cd 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts @@ -1,29 +1,25 @@ import { Injectable } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { UwtLogConfig } from '../../config/uwt-log.config/uwt-log.config'; +import { + UWT_REDACTED, + UwtLogApiProvider, + UwtLogConfig, + UwtLogDetail, + UwtLogger, + UwtLogQuery, + UwtLogRecord, + UwtTelemetryError, + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { provideUwtTelemetry, withLogging, withRedaction } from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { - UWT_LOG_API_PROVIDER, - UwtLogApiProvider, - UwtLogQuery -} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import * as uwtTelemetryRedactUtil from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { UWT_REDACTED } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; import { UwtLoggerService } from './uwt-logger.service'; -import { - UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import type { - UwtLogDetail, - UwtLogger, - UwtLogRecord -} from '../../models/uwt-log.models/uwt-log.models'; /** Captures what the library emitted, so a test can assert on it. */ @Injectable() diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts index 62c9885..707f063 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts @@ -1,28 +1,26 @@ import { DOCUMENT } from '@angular/common'; import { inject, Injectable, OnDestroy } from '@angular/core'; +import { + UWT_LOG_LEVEL_ORDER, + UwtLogDetail, + UwtLogger, + UwtLoggerName, + UwtLogLevel, + UwtLogQuery, + UwtLogRecord, + uwtRedactConfigFor, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { UWT_REDACT_CONFIG, uwtTelemetryEnabled, UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UWT_LOG_CONFIG } from '../../config/uwt-log.config/uwt-log.config'; -import { - UWT_LOG_LEVEL_ORDER, - type UwtLogger, - type UwtLoggerName, - type UwtLogDetail, - type UwtLogLevel, - type UwtLogRecord -} from '../../models/uwt-log.models/uwt-log.models'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { - UWT_LOG_API_PROVIDER, - UwtLogQuery -} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; import { uwtNormalizeError, - uwtRedactConfigFor, uwtRedactMetadata, uwtScrubString } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts index 446c4d4..0981901 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts @@ -1,29 +1,25 @@ import { Injectable, NgZone } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { UwtScenarioTelemetryConfig } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { + UWT_REDACTED, + UWT_TELEMETRY_EVENT_TYPE, + UwtLogApiProvider, + UwtLogQuery, + UwtLogRecord, + UwtScenarioRecord, + UwtScenarioStatus, + UwtScenarioTelemetryConfig, + UwtTelemetryError, + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { provideUwtTelemetry, withRedaction, withScenarios } from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; -import { - UwtScenarioRecord, - UwtScenarioStatus -} from '../../models/uwt-scenario.models/uwt-scenario.models'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { - UWT_LOG_API_PROVIDER, - UwtLogApiProvider, - UwtLogQuery -} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import { UWT_REDACTED } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { UwtScenarioTelemetryService } from './uwt-scenario-telemetry.service'; -import { - UWT_TELEMETRY_EVENT_TYPE, - UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; /** Captures what the library emitted, so a test can assert on it. */ @Injectable() diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts index 1fea5a7..ccb9798 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts @@ -1,26 +1,27 @@ import { DOCUMENT } from '@angular/common'; import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; import { Observable, Subject } from 'rxjs'; +import { + uwtDeepClone, + uwtRedactConfigFor, + UwtScenario, + UwtScenarioName, + UwtScenarioOptions, + UwtScenarioRecord, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { UWT_REDACT_CONFIG, uwtTelemetryEnabled, UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UWT_SCENARIO_TELEMETRY_CONFIG } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; -import type { - UwtScenarioName, - UwtScenarioOptions, - UwtScenarioRecord -} from '../../models/uwt-scenario.models/uwt-scenario.models'; -import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { - uwtDeepClone, + uwtDevModeReporter, uwtIsBrowser, uwtIsDevMode, uwtSafeVoid } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -import { uwtRedactConfigFor } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { UwtScenario } from '../../scenario/uwt-scenario/uwt-scenario'; /** * How many scenarios may be in flight before a development-mode warning @@ -137,8 +138,9 @@ export class UwtScenarioTelemetryService implements OnDestroy { scenarioTelemetryConfig: this.scenarioTelemetryConfig, redact: this.redact, sink: this.sink, - runOutsideAngular: (callback) => + runOutside: (callback) => this.zone?.runOutsideAngular(callback) ?? callback(), + report: uwtDevModeReporter, onSettled: (scenarioId, record) => { this.active.delete(scenarioId); if (!this._settled$.observed) { 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 36a0cd4..7871ad0 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts @@ -1,4 +1,11 @@ import { inject, Injectable, OnDestroy } from '@angular/core'; +import { + uwtClassifyTelemetryEvent, + UwtLogQuery, + UwtLogRecord, + uwtSessionElapsed, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { uwtSafe, uwtSafeVoid, @@ -10,17 +17,10 @@ import { uwtElectBroadcastHostLeader, uwtIsBroadcastMessage } from './uwt-broadcast.messages'; -import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { uwtTelemetryEnabled } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; import { UwtBroadcastLogApiProvider } from '../../providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; -import { - UWT_LOG_API_PROVIDER, - UwtLogQuery -} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import { uwtClassifyTelemetryEvent } from '../../utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; -import { uwtSessionElapsed } from '@absaoss-cps/ui-watchtower'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; /** The identity fields scenario, BI and log records carry. */ interface UwtIdentified { diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts index dff59f5..ae9f424 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts @@ -1,8 +1,9 @@ import { inject, Injectable, OnDestroy } from '@angular/core'; import { UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { uwtSafeVoid } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; import { @@ -10,7 +11,6 @@ import { uwtConnectBroadcastChannel, uwtIsBroadcastMessage } from './uwt-broadcast.messages'; -import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; /** * Telemetry sink for a follower realm — a micro-frontend fragment, a webview diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts index de09f92..5249c4a 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts @@ -1,13 +1,11 @@ import { inject, InjectionToken } from '@angular/core'; import { + UWT_LOG_LEVEL_ORDER, + UwtLogQuery, + UwtLogRecord, UwtTelemetryError, UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { - UWT_LOG_LEVEL_ORDER, - UwtLogRecord -} from '../../models/uwt-log.models/uwt-log.models'; -import { UwtLogQuery } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +} from '@absaoss-cps/ui-watchtower'; import { uwtTelemetryEnabled } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { uwtIsBrowser, diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts index 5a1996b..6356206 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts @@ -1,15 +1,19 @@ import { + UWT_DEFAULT_TELEMETRY_CONFIG, UWT_TELEMETRY_EVENT_TYPE, + UwtLogApiProvider, + UwtLogQuery, + UwtLogRecord, + uwtMarkSessionStart, UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; import { DOCUMENT } from '@angular/common'; import { Injectable, Injector, PLATFORM_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { UwtLoggerService } from '../../services/uwt-logger.service/uwt-logger.service'; import { - UWT_DEFAULT_TELEMETRY_CONFIG, UWT_REDACT_CONFIG, UWT_TELEMETRY_ENABLED, UWT_TELEMETRY_IDENTITY @@ -17,11 +21,7 @@ import { import { UWT_LOG_CONFIG } from '../../config/uwt-log.config/uwt-log.config'; import { UWT_SCENARIO_TELEMETRY_CONFIG } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; import { UwtScenarioTelemetryService } from '../../services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; -import { - UWT_LOG_API_PROVIDER, - UwtLogApiProvider, - UwtLogQuery -} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { UwtTelemetryBroadcastHost } from './uwt-broadcast-host.service'; import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; import { @@ -35,8 +35,6 @@ import { uwtElectBroadcastHostLeader, uwtIsBroadcastMessage } from './uwt-broadcast.messages'; -import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; -import { uwtMarkSessionStart } from '@absaoss-cps/ui-watchtower'; /** * Each realm gets its own injector — the shell and every fragment run in a diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json index b86e7c5..116f843 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json @@ -147,7 +147,7 @@ "error": { "message": "Declined", "name": "Error", - "stack": "Error: Declined\n at /Users/madfamily/Documents/ui-watchtower/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts:299:45" + "stack": "" }, "level": "error", "logger": "checkout", diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts index 635afb2..da002fe 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts @@ -4,27 +4,25 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import { UWT_DEFAULT_TELEMETRY_CONFIG, + UwtLogApiProvider, + UwtLogQuery, + UwtLogRecord, + UwtTelemetryError, + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; +import { UWT_REDACT_CONFIG, UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UWT_BI_TELEMETRY_CONFIG } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; import { UWT_LOG_CONFIG } from '../../config/uwt-log.config/uwt-log.config'; import { UWT_SCENARIO_TELEMETRY_CONFIG } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; -import { - UwtTelemetryError, - UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; -import { - UWT_LOG_API_PROVIDER, - UwtLogApiProvider, - UwtLogQuery -} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { UwtBroadcastLogApiProvider } from '../../providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; import { UwtBITelemetryService } from '../../services/uwt-bi-telemetry.service/uwt-bi-telemetry.service'; import { UwtLoggerService } from '../../services/uwt-logger.service/uwt-logger.service'; import { UwtScenarioTelemetryService } from '../../services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; -import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { UwtTelemetryBroadcastHost } from './uwt-broadcast-host.service'; import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; @@ -119,16 +117,19 @@ function plain(value: unknown): unknown { */ const TIMED = /(^|[a-z])(time|offset|delta|elapsed|duration|timestamp)$/i; +/** A stack trace names files and lines, which differ by machine and by edit. */ +const VARIES = (key: string): boolean => TIMED.test(key) || key === 'stack'; + /** * What can differ between runs made comparable: ids numbered by first - * appearance, and times and durations by their type. + * appearance, and times, durations and stack traces by their type. */ function stable(posted: Posted[]): Posted[] { const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const ids = new Map(); const walk = (value: unknown, key = ''): unknown => { - if (TIMED.test(key) && value !== '') { + if (VARIES(key) && value !== '') { return `<${typeof value}>`; } if (typeof value === 'string' && uuid.test(value)) { diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts index 59fd3e3..95fac2c 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts @@ -1,5 +1,5 @@ import { UwtNoopTelemetrySink } from './uwt-noop-telemetry.sink'; -import { UwtTelemetrySink } from '../uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; describe('UwtNoopTelemetrySink', () => { let sink: UwtTelemetrySink; diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts index 0b6f76d..017233a 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts @@ -1,9 +1,9 @@ import { Injectable } from '@angular/core'; import { UwtTelemetryError, - UwtTelemetryMetadata -} from '../../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { UwtTelemetrySink } from '../uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; + UwtTelemetryMetadata, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; /** * Sink that discards everything. diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts index 7c7d9f2..89a42e5 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts +++ b/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts @@ -1,78 +1,23 @@ -import { uwtSafeVoid } from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + UwtDebugFlag, + uwtDebugWrite as coreDebugWrite +} from '@absaoss-cps/ui-watchtower'; +import { uwtDevModeReporter } from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; /** - * LocalStorage keys recognised as telemetry debug switches. - * - * @group Types - */ -export type UwtDebugFlag = 'debugLogger' | 'debugScenario' | 'debugBI'; - -const ENABLED_VALUES = new Set(['true', '1']); - -/** - * Reports whether a telemetry debug flag is enabled in LocalStorage. - * - * Only `'true'` and `'1'` enable it (case-insensitive, trimmed). Any other - * non-empty value is treated as a comma-separated list of names, enabling the - * flag only when `name` matches one of them; a call with no `name` reads a - * list value as disabled. A missing key or a throwing `localStorage` also - * reads as disabled. - * - * @param flag the LocalStorage key to check - * @param name the name to test against a list value, when the flag is scoped - * @returns `true` only when the flag is enabled for this name - * - * @group Utils - */ -export function uwtIsDebugEnabled(flag: UwtDebugFlag, name?: string): boolean { - try { - const raw = globalThis.localStorage?.getItem(flag); - if (typeof raw !== 'string') { - return false; - } - - const value = raw.trim().toLowerCase(); - if (ENABLED_VALUES.has(value)) { - return true; - } - - if (name === undefined || !value) { - return false; - } - - return value - .split(',') - .some((entry) => entry.trim() === name.trim().toLowerCase()); - } catch { - return false; - } -} - -/** - * Writes a debug line to the console, but only when `flag` is on, and - * never at the caller's expense. - * - * The write is isolated in its own {@link uwtSafeVoid} rather than left to - * whatever wrapper the caller already sits inside. That isolation is the - * point: every caller prints a debug line immediately *before* handing the - * same payload to a sink or transport, so a patched or broken `console` - * must not be able to abort the send. Writing the line and shipping the - * data are independent failures. + * Writes a debug line to the console, but only when `flag` is on, and never + * at the caller's expense: the core's, reporting a failing write through + * {@link uwtDevModeReporter}. * * @param flag the debug switch that has to be on * @param write performs the console call; never runs when the flag is off * @param name the name to test against a list-valued flag, e.g. a logger * name for `debugLogger` - * - * @group Utils */ export function uwtDebugWrite( flag: UwtDebugFlag, write: () => void, name?: string ): void { - if (!uwtIsDebugEnabled(flag, name)) { - return; - } - uwtSafeVoid(`${flag}.write`, write); + coreDebugWrite(flag, write, name, uwtDevModeReporter); } diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts index 5ed74a9..fa3e63b 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts @@ -1,800 +1,100 @@ +import { isDevMode } from '@angular/core'; +import * as core from '@absaoss-cps/ui-watchtower'; +import type {} from '@absaoss-cps/ui-watchtower'; import { - UWT_DEFAULT_REDACT_CONFIG, - UWT_REDACTED, - uwtMergeMetadata, uwtNormalizeError, - uwtRedactConfigFor, uwtRedactMetadata, uwtScrubString } from './uwt-telemetry-redact.util'; -describe('uwtScrubString', () => { - it('should strip the query string and fragment from an absolute URL', () => { - expect( - uwtScrubString( - 'https://api.example.com/customers?token=abc123#section', - UWT_DEFAULT_REDACT_CONFIG - ) - ).toBe('https://api.example.com/customers'); - }); - - it('should strip a URL embedded inside a longer message', () => { - expect( - uwtScrubString( - 'Request to https://api.example.com/v1/users?apiKey=secret failed', - UWT_DEFAULT_REDACT_CONFIG - ) - ).toBe('Request to https://api.example.com/v1/users failed'); - }); - - it('should strip the query string from a root-relative path', () => { - expect( - uwtScrubString('/customers?ssn=123-45-6789', UWT_DEFAULT_REDACT_CONFIG) - ).toBe('/customers'); - }); - - it('should strip a root-relative path embedded inside a longer message', () => { - expect( - uwtScrubString( - 'Request to /api/customers/search?email=john@example.com&ssn=123-45-6789 failed', - UWT_DEFAULT_REDACT_CONFIG - ) - ).toBe('Request to /api/customers/search failed'); - }); - - it('should strip more than one embedded root-relative path in the same string', () => { - expect( - uwtScrubString( - 'compare /a/x?p=1 against /b/y?q=2 now', - UWT_DEFAULT_REDACT_CONFIG - ) - ).toBe('compare /a/x against /b/y now'); - }); - - it('should strip a root-relative path with no space before it', () => { - expect( - uwtScrubString( - 'Redirected to:/dashboard?sessionToken=abc123', - UWT_DEFAULT_REDACT_CONFIG - ) - ).toBe('Redirected to:/dashboard'); - }); - - it('should leave a plain message that merely ends in a question mark intact', () => { - expect( - uwtScrubString('Could not load the data?', UWT_DEFAULT_REDACT_CONFIG) - ).toBe('Could not load the data?'); - }); - - it('should truncate strings beyond the configured cap', () => { - const result = uwtScrubString('x'.repeat(50), { - ...UWT_DEFAULT_REDACT_CONFIG, - maxStringLength: 10 - }); - expect(result).toBe(`${'x'.repeat(10)}…`); - }); - - it('should leave URLs alone when stripping is disabled', () => { - expect( - uwtScrubString('https://example.com/a?b=c', { - ...UWT_DEFAULT_REDACT_CONFIG, - stripUrlQuery: false - }) - ).toBe('https://example.com/a?b=c'); - }); - - describe('value-pattern scanning', () => { - it('should not scan for any value pattern by default', () => { - expect( - uwtScrubString( - 'contact john.smith@example.com, card 4111111111111111, ssn 123-45-6789', - UWT_DEFAULT_REDACT_CONFIG - ) - ).toBe( - 'contact john.smith@example.com, card 4111111111111111, ssn 123-45-6789' - ); - }); - - it('should redact an email address when enabled', () => { - expect( - uwtScrubString('contact john.smith@example.com for help', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['email'] - }) - ).toBe(`contact ${UWT_REDACTED} for help`); - }); - - it('should not match a bare @-handle with no domain as an email', () => { - expect( - uwtScrubString('cc @someuser on this', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['email'] - }) - ).toBe('cc @someuser on this'); - }); - - it('should catch an email embedded in a URL path segment', () => { - expect( - uwtScrubString('/customers/john.smith@example.com', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['email'] - }) - ).toBe(`/customers/${UWT_REDACTED}`); - }); - - it('should redact a Luhn-valid credit card number when enabled', () => { - expect( - uwtScrubString('card on file: 4111111111111111', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['creditCard'] - }) - ).toBe(`card on file: ${UWT_REDACTED}`); - }); - - it('should leave a 16-digit run alone when it fails the Luhn checksum', () => { - expect( - uwtScrubString('order number: 1234567890123456', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['creditCard'] - }) - ).toBe('order number: 1234567890123456'); - }); - - it('should redact a well-formatted SSN when enabled', () => { - expect( - uwtScrubString('ssn on file: 123-45-6789', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['ssn'] - }) - ).toBe(`ssn on file: ${UWT_REDACTED}`); - }); - - it('should not match a bare 9-digit run with no dashes as an SSN', () => { - expect( - uwtScrubString('reference 123456789', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['ssn'] - }) - ).toBe('reference 123456789'); - }); - - it('should redact an IPv4 address when enabled', () => { - expect( - uwtScrubString('client at 192.168.1.1', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['ipv4'] - }) - ).toBe(`client at ${UWT_REDACTED}`); - }); - - it('should not match an out-of-range octet as an IPv4 address', () => { - expect( - uwtScrubString('version 999.999.999.999', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['ipv4'] - }) - ).toBe('version 999.999.999.999'); - }); - - it('should redact a US-shaped phone number when enabled', () => { - expect( - uwtScrubString('call (555) 123-4567', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['phone'] - }) - ).toBe(`call ${UWT_REDACTED}`); - }); - - it('should redact a South African phone number in international format when enabled', () => { - expect( - uwtScrubString('call +27 82 123 4567', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['phone'] - }) - ).toBe(`call ${UWT_REDACTED}`); - }); - - it('should redact a value matching an application-supplied pattern', () => { - expect( - uwtScrubString('internal ref ACC-98765', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValuePatterns: [/ACC-\d+/] - }) - ).toBe(`internal ref ${UWT_REDACTED}`); - }); - - it('should redact every occurrence even when the supplied pattern has no g flag', () => { - expect( - uwtScrubString('ref ACC-111 and also ref ACC-222', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValuePatterns: [/ACC-\d+/] - }) - ).toBe(`ref ${UWT_REDACTED} and also ref ${UWT_REDACTED}`); - }); - - it('should redact correctly across repeated calls reusing the same non-global pattern object', () => { - const pattern = /ACC-\d+/; - const config = { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValuePatterns: [pattern] - }; - - expect(uwtScrubString('ref ACC-111 and also ACC-222', config)).toBe( - `ref ${UWT_REDACTED} and also ${UWT_REDACTED}` - ); - - expect(uwtScrubString('ref ACC-333 and also ACC-444', config)).toBe( - `ref ${UWT_REDACTED} and also ${UWT_REDACTED}` - ); - }); - - it('should not recompile the same non-global pattern into a new RegExp on every call', () => { - const RegExpSpy = jest.spyOn(globalThis, 'RegExp'); - const pattern = /ACC-\d+/; - const config = { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValuePatterns: [pattern] - }; - - uwtScrubString('ref ACC-111', config); - const callsAfterFirst = RegExpSpy.mock.calls.length; - uwtScrubString('ref ACC-222', config); - const callsAfterSecond = RegExpSpy.mock.calls.length; - - expect(callsAfterFirst).toBeGreaterThan(0); - expect(callsAfterSecond).toBe(callsAfterFirst); - - RegExpSpy.mockRestore(); - }); - - it('should fully redact a credit card number regardless of scanValuePatterns order', () => { - const value = 'card on file: 4111111111111111'; - expect( - uwtScrubString(value, { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['phone', 'creditCard'] - }) - ).toBe(`card on file: ${UWT_REDACTED}`); - expect( - uwtScrubString(value, { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['creditCard', 'phone'] - }) - ).toBe(`card on file: ${UWT_REDACTED}`); - }); - - it('should apply multiple enabled patterns to the same string', () => { - expect( - uwtScrubString('email a@b.com or call (555) 123-4567', { - ...UWT_DEFAULT_REDACT_CONFIG, - scanValuePatterns: ['email', 'phone'] - }) - ).toBe(`email ${UWT_REDACTED} or call ${UWT_REDACTED}`); - }); - }); - - describe('extraValueTransforms', () => { - it('should run a custom transform on every string value', () => { - expect( - uwtScrubString('internal-id ACC-12345', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValueTransforms: [ - (value) => value.replace(/ACC-\d+/, UWT_REDACTED) - ] - }) - ).toBe(`internal-id ${UWT_REDACTED}`); - }); - - it('should run regardless of scanValuePatterns/extraValuePatterns being empty', () => { - expect( - uwtScrubString('plain text', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValueTransforms: [() => 'replaced'] - }) - ).toBe('replaced'); - }); - - it('should run multiple transforms in array order', () => { - expect( - uwtScrubString('start', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValueTransforms: [ - (value) => `${value}-a`, - (value) => `${value}-b` - ] - }) - ).toBe('start-a-b'); - }); - - describe('a throwing transform', () => { - let consoleWarn: jest.SpyInstance; - - beforeEach(() => { - consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleWarn.mockRestore(); - }); - - it('should skip it and keep the value from before it, reporting why', () => { - expect( - uwtScrubString('unchanged', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValueTransforms: [ - () => { - throw new Error('broken transform'); - } - ] - }) - ).toBe('unchanged'); - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining('extraValueTransforms'), - expect.any(Error) - ); - }); - - it('should still apply subsequent transforms after it', () => { - expect( - uwtScrubString('start', { - ...UWT_DEFAULT_REDACT_CONFIG, - extraValueTransforms: [ - () => { - throw new Error('broken transform'); - }, - (value) => `${value}-ok` - ] - }) - ).toBe('start-ok'); - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining('extraValueTransforms'), - expect.any(Error) - ); - }); - }); - - it('should still cap length after custom transforms run', () => { - expect( - uwtScrubString('short', { - ...UWT_DEFAULT_REDACT_CONFIG, - maxStringLength: 5, - extraValueTransforms: [() => 'a much longer replacement value'] - }) - ).toBe('a muc…'); - }); - }); -}); - -describe('uwtRedactMetadata', () => { - it('should keep primitive values', () => { - expect( - uwtRedactMetadata({ count: 3, name: 'csv', ok: true, empty: null }) - ).toEqual({ - count: 3, - name: 'csv', - ok: true, - empty: null - }); - }); - - it.each([ - 'password', - 'passwd', - 'accessToken', - 'refresh_token', - 'Authorization', - 'clientSecret', - 'apiKey', - 'api_key', - 'Cookie', - 'bearerToken', - 'jwt', - 'signature', - 'sessionKey', - 'ssn' - ])('should redact the sensitive key "%s"', (key) => { - expect(uwtRedactMetadata({ [key]: 'super-secret-value' })).toEqual({ - [key]: UWT_REDACTED - }); - }); - - it('should redact keys matching an application-supplied pattern', () => { - expect( - uwtRedactMetadata( - { customerRef: 'ABC' }, - { ...UWT_DEFAULT_REDACT_CONFIG, extraKeyPatterns: [/customerRef/i] } - ) - ).toEqual({ customerRef: UWT_REDACTED }); - }); - - it('should redact the same key consistently across repeated calls with a global pattern', () => { - const config = { - ...UWT_DEFAULT_REDACT_CONFIG, - extraKeyPatterns: [/internalId/gi] - }; - for (let i = 0; i < 4; i++) { - expect(uwtRedactMetadata({ internalId: 'value' }, config)).toEqual({ - internalId: UWT_REDACTED - }); - } - }); - - it('should drop nested objects rather than serializing them', () => { - expect( - uwtRedactMetadata({ user: { id: 1, email: 'a@b.c' }, safe: 'yes' }) - ).toEqual({ safe: 'yes' }); - }); - - it('should drop arrays, functions, symbols and undefined', () => { - expect( - uwtRedactMetadata({ - rows: [1, 2, 3], - fn: () => undefined, - sym: Symbol('s'), - missing: undefined, - kept: 1 - }) - ).toEqual({ kept: 1 }); - }); - - it('should drop non-finite numbers', () => { - expect( - uwtRedactMetadata({ a: NaN, b: Infinity, c: -Infinity, d: 0 }) - ).toEqual({ d: 0 }); - }); - - it('should scrub URLs inside string values', () => { - expect(uwtRedactMetadata({ url: 'https://x.dev/p?token=1' })).toEqual({ - url: 'https://x.dev/p' - }); - }); - - it('should cap the number of retained keys', () => { - const consoleWarn = jest - .spyOn(console, 'warn') - .mockImplementation(() => {}); - - const input: Record = {}; - for (let i = 0; i < 100; i++) { - input[`k${i}`] = i; - } - const result = uwtRedactMetadata(input, { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 5 - }); - expect(Object.keys(result ?? {})).toHaveLength(5); - - consoleWarn.mockRestore(); - }); +jest.mock('@angular/core', () => ({ + ...jest.requireActual('@angular/core'), + isDevMode: jest.fn(() => true) +})); - describe('truncation warning', () => { - let consoleWarn: jest.SpyInstance; +const isDevModeMock = isDevMode as jest.Mock; - beforeEach(() => { - consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleWarn.mockRestore(); - }); - - it('should warn once when maxKeys actually truncates something', () => { - uwtRedactMetadata( - { a: 1, b: 2, c: 3 }, - { ...UWT_DEFAULT_REDACT_CONFIG, maxKeys: 2 } - ); - - expect(consoleWarn).toHaveBeenCalledTimes(1); - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining('maxKeys') - ); - }); - - it('should stay silent when every key fits under maxKeys', () => { - uwtRedactMetadata( - { a: 1, b: 2 }, - { ...UWT_DEFAULT_REDACT_CONFIG, maxKeys: 2 } - ); - - expect(consoleWarn).not.toHaveBeenCalled(); - }); - }); - - it('should not throw on a cyclic object', () => { - const cyclic: Record = { name: 'root' }; - cyclic.self = cyclic; - expect(uwtRedactMetadata(cyclic)).toEqual({ name: 'root' }); - }); - - it.each([[null], [undefined], ['string'], [42], [[1, 2]]])( - 'should return undefined for the non-object input %p', - (input) => { - expect(uwtRedactMetadata(input)).toBeUndefined(); - } - ); - - it('should return undefined when nothing survives redaction', () => { - expect(uwtRedactMetadata({ nested: { a: 1 } })).toBeUndefined(); - }); -}); - -describe('uwtMergeMetadata', () => { - it('should return target unchanged when incoming is undefined', () => { - const target = { a: 1 }; - expect(uwtMergeMetadata(target, undefined)).toBe(target); - expect(target).toEqual({ a: 1 }); - }); - - it('should merge a disjoint bag in when under maxKeys', () => { - const target = { a: 1 }; - const result = uwtMergeMetadata( - target, - { b: 2 }, - { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 5 - } - ); - expect(result).toEqual({ a: 1, b: 2 }); - }); - - it('should drop a genuinely new key once the combined count reaches maxKeys', () => { - const consoleWarn = jest - .spyOn(console, 'warn') - .mockImplementation(() => {}); - const target = { a: 1 }; - try { - const result = uwtMergeMetadata( - target, - { b: 2 }, - { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 1 - } - ); - expect(result).toEqual({ a: 1 }); - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining('maxKeys') - ); - } finally { - consoleWarn.mockRestore(); - } - }); - - it('should allow updating a key already present even when target is at maxKeys', () => { - const target = { a: 1 }; - const result = uwtMergeMetadata( - target, - { a: 2 }, - { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 1 +/** Redaction itself is tested in the core; this is what this package adds. */ +describe('the redaction functions this package exports', () => { + const throwingTransform = { + ...core.UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + (): string => { + throw new Error('broken transform'); } - ); - expect(result).toEqual({ a: 2 }); - }); - - it('should mutate and return the same target reference', () => { - const target = { a: 1 }; - expect(uwtMergeMetadata(target, { b: 2 })).toBe(target); - }); - - describe('truncation warning', () => { - let consoleWarn: jest.SpyInstance; - - beforeEach(() => { - consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleWarn.mockRestore(); - }); - - it('should warn once when maxKeys actually truncates the combined result', () => { - uwtMergeMetadata( - { a: 1 }, - { b: 2 }, - { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 1 - } - ); - - expect(consoleWarn).toHaveBeenCalledTimes(1); - expect(consoleWarn).toHaveBeenCalledWith( - expect.stringContaining('maxKeys') - ); - }); - - it('should stay silent when the combined bag fits under maxKeys', () => { - uwtMergeMetadata( - { a: 1 }, - { b: 2 }, - { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 2 - } - ); - - expect(consoleWarn).not.toHaveBeenCalled(); - }); - - it('should stay silent when only existing keys are updated at maxKeys', () => { - uwtMergeMetadata( - { a: 1 }, - { a: 2 }, - { - ...UWT_DEFAULT_REDACT_CONFIG, - maxKeys: 1 - } - ); - - expect(consoleWarn).not.toHaveBeenCalled(); - }); - }); -}); + ] + }; + let consoleWarn: jest.SpyInstance; -describe('uwtNormalizeError', () => { - it('should reduce an Error to name, message and stack', () => { - const result = uwtNormalizeError(new TypeError('boom')); - expect(result?.name).toBe('TypeError'); - expect(result?.message).toBe('boom'); - expect(typeof result?.stack).toBe('string'); + beforeEach(() => { + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + isDevModeMock.mockReturnValue(true); }); - it.each([ - ['empty', ''], - ['missing', undefined] - ])('should fall back to the generic label when name is %s', (_case, name) => { - const error = new Error('boom'); - (error as { name: unknown }).name = name; - expect(uwtNormalizeError(error)?.name).toBe('Error'); + afterEach(() => { + jest.restoreAllMocks(); }); - it('should omit the stack when capture is disabled', () => { - const result = uwtNormalizeError(new Error('boom'), { - ...UWT_DEFAULT_REDACT_CONFIG, - includeStack: false - }); - expect(result?.stack).toBeUndefined(); - }); - - it('should cap the stack length', () => { - const error = new Error('boom'); - error.stack = 'y'.repeat(5000); - const result = uwtNormalizeError(error, { - ...UWT_DEFAULT_REDACT_CONFIG, - maxStackLength: 100 - }); - expect(result?.stack?.length).toBe(101); - }); + it('should return what the core returns', () => { + const config = { ...core.UWT_DEFAULT_REDACT_CONFIG, maxKeys: 2 }; + const metadata = { url: 'https://x.dev/p?token=1', password: 'x', n: 1 }; + const error = new Error('at https://x.dev/?q=1'); - it('should scrub URLs out of the error message', () => { - const result = uwtNormalizeError( - new Error('GET https://api.dev/me?access_token=xyz returned 401') + expect(uwtScrubString('https://x.dev/p?q=1', config)).toBe( + core.uwtScrubString('https://x.dev/p?q=1', config) ); - expect(result?.message).toBe('GET https://api.dev/me returned 401'); - }); - - it('should accept a thrown string', () => { - expect(uwtNormalizeError('plain failure')).toEqual({ - name: 'Error', - message: 'plain failure' - }); - }); - - it('should extract name and message from an HttpErrorResponse-shaped object', () => { - const httpError = { - name: 'HttpErrorResponse', - message: 'Http failure response for /api/customers: 404 Not Found', - status: 404, - statusText: 'Not Found', - url: '/api/customers', - ok: false, - error: { secret: 'raw response body — must never appear in output' } - }; - - const result = uwtNormalizeError(httpError); - - expect(result).toEqual({ - name: 'HttpErrorResponse', - message: 'Http failure response for /api/customers: 404 Not Found' - }); - expect(JSON.stringify(result)).not.toContain('raw response body'); - }); - - it('should fall back to a generic name when the HTTP-error-shaped object has none', () => { - const result = uwtNormalizeError({ - message: 'Http failure response for /api/x: 500 Internal Server Error', - status: 500, - statusText: 'Internal Server Error' - }); - - expect(result?.name).toBe('HttpErrorResponse'); - }); - - it('should not treat an arbitrary object with a status-shaped key as an HTTP error', () => { - expect( - uwtNormalizeError({ status: 404, message: 'unrelated object' }) - ).toEqual({ - name: 'UnknownError', - message: UWT_REDACTED - }); - }); - - it('should report the type of a thrown object without serializing it', () => { - expect(uwtNormalizeError({ password: 'hunter2' })).toEqual({ - name: 'UnknownError', - message: UWT_REDACTED - }); - }); - - it.each([[null], [undefined]])('should return undefined for %p', (input) => { - expect(uwtNormalizeError(input)).toBeUndefined(); - }); -}); - -describe('uwtRedactConfigFor', () => { - it('should return the same config unchanged when enabled', () => { - expect(uwtRedactConfigFor(UWT_DEFAULT_REDACT_CONFIG, true)).toBe( - UWT_DEFAULT_REDACT_CONFIG + expect(uwtRedactMetadata(metadata, config)).toEqual( + core.uwtRedactMetadata(metadata, config) ); - }); - - it('should turn off PII scrubbing when disabled', () => { - const config = uwtRedactConfigFor( - { - ...UWT_DEFAULT_REDACT_CONFIG, - extraKeyPatterns: [/x-internal/], - scanValuePatterns: ['email'], - extraValuePatterns: [/secret-\d+/] - }, - false + expect(uwtRedactMetadata(metadata)).toEqual( + core.uwtRedactMetadata(metadata) ); - - expect(config.extraKeyPatterns).toEqual([]); - expect(config.stripUrlQuery).toBe(false); - expect(config.scanValuePatterns).toEqual([]); - expect(config.extraValuePatterns).toEqual([]); + expect(uwtNormalizeError(error)).toEqual(core.uwtNormalizeError(error)); }); - it('should keep size caps, error normalization inputs, and extraValueTransforms when disabled', () => { - const transform = (value: string) => value; - const config = uwtRedactConfigFor( + it('should warn on the console, prefixed, in development mode', () => { + uwtScrubString('unchanged', throwingTransform); + uwtRedactMetadata( + { a: 1, b: 2, c: 3 }, { - ...UWT_DEFAULT_REDACT_CONFIG, - maxStringLength: 10, - maxKeys: 5, - maxStackLength: 100, - includeStack: true, - extraValueTransforms: [transform] - }, - false + ...core.UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 2 + } ); - - expect(config.maxStringLength).toBe(10); - expect(config.maxKeys).toBe(5); - expect(config.maxStackLength).toBe(100); - expect(config.includeStack).toBe(true); - expect(config.extraValueTransforms).toEqual([transform]); - }); - - it('should still redact a built-in denylisted key when disabled — it is a safety floor, not a privacy opt-in', () => { - const disabled = uwtRedactConfigFor(UWT_DEFAULT_REDACT_CONFIG, false); - - expect(uwtRedactMetadata({ password: 'hunter2' }, disabled)).toEqual({ - password: UWT_REDACTED - }); - }); - - it('should not redact an extraKeyPatterns-only key when disabled', () => { - const disabled = uwtRedactConfigFor( - { ...UWT_DEFAULT_REDACT_CONFIG, extraKeyPatterns: [/x-internal/] }, - false + // An error's message and its stack are each scrubbed. + uwtNormalizeError(new Error('boom'), throwingTransform); + + expect(consoleWarn.mock.calls).toEqual([ + [ + '[ngx-ui-watchtower] An extraValueTransforms function threw and was skipped', + expect.any(Error) + ], + [ + '[ngx-ui-watchtower] Metadata exceeded maxKeys (2) and was truncated. Attributes past the limit were dropped silently in production.' + ], + [ + '[ngx-ui-watchtower] An extraValueTransforms function threw and was skipped', + expect.any(Error) + ], + [ + '[ngx-ui-watchtower] An extraValueTransforms function threw and was skipped', + expect.any(Error) + ] + ]); + }); + + it('should stay silent in production mode', () => { + isDevModeMock.mockReturnValue(false); + + uwtScrubString('unchanged', throwingTransform); + uwtRedactMetadata( + { a: 1, b: 2, c: 3 }, + { + ...core.UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 2 + } ); - expect(uwtRedactMetadata({ 'x-internal-id': 'abc' }, disabled)).toEqual({ - 'x-internal-id': 'abc' - }); + expect(consoleWarn).not.toHaveBeenCalled(); }); }); diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts index d5097b3..3a8c662 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts +++ b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts @@ -1,264 +1,18 @@ import { + UWT_DEFAULT_REDACT_CONFIG, + uwtNormalizeError as coreNormalizeError, + UwtRedactConfig, + uwtRedactMetadata as coreRedactMetadata, + uwtScrubString as coreScrubString, UwtTelemetryError, UwtTelemetryMetadata -} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; -import { uwtIsDevMode } from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +} from '@absaoss-cps/ui-watchtower'; +import { uwtDevModeReporter } from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -/** - * Named, built-in value-content PII shapes {@link UwtRedactConfig.scanValuePatterns} - * can enable. - * - * @group Types - */ -export type UwtPiiValuePattern = - 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'; - -/** - * Tunables governing what telemetry is allowed to carry off the device. - * - * @group Interfaces - */ -export interface UwtRedactConfig { - /** - * Additional key patterns to redact, merged with the built-in denylist. - * Matched against the attribute key, case-insensitively. - */ - extraKeyPatterns: RegExp[]; - - /** Maximum length of any single string value before truncation. */ - maxStringLength: number; - - /** Maximum number of attributes retained on one payload. */ - maxKeys: number; - - /** Maximum length of a captured stack trace. */ - maxStackLength: number; - - /** Whether stack traces are captured at all. */ - includeStack: boolean; - - /** Whether URL query strings and fragments are stripped from string values. */ - stripUrlQuery: boolean; - - /** - * Value-content PII shapes to scan for, in addition to the key denylist. - * Off by default. Heuristic regexes, not certified detectors — `'phone'` - * is the noisiest; `'creditCard'` also requires a Luhn match. - */ - scanValuePatterns: UwtPiiValuePattern[]; - - /** - * Additional value-content regexes, merged with `scanValuePatterns` — the - * content counterpart to `extraKeyPatterns`. - */ - extraValuePatterns: RegExp[]; - - /** - * Application-supplied functions run on every string value, after all - * pattern-based scrubbing — an escape hatch for logic no regex can - * express. Runs independently of `scanValuePatterns`/`extraValuePatterns`. - * A throwing function is skipped (logged in dev mode), not fatal. - */ - extraValueTransforms: ((value: string) => string)[]; -} - -/** Keys whose values never leave the browser, matched case-insensitively (`Authorization`, `access_token`, `apiKey`, …). */ -const DENYLIST = - /pass(word|wd)?|secret|token|auth|credential|cookie|api[-_]?key|bearer|jwt|signature|session[-_]?key|ssn/i; - -/** Absolute http(s) URLs appearing anywhere inside a string. */ -const URL_PATTERN = /https?:\/\/[^\s"'<>]+/gi; - -/** - * A root-relative path with a query string or fragment. The lookbehind - * rejects a `/` that's mid-word (so this doesn't re-match inside an - * already-scrubbed URL) without requiring whitespace before the path — - * punctuation like `:` counts as a boundary too. - */ -const PATH_WITH_QUERY = /(?, - RegExp -> = { - // Common email shape, not full RFC 5322. - email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, - // Matches only the dashed `123-45-6789` format; a bare 9-digit run isn't - // reliably an SSN. - ssn: /\b\d{3}-\d{2}-\d{4}\b/g, - ipv4: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, - // NANP (US/Canada): 3-3-4 digits, optional `1`/`+1` country code, - // South Africa (27), Botswana (267), Ghana (233), Kenya - // (254), Mauritius (230), Mozambique (258), Namibia (264), Seychelles - // (248), Tanzania (255), Uganda (256), Zambia (260). - phone: - /(?:(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}|(?:\+|00)(?:27|267|233|254|230|258|264|248|255|256|260)(?:[-.\s]?\d){7,9})\b/g -}; - -/** - * Fixed order for {@link UwtRedactConfig.scanValuePatterns}, independent of - * the consumer's array order. `creditCard` must run before `phone`: phone - * has no leading `\b` and can match a trailing substring of a card number, - * fragmenting and leaking part of it if it ran first. - */ -const VALUE_PATTERN_ORDER: UwtPiiValuePattern[] = [ - 'email', - 'creditCard', - 'ssn', - 'ipv4', - 'phone' -]; - -/** - * Payment card number length, per ISO/IEC 7812 — 13 digits at the short - * end (older Visa) through 19 at the long (Maestro and co.). - */ -const CARD_MIN_DIGITS = 13; -const CARD_MAX_DIGITS = 19; - -/** - * Candidate digit runs, allowing the separators people actually type. - * Built from the bounds above so the pattern and the length check below - * cannot drift apart. - */ -const CREDIT_CARD_CANDIDATE = new RegExp( - `\\b(?:\\d[ -]?){${CARD_MIN_DIGITS},${CARD_MAX_DIGITS}}\\b`, - 'g' -); - -/** The standard Luhn checksum — tells a real card number from an unrelated digit run of the same length. */ -function isLuhnValid(digits: string): boolean { - let sum = 0; - let alternate = false; - - for (let i = digits.length - 1; i >= 0; i--) { - let n = digits.charCodeAt(i) - 48; // '0' - if (alternate) { - n *= 2; - if (n > 9) { - n -= 9; - } - } - sum += n; - alternate = !alternate; - } - - return digits.length > 0 && sum % 10 === 0; -} - -/** Redacts credit-card-shaped digit runs that also pass a Luhn checksum. */ -function scrubCreditCards(value: string): string { - return value.replace(CREDIT_CARD_CANDIDATE, (match) => { - const digits = match.replace(/\D/g, ''); - return digits.length >= CARD_MIN_DIGITS && - digits.length <= CARD_MAX_DIGITS && - isLuhnValid(digits) - ? UWT_REDACTED - : match; - }); -} - -/** - * Memoizes the global copy `ensureGlobal` builds for a non-global pattern — - * config-level regexes are stable across many calls, so without this a - * fresh `RegExp` would be compiled on every scrubbed string, for the life - * of the page. Safe to reuse: every use is via `.replace()`, which resets - * `lastIndex` itself before scanning. - */ -const globalPatternCache = new WeakMap(); - -/** Ensures a pattern has the `g` flag, so `.replace()` catches every occurrence, not just the first. */ -function ensureGlobal(pattern: RegExp): RegExp { - if (pattern.global) { - return pattern; - } - - let global = globalPatternCache.get(pattern); - if (!global) { - global = new RegExp(pattern.source, `${pattern.flags}g`); - globalPatternCache.set(pattern, global); - } - return global; -} - -/** - * Default redaction settings. Conservative; widen as needed. - * - * @group Utils - */ -export const UWT_DEFAULT_REDACT_CONFIG: UwtRedactConfig = { - extraKeyPatterns: [], - maxStringLength: 1024, - maxKeys: 50, - maxStackLength: 2048, - includeStack: true, - stripUrlQuery: true, - scanValuePatterns: [], - extraValuePatterns: [], - extraValueTransforms: [] -}; - -/** - * Effective redact config for a concern's own `redact: boolean` toggle - * (`UwtLogConfig.redact`, `UwtScenarioTelemetryConfig.redact`, `UwtBITelemetryConfig.redact`). - * Disabling it skips only the configurable scrubbing — the built-in - * credential denylist, size caps, error normalization and - * `extraValueTransforms` stay on regardless. - * - * @param config the concern's resolved `UWT_REDACT_CONFIG` - * @param enabled the concern's own `redact` setting - * @returns `config` unchanged when enabled, otherwise with configurable - * scrubbing turned off - * - * @group Utils - */ -export function uwtRedactConfigFor( - config: UwtRedactConfig, - enabled: boolean -): UwtRedactConfig { - if (enabled) { - return config; - } - - return { - ...config, - extraKeyPatterns: [], - stripUrlQuery: false, - scanValuePatterns: [], - extraValuePatterns: [] - }; -} - -function isDenied(key: string, config: UwtRedactConfig): boolean { - if (DENYLIST.test(key)) { - return true; - } - return config.extraKeyPatterns.some((pattern) => { - // A global/sticky pattern is stateful: .test() advances lastIndex, so - // reset it to avoid alternating matches across calls. - pattern.lastIndex = 0; - return pattern.test(key); - }); -} - -/** Reduces a URL to `origin + pathname` — tokens and identifiers tend to live in the query/fragment being dropped. */ -function scrubUrl(url: string): string { - try { - const parsed = new URL(url); - return `${parsed.origin}${parsed.pathname}`; - } catch { - const cut = url.search(/[?#]/); - return cut === -1 ? url : url.slice(0, cut); - } -} /** * Strips URL query strings/fragments, scans for opted-in PII shapes, runs @@ -272,46 +26,7 @@ function scrubUrl(url: string): string { * @group Utils */ export function uwtScrubString(value: string, config: UwtRedactConfig): string { - let result = value; - - if (config.stripUrlQuery) { - result = result.replace(URL_PATTERN, (match) => scrubUrl(match)); - result = result.replace(PATH_WITH_QUERY, (match) => scrubUrl(match)); - } - - if (config.scanValuePatterns.length || config.extraValuePatterns.length) { - for (const name of VALUE_PATTERN_ORDER) { - if (!config.scanValuePatterns.includes(name)) { - continue; - } - result = - name === 'creditCard' - ? scrubCreditCards(result) - : result.replace(VALUE_PATTERNS[name], UWT_REDACTED); - } - for (const pattern of config.extraValuePatterns) { - result = result.replace(ensureGlobal(pattern), UWT_REDACTED); - } - } - - for (const transform of config.extraValueTransforms) { - try { - result = transform(result); - } catch (error) { - if (uwtIsDevMode()) { - console.warn( - '[ngx-ui-watchtower] An extraValueTransforms function threw and was skipped', - error - ); - } - } - } - - if (result.length > config.maxStringLength) { - result = `${result.slice(0, config.maxStringLength)}…`; - } - - return result; + return coreScrubString(value, config, uwtDevModeReporter); } /** @@ -331,98 +46,7 @@ export function uwtRedactMetadata( value: unknown, config: UwtRedactConfig = UWT_DEFAULT_REDACT_CONFIG ): UwtTelemetryMetadata | undefined { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return undefined; - } - - const result: UwtTelemetryMetadata = {}; - let kept = 0; - let truncated = false; - - for (const [key, raw] of Object.entries(value)) { - if (kept >= config.maxKeys) { - truncated = true; - break; - } - - if (isDenied(key, config)) { - result[key] = UWT_REDACTED; - kept++; - continue; - } - - if (raw === null || typeof raw === 'boolean') { - result[key] = raw; - kept++; - } else if (typeof raw === 'number') { - if (Number.isFinite(raw)) { - result[key] = raw; - kept++; - } - } else if (typeof raw === 'string') { - result[key] = uwtScrubString(raw, config); - kept++; - } - } - - if (truncated && uwtIsDevMode()) { - console.warn( - `[ngx-ui-watchtower] Metadata exceeded maxKeys (${config.maxKeys}) and was truncated. Attributes past the limit were dropped silently in production.` - ); - } - - return kept > 0 ? result : undefined; -} - -/** - * Merges an already-redacted attribute bag into an existing one, - * re-applying `maxKeys` to the *combined* result. `uwtRedactMetadata` only - * bounds the bag passed to it in isolation — merging two independently - * capped bags without this can exceed the configured limit. A key already - * present in `target` is always updated (a replacement, not growth); only - * a genuinely new key counts against the cap, and is dropped once it's - * reached (warns once in dev mode, mirroring `uwtRedactMetadata`). - * - * Mutates and returns `target`. - * - * @param target the accumulator merged into, and returned - * @param incoming the bag being merged in, normally the output of {@link uwtRedactMetadata} - * @param config redaction settings - * @returns `target`, with `incoming` merged in - * - * @group Utils - */ -export function uwtMergeMetadata( - target: UwtTelemetryMetadata, - incoming: UwtTelemetryMetadata | undefined, - config: UwtRedactConfig = UWT_DEFAULT_REDACT_CONFIG -): UwtTelemetryMetadata { - if (!incoming) { - return target; - } - - let keyCount = Object.keys(target).length; - let truncated = false; - - for (const [key, value] of Object.entries(incoming)) { - const isNewKey = !(key in target); - if (isNewKey && keyCount >= config.maxKeys) { - truncated = true; - continue; - } - if (isNewKey) { - keyCount++; - } - target[key] = value; - } - - if (truncated && uwtIsDevMode()) { - console.warn( - `[ngx-ui-watchtower] Combined metadata exceeded maxKeys (${config.maxKeys}) and was truncated. Attributes past the limit were dropped silently in production.` - ); - } - - return target; + return coreRedactMetadata(value, config, uwtDevModeReporter); } /** @@ -440,55 +64,5 @@ export function uwtNormalizeError( error: unknown, config: UwtRedactConfig = UWT_DEFAULT_REDACT_CONFIG ): UwtTelemetryError | undefined { - if (error === null || error === undefined) { - return undefined; - } - - if (error instanceof Error) { - const normalized: UwtTelemetryError = { - name: error.name || 'Error', - message: uwtScrubString(error.message ?? '', config) - }; - - if (config.includeStack && typeof error.stack === 'string') { - normalized.stack = uwtScrubString(error.stack, { - ...config, - maxStringLength: config.maxStackLength - }); - } - - return normalized; - } - - if (typeof error === 'string') { - return { name: 'Error', message: uwtScrubString(error, config) }; - } - - if (isHttpErrorShaped(error)) { - // HttpErrorResponse doesn't extend Error, so instanceof above misses it. - return { - name: typeof error.name === 'string' ? error.name : 'HttpErrorResponse', - message: uwtScrubString(error.message, config) - }; - } - - return { - name: typeof error === 'object' ? 'UnknownError' : typeof error, - message: UWT_REDACTED - }; -} - -/** Duck-types an `HttpErrorResponse`-shaped object, avoiding an `@angular/common/http` import (and its runtime) just for this check. */ -function isHttpErrorShaped( - error: unknown -): error is { name?: unknown; message: string; status: number } { - if (error === null || typeof error !== 'object') { - return false; - } - const candidate = error as Record; - return ( - typeof candidate.message === 'string' && - typeof candidate.status === 'number' && - ('statusText' in candidate || 'url' in candidate) - ); + return coreNormalizeError(error, config, uwtDevModeReporter); } diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts index 78adfbb..0e6af00 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts @@ -1,13 +1,9 @@ import { isDevMode } from '@angular/core'; +import type {} from '@absaoss-cps/ui-watchtower'; import { - uwtDeepClone, - uwtElapsedNow, - uwtEpochToPerf, - uwtNow, uwtSafe, uwtSafeVoid, - uwtSafeVoidMaybeAsync, - uwtUuid + uwtSafeVoidMaybeAsync } from './uwt-telemetry-safe.util'; jest.mock('@angular/core', () => ({ @@ -195,171 +191,3 @@ describe('uwtSafeVoidMaybeAsync', () => { expect(unhandled).not.toHaveBeenCalled(); }); }); - -describe('uwtDeepClone', () => { - it('should produce a deep copy that mutation of the original does not affect', () => { - const original = { a: 1, nested: { b: [1, 2, 3] } }; - const clone = uwtDeepClone(original); - - original.nested.b.push(4); - - expect(clone).toEqual({ a: 1, nested: { b: [1, 2, 3] } }); - expect(clone).not.toBe(original); - expect(clone.nested).not.toBe(original.nested); - }); - - it('should fall back to a JSON round-trip when structuredClone is unavailable', () => { - const realStructuredClone = globalThis.structuredClone; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).structuredClone = undefined; - - try { - expect(uwtDeepClone({ a: 1, nested: { b: 2 } })).toEqual({ - a: 1, - nested: { b: 2 } - }); - } finally { - globalThis.structuredClone = realStructuredClone; - } - }); - - it('should fall back to a JSON round-trip when structuredClone throws', () => { - const realStructuredClone = globalThis.structuredClone; - globalThis.structuredClone = () => { - throw new Error('cannot clone'); - }; - - try { - expect(uwtDeepClone({ a: 1 })).toEqual({ a: 1 }); - } finally { - globalThis.structuredClone = realStructuredClone; - } - }); -}); - -describe('uwtUuid', () => { - const realCrypto = globalThis.crypto; - - afterEach(() => { - Object.defineProperty(globalThis, 'crypto', { - value: realCrypto, - configurable: true - }); - }); - - const useCrypto = (value: unknown) => - Object.defineProperty(globalThis, 'crypto', { - value, - configurable: true - }); - - it('should produce a UUID-shaped identifier', () => { - expect(uwtUuid()).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - ); - }); - - it('should produce distinct identifiers', () => { - const ids = new Set(Array.from({ length: 200 }, () => uwtUuid())); - expect(ids.size).toBe(200); - }); - - it('should fall back to getRandomValues when randomUUID is unavailable', () => { - useCrypto({ - getRandomValues: (array: Uint8Array) => { - for (let i = 0; i < array.length; i++) { - array[i] = i; - } - return array; - } - }); - - expect(uwtUuid()).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - ); - }); - - it('should fall back to Math.random when the Web Crypto API is absent', () => { - useCrypto(undefined); - - expect(uwtUuid()).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - ); - }); -}); - -describe('uwtNow', () => { - it('should return a number that does not go backwards', () => { - const first = uwtNow(); - const second = uwtNow(); - expect(typeof first).toBe('number'); - expect(second).toBeGreaterThanOrEqual(first); - }); -}); - -describe('uwtElapsedNow', () => { - it('should return a number that does not go backwards', () => { - const first = uwtElapsedNow(); - const second = uwtElapsedNow(); - expect(typeof first).toBe('number'); - expect(second).toBeGreaterThanOrEqual(first); - }); - - it('should read the same object jsdom exposes as globalThis.top', () => { - expect(globalThis.top).toBe(globalThis); - }); -}); - -describe('uwtEpochToPerf', () => { - const realPerformance = globalThis.performance; - - afterEach(() => { - Object.defineProperty(globalThis, 'performance', { - value: realPerformance, - configurable: true - }); - }); - - function usePerformance(timeOrigin: number, now: number) { - Object.defineProperty(globalThis, 'performance', { - value: { timeOrigin, now: () => now }, - configurable: true - }); - } - - it('should convert an epoch timestamp into the performance timeline', () => { - usePerformance(1_000_000, 5_000); - expect(uwtEpochToPerf(1_002_000)).toBe(2_000); - }); - - it('should accept the exact page origin', () => { - usePerformance(1_000_000, 5_000); - expect(uwtEpochToPerf(1_000_000)).toBe(0); - }); - - it('should reject a timestamp from before the page loaded', () => { - usePerformance(1_000_000, 5_000); - expect(uwtEpochToPerf(999_000)).toBeUndefined(); - }); - - it('should reject a timestamp in the future', () => { - usePerformance(1_000_000, 5_000); - expect(uwtEpochToPerf(1_010_000)).toBeUndefined(); - }); - - it.each([[NaN], [Infinity], [-Infinity]])( - 'should reject the non-finite input %p', - (input) => { - usePerformance(1_000_000, 5_000); - expect(uwtEpochToPerf(input)).toBeUndefined(); - } - ); - - it('should return undefined without a usable performance object', () => { - Object.defineProperty(globalThis, 'performance', { - value: undefined, - configurable: true - }); - expect(uwtEpochToPerf(1_000)).toBeUndefined(); - }); -}); diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts index ffe0dd2..57dacc2 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts +++ b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts @@ -1,60 +1,10 @@ import { isPlatformBrowser } from '@angular/common'; import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; - -/** - * Runs a telemetry operation without letting it affect the application. - * - * Every public entry point in this library routes through this wrapper — - * it never rethrows. In development the caught error is reported to the - * console; in production nothing is printed. - */ -export function uwtSafe(operation: string, fn: () => T, fallback: T): T { - try { - return fn(); - } catch (error) { - reportSuppressed(operation, error); - return fallback; - } -} - -/** - * Void-returning form of {@link uwtSafe}, for the common case of an emit call - * whose result nobody reads. - */ -export function uwtSafeVoid(operation: string, fn: () => void): void { - uwtSafe(operation, fn, undefined); -} - -/** - * Like {@link uwtSafeVoid}, but also catches a rejection from a `void`-typed - * extension point whose real implementation turns out to be `async` — an - * application-supplied provider hook, for instance. TypeScript accepts an - * `async` function wherever `() => void` is expected and discards the - * promise, so a plain `try`/`catch` can't see it reject. This checks for a - * thenable result and attaches a rejection handler. - * - * Not the default for every call: most `uwtSafeVoid` calls wrap this - * library's own synchronous code, where the extra check is pure overhead. - * Use this only for a function whose shape this library merely declares. - */ -export function uwtSafeVoidMaybeAsync(operation: string, fn: () => void): void { - uwtSafe( - operation, - () => { - const result: unknown = fn(); - if ( - result !== null && - typeof result === 'object' && - typeof (result as { then?: unknown }).then === 'function' - ) { - Promise.resolve(result as PromiseLike).catch((error) => - reportSuppressed(operation, error) - ); - } - }, - undefined - ); -} +import { + UwtReporter, + uwtSafe as coreSafe, + uwtSafeVoidMaybeAsync as coreSafeVoidMaybeAsync +} from '@absaoss-cps/ui-watchtower'; /** `isDevMode()` throws outside an Angular context, so this guards that once for every caller. */ export function uwtIsDevMode(): boolean { @@ -75,130 +25,36 @@ export function uwtIsBrowser(): boolean { } /** - * Deep-clones a plain-data value. Prefers `structuredClone`; falls back to - * a JSON round-trip where it's unavailable (e.g. under jsdom) or throws. - */ -export function uwtDeepClone(value: T): T { - if (typeof structuredClone === 'function') { - try { - return structuredClone(value); - } catch { - // fall through to the JSON fallback - } - } - return JSON.parse(JSON.stringify(value)) as T; -} - -function reportSuppressed(operation: string, error: unknown): void { - if (!uwtIsDevMode()) { - return; - } - try { - console.error(`[ngx-ui-watchtower] ${operation} failed`, error); - } catch { - // A patched/throwing console must never escape telemetry suppression. - } -} - -/** - * 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. + * 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. */ -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; +export const uwtDevModeReporter: UwtReporter = (level, message, ...details) => { + if (uwtIsDevMode()) { + console[level](`[ngx-ui-watchtower] ${message}`, ...details); } - return bytes; -} - -/** Lowercase hex encoding, two characters per byte. */ -function toHex(bytes: Uint8Array): string { - return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); -} +}; /** - * Generates a correlation identifier. Prefers `crypto.randomUUID`, falling - * back to formatting random bytes as a v4 UUID. + * Runs a telemetry operation without letting it affect the application: + * the core's fail-open wrapper, reporting through {@link uwtDevModeReporter}. */ -export function uwtUuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); - } - - const bytes = randomBytes(16); - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 - - const hex = toHex(bytes); - return [ - hex.slice(0, 8), - hex.slice(8, 12), - hex.slice(12, 16), - hex.slice(16, 20), - hex.slice(20) - ].join('-'); -} - -/** - * Monotonic-ish millisecond clock for duration measurement. - * `performance.now()` is preferred since it's immune to wall-clock - * adjustments. - */ -export function uwtNow(): number { - const perf = globalThis.performance; - return typeof perf?.now === 'function' ? perf.now() : Date.now(); +export function uwtSafe(operation: string, fn: () => T, fallback: T): T { + return coreSafe(operation, fn, fallback, uwtDevModeReporter); } /** - * Milliseconds since the *host* page loaded — prefers `top.performance.now()` - * over the local realm's own, the same reasoning `uwt-user-timings-internal.util.ts` - * applies to marks/measures: in a composed page, a fragment runs in its own - * same-origin iframe with a later `timeOrigin`, so a timeline position taken - * from its own `performance` doesn't line up against the shell's. Reading - * `top` always succeeds, even cross-origin; only touching a property on it - * can throw, in which case this falls back to {@link uwtNow}. + * Void-returning form of {@link uwtSafe}, for the common case of an emit call + * whose result nobody reads. */ -export function uwtElapsedNow(): number { - try { - const top = globalThis.top; - if (typeof top?.performance?.now === 'function') { - return top.performance.now(); - } - } catch { - // cross-origin, or sandboxed without `allow-same-origin` - } - return uwtNow(); +export function uwtSafeVoid(operation: string, fn: () => void): void { + coreSafe(operation, fn, undefined, uwtDevModeReporter); } /** - * Converts an epoch timestamp into the `performance.now()` timeline. - * Clamped to the current page's lifetime — a future timestamp, or one from - * before the page loaded, yields `undefined`. + * Like {@link uwtSafeVoid}, but also catches a rejection from a `void`-typed + * extension point whose real implementation turns out to be `async`. */ -export function uwtEpochToPerf(epochMs: number): number | undefined { - if (!Number.isFinite(epochMs)) { - return undefined; - } - - const perf = globalThis.performance; - if (typeof perf?.now !== 'function' || typeof perf.timeOrigin !== 'number') { - return undefined; - } - - const converted = epochMs - perf.timeOrigin; - const now = perf.now(); - - if (converted < 0 || converted > now) { - return undefined; - } - - return converted; +export function uwtSafeVoidMaybeAsync(operation: string, fn: () => void): void { + coreSafeVoidMaybeAsync(operation, fn, uwtDevModeReporter); } diff --git a/projects/ngx-ui-watchtower/src/public-api.ts b/projects/ngx-ui-watchtower/src/public-api.ts index 5219755..f9078e4 100644 --- a/projects/ngx-ui-watchtower/src/public-api.ts +++ b/projects/ngx-ui-watchtower/src/public-api.ts @@ -7,18 +7,20 @@ // Configuration export { - UWT_DEFAULT_TELEMETRY_CONFIG, UWT_REDACT_CONFIG, UWT_TELEMETRY_ENABLED, UWT_TELEMETRY_IDENTITY } from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; -export type { UwtTelemetryIdentity } from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +export { UWT_DEFAULT_TELEMETRY_CONFIG } from '@absaoss-cps/ui-watchtower'; +export type { + UwtBITelemetryConfig, + UwtLogConfig, + UwtScenarioTelemetryConfig, + UwtTelemetryIdentity +} from '@absaoss-cps/ui-watchtower'; export { UWT_BI_TELEMETRY_CONFIG } from './lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; -export type { UwtBITelemetryConfig } from './lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; export { UWT_LOG_CONFIG } from './lib/config/uwt-log.config/uwt-log.config'; -export type { UwtLogConfig } from './lib/config/uwt-log.config/uwt-log.config'; export { UWT_SCENARIO_TELEMETRY_CONFIG } from './lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; -export type { UwtScenarioTelemetryConfig } from './lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; // Providers export { @@ -35,21 +37,23 @@ export { withScenarios, withTelemetryEnabled } from './lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; -export { - UWT_LOG_API_PROVIDER, +export { UWT_LOG_API_PROVIDER } from './lib/providers/uwt-log-api.provider/uwt-log-api.provider'; +export type { UwtLogApiProvider, UwtLogQuery -} from './lib/providers/uwt-log-api.provider/uwt-log-api.provider'; +} from '@absaoss-cps/ui-watchtower'; export { UwtNoopLogApiProvider } from './lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider'; export { UwtBroadcastLogApiProvider } from './lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; -// Models +// Models, and the name registries an application augments: declared in +// the core and re-exported, so augmenting them on this package's module path +// reaches the same registries the core's own APIs use. export type { UwtBIEvent, UwtBIEventDetail, UwtBIEventName, UwtBIEventNames -} from './lib/models/uwt-bi.models/uwt-bi.models'; +} from '@absaoss-cps/ui-watchtower'; export type { UwtLogger, UwtLoggerName, @@ -57,8 +61,8 @@ export type { UwtLogDetail, UwtLogLevel, UwtLogRecord -} from './lib/models/uwt-log.models/uwt-log.models'; -export { UWT_LOG_LEVEL_ORDER } from './lib/models/uwt-log.models/uwt-log.models'; +} from '@absaoss-cps/ui-watchtower'; +export { UWT_LOG_LEVEL_ORDER } from '@absaoss-cps/ui-watchtower'; export type { UwtScenarioAggregate, UwtScenarioName, @@ -73,23 +77,23 @@ export type { UwtScenarioStepStatus, UwtScenarioSteps, UwtStepName -} from './lib/models/uwt-scenario.models/uwt-scenario.models'; +} from '@absaoss-cps/ui-watchtower'; export { UWT_DEFAULT_EVENT_NAMESPACE, UWT_TELEMETRY_EVENT_TYPE, uwtEventTypes -} from './lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +} from '@absaoss-cps/ui-watchtower'; export type { UwtTelemetryAttribution, UwtTelemetryError, UwtTelemetryEventTypes, UwtTelemetryMetadata -} from './lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +} from '@absaoss-cps/ui-watchtower'; // Services export { UwtBITelemetryService } from './lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service'; export { UwtLoggerService } from './lib/services/uwt-logger.service/uwt-logger.service'; -export { UwtScenario } from './lib/scenario/uwt-scenario/uwt-scenario'; +export { UwtScenario } from '@absaoss-cps/ui-watchtower'; export { UwtScenarioTelemetryService } from './lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; // Sinks @@ -99,25 +103,28 @@ export { UWT_BROADCAST_CHANNEL, UWT_DEFAULT_BROADCAST_CHANNEL } from './lib/sinks/uwt-broadcast/uwt-broadcast.messages'; -export { UwtTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +export { UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; export { UwtNoopTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; // Utilities an application or a custom sink needs export { traceScenario } from './lib/scenario/uwt-scenario-operators/uwt-scenario-operators'; -export { uwtClassifyTelemetryEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; -export type { UwtTelemetrySinkEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; +export { uwtClassifyTelemetryEvent } from '@absaoss-cps/ui-watchtower'; +export type { UwtTelemetrySinkEvent } from '@absaoss-cps/ui-watchtower'; export type { UwtTraceScenarioOptions } from './lib/scenario/uwt-scenario-operators/uwt-scenario-operators'; -export { uwtIsDebugEnabled } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; -export type { UwtDebugFlag } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; -export { uwtUuid } from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +export { uwtIsDebugEnabled, uwtUuid } from '@absaoss-cps/ui-watchtower'; +export type { UwtDebugFlag } from '@absaoss-cps/ui-watchtower'; export { uwtMarkSessionStart } from '@absaoss-cps/ui-watchtower'; export { UWT_DEFAULT_REDACT_CONFIG, UWT_REDACTED, + uwtRedactConfigFor +} from '@absaoss-cps/ui-watchtower'; +export type { UwtPiiValuePattern, - UwtRedactConfig, + UwtRedactConfig +} from '@absaoss-cps/ui-watchtower'; +export { uwtNormalizeError, - uwtRedactConfigFor, uwtRedactMetadata, uwtScrubString } from './lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md index 2ea939a..a58f076 100644 --- a/projects/ui-watchtower/README.md +++ b/projects/ui-watchtower/README.md @@ -6,8 +6,9 @@ scenario health telemetry, business/UX events and application logs, for any JavaScript or TypeScript application. > **Work in progress.** The engine is moving here from the Angular package in -> stages. Until it has, use `@absaoss-cps/ngx-ui-watchtower`; it installs this -> package by itself. +> stages: the models, scenarios, redaction and contracts are here; the engine +> that wires them together is next. Until it lands, use +> `@absaoss-cps/ngx-ui-watchtower`; it installs this package by itself. It imports no framework and touches no browser global when imported, so it loads in Node, on a server and in a worker as well as in a browser. @@ -18,11 +19,59 @@ loads in Node, on a server and in a worker as well as in a browser. npm install @absaoss-cps/ui-watchtower ``` +## Declare your names + +Scenario, step, event and logger names are typed, so a typo is a compile +error instead of a broken dashboard. Declare them once: + +```ts +// src/telemetry.schema.ts +declare module '@absaoss-cps/ui-watchtower' { + interface UwtScenarioNames { + 'load-customers': true; + } + interface UwtScenarioSteps { + fetch: true; + } + interface UwtBIEventNames { + export_clicked: true; + } + interface UwtLoggerNames { + checkout: true; + } +} + +export {}; +``` + +Until you declare any, plain strings are accepted. An Angular application +augments `@absaoss-cps/ngx-ui-watchtower` instead, which re-exports these +registries: either module path reaches the same ones. + +## Seeing what telemetry skips + +Telemetry never throws into your application, and the engine prints nothing +by itself. Hand it a `UwtReporter` where you want to see what it skipped or +dropped, typically in development: + +```ts +import { UwtReporter } from '@absaoss-cps/ui-watchtower'; + +const report: UwtReporter = (level, message, ...details) => + console[level](`[my-app] ${message}`, ...details); +``` + ## API -| Export | What it does | -| -------------------------------------- | ---------------------------------------------------------------------- | -| `uwtMarkSessionStart(sessionId, now?)` | Records when a session started, for the destination that owns sessions | +| Group | Exports | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Configuration | `UwtTelemetryIdentity`, `UwtScenarioTelemetryConfig`, `UwtLogConfig`, `UwtBITelemetryConfig`, `UWT_DEFAULT_TELEMETRY_CONFIG` | +| Models | scenario, step, BI event and log record types; `UWT_LOG_LEVEL_ORDER`, `UWT_TELEMETRY_EVENT_TYPE`, `uwtEventTypes` | +| Names | `UwtScenarioNames`, `UwtScenarioSteps`, `UwtBIEventNames`, `UwtLoggerNames` and their `Uwt*Name` types | +| Scenarios | `UwtScenario` | +| Contracts | `UwtTelemetrySink` (a destination), `UwtLogApiProvider` and `UwtLogQuery` (a log backend) | +| Redaction | `uwtRedactMetadata`, `uwtScrubString`, `uwtNormalizeError`, `uwtRedactConfigFor`, `UwtRedactConfig`, `UWT_DEFAULT_REDACT_CONFIG`, `UWT_REDACTED` | +| Utilities | `uwtClassifyTelemetryEvent`, `uwtIsDebugEnabled`, `uwtMarkSessionStart`, `uwtUuid`, `UwtReporter` | ## License diff --git a/projects/ui-watchtower/public-api.snapshot.json b/projects/ui-watchtower/public-api.snapshot.json index 48abe79..1989156 100644 --- a/projects/ui-watchtower/public-api.snapshot.json +++ b/projects/ui-watchtower/public-api.snapshot.json @@ -1,10 +1,358 @@ { "@absaoss-cps/ui-watchtower": { + "UWT_DEFAULT_EVENT_NAMESPACE": [ + "const: 'com.uwt'" + ], + "UWT_DEFAULT_REDACT_CONFIG": [ + "const: UwtRedactConfig" + ], + "UWT_DEFAULT_TELEMETRY_CONFIG": [ + "const: { eventNamespace: string; scenario: UwtScenarioTelemetryConfig; logs: UwtLogConfig; bi: UwtBITelemetryConfig; redact: UwtRedactConfig; }" + ], + "UWT_LOG_LEVEL_ORDER": [ + "const: Record" + ], + "UWT_REDACTED": [ + "const: '[redacted]'" + ], + "UWT_TELEMETRY_EVENT_TYPE": [ + "const: UwtTelemetryEventTypes" + ], + "UwtBIEvent": [ + "interface", + "application: string", + "eventName: string", + "eventTime: string", + "feature?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "scenarioId?: string | undefined", + "sessionId?: string | undefined", + "userId?: string | undefined" + ], + "UwtBIEventDetail": [ + "interface", + "feature?: string | undefined", + "scenarioId?: string | undefined" + ], + "UwtBIEventName": [ + "type = string" + ], + "UwtBIEventNames": [ + "interface" + ], + "UwtBITelemetryConfig": [ + "interface", + "dedupMaxKeys: number", + "dedupWindowMs: number", + "redact: boolean" + ], + "uwtClassifyTelemetryEvent": [ + "function(eventType: string, payload: object): UwtTelemetrySinkEvent" + ], + "UwtDebugFlag": [ + "type = 'debugLogger' | 'debugScenario' | 'debugBI'" + ], + "uwtDebugWrite": [ + "function(flag: UwtDebugFlag, write: () => void, name?: string | undefined, report?: UwtReporter | undefined): void" + ], + "uwtDeepClone": [ + "function(value: T): T" + ], + "uwtEventTypes": [ + "function(namespace?: string | undefined): UwtTelemetryEventTypes" + ], + "uwtIsDebugEnabled": [ + "function(flag: UwtDebugFlag, name?: string | undefined): boolean" + ], + "UwtLogApiProvider": [ + "interface", + "flush?(): void", + "query(filter: UwtLogQuery): Promise", + "send(record: UwtLogRecord): void" + ], + "UwtLogConfig": [ + "interface", + "levels?: Partial> | undefined", + "minLevel: UwtLogLevel", + "mirrorErrorsToRum: boolean", + "redact: boolean" + ], + "UwtLogDetail": [ + "interface", + "context?: string | undefined", + "correlationId?: string | undefined", + "error?: unknown", + "metadata?: UwtTelemetryMetadata | undefined" + ], + "UwtLogger": [ + "interface", + "error(message: string, detail?: UwtLogDetail | undefined): void", + "log(message: string, detail?: UwtLogDetail | undefined): void", + "warn(message: string, detail?: UwtLogDetail | undefined): void" + ], + "UwtLoggerName": [ + "type = string" + ], + "UwtLoggerNames": [ + "interface" + ], + "UwtLogLevel": [ + "type = 'log' | 'warn' | 'error'" + ], + "UwtLogQuery": [ + "interface", + "correlationId?: string | undefined", + "from?: string | undefined", + "limit?: number | undefined", + "logger?: string | undefined", + "minLevel?: UwtLogLevel | undefined", + "to?: string | undefined" + ], + "UwtLogRecord": [ + "interface", + "application: string", + "context?: string | undefined", + "correlationId?: string | undefined", + "environment: string", + "error?: UwtTelemetryError | undefined", + "level: UwtLogLevel", + "logger?: string | undefined", + "message: string", + "metadata?: UwtTelemetryMetadata | undefined", + "sessionId?: string | undefined", + "timestamp: string", + "userId?: string | undefined", + "version: string" + ], "uwtMarkSessionStart": [ "function(sessionId: string, now?: number | undefined): void" ], + "uwtNormalizeError": [ + "function(error: unknown, config?: UwtRedactConfig | undefined, report?: UwtReporter | undefined): UwtTelemetryError | undefined" + ], + "uwtNow": [ + "function(): number" + ], + "UwtPiiValuePattern": [ + "type = 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'" + ], + "UwtRedactConfig": [ + "interface", + "extraKeyPatterns: RegExp[]", + "extraValuePatterns: RegExp[]", + "extraValueTransforms: ((value: string) => string)[]", + "includeStack: boolean", + "maxKeys: number", + "maxStackLength: number", + "maxStringLength: number", + "scanValuePatterns: UwtPiiValuePattern[]", + "stripUrlQuery: boolean" + ], + "uwtRedactConfigFor": [ + "function(config: UwtRedactConfig, enabled: boolean): UwtRedactConfig" + ], + "uwtRedactMetadata": [ + "function(value: unknown, config?: UwtRedactConfig | undefined, report?: UwtReporter | undefined): UwtTelemetryMetadata | undefined" + ], + "UwtReporter": [ + "type = (level: 'warn' | 'error', message: string, ...details: unknown[]) => void" + ], + "uwtSafe": [ + "function(operation: string, fn: () => T, fallback: T, report?: UwtReporter | undefined): T" + ], + "uwtSafeVoid": [ + "function(operation: string, fn: () => void, report?: UwtReporter | undefined): void" + ], + "uwtSafeVoidMaybeAsync": [ + "function(operation: string, fn: () => void, report?: UwtReporter | undefined): void" + ], + "UwtScenario": [ + "class", + "new (options: UwtScenarioOptions, deps: UwtScenarioDeps): UwtScenario", + "aggregateEnd(name: string): UwtScenario", + "aggregateStart(name: string): UwtScenario", + "cancel(outcome?: UwtScenarioOutcome | undefined): void", + "complete(outcome?: UwtScenarioOutcome | undefined): void", + "endStep(detail?: UwtScenarioStepDetail | undefined): UwtScenario", + "fail(outcome?: UwtScenarioOutcome | undefined): void", + "failStep(error: unknown, detail?: UwtScenarioStepDetail | undefined): UwtScenario", + "get delta: number", + "get id: string", + "get isSettled: boolean", + "get name: string", + "get status: UwtScenarioStatus | undefined", + "incomplete(outcome?: UwtScenarioOutcome | undefined): void", + "setData(metadata: UwtTelemetryMetadata): UwtScenario", + "settle(status: UwtScenarioStatus, outcome?: UwtScenarioOutcome | undefined, error?: unknown): void", + "step(name: string, metadata?: UwtTelemetryMetadata | undefined): UwtScenario", + "toRecord(): UwtScenarioRecord" + ], + "UwtScenarioAggregate": [ + "interface", + "callCount: number", + "elapsed: number", + "name: string" + ], + "UwtScenarioName": [ + "type = string" + ], + "UwtScenarioNames": [ + "interface" + ], + "UwtScenarioOptions": [ + "interface", + "feature?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "operation?: string | undefined", + "parentScenarioId?: string | undefined", + "route?: string | undefined", + "startedAt?: number | undefined", + "timeoutMs?: number | undefined" + ], + "UwtScenarioOutcome": [ + "interface", + "error?: unknown", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "reason?: string | undefined", + "statusCode?: string | number | undefined" + ], + "UwtScenarioRecord": [ + "interface", + "aggregates?: UwtScenarioAggregate[] | undefined", + "application: string", + "delta: number", + "elapsed: number", + "endTime?: string | undefined", + "error?: UwtTelemetryError | undefined", + "exceededStepsLimit?: boolean | undefined", + "feature?: string | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "operation?: string | undefined", + "parentScenarioId?: string | undefined", + "previousStep?: string | undefined", + "reason?: string | undefined", + "route?: string | undefined", + "scenarioId: string", + "scenarioName: string", + "sessionElapsed?: number | undefined", + "sessionId?: string | undefined", + "startTime: string", + "status?: UwtScenarioStatus | undefined", + "statusCode?: string | number | undefined", + "stepCount: number", + "steps: UwtScenarioStep[]", + "userId?: string | undefined" + ], + "UwtScenarioStatus": [ + "type = 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'" + ], + "UwtScenarioStep": [ + "interface", + "elapsed?: number | undefined", + "endOffset?: number | undefined", + "error?: UwtTelemetryError | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "reason?: string | undefined", + "startOffset: number", + "status?: UwtScenarioStatus | undefined", + "stepDelta?: number | undefined" + ], + "UwtScenarioStepDetail": [ + "interface", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "reason?: string | undefined" + ], + "UwtScenarioStepEvent": [ + "interface", + "application: string", + "elapsed?: number | undefined", + "endOffset?: number | undefined", + "error?: UwtTelemetryError | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "reason?: string | undefined", + "scenarioId: string", + "scenarioName: string", + "sessionId?: string | undefined", + "startOffset: number", + "status?: UwtScenarioStatus | undefined", + "stepDelta?: number | undefined", + "userId?: string | undefined" + ], + "UwtScenarioSteps": [ + "interface" + ], + "UwtScenarioStepStatus": [ + "type = 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'" + ], + "UwtScenarioTelemetryConfig": [ + "interface", + "defaultTimeoutMs: number", + "emitLifecycleEvents: boolean", + "markCleanupFallbackMs: number", + "maxSteps: number", + "redact: boolean", + "userTimings: boolean" + ], + "uwtScrubString": [ + "function(value: string, config: UwtRedactConfig, report?: UwtReporter | undefined): string" + ], "uwtSessionElapsed": [ "function(sessionId: string | undefined, now?: number | undefined): number | undefined" + ], + "UwtStepName": [ + "type = string" + ], + "UwtTelemetryAttribution": [ + "interface", + "application: string", + "sessionId?: string | undefined", + "userId?: string | undefined" + ], + "UwtTelemetryError": [ + "interface", + "message: string", + "name: string", + "stack?: string | undefined" + ], + "UwtTelemetryEventTypes": [ + "interface", + "bi: string", + "scenario: string", + "scenarioStep: string" + ], + "UwtTelemetryIdentity": [ + "interface", + "application: string", + "environment: string", + "eventNamespace?: string | undefined", + "version: string" + ], + "UwtTelemetryMetadata": [ + "type = { [x: string]: string | number | boolean | null; }" + ], + "UwtTelemetrySink": [ + "abstract class", + "new (): UwtTelemetrySink", + "abstract flush(beacon?: boolean | undefined): void", + "abstract getSessionId(): string | undefined", + "abstract getUserId(): string | undefined", + "abstract record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "abstract recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "abstract setUserId(userId: string | undefined): void" + ], + "UwtTelemetrySinkEvent": [ + "type = { kind: 'scenario'; payload: UwtScenarioRecord; } | { kind: 'scenario-step'; payload: UwtScenarioStepEvent; } | { kind: 'bi'; payload: UwtBIEvent; } | { kind: 'unknown'; payload: object; }" + ], + "uwtUuid": [ + "function(): string" ] } } diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.spec.ts b/projects/ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.spec.ts rename to projects/ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.spec.ts diff --git a/projects/ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts b/projects/ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts new file mode 100644 index 0000000..788dbf3 --- /dev/null +++ b/projects/ui-watchtower/src/lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config.ts @@ -0,0 +1,34 @@ +/** + * Business and UX event tracking configuration. + * + * @group Interfaces + */ +export interface UwtBITelemetryConfig { + /** Milliseconds within which an identical event is treated as a double-fire. */ + dedupWindowMs: number; + + /** + * Distinct event keys tracked before expired ones are swept. + * + * Keyed per `eventName|scenarioId|feature|metadata`, which + * grows unbounded over a long session without this cap. + */ + dedupMaxKeys: number; + + /** + * Whether redaction runs on BI events. On by default. Turning it off + * skips only the *configurable* PII scrubbing (`extraKeyPatterns`, + * value-pattern scanning, URL-query stripping) — the built-in credential + * denylist, size caps, error normalization and any + * `UwtRedactConfig.extraValueTransforms` still apply; see + * {@link uwtRedactConfigFor}. + */ + redact: boolean; +} + +/** Default BI event tracking settings. */ +export const UWT_DEFAULT_BI_TELEMETRY_CONFIG: UwtBITelemetryConfig = { + dedupWindowMs: 400, + dedupMaxKeys: 100, + redact: true +}; diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.spec.ts b/projects/ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.spec.ts rename to projects/ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.spec.ts diff --git a/projects/ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts b/projects/ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts new file mode 100644 index 0000000..38414e4 --- /dev/null +++ b/projects/ui-watchtower/src/lib/config/uwt-log.config/uwt-log.config.ts @@ -0,0 +1,51 @@ +import { + UwtLoggerName, + UwtLogLevel +} from '../../models/uwt-log.models/uwt-log.models'; + +/** + * Application logging configuration. + * + * @group Interfaces + */ +export interface UwtLogConfig { + /** Records below this severity are discarded. */ + minLevel: UwtLogLevel; + + /** + * Per-logger overrides of {@link minLevel}, keyed by logger name. Loggers + * without an override use {@link minLevel}. + * + * ```typescript + * { minLevel: 'warn', levels: { checkout: 'log' } } + * ``` + */ + levels?: Partial>; + + /** + * Also report `logger.error` calls to the telemetry sink as RUM errors. Off + * by default; competes for the same session event budget as scenario and + * BI data. + * + * Mirrors `detail.error` when supplied; a message-only call builds a + * synthetic `Error` from the redacted message text instead. + */ + mirrorErrorsToRum: boolean; + + /** + * Whether redaction runs on log records. On by default. Turning it off + * skips only the *configurable* PII scrubbing (`extraKeyPatterns`, + * value-pattern scanning, URL-query stripping) — the built-in credential + * denylist, size caps, error normalization and any + * `UwtRedactConfig.extraValueTransforms` still apply; see + * {@link uwtRedactConfigFor}. + */ + redact: boolean; +} + +/** Default application logging settings. */ +export const UWT_DEFAULT_LOG_CONFIG: UwtLogConfig = { + minLevel: 'log', + mirrorErrorsToRum: false, + redact: true +}; diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.spec.ts b/projects/ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.spec.ts rename to projects/ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.spec.ts diff --git a/projects/ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts b/projects/ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts new file mode 100644 index 0000000..13d685c --- /dev/null +++ b/projects/ui-watchtower/src/lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config.ts @@ -0,0 +1,72 @@ +/** + * Scenario telemetry configuration. + * + * @group Interfaces + */ +export interface UwtScenarioTelemetryConfig { + /** + * Milliseconds after which an unsettled scenario auto-settles as `timeout`. + * `0` disables the default; individual scenarios can still opt in. + */ + defaultTimeoutMs: number; + + /** + * Emit an event per lifecycle transition instead of one packed event at + * settlement. Off by default. + * + * Counts against the RUM session's shared `sessionEventLimit` (200 by + * default). Suited to local debugging, not production traffic. + * + * Also governs per-step `debugScenario` console output, because the + * console prints one line per event actually sent and nothing else. + * With this off, a scenario prints a single line at settlement — the + * one record it sends. + */ + emitLifecycleEvents: boolean; + + /** + * Maximum number of steps retained per scenario. Further steps are counted in + * `stepCount` but not stored, bounding the payload of a runaway loop. + */ + maxSteps: number; + + /** + * Mirror scenario and step boundaries into `performance.mark` / `measure`, + * visible on the DevTools Performance track. Off by default. + * + * The `debugScenario` LocalStorage flag switches them on regardless of this + * setting. Marks are cleared when a scenario settles; see + * {@link markCleanupFallbackMs} for scenarios that never do. + */ + userTimings: boolean; + + /** + * How long in milliseconds an unsettled scenario's User Timing marks can + * remain in the Performance buffer before a fallback clears them. + * + * Only applies when `userTimings` is enabled and `timeoutMs: 0`. `0` + * disables the fallback, so marks accumulate for the page's lifetime. + */ + markCleanupFallbackMs: number; + + /** + * Whether redaction runs on scenario records. On by default. Turning it + * off skips only the *configurable* PII scrubbing (`extraKeyPatterns`, + * value-pattern scanning, URL-query stripping) — the built-in credential + * denylist, size caps, error normalization and any + * `UwtRedactConfig.extraValueTransforms` still apply; see + * {@link uwtRedactConfigFor}. + */ + redact: boolean; +} + +/** Default scenario telemetry settings. */ +export const UWT_DEFAULT_SCENARIO_TELEMETRY_CONFIG: UwtScenarioTelemetryConfig = + { + defaultTimeoutMs: 30_000, + emitLifecycleEvents: false, + maxSteps: 50, + userTimings: false, + markCleanupFallbackMs: 300_000, + redact: true + }; diff --git a/projects/ngx-ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.spec.ts b/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.spec.ts rename to projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.spec.ts diff --git a/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts b/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts new file mode 100644 index 0000000..b9d11f7 --- /dev/null +++ b/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts @@ -0,0 +1,62 @@ +import { + UwtBITelemetryConfig, + UWT_DEFAULT_BI_TELEMETRY_CONFIG +} from '../uwt-bi-telemetry.config/uwt-bi-telemetry.config'; +import { + UwtLogConfig, + UWT_DEFAULT_LOG_CONFIG +} from '../uwt-log.config/uwt-log.config'; +import { + UwtScenarioTelemetryConfig, + UWT_DEFAULT_SCENARIO_TELEMETRY_CONFIG +} from '../uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { UWT_DEFAULT_EVENT_NAMESPACE } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { + UWT_DEFAULT_REDACT_CONFIG, + UwtRedactConfig +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; + +/** + * The identity every telemetry record and event carries — application name, + * deployment environment and version, plus the event-type namespace. + * + * @group Interfaces + */ +export interface UwtTelemetryIdentity { + /** Application name, e.g. `composition`. Becomes a session attribute. */ + application: string; + + /** Deployment environment, e.g. `dev`, `qa`, `prod`. */ + environment: string; + + /** Application version, e.g. `22.0.0`. */ + version: string; + + /** + * Prefix for the custom event types this library emits — `{namespace}.scenario`, + * `{namespace}.scenario.step` and `{namespace}.bi`. + * + * Defaults to `com.uwt`. + */ + eventNamespace?: string; +} + +/** + * Library defaults for every concern not carried by {@link UwtTelemetryIdentity} + * — applied to anything a configuration leaves out. + * + * @group Utils + */ +export const UWT_DEFAULT_TELEMETRY_CONFIG: { + eventNamespace: string; + scenario: UwtScenarioTelemetryConfig; + logs: UwtLogConfig; + bi: UwtBITelemetryConfig; + redact: UwtRedactConfig; +} = { + eventNamespace: UWT_DEFAULT_EVENT_NAMESPACE, + scenario: UWT_DEFAULT_SCENARIO_TELEMETRY_CONFIG, + logs: UWT_DEFAULT_LOG_CONFIG, + bi: UWT_DEFAULT_BI_TELEMETRY_CONFIG, + redact: UWT_DEFAULT_REDACT_CONFIG +}; diff --git a/projects/ngx-ui-watchtower/src/lib/models/uwt-bi.models/uwt-bi.models.ts b/projects/ui-watchtower/src/lib/models/uwt-bi.models/uwt-bi.models.ts similarity index 91% rename from projects/ngx-ui-watchtower/src/lib/models/uwt-bi.models/uwt-bi.models.ts rename to projects/ui-watchtower/src/lib/models/uwt-bi.models/uwt-bi.models.ts index 0567bc3..401c6db 100644 --- a/projects/ngx-ui-watchtower/src/lib/models/uwt-bi.models/uwt-bi.models.ts +++ b/projects/ui-watchtower/src/lib/models/uwt-bi.models/uwt-bi.models.ts @@ -12,10 +12,13 @@ import { * the dashboard reads. Declaring the vocabulary turns that into a compile * error. * + * An Angular application augments `@absaoss-cps/ngx-ui-watchtower` instead, + * which re-exports this registry: either module path reaches the same one. + * * @example * ```typescript * // src/app/telemetry.schema.ts - * declare module '@absaoss-cps/ngx-ui-watchtower' { + * declare module '@absaoss-cps/ui-watchtower' { * interface UwtBIEventNames { * export_clicked: true; * theme_changed: true; diff --git a/projects/ngx-ui-watchtower/src/lib/models/uwt-log.models/uwt-log.models.ts b/projects/ui-watchtower/src/lib/models/uwt-log.models/uwt-log.models.ts similarity index 88% rename from projects/ngx-ui-watchtower/src/lib/models/uwt-log.models/uwt-log.models.ts rename to projects/ui-watchtower/src/lib/models/uwt-log.models/uwt-log.models.ts index 0c09583..d479689 100644 --- a/projects/ngx-ui-watchtower/src/lib/models/uwt-log.models/uwt-log.models.ts +++ b/projects/ui-watchtower/src/lib/models/uwt-log.models/uwt-log.models.ts @@ -12,10 +12,13 @@ import { * selects its per-logger level. Declaring the vocabulary turns a typo'd * name into a compile error instead of a filter that silently never matches. * + * An Angular application augments `@absaoss-cps/ngx-ui-watchtower` instead, + * which re-exports this registry: either module path reaches the same one. + * * @example * ```typescript * // src/app/telemetry.schema.ts - * declare module '@absaoss-cps/ngx-ui-watchtower' { + * declare module '@absaoss-cps/ui-watchtower' { * interface UwtLoggerNames { * checkout: true; * admin: true; @@ -80,7 +83,7 @@ export interface UwtLogDetail { /** * Identifier joining this log line to other telemetry. Pass a * {@link UwtScenario.id} here to tie this line to a scenario, and the - * same id reaches {@link UwtLoggerService.query} to read the journey + * same id reaches the logging `query` to read the journey * back across every logger that wrote during it. */ correlationId?: string; @@ -116,9 +119,8 @@ export interface UwtLogRecord } /** - * The application-facing logging API — what - * {@link UwtLoggerService.getLogger} hands back, so application code holds - * this contract without naming the service. + * The application-facing logging API — what `getLogger` hands back, so + * application code holds this contract without naming what created it. * * An instance *is* its name. The name is fixed when the logger is created * and stamped on every record it writes, so a record's `logger` can never diff --git a/projects/ngx-ui-watchtower/src/lib/models/uwt-scenario.models/uwt-scenario.models.ts b/projects/ui-watchtower/src/lib/models/uwt-scenario.models/uwt-scenario.models.ts similarity index 97% rename from projects/ngx-ui-watchtower/src/lib/models/uwt-scenario.models/uwt-scenario.models.ts rename to projects/ui-watchtower/src/lib/models/uwt-scenario.models/uwt-scenario.models.ts index 43cb5f2..6cd69e3 100644 --- a/projects/ngx-ui-watchtower/src/lib/models/uwt-scenario.models/uwt-scenario.models.ts +++ b/projects/ui-watchtower/src/lib/models/uwt-scenario.models/uwt-scenario.models.ts @@ -11,10 +11,13 @@ import { * Empty in the library. Augment it from your application, and every * `start({ name })` call is checked against it from then on. * + * An Angular application augments `@absaoss-cps/ngx-ui-watchtower` instead, + * which re-exports this registry: either module path reaches the same one. + * * @example * ```typescript * // src/app/telemetry/scenarios.schema.ts - * declare module '@absaoss-cps/ngx-ui-watchtower' { + * declare module '@absaoss-cps/ui-watchtower' { * interface UwtScenarioNames { * 'route-navigation': true; * 'load-dashboard': true; @@ -33,12 +36,12 @@ export interface UwtScenarioNames {} * Registry of this application's step and aggregate names. * * Steps and aggregates share one vocabulary — a name declared for a step is - * also valid passed to `aggregateStart`. + * also valid passed to `aggregateStart`. Augmented like {@link UwtScenarioNames}. * * @example * ```typescript * // src/app/telemetry/scenarios.schema.ts - * declare module '@absaoss-cps/ngx-ui-watchtower' { + * declare module '@absaoss-cps/ui-watchtower' { * interface UwtScenarioSteps { * 'resolve-route': true; * 'activate': true; diff --git a/projects/ngx-ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.spec.ts b/projects/ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.spec.ts rename to projects/ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.spec.ts diff --git a/projects/ngx-ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.ts b/projects/ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.ts rename to projects/ui-watchtower/src/lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models.ts diff --git a/projects/ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts b/projects/ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts new file mode 100644 index 0000000..bb7dcb7 --- /dev/null +++ b/projects/ui-watchtower/src/lib/providers/uwt-log-api.provider/uwt-log-api.provider.ts @@ -0,0 +1,93 @@ +import { + UwtLoggerName, + UwtLogLevel, + UwtLogRecord +} from '../../models/uwt-log.models/uwt-log.models'; + +/** + * Filter for reading log records back with `query`. + * + * Every field is optional and they combine with AND. A provider translates + * them however its store requires. + * + * @group Interfaces + */ +export interface UwtLogQuery { + /** + * Pulls one journey's lines together. Pass a {@link UwtScenario.id} to read + * everything logged during that scenario, across services. + */ + correlationId?: string; + + /** Restricts to one named logger. */ + logger?: UwtLoggerName; + + /** Discards records below this severity. */ + minLevel?: UwtLogLevel; + + /** ISO-8601 lower bound on `timestamp`, inclusive. */ + from?: string; + + /** ISO-8601 upper bound on `timestamp`, inclusive. */ + to?: string; + + /** Maximum number of records to return. */ + limit?: number; +} + +/** + * The application's log store, as this library needs to see it. + * + * Where records are kept, how they are authorised and how long they are + * retained are decisions for the consuming application; this is the seam it + * supplies them through. + * + * @example + * ```typescript + * send(record: UwtLogRecord): void { + * fetch('/api/logs', { method: 'POST', body: JSON.stringify(record) }).catch( + * () => undefined + * ); + * } + * + * async query(filter: UwtLogQuery): Promise { + * const params = new URLSearchParams(Object.entries(filter).map(([k, v]) => [k, String(v)])); + * return (await fetch(`/api/logs?${params}`)).json(); + * } + * ``` + * + * @group Interfaces + */ +export interface UwtLogApiProvider { + /** + * Ships one record. + * + * Called once per record, as it is written. Must be non-blocking and must + * not throw. A provider that batches accumulates its own queue here and + * flushes it via {@link flush}. + * + * @param record the redacted record + */ + send(record: UwtLogRecord): void; + + /** + * Reads records back. + * + * @param filter narrows what is returned; an empty filter means "everything + * this provider is willing to return" + * @returns the matching records + */ + query(filter: UwtLogQuery): Promise; + + /** + * Ships whatever this provider is holding, if it holds anything. + * + * Optional; only needed by a provider that batches in {@link send} instead + * of sending immediately. + * + * Called on `pagehide`, on `visibilitychange` going hidden, and on + * teardown. Must be synchronous and non-blocking. `fetch` with `keepalive` + * or `navigator.sendBeacon` are the usual choices. + */ + flush?(): void; +} diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts similarity index 99% rename from projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts rename to projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts index 1de060c..bd579d4 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts +++ b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.spec.ts @@ -1,9 +1,12 @@ +/** + * @jest-environment jsdom + */ import { UWT_DEFAULT_TELEMETRY_CONFIG } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UwtScenarioRecord } from '../../models/uwt-scenario.models/uwt-scenario.models'; import { UwtTelemetryMetadata } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { UWT_DEFAULT_REDACT_CONFIG } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { uwtMarkSessionStart } from '@absaoss-cps/ui-watchtower'; +import { uwtMarkSessionStart } from '../../utils/uwt-session-start.util/uwt-session-start.util'; import { UwtScenario, UwtScenarioDeps } from './uwt-scenario'; /** Captures what the scenario emitted, so a test can assert on it. */ diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts similarity index 84% rename from projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts rename to projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts index 0e32b2b..c91e1cf 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts +++ b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts @@ -30,7 +30,7 @@ import { uwtRedactMetadata, uwtScrubString } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { uwtSessionElapsed } from '@absaoss-cps/ui-watchtower'; +import { uwtSessionElapsed } from '../../utils/uwt-session-start.util/uwt-session-start.util'; import { uwtDeepClone, uwtElapsedNow, @@ -38,6 +38,7 @@ import { uwtNow, uwtSafe, uwtSafeVoid, + UwtReporter, uwtUuid } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; import { @@ -48,8 +49,8 @@ import { } from '../../utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util'; /** - * Collaborators a scenario needs. Supplied by - * {@link UwtScenarioTelemetryService}; not part of the public API. + * Collaborators a scenario needs. Supplied by whatever starts scenarios; + * not part of the public API. */ export interface UwtScenarioDeps { /** The application's identity, shared by every telemetry concern. */ @@ -59,8 +60,13 @@ export interface UwtScenarioDeps { /** Redaction settings, shared by every telemetry concern. */ redact: UwtRedactConfig; sink: UwtTelemetrySink; - /** Schedules timers outside Angular's zone when the host provides one. */ - runOutsideAngular?: (callback: () => T) => T; + /** + * Schedules timers outside the host framework's change detection, when it + * has one: Angular passes `NgZone.runOutsideAngular`. + */ + runOutside?: (callback: () => T) => T; + /** Receives what the scenario handles silently; see {@link UwtReporter}. */ + report?: UwtReporter; /** Called once with the scenario's id when it reaches a terminal state. */ onSettled: (scenarioId: string, record: UwtScenarioRecord) => void; /** Telemetry is switched off: the scenario records, times and sends nothing. */ @@ -81,8 +87,8 @@ const MAX_TIMEOUT_MS = 2_147_483_647; /** * A single in-flight user journey or feature execution. * - * Each call to {@link UwtScenarioTelemetryService.start} returns a fresh - * instance, so scenarios can run concurrently without interfering. + * Each call to `start` returns a fresh instance, so scenarios can run + * concurrently without interfering. * * @example * ```typescript @@ -175,17 +181,18 @@ export class UwtScenario { uwtIsDebugEnabled('debugScenario'); this.metadata = uwtSafe( 'scenario.construct', - () => uwtRedactMetadata(options.metadata, deps.redact) ?? {}, - {} + () => uwtRedactMetadata(options.metadata, deps.redact, deps.report) ?? {}, + {}, + deps.report ); this.feature = options.feature - ? uwtScrubString(options.feature, deps.redact) + ? uwtScrubString(options.feature, deps.redact, deps.report) : undefined; this.operation = options.operation - ? uwtScrubString(options.operation, deps.redact) + ? uwtScrubString(options.operation, deps.redact, deps.report) : undefined; this.route = options.route - ? uwtScrubString(options.route, deps.redact) + ? uwtScrubString(options.route, deps.redact, deps.report) : undefined; const backdated = @@ -210,8 +217,8 @@ export class UwtScenario { } /** - * The scenario's unique identifier and correlation id — pass it to - * {@link UwtLoggerService} calls, BI events, and backend requests to + * The scenario's unique identifier and correlation id — pass it to log + * calls, BI events, and backend requests to * reassemble a journey across frontend and backend telemetry. */ get id(): string { @@ -270,7 +277,7 @@ export class UwtScenario { name, startOffset: Math.round(uwtNow() - this.startedAt), metadata: this.openStepIncluded - ? uwtRedactMetadata(metadata, this.deps.redact) + ? uwtRedactMetadata(metadata, this.deps.redact, this.deps.report) : undefined }; @@ -322,9 +329,10 @@ export class UwtScenario { */ setData(metadata: UwtTelemetryMetadata): this { return this.mutate('setData', () => { - const safe = uwtRedactMetadata(metadata, this.deps.redact); + const { redact, report } = this.deps; + const safe = uwtRedactMetadata(metadata, redact, report); if (safe) { - uwtMergeMetadata(this.metadata, safe, this.deps.redact); + uwtMergeMetadata(this.metadata, safe, redact, report); } }); } @@ -444,7 +452,7 @@ export class UwtScenario { application: this.deps.identity.application }; } - const { redact } = this.deps; + const { redact, report } = this.deps; const settled = this.isSettled; const aggregates = this.collectAggregates(); @@ -495,7 +503,7 @@ export class UwtScenario { record.reason = this.settledReason; } if (this.settleError !== undefined) { - record.error = uwtNormalizeError(this.settleError, redact); + record.error = uwtNormalizeError(this.settleError, redact, report); } return record; @@ -521,72 +529,80 @@ export class UwtScenario { if (this.inert) { return; } - uwtSafeVoid(`scenario.${status}`, () => { - if (this.isSettled) { - return; - } - - this.clearTimeout(); - this.clearMarkCleanupTimer(); - this._duration = uwtNow() - this.startedAt; - this._status = status; - - const { message, reason } = outcome ?? {}; - const { redact } = this.deps; - - const resolvedMessage = message - ? uwtScrubString(message, redact) - : undefined; - const resolvedReason = reason - ? uwtScrubString(reason, redact) - : undefined; - const resolvedMetadata = uwtRedactMetadata(outcome?.metadata, redact); - - this.closeOpenStep( - status, - { - message: resolvedMessage, - reason: resolvedReason, - metadata: resolvedMetadata - }, - error, - true - ); - - this.steps.push({ - name: 'scenario-end', - startOffset: Math.round(this._duration), - endOffset: Math.round(this._duration), - stepDelta: 0, - elapsed: Math.round(uwtElapsedNow()), - status, - ...(resolvedMessage && { message: resolvedMessage }), - ...(resolvedReason && { reason: resolvedReason }), - ...(resolvedMetadata && { metadata: resolvedMetadata }), - ...(status === 'failure' && - error !== undefined && { - error: uwtNormalizeError(error, redact) - }) - }); - - this.settleOutcome = outcome; - this.settleError = error; - this.settledMessage = resolvedMessage; - this.settledReason = resolvedReason; - - if (resolvedMetadata) { - uwtMergeMetadata(this.metadata, resolvedMetadata, redact); - } - - this.measureScenarioTiming(); - - const record = this.toRecord(); - this.deps.onSettled(this._id, record); - this.emit( - { kind: 'scenario', payload: record }, - `${status} in ${Math.round(this._duration)}ms` - ); - }); + uwtSafeVoid( + `scenario.${status}`, + () => { + if (this.isSettled) { + return; + } + + this.clearTimeout(); + this.clearMarkCleanupTimer(); + this._duration = uwtNow() - this.startedAt; + this._status = status; + + const { message, reason } = outcome ?? {}; + const { redact, report } = this.deps; + + const resolvedMessage = message + ? uwtScrubString(message, redact, report) + : undefined; + const resolvedReason = reason + ? uwtScrubString(reason, redact, report) + : undefined; + const resolvedMetadata = uwtRedactMetadata( + outcome?.metadata, + redact, + report + ); + + this.closeOpenStep( + status, + { + message: resolvedMessage, + reason: resolvedReason, + metadata: resolvedMetadata + }, + error, + true + ); + + this.steps.push({ + name: 'scenario-end', + startOffset: Math.round(this._duration), + endOffset: Math.round(this._duration), + stepDelta: 0, + elapsed: Math.round(uwtElapsedNow()), + status, + ...(resolvedMessage && { message: resolvedMessage }), + ...(resolvedReason && { reason: resolvedReason }), + ...(resolvedMetadata && { metadata: resolvedMetadata }), + ...(status === 'failure' && + error !== undefined && { + error: uwtNormalizeError(error, redact, report) + }) + }); + + this.settleOutcome = outcome; + this.settleError = error; + this.settledMessage = resolvedMessage; + this.settledReason = resolvedReason; + + if (resolvedMetadata) { + uwtMergeMetadata(this.metadata, resolvedMetadata, redact, report); + } + + this.measureScenarioTiming(); + + const record = this.toRecord(); + this.deps.onSettled(this._id, record); + this.emit( + { kind: 'scenario', payload: record }, + `${status} in ${Math.round(this._duration)}ms` + ); + }, + this.deps.report + ); } /** The active telemetry sink's session id, when it has one. */ @@ -594,7 +610,8 @@ export class UwtScenario { return uwtSafe( 'scenario.getSessionId', () => this.deps.sink.getSessionId(), - undefined + undefined, + this.deps.report ); } @@ -603,7 +620,8 @@ export class UwtScenario { return uwtSafe( 'scenario.getUserId', () => this.deps.sink.getUserId(), - undefined + undefined, + this.deps.report ); } @@ -643,7 +661,7 @@ export class UwtScenario { } this.openStep = undefined; - const { redact } = this.deps; + const { redact, report } = this.deps; step.endOffset = Math.round(uwtNow() - this.startedAt); step.stepDelta = step.endOffset - step.startOffset; step.elapsed = Math.round(uwtElapsedNow()); @@ -652,24 +670,25 @@ export class UwtScenario { if (detail?.message) { step.message = alreadyRedacted ? detail.message - : uwtScrubString(detail.message, redact); + : uwtScrubString(detail.message, redact, report); } if (detail?.reason) { step.reason = alreadyRedacted ? detail.reason - : uwtScrubString(detail.reason, redact); + : uwtScrubString(detail.reason, redact, report); } if (detail?.metadata) { step.metadata = uwtMergeMetadata( step.metadata ?? {}, alreadyRedacted ? detail.metadata - : uwtRedactMetadata(detail.metadata, redact), - redact + : uwtRedactMetadata(detail.metadata, redact, report), + redact, + report ); } if (status === 'failure' && error !== undefined) { - step.error = uwtNormalizeError(error, redact); + step.error = uwtNormalizeError(error, redact, report); } // The two synthetic boundary markers never pass through here, so this @@ -773,7 +792,7 @@ export class UwtScenario { const remainingMs = Math.max(0, timeoutMs - (uwtNow() - this.startedAt)); - this.timeoutHandle = this.runOutsideAngular(() => + this.timeoutHandle = this.runOutside(() => setTimeout( () => { this.timeoutHandle = undefined; @@ -803,8 +822,8 @@ export class UwtScenario { * * This clears marks only — it does not settle the scenario. A scenario * left unsettled this way never fires {@link UwtScenarioDeps.onSettled}, - * so it stays in {@link UwtScenarioTelemetryService}'s active registry for - * the life of the page. + * so it stays among the active scenarios of whatever started it for the + * life of the page. */ private scheduleMarkCleanupFallback(): void { const fallbackMs = this.deps.scenarioTelemetryConfig.markCleanupFallbackMs; @@ -812,7 +831,7 @@ export class UwtScenario { return; } - this.markCleanupTimer = this.runOutsideAngular(() => + this.markCleanupTimer = this.runOutside(() => setTimeout( () => { this.markCleanupTimer = undefined; @@ -828,8 +847,8 @@ export class UwtScenario { this.markCleanupTimer = UwtScenario.clearHandle(this.markCleanupTimer); } - private runOutsideAngular(callback: () => T): T { - return this.deps.runOutsideAngular?.(callback) ?? callback(); + private runOutside(callback: () => T): T { + return this.deps.runOutside?.(callback) ?? callback(); } /** @@ -862,12 +881,16 @@ export class UwtScenario { if (this.inert) { return this; } - uwtSafeVoid(`scenario.${operation}`, () => { - if (this.isSettled) { - return; - } - apply(); - }); + uwtSafeVoid( + `scenario.${operation}`, + () => { + if (this.isSettled) { + return; + } + apply(); + }, + this.deps.report + ); return this; } @@ -887,8 +910,11 @@ export class UwtScenario { ? this.eventTypes.scenario : this.eventTypes.scenarioStep; - uwtDebugWrite('debugScenario', () => - writeToConsole(summary, eventType, emission.payload) + uwtDebugWrite( + 'debugScenario', + () => writeToConsole(summary, eventType, emission.payload), + undefined, + this.deps.report ); this.deps.sink.record(eventType, emission.payload); } diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink.ts b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink.ts similarity index 94% rename from projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink.ts rename to projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink.ts index 0e942ea..2a1af0f 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink.ts +++ b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink.ts @@ -1,4 +1,3 @@ -import { Injectable } from '@angular/core'; import { UwtTelemetryError, UwtTelemetryMetadata @@ -11,9 +10,11 @@ import { * integration be replaced or stubbed with no change to the telemetry * services or the applications that use them. * + * A plain class: extend it, or implement it, in any framework. Angular uses + * it as the injection token for the destination. + * * @group Services */ -@Injectable() export abstract class UwtTelemetrySink { /** * Records one custom event. Must be non-throwing and non-blocking. diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.spec.ts b/projects/ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.spec.ts similarity index 98% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.spec.ts rename to projects/ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.spec.ts index 6b344b8..fe23181 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.spec.ts +++ b/projects/ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.spec.ts @@ -1,3 +1,6 @@ +/** + * @jest-environment jsdom + */ import { uwtIsDebugEnabled } from './uwt-debug-flag.util'; describe('uwtIsDebugEnabled', () => { diff --git a/projects/ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts new file mode 100644 index 0000000..b4232c5 --- /dev/null +++ b/projects/ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts @@ -0,0 +1,81 @@ +import { + UwtReporter, + uwtSafeVoid +} from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; + +/** + * LocalStorage keys recognised as telemetry debug switches. + * + * @group Types + */ +export type UwtDebugFlag = 'debugLogger' | 'debugScenario' | 'debugBI'; + +const ENABLED_VALUES = new Set(['true', '1']); + +/** + * Reports whether a telemetry debug flag is enabled in LocalStorage. + * + * Only `'true'` and `'1'` enable it (case-insensitive, trimmed). Any other + * non-empty value is treated as a comma-separated list of names, enabling the + * flag only when `name` matches one of them; a call with no `name` reads a + * list value as disabled. A missing key or a throwing `localStorage` also + * reads as disabled. + * + * @param flag the LocalStorage key to check + * @param name the name to test against a list value, when the flag is scoped + * @returns `true` only when the flag is enabled for this name + * + * @group Utils + */ +export function uwtIsDebugEnabled(flag: UwtDebugFlag, name?: string): boolean { + try { + const raw = globalThis.localStorage?.getItem(flag); + if (typeof raw !== 'string') { + return false; + } + + const value = raw.trim().toLowerCase(); + if (ENABLED_VALUES.has(value)) { + return true; + } + + if (name === undefined || !value) { + return false; + } + + return value + .split(',') + .some((entry) => entry.trim() === name.trim().toLowerCase()); + } catch { + return false; + } +} + +/** + * Writes a debug line to the console, but only when `flag` is on, and + * never at the caller's expense. + * + * The write is isolated in its own {@link uwtSafeVoid} rather than left to + * whatever wrapper the caller already sits inside. That isolation is the + * point: every caller prints a debug line immediately *before* handing the + * same payload to a sink or transport, so a patched or broken `console` + * must not be able to abort the send. Writing the line and shipping the + * data are independent failures. + * + * @param flag the debug switch that has to be on + * @param write performs the console call; never runs when the flag is off + * @param name the name to test against a list-valued flag, e.g. a logger + * name for `debugLogger` + * @param report receives the error when the write throws + */ +export function uwtDebugWrite( + flag: UwtDebugFlag, + write: () => void, + name?: string, + report?: UwtReporter +): void { + if (!uwtIsDebugEnabled(flag, name)) { + return; + } + uwtSafeVoid(`${flag}.write`, write, report); +} diff --git a/projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts index 27a2915..531ed58 100644 --- a/projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts +++ b/projects/ui-watchtower/src/lib/utils/uwt-session-start.util/uwt-session-start.util.ts @@ -8,6 +8,8 @@ * cookie across reloads and tabs. Where storage is blocked, nothing is kept. */ +// Named for the package that introduced it. Kept: stored values outlive a +// deploy, and a shell and its fragments, on different versions, share them. const STORAGE_KEY = 'ngx-ui-watchtower.sessionStarts'; /** diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.spec.ts b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.spec.ts rename to projects/ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.spec.ts diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.ts rename to projects/ui-watchtower/src/lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util.ts diff --git a/projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts new file mode 100644 index 0000000..26d2de2 --- /dev/null +++ b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.spec.ts @@ -0,0 +1,816 @@ +import { + UWT_DEFAULT_REDACT_CONFIG, + UWT_REDACTED, + uwtMergeMetadata, + uwtNormalizeError, + uwtRedactConfigFor, + uwtRedactMetadata, + uwtScrubString +} from './uwt-telemetry-redact.util'; + +describe('uwtScrubString', () => { + it('should strip the query string and fragment from an absolute URL', () => { + expect( + uwtScrubString( + 'https://api.example.com/customers?token=abc123#section', + UWT_DEFAULT_REDACT_CONFIG + ) + ).toBe('https://api.example.com/customers'); + }); + + it('should strip a URL embedded inside a longer message', () => { + expect( + uwtScrubString( + 'Request to https://api.example.com/v1/users?apiKey=secret failed', + UWT_DEFAULT_REDACT_CONFIG + ) + ).toBe('Request to https://api.example.com/v1/users failed'); + }); + + it('should strip the query string from a root-relative path', () => { + expect( + uwtScrubString('/customers?ssn=123-45-6789', UWT_DEFAULT_REDACT_CONFIG) + ).toBe('/customers'); + }); + + it('should strip a root-relative path embedded inside a longer message', () => { + expect( + uwtScrubString( + 'Request to /api/customers/search?email=john@example.com&ssn=123-45-6789 failed', + UWT_DEFAULT_REDACT_CONFIG + ) + ).toBe('Request to /api/customers/search failed'); + }); + + it('should strip more than one embedded root-relative path in the same string', () => { + expect( + uwtScrubString( + 'compare /a/x?p=1 against /b/y?q=2 now', + UWT_DEFAULT_REDACT_CONFIG + ) + ).toBe('compare /a/x against /b/y now'); + }); + + it('should strip a root-relative path with no space before it', () => { + expect( + uwtScrubString( + 'Redirected to:/dashboard?sessionToken=abc123', + UWT_DEFAULT_REDACT_CONFIG + ) + ).toBe('Redirected to:/dashboard'); + }); + + it('should leave a plain message that merely ends in a question mark intact', () => { + expect( + uwtScrubString('Could not load the data?', UWT_DEFAULT_REDACT_CONFIG) + ).toBe('Could not load the data?'); + }); + + it('should truncate strings beyond the configured cap', () => { + const result = uwtScrubString('x'.repeat(50), { + ...UWT_DEFAULT_REDACT_CONFIG, + maxStringLength: 10 + }); + expect(result).toBe(`${'x'.repeat(10)}…`); + }); + + it('should leave URLs alone when stripping is disabled', () => { + expect( + uwtScrubString('https://example.com/a?b=c', { + ...UWT_DEFAULT_REDACT_CONFIG, + stripUrlQuery: false + }) + ).toBe('https://example.com/a?b=c'); + }); + + describe('value-pattern scanning', () => { + it('should not scan for any value pattern by default', () => { + expect( + uwtScrubString( + 'contact john.smith@example.com, card 4111111111111111, ssn 123-45-6789', + UWT_DEFAULT_REDACT_CONFIG + ) + ).toBe( + 'contact john.smith@example.com, card 4111111111111111, ssn 123-45-6789' + ); + }); + + it('should redact an email address when enabled', () => { + expect( + uwtScrubString('contact john.smith@example.com for help', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email'] + }) + ).toBe(`contact ${UWT_REDACTED} for help`); + }); + + it('should not match a bare @-handle with no domain as an email', () => { + expect( + uwtScrubString('cc @someuser on this', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email'] + }) + ).toBe('cc @someuser on this'); + }); + + it('should catch an email embedded in a URL path segment', () => { + expect( + uwtScrubString('/customers/john.smith@example.com', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email'] + }) + ).toBe(`/customers/${UWT_REDACTED}`); + }); + + it('should redact a Luhn-valid credit card number when enabled', () => { + expect( + uwtScrubString('card on file: 4111111111111111', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['creditCard'] + }) + ).toBe(`card on file: ${UWT_REDACTED}`); + }); + + it('should leave a 16-digit run alone when it fails the Luhn checksum', () => { + expect( + uwtScrubString('order number: 1234567890123456', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['creditCard'] + }) + ).toBe('order number: 1234567890123456'); + }); + + it('should redact a well-formatted SSN when enabled', () => { + expect( + uwtScrubString('ssn on file: 123-45-6789', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ssn'] + }) + ).toBe(`ssn on file: ${UWT_REDACTED}`); + }); + + it('should not match a bare 9-digit run with no dashes as an SSN', () => { + expect( + uwtScrubString('reference 123456789', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ssn'] + }) + ).toBe('reference 123456789'); + }); + + it('should redact an IPv4 address when enabled', () => { + expect( + uwtScrubString('client at 192.168.1.1', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ipv4'] + }) + ).toBe(`client at ${UWT_REDACTED}`); + }); + + it('should not match an out-of-range octet as an IPv4 address', () => { + expect( + uwtScrubString('version 999.999.999.999', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ipv4'] + }) + ).toBe('version 999.999.999.999'); + }); + + it('should redact a US-shaped phone number when enabled', () => { + expect( + uwtScrubString('call (555) 123-4567', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['phone'] + }) + ).toBe(`call ${UWT_REDACTED}`); + }); + + it('should redact a South African phone number in international format when enabled', () => { + expect( + uwtScrubString('call +27 82 123 4567', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['phone'] + }) + ).toBe(`call ${UWT_REDACTED}`); + }); + + it('should redact a value matching an application-supplied pattern', () => { + expect( + uwtScrubString('internal ref ACC-98765', { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [/ACC-\d+/] + }) + ).toBe(`internal ref ${UWT_REDACTED}`); + }); + + it('should redact every occurrence even when the supplied pattern has no g flag', () => { + expect( + uwtScrubString('ref ACC-111 and also ref ACC-222', { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [/ACC-\d+/] + }) + ).toBe(`ref ${UWT_REDACTED} and also ref ${UWT_REDACTED}`); + }); + + it('should redact correctly across repeated calls reusing the same non-global pattern object', () => { + const pattern = /ACC-\d+/; + const config = { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [pattern] + }; + + expect(uwtScrubString('ref ACC-111 and also ACC-222', config)).toBe( + `ref ${UWT_REDACTED} and also ${UWT_REDACTED}` + ); + + expect(uwtScrubString('ref ACC-333 and also ACC-444', config)).toBe( + `ref ${UWT_REDACTED} and also ${UWT_REDACTED}` + ); + }); + + it('should not recompile the same non-global pattern into a new RegExp on every call', () => { + const RegExpSpy = jest.spyOn(globalThis, 'RegExp'); + const pattern = /ACC-\d+/; + const config = { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [pattern] + }; + + uwtScrubString('ref ACC-111', config); + const callsAfterFirst = RegExpSpy.mock.calls.length; + uwtScrubString('ref ACC-222', config); + const callsAfterSecond = RegExpSpy.mock.calls.length; + + expect(callsAfterFirst).toBeGreaterThan(0); + expect(callsAfterSecond).toBe(callsAfterFirst); + + RegExpSpy.mockRestore(); + }); + + it('should fully redact a credit card number regardless of scanValuePatterns order', () => { + const value = 'card on file: 4111111111111111'; + expect( + uwtScrubString(value, { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['phone', 'creditCard'] + }) + ).toBe(`card on file: ${UWT_REDACTED}`); + expect( + uwtScrubString(value, { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['creditCard', 'phone'] + }) + ).toBe(`card on file: ${UWT_REDACTED}`); + }); + + it('should apply multiple enabled patterns to the same string', () => { + expect( + uwtScrubString('email a@b.com or call (555) 123-4567', { + ...UWT_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email', 'phone'] + }) + ).toBe(`email ${UWT_REDACTED} or call ${UWT_REDACTED}`); + }); + }); + + describe('extraValueTransforms', () => { + it('should run a custom transform on every string value', () => { + expect( + uwtScrubString('internal-id ACC-12345', { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + (value) => value.replace(/ACC-\d+/, UWT_REDACTED) + ] + }) + ).toBe(`internal-id ${UWT_REDACTED}`); + }); + + it('should run regardless of scanValuePatterns/extraValuePatterns being empty', () => { + expect( + uwtScrubString('plain text', { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [() => 'replaced'] + }) + ).toBe('replaced'); + }); + + it('should run multiple transforms in array order', () => { + expect( + uwtScrubString('start', { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + (value) => `${value}-a`, + (value) => `${value}-b` + ] + }) + ).toBe('start-a-b'); + }); + + describe('a throwing transform', () => { + let report: jest.Mock; + + beforeEach(() => { + report = jest.fn(); + }); + + it('should skip it and keep the value from before it, reporting why', () => { + expect( + uwtScrubString( + 'unchanged', + { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + () => { + throw new Error('broken transform'); + } + ] + }, + report + ) + ).toBe('unchanged'); + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('extraValueTransforms'), + expect.any(Error) + ); + }); + + it('should still apply subsequent transforms after it', () => { + expect( + uwtScrubString( + 'start', + { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + () => { + throw new Error('broken transform'); + }, + (value) => `${value}-ok` + ] + }, + report + ) + ).toBe('start-ok'); + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('extraValueTransforms'), + expect.any(Error) + ); + }); + + it('should print nothing without a reporter', () => { + const consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + try { + expect( + uwtScrubString('unchanged', { + ...UWT_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + () => { + throw new Error('broken transform'); + } + ] + }) + ).toBe('unchanged'); + expect(consoleWarn).not.toHaveBeenCalled(); + } finally { + consoleWarn.mockRestore(); + } + }); + }); + + it('should still cap length after custom transforms run', () => { + expect( + uwtScrubString('short', { + ...UWT_DEFAULT_REDACT_CONFIG, + maxStringLength: 5, + extraValueTransforms: [() => 'a much longer replacement value'] + }) + ).toBe('a muc…'); + }); + }); +}); + +describe('uwtRedactMetadata', () => { + it('should keep primitive values', () => { + expect( + uwtRedactMetadata({ count: 3, name: 'csv', ok: true, empty: null }) + ).toEqual({ + count: 3, + name: 'csv', + ok: true, + empty: null + }); + }); + + it.each([ + 'password', + 'passwd', + 'accessToken', + 'refresh_token', + 'Authorization', + 'clientSecret', + 'apiKey', + 'api_key', + 'Cookie', + 'bearerToken', + 'jwt', + 'signature', + 'sessionKey', + 'ssn' + ])('should redact the sensitive key "%s"', (key) => { + expect(uwtRedactMetadata({ [key]: 'super-secret-value' })).toEqual({ + [key]: UWT_REDACTED + }); + }); + + it('should redact keys matching an application-supplied pattern', () => { + expect( + uwtRedactMetadata( + { customerRef: 'ABC' }, + { ...UWT_DEFAULT_REDACT_CONFIG, extraKeyPatterns: [/customerRef/i] } + ) + ).toEqual({ customerRef: UWT_REDACTED }); + }); + + it('should redact the same key consistently across repeated calls with a global pattern', () => { + const config = { + ...UWT_DEFAULT_REDACT_CONFIG, + extraKeyPatterns: [/internalId/gi] + }; + for (let i = 0; i < 4; i++) { + expect(uwtRedactMetadata({ internalId: 'value' }, config)).toEqual({ + internalId: UWT_REDACTED + }); + } + }); + + it('should drop nested objects rather than serializing them', () => { + expect( + uwtRedactMetadata({ user: { id: 1, email: 'a@b.c' }, safe: 'yes' }) + ).toEqual({ safe: 'yes' }); + }); + + it('should drop arrays, functions, symbols and undefined', () => { + expect( + uwtRedactMetadata({ + rows: [1, 2, 3], + fn: () => undefined, + sym: Symbol('s'), + missing: undefined, + kept: 1 + }) + ).toEqual({ kept: 1 }); + }); + + it('should drop non-finite numbers', () => { + expect( + uwtRedactMetadata({ a: NaN, b: Infinity, c: -Infinity, d: 0 }) + ).toEqual({ d: 0 }); + }); + + it('should scrub URLs inside string values', () => { + expect(uwtRedactMetadata({ url: 'https://x.dev/p?token=1' })).toEqual({ + url: 'https://x.dev/p' + }); + }); + + it('should cap the number of retained keys', () => { + const input: Record = {}; + for (let i = 0; i < 100; i++) { + input[`k${i}`] = i; + } + const result = uwtRedactMetadata(input, { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 5 + }); + expect(Object.keys(result ?? {})).toHaveLength(5); + }); + + describe('truncation warning', () => { + let report: jest.Mock; + + beforeEach(() => { + report = jest.fn(); + }); + + it('should warn once when maxKeys actually truncates something', () => { + uwtRedactMetadata( + { a: 1, b: 2, c: 3 }, + { ...UWT_DEFAULT_REDACT_CONFIG, maxKeys: 2 }, + report + ); + + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('maxKeys') + ); + }); + + it('should stay silent when every key fits under maxKeys', () => { + uwtRedactMetadata( + { a: 1, b: 2 }, + { ...UWT_DEFAULT_REDACT_CONFIG, maxKeys: 2 }, + report + ); + + expect(report).not.toHaveBeenCalled(); + }); + }); + + it('should not throw on a cyclic object', () => { + const cyclic: Record = { name: 'root' }; + cyclic.self = cyclic; + expect(uwtRedactMetadata(cyclic)).toEqual({ name: 'root' }); + }); + + it.each([[null], [undefined], ['string'], [42], [[1, 2]]])( + 'should return undefined for the non-object input %p', + (input) => { + expect(uwtRedactMetadata(input)).toBeUndefined(); + } + ); + + it('should return undefined when nothing survives redaction', () => { + expect(uwtRedactMetadata({ nested: { a: 1 } })).toBeUndefined(); + }); +}); + +describe('uwtMergeMetadata', () => { + it('should return target unchanged when incoming is undefined', () => { + const target = { a: 1 }; + expect(uwtMergeMetadata(target, undefined)).toBe(target); + expect(target).toEqual({ a: 1 }); + }); + + it('should merge a disjoint bag in when under maxKeys', () => { + const target = { a: 1 }; + const result = uwtMergeMetadata( + target, + { b: 2 }, + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 5 + } + ); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it('should drop a genuinely new key once the combined count reaches maxKeys', () => { + const report = jest.fn(); + const target = { a: 1 }; + const result = uwtMergeMetadata( + target, + { b: 2 }, + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 1 + }, + report + ); + expect(result).toEqual({ a: 1 }); + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('maxKeys') + ); + }); + + it('should allow updating a key already present even when target is at maxKeys', () => { + const target = { a: 1 }; + const result = uwtMergeMetadata( + target, + { a: 2 }, + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 1 + } + ); + expect(result).toEqual({ a: 2 }); + }); + + it('should mutate and return the same target reference', () => { + const target = { a: 1 }; + expect(uwtMergeMetadata(target, { b: 2 })).toBe(target); + }); + + describe('truncation warning', () => { + let report: jest.Mock; + + beforeEach(() => { + report = jest.fn(); + }); + + it('should warn once when maxKeys actually truncates the combined result', () => { + uwtMergeMetadata( + { a: 1 }, + { b: 2 }, + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 1 + }, + report + ); + + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('maxKeys') + ); + }); + + it('should stay silent when the combined bag fits under maxKeys', () => { + uwtMergeMetadata( + { a: 1 }, + { b: 2 }, + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 2 + }, + report + ); + + expect(report).not.toHaveBeenCalled(); + }); + + it('should stay silent when only existing keys are updated at maxKeys', () => { + uwtMergeMetadata( + { a: 1 }, + { a: 2 }, + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxKeys: 1 + }, + report + ); + + expect(report).not.toHaveBeenCalled(); + }); + }); +}); + +describe('uwtNormalizeError', () => { + it('should reduce an Error to name, message and stack', () => { + const result = uwtNormalizeError(new TypeError('boom')); + expect(result?.name).toBe('TypeError'); + expect(result?.message).toBe('boom'); + expect(typeof result?.stack).toBe('string'); + }); + + it.each([ + ['empty', ''], + ['missing', undefined] + ])('should fall back to the generic label when name is %s', (_case, name) => { + const error = new Error('boom'); + (error as { name: unknown }).name = name; + expect(uwtNormalizeError(error)?.name).toBe('Error'); + }); + + it('should omit the stack when capture is disabled', () => { + const result = uwtNormalizeError(new Error('boom'), { + ...UWT_DEFAULT_REDACT_CONFIG, + includeStack: false + }); + expect(result?.stack).toBeUndefined(); + }); + + it('should cap the stack length', () => { + const error = new Error('boom'); + error.stack = 'y'.repeat(5000); + const result = uwtNormalizeError(error, { + ...UWT_DEFAULT_REDACT_CONFIG, + maxStackLength: 100 + }); + expect(result?.stack?.length).toBe(101); + }); + + it('should scrub URLs out of the error message', () => { + const result = uwtNormalizeError( + new Error('GET https://api.dev/me?access_token=xyz returned 401') + ); + expect(result?.message).toBe('GET https://api.dev/me returned 401'); + }); + + it('should accept a thrown string', () => { + expect(uwtNormalizeError('plain failure')).toEqual({ + name: 'Error', + message: 'plain failure' + }); + }); + + it('should extract name and message from an HttpErrorResponse-shaped object', () => { + const httpError = { + name: 'HttpErrorResponse', + message: 'Http failure response for /api/customers: 404 Not Found', + status: 404, + statusText: 'Not Found', + url: '/api/customers', + ok: false, + error: { secret: 'raw response body — must never appear in output' } + }; + + const result = uwtNormalizeError(httpError); + + expect(result).toEqual({ + name: 'HttpErrorResponse', + message: 'Http failure response for /api/customers: 404 Not Found' + }); + expect(JSON.stringify(result)).not.toContain('raw response body'); + }); + + it('should fall back to a generic name when the HTTP-error-shaped object has none', () => { + const result = uwtNormalizeError({ + message: 'Http failure response for /api/x: 500 Internal Server Error', + status: 500, + statusText: 'Internal Server Error' + }); + + expect(result?.name).toBe('HttpErrorResponse'); + }); + + it('should not treat an arbitrary object with a status-shaped key as an HTTP error', () => { + expect( + uwtNormalizeError({ status: 404, message: 'unrelated object' }) + ).toEqual({ + name: 'UnknownError', + message: UWT_REDACTED + }); + }); + + it('should report the type of a thrown object without serializing it', () => { + expect(uwtNormalizeError({ password: 'hunter2' })).toEqual({ + name: 'UnknownError', + message: UWT_REDACTED + }); + }); + + it.each([[null], [undefined]])('should return undefined for %p', (input) => { + expect(uwtNormalizeError(input)).toBeUndefined(); + }); +}); + +describe('uwtRedactConfigFor', () => { + it('should return the same config unchanged when enabled', () => { + expect(uwtRedactConfigFor(UWT_DEFAULT_REDACT_CONFIG, true)).toBe( + UWT_DEFAULT_REDACT_CONFIG + ); + }); + + it('should turn off PII scrubbing when disabled', () => { + const config = uwtRedactConfigFor( + { + ...UWT_DEFAULT_REDACT_CONFIG, + extraKeyPatterns: [/x-internal/], + scanValuePatterns: ['email'], + extraValuePatterns: [/secret-\d+/] + }, + false + ); + + expect(config.extraKeyPatterns).toEqual([]); + expect(config.stripUrlQuery).toBe(false); + expect(config.scanValuePatterns).toEqual([]); + expect(config.extraValuePatterns).toEqual([]); + }); + + it('should keep size caps, error normalization inputs, and extraValueTransforms when disabled', () => { + const transform = (value: string) => value; + const config = uwtRedactConfigFor( + { + ...UWT_DEFAULT_REDACT_CONFIG, + maxStringLength: 10, + maxKeys: 5, + maxStackLength: 100, + includeStack: true, + extraValueTransforms: [transform] + }, + false + ); + + expect(config.maxStringLength).toBe(10); + expect(config.maxKeys).toBe(5); + expect(config.maxStackLength).toBe(100); + expect(config.includeStack).toBe(true); + expect(config.extraValueTransforms).toEqual([transform]); + }); + + it('should still redact a built-in denylisted key when disabled — it is a safety floor, not a privacy opt-in', () => { + const disabled = uwtRedactConfigFor(UWT_DEFAULT_REDACT_CONFIG, false); + + expect(uwtRedactMetadata({ password: 'hunter2' }, disabled)).toEqual({ + password: UWT_REDACTED + }); + }); + + it('should not redact an extraKeyPatterns-only key when disabled', () => { + const disabled = uwtRedactConfigFor( + { ...UWT_DEFAULT_REDACT_CONFIG, extraKeyPatterns: [/x-internal/] }, + false + ); + + expect(uwtRedactMetadata({ 'x-internal-id': 'abc' }, disabled)).toEqual({ + 'x-internal-id': 'abc' + }); + }); +}); diff --git a/projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts new file mode 100644 index 0000000..7fc031d --- /dev/null +++ b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util.ts @@ -0,0 +1,516 @@ +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { + uwtReport, + UwtReporter +} from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; + +/** + * Named, built-in value-content PII shapes {@link UwtRedactConfig.scanValuePatterns} + * can enable. + * + * @group Types + */ +export type UwtPiiValuePattern = + 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'; + +/** + * Tunables governing what telemetry is allowed to carry off the device. + * + * @group Interfaces + */ +export interface UwtRedactConfig { + /** + * Additional key patterns to redact, merged with the built-in denylist. + * Matched against the attribute key, case-insensitively. + */ + extraKeyPatterns: RegExp[]; + + /** Maximum length of any single string value before truncation. */ + maxStringLength: number; + + /** Maximum number of attributes retained on one payload. */ + maxKeys: number; + + /** Maximum length of a captured stack trace. */ + maxStackLength: number; + + /** Whether stack traces are captured at all. */ + includeStack: boolean; + + /** Whether URL query strings and fragments are stripped from string values. */ + stripUrlQuery: boolean; + + /** + * Value-content PII shapes to scan for, in addition to the key denylist. + * Off by default. Heuristic regexes, not certified detectors — `'phone'` + * is the noisiest; `'creditCard'` also requires a Luhn match. + */ + scanValuePatterns: UwtPiiValuePattern[]; + + /** + * Additional value-content regexes, merged with `scanValuePatterns` — the + * content counterpart to `extraKeyPatterns`. + */ + extraValuePatterns: RegExp[]; + + /** + * Application-supplied functions run on every string value, after all + * pattern-based scrubbing — an escape hatch for logic no regex can + * express. Runs independently of `scanValuePatterns`/`extraValuePatterns`. + * A throwing function is skipped (logged in dev mode), not fatal. + */ + extraValueTransforms: ((value: string) => string)[]; +} + +/** Keys whose values never leave the browser, matched case-insensitively (`Authorization`, `access_token`, `apiKey`, …). */ +const DENYLIST = + /pass(word|wd)?|secret|token|auth|credential|cookie|api[-_]?key|bearer|jwt|signature|session[-_]?key|ssn/i; + +/** Absolute http(s) URLs appearing anywhere inside a string. */ +const URL_PATTERN = /https?:\/\/[^\s"'<>]+/gi; + +/** + * A root-relative path with a query string or fragment. The lookbehind + * rejects a `/` that's mid-word (so this doesn't re-match inside an + * already-scrubbed URL) without requiring whitespace before the path — + * punctuation like `:` counts as a boundary too. + */ +const PATH_WITH_QUERY = /(?, + RegExp +> = { + // Common email shape, not full RFC 5322. + email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, + // Matches only the dashed `123-45-6789` format; a bare 9-digit run isn't + // reliably an SSN. + ssn: /\b\d{3}-\d{2}-\d{4}\b/g, + ipv4: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, + // NANP (US/Canada): 3-3-4 digits, optional `1`/`+1` country code, + // South Africa (27), Botswana (267), Ghana (233), Kenya + // (254), Mauritius (230), Mozambique (258), Namibia (264), Seychelles + // (248), Tanzania (255), Uganda (256), Zambia (260). + phone: + /(?:(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}|(?:\+|00)(?:27|267|233|254|230|258|264|248|255|256|260)(?:[-.\s]?\d){7,9})\b/g +}; + +/** + * Fixed order for {@link UwtRedactConfig.scanValuePatterns}, independent of + * the consumer's array order. `creditCard` must run before `phone`: phone + * has no leading `\b` and can match a trailing substring of a card number, + * fragmenting and leaking part of it if it ran first. + */ +const VALUE_PATTERN_ORDER: UwtPiiValuePattern[] = [ + 'email', + 'creditCard', + 'ssn', + 'ipv4', + 'phone' +]; + +/** + * Payment card number length, per ISO/IEC 7812 — 13 digits at the short + * end (older Visa) through 19 at the long (Maestro and co.). + */ +const CARD_MIN_DIGITS = 13; +const CARD_MAX_DIGITS = 19; + +/** + * Candidate digit runs, allowing the separators people actually type. + * Built from the bounds above so the pattern and the length check below + * cannot drift apart. + */ +const CREDIT_CARD_CANDIDATE = new RegExp( + `\\b(?:\\d[ -]?){${CARD_MIN_DIGITS},${CARD_MAX_DIGITS}}\\b`, + 'g' +); + +/** The standard Luhn checksum — tells a real card number from an unrelated digit run of the same length. */ +function isLuhnValid(digits: string): boolean { + let sum = 0; + let alternate = false; + + for (let i = digits.length - 1; i >= 0; i--) { + let n = digits.charCodeAt(i) - 48; // '0' + if (alternate) { + n *= 2; + if (n > 9) { + n -= 9; + } + } + sum += n; + alternate = !alternate; + } + + return digits.length > 0 && sum % 10 === 0; +} + +/** Redacts credit-card-shaped digit runs that also pass a Luhn checksum. */ +function scrubCreditCards(value: string): string { + return value.replace(CREDIT_CARD_CANDIDATE, (match) => { + const digits = match.replace(/\D/g, ''); + return digits.length >= CARD_MIN_DIGITS && + digits.length <= CARD_MAX_DIGITS && + isLuhnValid(digits) + ? UWT_REDACTED + : match; + }); +} + +/** + * Memoizes the global copy `ensureGlobal` builds for a non-global pattern — + * config-level regexes are stable across many calls, so without this a + * fresh `RegExp` would be compiled on every scrubbed string, for the life + * of the page. Safe to reuse: every use is via `.replace()`, which resets + * `lastIndex` itself before scanning. + */ +const globalPatternCache = new WeakMap(); + +/** Ensures a pattern has the `g` flag, so `.replace()` catches every occurrence, not just the first. */ +function ensureGlobal(pattern: RegExp): RegExp { + if (pattern.global) { + return pattern; + } + + let global = globalPatternCache.get(pattern); + if (!global) { + global = new RegExp(pattern.source, `${pattern.flags}g`); + globalPatternCache.set(pattern, global); + } + return global; +} + +/** + * Default redaction settings. Conservative; widen as needed. + * + * @group Utils + */ +export const UWT_DEFAULT_REDACT_CONFIG: UwtRedactConfig = { + extraKeyPatterns: [], + maxStringLength: 1024, + maxKeys: 50, + maxStackLength: 2048, + includeStack: true, + stripUrlQuery: true, + scanValuePatterns: [], + extraValuePatterns: [], + extraValueTransforms: [] +}; + +/** + * Effective redact config for a concern's own `redact: boolean` toggle + * (`UwtLogConfig.redact`, `UwtScenarioTelemetryConfig.redact`, `UwtBITelemetryConfig.redact`). + * Disabling it skips only the configurable scrubbing — the built-in + * credential denylist, size caps, error normalization and + * `extraValueTransforms` stay on regardless. + * + * @param config the concern's resolved `UWT_REDACT_CONFIG` + * @param enabled the concern's own `redact` setting + * @returns `config` unchanged when enabled, otherwise with configurable + * scrubbing turned off + * + * @group Utils + */ +export function uwtRedactConfigFor( + config: UwtRedactConfig, + enabled: boolean +): UwtRedactConfig { + if (enabled) { + return config; + } + + return { + ...config, + extraKeyPatterns: [], + stripUrlQuery: false, + scanValuePatterns: [], + extraValuePatterns: [] + }; +} + +function isDenied(key: string, config: UwtRedactConfig): boolean { + if (DENYLIST.test(key)) { + return true; + } + return config.extraKeyPatterns.some((pattern) => { + // A global/sticky pattern is stateful: .test() advances lastIndex, so + // reset it to avoid alternating matches across calls. + pattern.lastIndex = 0; + return pattern.test(key); + }); +} + +/** Reduces a URL to `origin + pathname` — tokens and identifiers tend to live in the query/fragment being dropped. */ +function scrubUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + const cut = url.search(/[?#]/); + return cut === -1 ? url : url.slice(0, cut); + } +} + +/** + * Strips URL query strings/fragments, scans for opted-in PII shapes, runs + * `extraValueTransforms`, then caps length — in that order, so value + * scanning also catches PII embedded in a URL path segment. + * + * @param value the string to clean + * @param config redaction settings + * @param report receives a warning when an `extraValueTransforms` function + * throws and is skipped + * @returns the cleaned, length-capped string + * + * @group Utils + */ +export function uwtScrubString( + value: string, + config: UwtRedactConfig, + report?: UwtReporter +): string { + let result = value; + + if (config.stripUrlQuery) { + result = result.replace(URL_PATTERN, (match) => scrubUrl(match)); + result = result.replace(PATH_WITH_QUERY, (match) => scrubUrl(match)); + } + + if (config.scanValuePatterns.length || config.extraValuePatterns.length) { + for (const name of VALUE_PATTERN_ORDER) { + if (!config.scanValuePatterns.includes(name)) { + continue; + } + result = + name === 'creditCard' + ? scrubCreditCards(result) + : result.replace(VALUE_PATTERNS[name], UWT_REDACTED); + } + for (const pattern of config.extraValuePatterns) { + result = result.replace(ensureGlobal(pattern), UWT_REDACTED); + } + } + + for (const transform of config.extraValueTransforms) { + try { + result = transform(result); + } catch (error) { + uwtReport( + report, + 'warn', + 'An extraValueTransforms function threw and was skipped', + error + ); + } + } + + if (result.length > config.maxStringLength) { + result = `${result.slice(0, config.maxStringLength)}…`; + } + + return result; +} + +/** + * Produces a telemetry-safe attribute bag: flat primitives only (enforcing + * at runtime what {@link UwtTelemetryMetadata} states at compile time), + * denylisted keys redacted, strings scrubbed via {@link uwtScrubString}, + * non-finite numbers and objects/functions/symbols dropped, and the + * attribute count capped (with one warning to `report`). + * + * @param value the candidate attributes + * @param config redaction settings + * @param report receives a warning when attributes are dropped, or a + * transform throws + * @returns a safe attribute bag, or `undefined` when nothing survives + * + * @group Utils + */ +export function uwtRedactMetadata( + value: unknown, + config: UwtRedactConfig = UWT_DEFAULT_REDACT_CONFIG, + report?: UwtReporter +): UwtTelemetryMetadata | undefined { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const result: UwtTelemetryMetadata = {}; + let kept = 0; + let truncated = false; + + for (const [key, raw] of Object.entries(value)) { + if (kept >= config.maxKeys) { + truncated = true; + break; + } + + if (isDenied(key, config)) { + result[key] = UWT_REDACTED; + kept++; + continue; + } + + if (raw === null || typeof raw === 'boolean') { + result[key] = raw; + kept++; + } else if (typeof raw === 'number') { + if (Number.isFinite(raw)) { + result[key] = raw; + kept++; + } + } else if (typeof raw === 'string') { + result[key] = uwtScrubString(raw, config, report); + kept++; + } + } + + if (truncated) { + uwtReport( + report, + 'warn', + `Metadata exceeded maxKeys (${config.maxKeys}) and was truncated. Attributes past the limit were dropped silently in production.` + ); + } + + return kept > 0 ? result : undefined; +} + +/** + * Merges an already-redacted attribute bag into an existing one, + * re-applying `maxKeys` to the *combined* result. `uwtRedactMetadata` only + * bounds the bag passed to it in isolation — merging two independently + * capped bags without this can exceed the configured limit. A key already + * present in `target` is always updated (a replacement, not growth); only + * a genuinely new key counts against the cap, and is dropped once it's + * reached (with one warning to `report`, mirroring `uwtRedactMetadata`). + * + * Mutates and returns `target`. + * + * @param target the accumulator merged into, and returned + * @param incoming the bag being merged in, normally the output of {@link uwtRedactMetadata} + * @param config redaction settings + * @param report receives a warning when attributes are dropped + * @returns `target`, with `incoming` merged in + * + * @group Utils + */ +export function uwtMergeMetadata( + target: UwtTelemetryMetadata, + incoming: UwtTelemetryMetadata | undefined, + config: UwtRedactConfig = UWT_DEFAULT_REDACT_CONFIG, + report?: UwtReporter +): UwtTelemetryMetadata { + if (!incoming) { + return target; + } + + let keyCount = Object.keys(target).length; + let truncated = false; + + for (const [key, value] of Object.entries(incoming)) { + const isNewKey = !(key in target); + if (isNewKey && keyCount >= config.maxKeys) { + truncated = true; + continue; + } + if (isNewKey) { + keyCount++; + } + target[key] = value; + } + + if (truncated) { + uwtReport( + report, + 'warn', + `Combined metadata exceeded maxKeys (${config.maxKeys}) and was truncated. Attributes past the limit were dropped silently in production.` + ); + } + + return target; +} + +/** + * Converts a thrown value into a bounded, telemetry-safe shape — only the + * constructor name, scrubbed message and (optionally) a capped stack; never + * the raw error object. + * + * @param error any thrown value, including non-`Error` values + * @param config redaction settings + * @param report receives a warning when an `extraValueTransforms` function + * throws and is skipped + * @returns the normalized error, or `undefined` when there is nothing to report + * + * @group Utils + */ +export function uwtNormalizeError( + error: unknown, + config: UwtRedactConfig = UWT_DEFAULT_REDACT_CONFIG, + report?: UwtReporter +): UwtTelemetryError | undefined { + if (error === null || error === undefined) { + return undefined; + } + + if (error instanceof Error) { + const normalized: UwtTelemetryError = { + name: error.name || 'Error', + message: uwtScrubString(error.message ?? '', config, report) + }; + + if (config.includeStack && typeof error.stack === 'string') { + normalized.stack = uwtScrubString( + error.stack, + { ...config, maxStringLength: config.maxStackLength }, + report + ); + } + + return normalized; + } + + if (typeof error === 'string') { + return { name: 'Error', message: uwtScrubString(error, config, report) }; + } + + if (isHttpErrorShaped(error)) { + // HttpErrorResponse doesn't extend Error, so instanceof above misses it. + return { + name: typeof error.name === 'string' ? error.name : 'HttpErrorResponse', + message: uwtScrubString(error.message, config, report) + }; + } + + return { + name: typeof error === 'object' ? 'UnknownError' : typeof error, + message: UWT_REDACTED + }; +} + +/** Duck-types an `HttpErrorResponse`-shaped object, Angular's HTTP error, without importing a framework just for this check. */ +function isHttpErrorShaped( + error: unknown +): error is { name?: unknown; message: string; status: number } { + if (error === null || typeof error !== 'object') { + return false; + } + const candidate = error as Record; + return ( + typeof candidate.message === 'string' && + typeof candidate.status === 'number' && + ('statusText' in candidate || 'url' in candidate) + ); +} diff --git a/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts new file mode 100644 index 0000000..1998b57 --- /dev/null +++ b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.spec.ts @@ -0,0 +1,334 @@ +/** + * @jest-environment jsdom + */ +import { + uwtDeepClone, + uwtElapsedNow, + uwtEpochToPerf, + uwtNow, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync, + uwtUuid +} from './uwt-telemetry-safe.util'; + +describe('uwtSafe', () => { + let report: jest.Mock; + + beforeEach(() => { + report = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return the result when the operation succeeds', () => { + expect(uwtSafe('op', () => 42, 0, report)).toBe(42); + expect(report).not.toHaveBeenCalled(); + }); + + it('should return the fallback instead of throwing', () => { + const result = uwtSafe( + 'op', + () => { + throw new Error('boom'); + }, + 'fallback', + report + ); + expect(result).toBe('fallback'); + }); + + it('should never let a telemetry failure reach the caller', () => { + expect(() => + uwtSafeVoid('op', () => { + throw new Error('boom'); + }) + ).not.toThrow(); + }); + + it('should hand the suppressed error to the reporter, unprefixed', () => { + const error = new Error('boom'); + uwtSafeVoid( + 'scenario.step', + () => { + throw error; + }, + report + ); + expect(report).toHaveBeenCalledWith('error', 'scenario.step failed', error); + }); + + it('should print nothing without a reporter', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + uwtSafeVoid('scenario.step', () => { + throw new Error('boom'); + }); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('should not rethrow when the reporter itself throws', () => { + report.mockImplementation(() => { + throw new Error('reporter is broken'); + }); + expect(() => + uwtSafeVoid( + 'op', + () => { + throw new Error('boom'); + }, + report + ) + ).not.toThrow(); + }); +}); + +describe('uwtSafeVoidMaybeAsync', () => { + let report: jest.Mock; + + beforeEach(() => { + report = jest.fn(); + }); + + it('should behave exactly like uwtSafeVoid for a synchronous function', () => { + expect(() => + uwtSafeVoidMaybeAsync( + 'op', + () => { + throw new Error('boom'); + }, + report + ) + ).not.toThrow(); + expect(report).toHaveBeenCalledWith( + 'error', + 'op failed', + expect.any(Error) + ); + }); + + it('should not throw for a function that returns void', () => { + expect(() => + uwtSafeVoidMaybeAsync('op', () => undefined, report) + ).not.toThrow(); + expect(report).not.toHaveBeenCalled(); + }); + + it('should report a rejection from an async function typed as void', async () => { + const asyncFn = (async () => { + throw new Error('async boom'); + }) as () => void; + + uwtSafeVoidMaybeAsync('logApi.deliver', asyncFn, report); + + await Promise.resolve(); + await Promise.resolve(); + + expect(report).toHaveBeenCalledWith( + 'error', + 'logApi.deliver failed', + expect.any(Error) + ); + }); + + it('should not produce an unhandled rejection for a rejecting async function', async () => { + const asyncFn = (async () => { + throw new Error('unhandled if unguarded'); + }) as () => void; + + expect(() => uwtSafeVoidMaybeAsync('op', asyncFn)).not.toThrow(); + await Promise.resolve(); + await Promise.resolve(); + }); + + it('should not produce a new unhandled rejection when the reporter itself throws', async () => { + const unhandled = jest.fn(); + process.on('unhandledRejection', unhandled); + report.mockImplementation(() => { + throw new Error('reporter is broken'); + }); + const asyncFn = (async () => { + throw new Error('async boom'); + }) as () => void; + + uwtSafeVoidMaybeAsync('op', asyncFn, report); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + process.off('unhandledRejection', unhandled); + expect(report).toHaveBeenCalled(); + expect(unhandled).not.toHaveBeenCalled(); + }); +}); + +describe('uwtDeepClone', () => { + it('should produce a deep copy that mutation of the original does not affect', () => { + const original = { a: 1, nested: { b: [1, 2, 3] } }; + const clone = uwtDeepClone(original); + + original.nested.b.push(4); + + expect(clone).toEqual({ a: 1, nested: { b: [1, 2, 3] } }); + expect(clone).not.toBe(original); + expect(clone.nested).not.toBe(original.nested); + }); + + it('should fall back to a JSON round-trip when structuredClone is unavailable', () => { + const realStructuredClone = globalThis.structuredClone; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).structuredClone = undefined; + + try { + expect(uwtDeepClone({ a: 1, nested: { b: 2 } })).toEqual({ + a: 1, + nested: { b: 2 } + }); + } finally { + globalThis.structuredClone = realStructuredClone; + } + }); + + it('should fall back to a JSON round-trip when structuredClone throws', () => { + const realStructuredClone = globalThis.structuredClone; + globalThis.structuredClone = () => { + throw new Error('cannot clone'); + }; + + try { + expect(uwtDeepClone({ a: 1 })).toEqual({ a: 1 }); + } finally { + globalThis.structuredClone = realStructuredClone; + } + }); +}); + +describe('uwtUuid', () => { + const realCrypto = globalThis.crypto; + + afterEach(() => { + Object.defineProperty(globalThis, 'crypto', { + value: realCrypto, + configurable: true + }); + }); + + const useCrypto = (value: unknown) => + Object.defineProperty(globalThis, 'crypto', { + value, + configurable: true + }); + + it('should produce a UUID-shaped identifier', () => { + expect(uwtUuid()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + }); + + it('should produce distinct identifiers', () => { + const ids = new Set(Array.from({ length: 200 }, () => uwtUuid())); + expect(ids.size).toBe(200); + }); + + it('should fall back to getRandomValues when randomUUID is unavailable', () => { + useCrypto({ + getRandomValues: (array: Uint8Array) => { + for (let i = 0; i < array.length; i++) { + array[i] = i; + } + return array; + } + }); + + expect(uwtUuid()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); + + it('should fall back to Math.random when the Web Crypto API is absent', () => { + useCrypto(undefined); + + expect(uwtUuid()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); +}); + +describe('uwtNow', () => { + it('should return a number that does not go backwards', () => { + const first = uwtNow(); + const second = uwtNow(); + expect(typeof first).toBe('number'); + expect(second).toBeGreaterThanOrEqual(first); + }); +}); + +describe('uwtElapsedNow', () => { + it('should return a number that does not go backwards', () => { + const first = uwtElapsedNow(); + const second = uwtElapsedNow(); + expect(typeof first).toBe('number'); + expect(second).toBeGreaterThanOrEqual(first); + }); + + it('should read the same object jsdom exposes as globalThis.top', () => { + expect(globalThis.top).toBe(globalThis); + }); +}); + +describe('uwtEpochToPerf', () => { + const realPerformance = globalThis.performance; + + afterEach(() => { + Object.defineProperty(globalThis, 'performance', { + value: realPerformance, + configurable: true + }); + }); + + function usePerformance(timeOrigin: number, now: number) { + Object.defineProperty(globalThis, 'performance', { + value: { timeOrigin, now: () => now }, + configurable: true + }); + } + + it('should convert an epoch timestamp into the performance timeline', () => { + usePerformance(1_000_000, 5_000); + expect(uwtEpochToPerf(1_002_000)).toBe(2_000); + }); + + it('should accept the exact page origin', () => { + usePerformance(1_000_000, 5_000); + expect(uwtEpochToPerf(1_000_000)).toBe(0); + }); + + it('should reject a timestamp from before the page loaded', () => { + usePerformance(1_000_000, 5_000); + expect(uwtEpochToPerf(999_000)).toBeUndefined(); + }); + + it('should reject a timestamp in the future', () => { + usePerformance(1_000_000, 5_000); + expect(uwtEpochToPerf(1_010_000)).toBeUndefined(); + }); + + it.each([[NaN], [Infinity], [-Infinity]])( + 'should reject the non-finite input %p', + (input) => { + usePerformance(1_000_000, 5_000); + expect(uwtEpochToPerf(input)).toBeUndefined(); + } + ); + + it('should return undefined without a usable performance object', () => { + Object.defineProperty(globalThis, 'performance', { + value: undefined, + configurable: true + }); + expect(uwtEpochToPerf(1_000)).toBeUndefined(); + }); +}); diff --git a/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts new file mode 100644 index 0000000..139a0b7 --- /dev/null +++ b/projects/ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts @@ -0,0 +1,232 @@ +/** + * 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. + * + * 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: + * + * ```typescript + * const report: UwtReporter = (level, message, ...details) => + * console[level](`[my-app] ${message}`, ...details); + * ``` + * + * A reporter that throws is ignored. + * + * @group Types + */ +export type UwtReporter = ( + level: 'error' | 'warn', + message: string, + ...details: unknown[] +) => void; + +/** + * 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', + message: string, + ...details: unknown[] +): void { + if (!report) { + return; + } + try { + report(level, message, ...details); + } catch { + // A throwing reporter must never escape telemetry suppression. + } +} + +/** + * Runs a telemetry operation without letting it affect the application. + * + * Every public entry point routes through this wrapper — it never rethrows. + * The caught error goes to `report`, if given. + */ +export function uwtSafe( + operation: string, + fn: () => T, + fallback: T, + report?: UwtReporter +): T { + try { + return fn(); + } catch (error) { + uwtReport(report, 'error', `${operation} failed`, error); + return fallback; + } +} + +/** + * Void-returning form of {@link uwtSafe}, for the common case of an emit call + * whose result nobody reads. + */ +export function uwtSafeVoid( + operation: string, + fn: () => void, + report?: UwtReporter +): void { + uwtSafe(operation, fn, undefined, report); +} + +/** + * Like {@link uwtSafeVoid}, but also catches a rejection from a `void`-typed + * extension point whose real implementation turns out to be `async` — an + * application-supplied provider hook, for instance. TypeScript accepts an + * `async` function wherever `() => void` is expected and discards the + * promise, so a plain `try`/`catch` can't see it reject. This checks for a + * thenable result and attaches a rejection handler. + * + * Not the default for every call: most `uwtSafeVoid` calls wrap this + * library's own synchronous code, where the extra check is pure overhead. + * Use this only for a function whose shape this library merely declares. + */ +export function uwtSafeVoidMaybeAsync( + operation: string, + fn: () => void, + report?: UwtReporter +): void { + uwtSafe( + operation, + () => { + const result: unknown = fn(); + if ( + result !== null && + typeof result === 'object' && + typeof (result as { then?: unknown }).then === 'function' + ) { + Promise.resolve(result as PromiseLike).catch((error) => + uwtReport(report, 'error', `${operation} failed`, error) + ); + } + }, + undefined, + report + ); +} + +/** + * Deep-clones a plain-data value. Prefers `structuredClone`; falls back to + * a JSON round-trip where it's unavailable (e.g. under jsdom) or throws. + */ +export function uwtDeepClone(value: T): T { + if (typeof structuredClone === 'function') { + try { + return structuredClone(value); + } catch { + // fall through to the JSON fallback + } + } + return JSON.parse(JSON.stringify(value)) as T; +} + +/** + * Fills a byte array from the best available source. `crypto.getRandomValues` + * works on plain http:// too, where `crypto.randomUUID` doesn't. `Math.random` + * is the last-resort fallback for an environment with no Web Crypto at all. + */ +function randomBytes(count: number): Uint8Array { + const bytes = new Uint8Array(count); + const cryptoRef = globalThis.crypto; + + if (typeof cryptoRef?.getRandomValues === 'function') { + return cryptoRef.getRandomValues(bytes); + } + + for (let i = 0; i < count; i++) { + bytes[i] = (Math.random() * 256) | 0; + } + return bytes; +} + +/** Lowercase hex encoding, two characters per byte. */ +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Generates a correlation identifier. Prefers `crypto.randomUUID`, falling + * back to formatting random bytes as a v4 UUID. + * + * @group Utils + */ +export function uwtUuid(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + const bytes = randomBytes(16); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + + const hex = toHex(bytes); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20) + ].join('-'); +} + +/** + * Monotonic-ish millisecond clock for duration measurement. + * `performance.now()` is preferred since it's immune to wall-clock + * adjustments. + */ +export function uwtNow(): number { + const perf = globalThis.performance; + return typeof perf?.now === 'function' ? perf.now() : Date.now(); +} + +/** + * Milliseconds since the *host* page loaded — prefers `top.performance.now()` + * over the local realm's own, the same reasoning `uwt-user-timings-internal.util.ts` + * applies to marks/measures: in a composed page, a fragment runs in its own + * same-origin iframe with a later `timeOrigin`, so a timeline position taken + * from its own `performance` doesn't line up against the shell's. Reading + * `top` always succeeds, even cross-origin; only touching a property on it + * can throw, in which case this falls back to {@link uwtNow}. + */ +export function uwtElapsedNow(): number { + try { + const top = globalThis.top; + if (typeof top?.performance?.now === 'function') { + return top.performance.now(); + } + } catch { + // cross-origin, or sandboxed without `allow-same-origin` + } + return uwtNow(); +} + +/** + * Converts an epoch timestamp into the `performance.now()` timeline. + * Clamped to the current page's lifetime — a future timestamp, or one from + * before the page loaded, yields `undefined`. + */ +export function uwtEpochToPerf(epochMs: number): number | undefined { + if (!Number.isFinite(epochMs)) { + return undefined; + } + + const perf = globalThis.performance; + if (typeof perf?.now !== 'function' || typeof perf.timeOrigin !== 'number') { + return undefined; + } + + const converted = epochMs - perf.timeOrigin; + const now = perf.now(); + + if (converted < 0 || converted > now) { + return undefined; + } + + return converted; +} diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.spec.ts b/projects/ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.spec.ts similarity index 99% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.spec.ts rename to projects/ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.spec.ts index e5d3a97..ae49d66 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.spec.ts +++ b/projects/ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.spec.ts @@ -1,3 +1,6 @@ +/** + * @jest-environment jsdom + */ import { uwtClearMarks, uwtMark, diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.ts b/projects/ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.ts rename to projects/ui-watchtower/src/lib/utils/uwt-user-timings-internal.util/uwt-user-timings-internal.util.ts diff --git a/projects/ui-watchtower/src/public-api.ts b/projects/ui-watchtower/src/public-api.ts index abdf86a..585701e 100644 --- a/projects/ui-watchtower/src/public-api.ts +++ b/projects/ui-watchtower/src/public-api.ts @@ -5,9 +5,95 @@ * nothing touches a browser global when imported. */ -// Utilities a destination needs +// Configuration +export { UWT_DEFAULT_TELEMETRY_CONFIG } from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +export type { UwtTelemetryIdentity } from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +export type { UwtBITelemetryConfig } from './lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; +export type { UwtLogConfig } from './lib/config/uwt-log.config/uwt-log.config'; +export type { UwtScenarioTelemetryConfig } from './lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; + +// Models, and the name registries an application augments +export type { + UwtBIEvent, + UwtBIEventDetail, + UwtBIEventName, + UwtBIEventNames +} from './lib/models/uwt-bi.models/uwt-bi.models'; +export type { + UwtLogger, + UwtLoggerName, + UwtLoggerNames, + UwtLogDetail, + UwtLogLevel, + UwtLogRecord +} from './lib/models/uwt-log.models/uwt-log.models'; +export { UWT_LOG_LEVEL_ORDER } from './lib/models/uwt-log.models/uwt-log.models'; +export type { + UwtScenarioAggregate, + UwtScenarioName, + UwtScenarioNames, + UwtScenarioOptions, + UwtScenarioOutcome, + UwtScenarioRecord, + UwtScenarioStatus, + UwtScenarioStep, + UwtScenarioStepDetail, + UwtScenarioStepEvent, + UwtScenarioStepStatus, + UwtScenarioSteps, + UwtStepName +} from './lib/models/uwt-scenario.models/uwt-scenario.models'; +export { + UWT_DEFAULT_EVENT_NAMESPACE, + UWT_TELEMETRY_EVENT_TYPE, + uwtEventTypes +} from './lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +export type { + UwtTelemetryAttribution, + UwtTelemetryError, + UwtTelemetryEventTypes, + UwtTelemetryMetadata +} from './lib/models/uwt-telemetry-common.models/uwt-telemetry-common.models'; + +// Scenarios +export { UwtScenario } from './lib/scenario/uwt-scenario/uwt-scenario'; + +// Contracts a destination or a log backend implements +export { UwtTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +export type { + UwtLogApiProvider, + UwtLogQuery +} from './lib/providers/uwt-log-api.provider/uwt-log-api.provider'; + +// Utilities an application or a destination needs +export { uwtClassifyTelemetryEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; +export type { UwtTelemetrySinkEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; +export { uwtIsDebugEnabled } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; +export type { UwtDebugFlag } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; +export { uwtUuid } from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +export 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 { + UWT_DEFAULT_REDACT_CONFIG, + UWT_REDACTED, + uwtNormalizeError, + uwtRedactConfigFor, + uwtRedactMetadata, + uwtScrubString +} from './lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +export type { + UwtPiiValuePattern, + UwtRedactConfig +} from './lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; // Used by @absaoss-cps/ngx-ui-watchtower while the engine moves into this // package; removed from the public API once that code lives here too. export { uwtSessionElapsed } from './lib/utils/uwt-session-start.util/uwt-session-start.util'; +export { + uwtDeepClone, + uwtNow, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync +} from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +export { uwtDebugWrite } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; diff --git a/projects/ui-watchtower/type-tests/augmented.ts b/projects/ui-watchtower/type-tests/augmented.ts new file mode 100644 index 0000000..67ff10b --- /dev/null +++ b/projects/ui-watchtower/type-tests/augmented.ts @@ -0,0 +1,116 @@ +/** + * Module augmentation as a core consumer writes it: the four registries + * augmented on `@absaoss-cps/ui-watchtower`, with no framework involved. + * Registered names must reach every API that takes or returns a name, and + * anything else must be rejected. + * + * Compiled by `npm run typecheck` on its own, never run. An unused + * `@ts-expect-error` fails the compilation, so a name that stops being + * rejected is caught too. + */ +import { + UwtBIEvent, + UwtBIEventName, + UwtLogConfig, + UwtLoggerName, + UwtLogQuery, + UwtLogRecord, + UwtScenario, + UwtScenarioAggregate, + UwtScenarioName, + UwtScenarioOptions, + UwtScenarioRecord, + UwtScenarioStep, + UwtScenarioStepEvent, + UwtStepName, + UwtTelemetrySinkEvent +} from '@absaoss-cps/ui-watchtower'; + +declare module '@absaoss-cps/ui-watchtower' { + interface UwtScenarioNames { + checkout: true; + 'load-dashboard': true; + } + interface UwtScenarioSteps { + pay: true; + confirm: true; + } + interface UwtBIEventNames { + export_clicked: true; + } + interface UwtLoggerNames { + checkout: true; + } +} + +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +function expectType(): T | void { + // Checked at compile time only. +} + +// The names are exactly the registered ones. +expectType>(); +expectType>(); +expectType>(); +expectType>(); + +// And they reach every model that carries a name. +expectType>(); +expectType>(); +expectType< + Equals +>(); +expectType< + Equals< + UwtScenarioStep['name'], + UwtStepName | 'scenario-start' | 'scenario-end' + > +>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType< + Equals< + UwtLogConfig['levels'], + Partial> | undefined + > +>(); +expectType< + Equals< + Extract< + UwtTelemetrySinkEvent, + { kind: 'scenario' } + >['payload']['scenarioName'], + UwtScenarioName + > +>(); + +/** Registered steps accepted. */ +export function accepted(scenario: UwtScenario): void { + scenario.step('pay').step('confirm').endStep(); + scenario.aggregateStart('pay').aggregateEnd('pay'); +} + +/** Everything else rejected. */ +export function rejected(scenario: UwtScenario): void { + // @ts-expect-error not a registered step + scenario.step('pai'); + // @ts-expect-error not a registered step + scenario.aggregateStart('pai'); + // @ts-expect-error not a registered step + scenario.aggregateEnd('pai'); +} + +// @ts-expect-error not a registered scenario +export const options: UwtScenarioOptions = { name: 'chekout' }; +// @ts-expect-error not a registered logger +export const query: UwtLogQuery = { logger: 'chekout' }; +// @ts-expect-error not a registered logger +export const levels: UwtLogConfig['levels'] = { chekout: 'log' }; diff --git a/projects/ui-watchtower/type-tests/compat.ts b/projects/ui-watchtower/type-tests/compat.ts new file mode 100644 index 0000000..810cfc9 --- /dev/null +++ b/projects/ui-watchtower/type-tests/compat.ts @@ -0,0 +1,133 @@ +/** + * The core's public API used the way a framework-free application, or an + * integration, uses it. Compiled by `npm run typecheck` with plain `tsc`, + * never run: no framework is involved anywhere. No registry is augmented + * here, so every name is a plain string; see `augmented.ts` for registered + * names. + */ +import { + UWT_DEFAULT_EVENT_NAMESPACE, + UWT_DEFAULT_REDACT_CONFIG, + UWT_DEFAULT_TELEMETRY_CONFIG, + UWT_LOG_LEVEL_ORDER, + UWT_REDACTED, + UWT_TELEMETRY_EVENT_TYPE, + UwtBITelemetryConfig, + uwtClassifyTelemetryEvent, + UwtDebugFlag, + uwtEventTypes, + uwtIsDebugEnabled, + UwtLogApiProvider, + UwtLogConfig, + UwtLogLevel, + UwtLogQuery, + UwtLogRecord, + uwtMarkSessionStart, + uwtNormalizeError, + UwtPiiValuePattern, + UwtRedactConfig, + uwtRedactConfigFor, + uwtRedactMetadata, + UwtReporter, + UwtScenario, + UwtScenarioRecord, + UwtScenarioTelemetryConfig, + uwtScrubString, + UwtTelemetryError, + UwtTelemetryIdentity, + UwtTelemetryMetadata, + UwtTelemetrySink, + UwtTelemetrySinkEvent, + uwtUuid +} from '@absaoss-cps/ui-watchtower'; + +/** A destination the application writes itself. */ +export class AppSink extends UwtTelemetrySink { + readonly sent: UwtTelemetrySinkEvent[] = []; + + record(type: string, payload: object, _meta?: UwtTelemetryMetadata): void { + this.sent.push(uwtClassifyTelemetryEvent(type, payload)); + } + + recordError(_error: UwtTelemetryError, _meta?: UwtTelemetryMetadata): void { + // Sent elsewhere. + } + + getSessionId(): string | undefined { + return undefined; + } + + setUserId(_userId: string | undefined): void { + // Kept elsewhere. + } + + getUserId(): string | undefined { + return undefined; + } + + flush(_beacon?: boolean): void { + // Nothing buffered. + } +} + +/** A log backend. */ +export class AppLogBackend implements UwtLogApiProvider { + readonly ranks: number[] = []; + + send(record: UwtLogRecord): void { + const level: UwtLogLevel = record.level; + this.ranks.push(UWT_LOG_LEVEL_ORDER[level]); + } + + query(_filter: UwtLogQuery): Promise { + return Promise.resolve([]); + } +} + +/** Where the application wants to see what telemetry handles silently. */ +export const report: UwtReporter = (level, message, ...details) => + console[level](`[app] ${message}`, ...details); + +export const identity: UwtTelemetryIdentity = { + application: 'app', + environment: 'prod', + version: '1.0.0', + eventNamespace: UWT_DEFAULT_EVENT_NAMESPACE +}; + +export const configs: [ + UwtScenarioTelemetryConfig, + UwtLogConfig, + UwtBITelemetryConfig, + UwtRedactConfig +] = [ + UWT_DEFAULT_TELEMETRY_CONFIG.scenario, + UWT_DEFAULT_TELEMETRY_CONFIG.logs, + UWT_DEFAULT_TELEMETRY_CONFIG.bi, + UWT_DEFAULT_REDACT_CONFIG +]; + +/** A scenario, as an application handles one. */ +export function journey(scenario: UwtScenario): UwtScenarioRecord { + scenario.step('fetch', { rows: 1 }).endStep(); + scenario.complete({ statusCode: 200 }); + return scenario.toRecord(); +} + +/** The utilities an application or a destination calls. */ +export function utilities(): unknown[] { + const flag: UwtDebugFlag = 'debugScenario'; + const redact = uwtRedactConfigFor(UWT_DEFAULT_REDACT_CONFIG, true); + const patterns: UwtPiiValuePattern[] = redact.scanValuePatterns; + uwtMarkSessionStart(uwtUuid()); + return [ + uwtEventTypes(identity.eventNamespace), + UWT_TELEMETRY_EVENT_TYPE.scenario, + uwtIsDebugEnabled(flag), + uwtScrubString('a@b.c', redact, report), + uwtRedactMetadata({ email: 'a@b.c' }, redact, report), + uwtNormalizeError(new Error('x'), redact, report), + patterns, + UWT_REDACTED + ]; +} diff --git a/projects/ui-watchtower/type-tests/empty-registry.ts b/projects/ui-watchtower/type-tests/empty-registry.ts new file mode 100644 index 0000000..adc6175 --- /dev/null +++ b/projects/ui-watchtower/type-tests/empty-registry.ts @@ -0,0 +1,28 @@ +/** + * Nothing augmented: every name falls back to `string`, so an application + * that never declares a registry can use any name. + * + * Compiled by `npm run typecheck` with `compat.ts`, never run. It must not + * share a compilation with `augmented.ts`, whose augmentation would apply + * here too. + */ +import { + UwtBIEventName, + UwtLoggerName, + UwtScenarioName, + UwtStepName +} from '@absaoss-cps/ui-watchtower'; + +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +function expectType(): T | void { + // Checked at compile time only. +} + +expectType>(); +expectType>(); +expectType>(); +expectType>(); diff --git a/projects/ui-watchtower/type-tests/tsconfig.augmented.json b/projects/ui-watchtower/type-tests/tsconfig.augmented.json new file mode 100644 index 0000000..87beb04 --- /dev/null +++ b/projects/ui-watchtower/type-tests/tsconfig.augmented.json @@ -0,0 +1,5 @@ +/* The registries augmented, kept apart from the empty-registry fallback. */ +{ + "extends": "./tsconfig.json", + "files": ["augmented.ts"] +} diff --git a/projects/ui-watchtower/type-tests/tsconfig.json b/projects/ui-watchtower/type-tests/tsconfig.json new file mode 100644 index 0000000..95bb3d2 --- /dev/null +++ b/projects/ui-watchtower/type-tests/tsconfig.json @@ -0,0 +1,14 @@ +/* + * Core consumer type tests, compiled against the public entry point by plain + * `tsc`: no framework. `compat.ts` and `empty-registry.ts` share this + * compilation; the augmentation in `augmented.ts` would leak into them, so + * it has its own. + */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "files": ["compat.ts", "empty-registry.ts"] +} From ff89ccb7062328145d0f2b4cad16b233b91937af Mon Sep 17 00:00:00 2001 From: fateeand Date: Mon, 9 Nov 2026 18:36:54 +0100 Subject: [PATCH 04/21] feat: add the framework-independent telemetry engine --- .eslintrc.js | 24 + README.md | 1 + jest.config.js | 2 + package.json | 2 +- projects/ngx-ui-watchtower/DESIGN.md | 31 +- .../uwt-noop-log-api.provider.ts | 16 +- .../uwt-telemetry-common.providers.ts | 22 +- .../uwt-scenario-operators.spec.ts | 536 ++---------------- .../uwt-scenario-operators.ts | 94 +-- .../uwt-bi-telemetry.service.ts | 155 +---- .../uwt-logger.service.spec.ts | 3 +- .../uwt-logger.service/uwt-logger.service.ts | 245 +------- .../uwt-scenario-telemetry.service.ts | 162 +----- .../uwt-noop-telemetry.sink.ts | 41 +- .../uwt-debug-flag.util.ts | 23 - .../uwt-telemetry-safe.util.ts | 23 +- projects/ngx-ui-watchtower/src/public-api.ts | 2 +- projects/ngx-ui-watchtower/tsconfig.lib.json | 3 +- .../ngx-ui-watchtower/type-tests/compat.ts | 26 +- projects/ui-watchtower/README.md | 87 ++- projects/ui-watchtower/jest.config.js | 11 +- projects/ui-watchtower/package.json | 8 + .../ui-watchtower/public-api.snapshot.json | 141 ++++- projects/ui-watchtower/rxjs/ng-package.json | 6 + .../uwt-scenario-operators.spec.ts | 514 +++++++++++++++++ .../uwt-scenario-operators.ts | 129 +++++ projects/ui-watchtower/rxjs/src/public-api.ts | 9 + projects/ui-watchtower/rxjs/tsconfig.lib.json | 16 + .../uwt-telemetry-common.config.ts | 64 +++ .../uwt-bi-telemetry/uwt-bi-telemetry.ts | 252 ++++++++ .../uwt-log-telemetry/uwt-log-telemetry.ts | 380 +++++++++++++ .../uwt-scenario-telemetry.ts | 329 +++++++++++ .../uwt-telemetry-page/uwt-telemetry-page.ts | 56 ++ .../uwt-telemetry/uwt-telemetry.spec.ts | 516 +++++++++++++++++ .../lib/engine/uwt-telemetry/uwt-telemetry.ts | 200 +++++++ .../uwt-noop-log-api.provider.spec.ts | 0 .../uwt-noop-log-api.provider.ts | 26 + .../uwt-noop-telemetry.sink.spec.ts | 2 +- .../uwt-noop-telemetry.sink.ts | 50 ++ projects/ui-watchtower/src/public-api.spec.ts | 36 +- projects/ui-watchtower/src/public-api.ts | 45 +- projects/ui-watchtower/tsconfig.spec.json | 4 +- .../ui-watchtower/type-tests/augmented.ts | 25 + projects/ui-watchtower/type-tests/compat.ts | 107 ++++ tsconfig.json | 3 + 45 files changed, 3192 insertions(+), 1235 deletions(-) delete mode 100644 projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts create mode 100644 projects/ui-watchtower/rxjs/ng-package.json create mode 100644 projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.spec.ts create mode 100644 projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.ts create mode 100644 projects/ui-watchtower/rxjs/src/public-api.ts create mode 100644 projects/ui-watchtower/rxjs/tsconfig.lib.json create mode 100644 projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts create mode 100644 projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts create mode 100644 projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts create mode 100644 projects/ui-watchtower/src/lib/engine/uwt-telemetry-page/uwt-telemetry-page.ts create mode 100644 projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts create mode 100644 projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts (100%) create mode 100644 projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts rename projects/{ngx-ui-watchtower => ui-watchtower}/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts (94%) create mode 100644 projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts diff --git a/.eslintrc.js b/.eslintrc.js index 3e9bea3..b699dea 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -67,6 +67,30 @@ module.exports = { } ] } + }, + { + // The core's RxJS entry point: RxJS, and still no framework. + files: ['projects/ui-watchtower/rxjs/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@angular/*', + 'zone.js', + '@absaoss-cps/ngx-ui-watchtower', + '@absaoss-cps/ngx-ui-watchtower/*', + 'aws-rum-web', + 'aws-rum-web/*' + ], + message: 'The core is framework-independent.' + } + ] + } + ] + } } ] }; diff --git a/README.md b/README.md index 9797732..3f96ece 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ How it is designed: [projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watch ```text projects/ui-watchtower/ src/ @absaoss-cps/ui-watchtower — the framework-independent engine + rxjs/ secondary entry point — @absaoss-cps/ui-watchtower/rxjs type-tests/ consumer type tests, without a framework README.md usage guide, published to npm package.json the published package's manifest diff --git a/jest.config.js b/jest.config.js index 75c5452..312acea 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,6 +5,8 @@ module.exports = { moduleNameMapper: { '^@absaoss-cps/ui-watchtower$': '/projects/ui-watchtower/src/public-api.ts', + '^@absaoss-cps/ui-watchtower/rxjs$': + '/projects/ui-watchtower/rxjs/src/public-api.ts', '^@absaoss-cps/ngx-ui-watchtower$': '/projects/ngx-ui-watchtower/src/public-api.ts', '^@absaoss-cps/ngx-ui-watchtower/rum$': diff --git a/package.json b/package.json index f438134..408532c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint": "eslint \"**/*.ts\" --max-warnings=0", "format": "prettier --write \"**/*.{ts,mjs,html}\"", "format:check": "prettier --check \"**/*.{ts,mjs,html}\"", - "typecheck": "tsc -p projects/ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ui-watchtower/type-tests/tsconfig.json && tsc -p projects/ui-watchtower/type-tests/tsconfig.augmented.json && ng build --configuration=production --project=ui-watchtower && tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.json && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json", + "typecheck": "tsc -p projects/ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/rxjs/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ui-watchtower/type-tests/tsconfig.json && tsc -p projects/ui-watchtower/type-tests/tsconfig.augmented.json && ng build --configuration=production --project=ui-watchtower && tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.json && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json", "api:check": "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 6ef47bb..ea7bfad 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -89,6 +89,18 @@ flowchart TD Everything is wired through Angular DI, so a destination can be swapped in production and stubbed in tests. +The three services are Angular facades over the core's engine. Each holds the +core's class for its concern (`UwtScenarioTelemetry`, `UwtBITelemetry`, +`UwtLogTelemetry`), built from its own injections, so a missing provider still +fails where it always did: the logger needs a log provider but no +destination, scenarios need a destination and no log provider. The services +are root singletons, so each concern exists once per injector, and all of +them share the one destination and log provider. `settled$` is the core's +`onSettled` as an Observable that completes on destroy; `traceScenario` is the +core's `./rxjs` operator, reporting through this package's dev-mode +reporter. Framework-free applications compose the same three classes with +`uwtCreateTelemetry`. + ### Entry points Two, each pulling in only what it needs: @@ -199,13 +211,18 @@ providers: [ Angular nor this package, which a lint rule enforces. The library build compiles against the core's built declarations, not its source, so it sees the core exactly as an application does. -- **What the core owns, this package re-exports.** The models and name - registries, the configuration types and defaults, redaction, `UwtScenario` - and the `UwtTelemetrySink` and `UwtLogApiProvider` contracts are the - core's. This package keeps the injection tokens, the services and - providers, and the destinations that need Angular. `UwtTelemetrySink` is a - plain abstract class in the core, not `@Injectable()`: Angular uses it as - the destination's token as before, and a custom sink still extends it. +- **What the core owns, this package re-exports.** The engine, the models + and name registries, the configuration types and defaults + (`uwtResolveTelemetryConfig` completes every `with*` feature), redaction, + `UwtScenario`, the `UwtTelemetrySink` and `UwtLogApiProvider` contracts and + their no-op implementations are the core's. This package keeps the + injection tokens, the services and providers, and the destinations that + need Angular. `UwtTelemetrySink` is a plain abstract class in the core, not + `@Injectable()`: Angular uses it as the destination's token as before, and + a custom sink still extends it. Every class an application provides stays + `@Injectable()` here — `UwtNoopTelemetrySink` and `UwtNoopLogApiProvider` + are decorated subclasses of the core's — so the published declarations + keep their Angular metadata. - **Every export is explicit.** The barrels list their exports one by one — in a published package each exported name is a compatibility promise. Id generation, the clock, the fail-open wrappers, User Timings and the diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts index 480bc35..5424240 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts @@ -1,9 +1,5 @@ import { Injectable } from '@angular/core'; -import { - UwtLogApiProvider, - UwtLogQuery, - UwtLogRecord -} from '@absaoss-cps/ui-watchtower'; +import { UwtNoopLogApiProvider as CoreNoopLogApiProvider } from '@absaoss-cps/ui-watchtower'; /** * Log API provider that discards every record and finds none. @@ -27,12 +23,4 @@ import { * @group Services */ @Injectable() -export class UwtNoopLogApiProvider implements UwtLogApiProvider { - /** @inheritdoc */ - send(_record: UwtLogRecord): void {} - - /** @inheritdoc */ - query(_filter: UwtLogQuery): Promise { - return Promise.resolve([]); - } -} +export class UwtNoopLogApiProvider extends CoreNoopLogApiProvider {} diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts index 4fe5f43..0d32998 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts @@ -13,6 +13,7 @@ import { UWT_BROADCAST_CHANNEL } from '../../sinks/uwt-broadcast/uwt-broadcast.m import { UwtNoopTelemetrySink } from '../../sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; import { UWT_DEFAULT_TELEMETRY_CONFIG, + uwtResolveTelemetryConfig, UwtBITelemetryConfig, UwtLogConfig, UwtRedactConfig, @@ -60,7 +61,7 @@ export function withLogging( providers: [ { provide: UWT_LOG_CONFIG, - useValue: { ...UWT_DEFAULT_TELEMETRY_CONFIG.logs, ...config } + useValue: uwtResolveTelemetryConfig({ logging: config }).logging } ] }; @@ -81,7 +82,7 @@ export function withScenarios( providers: [ { provide: UWT_SCENARIO_TELEMETRY_CONFIG, - useValue: { ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario, ...config } + useValue: uwtResolveTelemetryConfig({ scenarios: config }).scenarios } ] }; @@ -102,7 +103,7 @@ export function withBIEvents( providers: [ { provide: UWT_BI_TELEMETRY_CONFIG, - useValue: { ...UWT_DEFAULT_TELEMETRY_CONFIG.bi, ...config } + useValue: uwtResolveTelemetryConfig({ bi: config }).bi } ] }; @@ -123,24 +124,11 @@ export function withBIEvents( export function withRedaction( config: Partial = {} ): UwtTelemetryFeature { - const defaults = UWT_DEFAULT_TELEMETRY_CONFIG.redact; return { providers: [ { provide: UWT_REDACT_CONFIG, - useValue: { - ...defaults, - ...config, - extraKeyPatterns: [ - ...(config.extraKeyPatterns ?? defaults.extraKeyPatterns) - ], - extraValuePatterns: [ - ...(config.extraValuePatterns ?? defaults.extraValuePatterns) - ], - extraValueTransforms: [ - ...(config.extraValueTransforms ?? defaults.extraValueTransforms) - ] - } + useValue: uwtResolveTelemetryConfig({ redaction: config }).redaction } ] }; diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts index 917d5bc..65d7bf9 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts @@ -1,512 +1,66 @@ -import { - defer, - EMPTY, - first, - type Observable, - firstValueFrom, - lastValueFrom, - of, - retry, - Subject, - switchMap, - take, - throwError -} from 'rxjs'; +import { isDevMode } from '@angular/core'; +import { of } from 'rxjs'; import { UWT_DEFAULT_REDACT_CONFIG, UWT_DEFAULT_TELEMETRY_CONFIG, - UwtScenario, - UwtScenarioRecord + UwtNoopTelemetrySink, + UwtScenario } from '@absaoss-cps/ui-watchtower'; -import { UwtNoopTelemetrySink } from '../../sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; import { traceScenario } from './uwt-scenario-operators'; -describe('traceScenario operator', () => { - let scenario: jest.Mocked; +jest.mock('@angular/core', () => ({ + ...jest.requireActual('@angular/core'), + isDevMode: jest.fn(() => true) +})); + +const isDevModeMock = isDevMode as jest.Mock; + +/** The operator itself is tested in the core; this is what this package adds. */ +describe('traceScenario, as this package exports it', () => { + const throwingMapper = (): never => { + throw new Error('mapper broke'); + }; + let consoleError: jest.SpyInstance; + + function scenario(): UwtScenario { + return new UwtScenario( + { name: 'load-customers' }, + { + identity: { application: 'app', environment: 'test', version: '1' }, + scenarioTelemetryConfig: { ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink: new UwtNoopTelemetrySink(), + onSettled: () => undefined + } + ); + } beforeEach(() => { - scenario = { - complete: jest.fn(), - fail: jest.fn(), - cancel: jest.fn(), - isSettled: false - } as unknown as jest.Mocked; + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + isDevModeMock.mockReturnValue(true); }); - it('should complete scenario on successful stream completion', (done) => { - of(['user1', 'user2']) - .pipe(traceScenario(scenario)) - .subscribe({ - next: (val) => { - expect(val).toEqual(['user1', 'user2']); - }, - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith(undefined); - expect(scenario.fail).not.toHaveBeenCalled(); - done(); - } - }); + afterEach(() => { + jest.restoreAllMocks(); }); - it('should complete scenario with mapped outcome using function argument', (done) => { - of(['item1', 'item2', 'item3']) - .pipe( - traceScenario(scenario, (items) => ({ - metadata: { itemCount: items.length } - })) - ) - .subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith({ - metadata: { itemCount: 3 } - }); - done(); - } - }); - }); - - it('should complete scenario with mapped outcome using options object', (done) => { - of({ status: 200, data: 'ok' }) - .pipe( - traceScenario(scenario, { - outcome: (res) => ({ - statusCode: res.status, - metadata: { result: res.data } - }) - }) - ) - .subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith({ - statusCode: 200, - metadata: { result: 'ok' } - }); - done(); - } - }); - }); - - it('should complete scenario with no outcome when the stream completes with no emissions', (done) => { - EMPTY.pipe( - traceScenario(scenario, () => ({ metadata: { unreachable: true } })) - ).subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith(undefined); - expect(scenario.fail).not.toHaveBeenCalled(); - done(); - } - }); - }); - - it('should fail scenario when the stream errors and re-throw the error', (done) => { - const error = new Error('network down'); - throwError(() => error) - .pipe(traceScenario(scenario)) - .subscribe({ - error: (err) => { - expect(err).toBe(error); - expect(scenario.fail).toHaveBeenCalledWith({ error }); - expect(scenario.complete).not.toHaveBeenCalled(); - done(); - } - }); - }); + it('should report a throwing outcome mapper on the console, prefixed, in development mode', () => { + const traced = scenario(); - it('should still settle the scenario when the outcome mapper throws', (done) => { - const consoleError = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); + of(1).pipe(traceScenario(traced, throwingMapper)).subscribe(); - of('value') - .pipe( - traceScenario(scenario, () => { - throw new Error('mapper is broken'); - }) - ) - .subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith(undefined); - expect(scenario.fail).not.toHaveBeenCalled(); - expect(consoleError).toHaveBeenCalledWith( - expect.stringContaining('failed'), - expect.any(Error) - ); - consoleError.mockRestore(); - done(); - } - }); - }); - - it('should not leak lastValue/hasValue into a resubscription of the same piped observable', (done) => { - let subscriptionCount = 0; - const source = defer(() => { - subscriptionCount++; - return subscriptionCount === 1 ? of('a') : EMPTY; - }); - - const traced$ = source.pipe( - traceScenario(scenario, (value) => ({ metadata: { value } })) + expect(traced.status).toBe('success'); + expect(consoleError).toHaveBeenCalledWith( + '[ngx-ui-watchtower] scenario.traceOutcome failed', + expect.any(Error) ); - - traced$.subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenNthCalledWith(1, { - metadata: { value: 'a' } - }); - - traced$.subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenNthCalledWith(2, undefined); - done(); - } - }); - } - }); }); - describe('teardown without a source complete/error', () => { - it('should cancel the scenario on a manual unsubscribe', () => { - const source = new Subject(); - const subscription = source.pipe(traceScenario(scenario)).subscribe(); - - subscription.unsubscribe(); - - expect(scenario.cancel).toHaveBeenCalledTimes(1); - expect(scenario.complete).not.toHaveBeenCalled(); - expect(scenario.fail).not.toHaveBeenCalled(); - }); - - it('should cancel a superseded scenario when switchMap moves to the next inner observable', () => { - const supersededScenario = { - complete: jest.fn(), - fail: jest.fn(), - cancel: jest.fn(), - isSettled: false - } as unknown as jest.Mocked; - const pendingFirstRequest = new Subject(); - const secondRequest = of('b'); - const trigger = new Subject(); - - trigger - .pipe( - switchMap((n) => - (n === 1 ? pendingFirstRequest : secondRequest).pipe( - traceScenario(n === 1 ? supersededScenario : scenario) - ) - ) - ) - .subscribe(); - - trigger.next(1); // subscribes traceScenario(supersededScenario), still pending - trigger.next(2); // switchMap unsubscribes it before it ever settles - - expect(supersededScenario.cancel).toHaveBeenCalledTimes(1); - expect(supersededScenario.complete).not.toHaveBeenCalled(); - expect(supersededScenario.fail).not.toHaveBeenCalled(); - expect(scenario.complete).toHaveBeenCalledWith(undefined); - }); - - it('should cancel with the configured cancelOutcome on unsubscribe', () => { - const source = new Subject(); - const subscription = source - .pipe( - traceScenario(scenario, { cancelOutcome: { reason: 'superseded' } }) - ) - .subscribe(); - - subscription.unsubscribe(); - - expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); - }); - - it('should accept cancelOutcome as a function, evaluated at unsubscribe time', () => { - const source = new Subject(); - const subscription = source - .pipe( - traceScenario(scenario, { - cancelOutcome: () => ({ reason: 'superseded' }) - }) - ) - .subscribe(); - - subscription.unsubscribe(); - - expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); - }); - - it('should still cancel, with no outcome, when cancelOutcome throws', () => { - const consoleError = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); - const source = new Subject(); - const subscription = source - .pipe( - traceScenario(scenario, { - cancelOutcome: () => { - throw new Error('cancelOutcome is broken'); - } - }) - ) - .subscribe(); - - subscription.unsubscribe(); - - expect(scenario.cancel).toHaveBeenCalledWith(undefined); - consoleError.mockRestore(); - }); - - it('should record a reason for a switchMap-superseded scenario, not just a bare cancel', () => { - const supersededScenario = { - complete: jest.fn(), - fail: jest.fn(), - cancel: jest.fn(), - isSettled: false - } as unknown as jest.Mocked; - const pendingFirstRequest = new Subject(); - const secondRequest = of('b'); - const trigger = new Subject(); - - trigger - .pipe( - switchMap((n) => - (n === 1 ? pendingFirstRequest : secondRequest).pipe( - traceScenario(n === 1 ? supersededScenario : scenario, { - cancelOutcome: { reason: 'superseded' } - }) - ) - ) - ) - .subscribe(); - - trigger.next(1); - trigger.next(2); - - expect(supersededScenario.cancel).toHaveBeenCalledWith({ - reason: 'superseded' - }); - }); - - it('should not cancel a scenario that already completed normally', (done) => { - of('value') - .pipe(traceScenario(scenario)) - .subscribe({ - complete: () => { - (scenario as unknown as { isSettled: boolean }).isSettled = true; - expect(scenario.cancel).not.toHaveBeenCalled(); - done(); - } - }); - }); - - it('should not cancel a scenario that already failed', (done) => { - const error = new Error('boom'); - throwError(() => error) - .pipe(traceScenario(scenario)) - .subscribe({ - error: () => { - (scenario as unknown as { isSettled: boolean }).isSettled = true; - expect(scenario.cancel).not.toHaveBeenCalled(); - done(); - } - }); - }); - }); - - describe('with a real scenario', () => { - let settled: UwtScenarioRecord[]; - - const countRows = (rows: string[]) => ({ - metadata: { count: rows.length } - }); - - function createScenario(name = 'load-customers'): UwtScenario { - return new UwtScenario( - { name }, - { - identity: { - application: 'test-app', - environment: 'test', - version: '1.0.0' - }, - scenarioTelemetryConfig: { - ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario - }, - redact: UWT_DEFAULT_REDACT_CONFIG, - sink: new UwtNoopTelemetrySink(), - onSettled: (_id, record) => settled.push(record) - } - ); - } - - beforeEach(() => { - settled = []; - }); - - describe('single-value sources', () => { - it('should settle as success with subscribe()', () => { - const real = createScenario(); - - of(['a', 'b']).pipe(traceScenario(real, countRows)).subscribe(); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with lastValueFrom', async () => { - const real = createScenario(); - - await lastValueFrom( - of(['a', 'b']).pipe(traceScenario(real, countRows)) - ); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with firstValueFrom', async () => { - const real = createScenario(); - - const rows = await firstValueFrom( - of(['a', 'b']).pipe(traceScenario(real, countRows)) - ); - - expect(rows).toEqual(['a', 'b']); - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with take(1) after the operator', () => { - const real = createScenario(); - - of(['a', 'b']) - .pipe(traceScenario(real, countRows), take(1)) - .subscribe(); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with first() after the operator', () => { - const real = createScenario(); - - of(['a', 'b']) - .pipe(traceScenario(real, countRows), first()) - .subscribe(); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with take(1) before the operator', () => { - const real = createScenario(); - - of(['a', 'b']) - .pipe(take(1), traceScenario(real, countRows)) - .subscribe(); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with take(1) before the operator under firstValueFrom', async () => { - const real = createScenario(); - - await firstValueFrom( - of(['a', 'b']).pipe(take(1), traceScenario(real, countRows)) - ); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - }); - - describe('switchMap superseding an inner stream', () => { - it('should abandon a stream superseded before it emitted', () => { - const superseded = createScenario('superseded'); - const latest = createScenario('latest'); - const pendingFirstRequest = new Subject(); - const trigger = new Subject(); - - trigger - .pipe( - switchMap((n) => - n === 1 - ? pendingFirstRequest.pipe(traceScenario(superseded)) - : of(['b']).pipe(traceScenario(latest)) - ) - ) - .subscribe(); - - trigger.next(1); - trigger.next(2); - - expect(superseded.status).toBe('abandoned'); - expect(latest.status).toBe('success'); - }); - - it('should settle as success a stream superseded after it emitted', () => { - const superseded = createScenario('superseded'); - const latest = createScenario('latest'); - const firstRequest = new Subject(); - const trigger = new Subject(); - - trigger - .pipe( - switchMap((n) => - n === 1 - ? firstRequest.pipe(traceScenario(superseded, countRows)) - : of(['b']).pipe(traceScenario(latest)) - ) - ) - .subscribe(); - - trigger.next(1); - firstRequest.next(['a']); // emitted, but never completed - trigger.next(2); - - expect(superseded.status).toBe('success'); - expect(superseded.toRecord().metadata).toEqual({ count: 1 }); - expect(latest.status).toBe('success'); - }); - }); - - it("should abandon a stream torn down after a value with valueThenTeardown: 'cancel'", () => { - const real = createScenario('poll-status'); - - of(['a'], ['b']) - .pipe(traceScenario(real, { valueThenTeardown: 'cancel' }), take(1)) - .subscribe(); - - expect(real.status).toBe('abandoned'); - }); - - describe('retry placement', () => { - function failOnce(): Observable { - let attempts = 0; - return defer(() => - attempts++ === 0 - ? throwError(() => new Error('transient')) - : of(['a']) - ); - } - - it('should settle as success when retry() comes before the operator', () => { - const real = createScenario(); - - failOnce().pipe(retry(1), traceScenario(real)).subscribe(); - - expect(real.status).toBe('success'); - }); - - it('should settle as failure when retry() comes after the operator, even if the retry succeeds', () => { - const real = createScenario(); + it('should stay silent in production mode', () => { + isDevModeMock.mockReturnValue(false); - failOnce().pipe(traceScenario(real), retry(1)).subscribe(); + of(1).pipe(traceScenario(scenario(), throwingMapper)).subscribe(); - // Documented limitation: the first error settles the scenario and - // the operator cannot see the downstream retry. - expect(real.status).toBe('failure'); - }); - }); + expect(consoleError).not.toHaveBeenCalled(); }); }); diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts index 3b731a1..0303e4f 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts @@ -1,38 +1,13 @@ -import { defer } from 'rxjs'; -import type { MonoTypeOperatorFunction, Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import type { MonoTypeOperatorFunction } from 'rxjs'; import type { UwtScenario, UwtScenarioOutcome } from '@absaoss-cps/ui-watchtower'; -import { uwtSafe } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; - -/** - * Options for configuring scenario completion within an RxJS stream. - * - * @group Interfaces - */ -export interface UwtTraceScenarioOptions { - /** - * Optional mapper that returns an outcome (metadata, statusCode, message) - * based on the emitted value when the Observable completes successfully. - */ - outcome?: (value: T) => UwtScenarioOutcome | void; - - /** - * Outcome used when the stream is unsubscribed before completing or - * erroring — e.g. a `switchMap` moving on to a newer value. - */ - cancelOutcome?: UwtScenarioOutcome | (() => UwtScenarioOutcome | void); - - /** - * What a downstream unsubscribe means once a value has been delivered. - * Default `'complete'`, so `firstValueFrom`, `take(1)` and `first()` settle - * as success. Use `'cancel'` for multi-value streams (polling, progress), - * where stopping after a value is abandonment. - */ - valueThenTeardown?: 'complete' | 'cancel'; -} +import { + traceScenario as coreTraceScenario, + UwtTraceScenarioOptions +} from '@absaoss-cps/ui-watchtower/rxjs'; +import { uwtDevModeReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; /** * An RxJS pipeable operator that automatically settles a {@link UwtScenario} @@ -60,60 +35,5 @@ export function traceScenario( options?: UwtTraceScenarioOptions | ((value: T) => UwtScenarioOutcome | void) ): MonoTypeOperatorFunction { - const outcomeMapper = - typeof options === 'function' ? options : options?.outcome; - const cancelOutcome = - typeof options === 'function' ? undefined : options?.cancelOutcome; - const valueThenTeardown = - (typeof options === 'function' ? undefined : options?.valueThenTeardown) ?? - 'complete'; - - return (source: Observable): Observable => - defer(() => { - let lastValue: T | undefined; - let hasValue = false; - - const complete = () => { - const outcome = - hasValue && outcomeMapper - ? uwtSafe( - 'scenario.traceOutcome', - () => outcomeMapper(lastValue as T), - undefined - ) - : undefined; - scenario.complete(outcome || undefined); - }; - - const unsubscribe = () => { - if (scenario.isSettled) { - return; - } - // The consumer got a value and stopped listening, as - // firstValueFrom, take(1) and first() do: treat it as completion. - if (hasValue && valueThenTeardown === 'complete') { - complete(); - return; - } - const resolved = - typeof cancelOutcome === 'function' - ? uwtSafe('scenario.traceCancelOutcome', cancelOutcome, undefined) - : cancelOutcome; - scenario.cancel(resolved || undefined); - }; - - return source.pipe( - tap({ - next: (value) => { - lastValue = value; - hasValue = true; - }, - complete, - error: (error: unknown) => { - scenario.fail({ error }); - }, - unsubscribe - }) - ); - }); + return coreTraceScenario(scenario, options, uwtDevModeReporter); } diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts index fd95a92..a5d9a50 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts @@ -1,11 +1,8 @@ import { inject, Injectable } from '@angular/core'; import { - UwtBIEvent, UwtBIEventDetail, UwtBIEventName, - uwtEventTypes, - uwtNow, - uwtRedactConfigFor, + UwtBITelemetry, UwtTelemetryMetadata, UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; @@ -15,15 +12,7 @@ import { UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UWT_BI_TELEMETRY_CONFIG } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; -import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; -import { - uwtRedactMetadata, - uwtScrubString -} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { - uwtSafe, - uwtSafeVoid -} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { uwtDevModeReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; /** * Business and UX event tracking — feature adoption, interaction analysis, @@ -60,18 +49,15 @@ import { */ @Injectable({ providedIn: 'root' }) export class UwtBITelemetryService { - private readonly identity = inject(UWT_TELEMETRY_IDENTITY); - private readonly biConfig = inject(UWT_BI_TELEMETRY_CONFIG); - private readonly redact = uwtRedactConfigFor( - inject(UWT_REDACT_CONFIG), - this.biConfig.redact - ); - - private readonly sink = inject(UwtTelemetrySink); - private readonly enabled = uwtTelemetryEnabled(); - - private readonly eventTypes = uwtEventTypes(this.identity.eventNamespace); - private readonly lastEmittedAt = new Map(); + /** The core's BI telemetry, configured from this injector. */ + private readonly bi = new UwtBITelemetry({ + identity: inject(UWT_TELEMETRY_IDENTITY), + config: inject(UWT_BI_TELEMETRY_CONFIG), + redact: inject(UWT_REDACT_CONFIG), + sink: inject(UwtTelemetrySink), + enabled: uwtTelemetryEnabled(), + report: uwtDevModeReporter + }); /** * Records a business or UX event. @@ -87,123 +73,6 @@ export class UwtBITelemetryService { metadata?: UwtTelemetryMetadata, detail?: UwtBIEventDetail ): void { - if (!this.enabled) { - return; - } - uwtSafeVoid('biTelemetry.track', () => { - if (!eventName) { - return; - } - - const redactedFeature = detail?.feature - ? uwtScrubString(detail.feature, this.redact) - : undefined; - const redactedMetadata = uwtRedactMetadata(metadata, this.redact); - - if ( - this.isDuplicate(eventName, redactedMetadata, { - ...detail, - feature: redactedFeature - }) - ) { - return; - } - - const event: UwtBIEvent = { - eventName, - eventTime: new Date().toISOString(), - scenarioId: detail?.scenarioId, - feature: redactedFeature, - metadata: redactedMetadata, - application: this.identity.application, - sessionId: uwtSafe( - 'biTelemetry.getSessionId', - () => this.sink.getSessionId(), - undefined - ), - userId: uwtSafe( - 'biTelemetry.getUserId', - () => this.sink.getUserId(), - undefined - ) - }; - - uwtDebugWrite('debugBI', () => - writeToConsole(eventName, this.eventTypes.bi, event) - ); - - this.sink.record(this.eventTypes.bi, event); - }); - } - - private isDuplicate( - eventName: string, - metadata: UwtTelemetryMetadata | undefined, - detail?: UwtBIEventDetail - ): boolean { - const key = JSON.stringify([ - eventName, - detail?.scenarioId ?? '', - detail?.feature ?? '', - this.metadataKey(metadata) - ]); - const now = uwtNow(); - const last = this.lastEmittedAt.get(key); - - if (last !== undefined && now - last < this.biConfig.dedupWindowMs) { - this.lastEmittedAt.delete(key); - this.lastEmittedAt.set(key, last); - return true; - } - - if (this.lastEmittedAt.size >= this.biConfig.dedupMaxKeys) { - for (const [staleKey, at] of this.lastEmittedAt) { - if (now - at >= this.biConfig.dedupWindowMs) { - this.lastEmittedAt.delete(staleKey); - } - } - - if (this.lastEmittedAt.size >= this.biConfig.dedupMaxKeys) { - const oldestKey = this.lastEmittedAt.keys().next().value; - if (oldestKey !== undefined) { - this.lastEmittedAt.delete(oldestKey); - } - } - } - - this.lastEmittedAt.delete(key); - this.lastEmittedAt.set(key, now); - return false; - } - - /** - * A stable string encoding a flat metadata object's content, for use in - * the dedup key — otherwise two same-named events with different metadata - * would collide and the second would be silently dropped. Keys are sorted - * so property order doesn't affect the result. Callers pass already- - * redacted metadata — a caller's raw values must never end up as a `Map` - * key living in this root service's memory for the page lifetime. - */ - private metadataKey(metadata: UwtTelemetryMetadata | undefined): string { - if (!metadata) { - return ''; - } - return JSON.stringify( - Object.keys(metadata) - .sort() - .map((k) => [k, metadata[k]]) - ); + this.bi.track(eventName, metadata, detail); } } - -/** - * Prefixed with the application - in a composed - * page every realm writes to the one console. - */ -function writeToConsole( - eventName: string, - eventType: string, - event: UwtBIEvent -): void { - console.log(`[${event.application}][bi] ${eventName} -> ${eventType}`, event); -} diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts index 271e2cd..8469578 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts @@ -18,7 +18,8 @@ import { withRedaction } from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import * as uwtTelemetryRedactUtil from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +// The logging logic lives in the core, which calls its own redaction module. +import * as uwtTelemetryRedactUtil from '../../../../../ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; import { UwtLoggerService } from './uwt-logger.service'; /** Captures what the library emitted, so a test can assert on it. */ diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts index 707f063..b568848 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts @@ -1,14 +1,10 @@ -import { DOCUMENT } from '@angular/common'; import { inject, Injectable, OnDestroy } from '@angular/core'; import { - UWT_LOG_LEVEL_ORDER, - UwtLogDetail, UwtLogger, UwtLoggerName, - UwtLogLevel, UwtLogQuery, UwtLogRecord, - uwtRedactConfigFor, + UwtLogTelemetry, UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; import { @@ -18,26 +14,11 @@ import { } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UWT_LOG_CONFIG } from '../../config/uwt-log.config/uwt-log.config'; import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; import { - uwtNormalizeError, - uwtRedactMetadata, - uwtScrubString -} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { - uwtIsBrowser, - uwtSafe, - uwtSafeVoid, - uwtSafeVoidMaybeAsync + uwtBrowserDocument, + uwtDevModeReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -/** Handed out while telemetry is off: every call does nothing. */ -const INERT_LOGGER: UwtLogger = Object.freeze({ - log: () => undefined, - warn: () => undefined, - error: () => undefined -}); - /** * Structured application logging. * @@ -77,64 +58,22 @@ const INERT_LOGGER: UwtLogger = Object.freeze({ */ @Injectable({ providedIn: 'root' }) export class UwtLoggerService implements OnDestroy { - private readonly identity = inject(UWT_TELEMETRY_IDENTITY); - private readonly logsConfig = inject(UWT_LOG_CONFIG); - private readonly redact = uwtRedactConfigFor( - inject(UWT_REDACT_CONFIG), - this.logsConfig.redact - ); - - /** Enrichment only (`sessionId`/`userId`, optional RUM mirroring). */ - private readonly sink = inject(UwtTelemetrySink, { optional: true }); - private readonly apiProvider = inject(UWT_LOG_API_PROVIDER); - private readonly document = inject(DOCUMENT); - private readonly enabled = uwtTelemetryEnabled(); - - /** Page listeners: only in a browser, and only while telemetry is on. */ - private readonly listensToPage = uwtIsBrowser() && this.enabled; - - private readonly onPageHide = () => this.flushProvider(); - private readonly onVisibilityChange = () => { - if (this.document.visibilityState === 'hidden') { - this.flushProvider(); - } - }; - - /** - * One logger per name, so {@link getLogger} is idempotent. - * - * Unbounded growth is not a concern the way it is for active scenarios: - * names come from the closed {@link UwtLoggerNames} vocabulary, so this - * map is bounded by what the application declared at build time. - */ - private readonly loggers = new Map(); - - constructor() { - if (this.listensToPage) { - this.document.defaultView?.addEventListener('pagehide', this.onPageHide); - - this.document.addEventListener( - 'visibilitychange', - this.onVisibilityChange - ); - } - } + /** The core's logging, configured from this injector. */ + private readonly logging = new UwtLogTelemetry({ + identity: inject(UWT_TELEMETRY_IDENTITY), + config: inject(UWT_LOG_CONFIG), + redact: inject(UWT_REDACT_CONFIG), + // Enrichment only (`sessionId`/`userId`, optional RUM mirroring). + sink: inject(UwtTelemetrySink, { optional: true }), + logApi: inject(UWT_LOG_API_PROVIDER), + document: uwtBrowserDocument(), + enabled: uwtTelemetryEnabled(), + report: uwtDevModeReporter + }); /** @inheritdoc */ ngOnDestroy(): void { - if (this.listensToPage) { - this.document.defaultView?.removeEventListener( - 'pagehide', - this.onPageHide - ); - this.document.removeEventListener( - 'visibilitychange', - this.onVisibilityChange - ); - } - if (this.enabled) { - this.flushProvider(); - } + this.logging.dispose(); } /** @@ -158,17 +97,8 @@ export class UwtLoggerService implements OnDestroy { * @param filter narrows what is returned * @returns the matching records, or `[]` when none can be read */ - async query(filter: UwtLogQuery = {}): Promise { - if (!this.enabled) { - return []; - } - const pending = uwtSafe( - 'logger.query', - () => this.apiProvider.query(filter), - undefined - ); - - return (await pending?.catch(() => undefined)) ?? []; + query(filter: UwtLogQuery = {}): Promise { + return this.logging.query(filter); } /** @@ -203,143 +133,6 @@ export class UwtLoggerService implements OnDestroy { * @returns the logger for that name, created once and reused */ getLogger(name: UwtLoggerName): UwtLogger { - if (!this.enabled) { - return INERT_LOGGER; - } - const cached = uwtSafe( - 'logger.getLogger', - () => this.loggers.get(name), - undefined - ); - if (cached) { - return cached; - } - - const logger = this.createLogger(name); - uwtSafeVoid('logger.register', () => this.loggers.set(name, logger)); - return logger; - } - - /** - * Builds the logger for one name. - * - * Private, and reached only through {@link getLogger}, so every logger - * handed out is registered and named. The name is captured here rather - * than read from each call's detail, which is why a record's `logger` - * cannot disagree with the logger that wrote it. - * - * @param name the logger name stamped onto every record it writes - * @returns a logger bound to that name - */ - private createLogger(name: UwtLoggerName): UwtLogger { - return { - log: (message, detail) => this.emit('log', name, message, detail), - warn: (message, detail) => this.emit('warn', name, message, detail), - error: (message, detail) => this.emit('error', name, message, detail) - }; + return this.logging.getLogger(name); } - - /** Severity floor for one logger — its own override, or the global one. */ - private minLevelFor(logger: UwtLoggerName): UwtLogLevel { - const { levels, minLevel } = this.logsConfig; - return levels?.[logger] ?? minLevel; - } - - private emit( - level: UwtLogLevel, - logger: UwtLoggerName, - message: string, - detail?: UwtLogDetail - ): void { - uwtSafeVoid(`logger.${level}`, () => { - if ( - UWT_LOG_LEVEL_ORDER[level] < - UWT_LOG_LEVEL_ORDER[this.minLevelFor(logger)] - ) { - return; - } - - const record = this.buildRecord(level, logger, message, detail); - - uwtDebugWrite('debugLogger', () => writeToConsole(record), record.logger); - - this.deliver(record); - - if (level === 'error' && this.logsConfig.mirrorErrorsToRum && this.sink) { - const mirrored = - record.error ?? - uwtNormalizeError(new Error(record.message), this.redact); - if (mirrored) { - this.sink.recordError(mirrored); - } - } - }); - } - - /** Guards against a throwing or secretly-async, rejecting provider. */ - private deliver(record: UwtLogRecord): void { - uwtSafeVoidMaybeAsync('logger.deliver', () => - this.apiProvider.send(record) - ); - } - - /** Gives the provider its chance to ship whatever it has queued itself. */ - private flushProvider(): void { - uwtSafeVoidMaybeAsync('logger.providerFlush', () => - this.apiProvider.flush?.() - ); - } - - private buildRecord( - level: UwtLogLevel, - logger: UwtLoggerName, - message: string, - detail?: UwtLogDetail - ): UwtLogRecord { - const redact = this.redact; - const sink = this.sink; - const identity = this.identity; - - return { - timestamp: new Date().toISOString(), - level, - message: uwtScrubString(String(message ?? ''), redact), - logger, - context: detail?.context - ? uwtScrubString(detail.context, redact) - : undefined, - metadata: uwtRedactMetadata(detail?.metadata, redact), - error: uwtNormalizeError(detail?.error, redact), - correlationId: detail?.correlationId - ? uwtScrubString(detail.correlationId, redact) - : undefined, - application: identity.application, - environment: identity.environment, - version: identity.version, - ...(sink && { - userId: uwtSafe('getUserId', () => sink.getUserId(), undefined), - sessionId: uwtSafe('getSessionId', () => sink.getSessionId(), undefined) - }) - }; - } -} - -/** - * Prints the record exactly as the transport receives it. - * - * Prefixed with the application - in a composed - * page every realm writes to the one console - then the logger name and - * the context, each when present. - */ -function writeToConsole(record: UwtLogRecord): void { - const scope = [record.logger, record.context] - .filter(Boolean) - .map((part) => `[${part}]`) - .join(''); - const suffix = record.correlationId ? ` (${record.correlationId})` : ''; - - console[record.level]( - `[${record.application}]${scope} ${record.message}${suffix}`, - record - ); } diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts index ccb9798..731717d 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts @@ -1,13 +1,11 @@ -import { DOCUMENT } from '@angular/common'; -import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; +import { inject, Injectable, OnDestroy } from '@angular/core'; import { Observable, Subject } from 'rxjs'; import { - uwtDeepClone, - uwtRedactConfigFor, UwtScenario, UwtScenarioName, UwtScenarioOptions, UwtScenarioRecord, + UwtScenarioTelemetry, UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; import { @@ -17,24 +15,11 @@ import { } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UWT_SCENARIO_TELEMETRY_CONFIG } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; import { + uwtBrowserDocument, uwtDevModeReporter, - uwtIsBrowser, - uwtIsDevMode, - uwtSafeVoid + uwtRunOutsideZone } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -/** - * How many scenarios may be in flight before a development-mode warning - * suggests a leak. - * - * Concurrent scenarios are legitimate — several journeys, tabs or panels - * can run at once — so this is a smell threshold, not a limit: nothing is - * dropped or rejected when it is crossed. Set well above any plausible - * real concurrency, so crossing it might mean scenarios with `timeoutMs: 0` or - * long lifecycles are being started and never settled. - */ -const ACTIVE_SCENARIO_WARN_THRESHOLD = 50; - /** * Creates and tracks scenarios — user journeys whose health this telemetry * layer measures. @@ -59,31 +44,17 @@ const ACTIVE_SCENARIO_WARN_THRESHOLD = 50; */ @Injectable({ providedIn: 'root' }) export class UwtScenarioTelemetryService implements OnDestroy { - private readonly identity = inject(UWT_TELEMETRY_IDENTITY); - private readonly scenarioTelemetryConfig = inject( - UWT_SCENARIO_TELEMETRY_CONFIG - ); - - private readonly redact = uwtRedactConfigFor( - inject(UWT_REDACT_CONFIG), - this.scenarioTelemetryConfig.redact - ); - - private readonly sink = inject(UwtTelemetrySink); - private readonly document = inject(DOCUMENT); - private readonly enabled = uwtTelemetryEnabled(); - - /** Page listeners: only in a browser, and only while telemetry is on. */ - private readonly listensToPage = uwtIsBrowser() && this.enabled; - private readonly zone = inject(NgZone, { optional: true }); - - private readonly active = new Map(); - private readonly onPageHide = () => this.flushActive(); - private readonly onVisibilityChange = () => { - if (this.document.visibilityState === 'hidden') { - this.flushSink(); - } - }; + /** The core's scenario telemetry, configured from this injector. */ + private readonly scenarios = new UwtScenarioTelemetry({ + identity: inject(UWT_TELEMETRY_IDENTITY), + config: inject(UWT_SCENARIO_TELEMETRY_CONFIG), + redact: inject(UWT_REDACT_CONFIG), + sink: inject(UwtTelemetrySink), + document: uwtBrowserDocument(), + enabled: uwtTelemetryEnabled(), + runOutside: uwtRunOutsideZone(), + report: uwtDevModeReporter + }); private readonly _settled$ = new Subject(); @@ -104,14 +75,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { this._settled$.asObservable(); constructor() { - if (this.listensToPage) { - this.document.defaultView?.addEventListener('pagehide', this.onPageHide); - - this.document.addEventListener( - 'visibilitychange', - this.onVisibilityChange - ); - } + this.scenarios.onSettled((record) => this._settled$.next(record)); } /** @@ -122,48 +86,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { * @returns an independent scenario instance */ start(options: UwtScenarioOptions): UwtScenario { - if (!this.enabled) { - // Same return type, nothing recorded, timed, registered or sent. - return new UwtScenario(options, { - identity: this.identity, - scenarioTelemetryConfig: this.scenarioTelemetryConfig, - redact: this.redact, - sink: this.sink, - onSettled: () => undefined, - inert: true - }); - } - const scenario = new UwtScenario(options, { - identity: this.identity, - scenarioTelemetryConfig: this.scenarioTelemetryConfig, - redact: this.redact, - sink: this.sink, - runOutside: (callback) => - this.zone?.runOutsideAngular(callback) ?? callback(), - report: uwtDevModeReporter, - onSettled: (scenarioId, record) => { - this.active.delete(scenarioId); - if (!this._settled$.observed) { - return; - } - // Cloned so a settled$ subscriber can't mutate the same object - // that's about to be (or already was) shipped to the sink. - uwtSafeVoid('scenarioTelemetry.notify', () => - this._settled$.next(uwtDeepClone(record)) - ); - } - }); - - uwtSafeVoid('scenarioTelemetry.register', () => { - this.active.set(scenario.id, scenario); - if (uwtIsDevMode() && this.active.size > ACTIVE_SCENARIO_WARN_THRESHOLD) { - console.warn( - `[ngx-ui-watchtower] High number of active scenarios (${this.active.size}). Ensure scenarios with timeoutMs: 0 or long lifecycles are settled on destroy.` - ); - } - }); - - return scenario; + return this.scenarios.start(options); } /** @@ -173,7 +96,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { * @returns the scenario, or `undefined` if unknown or already settled */ find(scenarioId: string): UwtScenario | undefined { - return this.active.get(scenarioId); + return this.scenarios.find(scenarioId); } /** @@ -189,7 +112,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { * @returns matching active scenarios, in start order */ findByName(name: UwtScenarioName): UwtScenario[] { - return this.getActive().filter((scenario) => scenario.name === name); + return this.scenarios.findByName(name); } /** @@ -208,8 +131,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { name: UwtScenarioName, scenarioId: string ): UwtScenario | undefined { - const scenario = this.find(scenarioId); - return scenario?.name === name ? scenario : undefined; + return this.scenarios.findByNameAndId(name, scenarioId); } /** @@ -218,52 +140,12 @@ export class UwtScenarioTelemetryService implements OnDestroy { * @returns the active scenarios, in start order */ getActive(): UwtScenario[] { - return [...this.active.values()]; + return this.scenarios.getActive(); } /** @inheritdoc */ ngOnDestroy(): void { - if (this.listensToPage) { - this.document.defaultView?.removeEventListener( - 'pagehide', - this.onPageHide - ); - this.document.removeEventListener( - 'visibilitychange', - this.onVisibilityChange - ); - } - if (this.enabled) { - this.flushActive(); - } + this.scenarios.dispose(); this._settled$.complete(); } - - /** - * Dispatches what the sink holds, leaving running scenarios alone. - * - * Uses the beacon transport since the page may not survive a normal request. - */ - private flushSink(): void { - uwtSafeVoid('scenarioTelemetry.flushSink', () => this.sink.flush(true)); - } - - /** - * Settles every in-flight scenario as abandoned and asks the sink to send - * what it holds using a transport that survives unload. - * - * Scenario emission is synchronous, so everything settled here reaches the - * sink before the beacon goes out. - */ - private flushActive(): void { - uwtSafeVoid('scenarioTelemetry.flush', () => { - for (const scenario of this.getActive()) { - scenario.settle('abandoned', { - reason: 'page-hidden', - metadata: { abandonedBy: 'page-hidden' } - }); - } - this.sink.flush(true); - }); - } } diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts index 017233a..98a6dea 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts @@ -1,9 +1,5 @@ import { Injectable } from '@angular/core'; -import { - UwtTelemetryError, - UwtTelemetryMetadata, - UwtTelemetrySink -} from '@absaoss-cps/ui-watchtower'; +import { UwtNoopTelemetrySink as CoreNoopTelemetrySink } from '@absaoss-cps/ui-watchtower'; /** * Sink that discards everything. @@ -15,37 +11,4 @@ import { * @group Services */ @Injectable() -export class UwtNoopTelemetrySink extends UwtTelemetrySink { - private userId?: string; - - /** @inheritdoc */ - record( - _eventType: string, - _payload: object, - _metadata?: UwtTelemetryMetadata - ): void {} - - /** @inheritdoc */ - recordError( - _error: UwtTelemetryError, - _metadata?: UwtTelemetryMetadata - ): void {} - - /** @inheritdoc */ - getSessionId(): string | undefined { - return undefined; - } - - /** @inheritdoc */ - setUserId(userId: string | undefined): void { - this.userId = userId; - } - - /** @inheritdoc */ - getUserId(): string | undefined { - return this.userId; - } - - /** @inheritdoc */ - flush(_beacon?: boolean): void {} -} +export class UwtNoopTelemetrySink extends CoreNoopTelemetrySink {} diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts deleted file mode 100644 index 89a42e5..0000000 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - UwtDebugFlag, - uwtDebugWrite as coreDebugWrite -} from '@absaoss-cps/ui-watchtower'; -import { uwtDevModeReporter } from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; - -/** - * Writes a debug line to the console, but only when `flag` is on, and never - * at the caller's expense: the core's, reporting a failing write through - * {@link uwtDevModeReporter}. - * - * @param flag the debug switch that has to be on - * @param write performs the console call; never runs when the flag is off - * @param name the name to test against a list-valued flag, e.g. a logger - * name for `debugLogger` - */ -export function uwtDebugWrite( - flag: UwtDebugFlag, - write: () => void, - name?: string -): void { - coreDebugWrite(flag, write, name, uwtDevModeReporter); -} diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts index 57dacc2..103f03e 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts +++ b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts @@ -1,5 +1,5 @@ -import { isPlatformBrowser } from '@angular/common'; -import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { inject, isDevMode, NgZone, PLATFORM_ID } from '@angular/core'; import { UwtReporter, uwtSafe as coreSafe, @@ -24,6 +24,25 @@ export function uwtIsBrowser(): boolean { return isPlatformBrowser(inject(PLATFORM_ID)); } +/** + * The page telemetry watches: the document in a browser, nothing in a + * server-side render. Must be called from an injection context. + */ +export function uwtBrowserDocument(): Document | null { + const document = inject(DOCUMENT); + return uwtIsBrowser() ? document : null; +} + +/** + * Runs a callback outside Angular's zone when there is one, so telemetry's + * long-lived timers never keep the application from becoming stable. Must + * be called from an injection context. + */ +export function uwtRunOutsideZone(): (callback: () => T) => T { + const zone = inject(NgZone, { optional: true }); + return (callback) => zone?.runOutsideAngular(callback) ?? callback(); +} + /** * How this package reports what telemetry handles silently: to the console, * prefixed, and only in development mode. Everything the core does on this diff --git a/projects/ngx-ui-watchtower/src/public-api.ts b/projects/ngx-ui-watchtower/src/public-api.ts index f9078e4..9bac8c6 100644 --- a/projects/ngx-ui-watchtower/src/public-api.ts +++ b/projects/ngx-ui-watchtower/src/public-api.ts @@ -110,7 +110,7 @@ export { UwtNoopTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-noop-telemet export { traceScenario } from './lib/scenario/uwt-scenario-operators/uwt-scenario-operators'; export { uwtClassifyTelemetryEvent } from '@absaoss-cps/ui-watchtower'; export type { UwtTelemetrySinkEvent } from '@absaoss-cps/ui-watchtower'; -export type { UwtTraceScenarioOptions } from './lib/scenario/uwt-scenario-operators/uwt-scenario-operators'; +export type { UwtTraceScenarioOptions } from '@absaoss-cps/ui-watchtower/rxjs'; export { uwtIsDebugEnabled, uwtUuid } from '@absaoss-cps/ui-watchtower'; export type { UwtDebugFlag } from '@absaoss-cps/ui-watchtower'; export { uwtMarkSessionStart } from '@absaoss-cps/ui-watchtower'; diff --git a/projects/ngx-ui-watchtower/tsconfig.lib.json b/projects/ngx-ui-watchtower/tsconfig.lib.json index db304db..d8e13ec 100644 --- a/projects/ngx-ui-watchtower/tsconfig.lib.json +++ b/projects/ngx-ui-watchtower/tsconfig.lib.json @@ -16,7 +16,8 @@ // The core as published: its built declarations, not its source. Build // it first (npm run build does). "paths": { - "@absaoss-cps/ui-watchtower": ["../../dist/ui-watchtower"] + "@absaoss-cps/ui-watchtower": ["../../dist/ui-watchtower"], + "@absaoss-cps/ui-watchtower/rxjs": ["../../dist/ui-watchtower/rxjs"] } }, "include": ["src/**/*.ts"], diff --git a/projects/ngx-ui-watchtower/type-tests/compat.ts b/projects/ngx-ui-watchtower/type-tests/compat.ts index 97cf617..1b2731b 100644 --- a/projects/ngx-ui-watchtower/type-tests/compat.ts +++ b/projects/ngx-ui-watchtower/type-tests/compat.ts @@ -7,7 +7,7 @@ * registry is augmented here, so every name is a plain string; see * `augmented.ts` for registered names. */ -import { ApplicationConfig, inject, Injectable } from '@angular/core'; +import { ApplicationConfig, inject, Injectable, NgModule } from '@angular/core'; import { Observable, of } from 'rxjs'; import { provideUwtTelemetry, @@ -340,3 +340,27 @@ export interface Everything { stepName: UwtStepName; error: UwtTelemetryError; } + +/** + * Every injectable class an application may list as a provider in a + * decorator. That each keeps its Angular metadata (`ɵfac`, `ɵprov`) is held + * by the API snapshot; the compiler's own check skips library classes. + */ +@NgModule({ + providers: [ + UwtScenarioTelemetryService, + UwtBITelemetryService, + UwtLoggerService, + UwtNoopTelemetrySink, + UwtNoopLogApiProvider, + UwtBroadcastTelemetrySink, + UwtBroadcastLogApiProvider, + UwtTelemetryBroadcastHost, + UwtRumTelemetrySink, + AppSink, + AppLogBackend, + AppRumCredentials, + { provide: UwtTelemetrySink, useClass: UwtNoopTelemetrySink } + ] +}) +export class AppTelemetryModule {} diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md index a58f076..24a61f0 100644 --- a/projects/ui-watchtower/README.md +++ b/projects/ui-watchtower/README.md @@ -5,20 +5,72 @@ The framework-independent engine behind scenario health telemetry, business/UX events and application logs, for any JavaScript or TypeScript application. -> **Work in progress.** The engine is moving here from the Angular package in -> stages: the models, scenarios, redaction and contracts are here; the engine -> that wires them together is next. Until it lands, use -> `@absaoss-cps/ngx-ui-watchtower`; it installs this package by itself. - It imports no framework and touches no browser global when imported, so it loads in Node, on a server and in a worker as well as in a browser. +> **Work in progress.** The broadcast and AWS RUM destinations are still +> moving here from the Angular package. + ## Install ```bash npm install @absaoss-cps/ui-watchtower +npm install rxjs # only for @absaoss-cps/ui-watchtower/rxjs +``` + +## Use + +Create one engine per application, with its identity, where telemetry goes +and where logs go: + +```ts +import { + uwtCreateTelemetry, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink +} from '@absaoss-cps/ui-watchtower'; + +const telemetry = uwtCreateTelemetry({ + identity: { application: 'cart', environment: 'prod', version: '1.4.0' }, + destination: new UwtNoopTelemetrySink(), // or your own UwtTelemetrySink + logApi: new UwtNoopLogApiProvider(), // or your own UwtLogApiProvider + logging: { minLevel: 'warn' } // optional overrides, per concern +}); + +// Scenarios: a user journey, its steps and outcome. +const scenario = telemetry.scenarios.start({ name: 'checkout' }); +scenario.step('pay'); +scenario.complete({ metadata: { items: 2 } }); + +// BI events. +telemetry.bi.track('export_clicked', { format: 'csv' }); + +// Logs, by named logger. +telemetry.logging.getLogger('checkout').warn('Slow payment'); + +// When the application, or a fragment, goes away. +telemetry.dispose(); ``` +Every call is fail-open: telemetry never throws into your application. +Configuration errors still throw, when the engine is created. + +| Option | What it does | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `identity` | Application, environment and version, carried by every record | +| `destination` | A `UwtTelemetrySink`: where scenarios, BI events and mirrored errors go | +| `logApi` | A `UwtLogApiProvider`: where logs go, and are read back from | +| `scenarios`, `logging`, `bi`, `redaction` | Overrides of each concern's defaults | +| `enabled` | `false` makes everything inert: nothing is recorded, sent or logged | +| `document` | The page whose lifecycle settles and flushes telemetry; the global one by default, `null` for none | +| `report` | Receives what telemetry skips or drops; nothing is reported without one | +| `runOutside` | Schedules timers outside a framework's change detection | + +`dispose()` stops watching the page, settles scenarios still in flight as +abandoned and flushes; the engine does nothing after that, and leaves no +listener or timer behind. The destination and log store you passed are +yours to close. + ## Declare your names Scenario, step, event and logger names are typed, so a typo is a compile @@ -50,9 +102,8 @@ registries: either module path reaches the same ones. ## Seeing what telemetry skips -Telemetry never throws into your application, and the engine prints nothing -by itself. Hand it a `UwtReporter` where you want to see what it skipped or -dropped, typically in development: +The engine prints nothing by itself. Hand it a `UwtReporter` where you want +to see what it skipped or dropped, typically in development: ```ts import { UwtReporter } from '@absaoss-cps/ui-watchtower'; @@ -61,17 +112,31 @@ const report: UwtReporter = (level, message, ...details) => console[level](`[my-app] ${message}`, ...details); ``` +## RxJS + +`traceScenario` settles a scenario from an Observable's outcome. It lives in +its own entry point, so the main one never loads RxJS: + +```ts +import { traceScenario } from '@absaoss-cps/ui-watchtower/rxjs'; + +customers$.pipe(traceScenario(scenario)).subscribe(); +``` + ## API | Group | Exports | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| Configuration | `UwtTelemetryIdentity`, `UwtScenarioTelemetryConfig`, `UwtLogConfig`, `UwtBITelemetryConfig`, `UWT_DEFAULT_TELEMETRY_CONFIG` | +| Engine | `uwtCreateTelemetry`, `UwtTelemetry`, `UwtTelemetryOptions`, `UwtTelemetryDocument` | +| Concerns | `UwtScenarioTelemetry`, `UwtBITelemetry`, `UwtLogTelemetry` and their options, for an adapter composing them itself | +| Configuration | `UwtTelemetryIdentity`, the per-concern config types, `UWT_DEFAULT_TELEMETRY_CONFIG`, `uwtResolveTelemetryConfig` | | Models | scenario, step, BI event and log record types; `UWT_LOG_LEVEL_ORDER`, `UWT_TELEMETRY_EVENT_TYPE`, `uwtEventTypes` | | Names | `UwtScenarioNames`, `UwtScenarioSteps`, `UwtBIEventNames`, `UwtLoggerNames` and their `Uwt*Name` types | | Scenarios | `UwtScenario` | -| Contracts | `UwtTelemetrySink` (a destination), `UwtLogApiProvider` and `UwtLogQuery` (a log backend) | +| Contracts | `UwtTelemetrySink` and `UwtNoopTelemetrySink`; `UwtLogApiProvider`, `UwtLogQuery` and `UwtNoopLogApiProvider` | | Redaction | `uwtRedactMetadata`, `uwtScrubString`, `uwtNormalizeError`, `uwtRedactConfigFor`, `UwtRedactConfig`, `UWT_DEFAULT_REDACT_CONFIG`, `UWT_REDACTED` | -| Utilities | `uwtClassifyTelemetryEvent`, `uwtIsDebugEnabled`, `uwtMarkSessionStart`, `uwtUuid`, `UwtReporter` | +| Utilities | `uwtClassifyTelemetryEvent`, `uwtIsDebugEnabled`, `uwtMarkSessionStart`, `uwtUuid`, `uwtSafe*`, `UwtReporter` | +| `/rxjs` | `traceScenario`, `UwtTraceScenarioOptions` | ## License diff --git a/projects/ui-watchtower/jest.config.js b/projects/ui-watchtower/jest.config.js index 992afc8..05504b1 100644 --- a/projects/ui-watchtower/jest.config.js +++ b/projects/ui-watchtower/jest.config.js @@ -7,18 +7,19 @@ module.exports = { displayName: 'ui-watchtower', rootDir: __dirname, - roots: ['/src'], + roots: ['/src', '/rxjs'], testEnvironment: 'node', transform: { '^.+\\.ts$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] }, moduleNameMapper: { - '^@absaoss-cps/ui-watchtower$': '/src/public-api.ts' + '^@absaoss-cps/ui-watchtower$': '/src/public-api.ts', + '^@absaoss-cps/ui-watchtower/rxjs$': '/rxjs/src/public-api.ts' }, coverageDirectory: '/../../coverage/ui-watchtower', collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.spec.ts', - '!src/**/public-api.ts' + '{src,rxjs}/**/*.ts', + '!**/*.spec.ts', + '!**/public-api.ts' ] }; diff --git a/projects/ui-watchtower/package.json b/projects/ui-watchtower/package.json index 9056eb2..4b08307 100644 --- a/projects/ui-watchtower/package.json +++ b/projects/ui-watchtower/package.json @@ -8,6 +8,14 @@ }, "homepage": "https://github.com/AbsaOSS/ui-watchtower", "license": "Apache-2.0", + "peerDependencies": { + "rxjs": "^7.8.2" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + }, "dependencies": { "tslib": "^2.8.1" }, diff --git a/projects/ui-watchtower/public-api.snapshot.json b/projects/ui-watchtower/public-api.snapshot.json index 1989156..b38bf28 100644 --- a/projects/ui-watchtower/public-api.snapshot.json +++ b/projects/ui-watchtower/public-api.snapshot.json @@ -40,24 +40,36 @@ "UwtBIEventNames": [ "interface" ], + "UwtBITelemetry": [ + "class", + "new (options: UwtBITelemetryOptions): UwtBITelemetry", + "dispose(): void", + "track(eventName: string, metadata?: UwtTelemetryMetadata | undefined, detail?: UwtBIEventDetail | undefined): void" + ], "UwtBITelemetryConfig": [ "interface", "dedupMaxKeys: number", "dedupWindowMs: number", "redact: boolean" ], + "UwtBITelemetryOptions": [ + "interface", + "config: UwtBITelemetryConfig", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "redact: UwtRedactConfig", + "report?: UwtReporter | undefined", + "sink: UwtTelemetrySink" + ], "uwtClassifyTelemetryEvent": [ "function(eventType: string, payload: object): UwtTelemetrySinkEvent" ], + "uwtCreateTelemetry": [ + "function(options: UwtTelemetryOptions): UwtTelemetry" + ], "UwtDebugFlag": [ "type = 'debugLogger' | 'debugScenario' | 'debugBI'" ], - "uwtDebugWrite": [ - "function(flag: UwtDebugFlag, write: () => void, name?: string | undefined, report?: UwtReporter | undefined): void" - ], - "uwtDeepClone": [ - "function(value: T): T" - ], "uwtEventTypes": [ "function(namespace?: string | undefined): UwtTelemetryEventTypes" ], @@ -124,15 +136,46 @@ "userId?: string | undefined", "version: string" ], + "UwtLogTelemetry": [ + "class", + "new (options: UwtLogTelemetryOptions): UwtLogTelemetry", + "dispose(): void", + "getLogger(name: string): UwtLogger", + "query(filter?: UwtLogQuery | undefined): Promise" + ], + "UwtLogTelemetryOptions": [ + "interface", + "config: UwtLogConfig", + "document?: UwtTelemetryDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "logApi: UwtLogApiProvider", + "redact: UwtRedactConfig", + "report?: UwtReporter | undefined", + "sink?: UwtTelemetrySink | null | undefined" + ], "uwtMarkSessionStart": [ "function(sessionId: string, now?: number | undefined): void" ], + "UwtNoopLogApiProvider": [ + "class", + "new (): UwtNoopLogApiProvider", + "query(_filter: UwtLogQuery): Promise", + "send(_record: UwtLogRecord): void" + ], + "UwtNoopTelemetrySink": [ + "class", + "new (): UwtNoopTelemetrySink", + "flush(_beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "record(_eventType: string, _payload: object, _metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(_error: UwtTelemetryError, _metadata?: UwtTelemetryMetadata | undefined): void", + "setUserId(userId: string | undefined): void" + ], "uwtNormalizeError": [ "function(error: unknown, config?: UwtRedactConfig | undefined, report?: UwtReporter | undefined): UwtTelemetryError | undefined" ], - "uwtNow": [ - "function(): number" - ], "UwtPiiValuePattern": [ "type = 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'" ], @@ -157,6 +200,9 @@ "UwtReporter": [ "type = (level: 'warn' | 'error', message: string, ...details: unknown[]) => void" ], + "uwtResolveTelemetryConfig": [ + "function(overrides?: UwtTelemetryConfigOverrides | undefined): UwtTelemetryConfig" + ], "uwtSafe": [ "function(operation: string, fn: () => T, fallback: T, report?: UwtReporter | undefined): T" ], @@ -292,6 +338,17 @@ "UwtScenarioStepStatus": [ "type = 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'" ], + "UwtScenarioTelemetry": [ + "class", + "new (options: UwtScenarioTelemetryOptions): UwtScenarioTelemetry", + "dispose(): void", + "find(scenarioId: string): UwtScenario | undefined", + "findByName(name: string): UwtScenario[]", + "findByNameAndId(name: string, scenarioId: string): UwtScenario | undefined", + "getActive(): UwtScenario[]", + "onSettled(listener: (record: UwtScenarioRecord) => void): () => void", + "start(options: UwtScenarioOptions): UwtScenario" + ], "UwtScenarioTelemetryConfig": [ "interface", "defaultTimeoutMs: number", @@ -301,6 +358,17 @@ "redact: boolean", "userTimings: boolean" ], + "UwtScenarioTelemetryOptions": [ + "interface", + "config: UwtScenarioTelemetryConfig", + "document?: UwtTelemetryDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "redact: UwtRedactConfig", + "report?: UwtReporter | undefined", + "runOutside?: ((callback: () => T) => T) | undefined", + "sink: UwtTelemetrySink" + ], "uwtScrubString": [ "function(value: string, config: UwtRedactConfig, report?: UwtReporter | undefined): string" ], @@ -310,12 +378,42 @@ "UwtStepName": [ "type = string" ], + "UwtTelemetry": [ + "interface", + "dispose(): void", + "flush(beacon?: boolean | undefined): void", + "readonly bi: UwtBITelemetry", + "readonly logging: UwtLogTelemetry", + "readonly scenarios: UwtScenarioTelemetry", + "setUserId(userId: string | undefined): void" + ], "UwtTelemetryAttribution": [ "interface", "application: string", "sessionId?: string | undefined", "userId?: string | undefined" ], + "UwtTelemetryConfig": [ + "interface", + "bi: UwtBITelemetryConfig", + "logging: UwtLogConfig", + "redaction: UwtRedactConfig", + "scenarios: UwtScenarioTelemetryConfig" + ], + "UwtTelemetryConfigOverrides": [ + "interface", + "bi?: Partial | undefined", + "logging?: Partial | undefined", + "redaction?: Partial | undefined", + "scenarios?: Partial | undefined" + ], + "UwtTelemetryDocument": [ + "interface", + "addEventListener(type: 'visibilitychange', listener: () => void): void", + "readonly defaultView: UwtListenable<'pagehide'> | null", + "readonly visibilityState: string", + "removeEventListener(type: 'visibilitychange', listener: () => void): void" + ], "UwtTelemetryError": [ "interface", "message: string", @@ -338,6 +436,20 @@ "UwtTelemetryMetadata": [ "type = { [x: string]: string | number | boolean | null; }" ], + "UwtTelemetryOptions": [ + "interface", + "bi?: Partial | undefined", + "destination: UwtTelemetrySink", + "document?: UwtTelemetryDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "logApi: UwtLogApiProvider", + "logging?: Partial | undefined", + "redaction?: Partial | undefined", + "report?: UwtReporter | undefined", + "runOutside?: ((callback: () => T) => T) | undefined", + "scenarios?: Partial | undefined" + ], "UwtTelemetrySink": [ "abstract class", "new (): UwtTelemetrySink", @@ -354,5 +466,16 @@ "uwtUuid": [ "function(): string" ] + }, + "@absaoss-cps/ui-watchtower/rxjs": { + "traceScenario": [ + "function(scenario: UwtScenario, options?: UwtTraceScenarioOptions | ((value: T) => void | UwtScenarioOutcome) | undefined, report?: UwtReporter | undefined): MonoTypeOperatorFunction" + ], + "UwtTraceScenarioOptions": [ + "interface", + "cancelOutcome?: UwtScenarioOutcome | (() => void | UwtScenarioOutcome) | undefined", + "outcome?: ((value: T) => void | UwtScenarioOutcome) | undefined", + "valueThenTeardown?: 'complete' | 'cancel' | undefined" + ] } } diff --git a/projects/ui-watchtower/rxjs/ng-package.json b/projects/ui-watchtower/rxjs/ng-package.json new file mode 100644 index 0000000..e09fb3f --- /dev/null +++ b/projects/ui-watchtower/rxjs/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.spec.ts b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.spec.ts new file mode 100644 index 0000000..59c3752 --- /dev/null +++ b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.spec.ts @@ -0,0 +1,514 @@ +import { + defer, + EMPTY, + first, + type Observable, + firstValueFrom, + lastValueFrom, + of, + retry, + Subject, + switchMap, + take, + throwError +} from 'rxjs'; +import { + UWT_DEFAULT_REDACT_CONFIG, + UWT_DEFAULT_TELEMETRY_CONFIG, + UwtNoopTelemetrySink, + UwtScenario, + UwtScenarioRecord +} from '@absaoss-cps/ui-watchtower'; +import { traceScenario } from './uwt-scenario-operators'; + +describe('traceScenario operator', () => { + let scenario: jest.Mocked; + + beforeEach(() => { + scenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + }); + + it('should complete scenario on successful stream completion', (done) => { + of(['user1', 'user2']) + .pipe(traceScenario(scenario)) + .subscribe({ + next: (val) => { + expect(val).toEqual(['user1', 'user2']); + }, + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should complete scenario with mapped outcome using function argument', (done) => { + of(['item1', 'item2', 'item3']) + .pipe( + traceScenario(scenario, (items) => ({ + metadata: { itemCount: items.length } + })) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith({ + metadata: { itemCount: 3 } + }); + done(); + } + }); + }); + + it('should complete scenario with mapped outcome using options object', (done) => { + of({ status: 200, data: 'ok' }) + .pipe( + traceScenario(scenario, { + outcome: (res) => ({ + statusCode: res.status, + metadata: { result: res.data } + }) + }) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith({ + statusCode: 200, + metadata: { result: 'ok' } + }); + done(); + } + }); + }); + + it('should complete scenario with no outcome when the stream completes with no emissions', (done) => { + EMPTY.pipe( + traceScenario(scenario, () => ({ metadata: { unreachable: true } })) + ).subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should fail scenario when the stream errors and re-throw the error', (done) => { + const error = new Error('network down'); + throwError(() => error) + .pipe(traceScenario(scenario)) + .subscribe({ + error: (err) => { + expect(err).toBe(error); + expect(scenario.fail).toHaveBeenCalledWith({ error }); + expect(scenario.complete).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should still settle the scenario when the outcome mapper throws', (done) => { + const report = jest.fn(); + + of('value') + .pipe( + traceScenario( + scenario, + () => { + throw new Error('mapper is broken'); + }, + report + ) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + expect(report).toHaveBeenCalledWith( + 'error', + 'scenario.traceOutcome failed', + expect.any(Error) + ); + done(); + } + }); + }); + + it('should not leak lastValue/hasValue into a resubscription of the same piped observable', (done) => { + let subscriptionCount = 0; + const source = defer(() => { + subscriptionCount++; + return subscriptionCount === 1 ? of('a') : EMPTY; + }); + + const traced$ = source.pipe( + traceScenario(scenario, (value) => ({ metadata: { value } })) + ); + + traced$.subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenNthCalledWith(1, { + metadata: { value: 'a' } + }); + + traced$.subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenNthCalledWith(2, undefined); + done(); + } + }); + } + }); + }); + + describe('teardown without a source complete/error', () => { + it('should cancel the scenario on a manual unsubscribe', () => { + const source = new Subject(); + const subscription = source.pipe(traceScenario(scenario)).subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledTimes(1); + expect(scenario.complete).not.toHaveBeenCalled(); + expect(scenario.fail).not.toHaveBeenCalled(); + }); + + it('should cancel a superseded scenario when switchMap moves to the next inner observable', () => { + const supersededScenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + const pendingFirstRequest = new Subject(); + const secondRequest = of('b'); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + (n === 1 ? pendingFirstRequest : secondRequest).pipe( + traceScenario(n === 1 ? supersededScenario : scenario) + ) + ) + ) + .subscribe(); + + trigger.next(1); // subscribes traceScenario(supersededScenario), still pending + trigger.next(2); // switchMap unsubscribes it before it ever settles + + expect(supersededScenario.cancel).toHaveBeenCalledTimes(1); + expect(supersededScenario.complete).not.toHaveBeenCalled(); + expect(supersededScenario.fail).not.toHaveBeenCalled(); + expect(scenario.complete).toHaveBeenCalledWith(undefined); + }); + + it('should cancel with the configured cancelOutcome on unsubscribe', () => { + const source = new Subject(); + const subscription = source + .pipe( + traceScenario(scenario, { cancelOutcome: { reason: 'superseded' } }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); + }); + + it('should accept cancelOutcome as a function, evaluated at unsubscribe time', () => { + const source = new Subject(); + const subscription = source + .pipe( + traceScenario(scenario, { + cancelOutcome: () => ({ reason: 'superseded' }) + }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); + }); + + it('should still cancel, with no outcome, when cancelOutcome throws', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const source = new Subject(); + const subscription = source + .pipe( + traceScenario(scenario, { + cancelOutcome: () => { + throw new Error('cancelOutcome is broken'); + } + }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith(undefined); + consoleError.mockRestore(); + }); + + it('should record a reason for a switchMap-superseded scenario, not just a bare cancel', () => { + const supersededScenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + const pendingFirstRequest = new Subject(); + const secondRequest = of('b'); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + (n === 1 ? pendingFirstRequest : secondRequest).pipe( + traceScenario(n === 1 ? supersededScenario : scenario, { + cancelOutcome: { reason: 'superseded' } + }) + ) + ) + ) + .subscribe(); + + trigger.next(1); + trigger.next(2); + + expect(supersededScenario.cancel).toHaveBeenCalledWith({ + reason: 'superseded' + }); + }); + + it('should not cancel a scenario that already completed normally', (done) => { + of('value') + .pipe(traceScenario(scenario)) + .subscribe({ + complete: () => { + (scenario as unknown as { isSettled: boolean }).isSettled = true; + expect(scenario.cancel).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should not cancel a scenario that already failed', (done) => { + const error = new Error('boom'); + throwError(() => error) + .pipe(traceScenario(scenario)) + .subscribe({ + error: () => { + (scenario as unknown as { isSettled: boolean }).isSettled = true; + expect(scenario.cancel).not.toHaveBeenCalled(); + done(); + } + }); + }); + }); + + describe('with a real scenario', () => { + let settled: UwtScenarioRecord[]; + + const countRows = (rows: string[]) => ({ + metadata: { count: rows.length } + }); + + function createScenario(name = 'load-customers'): UwtScenario { + return new UwtScenario( + { name }, + { + identity: { + application: 'test-app', + environment: 'test', + version: '1.0.0' + }, + scenarioTelemetryConfig: { + ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario + }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink: new UwtNoopTelemetrySink(), + onSettled: (_id, record) => settled.push(record) + } + ); + } + + beforeEach(() => { + settled = []; + }); + + describe('single-value sources', () => { + it('should settle as success with subscribe()', () => { + const real = createScenario(); + + of(['a', 'b']).pipe(traceScenario(real, countRows)).subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with lastValueFrom', async () => { + const real = createScenario(); + + await lastValueFrom( + of(['a', 'b']).pipe(traceScenario(real, countRows)) + ); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with firstValueFrom', async () => { + const real = createScenario(); + + const rows = await firstValueFrom( + of(['a', 'b']).pipe(traceScenario(real, countRows)) + ); + + expect(rows).toEqual(['a', 'b']); + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with take(1) after the operator', () => { + const real = createScenario(); + + of(['a', 'b']) + .pipe(traceScenario(real, countRows), take(1)) + .subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with first() after the operator', () => { + const real = createScenario(); + + of(['a', 'b']) + .pipe(traceScenario(real, countRows), first()) + .subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with take(1) before the operator', () => { + const real = createScenario(); + + of(['a', 'b']) + .pipe(take(1), traceScenario(real, countRows)) + .subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with take(1) before the operator under firstValueFrom', async () => { + const real = createScenario(); + + await firstValueFrom( + of(['a', 'b']).pipe(take(1), traceScenario(real, countRows)) + ); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + }); + + describe('switchMap superseding an inner stream', () => { + it('should abandon a stream superseded before it emitted', () => { + const superseded = createScenario('superseded'); + const latest = createScenario('latest'); + const pendingFirstRequest = new Subject(); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + n === 1 + ? pendingFirstRequest.pipe(traceScenario(superseded)) + : of(['b']).pipe(traceScenario(latest)) + ) + ) + .subscribe(); + + trigger.next(1); + trigger.next(2); + + expect(superseded.status).toBe('abandoned'); + expect(latest.status).toBe('success'); + }); + + it('should settle as success a stream superseded after it emitted', () => { + const superseded = createScenario('superseded'); + const latest = createScenario('latest'); + const firstRequest = new Subject(); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + n === 1 + ? firstRequest.pipe(traceScenario(superseded, countRows)) + : of(['b']).pipe(traceScenario(latest)) + ) + ) + .subscribe(); + + trigger.next(1); + firstRequest.next(['a']); // emitted, but never completed + trigger.next(2); + + expect(superseded.status).toBe('success'); + expect(superseded.toRecord().metadata).toEqual({ count: 1 }); + expect(latest.status).toBe('success'); + }); + }); + + it("should abandon a stream torn down after a value with valueThenTeardown: 'cancel'", () => { + const real = createScenario('poll-status'); + + of(['a'], ['b']) + .pipe(traceScenario(real, { valueThenTeardown: 'cancel' }), take(1)) + .subscribe(); + + expect(real.status).toBe('abandoned'); + }); + + describe('retry placement', () => { + function failOnce(): Observable { + let attempts = 0; + return defer(() => + attempts++ === 0 + ? throwError(() => new Error('transient')) + : of(['a']) + ); + } + + it('should settle as success when retry() comes before the operator', () => { + const real = createScenario(); + + failOnce().pipe(retry(1), traceScenario(real)).subscribe(); + + expect(real.status).toBe('success'); + }); + + it('should settle as failure when retry() comes after the operator, even if the retry succeeds', () => { + const real = createScenario(); + + failOnce().pipe(traceScenario(real), retry(1)).subscribe(); + + // Documented limitation: the first error settles the scenario and + // the operator cannot see the downstream retry. + expect(real.status).toBe('failure'); + }); + }); + }); +}); diff --git a/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.ts b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.ts new file mode 100644 index 0000000..6edb052 --- /dev/null +++ b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.ts @@ -0,0 +1,129 @@ +import { defer } from 'rxjs'; +import type { MonoTypeOperatorFunction, Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { + UwtReporter, + UwtScenario, + UwtScenarioOutcome, + uwtSafe +} from '@absaoss-cps/ui-watchtower'; + +/** + * Options for configuring scenario completion within an RxJS stream. + * + * @group Interfaces + */ +export interface UwtTraceScenarioOptions { + /** + * Optional mapper that returns an outcome (metadata, statusCode, message) + * based on the emitted value when the Observable completes successfully. + */ + outcome?: (value: T) => UwtScenarioOutcome | void; + + /** + * Outcome used when the stream is unsubscribed before completing or + * erroring — e.g. a `switchMap` moving on to a newer value. + */ + cancelOutcome?: UwtScenarioOutcome | (() => UwtScenarioOutcome | void); + + /** + * What a downstream unsubscribe means once a value has been delivered. + * Default `'complete'`, so `firstValueFrom`, `take(1)` and `first()` settle + * as success. Use `'cancel'` for multi-value streams (polling, progress), + * where stopping after a value is abandonment. + */ + valueThenTeardown?: 'complete' | 'cancel'; +} + +/** + * An RxJS pipeable operator that automatically settles a {@link UwtScenario} + * based on the stream's outcome: `complete()` on completion, `fail({ error })` + * on error. + * + * @example + * ```typescript + * this.http.get('/api/customers') + * .pipe( + * traceScenario(scenario, { + * outcome: (customers) => ({ metadata: { count: customers.length } }) + * }) + * ) + * .subscribe(); + * ``` + * + * @param scenario the scenario instance to track + * @param options optional outcome mapper or configuration + * @param report receives what a throwing outcome mapper threw; see + * {@link UwtReporter} + * + * @group Utils + */ +export function traceScenario( + scenario: UwtScenario, + options?: + UwtTraceScenarioOptions | ((value: T) => UwtScenarioOutcome | void), + report?: UwtReporter +): MonoTypeOperatorFunction { + const outcomeMapper = + typeof options === 'function' ? options : options?.outcome; + const cancelOutcome = + typeof options === 'function' ? undefined : options?.cancelOutcome; + const valueThenTeardown = + (typeof options === 'function' ? undefined : options?.valueThenTeardown) ?? + 'complete'; + + return (source: Observable): Observable => + defer(() => { + let lastValue: T | undefined; + let hasValue = false; + + const complete = () => { + const outcome = + hasValue && outcomeMapper + ? uwtSafe( + 'scenario.traceOutcome', + () => outcomeMapper(lastValue as T), + undefined, + report + ) + : undefined; + scenario.complete(outcome || undefined); + }; + + const unsubscribe = () => { + if (scenario.isSettled) { + return; + } + // The consumer got a value and stopped listening, as + // firstValueFrom, take(1) and first() do: treat it as completion. + if (hasValue && valueThenTeardown === 'complete') { + complete(); + return; + } + const resolved = + typeof cancelOutcome === 'function' + ? uwtSafe( + 'scenario.traceCancelOutcome', + cancelOutcome, + undefined, + report + ) + : cancelOutcome; + scenario.cancel(resolved || undefined); + }; + + return source.pipe( + tap({ + next: (value) => { + lastValue = value; + hasValue = true; + }, + complete, + error: (error: unknown) => { + scenario.fail({ error }); + }, + unsubscribe + }) + ); + }); +} diff --git a/projects/ui-watchtower/rxjs/src/public-api.ts b/projects/ui-watchtower/rxjs/src/public-api.ts new file mode 100644 index 0000000..e8e1ca7 --- /dev/null +++ b/projects/ui-watchtower/rxjs/src/public-api.ts @@ -0,0 +1,9 @@ +/* + * Public API Surface of ui-watchtower/rxjs + * + * RxJS integration, in its own entry point so the main one never loads + * RxJS: `rxjs` is an optional peer, needed only here. + */ + +export { traceScenario } from './lib/uwt-scenario-operators/uwt-scenario-operators'; +export type { UwtTraceScenarioOptions } from './lib/uwt-scenario-operators/uwt-scenario-operators'; diff --git a/projects/ui-watchtower/rxjs/tsconfig.lib.json b/projects/ui-watchtower/rxjs/tsconfig.lib.json new file mode 100644 index 0000000..c2d230e --- /dev/null +++ b/projects/ui-watchtower/rxjs/tsconfig.lib.json @@ -0,0 +1,16 @@ +/* Type-checks the ./rxjs entry point; ng-packagr builds it with the main tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib-core-rxjs", + // Includes the main entry point: it is read from source here. + "rootDir": "..", + "lib": ["ES2022", "DOM"], + "declaration": true, + "skipLibCheck": true, + "isolatedModules": false, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"] +} diff --git a/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts b/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts index b9d11f7..3b4b858 100644 --- a/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts +++ b/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts @@ -60,3 +60,67 @@ export const UWT_DEFAULT_TELEMETRY_CONFIG: { bi: UWT_DEFAULT_BI_TELEMETRY_CONFIG, redact: UWT_DEFAULT_REDACT_CONFIG }; + +/** + * Overrides for each concern's configuration: whatever is left out takes the + * library default. + * + * @group Interfaces + */ +export interface UwtTelemetryConfigOverrides { + scenarios?: Partial; + logging?: Partial; + bi?: Partial; + redaction?: Partial; +} + +/** + * Each concern's configuration, complete. + * + * @group Interfaces + */ +export interface UwtTelemetryConfig { + scenarios: UwtScenarioTelemetryConfig; + logging: UwtLogConfig; + bi: UwtBITelemetryConfig; + redaction: UwtRedactConfig; +} + +/** + * Completes each concern's configuration with the library defaults: every + * override over its default, shallowly. Redaction's pattern and transform + * lists are copied into fresh arrays, so callers never share array identity + * with the defaults or each other. + * + * The one place defaults are applied, for the engine and every adapter. + * + * @param overrides what the application sets + * @returns the configuration an engine runs with + * + * @group Utils + */ +export function uwtResolveTelemetryConfig( + overrides: UwtTelemetryConfigOverrides = {} +): UwtTelemetryConfig { + const defaults = UWT_DEFAULT_TELEMETRY_CONFIG; + const redaction = overrides.redaction ?? {}; + return { + scenarios: { ...defaults.scenario, ...overrides.scenarios }, + logging: { ...defaults.logs, ...overrides.logging }, + bi: { ...defaults.bi, ...overrides.bi }, + redaction: { + ...defaults.redact, + ...redaction, + extraKeyPatterns: [ + ...(redaction.extraKeyPatterns ?? defaults.redact.extraKeyPatterns) + ], + extraValuePatterns: [ + ...(redaction.extraValuePatterns ?? defaults.redact.extraValuePatterns) + ], + extraValueTransforms: [ + ...(redaction.extraValueTransforms ?? + defaults.redact.extraValueTransforms) + ] + } + }; +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts new file mode 100644 index 0000000..c8f8368 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts @@ -0,0 +1,252 @@ +import { UwtBITelemetryConfig } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { + UwtBIEvent, + UwtBIEventDetail, + UwtBIEventName +} from '../../models/uwt-bi.models/uwt-bi.models'; +import { + uwtEventTypes, + UwtTelemetryEventTypes, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; +import { + UwtRedactConfig, + uwtRedactConfigFor, + uwtRedactMetadata, + uwtScrubString +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + uwtNow, + UwtReporter, + uwtSafe, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; + +/** + * What {@link UwtBITelemetry} needs. + * + * @group Interfaces + */ +export interface UwtBITelemetryOptions { + /** The application's identity, shared by every concern. */ + identity: UwtTelemetryIdentity; + /** BI event settings, complete; see {@link uwtResolveTelemetryConfig}. */ + config: UwtBITelemetryConfig; + /** + * Redaction settings shared by every concern. `config.redact: false` + * turns their configurable part off for BI events. + */ + redact: UwtRedactConfig; + /** Where events go. */ + sink: UwtTelemetrySink; + /** `false` makes {@link UwtBITelemetry.track} do nothing. Default `true`. */ + enabled?: boolean; + /** Receives what is handled silently; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Business and UX event tracking — feature adoption, interaction analysis, + * funnel steps. + * + * Unlike scenarios, BI events are discrete: no duration, no lifecycle. Event + * names and attributes come entirely from the application. + * + * Repeated identical events within a short window are collapsed, absorbing + * double-fires from a `click` handler also bound to `keydown`, or from a + * user clicking twice. + * + * Console output is off unless the `debugBI` LocalStorage flag is set: + * + * ```js + * localStorage.setItem('debugBI', 'true'); + * ``` + * + * @example + * ```typescript + * telemetry.bi.track('export_clicked', { + * exportType: format, + * source: 'customer-table' + * }); + * ``` + * + * @group Classes + */ +export class UwtBITelemetry { + private readonly identity: UwtTelemetryIdentity; + private readonly config: UwtBITelemetryConfig; + private readonly redact: UwtRedactConfig; + private readonly sink: UwtTelemetrySink; + private readonly enabled: boolean; + private readonly report?: UwtReporter; + + private readonly eventTypes: UwtTelemetryEventTypes; + private readonly lastEmittedAt = new Map(); + private disposed = false; + + constructor(options: UwtBITelemetryOptions) { + this.identity = options.identity; + this.config = options.config; + this.redact = uwtRedactConfigFor(options.redact, options.config.redact); + this.sink = options.sink; + this.enabled = options.enabled !== false; + this.report = options.report; + this.eventTypes = uwtEventTypes(options.identity.eventNamespace); + } + + /** + * Records a business or UX event. + * + * @param eventName the application's own event name, e.g. `export_clicked`, + * as declared in {@link UwtBIEventNames}. Treat it as a metric dimension: + * keep the cardinality low and never interpolate an identifier into it. + * @param metadata flat attributes describing the interaction + * @param detail optional scenario correlation and feature + */ + track( + eventName: UwtBIEventName, + metadata?: UwtTelemetryMetadata, + detail?: UwtBIEventDetail + ): void { + if (!this.enabled || this.disposed) { + return; + } + uwtSafeVoid( + 'biTelemetry.track', + () => { + if (!eventName) { + return; + } + + const redactedFeature = detail?.feature + ? uwtScrubString(detail.feature, this.redact, this.report) + : undefined; + const redactedMetadata = uwtRedactMetadata( + metadata, + this.redact, + this.report + ); + + if ( + this.isDuplicate(eventName, redactedMetadata, { + ...detail, + feature: redactedFeature + }) + ) { + return; + } + + const event: UwtBIEvent = { + eventName, + eventTime: new Date().toISOString(), + scenarioId: detail?.scenarioId, + feature: redactedFeature, + metadata: redactedMetadata, + application: this.identity.application, + sessionId: uwtSafe( + 'biTelemetry.getSessionId', + () => this.sink.getSessionId(), + undefined, + this.report + ), + userId: uwtSafe( + 'biTelemetry.getUserId', + () => this.sink.getUserId(), + undefined, + this.report + ) + }; + + uwtDebugWrite( + 'debugBI', + () => writeToConsole(eventName, this.eventTypes.bi, event), + undefined, + this.report + ); + + this.sink.record(this.eventTypes.bi, event); + }, + this.report + ); + } + + /** Tracks nothing from now on, and forgets recent events. Calling it again does nothing. */ + dispose(): void { + this.disposed = true; + this.lastEmittedAt.clear(); + } + + private isDuplicate( + eventName: string, + metadata: UwtTelemetryMetadata | undefined, + detail?: UwtBIEventDetail + ): boolean { + const key = JSON.stringify([ + eventName, + detail?.scenarioId ?? '', + detail?.feature ?? '', + this.metadataKey(metadata) + ]); + const now = uwtNow(); + const last = this.lastEmittedAt.get(key); + + if (last !== undefined && now - last < this.config.dedupWindowMs) { + this.lastEmittedAt.delete(key); + this.lastEmittedAt.set(key, last); + return true; + } + + if (this.lastEmittedAt.size >= this.config.dedupMaxKeys) { + for (const [staleKey, at] of this.lastEmittedAt) { + if (now - at >= this.config.dedupWindowMs) { + this.lastEmittedAt.delete(staleKey); + } + } + + if (this.lastEmittedAt.size >= this.config.dedupMaxKeys) { + const oldestKey = this.lastEmittedAt.keys().next().value; + if (oldestKey !== undefined) { + this.lastEmittedAt.delete(oldestKey); + } + } + } + + this.lastEmittedAt.delete(key); + this.lastEmittedAt.set(key, now); + return false; + } + + /** + * A stable string encoding a flat metadata object's content, for use in + * the dedup key — otherwise two same-named events with different metadata + * would collide and the second would be silently dropped. Keys are sorted + * so property order doesn't affect the result. Callers pass already- + * redacted metadata — a caller's raw values must never end up as a `Map` + * key living in this long-lived object's memory for the page lifetime. + */ + private metadataKey(metadata: UwtTelemetryMetadata | undefined): string { + if (!metadata) { + return ''; + } + return JSON.stringify( + Object.keys(metadata) + .sort() + .map((k) => [k, metadata[k]]) + ); + } +} + +/** + * Prefixed with the application - in a composed + * page every realm writes to the one console. + */ +function writeToConsole( + eventName: string, + eventType: string, + event: UwtBIEvent +): void { + console.log(`[${event.application}][bi] ${eventName} -> ${eventType}`, event); +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts new file mode 100644 index 0000000..6236029 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts @@ -0,0 +1,380 @@ +import { UwtLogConfig } from '../../config/uwt-log.config/uwt-log.config'; +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { + UWT_LOG_LEVEL_ORDER, + UwtLogDetail, + UwtLogger, + UwtLoggerName, + UwtLogLevel, + UwtLogRecord +} from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; +import { + uwtNormalizeError, + UwtRedactConfig, + uwtRedactConfigFor, + uwtRedactMetadata, + uwtScrubString +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + UwtReporter, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + uwtListenToPage, + UwtTelemetryDocument, + uwtTelemetryDocument +} from '../uwt-telemetry-page/uwt-telemetry-page'; + +/** Handed out while telemetry is off: every call does nothing. */ +const INERT_LOGGER: UwtLogger = Object.freeze({ + log: () => undefined, + warn: () => undefined, + error: () => undefined +}); + +/** + * What {@link UwtLogTelemetry} needs. + * + * @group Interfaces + */ +export interface UwtLogTelemetryOptions { + /** The application's identity, shared by every concern. */ + identity: UwtTelemetryIdentity; + /** Logging settings, complete; see {@link uwtResolveTelemetryConfig}. */ + config: UwtLogConfig; + /** + * Redaction settings shared by every concern. `config.redact: false` + * turns their configurable part off for log records. + */ + redact: UwtRedactConfig; + /** The application's log store: where records go, and are read back from. */ + logApi: UwtLogApiProvider; + /** + * The telemetry destination, for enrichment only: records carry its + * `sessionId` and `userId`, and `config.mirrorErrorsToRum` reports errors + * to it. Optional. + */ + sink?: UwtTelemetrySink | null; + /** `false` makes every logger and query inert. Default `true`. */ + enabled?: boolean; + /** + * The page whose lifecycle flushes the log store: on `pagehide`, and when + * the page is hidden. Defaults to the global `document` where there is + * one; `null` listens to nothing. + */ + document?: UwtTelemetryDocument | null; + /** Receives what is handled silently; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Structured application logging. + * + * Log records go to the application's {@link UwtLogApiProvider}, **not** to + * the telemetry destination — RUM is a sampled, session-capped analytics + * stream, while logs need full fidelity and their own retention. + * + * Console output is off unless the `debugLogger` LocalStorage flag is set, in + * any environment: + * + * ```js + * localStorage.setItem('debugLogger', 'true'); + * ``` + * + * Every record carries a logger name, so this writes nothing itself — + * {@link getLogger} hands back the {@link UwtLogger} you call. + * + * @example + * ```typescript + * const logger = telemetry.logging.getLogger('checkout'); + * + * try { + * // … + * } catch (error) { + * logger.error('Failed to load customer data', { + * error, + * correlationId: scenario.id + * }); + * } + * ``` + * + * @group Classes + */ +export class UwtLogTelemetry { + private readonly identity: UwtTelemetryIdentity; + private readonly config: UwtLogConfig; + private readonly redact: UwtRedactConfig; + private readonly logApi: UwtLogApiProvider; + private readonly sink: UwtTelemetrySink | null; + private readonly enabled: boolean; + private readonly report?: UwtReporter; + private readonly stopListening: () => void; + private disposed = false; + + /** + * One logger per name, so {@link getLogger} is idempotent. + * + * Unbounded growth is not a concern the way it is for active scenarios: + * names come from the closed {@link UwtLoggerNames} vocabulary, so this + * map is bounded by what the application declared at build time. + */ + private readonly loggers = new Map(); + + constructor(options: UwtLogTelemetryOptions) { + this.identity = options.identity; + this.config = options.config; + this.redact = uwtRedactConfigFor(options.redact, options.config.redact); + this.logApi = options.logApi; + this.sink = options.sink ?? null; + this.enabled = options.enabled !== false; + this.report = options.report; + // Page listeners: only while telemetry is on. + this.stopListening = this.enabled + ? uwtListenToPage( + uwtTelemetryDocument(options.document), + () => this.flushProvider(), + () => this.flushProvider() + ) + : () => undefined; + } + + /** + * Reads records back from the application's log store. Useful for + * pulling one journey together — every line written during a scenario + * shares its id as the correlation id: + * + * @example + * ```typescript + * const lines = await telemetry.logging.query({ correlationId: scenario.id }); + * ``` + * + * Fail-open: a provider that throws or rejects resolves to `[]`. + * + * @param filter narrows what is returned + * @returns the matching records, or `[]` when none can be read + */ + async query(filter: UwtLogQuery = {}): Promise { + if (!this.enabled || this.disposed) { + return []; + } + const pending = uwtSafe( + 'logger.query', + () => this.logApi.query(filter), + undefined, + this.report + ); + + return (await pending?.catch(() => undefined)) ?? []; + } + + /** + * Returns the named logger for one part of the application — the only + * way to write a log record. + * + * The name lands on every record as `logger`, and four things key off + * it: {@link UwtLogConfig.levels} per-logger severity floors, the + * `debugLogger` flag's comma-separated filter, `query({ logger })` + * against the log store, and the console line's prefix. A record + * without one is reachable by none of them, which is why there is no + * unnamed `log`/`warn`/`error` here. + * + * The name is identity, not a label: asking twice returns the very same + * logger, the way a file name always refers to one file. + * + * @param name the logger name, declared in {@link UwtLoggerNames} + * @returns the logger for that name, created once and reused; an inert + * one while telemetry is off + */ + getLogger(name: UwtLoggerName): UwtLogger { + if (!this.enabled) { + return INERT_LOGGER; + } + const cached = uwtSafe( + 'logger.getLogger', + () => this.loggers.get(name), + undefined, + this.report + ); + if (cached) { + return cached; + } + + const logger = this.createLogger(name); + uwtSafeVoid( + 'logger.register', + () => this.loggers.set(name, logger), + this.report + ); + return logger; + } + + /** + * Stops watching the page and gives the log store its last chance to + * ship what it holds; every logger writes nothing from then on. Calling + * it again does nothing. + */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.stopListening(); + if (this.enabled) { + this.flushProvider(); + } + } + + /** + * Builds the logger for one name. + * + * Private, and reached only through {@link getLogger}, so every logger + * handed out is registered and named. The name is captured here rather + * than read from each call's detail, which is why a record's `logger` + * cannot disagree with the logger that wrote it. + * + * @param name the logger name stamped onto every record it writes + * @returns a logger bound to that name + */ + private createLogger(name: UwtLoggerName): UwtLogger { + return { + log: (message, detail) => this.emit('log', name, message, detail), + warn: (message, detail) => this.emit('warn', name, message, detail), + error: (message, detail) => this.emit('error', name, message, detail) + }; + } + + /** Severity floor for one logger — its own override, or the global one. */ + private minLevelFor(logger: UwtLoggerName): UwtLogLevel { + const { levels, minLevel } = this.config; + return levels?.[logger] ?? minLevel; + } + + private emit( + level: UwtLogLevel, + logger: UwtLoggerName, + message: string, + detail?: UwtLogDetail + ): void { + if (this.disposed) { + return; + } + uwtSafeVoid( + `logger.${level}`, + () => { + if ( + UWT_LOG_LEVEL_ORDER[level] < + UWT_LOG_LEVEL_ORDER[this.minLevelFor(logger)] + ) { + return; + } + + const record = this.buildRecord(level, logger, message, detail); + + uwtDebugWrite( + 'debugLogger', + () => writeToConsole(record), + record.logger, + this.report + ); + + this.deliver(record); + + if (level === 'error' && this.config.mirrorErrorsToRum && this.sink) { + const mirrored = + record.error ?? + uwtNormalizeError( + new Error(record.message), + this.redact, + this.report + ); + if (mirrored) { + this.sink.recordError(mirrored); + } + } + }, + this.report + ); + } + + /** Guards against a throwing or secretly-async, rejecting provider. */ + private deliver(record: UwtLogRecord): void { + uwtSafeVoidMaybeAsync( + 'logger.deliver', + () => this.logApi.send(record), + this.report + ); + } + + /** Gives the provider its chance to ship whatever it has queued itself. */ + private flushProvider(): void { + uwtSafeVoidMaybeAsync( + 'logger.providerFlush', + () => this.logApi.flush?.(), + this.report + ); + } + + private buildRecord( + level: UwtLogLevel, + logger: UwtLoggerName, + message: string, + detail?: UwtLogDetail + ): UwtLogRecord { + const { identity, redact, report, sink } = this; + + return { + timestamp: new Date().toISOString(), + level, + message: uwtScrubString(String(message ?? ''), redact, report), + logger, + context: detail?.context + ? uwtScrubString(detail.context, redact, report) + : undefined, + metadata: uwtRedactMetadata(detail?.metadata, redact, report), + error: uwtNormalizeError(detail?.error, redact, report), + correlationId: detail?.correlationId + ? uwtScrubString(detail.correlationId, redact, report) + : undefined, + application: identity.application, + environment: identity.environment, + version: identity.version, + ...(sink && { + userId: uwtSafe('getUserId', () => sink.getUserId(), undefined, report), + sessionId: uwtSafe( + 'getSessionId', + () => sink.getSessionId(), + undefined, + report + ) + }) + }; + } +} + +/** + * Prints the record exactly as the transport receives it. + * + * Prefixed with the application - in a composed + * page every realm writes to the one console - then the logger name and + * the context, each when present. + */ +function writeToConsole(record: UwtLogRecord): void { + const scope = [record.logger, record.context] + .filter(Boolean) + .map((part) => `[${part}]`) + .join(''); + const suffix = record.correlationId ? ` (${record.correlationId})` : ''; + + console[record.level]( + `[${record.application}]${scope} ${record.message}${suffix}`, + record + ); +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts new file mode 100644 index 0000000..5a8fa17 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts @@ -0,0 +1,329 @@ +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UwtScenarioTelemetryConfig } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { + UwtScenarioName, + UwtScenarioOptions, + UwtScenarioRecord +} from '../../models/uwt-scenario.models/uwt-scenario.models'; +import { UwtScenario } from '../../scenario/uwt-scenario/uwt-scenario'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { + UwtRedactConfig, + uwtRedactConfigFor +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + uwtDeepClone, + uwtReport, + UwtReporter, + uwtSafe, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + uwtListenToPage, + UwtTelemetryDocument, + uwtTelemetryDocument +} from '../uwt-telemetry-page/uwt-telemetry-page'; + +/** + * How many scenarios may be in flight before a warning suggests a leak. + * + * Concurrent scenarios are legitimate — several journeys, tabs or panels + * can run at once — so this is a smell threshold, not a limit: nothing is + * dropped or rejected when it is crossed. Set well above any plausible + * real concurrency, so crossing it might mean scenarios with `timeoutMs: 0` or + * long lifecycles are being started and never settled. + */ +const ACTIVE_SCENARIO_WARN_THRESHOLD = 50; + +/** + * What {@link UwtScenarioTelemetry} needs. + * + * @group Interfaces + */ +export interface UwtScenarioTelemetryOptions { + /** The application's identity, shared by every concern. */ + identity: UwtTelemetryIdentity; + /** Scenario settings, complete; see {@link uwtResolveTelemetryConfig}. */ + config: UwtScenarioTelemetryConfig; + /** + * Redaction settings shared by every concern. `config.redact: false` + * turns their configurable part off for scenarios. + */ + redact: UwtRedactConfig; + /** Where settled scenarios and their steps go. */ + sink: UwtTelemetrySink; + /** `false` makes every scenario inert. Default `true`. */ + enabled?: boolean; + /** + * The page whose lifecycle settles and flushes scenarios: in-flight ones + * are settled as abandoned on `pagehide`, and the sink is flushed when the + * page is hidden. Defaults to the global `document` where there is one; + * `null` listens to nothing. + */ + document?: UwtTelemetryDocument | null; + /** + * Runs timer scheduling outside the host framework's change detection, + * when it has one: Angular passes `NgZone.runOutsideAngular`. + */ + runOutside?: (callback: () => T) => T; + /** Receives what is handled silently; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Creates and tracks scenarios — user journeys whose health telemetry + * measures. + * + * Each {@link start} call returns an independent {@link UwtScenario}; there is + * no "current" scenario, so overlapping journeys are tracked separately. + * Scenarios still in flight when the page unloads are settled as + * `abandoned`, and so are those still running when this is disposed. + * + * @example + * ```typescript + * const scenario = telemetry.scenarios.start({ name: 'load-dashboard' }); + * try { + * scenario.step('fetch-widgets'); + * const widgets = await api.widgets(); + * scenario.complete({ metadata: { widgetCount: widgets.length } }); + * } catch (error) { + * scenario.fail({ error }); + * } + * ``` + * + * @group Classes + */ +export class UwtScenarioTelemetry { + private readonly identity: UwtTelemetryIdentity; + private readonly config: UwtScenarioTelemetryConfig; + private readonly redact: UwtRedactConfig; + private readonly sink: UwtTelemetrySink; + private readonly enabled: boolean; + private readonly runOutside?: (callback: () => T) => T; + private readonly report?: UwtReporter; + + private readonly active = new Map(); + private readonly listeners = new Set<(record: UwtScenarioRecord) => void>(); + private readonly stopListening: () => void; + private disposed = false; + + constructor(options: UwtScenarioTelemetryOptions) { + this.identity = options.identity; + this.config = options.config; + this.redact = uwtRedactConfigFor(options.redact, options.config.redact); + this.sink = options.sink; + this.enabled = options.enabled !== false; + this.runOutside = options.runOutside; + this.report = options.report; + // Page listeners: only while telemetry is on. + this.stopListening = this.enabled + ? uwtListenToPage( + uwtTelemetryDocument(options.document), + () => this.flushActive(), + () => this.flushSink() + ) + : () => undefined; + } + + /** + * Starts a new scenario. + * + * @param options the scenario name and optional classification, timeout and + * attributes + * @returns an independent scenario instance; an inert one while telemetry + * is off or once this is disposed + */ + start(options: UwtScenarioOptions): UwtScenario { + if (!this.enabled || this.disposed) { + // Same return type, nothing recorded, timed, registered or sent. + return new UwtScenario(options, { + identity: this.identity, + scenarioTelemetryConfig: this.config, + redact: this.redact, + sink: this.sink, + onSettled: () => undefined, + inert: true + }); + } + const scenario = new UwtScenario(options, { + identity: this.identity, + scenarioTelemetryConfig: this.config, + redact: this.redact, + sink: this.sink, + runOutside: this.runOutside, + report: this.report, + onSettled: (scenarioId, record) => { + this.active.delete(scenarioId); + this.notify(record); + } + }); + + uwtSafeVoid( + 'scenarioTelemetry.register', + () => { + this.active.set(scenario.id, scenario); + if (this.active.size > ACTIVE_SCENARIO_WARN_THRESHOLD) { + uwtReport( + this.report, + 'warn', + `High number of active scenarios (${this.active.size}). Ensure scenarios with timeoutMs: 0 or long lifecycles are settled on destroy.` + ); + } + }, + this.report + ); + + return scenario; + } + + /** + * Looks up a scenario that has not yet settled. + * + * @param scenarioId the id to look up + * @returns the scenario, or `undefined` if unknown or already settled + */ + find(scenarioId: string): UwtScenario | undefined { + return this.active.get(scenarioId); + } + + /** + * Every active scenario with the given name. + * + * `name` is a metric dimension, not a concurrency key - any number of + * scenarios can legitimately share one (two tabs, two dashboard panels, + * two independently-edited rows). Use this to inspect what's currently + * running under a name and decide for yourself, e.g. before starting + * another one. + * + * @param name the scenario name to match + * @returns matching active scenarios, in start order + */ + findByName(name: UwtScenarioName): UwtScenario[] { + return this.getActive().filter((scenario) => scenario.name === name); + } + + /** + * Looks up an active scenario by id, asserting it has the expected name. + * + * The same lookup as {@link find}, with an extra check that its `name` + * matches - a defensive assertion for a caller that already expects a + * specific scenario name at this id, rather than a bare id lookup. + * + * @param name the expected scenario name + * @param scenarioId the id to look up + * @returns the scenario, or `undefined` if not active or its name + * doesn't match + */ + findByNameAndId( + name: UwtScenarioName, + scenarioId: string + ): UwtScenario | undefined { + const scenario = this.find(scenarioId); + return scenario?.name === name ? scenario : undefined; + } + + /** + * Every scenario currently in flight. + * + * @returns the active scenarios, in start order + */ + getActive(): UwtScenario[] { + return [...this.active.values()]; + } + + /** + * Calls `listener` with every scenario record as it settles. + * + * Useful for reacting to outcomes without wrapping every call site — a + * debug overlay, a retry prompt, a test harness asserting on journeys. + * Listeners share one copy of each record, so none of them can change + * what the sink receives; a throwing listener affects no other. + * + * @param listener called once per settled scenario + * @returns stops calling `listener` + */ + onSettled(listener: (record: UwtScenarioRecord) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Stops watching the page, settles every scenario still in flight as + * abandoned and flushes the sink, then calls no listener again. Every + * later {@link start} returns an inert scenario. Calling it again does + * nothing. + */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.stopListening(); + if (this.enabled) { + this.flushActive(); + } + this.listeners.clear(); + } + + private notify(record: UwtScenarioRecord): void { + if (!this.listeners.size) { + return; + } + // Copied so a listener can't mutate the same object that's about to be + // (or already was) shipped to the sink. + const copy = uwtSafe( + 'scenarioTelemetry.notify', + () => uwtDeepClone(record), + undefined, + this.report + ); + if (!copy) { + return; + } + for (const listener of [...this.listeners]) { + uwtSafeVoid( + 'scenarioTelemetry.notify', + () => listener(copy), + this.report + ); + } + } + + /** + * Dispatches what the sink holds, leaving running scenarios alone. + * + * Uses the beacon transport since the page may not survive a normal request. + */ + private flushSink(): void { + uwtSafeVoid( + 'scenarioTelemetry.flushSink', + () => this.sink.flush(true), + this.report + ); + } + + /** + * Settles every in-flight scenario as abandoned and asks the sink to send + * what it holds using a transport that survives unload. + * + * Scenario emission is synchronous, so everything settled here reaches the + * sink before the beacon goes out. + */ + private flushActive(): void { + uwtSafeVoid( + 'scenarioTelemetry.flush', + () => { + for (const scenario of this.getActive()) { + scenario.settle('abandoned', { + reason: 'page-hidden', + metadata: { abandonedBy: 'page-hidden' } + }); + } + this.sink.flush(true); + }, + this.report + ); + } +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry-page/uwt-telemetry-page.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry-page/uwt-telemetry-page.ts new file mode 100644 index 0000000..e6d89fa --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry-page/uwt-telemetry-page.ts @@ -0,0 +1,56 @@ +/** Something telemetry can listen to: a page's window, or its document. */ +interface UwtListenable { + addEventListener(type: TEvent, listener: () => void): void; + removeEventListener(type: TEvent, listener: () => void): void; +} + +/** + * The part of a page telemetry watches: whether it is visible, and its + * window's `pagehide`. A browser `Document` is one; so is a test double. + * Typed structurally, so using the engine needs no DOM typings. + * + * @group Interfaces + */ +export interface UwtTelemetryDocument extends UwtListenable<'visibilitychange'> { + readonly visibilityState: string; + readonly defaultView: UwtListenable<'pagehide'> | null; +} + +/** + * The page to watch: the one given, the global `document` where there is one + * when none is, and nothing for `null`. + */ +export function uwtTelemetryDocument( + document: UwtTelemetryDocument | null | undefined +): UwtTelemetryDocument | null { + return document === undefined ? (globalThis.document ?? null) : document; +} + +/** + * Calls `onPageHide` when the page is going away (`pagehide`) and `onHidden` + * when it moves to the background (`visibilitychange` to hidden). + * + * @param document the page, or `null` to listen to nothing + * @returns removes both listeners + */ +export function uwtListenToPage( + document: UwtTelemetryDocument | null, + onPageHide: () => void, + onHidden: () => void +): () => void { + if (!document) { + return () => undefined; + } + const view = document.defaultView; + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + onHidden(); + } + }; + view?.addEventListener('pagehide', onPageHide); + document.addEventListener('visibilitychange', onVisibilityChange); + return () => { + view?.removeEventListener('pagehide', onPageHide); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts new file mode 100644 index 0000000..df8d829 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts @@ -0,0 +1,516 @@ +/** + * @jest-environment jsdom + */ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { UwtScenarioRecord } from '../../models/uwt-scenario.models/uwt-scenario.models'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UWT_REDACTED } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { UwtTelemetryOptions, uwtCreateTelemetry } from './uwt-telemetry'; + +/** Keeps what the engine sends, so a test can assert on it. */ +class RecordingSink extends UwtTelemetrySink { + readonly events: { eventType: string; payload: Record }[] = + []; + + readonly errors: UwtTelemetryError[] = []; + readonly flushes: (boolean | undefined)[] = []; + userId: string | undefined; + + record(eventType: string, payload: object): void { + this.events.push({ + eventType, + payload: payload as Record + }); + } + + recordError(error: UwtTelemetryError, _meta?: UwtTelemetryMetadata): void { + this.errors.push(error); + } + + getSessionId(): string | undefined { + return 'session-1'; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(beacon?: boolean): void { + this.flushes.push(beacon); + } + + ofType(eventType: string): Record[] { + return this.events + .filter((e) => e.eventType === eventType) + .map((e) => e.payload); + } +} + +/** A log store kept in memory. */ +class RecordingLogApi implements UwtLogApiProvider { + readonly records: UwtLogRecord[] = []; + flushCount = 0; + + send(record: UwtLogRecord): void { + this.records.push(record); + } + + query(filter: UwtLogQuery): Promise { + return Promise.resolve( + this.records.filter((r) => !filter.logger || r.logger === filter.logger) + ); + } + + flush(): void { + this.flushCount++; + } +} + +const identity = { + application: 'cart', + environment: 'test', + version: '1.0.0', + eventNamespace: 'com.cart' +}; + +function create(overrides: Partial = {}) { + const sink = new RecordingSink(); + const logApi = new RecordingLogApi(); + const telemetry = uwtCreateTelemetry({ + identity, + destination: sink, + logApi, + scenarios: { defaultTimeoutMs: 0 }, + ...overrides + }); + return { telemetry, sink, logApi }; +} + +describe('uwtCreateTelemetry', () => { + let engines: { dispose(): void }[]; + + beforeEach(() => { + engines = []; + }); + + afterEach(() => { + engines.forEach((engine) => engine.dispose()); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + function engine(overrides: Partial = {}) { + const created = create(overrides); + engines.push(created.telemetry); + return created; + } + + describe('scenarios', () => { + it('should send a settled scenario to the destination, with its steps and identity', () => { + const { telemetry, sink } = engine(); + + telemetry.scenarios.start({ name: 'checkout' }).step('pay').complete(); + + const [record] = sink.ofType('com.cart.scenario'); + expect(record).toMatchObject({ + scenarioName: 'checkout', + status: 'success', + application: 'cart', + sessionId: 'session-1' + }); + expect((record.steps as { name: string }[]).map((s) => s.name)).toEqual([ + 'scenario-start', + 'pay', + 'scenario-end' + ]); + }); + + it('should track what is in flight until it settles', () => { + const { telemetry } = engine(); + + const a = telemetry.scenarios.start({ name: 'checkout' }); + const b = telemetry.scenarios.start({ name: 'search' }); + expect(telemetry.scenarios.getActive()).toEqual([a, b]); + expect(telemetry.scenarios.findByName('checkout')).toEqual([a]); + expect( + telemetry.scenarios.findByNameAndId('search', a.id) + ).toBeUndefined(); + + a.complete(); + expect(telemetry.scenarios.find(a.id)).toBeUndefined(); + expect(telemetry.scenarios.getActive()).toEqual([b]); + }); + + it('should give every settled listener one shared copy, so none can change what is sent', () => { + const { telemetry, sink } = engine(); + const seen: UwtScenarioRecord[] = []; + telemetry.scenarios.onSettled((record) => { + seen.push(record); + record.metadata = { tampered: true }; + }); + telemetry.scenarios.onSettled((record) => seen.push(record)); + + telemetry.scenarios + .start({ name: 'checkout' }) + .complete({ metadata: { real: true } }); + + expect(seen).toHaveLength(2); + expect(seen[0]).toBe(seen[1]); + expect(sink.ofType('com.cart.scenario')[0].metadata).toEqual({ + real: true + }); + }); + + it('should keep calling other listeners when one throws, and report it', () => { + const report = jest.fn(); + const { telemetry } = engine({ report }); + const seen: string[] = []; + telemetry.scenarios.onSettled(() => { + throw new Error('listener broke'); + }); + telemetry.scenarios.onSettled((record) => seen.push(record.scenarioName)); + + expect(() => + telemetry.scenarios.start({ name: 'checkout' }).complete() + ).not.toThrow(); + + expect(seen).toEqual(['checkout']); + expect(report).toHaveBeenCalledWith( + 'error', + 'scenarioTelemetry.notify failed', + expect.any(Error) + ); + }); + + it('should stop calling a listener once it unsubscribes', () => { + const { telemetry } = engine(); + const listener = jest.fn(); + const stop = telemetry.scenarios.onSettled(listener); + + stop(); + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should settle a scenario that outlives its timeout, scheduling the timer through runOutside', () => { + jest.useFakeTimers(); + let outside = 0; + const runOutside = (callback: () => T): T => { + outside++; + return callback(); + }; + const { telemetry, sink } = engine({ + runOutside, + scenarios: { defaultTimeoutMs: 1_000 } + }); + + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + expect(outside).toBe(1); + + jest.advanceTimersByTime(1_000); + + expect(scenario.status).toBe('timeout'); + expect(sink.ofType('com.cart.scenario')[0].status).toBe('timeout'); + }); + }); + + describe('BI events', () => { + it('should send an event, redacted, and collapse an identical one fired straight after', () => { + const { telemetry, sink } = engine(); + + telemetry.bi.track('export_clicked', { token: 'x', format: 'csv' }); + telemetry.bi.track('export_clicked', { token: 'x', format: 'csv' }); + + const events = sink.ofType('com.cart.bi'); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventName: 'export_clicked', + application: 'cart', + metadata: { token: UWT_REDACTED, format: 'csv' } + }); + }); + }); + + describe('logging', () => { + it('should send records at or above the floor, stamped and redacted', async () => { + const { telemetry, logApi } = engine({ + logging: { minLevel: 'warn', levels: { audit: 'log' } } + }); + + telemetry.logging.getLogger('checkout').log('dropped'); + telemetry.logging + .getLogger('checkout') + .warn('Slow', { metadata: { token: 'x' } }); + telemetry.logging.getLogger('audit').log('kept'); + + expect(logApi.records.map((r) => r.message)).toEqual(['Slow', 'kept']); + expect(logApi.records[0]).toMatchObject({ + level: 'warn', + logger: 'checkout', + application: 'cart', + environment: 'test', + version: '1.0.0', + sessionId: 'session-1', + metadata: { token: UWT_REDACTED } + }); + expect(await telemetry.logging.query({ logger: 'audit' })).toHaveLength( + 1 + ); + }); + + it('should hand out the same logger for the same name', () => { + const { telemetry } = engine(); + expect(telemetry.logging.getLogger('checkout')).toBe( + telemetry.logging.getLogger('checkout') + ); + }); + + it('should mirror errors to the destination when asked', () => { + const { telemetry, sink } = engine({ + logging: { mirrorErrorsToRum: true } + }); + + telemetry.logging.getLogger('checkout').error('Payment failed'); + + expect(sink.errors).toEqual([ + expect.objectContaining({ message: 'Payment failed' }) + ]); + }); + + it('should read back nothing, rather than fail, from a store that throws', async () => { + const { telemetry, logApi } = engine(); + jest.spyOn(logApi, 'query').mockImplementation(() => { + throw new Error('store is down'); + }); + + expect(await telemetry.logging.query()).toEqual([]); + }); + }); + + describe('configuration', () => { + it('should apply redaction overrides to every concern', () => { + const { telemetry, sink, logApi } = engine({ + redaction: { extraKeyPatterns: [/^internal/i] } + }); + + telemetry.bi.track('export_clicked', { internalRef: 'x' }); + telemetry.logging + .getLogger('checkout') + .warn('x', { metadata: { internalRef: 'x' } }); + + expect(sink.ofType('com.cart.bi')[0].metadata).toEqual({ + internalRef: UWT_REDACTED + }); + expect(logApi.records[0].metadata).toEqual({ + internalRef: UWT_REDACTED + }); + }); + + it('should turn off configurable redaction for one concern only', () => { + const { telemetry, sink } = engine({ + bi: { redact: false }, + redaction: { scanValuePatterns: ['email'] } + }); + + telemetry.bi.track('export_clicked', { note: 'jane@example.com' }); + telemetry.scenarios + .start({ name: 'checkout', metadata: { note: 'jane@example.com' } }) + .complete(); + + expect(sink.ofType('com.cart.bi')[0].metadata).toEqual({ + note: 'jane@example.com' + }); + expect(sink.ofType('com.cart.scenario')[0].metadata).toEqual({ + note: UWT_REDACTED + }); + }); + + it('should throw when the identity, destination or log store is missing', () => { + expect(() => + uwtCreateTelemetry({} as unknown as UwtTelemetryOptions) + ).toThrow( + '[ui-watchtower] uwtCreateTelemetry needs identity.application, destination, logApi' + ); + }); + }); + + describe('failures', () => { + it('should never let a broken destination reach the application, and report it', () => { + const report = jest.fn(); + const { telemetry, sink } = engine({ report }); + jest.spyOn(sink, 'record').mockImplementation(() => { + throw new Error('destination is down'); + }); + + expect(() => { + telemetry.scenarios.start({ name: 'checkout' }).complete(); + telemetry.bi.track('export_clicked'); + }).not.toThrow(); + + expect(report).toHaveBeenCalledWith( + 'error', + 'scenario.success failed', + expect.any(Error) + ); + expect(report).toHaveBeenCalledWith( + 'error', + 'biTelemetry.track failed', + expect.any(Error) + ); + }); + + it('should print nothing without a reporter', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const { telemetry, sink } = engine(); + jest.spyOn(sink, 'record').mockImplementation(() => { + throw new Error('destination is down'); + }); + + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(consoleError).not.toHaveBeenCalled(); + }); + }); + + describe('disabled', () => { + it('should record, send, log and watch nothing', async () => { + jest.useFakeTimers(); + const listen = jest.spyOn(document, 'addEventListener'); + const { telemetry, sink, logApi } = engine({ + enabled: false, + scenarios: { defaultTimeoutMs: 1_000 } + }); + + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + scenario.step('pay').complete(); + telemetry.bi.track('export_clicked'); + telemetry.logging.getLogger('checkout').error('x'); + telemetry.setUserId('user-1'); + telemetry.flush(); + + expect(scenario.status).toBeUndefined(); + expect(sink.events).toEqual([]); + expect(sink.userId).toBeUndefined(); + expect(sink.flushes).toEqual([]); + expect(logApi.records).toEqual([]); + expect(await telemetry.logging.query()).toEqual([]); + expect(listen).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + }); + }); + + describe('the page', () => { + function hide(state: 'hidden' | 'visible'): void { + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue(state); + document.dispatchEvent(new Event('visibilitychange')); + } + + it('should settle what is in flight as abandoned, and flush, when the page goes away', () => { + const { telemetry, sink, logApi } = engine(); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + + window.dispatchEvent(new Event('pagehide')); + + expect(scenario.status).toBe('abandoned'); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + + it('should flush, without settling, when the page is hidden', () => { + const { telemetry, sink, logApi } = engine(); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + + hide('hidden'); + + expect(scenario.isSettled).toBe(false); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + + it('should watch no page given null', () => { + const { telemetry, sink } = engine({ document: null }); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + + window.dispatchEvent(new Event('pagehide')); + hide('hidden'); + + expect(scenario.isSettled).toBe(false); + expect(sink.flushes).toEqual([]); + }); + }); + + describe('setUserId and flush', () => { + it('should pass the user to the destination, and flush it and the log store', () => { + const { telemetry, sink, logApi } = engine(); + + telemetry.setUserId('user-1'); + telemetry.flush(true); + + expect(sink.userId).toBe('user-1'); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + }); + + describe('dispose', () => { + it('should settle what is in flight as abandoned, flush once, and stop watching the page', () => { + jest.useFakeTimers(); + const { telemetry, sink, logApi } = engine({ + scenarios: { defaultTimeoutMs: 60_000 } + }); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + expect(jest.getTimerCount()).toBe(1); + + telemetry.dispose(); + telemetry.dispose(); + + expect(scenario.status).toBe('abandoned'); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + expect(jest.getTimerCount()).toBe(0); + + window.dispatchEvent(new Event('pagehide')); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + + it('should leave everything inert afterwards, with no timer or listener', () => { + jest.useFakeTimers(); + const listener = jest.fn(); + const { telemetry, sink, logApi } = engine({ + scenarios: { defaultTimeoutMs: 60_000 } + }); + telemetry.scenarios.onSettled(listener); + const logger = telemetry.logging.getLogger('checkout'); + + telemetry.dispose(); + const late = telemetry.scenarios.start({ name: 'checkout' }); + late.complete(); + telemetry.bi.track('export_clicked'); + logger.error('late'); + telemetry.setUserId('user-1'); + + expect(late.isSettled).toBe(false); + expect(jest.getTimerCount()).toBe(0); + expect(listener).not.toHaveBeenCalled(); + expect(sink.events).toEqual([]); + expect(sink.userId).toBeUndefined(); + expect(logApi.records).toEqual([]); + }); + }); +}); diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts new file mode 100644 index 0000000..8eddfe5 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts @@ -0,0 +1,200 @@ +import { + UwtTelemetryConfigOverrides, + UwtTelemetryIdentity, + uwtResolveTelemetryConfig +} from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UwtLogApiProvider } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { + UwtReporter, + uwtSafeVoid, + uwtSafeVoidMaybeAsync +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UwtBITelemetry } from '../uwt-bi-telemetry/uwt-bi-telemetry'; +import { UwtLogTelemetry } from '../uwt-log-telemetry/uwt-log-telemetry'; +import { UwtScenarioTelemetry } from '../uwt-scenario-telemetry/uwt-scenario-telemetry'; +import { + UwtTelemetryDocument, + uwtTelemetryDocument +} from '../uwt-telemetry-page/uwt-telemetry-page'; + +/** + * What {@link uwtCreateTelemetry} needs: who the application is, where + * telemetry and logs go, and anything to change from the defaults. + * + * @group Interfaces + */ +export interface UwtTelemetryOptions extends UwtTelemetryConfigOverrides { + /** The application's identity, carried by every record and event. */ + identity: UwtTelemetryIdentity; + /** + * Where scenarios, BI events and mirrored errors go — exactly one. + * `new UwtNoopTelemetrySink()` sends them nowhere. + */ + destination: UwtTelemetrySink; + /** + * The application's log store. `new UwtNoopLogApiProvider()` keeps no + * logs, said explicitly. + */ + logApi: UwtLogApiProvider; + /** + * `false` makes everything inert: nothing is recorded, redacted, timed, + * sent or logged, and nothing listens to the page. Default `true`. + */ + enabled?: boolean; + /** + * The page whose lifecycle settles and flushes telemetry. Defaults to the + * global `document` where there is one; `null` listens to nothing, as on + * a server. + */ + document?: UwtTelemetryDocument | null; + /** + * Runs timer scheduling outside the host framework's change detection, + * when it has one. + */ + runOutside?: (callback: () => T) => T; + /** + * Receives what telemetry handles silently: failures it skipped, data it + * dropped. Nothing is reported without one; see {@link UwtReporter}. + */ + report?: UwtReporter; +} + +/** + * A configured telemetry engine: one per application, or per fragment in a + * composed page. + * + * @group Interfaces + */ +export interface UwtTelemetry { + /** Scenarios — user journeys and their health. */ + readonly scenarios: UwtScenarioTelemetry; + /** Business and UX events. */ + readonly bi: UwtBITelemetry; + /** Structured application logs. */ + readonly logging: UwtLogTelemetry; + /** + * Attributes telemetry from now on to an application user, or to nobody + * (`undefined`, or `''`). Passed to the destination. + */ + setUserId(userId: string | undefined): void; + /** + * Sends what the destination and the log store hold. + * + * @param beacon use a transport that survives page unload + */ + flush(beacon?: boolean): void; + /** + * Stops watching the page, settles scenarios still in flight as abandoned + * and flushes, then does nothing more: every concern is inert from then + * on, and no listener or timer of this engine is left. Calling it again + * does nothing. The destination and the log store stay yours to close. + */ + dispose(): void; +} + +/** + * Creates a telemetry engine: scenarios, BI events and logging, all sending + * to one destination and one log store. Needs no framework. + * + * @example + * ```typescript + * const telemetry = uwtCreateTelemetry({ + * identity: { application: 'cart', environment: 'prod', version: '1.4.0' }, + * destination: new UwtNoopTelemetrySink(), + * logApi: new UwtNoopLogApiProvider(), + * logging: { minLevel: 'warn' } + * }); + * + * const scenario = telemetry.scenarios.start({ name: 'checkout' }); + * scenario.step('pay'); + * scenario.complete(); + * + * telemetry.dispose(); + * ``` + * + * @param options identity, destination, log store and overrides + * @returns the engine + * @throws when the identity, destination or log store is missing + * + * @group Utils + */ +export function uwtCreateTelemetry(options: UwtTelemetryOptions): UwtTelemetry { + assertOptions(options); + const { identity, destination, logApi, report } = options; + const enabled = options.enabled !== false; + const config = uwtResolveTelemetryConfig(options); + const document = uwtTelemetryDocument(options.document); + const shared = { identity, redact: config.redaction, enabled, report }; + + const scenarios = new UwtScenarioTelemetry({ + ...shared, + config: config.scenarios, + sink: destination, + document, + runOutside: options.runOutside + }); + const bi = new UwtBITelemetry({ + ...shared, + config: config.bi, + sink: destination + }); + const logging = new UwtLogTelemetry({ + ...shared, + config: config.logging, + logApi, + sink: destination, + document + }); + + let disposed = false; + const live = () => enabled && !disposed; + + return { + scenarios, + bi, + logging, + setUserId(userId) { + if (live()) { + uwtSafeVoid( + 'telemetry.setUserId', + () => destination.setUserId(userId), + report + ); + } + }, + flush(beacon) { + if (live()) { + uwtSafeVoid('telemetry.flush', () => destination.flush(beacon), report); + uwtSafeVoidMaybeAsync( + 'telemetry.logFlush', + () => logApi.flush?.(), + report + ); + } + }, + dispose() { + if (disposed) { + return; + } + disposed = true; + scenarios.dispose(); + logging.dispose(); + bi.dispose(); + } + }; +} + +/** Configuration errors throw: telemetry fails open, its setup does not. */ +function assertOptions(options: UwtTelemetryOptions): void { + const missing = [ + !options?.identity?.application && 'identity.application', + typeof options?.destination?.record !== 'function' && 'destination', + typeof options?.logApi?.send !== 'function' && 'logApi' + ].filter(Boolean); + if (missing.length) { + throw new Error( + `[ui-watchtower] uwtCreateTelemetry needs ${missing.join(', ')}` + ); + } +} diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts b/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts rename to projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts diff --git a/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts b/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts new file mode 100644 index 0000000..676b518 --- /dev/null +++ b/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts @@ -0,0 +1,26 @@ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../uwt-log-api.provider/uwt-log-api.provider'; + +/** + * Log API provider that discards every record and finds none. + * + * The explicit way to say an application has no log backend — never a + * default. Mostly for a shell that hosts fragments but logs nothing: the + * broadcast host requires a log provider, so that one can't be forgotten. + * + * Bound in a shell, it discards its fragments' log records too. + * + * @group Services + */ +export class UwtNoopLogApiProvider implements UwtLogApiProvider { + /** @inheritdoc */ + send(_record: UwtLogRecord): void {} + + /** @inheritdoc */ + query(_filter: UwtLogQuery): Promise { + return Promise.resolve([]); + } +} diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts similarity index 94% rename from projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts rename to projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts index 95fac2c..59fd3e3 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts +++ b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts @@ -1,5 +1,5 @@ import { UwtNoopTelemetrySink } from './uwt-noop-telemetry.sink'; -import { UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; +import { UwtTelemetrySink } from '../uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; describe('UwtNoopTelemetrySink', () => { let sink: UwtTelemetrySink; diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts new file mode 100644 index 0000000..4f13ce7 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts @@ -0,0 +1,50 @@ +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtTelemetrySink } from '../uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; + +/** + * Sink that discards everything. + * + * The explicit way to send telemetry nowhere — never a default. Debug flags, + * logging and scenario mechanics all still work, with no AWS account or + * `aws-rum-web` dependency needed. Angular selects it with + * `provideUwtTelemetrySink('noop')`. + * + * @group Services + */ +export class UwtNoopTelemetrySink extends UwtTelemetrySink { + private userId?: string; + + /** @inheritdoc */ + record( + _eventType: string, + _payload: object, + _metadata?: UwtTelemetryMetadata + ): void {} + + /** @inheritdoc */ + recordError( + _error: UwtTelemetryError, + _metadata?: UwtTelemetryMetadata + ): void {} + + /** @inheritdoc */ + getSessionId(): string | undefined { + return undefined; + } + + /** @inheritdoc */ + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(_beacon?: boolean): void {} +} diff --git a/projects/ui-watchtower/src/public-api.spec.ts b/projects/ui-watchtower/src/public-api.spec.ts index b167832..c7fa8b2 100644 --- a/projects/ui-watchtower/src/public-api.spec.ts +++ b/projects/ui-watchtower/src/public-api.spec.ts @@ -1,4 +1,9 @@ import * as core from './public-api'; +import { + UwtLogRecord, + UwtNoopTelemetrySink, + uwtCreateTelemetry +} from './public-api'; /** * The core must import, and run, where there is no browser: in Node, on a @@ -10,11 +15,40 @@ describe('the core outside a browser', () => { expect(typeof window).toBe('undefined'); expect(typeof document).toBe('undefined'); expect(typeof localStorage).toBe('undefined'); - expect(Object.keys(core)).toContain('uwtMarkSessionStart'); + expect(Object.keys(core)).toContain('uwtCreateTelemetry'); }); it('should keep no session start, and not throw, without storage', () => { expect(() => core.uwtMarkSessionStart('s1', 1_000)).not.toThrow(); expect(core.uwtSessionElapsed('s1', 2_000)).toBeUndefined(); }); + + it('should run scenarios, BI events and logging, and dispose, with no page', async () => { + const sent: { eventType: string; payload: object }[] = []; + const destination = new (class extends UwtNoopTelemetrySink { + override record(eventType: string, payload: object): void { + sent.push({ eventType, payload }); + } + })(); + const records: UwtLogRecord[] = []; + const telemetry = uwtCreateTelemetry({ + identity: { application: 'worker', environment: 'test', version: '1' }, + destination, + logApi: { + send: (record) => records.push(record), + query: async () => records + } + }); + + telemetry.scenarios.start({ name: 'import' }).step('parse').complete(); + telemetry.bi.track('file_imported', { rows: 3 }); + telemetry.logging.getLogger('import').log('Imported'); + + expect(sent.map((e) => e.eventType)).toEqual([ + 'com.uwt.scenario', + 'com.uwt.bi' + ]); + expect(await telemetry.logging.query()).toHaveLength(1); + expect(() => telemetry.dispose()).not.toThrow(); + }); }); diff --git a/projects/ui-watchtower/src/public-api.ts b/projects/ui-watchtower/src/public-api.ts index 585701e..7cf1fbc 100644 --- a/projects/ui-watchtower/src/public-api.ts +++ b/projects/ui-watchtower/src/public-api.ts @@ -5,9 +5,30 @@ * nothing touches a browser global when imported. */ +// The engine +export { uwtCreateTelemetry } from './lib/engine/uwt-telemetry/uwt-telemetry'; +export type { + UwtTelemetry, + UwtTelemetryOptions +} from './lib/engine/uwt-telemetry/uwt-telemetry'; +export { UwtScenarioTelemetry } from './lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry'; +export type { UwtScenarioTelemetryOptions } from './lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry'; +export { UwtBITelemetry } from './lib/engine/uwt-bi-telemetry/uwt-bi-telemetry'; +export type { UwtBITelemetryOptions } from './lib/engine/uwt-bi-telemetry/uwt-bi-telemetry'; +export { UwtLogTelemetry } from './lib/engine/uwt-log-telemetry/uwt-log-telemetry'; +export type { UwtLogTelemetryOptions } from './lib/engine/uwt-log-telemetry/uwt-log-telemetry'; +export type { UwtTelemetryDocument } from './lib/engine/uwt-telemetry-page/uwt-telemetry-page'; + // Configuration -export { UWT_DEFAULT_TELEMETRY_CONFIG } from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; -export type { UwtTelemetryIdentity } from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +export { + UWT_DEFAULT_TELEMETRY_CONFIG, + uwtResolveTelemetryConfig +} from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +export type { + UwtTelemetryConfig, + UwtTelemetryConfigOverrides, + UwtTelemetryIdentity +} from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; export type { UwtBITelemetryConfig } from './lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; export type { UwtLogConfig } from './lib/config/uwt-log.config/uwt-log.config'; export type { UwtScenarioTelemetryConfig } from './lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; @@ -58,19 +79,27 @@ export type { // Scenarios export { UwtScenario } from './lib/scenario/uwt-scenario/uwt-scenario'; -// Contracts a destination or a log backend implements +// Contracts a destination or a log backend implements, and the explicit +// "nowhere" of each export { UwtTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +export { UwtNoopTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; export type { UwtLogApiProvider, UwtLogQuery } from './lib/providers/uwt-log-api.provider/uwt-log-api.provider'; +export { UwtNoopLogApiProvider } from './lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider'; // Utilities an application or a destination needs export { uwtClassifyTelemetryEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; export type { UwtTelemetrySinkEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; export { uwtIsDebugEnabled } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; export type { UwtDebugFlag } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; -export { uwtUuid } from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +export { + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync, + uwtUuid +} from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; export type { UwtReporter } from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; export { uwtMarkSessionStart } from './lib/utils/uwt-session-start.util/uwt-session-start.util'; export { @@ -89,11 +118,3 @@ export type { // Used by @absaoss-cps/ngx-ui-watchtower while the engine moves into this // package; removed from the public API once that code lives here too. export { uwtSessionElapsed } from './lib/utils/uwt-session-start.util/uwt-session-start.util'; -export { - uwtDeepClone, - uwtNow, - uwtSafe, - uwtSafeVoid, - uwtSafeVoidMaybeAsync -} from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -export { uwtDebugWrite } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; diff --git a/projects/ui-watchtower/tsconfig.spec.json b/projects/ui-watchtower/tsconfig.spec.json index f0716fe..4271b51 100644 --- a/projects/ui-watchtower/tsconfig.spec.json +++ b/projects/ui-watchtower/tsconfig.spec.json @@ -2,9 +2,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/spec-core", - "rootDir": "./src", + "rootDir": ".", "types": ["jest", "node"], "isolatedModules": false }, - "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] + "include": ["src/**/*.spec.ts", "rxjs/**/*.spec.ts", "src/**/*.d.ts"] } diff --git a/projects/ui-watchtower/type-tests/augmented.ts b/projects/ui-watchtower/type-tests/augmented.ts index 67ff10b..d009bdc 100644 --- a/projects/ui-watchtower/type-tests/augmented.ts +++ b/projects/ui-watchtower/type-tests/augmented.ts @@ -9,6 +9,7 @@ * rejected is caught too. */ import { + UwtTelemetry, UwtBIEvent, UwtBIEventName, UwtLogConfig, @@ -114,3 +115,27 @@ export const options: UwtScenarioOptions = { name: 'chekout' }; export const query: UwtLogQuery = { logger: 'chekout' }; // @ts-expect-error not a registered logger export const levels: UwtLogConfig['levels'] = { chekout: 'log' }; + +/** Through the engine: registered names accepted, everything else rejected. */ +export async function engine(telemetry: UwtTelemetry): Promise { + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + telemetry.scenarios.findByName('load-dashboard'); + telemetry.scenarios.findByNameAndId('checkout', scenario.id); + telemetry.scenarios.onSettled((_record) => { + expectType>(); + }); + telemetry.bi.track('export_clicked'); + telemetry.logging.getLogger('checkout').warn('careful'); + await telemetry.logging.query({ logger: 'checkout' }); + + // @ts-expect-error not a registered scenario + telemetry.scenarios.start({ name: 'chekout' }); + // @ts-expect-error not a registered scenario + telemetry.scenarios.findByName('chekout'); + // @ts-expect-error not a registered business event + telemetry.bi.track('export_clikced'); + // @ts-expect-error not a registered logger + telemetry.logging.getLogger('chekout'); + // @ts-expect-error not a registered logger + await telemetry.logging.query({ logger: 'chekout' }); +} diff --git a/projects/ui-watchtower/type-tests/compat.ts b/projects/ui-watchtower/type-tests/compat.ts index 810cfc9..7769c5d 100644 --- a/projects/ui-watchtower/type-tests/compat.ts +++ b/projects/ui-watchtower/type-tests/compat.ts @@ -5,6 +5,11 @@ * here, so every name is a plain string; see `augmented.ts` for registered * names. */ +import { firstValueFrom, Observable, of } from 'rxjs'; +import { + traceScenario, + UwtTraceScenarioOptions +} from '@absaoss-cps/ui-watchtower/rxjs'; import { UWT_DEFAULT_EVENT_NAMESPACE, UWT_DEFAULT_REDACT_CONFIG, @@ -12,8 +17,11 @@ import { UWT_LOG_LEVEL_ORDER, UWT_REDACTED, UWT_TELEMETRY_EVENT_TYPE, + UwtBITelemetry, UwtBITelemetryConfig, + UwtBITelemetryOptions, uwtClassifyTelemetryEvent, + uwtCreateTelemetry, UwtDebugFlag, uwtEventTypes, uwtIsDebugEnabled, @@ -22,20 +30,35 @@ import { UwtLogLevel, UwtLogQuery, UwtLogRecord, + UwtLogTelemetry, + UwtLogTelemetryOptions, uwtMarkSessionStart, uwtNormalizeError, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink, UwtPiiValuePattern, UwtRedactConfig, uwtRedactConfigFor, uwtRedactMetadata, UwtReporter, + uwtResolveTelemetryConfig, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync, UwtScenario, UwtScenarioRecord, + UwtScenarioTelemetry, UwtScenarioTelemetryConfig, + UwtScenarioTelemetryOptions, uwtScrubString, + UwtTelemetry, + UwtTelemetryConfig, + UwtTelemetryConfigOverrides, + UwtTelemetryDocument, UwtTelemetryError, UwtTelemetryIdentity, UwtTelemetryMetadata, + UwtTelemetryOptions, UwtTelemetrySink, UwtTelemetrySinkEvent, uwtUuid @@ -131,3 +154,87 @@ export function utilities(): unknown[] { UWT_REDACTED ]; } + +/** The engine, as a framework-free application creates and uses it. */ +export async function engine( + page: UwtTelemetryDocument | null +): Promise { + const overrides: UwtTelemetryConfigOverrides = { + logging: { minLevel: 'warn' }, + redaction: { scanValuePatterns: ['email'] } + }; + const options: UwtTelemetryOptions = { + identity, + destination: new UwtNoopTelemetrySink(), + logApi: new UwtNoopLogApiProvider(), + document: page, + runOutside: (callback) => callback(), + report, + ...overrides + }; + const telemetry: UwtTelemetry = uwtCreateTelemetry(options); + const config: UwtTelemetryConfig = uwtResolveTelemetryConfig(overrides); + + const scenario: UwtScenario = telemetry.scenarios.start({ name: 'checkout' }); + const stop = telemetry.scenarios.onSettled((record: UwtScenarioRecord) => + report('warn', record.scenarioName) + ); + const traced: UwtTraceScenarioOptions = { + outcome: (count) => ({ metadata: { count } }) + }; + const piped: Observable = of(1).pipe( + traceScenario(scenario, traced, report) + ); + await firstValueFrom(piped); + telemetry.bi.track( + 'export_clicked', + { format: 'csv' }, + { scenarioId: scenario.id } + ); + telemetry.logging.getLogger('checkout').warn('careful'); + const lines: UwtLogRecord[] = await telemetry.logging.query({ limit: 1 }); + telemetry.setUserId('user-1'); + telemetry.flush(true); + stop(); + telemetry.dispose(); + uwtSafeVoid('app.cleanup', () => undefined, report); + uwtSafeVoidMaybeAsync('app.async', async () => undefined, report); + return uwtSafe( + 'app.count', + () => lines.length + config.bi.dedupWindowMs, + 0, + report + ); +} + +/** Each concern on its own, as an adapter composes them. */ +export function concerns( + sink: UwtTelemetrySink, + logApi: UwtLogApiProvider +): unknown[] { + const config = uwtResolveTelemetryConfig(); + const shared = { identity, redact: config.redaction, report }; + const scenarioOptions: UwtScenarioTelemetryOptions = { + ...shared, + config: config.scenarios, + sink, + document: null + }; + const biOptions: UwtBITelemetryOptions = { + ...shared, + config: config.bi, + sink + }; + const logOptions: UwtLogTelemetryOptions = { + ...shared, + config: config.logging, + logApi, + sink, + document: null + }; + return [ + new UwtScenarioTelemetry(scenarioOptions), + new UwtBITelemetry(biOptions), + new UwtLogTelemetry(logOptions) + ]; +} diff --git a/tsconfig.json b/tsconfig.json index 24ff742..524f134 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,9 @@ "@absaoss-cps/ui-watchtower": [ "./projects/ui-watchtower/src/public-api.ts" ], + "@absaoss-cps/ui-watchtower/rxjs": [ + "./projects/ui-watchtower/rxjs/src/public-api.ts" + ], "@absaoss-cps/ngx-ui-watchtower": [ "./projects/ngx-ui-watchtower/src/public-api.ts" ], From 35d36ad317b82f3ea58d4d57a4f1fd3f50d39f29 Mon Sep 17 00:00:00 2001 From: fateeand Date: Mon, 9 Nov 2026 19:21:25 +0100 Subject: [PATCH 05/21] refactor: move the shell/fragment broadcast protocol into the core --- projects/ngx-ui-watchtower/DESIGN.md | 11 + projects/ngx-ui-watchtower/README.md | 3 + .../public-api.snapshot.json | 3 + .../uwt-broadcast-log-api.provider.ts | 110 +---- .../uwt-telemetry-common.providers.ts | 7 +- .../uwt-broadcast-host.service.ts | 322 +------------ .../uwt-broadcast-telemetry.sink.ts | 106 +---- .../uwt-broadcast/uwt-broadcast.messages.ts | 396 ++-------------- .../sinks/uwt-broadcast/uwt-broadcast.spec.ts | 325 +------------ .../uwt-broadcast/uwt-broadcast.wire.spec.ts | 313 ++++++++++--- .../uwt-telemetry-safe.util.ts | 23 + projects/ngx-ui-watchtower/src/public-api.ts | 6 +- projects/ui-watchtower/README.md | 55 ++- .../ui-watchtower/public-api.snapshot.json | 77 ++++ .../uwt-broadcast-log-api.provider.ts | 176 +++++++ .../sinks/uwt-broadcast/uwt-broadcast-host.ts | 414 +++++++++++++++++ .../uwt-broadcast-telemetry.sink.ts | 177 +++++++ .../uwt-broadcast.messages.spec.ts | 369 +++++++++++++++ .../uwt-broadcast/uwt-broadcast.messages.ts | 430 ++++++++++++++++++ .../sinks/uwt-broadcast/uwt-broadcast.spec.ts | 245 ++++++++++ projects/ui-watchtower/src/public-api.ts | 21 + projects/ui-watchtower/type-tests/compat.ts | 56 +++ 22 files changed, 2384 insertions(+), 1261 deletions(-) create mode 100644 projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts create mode 100644 projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.ts create mode 100644 projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts create mode 100644 projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.spec.ts create mode 100644 projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts create mode 100644 projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index ea7bfad..690f281 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -2183,6 +2183,17 @@ providers: [ Code inside a fragment does not change: same services, same calls. One session, one budget, one bundle. +Either side may be built without Angular. The protocol — the messages, the +host, the forwarding sink and log provider — lives in +`@absaoss-cps/ui-watchtower`, and this package's classes are injectable +subclasses of the core's. An Angular shell hosts a framework-free fragment, +and a framework-free shell an Angular one, exchanging the same messages: the +wire test holds every pairing to the fixture recorded before the core was +extracted. A fragment wired by `provideUwtTelemetrySink('broadcast')` opens one +channel for its sink and log provider, closed with its injector. The host +takes part — counting toward the duplicate check, electing a leader — only in +a browser with telemetry on, so server-side renders never see each other. + ### Connecting a fragment Two providers, and that is the whole integration: diff --git a/projects/ngx-ui-watchtower/README.md b/projects/ngx-ui-watchtower/README.md index dd7a479..dbe32a6 100644 --- a/projects/ngx-ui-watchtower/README.md +++ b/projects/ngx-ui-watchtower/README.md @@ -352,6 +352,9 @@ providers: [ ``` Fragment code doesn't change. Give each fragment its own `application` name. +A shell or a fragment needn't be Angular: one built on +[`@absaoss-cps/ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ui-watchtower) +connects the same way. ## Seeing what is sent diff --git a/projects/ngx-ui-watchtower/public-api.snapshot.json b/projects/ngx-ui-watchtower/public-api.snapshot.json index a47a005..dd88499 100644 --- a/projects/ngx-ui-watchtower/public-api.snapshot.json +++ b/projects/ngx-ui-watchtower/public-api.snapshot.json @@ -100,6 +100,7 @@ "new (): UwtBroadcastLogApiProvider", "static ɵfac: unknown", "static ɵprov: ɵɵInjectableDeclaration", + "dispose(): void", "flush(): void", "ngOnDestroy(): void", "query(filter: UwtLogQuery): Promise", @@ -110,6 +111,7 @@ "new (): UwtBroadcastTelemetrySink", "static ɵfac: unknown", "static ɵprov: ɵɵInjectableDeclaration", + "dispose(): void", "flush(beacon?: boolean | undefined): void", "getSessionId(): string | undefined", "getUserId(): string | undefined", @@ -411,6 +413,7 @@ "new (): UwtTelemetryBroadcastHost", "static ɵfac: unknown", "static ɵprov: ɵɵInjectableDeclaration", + "dispose(): void", "get received: number", "ngOnDestroy(): void" ], diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts index db8cda5..00dbe08 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts @@ -1,29 +1,13 @@ -import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; +import { Injectable, OnDestroy } from '@angular/core'; +import { UwtBroadcastLogApiProvider as CoreBroadcastLogApiProvider } from '@absaoss-cps/ui-watchtower'; +import { uwtBroadcastConnectionOptions } from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; import { - UwtLogApiProvider, - UwtLogQuery, - UwtLogRecord, - uwtUuid -} from '@absaoss-cps/ui-watchtower'; -import { uwtSafeVoid } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -import { - UwtBroadcastConnection, - uwtConnectBroadcastChannel, - uwtIsBroadcastLogRecord, - uwtIsBroadcastMessage -} from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; - -/** - * How long a follower waits for the host to answer a log query before - * resolving to `[]`, in milliseconds. - */ -export const UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS = 10_000; + uwtDevModeReporter, + uwtRunInsideZone, + uwtRunOutsideZone +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -/** A log query waiting for the host's answer. */ -interface UwtPendingLogQuery { - resolve: (records: UwtLogRecord[]) => void; - timer: ReturnType; -} +export { UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS } from '@absaoss-cps/ui-watchtower'; /** * Log API provider for a follower realm — the log counterpart of @@ -45,82 +29,20 @@ interface UwtPendingLogQuery { */ @Injectable() export class UwtBroadcastLogApiProvider - implements UwtLogApiProvider, OnDestroy + extends CoreBroadcastLogApiProvider + implements OnDestroy { - private readonly connection: UwtBroadcastConnection = - uwtConnectBroadcastChannel('broadcastLogApi'); - - private readonly pending = new Map(); - - /** Absent outside an application, e.g. a bare `Injector.create`. */ - private readonly zone = inject(NgZone, { optional: true }); - constructor() { - this.connection.onMessage((data) => this.onMessage(data)); - } - - /** @inheritdoc */ - send(record: UwtLogRecord): void { - this.connection.post({ kind: 'log', record }); - } - - /** @inheritdoc */ - query(filter: UwtLogQuery): Promise { - if (!this.connection.isOpen()) { - return Promise.resolve([]); - } - - return new Promise((resolve) => { - const id = uwtUuid(); - const timer = this.outsideZone(() => - setTimeout( - () => this.answer(id, []), - UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS - ) - ); - this.pending.set(id, { resolve, timer }); - this.connection.post({ kind: 'log-query', id, filter }); + super({ + ...uwtBroadcastConnectionOptions(), + runOutside: uwtRunOutsideZone(), + runInside: uwtRunInsideZone(), + report: uwtDevModeReporter }); } - /** @inheritdoc */ - flush(): void { - this.connection.post({ kind: 'log-flush' }); - } - /** Resolves every unanswered query to `[]`, then closes the channel. */ ngOnDestroy(): void { - for (const id of [...this.pending.keys()]) { - this.answer(id, []); - } - this.connection.close(); - } - - private onMessage(data: unknown): void { - uwtSafeVoid('broadcastLogApi.receive', () => { - if (!uwtIsBroadcastMessage(data) || data.kind !== 'log-query-result') { - return; - } - // Other followers' answers arrive here too; answer() ignores them. - this.answer(data.id, data.records.filter(uwtIsBroadcastLogRecord)); - }); - } - - private answer(id: string, records: UwtLogRecord[]): void { - const query = this.pending.get(id); - if (!query) { - return; - } - this.pending.delete(id); - clearTimeout(query.timer); - if (this.zone) { - this.zone.run(() => query.resolve(records)); - } else { - query.resolve(records); - } - } - - private outsideZone(fn: () => T): T { - return this.zone ? this.zone.runOutsideAngular(fn) : fn(); + this.dispose(); } } diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts index 0d32998..25c7361 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts @@ -9,7 +9,10 @@ import { } from '@angular/core'; import { UwtTelemetryBroadcastHost } from '../../sinks/uwt-broadcast/uwt-broadcast-host.service'; import { UwtBroadcastTelemetrySink } from '../../sinks/uwt-broadcast/uwt-broadcast-telemetry.sink'; -import { UWT_BROADCAST_CHANNEL } from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; +import { + UWT_BROADCAST_CHANNEL, + UwtBroadcastConnectionRef +} from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; import { UwtNoopTelemetrySink } from '../../sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; import { UWT_DEFAULT_TELEMETRY_CONFIG, @@ -376,6 +379,8 @@ export function provideUwtTelemetrySink( switch (mode) { case 'broadcast': return makeEnvironmentProviders([ + // One channel for the realm's sink and log provider. + UwtBroadcastConnectionRef, provideUwtTelemetryDestination(UwtBroadcastTelemetrySink), UwtBroadcastLogApiProvider, { diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts index 7871ad0..c0c2813 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.service.ts @@ -1,39 +1,11 @@ import { inject, Injectable, OnDestroy } from '@angular/core'; import { - uwtClassifyTelemetryEvent, - UwtLogQuery, - UwtLogRecord, - uwtSessionElapsed, + UwtTelemetryBroadcastHost as CoreTelemetryBroadcastHost, UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; -import { - uwtSafe, - uwtSafeVoid, - uwtSafeVoidMaybeAsync -} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -import { - UwtBroadcastConnection, - uwtConnectBroadcastChannel, - uwtElectBroadcastHostLeader, - uwtIsBroadcastMessage -} from './uwt-broadcast.messages'; -import { uwtTelemetryEnabled } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; -import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; -import { UwtBroadcastLogApiProvider } from '../../providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; - -/** The identity fields scenario, BI and log records carry. */ -interface UwtIdentified { - sessionId?: string; - userId?: string; - sessionElapsed?: number; -} - -/** - * Active host count per channel, scoped to this JS realm — distinguishes a - * true duplicate provider from another tab sharing the same channel. - */ -const hostsInThisRealm = new Map(); +import { uwtHostReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { uwtBroadcastChannelOptions } from './uwt-broadcast.messages'; /** * Receives telemetry forwarded by follower realms and records it through this @@ -73,287 +45,21 @@ const hostsInThisRealm = new Map(); * @group Services */ @Injectable() -export class UwtTelemetryBroadcastHost implements OnDestroy { - private readonly sink = inject(UwtTelemetrySink); - private readonly logApi = inject(UWT_LOG_API_PROVIDER); - private readonly connection: UwtBroadcastConnection = - uwtConnectBroadcastChannel('broadcastHost'); - - /** Number of messages accepted, for tests and diagnostics. */ - private _received = 0; - - /** - * The session and user id last sent in an `identity` message. - * - * Compared against the sink's current values on every follower activity, so - * a late-resolving session id or user id gets announced once it exists. - */ - private lastAnnouncedSessionId?: string; - private lastAnnouncedUserId?: string; - - /** Whether this realm won the leader election; non-leaders stay passive. */ - private isLeader = false; - private releaseLeadership: () => void = () => undefined; - private readonly enabled = uwtTelemetryEnabled(); - +export class UwtTelemetryBroadcastHost + extends CoreTelemetryBroadcastHost + implements OnDestroy +{ constructor() { - if (!this.enabled) { - // Telemetry is off: no listening, no leader election. - return; - } - this.warnIfDuplicateInThisRealm(); - if (this.forwardsItsOwnRealm()) { - this.connection.close(); - return; - } - - this.connection.onMessage((data) => this.onMessage(data)); - this.releaseLeadership = uwtElectBroadcastHostLeader( - this.connection.channelName, - () => { - this.isLeader = true; - this.announceIdentity(); - } - ); - } - - /** How many follower messages have been accepted. */ - get received(): number { - return this._received; + super({ + sink: inject(UwtTelemetrySink), + logApi: inject(UWT_LOG_API_PROVIDER), + ...uwtBroadcastChannelOptions(), + report: uwtHostReporter + }); } /** @inheritdoc */ ngOnDestroy(): void { - if (this.enabled) { - this.forgetInThisRealm(); - } - this.releaseLeadership(); - this.connection.close(); - } - - private onMessage(data: unknown): void { - uwtSafeVoid('broadcastHost.receive', () => { - if (!this.isLeader || !uwtIsBroadcastMessage(data)) { - return; - } - - if ( - data.kind !== 'identity' && - data.kind !== 'identity-request' && - data.kind !== 'log-query-result' - ) { - this._received++; - } - - switch (data.kind) { - case 'event': - this.sink.record( - data.eventType, - this.stampEvent(data.eventType, data.payload), - data.metadata - ); - this.reannounceIfIdentityChanged(); - break; - case 'error': - this.sink.recordError(data.error, data.metadata); - this.reannounceIfIdentityChanged(); - break; - case 'user': - this.sink.setUserId(data.userId); - this.announceIdentity(); - break; - case 'flush': - this.sink.flush(data.beacon); - this.reannounceIfIdentityChanged(); - break; - case 'identity-request': - this.announceIdentity(); - break; - case 'identity': - // Another realm's host on this origin-wide channel — expected, - // not a problem. Same-document duplicates are caught by - // warnIfDuplicateInThisRealm() instead. - break; - case 'log': - this.deliverLog(this.withIdentity(data.record)); - this.reannounceIfIdentityChanged(); - break; - case 'log-flush': - uwtSafeVoidMaybeAsync('broadcastHost.logFlush', () => - this.logApi?.flush?.() - ); - break; - case 'log-query': - this.answerLogQuery(data.id, data.filter); - break; - case 'log-query-result': - // Hosts answer queries; they never ask them. - break; - } - }); - } - - /** - * A forwarded scenario or BI record with this realm's identity; any other - * payload as it is. - */ - private stampEvent(eventType: string, payload: object): object { - const { kind } = uwtClassifyTelemetryEvent(eventType, payload); - return kind === 'unknown' - ? payload - : this.withIdentity(payload as UwtIdentified, kind === 'scenario'); - } - - /** - * `record` with this realm's session and user id. A follower stamps the ids - * it was last told, which lag behind whenever they change here — the client - * starting after the follower asked, a session expiring, a sign-out — so its - * record could name a session other than the one it is sent in. With - * `measuresSession`, a changed session's `sessionElapsed` is measured again. - */ - private withIdentity( - record: T, - measuresSession = false - ): T { - const sessionId = this.currentSessionId(); - // '' is no user, as `setUserId('')` signs out. - const userId = this.currentUserId() || undefined; - if (record.sessionId === sessionId && record.userId === userId) { - return record; - } - // An id this realm doesn't have stays `undefined`, - // as in the library's own records. - const stamped: T = { ...record, sessionId, userId }; - if (measuresSession && record.sessionId !== sessionId) { - // The old session's value no longer applies; absent when unknown. - delete stamped.sessionElapsed; - const elapsed = uwtSessionElapsed(sessionId); - if (elapsed !== undefined) { - stamped.sessionElapsed = Math.round(elapsed); - } - } - return stamped; - } - - /** Hands a follower's record to this realm's log API provider. */ - private deliverLog(record: UwtLogRecord): void { - uwtSafeVoidMaybeAsync('broadcastHost.log', () => this.logApi.send(record)); - } - - /** - * Answers a follower's query from this realm's log API provider. Always - * answers — `[]` when there is no provider or it fails — so the follower - * isn't left waiting for its timeout. - */ - private answerLogQuery(id: string, filter: UwtLogQuery): void { - const reply = (records: unknown) => - this.connection.post({ - kind: 'log-query-result', - id, - records: Array.isArray(records) ? records : [] - }); - - const pending = uwtSafe( - 'broadcastHost.logQuery', - () => this.logApi.query(filter), - undefined - ); - - if (!pending) { - reply([]); - return; - } - // Promise.resolve: a provider breaking its contract with a plain value - // still gets an answer out. - Promise.resolve(pending).then(reply, () => reply([])); - } - - /** - * Whether this realm's own sink or log provider forwards over the - * channel. Hosting then would loop: the host receives its own realm's - * messages and hands them straight back to the channel. - */ - private forwardsItsOwnRealm(): boolean { - const forwards = - this.sink instanceof UwtBroadcastTelemetrySink || - this.logApi instanceof UwtBroadcastLogApiProvider; - - if (forwards) { - uwtSafeVoid('broadcastHost.forwardingRealmWarning', () => { - console.warn( - `[ngx-ui-watchtower] this realm forwards its own telemetry on channel "${this.connection.channelName}", so it cannot also host it; the host stays inactive. Provide the host only in the realm with the real sink and log provider` - ); - }); - } - return forwards; - } - - /** - * Warns when another host is already active on the same channel in this - * realm — a real misconfiguration. Not based on the `identity` broadcast, - * which can't distinguish this from a different, legitimate tab. - */ - private warnIfDuplicateInThisRealm(): void { - const channelName = this.connection.channelName; - const activeCount = hostsInThisRealm.get(channelName) ?? 0; - - if (activeCount > 0) { - uwtSafeVoid('broadcastHost.duplicateWarning', () => { - console.warn( - `[ngx-ui-watchtower] a second telemetry host is active on channel "${channelName}" in this document; only one realm should provide it` - ); - }); - } - - hostsInThisRealm.set(channelName, activeCount + 1); - } - - /** Releases this host's slot in {@link hostsInThisRealm}. */ - private forgetInThisRealm(): void { - const channelName = this.connection.channelName; - const activeCount = hostsInThisRealm.get(channelName) ?? 0; - - if (activeCount <= 1) { - hostsInThisRealm.delete(channelName); - } else { - hostsInThisRealm.set(channelName, activeCount - 1); - } - } - - private announceIdentity(): void { - const sessionId = this.currentSessionId(); - const userId = this.currentUserId(); - this.lastAnnouncedSessionId = sessionId; - this.lastAnnouncedUserId = userId; - this.connection.post({ kind: 'identity', sessionId, userId }); - } - - /** - * Re-announces identity when the sink's session id or user id has moved on - * from what followers were last told. - */ - private reannounceIfIdentityChanged(): void { - if ( - this.currentSessionId() !== this.lastAnnouncedSessionId || - this.currentUserId() !== this.lastAnnouncedUserId - ) { - this.announceIdentity(); - } - } - - private currentSessionId(): string | undefined { - return uwtSafe( - 'broadcastHost.getSessionId', - () => this.sink.getSessionId(), - undefined - ); - } - - private currentUserId(): string | undefined { - return uwtSafe( - 'broadcastHost.getUserId', - () => this.sink.getUserId(), - undefined - ); + this.dispose(); } } diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts index ae9f424..9ba3775 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts @@ -1,16 +1,8 @@ import { inject, Injectable, OnDestroy } from '@angular/core'; -import { - UwtTelemetryError, - UwtTelemetryMetadata, - UwtTelemetrySink -} from '@absaoss-cps/ui-watchtower'; +import { UwtBroadcastTelemetrySink as CoreBroadcastTelemetrySink } from '@absaoss-cps/ui-watchtower'; import { UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; -import { uwtSafeVoid } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -import { - UwtBroadcastConnection, - uwtConnectBroadcastChannel, - uwtIsBroadcastMessage -} from './uwt-broadcast.messages'; +import { uwtDevModeReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { uwtBroadcastConnectionOptions } from './uwt-broadcast.messages'; /** * Telemetry sink for a follower realm — a micro-frontend fragment, a webview @@ -34,99 +26,19 @@ import { */ @Injectable() export class UwtBroadcastTelemetrySink - extends UwtTelemetrySink + extends CoreBroadcastTelemetrySink implements OnDestroy { - private readonly connection: UwtBroadcastConnection = - uwtConnectBroadcastChannel('broadcastSink'); - - /** The host's session id, once it has answered. */ - private sessionId?: string; - private userId?: string; - - private readonly config = inject(UWT_TELEMETRY_IDENTITY); - - /** - * This realm's own identity, stamped onto everything it forwards. - * - * The host records through its own RUM client, so without this, forwarded - * events would all be labelled as the host's realm. - */ - private readonly origin = { - application: this.config.application, - environment: this.config.environment, - appVersion: this.config.version - }; - constructor() { - super(); - - this.connection.onMessage((data) => this.onMessage(data)); - this.connection.post({ kind: 'identity-request' }); - } - - /** @inheritdoc */ - record( - eventType: string, - payload: object, - metadata?: UwtTelemetryMetadata - ): void { - this.connection.post({ - kind: 'event', - eventType, - payload, - metadata: { ...metadata, ...this.origin } - }); - } - - /** @inheritdoc */ - recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata): void { - this.connection.post({ - kind: 'error', - error, - metadata: { ...metadata, ...this.origin } + super({ + identity: inject(UWT_TELEMETRY_IDENTITY), + ...uwtBroadcastConnectionOptions(), + report: uwtDevModeReporter }); } - /** - * The host's session id. - * - * Returns `undefined` until the host answers. - * - * @returns the shared session id, when known - */ - getSessionId(): string | undefined { - return this.sessionId; - } - - /** @inheritdoc */ - setUserId(userId: string | undefined): void { - this.userId = userId; - this.connection.post({ kind: 'user', userId }); - } - - /** @inheritdoc */ - getUserId(): string | undefined { - return this.userId; - } - - /** @inheritdoc */ - flush(beacon = false): void { - this.connection.post({ kind: 'flush', beacon }); - } - /** @inheritdoc */ ngOnDestroy(): void { - this.connection.close(); - } - - private onMessage(data: unknown): void { - uwtSafeVoid('broadcastSink.receive', () => { - if (!uwtIsBroadcastMessage(data) || data.kind !== 'identity') { - return; - } - this.sessionId = data.sessionId; - this.userId = data.userId; - }); + this.dispose(); } } diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts index 5249c4a..8e3f80e 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts @@ -1,23 +1,16 @@ -import { inject, InjectionToken } from '@angular/core'; +import { inject, Injectable, InjectionToken, OnDestroy } from '@angular/core'; import { - UWT_LOG_LEVEL_ORDER, - UwtLogQuery, - UwtLogRecord, - UwtTelemetryError, - UwtTelemetryMetadata + UWT_DEFAULT_BROADCAST_CHANNEL, + UwtBroadcastConnection, + uwtConnectBroadcastChannel as coreConnectBroadcastChannel } from '@absaoss-cps/ui-watchtower'; import { uwtTelemetryEnabled } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { - uwtIsBrowser, - uwtSafeVoid + uwtDevModeReporter, + uwtIsBrowser } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -/** - * Default `BroadcastChannel` name used between telemetry realms. - * - * @group Utils - */ -export const UWT_DEFAULT_BROADCAST_CHANNEL = 'ngx-ui-watchtower'; +export { UWT_DEFAULT_BROADCAST_CHANNEL } from '@absaoss-cps/ui-watchtower'; /** * Overrides the channel name shared by the host and its followers. @@ -32,356 +25,65 @@ export const UWT_BROADCAST_CHANNEL = new InjectionToken( ); /** - * Everything that travels between telemetry realms. - * - * Structured-cloned by `BroadcastChannel`, so every payload must be plain - * data. + * Where this injector's realm connects: the channel name — the + * {@link UWT_BROADCAST_CHANNEL} override, or the default — and whether a + * channel may open at all: only in a browser, while telemetry is on. Must be + * called from an injection context. */ -export type UwtBroadcastMessage = - | { - kind: 'event'; - eventType: string; - payload: object; - metadata?: UwtTelemetryMetadata; - } - | { - kind: 'error'; - error: UwtTelemetryError; - metadata?: UwtTelemetryMetadata; - } - | { kind: 'user'; userId: string | undefined } - | { kind: 'flush'; beacon: boolean } - | { kind: 'identity-request' } - /** The host announcing shared identity. Always sends both fields, even when only one changed. */ - | { kind: 'identity'; sessionId?: string; userId?: string } - /** A follower's log record, for the host to hand to its log API provider. */ - | { kind: 'log'; record: UwtLogRecord } - /** A follower asking the host to flush its log API provider. */ - | { kind: 'log-flush' } - /** A follower reading records back through the host's log API provider. */ - | { kind: 'log-query'; id: string; filter: UwtLogQuery } - /** - * The host's answer to one `log-query`, matched by `id`. The records come - * from the host's backend unchecked; the asking follower keeps only those - * that pass {@link uwtIsBroadcastLogRecord}. - */ - | { kind: 'log-query-result'; id: string; records: unknown[] }; - -/** Every `kind` the union above accepts, for {@link uwtIsBroadcastMessage}. */ -const MESSAGE_KINDS: ReadonlySet = new Set([ - 'event', - 'error', - 'user', - 'flush', - 'identity-request', - 'identity', - 'log', - 'log-flush', - 'log-query', - 'log-query-result' -] as const); - -const LOG_LEVELS: ReadonlySet = new Set( - Object.keys(UWT_LOG_LEVEL_ORDER) -); - -/** Minimal `BroadcastChannel` surface this library relies on. */ -export interface UwtBroadcastChannelLike { - postMessage(message: unknown): void; - close(): void; - onmessage: ((event: { data: unknown }) => void) | null; -} - -/** - * Opens a broadcast channel, when the browser provides the API. - * - * Feature-detected: unavailable under jsdom (tests) and in server-side - * rendering. - */ -export function uwtOpenBroadcastChannel( - name: string -): UwtBroadcastChannelLike | undefined { - const Channel = ( - globalThis as { - BroadcastChannel?: new (name: string) => UwtBroadcastChannelLike; - } - ).BroadcastChannel; - - if (typeof Channel !== 'function') { - return undefined; - } - - try { - return new Channel(name); - } catch { - return undefined; - } -} - -/** Minimal Web Locks API surface this library relies on. */ -interface UwtLockManagerLike { - request(name: string, callback: () => Promise): Promise; -} - -/** - * Elects exactly one leader among same-origin realms on this channel, via - * the Web Locks API — prevents two shell tabs from both recording every - * forwarded message. Fails open (elects immediately) when Web Locks is - * unavailable. - * - * @returns releases the lock so the next queued realm can become leader. - */ -export function uwtElectBroadcastHostLeader( - channelName: string, - onElected: () => void -): () => void { - const locks = (globalThis as { navigator?: { locks?: UwtLockManagerLike } }) - .navigator?.locks; - - if (!locks) { - onElected(); - return () => undefined; - } - - let released = false; - let release: () => void = () => { - released = true; - }; - - const failOpen = () => { - if (!released) { - onElected(); - } +export function uwtBroadcastChannelOptions(): { + channelName: string; + enabled: boolean; +} { + return { + channelName: + inject(UWT_BROADCAST_CHANNEL, { optional: true }) ?? + UWT_DEFAULT_BROADCAST_CHANNEL, + enabled: uwtIsBrowser() && uwtTelemetryEnabled() }; - - try { - locks - .request( - `ngx-ui-watchtower-host:${channelName}`, - () => - new Promise((resolve) => { - if (released) { - resolve(); - return; - } - release = () => { - released = true; - resolve(); - }; - onElected(); - }) - ) - .catch(failOpen); - } catch { - failOpen(); - } - - return () => release(); -} - -/** A realm's live connection to the shared broadcast channel. */ -export interface UwtBroadcastConnection { - /** The channel name actually in use — the injected override, or the default. */ - readonly channelName: string; - - /** Whether a channel is open — false without `BroadcastChannel`, on the server, and once closed. */ - isOpen(): boolean; - - /** Sends a message to every other realm on this channel. No-op if unavailable. */ - post(message: UwtBroadcastMessage): void; - - /** Registers the handler for incoming messages. No-op if the channel could not be opened. */ - onMessage(handler: (data: unknown) => void): void; - - /** Closes the channel and stops any further delivery. Safe to call more than once. */ - close(): void; } /** - * Opens this realm's connection to the shared broadcast channel: resolves the - * channel name, feature-detects `BroadcastChannel`, and wraps every operation - * in the library's fail-open guard. - * - * Must be called from an injection context (a field initializer or a - * constructor). + * Opens this realm's connection to the shared broadcast channel, configured + * from this injector, reporting through the dev-mode reporter. Must be called + * from an injection context (a field initializer or a constructor). */ export function uwtConnectBroadcastChannel( operation: string ): UwtBroadcastConnection { - const channelName = - inject(UWT_BROADCAST_CHANNEL, { optional: true }) ?? - UWT_DEFAULT_BROADCAST_CHANNEL; - // No channel on the server or while telemetry is off: every call is a no-op. - let channel = - uwtIsBrowser() && uwtTelemetryEnabled() - ? uwtOpenBroadcastChannel(channelName) - : undefined; - - return { + const { channelName, enabled } = uwtBroadcastChannelOptions(); + return coreConnectBroadcastChannel({ channelName, - isOpen() { - return channel !== undefined; - }, - post(message) { - uwtSafeVoid(`${operation}.post`, () => channel?.postMessage(message)); - }, - onMessage(handler) { - if (channel) { - channel.onmessage = (event) => handler(event.data); - } - }, - close() { - uwtSafeVoid(`${operation}.close`, () => { - channel?.close(); - channel = undefined; - }); - } - }; -} - -/** - * Whether `value` is a valid `metadata` field: absent, or a non-array - * object whose values are all finite numbers, strings, booleans, or null — - * the same flat-primitives contract `uwtRedactMetadata` enforces on the way - * out. Without this, a same-origin sender (not necessarily this library's - * own sink) could post a nested object on the channel that reaches a - * receiving sink's own sanitizer — e.g. `UwtRumTelemetrySink.sanitize()` — - * unchecked, since that step trusts the shape rather than re-validating it. - */ -function isValidBroadcastMetadata(value: unknown): boolean { - if (value === undefined) { - return true; - } - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return false; - } - return Object.values(value).every( - (v) => - v === null || - typeof v === 'string' || - typeof v === 'boolean' || - (typeof v === 'number' && Number.isFinite(v)) - ); -} - -function isOptionalString(value: unknown): boolean { - return value === undefined || typeof value === 'string'; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isValidTelemetryError(value: unknown): boolean { - return ( - isRecord(value) && - typeof value.name === 'string' && - typeof value.message === 'string' && - isOptionalString(value.stack) - ); -} - -function isLogLevel(value: unknown): boolean { - return typeof value === 'string' && LOG_LEVELS.has(value); + open: enabled, + operation, + report: uwtDevModeReporter + }); } /** - * Whether `value` has the shape of a {@link UwtLogRecord}: the required - * strings, a known level, optional strings where the record has them, and - * metadata and error held to the same rules as on an `event` or `error` - * message. + * The one connection a realm's broadcast sink and log provider share: + * provided with them by `provideUwtTelemetrySink('broadcast')`, and closed + * with their injector. Internal. */ -export function uwtIsBroadcastLogRecord(value: unknown): value is UwtLogRecord { - return ( - isRecord(value) && - typeof value.timestamp === 'string' && - isLogLevel(value.level) && - typeof value.message === 'string' && - typeof value.application === 'string' && - typeof value.environment === 'string' && - typeof value.version === 'string' && - isOptionalString(value.logger) && - isOptionalString(value.context) && - isOptionalString(value.correlationId) && - isOptionalString(value.userId) && - isOptionalString(value.sessionId) && - isValidBroadcastMetadata(value.metadata) && - (value.error === undefined || isValidTelemetryError(value.error)) - ); -} +@Injectable() +export class UwtBroadcastConnectionRef implements OnDestroy { + readonly connection = uwtConnectBroadcastChannel('broadcast'); -function isValidLogQuery(value: unknown): boolean { - return ( - isRecord(value) && - isOptionalString(value.correlationId) && - isOptionalString(value.logger) && - (value.minLevel === undefined || isLogLevel(value.minLevel)) && - isOptionalString(value.from) && - isOptionalString(value.to) && - (value.limit === undefined || - (typeof value.limit === 'number' && Number.isFinite(value.limit))) - ); + /** @inheritdoc */ + ngOnDestroy(): void { + this.connection.close(); + } } /** - * Narrows an incoming `BroadcastChannel` payload to a telemetry message. - * - * Checks each kind's required fields, not just `kind`, so a same-named - * message from something else on the channel is rejected. + * How a broadcast sink or log provider connects: through its injector's + * shared connection when there is one, on its own otherwise. Must be called + * from an injection context. */ -export function uwtIsBroadcastMessage( - data: unknown -): data is UwtBroadcastMessage { - if (typeof data !== 'object' || data === null) { - return false; - } - - const message = data as Record; - const kind = message.kind as UwtBroadcastMessage['kind']; - if (!MESSAGE_KINDS.has(kind)) { - return false; - } - - switch (kind) { - case 'event': - return ( - typeof message.eventType === 'string' && - typeof message.payload === 'object' && - message.payload !== null && - isValidBroadcastMetadata(message.metadata) - ); - case 'error': - return ( - isValidTelemetryError(message.error) && - isValidBroadcastMetadata(message.metadata) - ); - case 'flush': - return typeof message.beacon === 'boolean'; - case 'user': - // Absent or `undefined` userId both mean "clear it". - return message.userId === undefined || typeof message.userId === 'string'; - case 'identity': - return ( - (message.sessionId === undefined || - typeof message.sessionId === 'string') && - (message.userId === undefined || typeof message.userId === 'string') - ); - case 'identity-request': - case 'log-flush': - return true; - case 'log': - return uwtIsBroadcastLogRecord(message.record); - case 'log-query': - return ( - typeof message.id === 'string' && - message.id !== '' && - isValidLogQuery(message.filter) - ); - case 'log-query-result': - return ( - typeof message.id === 'string' && - message.id !== '' && - Array.isArray(message.records) - ); - } +export function uwtBroadcastConnectionOptions(): + | { connection: UwtBroadcastConnection } + | { channelName: string; enabled: boolean } { + const shared = inject(UwtBroadcastConnectionRef, { optional: true }); + return shared + ? { connection: shared.connection } + : uwtBroadcastChannelOptions(); } diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts index 6356206..ea28aba 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts @@ -31,9 +31,7 @@ import { import { UWT_BROADCAST_CHANNEL, UWT_DEFAULT_BROADCAST_CHANNEL, - uwtConnectBroadcastChannel, - uwtElectBroadcastHostLeader, - uwtIsBroadcastMessage + uwtConnectBroadcastChannel } from './uwt-broadcast.messages'; /** @@ -1087,327 +1085,6 @@ describe('broadcast telemetry across realms', () => { }); }); - describe('uwtElectBroadcastHostLeader', () => { - afterEach(() => LockManagerStub.uninstall()); - - it('should elect immediately when the Locks API is unavailable', () => { - const onElected = jest.fn(); - uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); - - expect(onElected).toHaveBeenCalledTimes(1); - }); - - it('should elect once the lock is granted', () => { - LockManagerStub.install(); - const onElected = jest.fn(); - uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); - - expect(onElected).toHaveBeenCalledTimes(1); - }); - - it('should fail open and still elect when request() itself rejects', async () => { - Object.defineProperty(globalThis.navigator, 'locks', { - value: { request: () => Promise.reject(new Error('not-fully-active')) }, - configurable: true - }); - const onElected = jest.fn(); - - uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); - // Let the rejection's microtask settle. - await Promise.resolve(); - await Promise.resolve(); - - expect(onElected).toHaveBeenCalledTimes(1); - }); - - it('should fail open and still elect when request() itself throws synchronously', () => { - Object.defineProperty(globalThis.navigator, 'locks', { - value: { - request: () => { - throw new Error('locks unavailable in this context'); - } - }, - configurable: true - }); - const onElected = jest.fn(); - - expect(() => - uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected) - ).not.toThrow(); - expect(onElected).toHaveBeenCalledTimes(1); - }); - - it('should not elect a requester released while still queued, and should let the next requester through', async () => { - LockManagerStub.install(); - const onElectedHolder = jest.fn(); - const onElectedA = jest.fn(); - const onElectedB = jest.fn(); - - const releaseHolder = uwtElectBroadcastHostLeader( - 'ngx-ui-watchtower', - onElectedHolder - ); - const releaseA = uwtElectBroadcastHostLeader( - 'ngx-ui-watchtower', - onElectedA - ); - uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElectedB); - - // A is released before ever being granted the lock. - releaseA(); - // The current holder releases, so the lock passes down the queue. - releaseHolder(); - for (let i = 0; i < 6; i++) { - await Promise.resolve(); - } - - expect(onElectedA).not.toHaveBeenCalled(); - expect(onElectedB).toHaveBeenCalledTimes(1); - }); - }); - - describe('uwtIsBroadcastMessage', () => { - it.each([null, undefined, 'string', 42, ['array']])( - 'should reject the non-object payload %p', - (data) => { - expect(uwtIsBroadcastMessage(data)).toBe(false); - } - ); - - it('should reject a payload with an unknown kind', () => { - expect(uwtIsBroadcastMessage({ kind: 'other library' })).toBe(false); - }); - - it('should accept a well-formed event message', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'event', - eventType: 'com.uwt.bi', - payload: { eventName: 'x' } - }) - ).toBe(true); - }); - - it.each([ - { eventType: 'com.uwt.bi' }, - { payload: {} }, - { eventType: 1, payload: {} }, - { eventType: 'com.uwt.bi', payload: 'not-an-object' }, - { - eventType: 'com.uwt.bi', - payload: {}, - metadata: { nested: {} } - }, - { - eventType: 'com.uwt.bi', - payload: {}, - metadata: { list: [1, 2] } - }, - { - eventType: 'com.uwt.bi', - payload: {}, - metadata: 'not-an-object' - } - ])('should reject a malformed event message %p', (fields) => { - expect(uwtIsBroadcastMessage({ kind: 'event', ...fields })).toBe(false); - }); - - it('should accept an event message with flat-primitive metadata', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'event', - eventType: 'com.uwt.bi', - payload: {}, - metadata: { count: 3, ok: true, note: 'x', blank: null } - }) - ).toBe(true); - }); - - it('should accept an event message with no metadata', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'event', - eventType: 'com.uwt.bi', - payload: {} - }) - ).toBe(true); - }); - - it('should accept a well-formed error message', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'error', - error: { name: 'Error', message: 'boom' } - }) - ).toBe(true); - }); - - it.each([ - {}, - { error: { name: 'Error' } }, - { error: { message: 'boom' } }, - { error: 'boom' }, - { error: { name: 'Error', message: 'boom', stack: 42 } }, - { - error: { name: 'Error', message: 'boom' }, - metadata: { nested: {} } - } - ])('should reject a malformed error message %p', (fields) => { - expect(uwtIsBroadcastMessage({ kind: 'error', ...fields })).toBe(false); - }); - - it('should accept an error message with flat-primitive metadata', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'error', - error: { name: 'Error', message: 'boom' }, - metadata: { origin: 'checkout' } - }) - ).toBe(true); - }); - - it('should accept an error message with a string stack', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'error', - error: { name: 'Error', message: 'boom', stack: 'at foo.ts:1' } - }) - ).toBe(true); - }); - - it('should accept a well-formed flush message', () => { - expect(uwtIsBroadcastMessage({ kind: 'flush', beacon: true })).toBe(true); - }); - - it('should reject a flush message with a non-boolean beacon', () => { - expect(uwtIsBroadcastMessage({ kind: 'flush', beacon: 'true' })).toBe( - false - ); - }); - - it.each([{ userId: 'u-1' }, { userId: undefined }, {}])( - 'should accept a well-formed user message %p', - (fields) => { - expect(uwtIsBroadcastMessage({ kind: 'user', ...fields })).toBe(true); - } - ); - - it('should reject a user message with a non-string, non-undefined userId', () => { - expect(uwtIsBroadcastMessage({ kind: 'user', userId: 42 })).toBe(false); - }); - - const logRecord = { - timestamp: '2026-01-01T00:00:00.000Z', - level: 'warn', - message: 'Careful', - application: 'cart', - environment: 'test', - version: '1.0.0' - }; - - it('should accept a well-formed log message', () => { - expect(uwtIsBroadcastMessage({ kind: 'log', record: logRecord })).toBe( - true - ); - expect( - uwtIsBroadcastMessage({ - kind: 'log', - record: { - ...logRecord, - logger: 'checkout', - context: 'Cart', - correlationId: 'c-1', - sessionId: 's', - userId: 'u', - metadata: { count: 1 }, - error: { name: 'Error', message: 'boom' } - } - }) - ).toBe(true); - }); - - it.each([ - { level: 'debug' }, - { level: 'toString' }, - { application: undefined }, - { message: 42 }, - { logger: 7 }, - { metadata: { nested: {} } }, - { error: { name: 'Error' } } - ])('should reject a log message whose record has %p', (fields) => { - expect( - uwtIsBroadcastMessage({ - kind: 'log', - record: { ...logRecord, ...fields } - }) - ).toBe(false); - }); - - it('should accept log-flush, log-query and log-query-result messages', () => { - expect(uwtIsBroadcastMessage({ kind: 'log-flush' })).toBe(true); - expect( - uwtIsBroadcastMessage({ - kind: 'log-query', - id: 'q-1', - filter: { correlationId: 'c-1', minLevel: 'warn', limit: 5 } - }) - ).toBe(true); - expect( - uwtIsBroadcastMessage({ - kind: 'log-query-result', - id: 'q-1', - records: [] - }) - ).toBe(true); - }); - - it.each([ - { id: '', filter: {} }, - { id: 'q-1', filter: null }, - { id: 'q-1', filter: { limit: 'ten' } }, - { id: 'q-1', filter: { minLevel: 'verbose' } } - ])('should reject a malformed log-query message %p', (fields) => { - expect(uwtIsBroadcastMessage({ kind: 'log-query', ...fields })).toBe( - false - ); - }); - - it('should reject a log-query-result without a records array', () => { - expect( - uwtIsBroadcastMessage({ - kind: 'log-query-result', - id: 'q-1', - records: 'none' - }) - ).toBe(false); - }); - - it('should accept an identity-request message with no other fields', () => { - expect(uwtIsBroadcastMessage({ kind: 'identity-request' })).toBe(true); - }); - - it.each([ - {}, - { sessionId: 's-1' }, - { userId: 'u-1' }, - { - sessionId: 's-1', - userId: 'u-1' - } - ])('should accept an identity message %p', (fields) => { - expect(uwtIsBroadcastMessage({ kind: 'identity', ...fields })).toBe(true); - }); - - it.each([{ sessionId: 42 }, { userId: 42 }, { sessionId: 42, userId: 42 }])( - 'should reject an identity message with a non-string, non-undefined field %p', - (fields) => { - expect(uwtIsBroadcastMessage({ kind: 'identity', ...fields })).toBe( - false - ); - } - ); - }); - describe('uwtConnectBroadcastChannel', () => { it('should default the channel name to UWT_DEFAULT_BROADCAST_CHANNEL', () => { const connection = TestBed.runInInjectionContext(() => diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts index da002fe..2eb2305 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts @@ -1,12 +1,27 @@ import { DOCUMENT } from '@angular/common'; -import { Injectable, Injector, PLATFORM_ID } from '@angular/core'; +import { + createEnvironmentInjector, + EnvironmentInjector, + Injectable, + Injector, + PLATFORM_ID +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import { + UwtBroadcastLogApiProvider as CoreBroadcastLogApiProvider, + UwtBroadcastTelemetrySink as CoreBroadcastTelemetrySink, + uwtConnectBroadcastChannel, + uwtCreateTelemetry, UWT_DEFAULT_TELEMETRY_CONFIG, UwtLogApiProvider, + UwtLogger, UwtLogQuery, UwtLogRecord, + UwtScenario, + UwtScenarioOptions, + UwtTelemetryBroadcastHost as CoreTelemetryBroadcastHost, UwtTelemetryError, UwtTelemetryMetadata, UwtTelemetrySink @@ -20,6 +35,10 @@ import { UWT_LOG_CONFIG } from '../../config/uwt-log.config/uwt-log.config'; import { UWT_SCENARIO_TELEMETRY_CONFIG } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { UwtBroadcastLogApiProvider } from '../../providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; +import { + provideUwtTelemetry, + provideUwtTelemetrySink +} from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; import { UwtBITelemetryService } from '../../services/uwt-bi-telemetry.service/uwt-bi-telemetry.service'; import { UwtLoggerService } from '../../services/uwt-logger.service/uwt-logger.service'; import { UwtScenarioTelemetryService } from '../../services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; @@ -28,14 +47,22 @@ import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; /** * The broadcast protocol is a contract between realms deployed separately: - * a shell and its fragments may run different versions. This records every - * message a shell and a fragment exchange through one full session, and - * checks it against the fixture recorded from the implementation before the - * core was extracted. Record it again only deliberately, with - * `UPDATE_WIRE_FIXTURE=1`. + * a shell and its fragments may run different versions, and may be built + * with or without Angular. This records every message a shell and a fragment + * exchange through one full session, and checks it against the fixture + * recorded from the implementation before the core was extracted — for an + * Angular or a core-only realm on either side. Record it again only + * deliberately, with `UPDATE_WIRE_FIXTURE=1`. */ const FIXTURE = join(__dirname, 'uwt-broadcast.wire.fixture.json'); +const IDENTITY = { + application: 'realm', + environment: 'test', + version: '1.0.0', + eventNamespace: 'com.uwt' +}; + /** A message as posted: by which realm, and what it carried. */ interface Posted { from: string; @@ -68,6 +95,11 @@ class WireChannel { WireChannel.channels.clear(); } + /** The realms with a channel still open, one entry per channel. */ + static open(): string[] { + return [...WireChannel.channels.values()].flat().map((c) => c.realm); + } + postMessage(message: unknown): void { if (this.closed) { return; @@ -154,11 +186,12 @@ function stable(posted: Posted[]): Posted[] { /** The shell's real destination, with a session and user it controls. */ @Injectable() class ShellSink extends UwtTelemetrySink { + readonly events: string[] = []; sessionId: string | undefined = 'shell-session'; userId?: string; - record(_eventType: string, _payload: object, _meta?: UwtTelemetryMetadata) { - // Recorded on the wire, not here. + record(eventType: string, _payload: object, _meta?: UwtTelemetryMetadata) { + this.events.push(eventType); } recordError(_error: UwtTelemetryError, _meta?: UwtTelemetryMetadata) { @@ -204,21 +237,29 @@ class ShellLogApi implements UwtLogApiProvider { } } -/** A realm: its own injector, as the shell and each fragment have. */ +/** A shell: its real destination, and how it goes away. */ +interface Shell { + sink: ShellSink; + destroy(): void; +} + +/** A fragment, whatever it is built with: what its application code calls. */ +interface Fragment { + start(options: UwtScenarioOptions): UwtScenario; + track(eventName: string, metadata?: UwtTelemetryMetadata): void; + getLogger(name: string): UwtLogger; + sink: UwtTelemetrySink; + logApi: UwtLogApiProvider; + destroy(): void; +} + +/** An Angular realm: its own injector, as the shell and each fragment have. */ function createRealm(providers: unknown[]): Injector { return Injector.create({ providers: [ { provide: PLATFORM_ID, useValue: 'browser' }, { provide: DOCUMENT, useValue: document }, - { - provide: UWT_TELEMETRY_IDENTITY, - useValue: { - application: 'realm', - environment: 'test', - version: '1.0.0', - eventNamespace: 'com.uwt' - } - }, + { provide: UWT_TELEMETRY_IDENTITY, useValue: IDENTITY }, { provide: UWT_SCENARIO_TELEMETRY_CONFIG, useValue: UWT_DEFAULT_TELEMETRY_CONFIG.scenario @@ -240,6 +281,99 @@ function createRealm(providers: unknown[]): Injector { }); } +/** What application code calls in an Angular fragment. */ +function angularFragmentOf(injector: Injector, destroy: () => void): Fragment { + const scenarios = injector.get(UwtScenarioTelemetryService); + const bi = injector.get(UwtBITelemetryService); + const logs = injector.get(UwtLoggerService); + return { + start: (options) => scenarios.start(options), + track: (eventName, metadata) => bi.track(eventName, metadata), + getLogger: (name) => logs.getLogger(name), + sink: injector.get(UwtTelemetrySink), + logApi: injector.get(UWT_LOG_API_PROVIDER), + destroy + }; +} + +const shells: Record Shell> = { + 'an Angular shell': () => { + const shell = createRealm([ + ShellSink, + { provide: UwtTelemetrySink, useExisting: ShellSink }, + ShellLogApi, + { provide: UWT_LOG_API_PROVIDER, useExisting: ShellLogApi }, + UwtTelemetryBroadcastHost + ]); + const host = shell.get(UwtTelemetryBroadcastHost); + return { sink: shell.get(ShellSink), destroy: () => host.ngOnDestroy() }; + }, + 'a core shell': () => { + const sink = new ShellSink(); + const host = new CoreTelemetryBroadcastHost({ + sink, + logApi: new ShellLogApi() + }); + return { sink, destroy: () => host.dispose() }; + } +}; + +const fragments: Record Fragment> = { + // Each class opens its own channel, as before the extraction. + 'an Angular fragment': () => { + const realm = createRealm([ + UwtBroadcastTelemetrySink, + { provide: UwtTelemetrySink, useExisting: UwtBroadcastTelemetrySink }, + UwtBroadcastLogApiProvider, + { provide: UWT_LOG_API_PROVIDER, useExisting: UwtBroadcastLogApiProvider } + ]); + return angularFragmentOf(realm, () => { + realm.get(UwtBroadcastTelemetrySink).ngOnDestroy(); + realm.get(UwtBroadcastLogApiProvider).ngOnDestroy(); + }); + }, + // Wired by provideUwtTelemetrySink: one channel, shared. + 'an Angular fragment sharing one channel': () => { + const injector = createEnvironmentInjector( + [ + provideUwtTelemetry(IDENTITY), + provideUwtTelemetrySink('broadcast'), + UwtScenarioTelemetryService, + UwtBITelemetryService, + UwtLoggerService + ], + TestBed.inject(EnvironmentInjector) + ); + return angularFragmentOf(injector, () => injector.destroy()); + }, + 'a core-only fragment': () => { + const connection = uwtConnectBroadcastChannel(); + const sink = new CoreBroadcastTelemetrySink({ + identity: IDENTITY, + connection + }); + const logApi = new CoreBroadcastLogApiProvider({ connection }); + const telemetry = uwtCreateTelemetry({ + identity: IDENTITY, + destination: sink, + logApi + }); + return { + start: (options) => telemetry.scenarios.start(options), + track: (eventName, metadata) => telemetry.bi.track(eventName, metadata), + getLogger: (name) => telemetry.logging.getLogger(name), + sink, + logApi, + destroy: () => { + telemetry.dispose(); + sink.dispose(); + logApi.dispose(); + connection.close(); + } + }; + } +}; + describe('the broadcast wire protocol', () => { beforeEach(() => { localStorage.clear(); @@ -262,64 +396,99 @@ describe('the broadcast wire protocol', () => { } } - it('should carry what a shell and a fragment exchange, unchanged', async () => { + /** A shell and a fragment, each in its own realm. */ + async function connect(shellKind: string, fragmentKind: string) { WireChannel.realm = 'shell'; - const shell = createRealm([ - ShellSink, - { provide: UwtTelemetrySink, useExisting: ShellSink }, - ShellLogApi, - { provide: UWT_LOG_API_PROVIDER, useExisting: ShellLogApi }, - UwtTelemetryBroadcastHost - ]); - const shellSink = shell.get(ShellSink); - const host = shell.get(UwtTelemetryBroadcastHost); - + const shell = shells[shellKind](); WireChannel.realm = 'fragment'; - const fragment = createRealm([ - UwtBroadcastTelemetrySink, - { provide: UwtTelemetrySink, useExisting: UwtBroadcastTelemetrySink }, - UwtBroadcastLogApiProvider, - { provide: UWT_LOG_API_PROVIDER, useExisting: UwtBroadcastLogApiProvider } - ]); - const sink = fragment.get(UwtTelemetrySink); - const logApi = fragment.get(UWT_LOG_API_PROVIDER); - await settle(); - - // A scenario with a step, a business event, logs and a mirrored error. - const scenario = fragment - .get(UwtScenarioTelemetryService) - .start({ name: 'checkout', metadata: { cart: 'c-1' } }); - scenario.step('pay', { method: 'card' }); - scenario.endStep(); - scenario.complete({ metadata: { items: 2 } }); - fragment - .get(UwtBITelemetryService) - .track('export_clicked', { format: 'csv' }); - const logger = fragment.get(UwtLoggerService).getLogger('checkout'); - logger.warn('Slow payment', { metadata: { ms: 900 } }); - logger.error('Payment failed', { error: new Error('Declined') }); - await settle(); - - // A sign-in, flushes, and a query answered by the shell's backend. - sink.setUserId('user-1'); + const fragment = fragments[fragmentKind](); await settle(); - sink.flush(true); - logApi.flush?.(); - const found = logApi.query({ logger: 'checkout', limit: 1 }); - await settle(); - expect(await found).toHaveLength(1); + return { shell, fragment }; + } - // The shell's session moves on: the next record is stamped with it. - shellSink.sessionId = 'shell-session-2'; - fragment.get(UwtBITelemetryService).track('theme_changed'); - await settle(); + const pairs = [ + ['an Angular shell', 'an Angular fragment'], + ['an Angular shell', 'an Angular fragment sharing one channel'], + ['an Angular shell', 'a core-only fragment'], + ['a core shell', 'an Angular fragment'], + ['a core shell', 'an Angular fragment sharing one channel'], + ['a core shell', 'a core-only fragment'] + ]; + + it.each(pairs)( + 'should carry what %s and %s exchange, unchanged', + async (shellKind, fragmentKind) => { + const { shell, fragment } = await connect(shellKind, fragmentKind); + + // A scenario with a step, a business event, logs and a mirrored error. + const scenario = fragment.start({ + name: 'checkout', + metadata: { cart: 'c-1' } + }); + scenario.step('pay', { method: 'card' }); + scenario.endStep(); + scenario.complete({ metadata: { items: 2 } }); + fragment.track('export_clicked', { format: 'csv' }); + const logger = fragment.getLogger('checkout'); + logger.warn('Slow payment', { metadata: { ms: 900 } }); + logger.error('Payment failed', { error: new Error('Declined') }); + await settle(); + + // A sign-in, flushes, and a query answered by the shell's backend. + fragment.sink.setUserId('user-1'); + await settle(); + fragment.sink.flush(true); + fragment.logApi.flush?.(); + const found = fragment.logApi.query({ logger: 'checkout', limit: 1 }); + await settle(); + expect(await found).toHaveLength(1); + + // The shell's session moves on: the next record is stamped with it. + shell.sink.sessionId = 'shell-session-2'; + fragment.track('theme_changed'); + await settle(); + + shell.destroy(); + // The session, and the host going away, as the fixture recorded them. + const recorded = stable(WireChannel.posted); + fragment.destroy(); + if ( + process.env.UPDATE_WIRE_FIXTURE === '1' && + shellKind === pairs[0][0] && + fragmentKind === pairs[0][1] + ) { + writeFileSync(FIXTURE, JSON.stringify(recorded, null, 2) + '\n'); + } + expect(existsSync(FIXTURE)).toBe(true); + expect(recorded).toEqual(JSON.parse(readFileSync(FIXTURE, 'utf8'))); + expect(WireChannel.open()).toEqual([]); + } + ); + + it.each(Object.keys(fragments))( + 'should send nothing twice, and leave no channel open, when %s is remounted', + async (fragmentKind) => { + const { shell, fragment } = await connect( + 'an Angular shell', + fragmentKind + ); - host.ngOnDestroy(); - const recorded = stable(WireChannel.posted); - if (process.env.UPDATE_WIRE_FIXTURE === '1') { - writeFileSync(FIXTURE, JSON.stringify(recorded, null, 2) + '\n'); + fragment.track('export_clicked'); + const pending = fragment.logApi.query({}); + fragment.destroy(); + expect(await pending).toEqual([]); + expect(WireChannel.open()).toEqual(['shell']); + + WireChannel.realm = 'fragment'; + const remounted = fragments[fragmentKind](); + await settle(); + remounted.track('theme_changed'); + await settle(); + + expect(shell.sink.events).toEqual(['com.uwt.bi', 'com.uwt.bi']); + remounted.destroy(); + shell.destroy(); + expect(WireChannel.open()).toEqual([]); } - expect(existsSync(FIXTURE)).toBe(true); - expect(recorded).toEqual(JSON.parse(readFileSync(FIXTURE, 'utf8'))); - }); + ); }); 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 103f03e..62ed090 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 @@ -43,6 +43,16 @@ export function uwtRunOutsideZone(): (callback: () => T) => T { return (callback) => zone?.runOutsideAngular(callback) ?? callback(); } +/** + * Runs a callback inside Angular's zone when there is one, so what it + * resolves is seen by change detection. Must be called from an injection + * context. + */ +export function uwtRunInsideZone(): (callback: () => T) => T { + const zone = inject(NgZone, { optional: true }); + return (callback) => (zone ? zone.run(callback) : callback()); +} + /** * How this package reports what telemetry handles silently: to the console, * prefixed, and only in development mode. Everything the core does on this @@ -54,6 +64,19 @@ export const uwtDevModeReporter: UwtReporter = (level, 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') { + console.warn(`[ngx-ui-watchtower] ${message}`, ...details); + } else { + uwtDevModeReporter(level, message, ...details); + } +}; + /** * Runs a telemetry operation without letting it affect the application: * the core's fail-open wrapper, reporting through {@link uwtDevModeReporter}. diff --git a/projects/ngx-ui-watchtower/src/public-api.ts b/projects/ngx-ui-watchtower/src/public-api.ts index 9bac8c6..e6dec4d 100644 --- a/projects/ngx-ui-watchtower/src/public-api.ts +++ b/projects/ngx-ui-watchtower/src/public-api.ts @@ -99,10 +99,8 @@ export { UwtScenarioTelemetryService } from './lib/services/uwt-scenario-telemet // Sinks export { UwtTelemetryBroadcastHost } from './lib/sinks/uwt-broadcast/uwt-broadcast-host.service'; export { UwtBroadcastTelemetrySink } from './lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink'; -export { - UWT_BROADCAST_CHANNEL, - UWT_DEFAULT_BROADCAST_CHANNEL -} from './lib/sinks/uwt-broadcast/uwt-broadcast.messages'; +export { UWT_BROADCAST_CHANNEL } from './lib/sinks/uwt-broadcast/uwt-broadcast.messages'; +export { UWT_DEFAULT_BROADCAST_CHANNEL } from '@absaoss-cps/ui-watchtower'; export { UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; export { UwtNoopTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md index 24a61f0..7f33136 100644 --- a/projects/ui-watchtower/README.md +++ b/projects/ui-watchtower/README.md @@ -8,8 +8,8 @@ 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 broadcast and AWS RUM destinations are still -> moving here from the Angular package. +> **Work in progress.** The AWS RUM destination is still moving here from the +> Angular package. ## Install @@ -71,6 +71,32 @@ 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. +## Shell and fragments + +In a page composed of fragments, each in its own JavaScript realm, the shell +sends everything and the fragments forward to it over a `BroadcastChannel`, +so a user is one session, not one per fragment: + +```ts +// shell: records what fragments forward, through its own destination and +// log store +const host = new UwtTelemetryBroadcastHost({ sink: destination, logApi }); + +// each fragment: forwards everything, over one channel +const identity = { application: 'cart', environment: 'prod', version: '1.4.0' }; +const connection = uwtConnectBroadcastChannel(); +const telemetry = uwtCreateTelemetry({ + identity, + destination: new UwtBroadcastTelemetrySink({ identity, connection }), + logApi: new UwtBroadcastLogApiProvider({ connection }) +}); +``` + +Either side can be an Angular application using +`@absaoss-cps/ngx-ui-watchtower`; they exchange the same messages. Dispose +the host, the sink, the log provider and the connection when their realm goes +away. + ## Declare your names Scenario, step, event and logger names are typed, so a typo is a compile @@ -125,18 +151,19 @@ 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` | -| 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`, `uwtUuid`, `uwtSafe*`, `UwtReporter` | +| `/rxjs` | `traceScenario`, `UwtTraceScenarioOptions` | ## License diff --git a/projects/ui-watchtower/public-api.snapshot.json b/projects/ui-watchtower/public-api.snapshot.json index b38bf28..1c0bbed 100644 --- a/projects/ui-watchtower/public-api.snapshot.json +++ b/projects/ui-watchtower/public-api.snapshot.json @@ -1,5 +1,11 @@ { "@absaoss-cps/ui-watchtower": { + "UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS": [ + "const: 10000" + ], + "UWT_DEFAULT_BROADCAST_CHANNEL": [ + "const: 'ngx-ui-watchtower'" + ], "UWT_DEFAULT_EVENT_NAMESPACE": [ "const: 'com.uwt'" ], @@ -61,9 +67,66 @@ "report?: UwtReporter | undefined", "sink: UwtTelemetrySink" ], + "UwtBroadcastConnection": [ + "interface", + "close(): void", + "isOpen(): boolean", + "onMessage(handler: (data: unknown) => void): () => void", + "post(message: UwtBroadcastMessage): void", + "readonly channelName: string" + ], + "UwtBroadcastConnectOptions": [ + "interface", + "channelName?: string | undefined", + "open?: boolean | undefined", + "operation?: string | undefined", + "report?: UwtReporter | undefined" + ], + "UwtBroadcastLogApiProvider": [ + "class", + "new (options?: UwtBroadcastLogApiProviderOptions | undefined): UwtBroadcastLogApiProvider", + "dispose(): void", + "flush(): void", + "query(filter: UwtLogQuery): Promise", + "send(record: UwtLogRecord): void" + ], + "UwtBroadcastLogApiProviderOptions": [ + "interface", + "channelName?: string | undefined", + "connection?: UwtBroadcastConnection | undefined", + "enabled?: boolean | undefined", + "report?: UwtReporter | undefined", + "runInside?: ((callback: () => T) => T) | undefined", + "runOutside?: ((callback: () => T) => T) | undefined" + ], + "UwtBroadcastMessage": [ + "type = { kind: 'event'; eventType: string; payload: object; metadata?: UwtTelemetryMetadata | undefined; } | { kind: 'error'; error: UwtTelemetryError; metadata?: UwtTelemetryMetadata | undefined; } | { kind: 'user'; userId: string | undefined; } | { kind: 'flush'; beacon: boolean; } | { kind: 'identity-request'; } | { kind: 'identity'; sessionId?: string | undefined; userId?: string | undefined; } | { kind: 'log'; record: UwtLogRecord; } | { kind: 'log-flush'; } | { kind: 'log-query'; id: string; filter: UwtLogQuery; } | { kind: 'log-query-result'; id: string; records: unknown[]; }" + ], + "UwtBroadcastTelemetrySink": [ + "class", + "new (options: UwtBroadcastTelemetrySinkOptions): UwtBroadcastTelemetrySink", + "dispose(): void", + "flush(beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "setUserId(userId: string | undefined): void" + ], + "UwtBroadcastTelemetrySinkOptions": [ + "interface", + "channelName?: string | undefined", + "connection?: UwtBroadcastConnection | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "report?: UwtReporter | undefined" + ], "uwtClassifyTelemetryEvent": [ "function(eventType: string, payload: object): UwtTelemetrySinkEvent" ], + "uwtConnectBroadcastChannel": [ + "function(options?: UwtBroadcastConnectOptions | undefined): UwtBroadcastConnection" + ], "uwtCreateTelemetry": [ "function(options: UwtTelemetryOptions): UwtTelemetry" ], @@ -393,6 +456,20 @@ "sessionId?: string | undefined", "userId?: string | undefined" ], + "UwtTelemetryBroadcastHost": [ + "class", + "new (options: UwtTelemetryBroadcastHostOptions): UwtTelemetryBroadcastHost", + "dispose(): void", + "get received: number" + ], + "UwtTelemetryBroadcastHostOptions": [ + "interface", + "channelName?: string | undefined", + "enabled?: boolean | undefined", + "logApi: UwtLogApiProvider", + "report?: UwtReporter | undefined", + "sink: UwtTelemetrySink" + ], "UwtTelemetryConfig": [ "interface", "bi: UwtBITelemetryConfig", diff --git a/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts b/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts new file mode 100644 index 0000000..c0e7ea3 --- /dev/null +++ b/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts @@ -0,0 +1,176 @@ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtBroadcastConnection, + uwtConnectBroadcastChannel, + uwtIsBroadcastLogRecord, + uwtIsBroadcastMessage +} from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; +import { + UwtReporter, + uwtSafeVoid, + uwtUuid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../uwt-log-api.provider/uwt-log-api.provider'; + +/** + * How long a follower waits for the host to answer a log query before + * resolving to `[]`, in milliseconds. + * + * @group Utils + */ +export const UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS = 10_000; + +/** A log query waiting for the host's answer. */ +interface UwtPendingLogQuery { + resolve: (records: UwtLogRecord[]) => void; + timer: ReturnType; +} + +/** + * What {@link UwtBroadcastLogApiProvider} needs; all optional. + * + * @group Interfaces + */ +export interface UwtBroadcastLogApiProviderOptions { + /** + * The channel's name; must match the host's. Default + * {@link UWT_DEFAULT_BROADCAST_CHANNEL}. + */ + channelName?: string; + /** + * `false` forwards nothing, and every query resolves to `[]` at once — + * on a server, or while telemetry is off. Default `true`. + */ + enabled?: boolean; + /** + * A connection to share with the realm's {@link UwtBroadcastTelemetrySink}, + * instead of opening one of its own. Left open by + * {@link UwtBroadcastLogApiProvider.dispose}: whoever opened it closes it. + */ + connection?: UwtBroadcastConnection; + /** + * Schedules the query timeout outside the host framework's change + * detection: Angular passes `NgZone.runOutsideAngular`. + */ + runOutside?: (callback: () => T) => T; + /** + * Hands a query's answer back inside the host framework's change + * detection: Angular passes `NgZone.run`. + */ + runInside?: (callback: () => T) => T; + /** Receives failures; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Log API provider for a follower realm — the log counterpart of + * {@link UwtBroadcastTelemetrySink}. + * + * Ships nothing itself. Records are forwarded over the `BroadcastChannel` + * to the realm running a {@link UwtTelemetryBroadcastHost}, which hands + * them to its own log provider: one log backend, one set of credentials, + * for the whole composed page. + * + * `query()` is answered by the host's provider too. It resolves to `[]` + * when no host answers within {@link UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS}, + * and at once when no channel could be opened. + * + * @group Services + */ +export class UwtBroadcastLogApiProvider implements UwtLogApiProvider { + private readonly connection: UwtBroadcastConnection; + private readonly ownsConnection: boolean; + private readonly stopListening: () => void; + private readonly runOutside: (callback: () => T) => T; + private readonly runInside: (callback: () => T) => T; + private readonly report?: UwtReporter; + private readonly pending = new Map(); + + constructor(options: UwtBroadcastLogApiProviderOptions = {}) { + this.report = options.report; + this.ownsConnection = !options.connection; + this.connection = + options.connection ?? + uwtConnectBroadcastChannel({ + channelName: options.channelName, + open: options.enabled !== false, + operation: 'broadcastLogApi', + report: options.report + }); + this.runOutside = options.runOutside ?? ((callback) => callback()); + this.runInside = options.runInside ?? ((callback) => callback()); + this.stopListening = this.connection.onMessage((data) => + this.onMessage(data) + ); + } + + /** @inheritdoc */ + send(record: UwtLogRecord): void { + this.connection.post({ kind: 'log', record }); + } + + /** @inheritdoc */ + query(filter: UwtLogQuery): Promise { + if (!this.connection.isOpen()) { + return Promise.resolve([]); + } + + return new Promise((resolve) => { + const id = uwtUuid(); + const timer = this.runOutside(() => + setTimeout( + () => this.answer(id, []), + UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS + ) + ); + this.pending.set(id, { resolve, timer }); + this.connection.post({ kind: 'log-query', id, filter }); + }); + } + + /** @inheritdoc */ + flush(): void { + this.connection.post({ kind: 'log-flush' }); + } + + /** + * Resolves every unanswered query to `[]`, stops listening, and closes + * the channel if this provider opened it. Calling it again does nothing. + */ + dispose(): void { + for (const id of [...this.pending.keys()]) { + this.answer(id, []); + } + this.stopListening(); + if (this.ownsConnection) { + this.connection.close(); + } + } + + private onMessage(data: unknown): void { + uwtSafeVoid( + 'broadcastLogApi.receive', + () => { + if (!uwtIsBroadcastMessage(data) || data.kind !== 'log-query-result') { + return; + } + // Other followers' answers arrive here too; answer() ignores them. + this.answer(data.id, data.records.filter(uwtIsBroadcastLogRecord)); + }, + this.report + ); + } + + private answer(id: string, records: UwtLogRecord[]): void { + const query = this.pending.get(id); + if (!query) { + return; + } + this.pending.delete(id); + clearTimeout(query.timer); + this.runInside(() => query.resolve(records)); + } +} diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.ts new file mode 100644 index 0000000..743baa5 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-host.ts @@ -0,0 +1,414 @@ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { UwtBroadcastLogApiProvider } from '../../providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { uwtSessionElapsed } from '../../utils/uwt-session-start.util/uwt-session-start.util'; +import { uwtClassifyTelemetryEvent } from '../../utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; +import { + uwtReport, + UwtReporter, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; +import { + UwtBroadcastConnection, + uwtConnectBroadcastChannel, + uwtElectBroadcastHostLeader, + uwtIsBroadcastMessage +} from './uwt-broadcast.messages'; + +/** The identity fields scenario, BI and log records carry. */ +interface UwtIdentified { + sessionId?: string; + userId?: string; + sessionElapsed?: number; +} + +/** + * Active host count per channel, scoped to this JS realm — distinguishes a + * true duplicate host from another tab sharing the same channel. Realm-wide + * on purpose; only a host that is active in a browser counts, so server-side + * renders never see each other's. + */ +const hostsInThisRealm = new Map(); + +/** 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. + * + * @group Interfaces + */ +export interface UwtTelemetryBroadcastHostOptions { + /** This realm's real destination, recording what followers forward. */ + sink: UwtTelemetrySink; + /** + * This realm's real log store: it receives followers' records and answers + * their queries. + */ + logApi: UwtLogApiProvider; + /** + * The channel's name; must match the followers'. Default + * {@link UWT_DEFAULT_BROADCAST_CHANNEL}. + */ + channelName?: string; + /** + * `false` hosts nothing — no channel, no election: on a server, or while + * telemetry is off. Default `true`. + */ + enabled?: boolean; + /** + * Receives failures, and 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. + */ + report?: UwtReporter; +} + +/** + * Receives telemetry forwarded by follower realms and records it through this + * realm's sink. + * + * Runs in the shell, the realm with the real sink. Fragments using + * {@link UwtBroadcastTelemetrySink} post their events here, so one AWS client, + * one session and one event budget serve the whole composed page. Their + * scenario, BI and log records are stamped with this realm's current session + * and user, which a fragment's own copy can lag behind. + * + * Fragments' log records arrive the same way, from + * {@link UwtBroadcastLogApiProvider}, and go to this realm's log provider — + * which also answers their `query()` calls. + * + * A realm that forwards its own telemetry cannot also host it: the host + * would receive its own realm's messages and forward them again, forever. + * Such a host warns and stays inactive. + * + * A Web Locks-based election keeps exactly one host active per channel; + * others stay passive. + * + * @group Services + */ +export class UwtTelemetryBroadcastHost { + private readonly sink: UwtTelemetrySink; + private readonly logApi: UwtLogApiProvider; + private readonly connection: UwtBroadcastConnection; + private readonly enabled: boolean; + private readonly report?: UwtReporter; + + /** Number of messages accepted, for tests and diagnostics. */ + private _received = 0; + + /** + * The session and user id last sent in an `identity` message. + * + * Compared against the sink's current values on every follower activity, so + * a late-resolving session id or user id gets announced once it exists. + */ + private lastAnnouncedSessionId?: string; + private lastAnnouncedUserId?: string; + + /** Whether this realm won the leader election; non-leaders stay passive. */ + private isLeader = false; + private releaseLeadership: () => void = () => undefined; + private disposed = false; + + constructor(options: UwtTelemetryBroadcastHostOptions) { + this.sink = options.sink; + this.logApi = options.logApi; + this.enabled = options.enabled !== false; + this.report = options.report; + this.connection = uwtConnectBroadcastChannel({ + channelName: options.channelName, + open: this.enabled, + operation: 'broadcastHost', + report: options.report + }); + + if (!this.enabled) { + // No listening, no leader election. + return; + } + this.warnIfDuplicateInThisRealm(); + if (this.forwardsItsOwnRealm()) { + this.connection.close(); + return; + } + + this.connection.onMessage((data) => this.onMessage(data)); + this.releaseLeadership = uwtElectBroadcastHostLeader( + this.connection.channelName, + () => { + this.isLeader = true; + this.announceIdentity(); + } + ); + } + + /** How many follower messages have been accepted. */ + get received(): number { + return this._received; + } + + /** + * Gives up leadership and closes the channel. Calling it again does + * nothing. + */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + if (this.enabled) { + this.forgetInThisRealm(); + } + this.releaseLeadership(); + this.connection.close(); + } + + private onMessage(data: unknown): void { + uwtSafeVoid( + 'broadcastHost.receive', + () => { + if (!this.isLeader || !uwtIsBroadcastMessage(data)) { + return; + } + + if ( + data.kind !== 'identity' && + data.kind !== 'identity-request' && + data.kind !== 'log-query-result' + ) { + this._received++; + } + + switch (data.kind) { + case 'event': + this.sink.record( + data.eventType, + this.stampEvent(data.eventType, data.payload), + data.metadata + ); + this.reannounceIfIdentityChanged(); + break; + case 'error': + this.sink.recordError(data.error, data.metadata); + this.reannounceIfIdentityChanged(); + break; + case 'user': + this.sink.setUserId(data.userId); + this.announceIdentity(); + break; + case 'flush': + this.sink.flush(data.beacon); + this.reannounceIfIdentityChanged(); + break; + case 'identity-request': + this.announceIdentity(); + break; + case 'identity': + // Another realm's host on this origin-wide channel — expected, + // not a problem. Same-document duplicates are caught by + // warnIfDuplicateInThisRealm() instead. + break; + case 'log': + this.deliverLog(this.withIdentity(data.record)); + this.reannounceIfIdentityChanged(); + break; + case 'log-flush': + uwtSafeVoidMaybeAsync( + 'broadcastHost.logFlush', + () => this.logApi?.flush?.(), + this.report + ); + break; + case 'log-query': + this.answerLogQuery(data.id, data.filter); + break; + case 'log-query-result': + // Hosts answer queries; they never ask them. + break; + } + }, + this.report + ); + } + + /** + * A forwarded scenario or BI record with this realm's identity; any other + * payload as it is. + */ + private stampEvent(eventType: string, payload: object): object { + const { kind } = uwtClassifyTelemetryEvent(eventType, payload); + return kind === 'unknown' + ? payload + : this.withIdentity(payload as UwtIdentified, kind === 'scenario'); + } + + /** + * `record` with this realm's session and user id. A follower stamps the ids + * it was last told, which lag behind whenever they change here — the client + * starting after the follower asked, a session expiring, a sign-out — so its + * record could name a session other than the one it is sent in. With + * `measuresSession`, a changed session's `sessionElapsed` is measured again. + */ + private withIdentity( + record: T, + measuresSession = false + ): T { + const sessionId = this.currentSessionId(); + // '' is no user, as `setUserId('')` signs out. + const userId = this.currentUserId() || undefined; + if (record.sessionId === sessionId && record.userId === userId) { + return record; + } + // An id this realm doesn't have stays `undefined`, + // as in the library's own records. + const stamped: T = { ...record, sessionId, userId }; + if (measuresSession && record.sessionId !== sessionId) { + // The old session's value no longer applies; absent when unknown. + delete stamped.sessionElapsed; + const elapsed = uwtSessionElapsed(sessionId); + if (elapsed !== undefined) { + stamped.sessionElapsed = Math.round(elapsed); + } + } + return stamped; + } + + /** Hands a follower's record to this realm's log API provider. */ + private deliverLog(record: UwtLogRecord): void { + uwtSafeVoidMaybeAsync( + 'broadcastHost.log', + () => this.logApi.send(record), + this.report + ); + } + + /** + * Answers a follower's query from this realm's log API provider. Always + * answers — `[]` when there is no provider or it fails — so the follower + * isn't left waiting for its timeout. + */ + private answerLogQuery(id: string, filter: UwtLogQuery): void { + const reply = (records: unknown) => + this.connection.post({ + kind: 'log-query-result', + id, + records: Array.isArray(records) ? records : [] + }); + + const pending = uwtSafe( + 'broadcastHost.logQuery', + () => this.logApi.query(filter), + undefined, + this.report + ); + + if (!pending) { + reply([]); + return; + } + // Promise.resolve: a provider breaking its contract with a plain value + // still gets an answer out. + Promise.resolve(pending).then(reply, () => reply([])); + } + + /** + * Whether this realm's own sink or log provider forwards over the + * channel. Hosting then would loop: the host receives its own realm's + * messages and hands them straight back to the channel. + */ + private forwardsItsOwnRealm(): boolean { + const forwards = + this.sink instanceof UwtBroadcastTelemetrySink || + this.logApi instanceof UwtBroadcastLogApiProvider; + + if (forwards) { + this.warn( + `this realm forwards its own telemetry on channel "${this.connection.channelName}", so it cannot also host it; the host stays inactive. Provide the host only in the realm with the real sink and log provider` + ); + } + return forwards; + } + + /** + * Warns when another host is already active on the same channel in this + * realm — a real misconfiguration. Not based on the `identity` broadcast, + * which can't distinguish this from a different, legitimate tab. + */ + private warnIfDuplicateInThisRealm(): void { + const channelName = this.connection.channelName; + const activeCount = hostsInThisRealm.get(channelName) ?? 0; + + if (activeCount > 0) { + this.warn( + `a second telemetry host is active on channel "${channelName}" in this document; only one realm should provide it` + ); + } + + hostsInThisRealm.set(channelName, activeCount + 1); + } + + /** Releases this host's slot in {@link hostsInThisRealm}. */ + private forgetInThisRealm(): void { + const channelName = this.connection.channelName; + const activeCount = hostsInThisRealm.get(channelName) ?? 0; + + if (activeCount <= 1) { + hostsInThisRealm.delete(channelName); + } else { + hostsInThisRealm.set(channelName, activeCount - 1); + } + } + + /** A host that cannot work as configured is never silent. */ + private warn(message: string): void { + uwtReport(this.report ?? consoleReporter, 'warn', message); + } + + private announceIdentity(): void { + const sessionId = this.currentSessionId(); + const userId = this.currentUserId(); + this.lastAnnouncedSessionId = sessionId; + this.lastAnnouncedUserId = userId; + this.connection.post({ kind: 'identity', sessionId, userId }); + } + + /** + * Re-announces identity when the sink's session id or user id has moved on + * from what followers were last told. + */ + private reannounceIfIdentityChanged(): void { + if ( + this.currentSessionId() !== this.lastAnnouncedSessionId || + this.currentUserId() !== this.lastAnnouncedUserId + ) { + this.announceIdentity(); + } + } + + private currentSessionId(): string | undefined { + return uwtSafe( + 'broadcastHost.getSessionId', + () => this.sink.getSessionId(), + undefined, + this.report + ); + } + + private currentUserId(): string | undefined { + return uwtSafe( + 'broadcastHost.getUserId', + () => this.sink.getUserId(), + undefined, + this.report + ); + } +} diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts new file mode 100644 index 0000000..a3f6864 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts @@ -0,0 +1,177 @@ +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { + UwtReporter, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { + UwtBroadcastConnection, + uwtConnectBroadcastChannel, + uwtIsBroadcastMessage +} from './uwt-broadcast.messages'; + +/** + * What {@link UwtBroadcastTelemetrySink} needs. + * + * @group Interfaces + */ +export interface UwtBroadcastTelemetrySinkOptions { + /** This realm's identity, stamped onto everything it forwards. */ + identity: UwtTelemetryIdentity; + /** + * The channel's name; must match the host's. Default + * {@link UWT_DEFAULT_BROADCAST_CHANNEL}. + */ + channelName?: string; + /** + * `false` forwards nothing — on a server, or while telemetry is off. + * Default `true`. + */ + enabled?: boolean; + /** + * A connection to share with the realm's + * {@link UwtBroadcastLogApiProvider}, instead of opening one of its own. + * Left open by {@link UwtBroadcastTelemetrySink.dispose}: whoever opened + * it closes it. + */ + connection?: UwtBroadcastConnection; + /** Receives failures; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Telemetry sink for a follower realm — a micro-frontend fragment, a webview + * panel, any context with its own JavaScript realm but the same origin. + * + * Creates no AWS client of its own. Events are forwarded over a + * `BroadcastChannel` to the realm running a {@link UwtTelemetryBroadcastHost}, + * which records them through the one real sink. Application code sees no + * difference — it uses the same engine and calls the same methods. + * + * @group Services + */ +export class UwtBroadcastTelemetrySink extends UwtTelemetrySink { + private readonly connection: UwtBroadcastConnection; + private readonly ownsConnection: boolean; + private readonly stopListening: () => void; + private readonly report?: UwtReporter; + + /** The host's session id, once it has answered. */ + private sessionId?: string; + private userId?: string; + + /** + * This realm's own identity, stamped onto everything it forwards. + * + * The host records through its own RUM client, so without this, forwarded + * events would all be labelled as the host's realm. + */ + private readonly origin: { + application: string; + environment: string; + appVersion: string; + }; + + constructor(options: UwtBroadcastTelemetrySinkOptions) { + super(); + this.report = options.report; + this.ownsConnection = !options.connection; + this.connection = + options.connection ?? + uwtConnectBroadcastChannel({ + channelName: options.channelName, + open: options.enabled !== false, + operation: 'broadcastSink', + report: options.report + }); + this.origin = { + application: options.identity.application, + environment: options.identity.environment, + appVersion: options.identity.version + }; + + this.stopListening = this.connection.onMessage((data) => + this.onMessage(data) + ); + this.connection.post({ kind: 'identity-request' }); + } + + /** @inheritdoc */ + record( + eventType: string, + payload: object, + metadata?: UwtTelemetryMetadata + ): void { + this.connection.post({ + kind: 'event', + eventType, + payload, + metadata: { ...metadata, ...this.origin } + }); + } + + /** @inheritdoc */ + recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata): void { + this.connection.post({ + kind: 'error', + error, + metadata: { ...metadata, ...this.origin } + }); + } + + /** + * The host's session id. + * + * Returns `undefined` until the host answers. + * + * @returns the shared session id, when known + */ + getSessionId(): string | undefined { + return this.sessionId; + } + + /** @inheritdoc */ + setUserId(userId: string | undefined): void { + this.userId = userId; + this.connection.post({ kind: 'user', userId }); + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(beacon = false): void { + this.connection.post({ kind: 'flush', beacon }); + } + + /** + * Stops listening, and closes the channel if this sink opened it. Calling + * it again does nothing. + */ + dispose(): void { + this.stopListening(); + if (this.ownsConnection) { + this.connection.close(); + } + } + + private onMessage(data: unknown): void { + uwtSafeVoid( + 'broadcastSink.receive', + () => { + if (!uwtIsBroadcastMessage(data) || data.kind !== 'identity') { + return; + } + this.sessionId = data.sessionId; + this.userId = data.userId; + }, + this.report + ); + } +} diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.spec.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.spec.ts new file mode 100644 index 0000000..f66da99 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.spec.ts @@ -0,0 +1,369 @@ +/** + * @jest-environment jsdom + */ +import { + uwtElectBroadcastHostLeader, + uwtIsBroadcastMessage +} from './uwt-broadcast.messages'; + +/** Minimal Web Locks API stub: grants each named lock to one requester at a time. */ +class LockManagerStub { + private readonly held = new Set(); + private readonly queues = new Map void)[]>(); + + request(name: string, callback: () => Promise): Promise { + return new Promise((resolve) => { + const grant = () => { + this.held.add(name); + callback().then(() => { + this.held.delete(name); + resolve(); + this.queues.get(name)?.shift()?.(); + }); + }; + + if (this.held.has(name)) { + const queue = this.queues.get(name) ?? []; + queue.push(grant); + this.queues.set(name, queue); + } else { + grant(); + } + }); + } + + /** Installs the stub as `navigator.locks`. */ + static install(): void { + Object.defineProperty(globalThis.navigator, 'locks', { + value: new LockManagerStub(), + configurable: true + }); + } + + static uninstall(): void { + delete (globalThis.navigator as { locks?: unknown }).locks; + } +} + +describe('the broadcast protocol', () => { + describe('uwtElectBroadcastHostLeader', () => { + afterEach(() => LockManagerStub.uninstall()); + + it('should elect immediately when the Locks API is unavailable', () => { + const onElected = jest.fn(); + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should elect once the lock is granted', () => { + LockManagerStub.install(); + const onElected = jest.fn(); + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should fail open and still elect when request() itself rejects', async () => { + Object.defineProperty(globalThis.navigator, 'locks', { + value: { request: () => Promise.reject(new Error('not-fully-active')) }, + configurable: true + }); + const onElected = jest.fn(); + + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); + // Let the rejection's microtask settle. + await Promise.resolve(); + await Promise.resolve(); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should fail open and still elect when request() itself throws synchronously', () => { + Object.defineProperty(globalThis.navigator, 'locks', { + value: { + request: () => { + throw new Error('locks unavailable in this context'); + } + }, + configurable: true + }); + const onElected = jest.fn(); + + expect(() => + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected) + ).not.toThrow(); + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should not elect a requester released while still queued, and should let the next requester through', async () => { + LockManagerStub.install(); + const onElectedHolder = jest.fn(); + const onElectedA = jest.fn(); + const onElectedB = jest.fn(); + + const releaseHolder = uwtElectBroadcastHostLeader( + 'ngx-ui-watchtower', + onElectedHolder + ); + const releaseA = uwtElectBroadcastHostLeader( + 'ngx-ui-watchtower', + onElectedA + ); + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElectedB); + + // A is released before ever being granted the lock. + releaseA(); + // The current holder releases, so the lock passes down the queue. + releaseHolder(); + for (let i = 0; i < 6; i++) { + await Promise.resolve(); + } + + expect(onElectedA).not.toHaveBeenCalled(); + expect(onElectedB).toHaveBeenCalledTimes(1); + }); + }); + + describe('uwtIsBroadcastMessage', () => { + it.each([null, undefined, 'string', 42, ['array']])( + 'should reject the non-object payload %p', + (data) => { + expect(uwtIsBroadcastMessage(data)).toBe(false); + } + ); + + it('should reject a payload with an unknown kind', () => { + expect(uwtIsBroadcastMessage({ kind: 'other library' })).toBe(false); + }); + + it('should accept a well-formed event message', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'event', + eventType: 'com.uwt.bi', + payload: { eventName: 'x' } + }) + ).toBe(true); + }); + + it.each([ + { eventType: 'com.uwt.bi' }, + { payload: {} }, + { eventType: 1, payload: {} }, + { eventType: 'com.uwt.bi', payload: 'not-an-object' }, + { + eventType: 'com.uwt.bi', + payload: {}, + metadata: { nested: {} } + }, + { + eventType: 'com.uwt.bi', + payload: {}, + metadata: { list: [1, 2] } + }, + { + eventType: 'com.uwt.bi', + payload: {}, + metadata: 'not-an-object' + } + ])('should reject a malformed event message %p', (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'event', ...fields })).toBe(false); + }); + + it('should accept an event message with flat-primitive metadata', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'event', + eventType: 'com.uwt.bi', + payload: {}, + metadata: { count: 3, ok: true, note: 'x', blank: null } + }) + ).toBe(true); + }); + + it('should accept an event message with no metadata', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'event', + eventType: 'com.uwt.bi', + payload: {} + }) + ).toBe(true); + }); + + it('should accept a well-formed error message', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom' } + }) + ).toBe(true); + }); + + it.each([ + {}, + { error: { name: 'Error' } }, + { error: { message: 'boom' } }, + { error: 'boom' }, + { error: { name: 'Error', message: 'boom', stack: 42 } }, + { + error: { name: 'Error', message: 'boom' }, + metadata: { nested: {} } + } + ])('should reject a malformed error message %p', (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'error', ...fields })).toBe(false); + }); + + it('should accept an error message with flat-primitive metadata', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom' }, + metadata: { origin: 'checkout' } + }) + ).toBe(true); + }); + + it('should accept an error message with a string stack', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom', stack: 'at foo.ts:1' } + }) + ).toBe(true); + }); + + it('should accept a well-formed flush message', () => { + expect(uwtIsBroadcastMessage({ kind: 'flush', beacon: true })).toBe(true); + }); + + it('should reject a flush message with a non-boolean beacon', () => { + expect(uwtIsBroadcastMessage({ kind: 'flush', beacon: 'true' })).toBe( + false + ); + }); + + it.each([{ userId: 'u-1' }, { userId: undefined }, {}])( + 'should accept a well-formed user message %p', + (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'user', ...fields })).toBe(true); + } + ); + + it('should reject a user message with a non-string, non-undefined userId', () => { + expect(uwtIsBroadcastMessage({ kind: 'user', userId: 42 })).toBe(false); + }); + + const logRecord = { + timestamp: '2026-01-01T00:00:00.000Z', + level: 'warn', + message: 'Careful', + application: 'cart', + environment: 'test', + version: '1.0.0' + }; + + it('should accept a well-formed log message', () => { + expect(uwtIsBroadcastMessage({ kind: 'log', record: logRecord })).toBe( + true + ); + expect( + uwtIsBroadcastMessage({ + kind: 'log', + record: { + ...logRecord, + logger: 'checkout', + context: 'Cart', + correlationId: 'c-1', + sessionId: 's', + userId: 'u', + metadata: { count: 1 }, + error: { name: 'Error', message: 'boom' } + } + }) + ).toBe(true); + }); + + it.each([ + { level: 'debug' }, + { level: 'toString' }, + { application: undefined }, + { message: 42 }, + { logger: 7 }, + { metadata: { nested: {} } }, + { error: { name: 'Error' } } + ])('should reject a log message whose record has %p', (fields) => { + expect( + uwtIsBroadcastMessage({ + kind: 'log', + record: { ...logRecord, ...fields } + }) + ).toBe(false); + }); + + it('should accept log-flush, log-query and log-query-result messages', () => { + expect(uwtIsBroadcastMessage({ kind: 'log-flush' })).toBe(true); + expect( + uwtIsBroadcastMessage({ + kind: 'log-query', + id: 'q-1', + filter: { correlationId: 'c-1', minLevel: 'warn', limit: 5 } + }) + ).toBe(true); + expect( + uwtIsBroadcastMessage({ + kind: 'log-query-result', + id: 'q-1', + records: [] + }) + ).toBe(true); + }); + + it.each([ + { id: '', filter: {} }, + { id: 'q-1', filter: null }, + { id: 'q-1', filter: { limit: 'ten' } }, + { id: 'q-1', filter: { minLevel: 'verbose' } } + ])('should reject a malformed log-query message %p', (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'log-query', ...fields })).toBe( + false + ); + }); + + it('should reject a log-query-result without a records array', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'log-query-result', + id: 'q-1', + records: 'none' + }) + ).toBe(false); + }); + + it('should accept an identity-request message with no other fields', () => { + expect(uwtIsBroadcastMessage({ kind: 'identity-request' })).toBe(true); + }); + + it.each([ + {}, + { sessionId: 's-1' }, + { userId: 'u-1' }, + { + sessionId: 's-1', + userId: 'u-1' + } + ])('should accept an identity message %p', (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'identity', ...fields })).toBe(true); + }); + + it.each([{ sessionId: 42 }, { userId: 42 }, { sessionId: 42, userId: 42 }])( + 'should reject an identity message with a non-string, non-undefined field %p', + (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'identity', ...fields })).toBe( + false + ); + } + ); + }); +}); diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts new file mode 100644 index 0000000..76eb481 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts @@ -0,0 +1,430 @@ +import { + UWT_LOG_LEVEL_ORDER, + UwtLogRecord +} from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtLogQuery } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { + UwtReporter, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; + +/** + * Default `BroadcastChannel` name used between telemetry realms. + * + * Named for the package that introduced it. Kept: realms deployed + * separately, on different versions, must still meet on it. + * + * @group Utils + */ +export const UWT_DEFAULT_BROADCAST_CHANNEL = 'ngx-ui-watchtower'; + +/** + * Everything that travels between telemetry realms. + * + * Structured-cloned by `BroadcastChannel`, so every payload must be plain + * data. + */ +export type UwtBroadcastMessage = + | { + kind: 'event'; + eventType: string; + payload: object; + metadata?: UwtTelemetryMetadata; + } + | { + kind: 'error'; + error: UwtTelemetryError; + metadata?: UwtTelemetryMetadata; + } + | { kind: 'user'; userId: string | undefined } + | { kind: 'flush'; beacon: boolean } + | { kind: 'identity-request' } + /** The host announcing shared identity. Always sends both fields, even when only one changed. */ + | { kind: 'identity'; sessionId?: string; userId?: string } + /** A follower's log record, for the host to hand to its log API provider. */ + | { kind: 'log'; record: UwtLogRecord } + /** A follower asking the host to flush its log API provider. */ + | { kind: 'log-flush' } + /** A follower reading records back through the host's log API provider. */ + | { kind: 'log-query'; id: string; filter: UwtLogQuery } + /** + * The host's answer to one `log-query`, matched by `id`. The records come + * from the host's backend unchecked; the asking follower keeps only those + * that pass {@link uwtIsBroadcastLogRecord}. + */ + | { kind: 'log-query-result'; id: string; records: unknown[] }; + +/** Every `kind` the union above accepts, for {@link uwtIsBroadcastMessage}. */ +const MESSAGE_KINDS: ReadonlySet = new Set([ + 'event', + 'error', + 'user', + 'flush', + 'identity-request', + 'identity', + 'log', + 'log-flush', + 'log-query', + 'log-query-result' +] as const); + +const LOG_LEVELS: ReadonlySet = new Set( + Object.keys(UWT_LOG_LEVEL_ORDER) +); + +/** Minimal `BroadcastChannel` surface this library relies on. */ +export interface UwtBroadcastChannelLike { + postMessage(message: unknown): void; + close(): void; + onmessage: ((event: { data: unknown }) => void) | null; +} + +/** + * Opens a broadcast channel, when the browser provides the API. + * + * Feature-detected: unavailable under jsdom (tests) and in server-side + * rendering. + */ +export function uwtOpenBroadcastChannel( + name: string +): UwtBroadcastChannelLike | undefined { + const Channel = ( + globalThis as { + BroadcastChannel?: new (name: string) => UwtBroadcastChannelLike; + } + ).BroadcastChannel; + + if (typeof Channel !== 'function') { + return undefined; + } + + try { + return new Channel(name); + } catch { + return undefined; + } +} + +/** Minimal Web Locks API surface this library relies on. */ +interface UwtLockManagerLike { + request(name: string, callback: () => Promise): Promise; +} + +/** + * Elects exactly one leader among same-origin realms on this channel, via + * the Web Locks API — prevents two shell tabs from both recording every + * forwarded message. Fails open (elects immediately) when Web Locks is + * unavailable. + * + * @returns releases the lock so the next queued realm can become leader. + */ +export function uwtElectBroadcastHostLeader( + channelName: string, + onElected: () => void +): () => void { + const locks = (globalThis as { navigator?: { locks?: UwtLockManagerLike } }) + .navigator?.locks; + + if (!locks) { + onElected(); + return () => undefined; + } + + let released = false; + let release: () => void = () => { + released = true; + }; + + const failOpen = () => { + if (!released) { + onElected(); + } + }; + + try { + locks + .request( + `ngx-ui-watchtower-host:${channelName}`, + () => + new Promise((resolve) => { + if (released) { + resolve(); + return; + } + release = () => { + released = true; + resolve(); + }; + onElected(); + }) + ) + .catch(failOpen); + } catch { + failOpen(); + } + + return () => release(); +} + +/** + * A realm's live connection to the shared broadcast channel. + * + * @group Interfaces + */ +export interface UwtBroadcastConnection { + /** The channel name in use. */ + readonly channelName: string; + + /** Whether a channel is open — false without `BroadcastChannel`, when not opened, and once closed. */ + isOpen(): boolean; + + /** Sends a message to every other realm on this channel. No-op if unavailable. */ + post(message: UwtBroadcastMessage): void; + + /** + * Adds a handler for incoming messages. No-op if no channel is open. + * + * @returns removes the handler + */ + onMessage(handler: (data: unknown) => void): () => void; + + /** Closes the channel and stops any further delivery. Safe to call more than once. */ + close(): void; +} + +/** + * Where, and whether, to connect. + * + * @group Interfaces + */ +export interface UwtBroadcastConnectOptions { + /** + * The channel's name; every realm on it must use the same one. Default + * {@link UWT_DEFAULT_BROADCAST_CHANNEL}. + */ + channelName?: string; + /** + * `false` opens no channel, so every call is a no-op: on a server, or + * while telemetry is off. Default `true`. + */ + open?: boolean; + /** Names this connection in failure reports. Default `broadcast`. */ + operation?: string; + /** Receives failures; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Opens this realm's connection to the shared broadcast channel: + * feature-detects `BroadcastChannel`, and wraps every operation in the + * library's fail-open guard. One connection can serve several handlers — + * a realm's broadcast sink and log provider share one. + * + * @param options the channel name, and whether to open it at all + * @returns the connection; a no-op one when no channel could be opened + * + * @group Utils + */ +export function uwtConnectBroadcastChannel( + options: UwtBroadcastConnectOptions = {} +): UwtBroadcastConnection { + const channelName = options.channelName ?? UWT_DEFAULT_BROADCAST_CHANNEL; + const operation = options.operation ?? 'broadcast'; + const { report } = options; + const handlers = new Set<(data: unknown) => void>(); + let channel = + options.open === false ? undefined : uwtOpenBroadcastChannel(channelName); + + if (channel) { + channel.onmessage = (event) => { + for (const handler of [...handlers]) { + uwtSafeVoid(`${operation}.receive`, () => handler(event.data), report); + } + }; + } + + return { + channelName, + isOpen() { + return channel !== undefined; + }, + post(message) { + uwtSafeVoid( + `${operation}.post`, + () => channel?.postMessage(message), + report + ); + }, + onMessage(handler) { + if (!channel) { + return () => undefined; + } + handlers.add(handler); + return () => { + handlers.delete(handler); + }; + }, + close() { + uwtSafeVoid( + `${operation}.close`, + () => { + handlers.clear(); + channel?.close(); + channel = undefined; + }, + report + ); + } + }; +} + +/** + * Whether `value` is a valid `metadata` field: absent, or a non-array + * object whose values are all finite numbers, strings, booleans, or null — + * the same flat-primitives contract `uwtRedactMetadata` enforces on the way + * out. Without this, a same-origin sender (not necessarily this library's + * own sink) could post a nested object on the channel that reaches a + * receiving sink's own sanitizer — e.g. `UwtRumTelemetrySink.sanitize()` — + * unchecked, since that step trusts the shape rather than re-validating it. + */ +function isValidBroadcastMetadata(value: unknown): boolean { + if (value === undefined) { + return true; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + return Object.values(value).every( + (v) => + v === null || + typeof v === 'string' || + typeof v === 'boolean' || + (typeof v === 'number' && Number.isFinite(v)) + ); +} + +function isOptionalString(value: unknown): boolean { + return value === undefined || typeof value === 'string'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isValidTelemetryError(value: unknown): boolean { + return ( + isRecord(value) && + typeof value.name === 'string' && + typeof value.message === 'string' && + isOptionalString(value.stack) + ); +} + +function isLogLevel(value: unknown): boolean { + return typeof value === 'string' && LOG_LEVELS.has(value); +} + +/** + * Whether `value` has the shape of a {@link UwtLogRecord}: the required + * strings, a known level, optional strings where the record has them, and + * metadata and error held to the same rules as on an `event` or `error` + * message. + */ +export function uwtIsBroadcastLogRecord(value: unknown): value is UwtLogRecord { + return ( + isRecord(value) && + typeof value.timestamp === 'string' && + isLogLevel(value.level) && + typeof value.message === 'string' && + typeof value.application === 'string' && + typeof value.environment === 'string' && + typeof value.version === 'string' && + isOptionalString(value.logger) && + isOptionalString(value.context) && + isOptionalString(value.correlationId) && + isOptionalString(value.userId) && + isOptionalString(value.sessionId) && + isValidBroadcastMetadata(value.metadata) && + (value.error === undefined || isValidTelemetryError(value.error)) + ); +} + +function isValidLogQuery(value: unknown): boolean { + return ( + isRecord(value) && + isOptionalString(value.correlationId) && + isOptionalString(value.logger) && + (value.minLevel === undefined || isLogLevel(value.minLevel)) && + isOptionalString(value.from) && + isOptionalString(value.to) && + (value.limit === undefined || + (typeof value.limit === 'number' && Number.isFinite(value.limit))) + ); +} + +/** + * Narrows an incoming `BroadcastChannel` payload to a telemetry message. + * + * Checks each kind's required fields, not just `kind`, so a same-named + * message from something else on the channel is rejected. + */ +export function uwtIsBroadcastMessage( + data: unknown +): data is UwtBroadcastMessage { + if (typeof data !== 'object' || data === null) { + return false; + } + + const message = data as Record; + const kind = message.kind as UwtBroadcastMessage['kind']; + if (!MESSAGE_KINDS.has(kind)) { + return false; + } + + switch (kind) { + case 'event': + return ( + typeof message.eventType === 'string' && + typeof message.payload === 'object' && + message.payload !== null && + isValidBroadcastMetadata(message.metadata) + ); + case 'error': + return ( + isValidTelemetryError(message.error) && + isValidBroadcastMetadata(message.metadata) + ); + case 'flush': + return typeof message.beacon === 'boolean'; + case 'user': + // Absent or `undefined` userId both mean "clear it". + return message.userId === undefined || typeof message.userId === 'string'; + case 'identity': + return ( + (message.sessionId === undefined || + typeof message.sessionId === 'string') && + (message.userId === undefined || typeof message.userId === 'string') + ); + case 'identity-request': + case 'log-flush': + return true; + case 'log': + return uwtIsBroadcastLogRecord(message.record); + case 'log-query': + return ( + typeof message.id === 'string' && + message.id !== '' && + isValidLogQuery(message.filter) + ); + case 'log-query-result': + return ( + typeof message.id === 'string' && + message.id !== '' && + Array.isArray(message.records) + ); + } +} diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts new file mode 100644 index 0000000..578f68e --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts @@ -0,0 +1,245 @@ +/** + * @jest-environment jsdom + */ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS, + UwtBroadcastLogApiProvider +} from '../../providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; +import { UwtNoopLogApiProvider } from '../../providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider'; +import { UwtNoopTelemetrySink } from '../uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; +import { UwtTelemetryBroadcastHost } from './uwt-broadcast-host'; +import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; +import { uwtConnectBroadcastChannel } from './uwt-broadcast.messages'; + +/** + * `BroadcastChannel` for the test: delivers asynchronously to every other + * open instance of the same name. + */ +class ChannelStub { + static readonly open = new Set(); + onmessage: ((event: { data: unknown }) => void) | null = null; + + constructor(readonly name: string) { + ChannelStub.open.add(this); + } + + postMessage(message: unknown): void { + for (const peer of ChannelStub.open) { + if (peer !== this && peer.name === this.name) { + const data = JSON.parse(JSON.stringify(message)); + setTimeout(() => peer.onmessage?.({ data }), 0); + } + } + } + + close(): void { + ChannelStub.open.delete(this); + } +} + +async function settle(): Promise { + for (let turn = 0; turn < 5; turn++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +const identity = { application: 'cart', environment: 'test', version: '1' }; + +describe('the core broadcast classes', () => { + let hosts: UwtTelemetryBroadcastHost[]; + + /** A host, disposed after the test whatever happens. */ + function hostOf( + options: ConstructorParameters[0] + ): UwtTelemetryBroadcastHost { + const host = new UwtTelemetryBroadcastHost(options); + hosts.push(host); + return host; + } + + beforeEach(() => { + hosts = []; + Object.defineProperty(globalThis, 'BroadcastChannel', { + value: ChannelStub, + configurable: true, + writable: true + }); + }); + + afterEach(() => { + hosts.forEach((host) => host.dispose()); + ChannelStub.open.clear(); + delete (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel; + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('a shared connection', () => { + it('should serve a sink and a log provider over one channel', async () => { + const connection = uwtConnectBroadcastChannel(); + const sink = new UwtBroadcastTelemetrySink({ identity, connection }); + const logApi = new UwtBroadcastLogApiProvider({ connection }); + const host = hostOf({ + sink: new UwtNoopTelemetrySink(), + logApi: { + send: () => undefined, + query: async () => [record('found')] + } + }); + await settle(); + + expect(ChannelStub.open.size).toBe(2); + const query = logApi.query({}); + await settle(); + expect((await query).map((r) => r.message)).toEqual(['found']); + + sink.flush(); + await settle(); + expect(host.received).toBe(2); + sink.dispose(); + logApi.dispose(); + connection.close(); + }); + + it('should be closed by whoever opened it, not by those it was lent to', () => { + const connection = uwtConnectBroadcastChannel(); + const sink = new UwtBroadcastTelemetrySink({ identity, connection }); + const logApi = new UwtBroadcastLogApiProvider({ connection }); + + sink.dispose(); + logApi.dispose(); + expect(connection.isOpen()).toBe(true); + + connection.close(); + expect(ChannelStub.open.size).toBe(0); + }); + + it('should be opened, and closed, by a sink given none', () => { + const sink = new UwtBroadcastTelemetrySink({ identity }); + expect(ChannelStub.open.size).toBe(1); + + sink.dispose(); + sink.dispose(); + expect(ChannelStub.open.size).toBe(0); + }); + + it('should open nothing while disabled', async () => { + const sink = new UwtBroadcastTelemetrySink({ identity, enabled: false }); + const logApi = new UwtBroadcastLogApiProvider({ enabled: false }); + + expect(ChannelStub.open.size).toBe(0); + expect(() => sink.record('com.uwt.bi', {})).not.toThrow(); + await expect(logApi.query({})).resolves.toEqual([]); + }); + }); + + describe('a log query', () => { + it('should resolve to [] when no host answers in time, its timer scheduled through runOutside', async () => { + jest.useFakeTimers(); + let outside = 0; + const logApi = new UwtBroadcastLogApiProvider({ + runOutside: (callback) => { + outside++; + return callback(); + } + }); + + const query = logApi.query({}); + jest.advanceTimersByTime(UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS); + + await expect(query).resolves.toEqual([]); + expect(outside).toBe(1); + expect(jest.getTimerCount()).toBe(0); + logApi.dispose(); + }); + + it('should resolve to [] when the provider is disposed, leaving no timer', async () => { + jest.useFakeTimers(); + const logApi = new UwtBroadcastLogApiProvider(); + + const query = logApi.query({}); + logApi.dispose(); + + await expect(query).resolves.toEqual([]); + expect(jest.getTimerCount()).toBe(0); + }); + }); + + describe('the host', () => { + const shell = () => ({ + sink: new UwtNoopTelemetrySink(), + logApi: new UwtNoopLogApiProvider() + }); + + it('should report a second host in this realm to its reporter', () => { + const report = jest.fn(); + const first = hostOf({ ...shell(), report }); + const second = hostOf({ ...shell(), report }); + + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('a second telemetry host is active') + ); + first.dispose(); + second.dispose(); + }); + + it('should warn on the console without a reporter: a misconfigured host is never silent', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const forwarding = hostOf({ + sink: new UwtBroadcastTelemetrySink({ identity }), + logApi: new UwtNoopLogApiProvider() + }); + + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/^\[ui-watchtower\] .*cannot also host it/) + ); + forwarding.dispose(); + }); + + it('should neither count nor open anything while disabled', () => { + const report = jest.fn(); + const disabled = hostOf({ + ...shell(), + enabled: false, + report + }); + const active = hostOf({ ...shell(), report }); + + expect(report).not.toHaveBeenCalled(); + expect(ChannelStub.open.size).toBe(1); + disabled.dispose(); + active.dispose(); + }); + + it('should give up its place only once, however often it is disposed', () => { + const report = jest.fn(); + const first = hostOf({ ...shell(), report }); + const second = hostOf({ ...shell(), report }); + second.dispose(); + second.dispose(); + report.mockClear(); + + const third = hostOf({ ...shell(), report }); + + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('a second telemetry host is active') + ); + first.dispose(); + third.dispose(); + }); + }); +}); + +function record(message: string): UwtLogRecord { + return { + timestamp: '2026-01-01T00:00:00.000Z', + level: 'log', + message, + application: 'shell', + environment: 'test', + version: '1' + }; +} diff --git a/projects/ui-watchtower/src/public-api.ts b/projects/ui-watchtower/src/public-api.ts index 7cf1fbc..665096d 100644 --- a/projects/ui-watchtower/src/public-api.ts +++ b/projects/ui-watchtower/src/public-api.ts @@ -89,6 +89,27 @@ export type { } from './lib/providers/uwt-log-api.provider/uwt-log-api.provider'; export { UwtNoopLogApiProvider } from './lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider'; +// Shell and fragments: a fragment forwards its telemetry and logs over a +// BroadcastChannel to the shell's host, which records them +export { + UWT_DEFAULT_BROADCAST_CHANNEL, + uwtConnectBroadcastChannel +} from './lib/sinks/uwt-broadcast/uwt-broadcast.messages'; +export type { + UwtBroadcastConnection, + UwtBroadcastConnectOptions, + UwtBroadcastMessage +} from './lib/sinks/uwt-broadcast/uwt-broadcast.messages'; +export { UwtBroadcastTelemetrySink } from './lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink'; +export type { UwtBroadcastTelemetrySinkOptions } from './lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink'; +export { + UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS, + UwtBroadcastLogApiProvider +} from './lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; +export type { UwtBroadcastLogApiProviderOptions } from './lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; +export { UwtTelemetryBroadcastHost } from './lib/sinks/uwt-broadcast/uwt-broadcast-host'; +export type { UwtTelemetryBroadcastHostOptions } from './lib/sinks/uwt-broadcast/uwt-broadcast-host'; + // Utilities an application or a destination needs export { uwtClassifyTelemetryEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; export type { UwtTelemetrySinkEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; diff --git a/projects/ui-watchtower/type-tests/compat.ts b/projects/ui-watchtower/type-tests/compat.ts index 7769c5d..47b06d2 100644 --- a/projects/ui-watchtower/type-tests/compat.ts +++ b/projects/ui-watchtower/type-tests/compat.ts @@ -11,6 +11,18 @@ import { UwtTraceScenarioOptions } from '@absaoss-cps/ui-watchtower/rxjs'; import { + UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS, + UWT_DEFAULT_BROADCAST_CHANNEL, + UwtBroadcastConnection, + UwtBroadcastConnectOptions, + UwtBroadcastLogApiProvider, + UwtBroadcastLogApiProviderOptions, + UwtBroadcastMessage, + UwtBroadcastTelemetrySink, + UwtBroadcastTelemetrySinkOptions, + uwtConnectBroadcastChannel, + UwtTelemetryBroadcastHost, + UwtTelemetryBroadcastHostOptions, UWT_DEFAULT_EVENT_NAMESPACE, UWT_DEFAULT_REDACT_CONFIG, UWT_DEFAULT_TELEMETRY_CONFIG, @@ -238,3 +250,47 @@ export function concerns( new UwtLogTelemetry(logOptions) ]; } + +/** A composed page without a framework: a fragment, and the shell hosting it. */ +export function composedPage( + shellSink: UwtTelemetrySink, + shellLogs: UwtLogApiProvider +): () => void { + const connectOptions: UwtBroadcastConnectOptions = { + channelName: UWT_DEFAULT_BROADCAST_CHANNEL, + open: true, + report + }; + const connection: UwtBroadcastConnection = + uwtConnectBroadcastChannel(connectOptions); + const sinkOptions: UwtBroadcastTelemetrySinkOptions = { + identity, + connection + }; + const logOptions: UwtBroadcastLogApiProviderOptions = { + connection, + runOutside: (callback) => callback(), + runInside: (callback) => callback() + }; + const fragment = uwtCreateTelemetry({ + identity, + destination: new UwtBroadcastTelemetrySink(sinkOptions), + logApi: new UwtBroadcastLogApiProvider(logOptions) + }); + + const hostOptions: UwtTelemetryBroadcastHostOptions = { + sink: shellSink, + logApi: shellLogs, + report + }; + const host = new UwtTelemetryBroadcastHost(hostOptions); + const flush: UwtBroadcastMessage = { kind: 'flush', beacon: true }; + connection.post(flush); + + return () => { + report('warn', `${host.received} of ${UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS}`); + fragment.dispose(); + host.dispose(); + connection.close(); + }; +} From bd46da823959addd321f45df454f5687e0e2f608 Mon Sep 17 00:00:00 2001 From: fateeand Date: Mon, 9 Nov 2026 19:39:46 +0100 Subject: [PATCH 06/21] refactor: move the AWS RUM sink into the core's rum entry point --- .eslintrc.js | 25 + README.md | 1 + jest.config.js | 2 + package.json | 2 +- projects/ngx-ui-watchtower/DESIGN.md | 21 +- .../public-api.snapshot.json | 1 + .../uwt-rum-credentials.ts | 345 +----- .../uwt-rum-internal.util.ts | 120 +- .../uwt-rum-telemetry.sink.ts | 991 +-------------- .../uwt-broadcast-host.service.ts | 4 +- .../uwt-telemetry-safe.util.ts | 22 +- projects/ngx-ui-watchtower/tsconfig.lib.json | 3 +- projects/ui-watchtower/README.md | 76 +- projects/ui-watchtower/jest.config.js | 7 +- 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 | 4 +- .../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 + 29 files changed, 2034 insertions(+), 1476 deletions(-) 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..4dad6eb 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$': 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.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.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts index 62ed090..ae141ff 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 @@ -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..f1dee2c 100644 --- a/projects/ui-watchtower/jest.config.js +++ b/projects/ui-watchtower/jest.config.js @@ -7,18 +7,19 @@ module.exports = { displayName: 'ui-watchtower', rootDir: __dirname, - roots: ['/src', '/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' ] 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..0bb45f8 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 @@ -178,7 +178,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,7 +224,7 @@ 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(); 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" ], From e21cd260989b20e8ce5d5b7ad91c048f863bd3db Mon Sep 17 00:00:00 2001 From: fateeand Date: Mon, 9 Nov 2026 19:55:45 +0100 Subject: [PATCH 07/21] test: smoke-test the packed packages in real consumers --- .github/workflows/checks.yml | 24 + README.md | 15 +- package.json | 3 +- projects/ngx-ui-watchtower/DESIGN.md | 6 + scripts/smoke/angular/angular.json | 24 + .../angular/src/app/app.config.server.ts | 8 + .../angular/src/app/app.routes.server.ts | 5 + scripts/smoke/angular/src/app/app.routes.ts | 4 + scripts/smoke/angular/src/app/app.ts | 9 + scripts/smoke/angular/src/app/home.ts | 28 ++ scripts/smoke/angular/src/index.html | 11 + scripts/smoke/angular/src/main.server.ts | 11 + scripts/smoke/angular/src/main.ts | 5 + scripts/smoke/angular/src/telemetry.schema.ts | 17 + scripts/smoke/angular/tsconfig.json | 18 + .../angular/variants/fragment.app.config.ts | 24 + .../angular/variants/shell.app.config.ts | 36 ++ scripts/smoke/core-node/consumer.mjs | 79 ++++ scripts/smoke/core-ts/nodom.ts | 17 + scripts/smoke/smoke.mjs | 411 ++++++++++++++++++ 20 files changed, 751 insertions(+), 4 deletions(-) create mode 100644 scripts/smoke/angular/angular.json create mode 100644 scripts/smoke/angular/src/app/app.config.server.ts create mode 100644 scripts/smoke/angular/src/app/app.routes.server.ts create mode 100644 scripts/smoke/angular/src/app/app.routes.ts create mode 100644 scripts/smoke/angular/src/app/app.ts create mode 100644 scripts/smoke/angular/src/app/home.ts create mode 100644 scripts/smoke/angular/src/index.html create mode 100644 scripts/smoke/angular/src/main.server.ts create mode 100644 scripts/smoke/angular/src/main.ts create mode 100644 scripts/smoke/angular/src/telemetry.schema.ts create mode 100644 scripts/smoke/angular/tsconfig.json create mode 100644 scripts/smoke/angular/variants/fragment.app.config.ts create mode 100644 scripts/smoke/angular/variants/shell.app.config.ts create mode 100644 scripts/smoke/core-node/consumer.mjs create mode 100644 scripts/smoke/core-ts/nodom.ts create mode 100644 scripts/smoke/smoke.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 280d18f..fbe6838 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -84,6 +84,30 @@ jobs: base-coverage-file: ./coverage/ui-watchtower/report.json skip-step: all + smoke: + name: Packed packages + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 22.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build both packages + run: npm run build + + # Packs both packages and installs them, outside the workspace, into a + # core-only Node and TypeScript consumer and an unchanged Angular one. + - name: Smoke-test the packed packages + run: npm run smoke + lint: name: Lint and format runs-on: ubuntu-latest diff --git a/README.md b/README.md index 72f94fd..2059a80 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ projects/ngx-ui-watchtower/ README.md usage guide, published to npm DESIGN.md full design package.json the published package's manifest -scripts/ the public API snapshot +scripts/ the public API snapshot, and the packaging smoke test ``` The Angular package depends on the core, never the other way round. @@ -66,11 +66,12 @@ application does. Tests and the typecheck read the core's source. | `npm run typecheck` | Builds the core, then type-checks every tsconfig and type test | | `npm run api:check` | Compares the built public APIs with their committed snapshots | | `npm run api:update` | Writes those snapshots again, for an intended API change | +| `npm run smoke` | Installs the built packages, packed, into throwaway consumers | | `npm run lint` | ESLint, no warnings allowed | | `npm run format` | Formats with Prettier | | `npm run format:check` | Checks formatting without writing | -Three checks guard what consumers depend on: +Four checks guard what consumers depend on: - **Type tests** (`type-tests/` in each package) compile a consumer that uses every public export, and one that augments the name registries: registered @@ -84,7 +85,14 @@ Three checks guard what consumers depend on: (`projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json`) records every message a shell and a fragment exchange. Shells and fragments are deployed separately, so it changes only with a deliberate protocol change: - `UPDATE_WIRE_FIXTURE=1 npm test` records it again. + `UPDATE_WIRE_FIXTURE=1 npm test` records it again. Every pairing of an + Angular or framework-free shell and fragment is held to it. +- **The smoke test** (`scripts/smoke/`) packs both packages and installs the + tarballs, outside this workspace, into a framework-free Node consumer (no + Angular, RxJS or `aws-rum-web` installed), a TypeScript one (the core's + type tests, and a project without the DOM typings), and an unchanged + Angular application, prerendered as a fragment without `aws-rum-web` and as + a shell with `/rum`. It installs from the registry, so it needs a network. ## Before you push @@ -97,6 +105,7 @@ npm run lint npm run format:check npm run build npm run api:check +npm run smoke ``` CI runs the same checks on every pull request and every push to `main`. diff --git a/package.json b/package.json index 5d3309e..2b85890 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "format:check": "prettier --check \"**/*.{ts,mjs,html}\"", "typecheck": "tsc -p projects/ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/rxjs/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ui-watchtower/type-tests/tsconfig.json && tsc -p projects/ui-watchtower/type-tests/tsconfig.augmented.json && ng build --configuration=production --project=ui-watchtower && tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.json && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json", "api:check": "node scripts/api-snapshot.mjs", - "api:update": "node scripts/api-snapshot.mjs --update" + "api:update": "node scripts/api-snapshot.mjs --update", + "smoke": "node scripts/smoke/smoke.mjs" }, "private": true, "dependencies": { diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index d4c7623..1f0e8fd 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -239,6 +239,12 @@ providers: [ `@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: its Angular helpers and the reporter. +- **What is published is tested as published.** `npm run smoke` installs + both tarballs into consumers outside the workspace: a framework-free one + with neither Angular nor `aws-rum-web` installed, and an Angular + application, prerendered as a fragment without `aws-rum-web` — which then + holds no RUM code — and as a shell with `/rum`, whose RUM client is a chunk + loaded on demand. - **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. diff --git a/scripts/smoke/angular/angular.json b/scripts/smoke/angular/angular.json new file mode 100644 index 0000000..eec8f64 --- /dev/null +++ b/scripts/smoke/angular/angular.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "cli": { "analytics": false }, + "projects": { + "consumer": { + "projectType": "application", + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "server": "src/main.server.ts", + "outputMode": "static", + "index": "src/index.html", + "outputPath": "dist", + "tsConfig": "tsconfig.json" + } + } + } + } + } +} diff --git a/scripts/smoke/angular/src/app/app.config.server.ts b/scripts/smoke/angular/src/app/app.config.server.ts new file mode 100644 index 0000000..1f253e9 --- /dev/null +++ b/scripts/smoke/angular/src/app/app.config.server.ts @@ -0,0 +1,8 @@ +import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; + +export const config: ApplicationConfig = mergeApplicationConfig(appConfig, { + providers: [provideServerRendering(withRoutes(serverRoutes))] +}); diff --git a/scripts/smoke/angular/src/app/app.routes.server.ts b/scripts/smoke/angular/src/app/app.routes.server.ts new file mode 100644 index 0000000..dae0f3f --- /dev/null +++ b/scripts/smoke/angular/src/app/app.routes.server.ts @@ -0,0 +1,5 @@ +import { RenderMode, ServerRoute } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Prerender } +]; diff --git a/scripts/smoke/angular/src/app/app.routes.ts b/scripts/smoke/angular/src/app/app.routes.ts new file mode 100644 index 0000000..9742bb2 --- /dev/null +++ b/scripts/smoke/angular/src/app/app.routes.ts @@ -0,0 +1,4 @@ +import { Routes } from '@angular/router'; +import { Home } from './home'; + +export const routes: Routes = [{ path: '', component: Home }]; diff --git a/scripts/smoke/angular/src/app/app.ts b/scripts/smoke/angular/src/app/app.ts new file mode 100644 index 0000000..8b1e188 --- /dev/null +++ b/scripts/smoke/angular/src/app/app.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet], + template: '' +}) +export class App {} diff --git a/scripts/smoke/angular/src/app/home.ts b/scripts/smoke/angular/src/app/home.ts new file mode 100644 index 0000000..07b0a19 --- /dev/null +++ b/scripts/smoke/angular/src/app/home.ts @@ -0,0 +1,28 @@ +import { Component, inject } from '@angular/core'; +import { + UwtBITelemetryService, + UwtLoggerService, + UwtScenarioTelemetryService +} from '@absaoss-cps/ngx-ui-watchtower'; + +/** Uses every service while it renders — on the server, when prerendered. */ +@Component({ + selector: 'app-home', + template: '

scenario {{ status }}

' +}) +export class Home { + readonly status: string; + + constructor() { + const scenarios = inject(UwtScenarioTelemetryService); + const scenario = scenarios.start({ name: 'render-home' }); + scenario.step('render'); + inject(UwtBITelemetryService).track('home_rendered', { server: true }); + inject(UwtLoggerService).getLogger('smoke').log('Home rendered'); + scenario.complete(); + this.status = scenario.status ?? 'running'; + + // @ts-expect-error not a registered scenario: the augmentation applies + scenarios.findByName('render-hom'); + } +} diff --git a/scripts/smoke/angular/src/index.html b/scripts/smoke/angular/src/index.html new file mode 100644 index 0000000..6e87903 --- /dev/null +++ b/scripts/smoke/angular/src/index.html @@ -0,0 +1,11 @@ + + + + + ui-watchtower smoke + + + + + + diff --git a/scripts/smoke/angular/src/main.server.ts b/scripts/smoke/angular/src/main.server.ts new file mode 100644 index 0000000..eea9a7e --- /dev/null +++ b/scripts/smoke/angular/src/main.server.ts @@ -0,0 +1,11 @@ +import { + BootstrapContext, + bootstrapApplication +} from '@angular/platform-browser'; +import { App } from './app/app'; +import { config } from './app/app.config.server'; + +const bootstrap = (context: BootstrapContext) => + bootstrapApplication(App, config, context); + +export default bootstrap; diff --git a/scripts/smoke/angular/src/main.ts b/scripts/smoke/angular/src/main.ts new file mode 100644 index 0000000..3b1a614 --- /dev/null +++ b/scripts/smoke/angular/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { App } from './app/app'; +import { appConfig } from './app/app.config'; + +bootstrapApplication(App, appConfig).catch((error) => console.error(error)); diff --git a/scripts/smoke/angular/src/telemetry.schema.ts b/scripts/smoke/angular/src/telemetry.schema.ts new file mode 100644 index 0000000..a3510e2 --- /dev/null +++ b/scripts/smoke/angular/src/telemetry.schema.ts @@ -0,0 +1,17 @@ +/** The application's names, as an existing consumer declares them. */ +declare module '@absaoss-cps/ngx-ui-watchtower' { + interface UwtScenarioNames { + 'render-home': true; + } + interface UwtScenarioSteps { + render: true; + } + interface UwtBIEventNames { + home_rendered: true; + } + interface UwtLoggerNames { + smoke: true; + } +} + +export {}; diff --git a/scripts/smoke/angular/tsconfig.json b/scripts/smoke/angular/tsconfig.json new file mode 100644 index 0000000..8b8c5e7 --- /dev/null +++ b/scripts/smoke/angular/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022", "dom"] + }, + "angularCompilerOptions": { + "strictTemplates": true + }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/smoke/angular/variants/fragment.app.config.ts b/scripts/smoke/angular/variants/fragment.app.config.ts new file mode 100644 index 0000000..ee30ffb --- /dev/null +++ b/scripts/smoke/angular/variants/fragment.app.config.ts @@ -0,0 +1,24 @@ +import { + ApplicationConfig, + provideZonelessChangeDetection +} from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { + provideUwtTelemetry, + provideUwtTelemetrySink +} from '@absaoss-cps/ngx-ui-watchtower'; +import { routes } from './app.routes'; + +/** A fragment: no RUM, and aws-rum-web is not installed. */ +export const appConfig: ApplicationConfig = { + providers: [ + provideZonelessChangeDetection(), + provideRouter(routes), + provideUwtTelemetry({ + application: 'smoke', + environment: 'test', + version: '1.0.0' + }), + provideUwtTelemetrySink('broadcast') + ] +}; diff --git a/scripts/smoke/angular/variants/shell.app.config.ts b/scripts/smoke/angular/variants/shell.app.config.ts new file mode 100644 index 0000000..2607f83 --- /dev/null +++ b/scripts/smoke/angular/variants/shell.app.config.ts @@ -0,0 +1,36 @@ +import { + ApplicationConfig, + provideZonelessChangeDetection +} from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { + provideUwtTelemetry, + provideUwtTelemetryBroadcastHost, + UWT_LOG_API_PROVIDER, + UwtNoopLogApiProvider +} from '@absaoss-cps/ngx-ui-watchtower'; +import { + provideUwtTelemetryRumSink, + UWT_RUM_CREDENTIALS_PROVIDER +} from '@absaoss-cps/ngx-ui-watchtower/rum'; +import { routes } from './app.routes'; + +/** A shell: AWS RUM, hosting its fragments. */ +export const appConfig: ApplicationConfig = { + providers: [ + provideZonelessChangeDetection(), + provideRouter(routes), + provideUwtTelemetry({ + application: 'smoke', + environment: 'test', + version: '1.0.0' + }), + provideUwtTelemetryRumSink(), + { + provide: UWT_RUM_CREDENTIALS_PROVIDER, + useValue: { load: async () => null } + }, + { provide: UWT_LOG_API_PROVIDER, useClass: UwtNoopLogApiProvider }, + provideUwtTelemetryBroadcastHost() + ] +}; diff --git a/scripts/smoke/core-node/consumer.mjs b/scripts/smoke/core-node/consumer.mjs new file mode 100644 index 0000000..a3bfc11 --- /dev/null +++ b/scripts/smoke/core-node/consumer.mjs @@ -0,0 +1,79 @@ +/** + * A framework-free Node application using the installed core: no Angular, + * RxJS or aws-rum-web is installed beside it, and there is no browser. + */ +import assert from 'node:assert/strict'; +import { + UWT_TELEMETRY_EVENT_TYPE, + uwtCreateTelemetry, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; + +assert.equal(typeof globalThis.window, 'undefined'); +assert.equal(typeof globalThis.document, 'undefined'); + +/** The application's own destination. */ +class Recording extends UwtTelemetrySink { + events = []; + + record(eventType, payload) { + this.events.push({ eventType, payload }); + } + + recordError() {} + + getSessionId() { + return 'session-1'; + } + + setUserId() {} + + getUserId() { + return undefined; + } + + flush() {} +} + +const destination = new Recording(); +const records = []; +const telemetry = uwtCreateTelemetry({ + identity: { application: 'worker', environment: 'test', version: '1.0.0' }, + destination, + logApi: { + send: (record) => records.push(record), + query: async () => records + } +}); + +const scenario = telemetry.scenarios.start({ + name: 'import', + metadata: { token: 'secret' } +}); +scenario.step('parse'); +scenario.complete(); +telemetry.bi.track('file_imported', { rows: 3 }); +telemetry.logging.getLogger('import').warn('Slow import'); + +assert.deepEqual( + destination.events.map((event) => event.eventType), + [UWT_TELEMETRY_EVENT_TYPE.scenario, UWT_TELEMETRY_EVENT_TYPE.bi] +); +assert.equal(destination.events[0].payload.status, 'success'); +assert.equal(destination.events[0].payload.metadata.token, '[redacted]'); +assert.equal((await telemetry.logging.query()).length, 1); +telemetry.dispose(); + +// The RUM entry point loads aws-rum-web only once a sink starts, which +// without a page it never does: it is not installed here. +const { UwtRumTelemetrySink } = await import('@absaoss-cps/ui-watchtower/rum'); +const rum = new UwtRumTelemetrySink({ + identity: { application: 'worker', environment: 'test', version: '1.0.0' }, + credentialsProvider: { + load: async () => assert.fail('a sink without a page never starts') + } +}); +await rum.init(); +rum.dispose(); + +console.log('core-only Node consumer: ok'); diff --git a/scripts/smoke/core-ts/nodom.ts b/scripts/smoke/core-ts/nodom.ts new file mode 100644 index 0000000..74df43b --- /dev/null +++ b/scripts/smoke/core-ts/nodom.ts @@ -0,0 +1,17 @@ +/** + * A project without the DOM typings — a Node service, a worker — compiles + * against the core's main entry point: its declarations need none. + */ +import { + uwtCreateTelemetry, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink, + UwtTelemetry +} from '@absaoss-cps/ui-watchtower'; + +export const telemetry: UwtTelemetry = uwtCreateTelemetry({ + identity: { application: 'worker', environment: 'test', version: '1.0.0' }, + destination: new UwtNoopTelemetrySink(), + logApi: new UwtNoopLogApiProvider(), + document: null +}); diff --git a/scripts/smoke/smoke.mjs b/scripts/smoke/smoke.mjs new file mode 100644 index 0000000..29fcf65 --- /dev/null +++ b/scripts/smoke/smoke.mjs @@ -0,0 +1,411 @@ +/** + * Proves the packed packages work outside this workspace — no path alias, no + * source — installed from their tarballs, as an application installs them. + * + * npm run build && npm run smoke (-- --keep keeps the consumers) + * + * 1. Packs both packages and checks what each tarball holds. + * 2. A core-only Node consumer: no Angular, RxJS or aws-rum-web installed. + * 3. A core-only TypeScript consumer: the core's type tests, and a project + * without the DOM typings. + * 4. An unchanged Angular consumer, built and prerendered as a fragment + * without aws-rum-web installed, then as a shell with /rum; and the + * Angular package's type tests. + * + * The Angular package depends on a core version that may not be published + * yet, so every consumer resolves it to the packed core through `overrides`. + * Installs from the registry and npm's cache; writes nothing in the + * workspace. + */ +import { execFileSync } from 'node:child_process'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = fileURLToPath(new URL('../..', import.meta.url)); +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const WORK = mkdtempSync(join(tmpdir(), 'uwt-smoke-')); +const KEEP = process.argv.includes('--keep'); +const workspace = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); + +/** The workspace's own range for a package, so consumers match what it tests. */ +function rangeOf(name) { + const range = + workspace.dependencies?.[name] ?? workspace.devDependencies?.[name]; + check(range, `the workspace declares ${name}`); + return range; +} + +function check(condition, what) { + if (!condition) { + throw new Error(`smoke: expected ${what}`); + } +} + +function run(command, args, cwd) { + return execFileSync(command, args, { + cwd, + encoding: 'utf8', + env: { ...process.env, NG_CLI_ANALYTICS: 'false', CI: 'true' }, + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 64 * 1024 * 1024 + }); +} + +function step(message) { + console.log(`✔ ${message}`); +} + +/** Packs a built package, keeping its manifest and file list. */ +function pack(dist) { + const [packed] = JSON.parse( + run('npm', ['pack', '--json', '--pack-destination', WORK], join(ROOT, dist)) + ); + return { + ...packed, + tarball: join(WORK, packed.filename), + manifest: JSON.parse(readFileSync(join(ROOT, dist, 'package.json'), 'utf8')) + }; +} + +/** What every published tarball must, and must not, hold. */ +function checkTarball(pkg, optionalPeers) { + const files = new Set(pkg.files.map((file) => file.path)); + for (const required of ['README.md', 'LICENSE', 'package.json']) { + check(files.has(required), `${pkg.name} to ship ${required}`); + } + for (const [subpath, target] of Object.entries(pkg.manifest.exports)) { + if (subpath === './package.json') { + continue; + } + for (const file of [target.types, target.default]) { + check( + files.has(file.replace(/^\.\//, '')), + `${pkg.name} to ship ${file} for ${subpath}` + ); + } + } + for (const file of files) { + check( + !/(^|\/)src\//.test(file) && + !/\.spec\.|tsconfig|ng-package\.json/.test(file) && + !(file.endsWith('.ts') && !file.endsWith('.d.ts')), + `${pkg.name} not to ship ${file}` + ); + } + const { manifest } = pkg; + check(!manifest.private, `${pkg.name} not to be private`); + check( + manifest.publishConfig?.access === 'public', + `${pkg.name} to publish publicly` + ); + check(manifest.license === 'Apache-2.0', `${pkg.name} to carry its license`); + for (const [name, range] of Object.entries({ + ...manifest.dependencies, + ...manifest.peerDependencies + })) { + check( + !/^(file|link|workspace|portal):/.test(range), + `${pkg.name} to depend on ${name} by a published version, not ${range}` + ); + } + for (const peer of optionalPeers) { + check( + manifest.peerDependenciesMeta?.[peer]?.optional === true, + `${pkg.name} to declare ${peer} an optional peer` + ); + } +} + +/** A consumer project in its own directory, outside the workspace. */ +function consumer(name, manifest, template) { + const dir = join(WORK, name); + if (template) { + cpSync(join(HERE, template), dir, { recursive: true }); + } else { + mkdirSync(dir); + } + writeFileSync( + join(dir, 'package.json'), + JSON.stringify( + { + name: `uwt-smoke-${name}`, + version: '0.0.0', + private: true, + ...manifest + }, + null, + 2 + ) + ); + return dir; +} + +function install(dir, ...packages) { + run( + 'npm', + [ + 'install', + '--no-audit', + '--no-fund', + '--prefer-offline', + '--loglevel=error', + ...packages + ], + dir + ); +} + +/** Every installed copy of a package, by its directory. */ +function copiesOf(dir, name) { + const nodes = JSON.parse(run('npm', ['query', `#${name}`], dir)); + return new Set(nodes.map((node) => node.path)).size; +} + +/** Compiles a consumer's copy of a package's type tests. */ +function typeTests(dir, from, compiler, extra) { + cpSync(from, join(dir, 'type-tests'), { + recursive: true, + filter: (path) => !path.endsWith('.json') + }); + const base = { extends: '../tsconfig.json', include: [] }; + const configs = { + 'tsconfig.json': { files: ['compat.ts', 'empty-registry.ts'] }, + 'tsconfig.augmented.json': { files: ['augmented.ts'] } + }; + for (const [file, files] of Object.entries(configs)) { + writeFileSync( + join(dir, 'type-tests', file), + JSON.stringify({ + ...base, + compilerOptions: { noEmit: true, ...extra }, + ...files + }) + ); + run( + join(dir, 'node_modules', '.bin', compiler), + ['-p', `type-tests/${file}`], + dir + ); + } +} + +/** + * Builds the Angular consumer as one variant, prerendering its page; returns + * where the RUM sink and the RUM client (aws-rum-web) ended up. + */ +function buildAngular(dir, variant) { + cpSync( + join(dir, 'variants', `${variant}.app.config.ts`), + join(dir, 'src', 'app', 'app.config.ts') + ); + rmSync(join(dir, 'dist'), { recursive: true, force: true }); + run( + 'node', + [join(dir, 'node_modules', '@angular', 'cli', 'bin', 'ng.js'), 'build'], + dir + ); + const browser = join(dir, 'dist', 'browser'); + const page = readFileSync(join(browser, 'index.html'), 'utf8'); + check( + page.includes('scenario success'), + `the ${variant} page prerendered, its scenario run on the server` + ); + const code = Object.fromEntries( + readdirSync(browser) + .filter((file) => file.endsWith('.js')) + .map((file) => [file, readFileSync(join(browser, file), 'utf8')]) + ); + const files = Object.keys(code); + return { + page, + main: code['main.js'] ?? '', + // The sink's default endpoint, and the client's own API path: each in + // its own package only. + sink: files.filter((file) => code[file].includes('dataplane.rum')), + client: files.filter((file) => code[file].includes('appmonitors')) + }; +} + +try { + // 1. The tarballs. + const core = pack('dist/ui-watchtower'); + const ngx = pack('dist/ngx-ui-watchtower'); + checkTarball(core, ['aws-rum-web', 'rxjs']); + checkTarball(ngx, ['aws-rum-web']); + check(core.version === ngx.version, 'both packages at one version'); + const coreRange = ngx.manifest.dependencies['@absaoss-cps/ui-watchtower']; + check( + coreRange?.replace(/^[\^~]/, '') === core.version, + `the Angular package to depend on the core it is released with (${core.version}), not ${coreRange}` + ); + check( + !ngx.manifest.peerDependencies['@absaoss-cps/ui-watchtower'], + 'the core to be a dependency of the Angular package, not a peer' + ); + step(`packed ${core.filename} and ${ngx.filename}: contents and manifests`); + + const coreSpec = `file:${core.tarball}`; + const overrides = { '@absaoss-cps/ui-watchtower': coreSpec }; + + // 2. A Node application, the core only. + const node = consumer( + 'core-node', + { + type: 'module', + dependencies: { '@absaoss-cps/ui-watchtower': coreSpec } + }, + 'core-node' + ); + install(node); + for (const absent of ['@angular/core', 'rxjs', 'aws-rum-web']) { + check( + !existsSync(join(node, 'node_modules', absent)), + `the core alone not to install ${absent}` + ); + } + run('node', ['consumer.mjs'], node); + step( + 'core-only Node consumer: no Angular, RxJS or aws-rum-web; runs, disposes' + ); + + // 3. A TypeScript application, the core only. + const ts = consumer( + 'core-ts', + { + dependencies: { + '@absaoss-cps/ui-watchtower': coreSpec, + rxjs: rangeOf('rxjs') + }, + devDependencies: { typescript: rangeOf('typescript') } + }, + 'core-ts' + ); + writeFileSync( + join(ts, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + strict: true, + target: 'ES2022', + module: 'ES2022', + moduleResolution: 'bundler', + lib: ['ES2022', 'DOM'], + types: [], + skipLibCheck: false + } + }) + ); + writeFileSync( + join(ts, 'tsconfig.nodom.json'), + JSON.stringify({ + extends: './tsconfig.json', + compilerOptions: { noEmit: true, lib: ['ES2022'] }, + files: ['nodom.ts'] + }) + ); + install(ts); + typeTests(ts, join(ROOT, 'projects', 'ui-watchtower', 'type-tests'), 'tsc'); + run( + join(ts, 'node_modules', '.bin', 'tsc'), + ['-p', 'tsconfig.nodom.json'], + ts + ); + step( + 'core-only TypeScript consumer: type tests, augmentation, no DOM typings' + ); + + // 4. An Angular application, as it was written before the core existed. + const angular = [ + '@angular/common', + '@angular/compiler', + '@angular/core', + '@angular/platform-browser' + ]; + const app = consumer( + 'angular', + { + dependencies: { + ...Object.fromEntries(angular.map((name) => [name, rangeOf(name)])), + '@angular/platform-server': rangeOf('@angular/core'), + '@angular/router': rangeOf('@angular/core'), + '@angular/ssr': rangeOf('@angular/build'), + '@absaoss-cps/ngx-ui-watchtower': `file:${ngx.tarball}`, + rxjs: rangeOf('rxjs'), + tslib: rangeOf('tslib') + }, + devDependencies: { + '@angular/build': rangeOf('@angular/build'), + '@angular/cli': rangeOf('@angular/cli'), + '@angular/compiler-cli': rangeOf('@angular/compiler-cli'), + typescript: rangeOf('typescript') + }, + overrides + }, + 'angular' + ); + install(app); + check(copiesOf(app, '@angular/core') === 1, 'one copy of Angular'); + check( + copiesOf(app, '@absaoss-cps/ui-watchtower') === 1, + 'one copy of the core, installed with the Angular package' + ); + check( + !existsSync(join(app, 'node_modules', 'aws-rum-web')), + 'aws-rum-web not installed for an application without /rum' + ); + const fragment = buildAngular(app, 'fragment'); + check( + !fragment.sink.length && !fragment.client.length, + 'neither the RUM sink nor the RUM client in a build without /rum' + ); + step( + 'Angular fragment without aws-rum-web: built, prerendered, one Angular, one core' + ); + typeTests( + app, + join(ROOT, 'projects', 'ngx-ui-watchtower', 'type-tests'), + 'ngc' + ); + step( + 'Angular consumer type tests: every export, augmentation, compiled by ngc' + ); + + install(app, `aws-rum-web@${rangeOf('aws-rum-web')}`); + const shell = buildAngular(app, 'shell'); + const [client] = shell.client; + check( + shell.client.length === 1 && client !== 'main.js', + `the RUM client in a chunk of its own, not in main.js: ${shell.client}` + ); + check( + shell.main.includes(`import("./${client}")`) && + !shell.main.includes(`from"./${client}"`) && + !shell.page.includes(client), + 'the RUM client loaded on demand only, never up front' + ); + step( + 'Angular shell with /rum: built, prerendered, aws-rum-web loaded on demand' + ); + + if (KEEP) { + console.log(`smoke: consumers kept in ${WORK}`); + } else { + rmSync(WORK, { recursive: true, force: true }); + } + console.log('smoke: ok'); +} catch (error) { + console.error(error.stdout ?? '', error.stderr ?? ''); + console.error(error.message); + console.error(`smoke: consumers kept in ${WORK}`); + process.exitCode = 1; +} From 6e52219298451c3a4e9a75dab44e5b226c9a95f8 Mon Sep 17 00:00:00 2001 From: fateeand Date: Mon, 9 Nov 2026 22:48:18 +0100 Subject: [PATCH 08/21] ci: release and publish both packages with one linked version --- .github/workflows/checks.yml | 24 ++ .github/workflows/release-please.yml | 22 +- .release-please-manifest.json | 1 + README.md | 95 ++++-- package.json | 6 +- projects/ngx-ui-watchtower/DESIGN.md | 7 + release-please-config.json | 49 ++-- scripts/publish-packages.mjs | 195 +++++++++++++ scripts/publish-packages.test.mjs | 318 +++++++++++++++++++++ scripts/release-check.mjs | 412 +++++++++++++++++++++++++++ 10 files changed, 1079 insertions(+), 50 deletions(-) create mode 100644 scripts/publish-packages.mjs create mode 100644 scripts/publish-packages.test.mjs create mode 100644 scripts/release-check.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index fbe6838..1a6336b 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -108,6 +108,30 @@ jobs: - name: Smoke-test the packed packages run: npm run smoke + release: + name: Release config + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + # The release check compares bootstrap-sha with the tagged commit. + fetch-tags: true + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 22.x + + # Plain Node: neither needs the workspace's dependencies. + - name: Test the publish script and the release workflow's wiring + run: npm run test:release + + # Replays releases through the Release Please version the release + # workflow runs, on this repository's config. + - name: Check the release config + run: npm run release:check + lint: name: Lint and format runs-on: ubuntu-latest diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 8df3dbd..60c286c 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -4,17 +4,22 @@ on: push: branches: - main + # Retries a failed or partial publish: run it on the release tag, + # e.g. `gh workflow run release-please.yml --ref ngx-ui-watchtower-v0.5.0`. + workflow_dispatch: permissions: {} jobs: release-please: + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: write pull-requests: write outputs: - release_created: ${{ steps.release.outputs['projects/ngx-ui-watchtower--release_created'] }} + core_release_created: ${{ steps.release.outputs['projects/ui-watchtower--release_created'] }} + ngx_release_created: ${{ steps.release.outputs['projects/ngx-ui-watchtower--release_created'] }} steps: - uses: googleapis/release-please-action@v5 id: release @@ -26,7 +31,13 @@ jobs: publish: needs: release-please - if: ${{ needs.release-please.outputs.release_created == 'true' }} + # After a release of either package, or when run by hand to retry. On a + # manual run release-please is skipped, which !cancelled() lets through. + if: >- + ${{ !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.release-please.outputs.core_release_created == 'true' || + needs.release-please.outputs.ngx_release_created == 'true') }} runs-on: ubuntu-latest permissions: # Required for npm trusted publishing (OIDC). No npm token needed. @@ -48,9 +59,10 @@ jobs: - name: Install dependencies run: npm ci - - name: Build library + - name: Build both packages run: npm run build + # The core, then the Angular package: each only if its release tag + # points at this commit and the version isn't on npm yet. - name: Publish to npm - working-directory: dist/ngx-ui-watchtower - run: npm publish + run: node scripts/publish-packages.mjs diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 8c7507b..6c05ea8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,4 @@ { + "projects/ui-watchtower": "0.4.0", "projects/ngx-ui-watchtower": "0.4.0" } diff --git a/README.md b/README.md index 2059a80..c1ed8dc 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ projects/ngx-ui-watchtower/ README.md usage guide, published to npm DESIGN.md full design package.json the published package's manifest -scripts/ the public API snapshot, and the packaging smoke test +scripts/ the public API snapshot, the packaging smoke test, and publishing ``` The Angular package depends on the core, never the other way round. @@ -56,20 +56,22 @@ application does. Tests and the typecheck read the core's source. ## Scripts -| Script | What it does | -| ----------------------- | -------------------------------------------------------------- | -| `npm run build` | Builds both packages (production), the core first | -| `npm run watch` | Builds the core, then rebuilds the Angular package on change | -| `npm run test` | Runs the unit tests of both packages (Jest) | -| `npm run test:core` | Runs the core's unit tests only: plain Jest, no Angular | -| `npm run test:coverage` | Runs the unit tests with coverage, as CI does | -| `npm run typecheck` | Builds the core, then type-checks every tsconfig and type test | -| `npm run api:check` | Compares the built public APIs with their committed snapshots | -| `npm run api:update` | Writes those snapshots again, for an intended API change | -| `npm run smoke` | Installs the built packages, packed, into throwaway consumers | -| `npm run lint` | ESLint, no warnings allowed | -| `npm run format` | Formats with Prettier | -| `npm run format:check` | Checks formatting without writing | +| Script | What it does | +| ----------------------- | ---------------------------------------------------------------- | +| `npm run build` | Builds both packages (production), the core first | +| `npm run watch` | Builds the core, then rebuilds the Angular package on change | +| `npm run test` | Runs the unit tests of both packages (Jest), then `test:release` | +| `npm run test:core` | Runs the core's unit tests only: plain Jest, no Angular | +| `npm run test:release` | Tests the publish script and the release workflow's wiring | +| `npm run test:coverage` | Runs the unit tests with coverage, as CI does | +| `npm run typecheck` | Builds the core, then type-checks every tsconfig and type test | +| `npm run api:check` | Compares the built public APIs with their committed snapshots | +| `npm run api:update` | Writes those snapshots again, for an intended API change | +| `npm run smoke` | Installs the built packages, packed, into throwaway consumers | +| `npm run release:check` | Replays releases through Release Please on the release config | +| `npm run lint` | ESLint, no warnings allowed | +| `npm run format` | Formats with Prettier | +| `npm run format:check` | Checks formatting without writing | Four checks guard what consumers depend on: @@ -106,6 +108,7 @@ npm run format:check npm run build npm run api:check npm run smoke +npm run release:check ``` CI runs the same checks on every pull request and every push to `main`. @@ -124,15 +127,59 @@ Squash-merge pull requests: the title then becomes the commit message on ## Releases Releases are automated with [release-please](https://github.com/googleapis/release-please) -— don't publish by hand. - -1. Every push to `main` updates a release PR. `feat` bumps the minor version, - `fix`, `perf` and `revert` the patch; until 1.0, a breaking change bumps - the minor. Other types don't trigger a release. -2. The release PR holds the version bump and the `CHANGELOG.md` entry. -3. Merging it tags the release, creates the GitHub release and publishes the - package to npm from GitHub Actions through trusted publishing — no npm - token. +— don't publish by hand. Both packages share one version and always release +together: a change to either releases both. + +1. Every push to `main` updates one release PR. `feat` bumps the minor + version, `fix`, `perf` and `revert` the patch; until 1.0, a breaking change + bumps the minor. Other types don't trigger a release. +2. The release PR holds both version bumps, each package's `CHANGELOG.md` + entry, and the Angular package's dependency on the core, moved to `^` the + new version. +3. Merging it tags both releases, `ui-watchtower-v` and + `ngx-ui-watchtower-v` (until 0.4.0, the Angular package's tags + were `v`), and creates their GitHub releases. GitHub Actions then + publishes the core, and only after it the Angular package, through trusted + publishing — no npm token. + +**A failed publish** stops before the next package, so the Angular package +never depends on a core that isn't on npm. To finish a partial release, run +the workflow again on the release tag: + +```bash +gh workflow run release-please.yml --ref ngx-ui-watchtower-v +``` + +[`scripts/publish-packages.mjs`](scripts/publish-packages.mjs) publishes a +package only if its release tag points at that commit and npm doesn't have +the version yet; any other answer from npm stops it. + +**The core's first publish is by hand.** npm sets up trusted publishing only +for a package that exists. Before the first release PR is merged, an +`@absaoss-cps` owner publishes `@absaoss-cps/ui-watchtower` once, at the +version in `.release-please-manifest.json`, from `main`: + +```bash +npm login +npm ci && npm run build +npm publish dist/ui-watchtower +``` + +Then, on npmjs.com, in the package's settings, add the trusted publisher the +Angular package has: GitHub Actions, `AbsaOSS/ui-watchtower`, workflow +`release-please.yml`. If the release PR is merged first, publishing stops at +the core; do the above, then run the workflow on the release tag. + +`bootstrap-sha` in `release-please-config.json` is the commit tagged +`v0.4.0`: it keeps what that release shipped out of the first linked +changelogs, and is read only until both packages have a release. If another +`v` release reaches `main` first, set it to that release's commit. +`npm run release:check` compares the two. + +`npm run release:check` replays releases through the Release Please version +the workflow runs, on this config: one changed package releases both, the +dependency moves with them, and merging creates the tags publishing looks +for. It installs Release Please, so it needs a network. ## License diff --git a/package.json b/package.json index 2b85890..354a0d9 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,9 @@ "ng": "ng", "build": "ng build --configuration=production --project=ui-watchtower && ng build --configuration=production --project=ngx-ui-watchtower", "watch": "ng build ui-watchtower --configuration development && ng build ngx-ui-watchtower --watch --configuration development", - "test": "npm run test:core && ng test --project=ngx-ui-watchtower", + "test": "npm run test:core && ng test --project=ngx-ui-watchtower && npm run test:release", "test:core": "jest --config projects/ui-watchtower/jest.config.js", + "test:release": "node --test scripts/*.test.mjs", "test:coverage": "jest --config projects/ui-watchtower/jest.config.js --coverage --coverageReporters=text-summary --coverageReporters=lcov --json --outputFile=./coverage/ui-watchtower/report.json && ng test --project=ngx-ui-watchtower --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json", "lint": "eslint \"**/*.ts\" --max-warnings=0", "format": "prettier --write \"**/*.{ts,mjs,html}\"", @@ -14,7 +15,8 @@ "typecheck": "tsc -p projects/ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/rxjs/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ui-watchtower/type-tests/tsconfig.json && tsc -p projects/ui-watchtower/type-tests/tsconfig.augmented.json && ng build --configuration=production --project=ui-watchtower && tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.json && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json", "api:check": "node scripts/api-snapshot.mjs", "api:update": "node scripts/api-snapshot.mjs --update", - "smoke": "node scripts/smoke/smoke.mjs" + "smoke": "node scripts/smoke/smoke.mjs", + "release:check": "node scripts/release-check.mjs" }, "private": true, "dependencies": { diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index 1f0e8fd..f4ef413 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -245,6 +245,13 @@ providers: [ application, prerendered as a fragment without `aws-rum-web` — which then holds no RUM code — and as a shell with `/rum`, whose RUM client is a chunk loaded on demand. +- **Both packages share one version and release together.** Release Please + links their versions: a change to either releases both, and the same + release PR moves this package's dependency on the core to `^` the new + version. The release workflow publishes the core first and this package + only after it, so this package never depends on a core version npm doesn't + have. Publishing skips what npm already has, so running the workflow again + on the release tag completes a partial release. - **Test doubles live in the specs that use them**, declared inline, never in a shared file and never exported. The one browser gap needing a stub is `BroadcastChannel`, which jsdom doesn't implement. diff --git a/release-please-config.json b/release-please-config.json index 2da4450..552c6f8 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,27 +1,38 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "release-type": "node", - "include-component-in-tag": false, - "bootstrap-sha": "ebd38afab87af29f9ee27ecfa0eea90b81978881", + "bootstrap-sha": "1c05bd23b30ddf8105aab40ece85db32750cd2db", + "bump-minor-pre-major": true, + "changelog-path": "CHANGELOG.md", + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "style", "section": "Styles", "hidden": true }, + { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, + { "type": "refactor", "section": "Code Refactoring", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true } + ], "packages": { + "projects/ui-watchtower": { + "package-name": "@absaoss-cps/ui-watchtower", + "component": "ui-watchtower" + }, "projects/ngx-ui-watchtower": { - "release-type": "node", "package-name": "@absaoss-cps/ngx-ui-watchtower", - "changelog-path": "CHANGELOG.md", - "bump-minor-pre-major": true, - "changelog-sections": [ - { "type": "feat", "section": "Features" }, - { "type": "fix", "section": "Bug Fixes" }, - { "type": "perf", "section": "Performance Improvements" }, - { "type": "revert", "section": "Reverts" }, - { "type": "docs", "section": "Documentation", "hidden": true }, - { "type": "style", "section": "Styles", "hidden": true }, - { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, - { "type": "refactor", "section": "Code Refactoring", "hidden": true }, - { "type": "test", "section": "Tests", "hidden": true }, - { "type": "build", "section": "Build System", "hidden": true }, - { "type": "ci", "section": "Continuous Integration", "hidden": true } - ] + "component": "ngx-ui-watchtower" } - } + }, + "plugins": [ + { "type": "node-workspace", "merge": false }, + { + "type": "linked-versions", + "groupName": "ui-watchtower", + "components": ["ui-watchtower", "ngx-ui-watchtower"] + } + ] } diff --git a/scripts/publish-packages.mjs b/scripts/publish-packages.mjs new file mode 100644 index 0000000..135fe96 --- /dev/null +++ b/scripts/publish-packages.mjs @@ -0,0 +1,195 @@ +// Publishes the packages released at the checked-out commit: the core first, +// then the Angular package that depends on it. Run after `npm run build`; it +// publishes the built dist/ directories, never sources or the private root. +// +// A package is published only if its release tag points at this commit and +// its version isn't on npm yet, so a failed run can simply be run again on +// the release tag. Anything npm reports other than "not found" stops the run +// before any later package: a failed core is never followed by its adapter. +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** In publishing order: each package after those it depends on. */ +export const PACKAGES = [ + { + name: '@absaoss-cps/ui-watchtower', + path: 'projects/ui-watchtower', + component: 'ui-watchtower', + dist: 'dist/ui-watchtower' + }, + { + name: '@absaoss-cps/ngx-ui-watchtower', + path: 'projects/ngx-ui-watchtower', + component: 'ngx-ui-watchtower', + dist: 'dist/ngx-ui-watchtower', + dependsOn: '@absaoss-cps/ui-watchtower' + } +]; + +/** + * @param {object} options + * @param {string} options.root the repository root, holding the built dist/ + * @param {(command: string, args: string[], options?: {cwd?: string, inherit?: boolean}) => + * {status: number | null, stdout: string, stderr: string}} options.run runs a command + * @param {(line: string) => void} options.log prints progress + */ +export function publishPackages({ root, run, log }) { + const releases = PACKAGES.map((pkg) => { + const manifest = readBuiltManifest(root, pkg); + const tag = `${pkg.component}-v${manifest.version}`; + return { ...pkg, manifest, version: manifest.version, tag }; + }); + const head = git(run, ['rev-parse', 'HEAD']).trim(); + const here = releases.filter((release) => tagPointsAt(run, release, head)); + if (here.length === 0) { + throw new Error( + `no release tag points at ${head}: run this on a release tag` + ); + } + here.forEach((release) => checkDependencyRange(release, releases)); + + const onNpm = new Set(); + for (const release of releases) { + const label = `${release.name}@${release.version}`; + if (!here.includes(release)) { + log(`${label}: skipped, not released at this commit`); + continue; + } + if (isPublished(run, release.name, release.version)) { + log(`${label}: skipped, already on npm`); + } else { + checkDependencyOnNpm(run, release, releases, onNpm); + const result = run('npm', ['publish'], { + cwd: join(root, release.dist), + inherit: true + }); + if (result.status !== 0) { + throw new Error(`npm publish ${label} failed (exit ${result.status})`); + } + log(`${label}: published`); + } + onNpm.add(release.name); + } +} + +/** The built manifest, which must be the package's own: never the root's. */ +function readBuiltManifest(root, pkg) { + const manifest = JSON.parse( + readFileSync(join(root, pkg.dist, 'package.json'), 'utf8') + ); + if (manifest.name !== pkg.name || manifest.private) { + throw new Error(`${pkg.dist} is not the publishable ${pkg.name}`); + } + return manifest; +} + +function git(run, args) { + const result = run('git', args); + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${firstLine(result)}`); + } + return result.stdout; +} + +function tagPointsAt(run, release, head) { + const listing = git(run, [ + 'ls-remote', + '--tags', + 'origin', + `refs/tags/${release.tag}` + ]); + return listing.split('\n').some((line) => line.split('\t')[0] === head); +} + +/** Linked versions: the adapter depends on the core it was released with. */ +function checkDependencyRange(release, releases) { + if (!release.dependsOn) { + return; + } + const dependency = releases.find((other) => other.name === release.dependsOn); + const range = release.manifest.dependencies?.[dependency.name]; + if (range !== `^${dependency.version}`) { + throw new Error( + `${release.name}@${release.version} depends on ${dependency.name}@${range}, ` + + `not on ^${dependency.version}, the version released with it` + ); + } +} + +function checkDependencyOnNpm(run, release, releases, onNpm) { + const dependency = releases.find((other) => other.name === release.dependsOn); + if ( + dependency && + !onNpm.has(dependency.name) && + !isPublished(run, dependency.name, dependency.version) + ) { + throw new Error( + `${release.name}@${release.version} needs ` + + `${dependency.name}@${dependency.version}, which is not on npm` + ); + } +} + +/** On npm, or not found there; any other answer is an error, never a guess. */ +function isPublished(run, name, version) { + const spec = `${name}@${version}`; + const result = run('npm', [ + 'view', + spec, + 'version', + '--json', + '--prefer-online' + ]); + const output = parseJson(result.stdout); + if (result.status === 0 && output === version) { + return true; + } + if (result.status !== 0 && output?.error?.code === 'E404') { + return false; + } + const reason = output?.error + ? `${output.error.code}: ${output.error.summary}` + : firstLine(result); + throw new Error(`npm view ${spec} failed: ${reason}`); +} + +function parseJson(text) { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +function firstLine({ status, stdout, stderr }) { + return (stderr || stdout).trim().split('\n')[0] || `exit ${status}`; +} + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); + +function run(command, args, { cwd = ROOT, inherit = false } = {}) { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + stdio: inherit ? 'inherit' : 'pipe' + }); + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? String(result.error ?? '') + }; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + publishPackages({ root: ROOT, run, log: (line) => console.log(line) }); + } catch (error) { + console.error(`publish: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/scripts/publish-packages.test.mjs b/scripts/publish-packages.test.mjs new file mode 100644 index 0000000..4b2627d --- /dev/null +++ b/scripts/publish-packages.test.mjs @@ -0,0 +1,318 @@ +// Tests of the publish script, and of how the release workflow and config +// use it. Offline: git and npm are simulated. `npm run test:release`. +import assert from 'node:assert/strict'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { after, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { PACKAGES, publishPackages } from './publish-packages.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const [CORE, NGX] = PACKAGES.map((pkg) => pkg.name); +const HEAD = 'a1b2c3d'; +const CORE_TAG = 'ui-watchtower-v0.5.0'; +const NGX_TAG = 'ngx-ui-watchtower-v0.5.0'; + +const roots = []; +after(() => + roots.forEach((root) => rmSync(root, { recursive: true, force: true })) +); + +/** + * Both packages built at 0.5.0; `tags` point at HEAD, `elsewhere` at another + * commit; the registry holds `onNpm`. `failing` makes a command fail: + * `{ 'view ': '', 'publish ': true }`. + */ +function workspace({ + tags = [CORE_TAG, NGX_TAG], + elsewhere = [], + onNpm = [], + failing = {}, + range = '^0.5.0', + built = {} +} = {}) { + const root = mkdtempSync(join(tmpdir(), 'uwt-publish-')); + roots.push(root); + const manifests = { + [CORE]: { name: CORE, version: '0.5.0' }, + [NGX]: { name: NGX, version: '0.5.0', dependencies: { [CORE]: range } }, + ...built + }; + for (const pkg of PACKAGES) { + mkdirSync(join(root, pkg.dist), { recursive: true }); + writeFileSync( + join(root, pkg.dist, 'package.json'), + JSON.stringify(manifests[pkg.name]) + ); + } + + const registry = new Set(onNpm); + const published = []; + const commands = []; + const ok = (stdout) => ({ status: 0, stdout, stderr: '' }); + const npmError = (code) => ({ + status: 1, + stdout: JSON.stringify({ + error: { code, summary: `${code} from the registry` } + }), + stderr: '' + }); + const run = (command, args, { cwd } = {}) => { + commands.push([command, ...args].join(' ')); + if (command === 'git' && args[0] === 'rev-parse') { + return ok(`${HEAD}\n`); + } + if (command === 'git' && args[0] === 'ls-remote') { + const tag = args.at(-1).replace('refs/tags/', ''); + const sha = tags.includes(tag) + ? HEAD + : elsewhere.includes(tag) + ? 'f00ba47' + : ''; + return ok(sha ? `${sha}\trefs/tags/${tag}\n` : ''); + } + if (command === 'npm' && args[0] === 'view') { + const spec = args[1]; + if (failing[`view ${spec}`]) { + return npmError(failing[`view ${spec}`]); + } + return registry.has(spec) + ? ok(JSON.stringify(spec.split('@').pop())) + : npmError('E404'); + } + if (command === 'npm' && args[0] === 'publish') { + const { name, version } = JSON.parse( + readFileSync(join(cwd, 'package.json'), 'utf8') + ); + if (failing[`publish ${name}`]) { + return { status: 1, stdout: '', stderr: '' }; + } + registry.add(`${name}@${version}`); + published.push(name); + return ok(''); + } + throw new Error(`unexpected command: ${command} ${args.join(' ')}`); + }; + const lines = []; + return { + root, + run, + log: (line) => lines.push(line), + published, + commands, + lines + }; +} + +describe('publishing a release', () => { + it('publishes a first release, when the core has never been on npm: the core first', () => { + const release = workspace(); + + publishPackages(release); + + assert.deepEqual(release.published, [CORE, NGX]); + }); + + it('publishes a coordinated release of both packages, the core first', () => { + const release = workspace({ onNpm: [`${CORE}@0.4.0`, `${NGX}@0.4.0`] }); + + publishPackages(release); + + assert.deepEqual(release.published, [CORE, NGX]); + assert.deepEqual(release.lines, [ + `${CORE}@0.5.0: published`, + `${NGX}@0.5.0: published` + ]); + }); + + it('retries a partial release: skips the core already on npm, publishes the Angular package', () => { + const release = workspace({ onNpm: [`${CORE}@0.5.0`] }); + + publishPackages(release); + + assert.deepEqual(release.published, [NGX]); + assert.deepEqual(release.lines, [ + `${CORE}@0.5.0: skipped, already on npm`, + `${NGX}@0.5.0: published` + ]); + }); + + it('can run again after a complete release, publishing nothing', () => { + const release = workspace({ onNpm: [`${CORE}@0.5.0`, `${NGX}@0.5.0`] }); + + publishPackages(release); + + assert.deepEqual(release.published, []); + }); + + it('publishes nothing at a commit no release tag points at', () => { + const release = workspace({ tags: [], elsewhere: [CORE_TAG, NGX_TAG] }); + + assert.throws( + () => publishPackages(release), + /no release tag points at a1b2c3d/ + ); + assert.ok(!release.commands.some((command) => command.startsWith('npm'))); + }); + + it('never publishes the Angular package once the core fails to publish', () => { + const release = workspace({ failing: { [`publish ${CORE}`]: true } }); + + assert.throws( + () => publishPackages(release), + /npm publish @absaoss-cps\/ui-watchtower@0\.5\.0 failed/ + ); + assert.deepEqual(release.published, []); + assert.ok(!release.commands.some((command) => command.includes(NGX))); + }); + + it('stops on any npm answer other than not found, taking it for neither published nor missing', () => { + const release = workspace({ failing: { [`view ${CORE}@0.5.0`]: 'E401' } }); + + assert.throws( + () => publishPackages(release), + /npm view @absaoss-cps\/ui-watchtower@0\.5\.0 failed: E401/ + ); + assert.deepEqual(release.published, []); + }); + + it('publishes only the package released at this commit', () => { + const release = workspace({ + tags: [NGX_TAG], + elsewhere: [CORE_TAG], + onNpm: [`${CORE}@0.5.0`] + }); + + publishPackages(release); + + assert.deepEqual(release.published, [NGX]); + assert.equal( + release.lines[0], + `${CORE}@0.5.0: skipped, not released at this commit` + ); + }); + + it('does not publish the Angular package before the core version it needs is on npm', () => { + const release = workspace({ tags: [NGX_TAG], elsewhere: [CORE_TAG] }); + + assert.throws( + () => publishPackages(release), + /needs @absaoss-cps\/ui-watchtower@0\.5\.0, which is not on npm/ + ); + assert.deepEqual(release.published, []); + }); + + it('publishes nothing when the Angular package does not depend on the core released with it', () => { + const release = workspace({ range: '^0.4.0' }); + + assert.throws( + () => publishPackages(release), + /depends on @absaoss-cps\/ui-watchtower@\^0\.4\.0/ + ); + assert.deepEqual(release.published, []); + }); + + it('publishes nothing from a directory that is not the built package', () => { + const release = workspace({ + built: { [NGX]: { name: NGX, version: '0.5.0', private: true } } + }); + + assert.throws( + () => publishPackages(release), + /dist\/ngx-ui-watchtower is not the publishable/ + ); + assert.deepEqual(release.published, []); + }); +}); + +describe('the release wiring', () => { + const read = (path) => readFileSync(join(ROOT, path), 'utf8'); + const config = JSON.parse(read('release-please-config.json')); + const workflow = read('.github/workflows/release-please.yml'); + const releaseJob = workflow.slice( + workflow.indexOf('\n release-please:'), + workflow.indexOf('\n publish:') + ); + const publishJob = workflow.slice(workflow.indexOf('\n publish:')); + + it('publishes the packages Release Please releases, under the same names and tags', () => { + const manifest = JSON.parse(read('.release-please-manifest.json')); + const linked = config.plugins.find( + (plugin) => plugin.type === 'linked-versions' + ); + + for (const pkg of PACKAGES) { + assert.equal(config.packages[pkg.path].component, pkg.component); + assert.equal(config.packages[pkg.path]['package-name'], pkg.name); + assert.equal(JSON.parse(read(`${pkg.path}/package.json`)).name, pkg.name); + assert.ok(pkg.path in manifest, `${pkg.path} is in the manifest`); + assert.ok( + linked.components.includes(pkg.component), + `${pkg.component} is linked` + ); + assert.match(pkg.dist, /^dist\//); + } + assert.equal(JSON.parse(read('package.json')).private, true); + }); + + it('runs Release Please on pushes, and exposes each package release', () => { + assert.match(releaseJob, /if: github\.event_name == 'push'/); + for (const pkg of PACKAGES) { + assert.ok( + releaseJob.includes( + `steps.release.outputs['${pkg.path}--release_created']` + ) + ); + } + }); + + it('publishes after a release of either package, or when run by hand to retry', () => { + const condition = publishJob.slice( + publishJob.indexOf('if:'), + publishJob.indexOf('runs-on:') + ); + const terms = condition.replace(/\s+/g, ' '); + + assert.match(workflow, /\n {2}workflow_dispatch:/); + assert.ok(terms.includes('!cancelled()')); + assert.ok(terms.includes("github.event_name == 'workflow_dispatch'")); + assert.ok( + terms.includes( + "needs.release-please.outputs.core_release_created == 'true'" + ) + ); + assert.ok( + terms.includes( + "needs.release-please.outputs.ngx_release_created == 'true'" + ) + ); + }); + + it('builds, then publishes through this script, with trusted publishing and no cache', () => { + const steps = [ + 'run: npm ci', + 'run: npm run build', + 'run: node scripts/publish-packages.mjs' + ]; + const positions = steps.map((step) => publishJob.indexOf(step)); + + assert.ok( + positions.every((position) => position > 0), + 'every step is there' + ); + assert.deepEqual( + [...positions].sort((a, b) => a - b), + positions + ); + assert.match(publishJob, /id-token: write/); + assert.match(publishJob, /package-manager-cache: false/); + assert.doesNotMatch(publishJob, /npm publish/); + }); +}); diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs new file mode 100644 index 0000000..5b3bab0 --- /dev/null +++ b/scripts/release-check.mjs @@ -0,0 +1,412 @@ +// Replays releases through Release Please on this repository's release config, +// as the release workflow runs it: what each release PR changes, and which +// releases merging it creates. GitHub is simulated in memory; Release Please +// is installed, at the version the workflow's action bundles, into a +// temporary directory, so this needs a network. `npm run release:check`. +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PACKAGES } from './publish-packages.mjs'; + +/** What googleapis/release-please-action@v5 (5.0.0) bundles: keep in step. */ +const RELEASE_PLEASE = '17.6.0'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const WORK = mkdtempSync(join(tmpdir(), 'uwt-release-check-')); +const [CORE, NGX] = PACKAGES; +const read = (path) => readFileSync(join(ROOT, path), 'utf8'); +const config = JSON.parse(read('release-please-config.json')); +const manifest = JSON.parse(read('.release-please-manifest.json')); + +function check(condition, what) { + if (!condition) { + throw new Error(`release-check: expected ${what}`); + } +} + +/** A repository holding `files` on main, its releases, commits (newest first) and merged PRs. */ +function gitHub({ files, releases = [], commits = [], pulls = [] }, Errors) { + const contents = (path) => { + if (!(path in files)) { + throw new Errors.FileNotFoundError(path); + } + return { + sha: path, + content: '', + parsedContent: files[path], + mode: '100644' + }; + }; + const yieldAll = (items) => + async function* () { + yield* items; + }; + return { + repository: { + owner: 'AbsaOSS', + repo: 'ui-watchtower', + defaultBranch: 'main' + }, + getFileContentsOnBranch: async (path) => contents(path), + getFileJson: async (path) => JSON.parse(contents(path).parsedContent), + releaseIterator: yieldAll(releases), + tagIterator: yieldAll( + releases.map((release) => ({ name: release.tagName, sha: release.sha })) + ), + mergeCommitIterator: yieldAll(commits), + pullRequestIterator: yieldAll(pulls), + findFilesByFilenameAndRef: async () => [], + findFilesByGlobAndRef: async () => [] + }; +} + +const release = (tagName, sha) => ({ + name: tagName, + tagName, + sha, + notes: '', + url: '' +}); +const commit = (sha, message, ...files) => ({ sha, message, files }); +const file = (pkg, name = 'src/index.ts') => `${pkg.path}/${name}`; +const json = (files, path) => JSON.parse(files[path]); + +/** The release files at one linked `version`: what a release leaves behind. */ +function filesAt(version) { + const files = { + 'release-please-config.json': read('release-please-config.json'), + '.release-please-manifest.json': JSON.stringify( + Object.fromEntries(PACKAGES.map((pkg) => [pkg.path, version])) + ) + }; + for (const pkg of PACKAGES) { + const packageJson = JSON.parse(read(`${pkg.path}/package.json`)); + packageJson.version = version; + if (pkg.dependsOn) { + packageJson.dependencies[pkg.dependsOn] = `^${version}`; + } + files[`${pkg.path}/package.json`] = JSON.stringify(packageJson, null, 2); + } + return files; +} + +/** The repository after merging `pullRequest`. */ +function merge(files, pullRequest) { + const merged = { ...files }; + for (const { path, createIfMissing, updater } of pullRequest.updates) { + if (path in merged || createIfMissing) { + merged[path] = updater.updateContent(merged[path]); + } + } + return merged; +} + +/** + * Only keys Release Please defines: in its schema, or read by its config + * parser (the schema leaves a few out, `component` and `package-name` among + * them). A misspelt key is in neither, and would be silently ignored. + */ +function checkConfigKeys(configSchema, parser) { + const parsed = [...parser.matchAll(/config\['([\w-]+)'\]/g)].map( + (match) => match[1] + ); + const known = (options, defined, where) => { + const keys = new Set([...Object.keys(defined), ...parsed]); + const unknown = Object.keys(options).filter((key) => !keys.has(key)); + check(unknown.length === 0, `only known keys ${where}, not ${unknown}`); + }; + known(config, configSchema.properties, 'in the config'); + for (const [path, options] of Object.entries(config.packages)) { + known( + options, + configSchema.definitions.ReleaserConfigOptions.properties, + `for ${path}` + ); + } + const plugins = configSchema.allOf[1].properties.plugins.items.anyOf; + for (const plugin of config.plugins) { + const variant = plugins.find((option) => + option.properties?.type?.enum?.includes(plugin.type) + ); + check(variant, `a plugin Release Please defines, not ${plugin.type}`); + const unknown = Object.keys(plugin).filter( + (key) => !(key in variant.properties) + ); + check( + unknown.length === 0, + `only keys the ${plugin.type} plugin defines, not ${unknown}` + ); + } +} + +async function main() { + writeFileSync(join(WORK, 'package.json'), '{ "private": true }'); + execFileSync( + 'npm', + [ + 'install', + `release-please@${RELEASE_PLEASE}`, + '--no-audit', + '--no-fund', + '--loglevel=error' + ], + { cwd: WORK, stdio: 'inherit' } + ); + const require = createRequire(join(WORK, 'package.json')); + const { + Errors, + Manifest, + configSchema, + setLogger + } = require('release-please'); + const parser = readFileSync( + require.resolve('release-please/build/src/manifest.js'), + 'utf8' + ); + const silent = () => {}; + setLogger({ + error: silent, + warn: silent, + info: silent, + debug: silent, + trace: silent + }); + + const manifestOf = (state) => + Manifest.fromManifest( + gitHub(state, Errors), + 'main', + 'release-please-config.json', + '.release-please-manifest.json' + ); + /** The release PR Release Please would open, if any. */ + const pullRequestFor = async (state) => { + const pullRequests = await (await manifestOf(state)).buildPullRequests(); + check( + pullRequests.length <= 1, + `one release PR at most, got ${pullRequests.length}` + ); + return pullRequests[0]; + }; + /** After the PR: each package's version, and the Angular package's core range. */ + const outcome = (files, pullRequest) => { + const merged = merge(files, pullRequest); + const versions = Object.fromEntries( + PACKAGES.map((pkg) => [ + pkg.component, + json(merged, `${pkg.path}/package.json`).version + ]) + ); + const range = json(merged, `${NGX.path}/package.json`).dependencies[ + CORE.name + ]; + return { merged, versions, range }; + }; + const linkedAt = (version, ...changes) => ({ + files: filesAt(version), + releases: PACKAGES.map((pkg) => + release(`${pkg.component}-v${version}`, 'r1') + ), + commits: [ + ...changes, + commit('r1', 'chore: release main', file(CORE, 'package.json')) + ] + }); + + checkConfigKeys(configSchema, parser); + const current = manifest[CORE.path]; + check( + PACKAGES.every((pkg) => manifest[pkg.path] === current), + 'both packages at one version in the manifest' + ); + console.log( + `✔ config: only keys Release Please defines; both packages at ${current}` + ); + + // The first linked release, replayed from the workspace as it is: the core + // never released, the Angular package's last release tagged v + // (before linking) at bootstrap-sha, which keeps what that release already + // shipped out of the new changelogs. While git has that tag, bootstrap-sha + // must be its commit; once the packages are linked, the tag is gone. + const lastTagged = spawnSync( + 'git', + ['rev-parse', '--verify', '--quiet', `v${current}^{commit}`], + { cwd: ROOT, encoding: 'utf8' } + ).stdout?.trim(); + if (lastTagged) { + check( + config['bootstrap-sha'] === lastTagged, + `bootstrap-sha to be ${lastTagged}, the commit tagged v${current}` + ); + } + const first = { + files: { + 'release-please-config.json': read('release-please-config.json'), + '.release-please-manifest.json': read('.release-please-manifest.json'), + [`${CORE.path}/package.json`]: read(`${CORE.path}/package.json`), + [`${NGX.path}/package.json`]: read(`${NGX.path}/package.json`), + [`${NGX.path}/CHANGELOG.md`]: read(`${NGX.path}/CHANGELOG.md`) + }, + releases: [release(`v${current}`, config['bootstrap-sha'])], + commits: [ + commit( + 'c3', + 'feat: add the framework-independent engine', + file(CORE), + file(NGX) + ), + commit('c2', 'fix: flush logs on page hide', file(NGX)), + commit('c1', 'feat: document the workspace', 'README.md'), + commit( + config['bootstrap-sha'], + `chore(main): release ${current}`, + file(NGX, 'package.json') + ), + commit('c0', 'feat: a feature already released', file(NGX)) + ] + }; + const [major, minor] = current.split('.'); + const next = `${major}.${Number(minor) + 1}.0`; + const firstPullRequest = await pullRequestFor(first); + check(firstPullRequest, 'a release PR for the first linked release'); + const firstRelease = outcome(first.files, firstPullRequest); + // Each package's new changelog entry: from its heading to the one before. + const changelogs = PACKAGES.map((pkg) => { + const changelog = firstRelease.merged[`${pkg.path}/CHANGELOG.md`] ?? ''; + const start = changelog.indexOf(`## [${next}]`); + const end = changelog.indexOf('\n## ', start + 1); + return start < 0 ? '' : changelog.slice(start, end < 0 ? undefined : end); + }); + check( + firstRelease.versions[CORE.component] === next && + firstRelease.versions[NGX.component] === next, + `both packages released at ${next}, got ${JSON.stringify(firstRelease.versions)}` + ); + check( + firstRelease.range === `^${next}`, + `the Angular package on ^${next}, got ${firstRelease.range}` + ); + check( + PACKAGES.every( + (pkg) => + json(firstRelease.merged, '.release-please-manifest.json')[pkg.path] === + next + ), + `the manifest at ${next}` + ); + check(changelogs.every(Boolean), `a ${next} entry in both changelogs`); + check( + changelogs[0].includes('add the framework-independent engine') && + !changelogs[0].includes('flush logs on page hide'), + "only the core's own changes in its changelog" + ); + check( + changelogs[1].includes('flush logs on page hide') && + !changelogs.some((changelog) => changelog.includes('already released')), + 'nothing from the release before in the new entries' + ); + console.log( + `✔ first linked release: both packages ${current} → ${next}, the Angular package on ^${next}, two changelogs` + + (lastTagged ? `, from v${current}'s commit` : '') + ); + + for (const [what, change, expected] of [ + [ + 'an Angular-only fix', + commit('d1', 'fix: an Angular-only fix', file(NGX)), + '0.5.1' + ], + [ + 'a core-only feature', + commit('d1', 'feat: a core-only feature', file(CORE)), + '0.6.0' + ], + [ + 'a breaking change before 1.0', + commit('d1', 'feat!: drop an option', file(CORE)), + '0.6.0' + ] + ]) { + const state = linkedAt('0.5.0', change); + const pullRequest = await pullRequestFor(state); + check(pullRequest, `a release PR for ${what}`); + const { versions, range } = outcome(state.files, pullRequest); + check( + versions[CORE.component] === expected && + versions[NGX.component] === expected, + `${what} to release both packages at ${expected}, got ${JSON.stringify(versions)}` + ); + check( + range === `^${expected}`, + `the Angular package on ^${expected} after ${what}, got ${range}` + ); + console.log(`✔ ${what} releases both packages: 0.5.0 → ${expected}`); + } + + const quiet = linkedAt( + '0.5.0', + commit('e4', 'docs: explain a setting', file(CORE, 'README.md')), + commit('e3', 'refactor: tidy the engine', file(CORE)), + commit('e2', 'test: cover a case', file(NGX, 'src/a.spec.ts')), + commit('e1', 'feat: a workspace script', 'scripts/tool.mjs') + ); + check( + !(await pullRequestFor(quiet)), + 'no release PR without a user-facing change to a package' + ); + console.log( + '✔ no release from docs, refactors or tests, nor from changes outside the packages' + ); + + const merged = merge(first.files, firstPullRequest); + const releases = await ( + await manifestOf({ + files: merged, + releases: first.releases, + commits: [ + commit('m1', firstPullRequest.title.toString()), + ...first.commits + ], + pulls: [ + { + headBranchName: firstPullRequest.headRefName, + baseBranchName: 'main', + number: 1, + title: firstPullRequest.title.toString(), + body: firstPullRequest.body.toString(), + labels: firstPullRequest.labels, + files: [], + sha: 'm1' + } + ] + }) + ).buildReleases(); + const created = releases + .map((created) => `${created.path} ${created.tag} ${created.sha}`) + .sort(); + const expected = PACKAGES.map( + (pkg) => `${pkg.path} ${pkg.component}-v${next} m1` + ).sort(); + check( + JSON.stringify(created) === JSON.stringify(expected), + `merging it to create ${expected.join(', ')}; got ${created.join(', ')}` + ); + console.log( + `✔ merging the release PR creates ${PACKAGES.map((pkg) => `${pkg.component}-v${next}`).join(' and ')} at the merge commit: the tags publishing looks for` + ); +} + +try { + await main(); + console.log('release-check: ok'); +} catch (error) { + console.error( + error.message.startsWith('release-check:') ? error.message : error + ); + process.exitCode = 1; +} finally { + rmSync(WORK, { recursive: true, force: true }); +} From 941c062f03e0f8e19979998e9e04efc4c9eb844d Mon Sep 17 00:00:00 2001 From: fateeand Date: Mon, 9 Nov 2026 23:03:08 +0100 Subject: [PATCH 09/21] docs: document the core, who owns what, and writing adapters --- README.md | 6 +- projects/ngx-ui-watchtower/DESIGN.md | 11 +++- projects/ngx-ui-watchtower/README.md | 11 ++++ projects/ui-watchtower/README.md | 85 ++++++++++++++++++++++++++-- 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c1ed8dc..8daf4f2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ logs, with an optional AWS CloudWatch RUM sink. It builds on framework-independent engine, which lives here too. How to use the library: [projects/ngx-ui-watchtower/README.md](projects/ngx-ui-watchtower/README.md) -(also the npm page). +(also the npm page). Without Angular, and to write an adapter for another +framework: [projects/ui-watchtower/README.md](projects/ui-watchtower/README.md). How it is designed: [projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watchtower/DESIGN.md). ## Layout @@ -38,7 +39,8 @@ projects/ngx-ui-watchtower/ scripts/ the public API snapshot, the packaging smoke test, and publishing ``` -The Angular package depends on the core, never the other way round. +The Angular package depends on the core, never the other way round. Both +share one version and are released together (see [Releases](#releases)). ## Getting started diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index f4ef413..fa572d6 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -210,7 +210,10 @@ providers: [ the same names. The dependency runs one way: the core imports neither Angular nor this package, which a lint rule enforces. The library build compiles against the core's built declarations, not its source, so it sees - the core exactly as an application does. + the core exactly as an application does. An application upgrading from + 0.4 changes nothing. Another framework's adapter needs only the core's + public entry points, as this package does; the core's README has a short + guide. - **What the core owns, this package re-exports.** The engine, the models and name registries, the configuration types and defaults (`uwtResolveTelemetryConfig` completes every `with*` feature), redaction, @@ -2205,6 +2208,12 @@ providers: [ Code inside a fragment does not change: same services, same calls. One session, one budget, one bundle. +| Realm | Destination | RUM and `UWT_RUM_CREDENTIALS_PROVIDER` | `UWT_LOG_API_PROVIDER` | +| -------------- | --------------------------------------------------- | -------------------------------------- | -------------------------------------- | +| Standalone app | Its own: `provideUwtTelemetryRumSink()`, or another | The app's | The app's | +| Shell | Its own, plus `provideUwtTelemetryBroadcastHost()` | The shell's: the only ones on the page | The shell's, also for fragments' logs | +| Fragment | `provideUwtTelemetrySink('broadcast')` | None: the shell's records for it | Bound by `'broadcast'`, to the shell's | + Either side may be built without Angular. The protocol — the messages, the host, the forwarding sink and log provider — lives in `@absaoss-cps/ui-watchtower`, and this package's classes are injectable diff --git a/projects/ngx-ui-watchtower/README.md b/projects/ngx-ui-watchtower/README.md index dbe32a6..84d952d 100644 --- a/projects/ngx-ui-watchtower/README.md +++ b/projects/ngx-ui-watchtower/README.md @@ -31,6 +31,9 @@ Requires Angular 21 or 22 and RxJS 7.8. It brings the framework-independent engine it builds on, with it: there is nothing more to install or configure. +Upgrading from 0.4 needs no code or configuration change: the imports, +providers, services, name declarations and behavior stay the same. + ## Setup ```ts @@ -351,6 +354,14 @@ providers: [ ]; ``` +Who owns what: + +| Realm | Destination | RUM and its credentials provider | Log backend | +| -------------- | --------------------------------------------------- | -------------------------------------- | ------------------------------------- | +| Standalone app | Its own: `provideUwtTelemetryRumSink()`, or another | The app's | The app's | +| Shell | Its own, plus `provideUwtTelemetryBroadcastHost()` | The shell's: the only ones on the page | The shell's, also for fragments' logs | +| Fragment | `provideUwtTelemetrySink('broadcast')` | None: the shell's records for it | None: `'broadcast'` forwards them | + Fragment code doesn't change. Give each fragment its own `application` name. A shell or a fragment needn't be Angular: one built on [`@absaoss-cps/ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ui-watchtower) diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md index ec808bf..8cf8503 100644 --- a/projects/ui-watchtower/README.md +++ b/projects/ui-watchtower/README.md @@ -55,7 +55,7 @@ Configuration errors still throw, when the engine is created. | Option | What it does | | ----------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `identity` | Application, environment and version, carried by every record | +| `identity` | Application, environment, version and optional `eventNamespace`, carried by every record | | `destination` | A `UwtTelemetrySink`: where scenarios, BI events and mirrored errors go | | `logApi` | A `UwtLogApiProvider`: where logs go, and are read back from | | `scenarios`, `logging`, `bi`, `redaction` | Overrides of each concern's defaults | @@ -69,6 +69,21 @@ 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. +### Turning telemetry off + +Keep the same calls and pass one switch: + +```ts +const telemetry = uwtCreateTelemetry({ ...options, enabled: false }); +``` + +When off, nothing is recorded, sent or logged, and nothing listens to the +page. Your code doesn't change: `start()` still returns a scenario (inert: +it records nothing and never settles), lookups find nothing, loggers do +nothing and `query()` resolves `[]`. The RUM sink and the broadcast classes +take the same `enabled`: pass it to them too, and off they request no +credentials, download no SDK and open no channel. + ## AWS CloudWatch RUM The RUM destination lives in its own entry point, so the main one never @@ -120,9 +135,20 @@ const telemetry = uwtCreateTelemetry({ ``` Either side can be an Angular application using -`@absaoss-cps/ngx-ui-watchtower`; they exchange the same messages. Dispose -the host, the sink, the log provider and the connection when their realm goes -away. +`@absaoss-cps/ngx-ui-watchtower`; they exchange the same messages. Give each +fragment its own `application` name, and the shell's `eventNamespace`. + +Who owns what: + +| Realm | Destination | RUM client and credentials | Log store | +| -------------- | ----------------------------------------------- | ----------------------------------- | ------------------------------------------------- | +| Standalone app | Its own: `UwtRumTelemetrySink`, or another sink | The app's | The app's | +| Shell | Its own, plus a `UwtTelemetryBroadcastHost` | The shell's: the only one on a page | The shell's, which also stores fragments' records | +| Fragment | `UwtBroadcastTelemetrySink` | None: the shell's records for it | None: `UwtBroadcastLogApiProvider` forwards | + +A fragment reports the shell's session and user. Whoever creates something +disposes it: dispose the engine, then the host, the sink, the log provider +and the connection, when their realm goes away. ## Declare your names @@ -178,6 +204,51 @@ import { traceScenario } from '@absaoss-cps/ui-watchtower/rxjs'; customers$.pipe(traceScenario(scenario)).subscribe(); ``` +## Writing an adapter for another framework + +`@absaoss-cps/ngx-ui-watchtower` is one adapter. Another framework's needs +only the public entry points above: no deep import, no private file. + +1. **One engine per application, or per fragment.** Create it with + `uwtCreateTelemetry` at startup, from the framework's configuration, and + share it through the framework's context or dependency injection, never + one per component. If the framework resolves each concern on its own, as + Angular's DI does, construct `UwtScenarioTelemetry`, `UwtBITelemetry` and + `UwtLogTelemetry` instead, from `uwtResolveTelemetryConfig(overrides)`, + one destination and one log store. +2. **The platform.** Pass `document: null` when rendering on a server, and + `runOutside` if the framework waits for pending timers before it is + stable, as Angular's zone does. +3. **Failures.** Pass a `report`, such as `uwtConsoleReporter` in + development. Telemetry calls never throw; `uwtCreateTelemetry` does, when + the identity, destination or log store is missing. +4. **Reactive APIs.** Adapt `scenarios.onSettled(listener)`, which returns + an unsubscribe function, to the framework's primitive: an Observable, a + signal, a store. +5. **Destinations.** Accept any `UwtTelemetrySink`, and offer the core's: + the RUM sink, started with an unawaited `init()`; for a fragment, the + broadcast sink and log provider sharing one `uwtConnectBroadcastChannel()`; + for a shell, a `UwtTelemetryBroadcastHost`. Pass each the engine's + `enabled`. +6. **Teardown.** When the application or fragment is destroyed, dispose the + engine and then what you created for it. Every `dispose()` can be called + more than once. +7. **Names.** Re-export the four registries, so applications augment your + package's module path: + + ```ts + export type { + UwtBIEventNames, + UwtLoggerNames, + UwtScenarioNames, + UwtScenarioSteps + } from '@absaoss-cps/ui-watchtower'; + ``` + +8. **The dependency.** Depend on `@absaoss-cps/ui-watchtower` with a caret + range, as a dependency rather than a peer, so installing the adapter + installs the core. + ## API | Group | Exports | @@ -195,6 +266,12 @@ customers$.pipe(traceScenario(scenario)).subscribe(); | `/rxjs` | `traceScenario`, `UwtTraceScenarioOptions` | | `/rum` | `UwtRumTelemetrySink` and its options; `UwtRumCredentialsProvider`, `UwtRumBootstrap`, `UwtRumAppMonitorConfig`, `UwtRumCredentials` | +## Versions + +This package and `@absaoss-cps/ngx-ui-watchtower` are released together, +always at the same version. The Angular package depends on this one, never +the other way round, and installs it for you. + ## License Apache-2.0 From 246c7b23b6a1bb305613b0d5a81bcbd93de88a4d Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 10 Nov 2026 12:17:12 +0100 Subject: [PATCH 10/21] fix: accept annotated release tags, and document the shared connection's off switch --- projects/ui-watchtower/README.md | 10 +++++---- scripts/publish-packages.mjs | 12 +++++------ scripts/publish-packages.test.mjs | 35 ++++++++++++++++++++++++++----- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md index 8cf8503..2ebb7c1 100644 --- a/projects/ui-watchtower/README.md +++ b/projects/ui-watchtower/README.md @@ -80,9 +80,11 @@ const telemetry = uwtCreateTelemetry({ ...options, enabled: false }); When off, nothing is recorded, sent or logged, and nothing listens to the page. Your code doesn't change: `start()` still returns a scenario (inert: it records nothing and never settles), lookups find nothing, loggers do -nothing and `query()` resolves `[]`. The RUM sink and the broadcast classes -take the same `enabled`: pass it to them too, and off they request no -credentials, download no SDK and open no channel. +nothing and `query()` resolves `[]`. Pass the switch on to what you created +for the engine, so that off it requests no credentials, downloads no SDK and +opens no channel: `enabled` to the RUM sink and the broadcast classes, and +`open` to `uwtConnectBroadcastChannel()`. A sink and log provider sharing a +connection ignore their own `enabled`: the connection decides. ## AWS CloudWatch RUM @@ -229,7 +231,7 @@ only the public entry points above: no deep import, no private file. the RUM sink, started with an unawaited `init()`; for a fragment, the broadcast sink and log provider sharing one `uwtConnectBroadcastChannel()`; for a shell, a `UwtTelemetryBroadcastHost`. Pass each the engine's - `enabled`. + `enabled`, and the shared connection `open: enabled`. 6. **Teardown.** When the application or fragment is destroyed, dispose the engine and then what you created for it. Every `dispose()` can be called more than once. diff --git a/scripts/publish-packages.mjs b/scripts/publish-packages.mjs index 135fe96..5b47d10 100644 --- a/scripts/publish-packages.mjs +++ b/scripts/publish-packages.mjs @@ -93,13 +93,13 @@ function git(run, args) { return result.stdout; } +/** + * A lightweight tag is listed as its commit; an annotated one as its tag + * object, and as `^{}` the commit it points at, listed only when asked for. + */ function tagPointsAt(run, release, head) { - const listing = git(run, [ - 'ls-remote', - '--tags', - 'origin', - `refs/tags/${release.tag}` - ]); + const ref = `refs/tags/${release.tag}`; + const listing = git(run, ['ls-remote', '--tags', 'origin', ref, `${ref}^{}`]); return listing.split('\n').some((line) => line.split('\t')[0] === head); } diff --git a/scripts/publish-packages.test.mjs b/scripts/publish-packages.test.mjs index 4b2627d..4a13200 100644 --- a/scripts/publish-packages.test.mjs +++ b/scripts/publish-packages.test.mjs @@ -26,12 +26,13 @@ after(() => ); /** - * Both packages built at 0.5.0; `tags` point at HEAD, `elsewhere` at another - * commit; the registry holds `onNpm`. `failing` makes a command fail: + * Both packages built at 0.5.0; `tags` point at HEAD, `annotated` too as + * annotated tags, `elsewhere` at another commit; the registry holds `onNpm`. `failing` makes a command fail: * `{ 'view ': '', 'publish ': true }`. */ function workspace({ tags = [CORE_TAG, NGX_TAG], + annotated = [], elsewhere = [], onNpm = [], failing = {}, @@ -70,13 +71,29 @@ function workspace({ return ok(`${HEAD}\n`); } if (command === 'git' && args[0] === 'ls-remote') { - const tag = args.at(-1).replace('refs/tags/', ''); - const sha = tags.includes(tag) + // As git lists them: a lightweight tag as its commit, an annotated one + // as its tag object and then `^{}` its commit; only the refs asked for. + const asked = args.slice(3); + const tag = asked[0].replace('refs/tags/', '').replace(/\^\{\}$/, ''); + const sha = [...tags, ...annotated].includes(tag) ? HEAD : elsewhere.includes(tag) ? 'f00ba47' : ''; - return ok(sha ? `${sha}\trefs/tags/${tag}\n` : ''); + const listed = !sha + ? [] + : annotated.includes(tag) + ? [ + ['7a9c0b1', `refs/tags/${tag}`], + [sha, `refs/tags/${tag}^{}`] + ] + : [[sha, `refs/tags/${tag}`]]; + return ok( + listed + .filter(([, ref]) => asked.includes(ref)) + .map(([at, ref]) => `${at}\t${ref}\n`) + .join('') + ); } if (command === 'npm' && args[0] === 'view') { const spec = args[1]; @@ -152,6 +169,14 @@ describe('publishing a release', () => { assert.deepEqual(release.published, []); }); + it('publishes on annotated release tags, as on lightweight ones', () => { + const release = workspace({ tags: [], annotated: [CORE_TAG, NGX_TAG] }); + + publishPackages(release); + + assert.deepEqual(release.published, [CORE, NGX]); + }); + it('publishes nothing at a commit no release tag points at', () => { const release = workspace({ tags: [], elsewhere: [CORE_TAG, NGX_TAG] }); From 2147f2187675db55b748ae36cf47af3b4ef9f06c Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 10 Nov 2026 15:26:08 +0100 Subject: [PATCH 11/21] logos update --- README.md | 4 ++-- assets/logo/logo-stacked-auto.svg | 2 +- assets/logo/logo-stacked-dark.svg | 2 +- assets/logo/logo-stacked-light.svg | 2 +- assets/logo/ngx-logo-stacked-auto.svg | 1 + assets/logo/ngx-logo-stacked-dark.svg | 1 + assets/logo/ngx-logo-stacked-light.svg | 1 + projects/ngx-ui-watchtower/README.md | 6 +++--- projects/ui-watchtower/README.md | 8 ++++++++ 9 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 assets/logo/ngx-logo-stacked-auto.svg create mode 100644 assets/logo/ngx-logo-stacked-dark.svg create mode 100644 assets/logo/ngx-logo-stacked-light.svg diff --git a/README.md b/README.md index 8daf4f2..21c45be 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ - Angular UI Watchtower + UI Watchtower

-# Angular UI Watchtower +# UI Watchtower Source of [`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower), a telemetry library for Angular apps: scenarios (user end-to-end journeys or specific functional workflows through an app), BI events and diff --git a/assets/logo/logo-stacked-auto.svg b/assets/logo/logo-stacked-auto.svg index ff6f3a4..ee0fc8f 100644 --- a/assets/logo/logo-stacked-auto.svg +++ b/assets/logo/logo-stacked-auto.svg @@ -1 +1 @@ -Angular UI Watchtower +UI Watchtower diff --git a/assets/logo/logo-stacked-dark.svg b/assets/logo/logo-stacked-dark.svg index 2231b0f..5659080 100644 --- a/assets/logo/logo-stacked-dark.svg +++ b/assets/logo/logo-stacked-dark.svg @@ -1 +1 @@ -Angular UI Watchtower +UI Watchtower diff --git a/assets/logo/logo-stacked-light.svg b/assets/logo/logo-stacked-light.svg index 3191bf6..d4a894e 100644 --- a/assets/logo/logo-stacked-light.svg +++ b/assets/logo/logo-stacked-light.svg @@ -1 +1 @@ -Angular UI Watchtower +UI Watchtower diff --git a/assets/logo/ngx-logo-stacked-auto.svg b/assets/logo/ngx-logo-stacked-auto.svg new file mode 100644 index 0000000..53ea0e8 --- /dev/null +++ b/assets/logo/ngx-logo-stacked-auto.svg @@ -0,0 +1 @@ +Angular UI Watchtower diff --git a/assets/logo/ngx-logo-stacked-dark.svg b/assets/logo/ngx-logo-stacked-dark.svg new file mode 100644 index 0000000..82f8618 --- /dev/null +++ b/assets/logo/ngx-logo-stacked-dark.svg @@ -0,0 +1 @@ +Angular UI Watchtower diff --git a/assets/logo/ngx-logo-stacked-light.svg b/assets/logo/ngx-logo-stacked-light.svg new file mode 100644 index 0000000..ff137bb --- /dev/null +++ b/assets/logo/ngx-logo-stacked-light.svg @@ -0,0 +1 @@ +Angular UI Watchtower diff --git a/projects/ngx-ui-watchtower/README.md b/projects/ngx-ui-watchtower/README.md index 84d952d..9c9e245 100644 --- a/projects/ngx-ui-watchtower/README.md +++ b/projects/ngx-ui-watchtower/README.md @@ -1,8 +1,8 @@

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

diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md index 2ebb7c1..8edf187 100644 --- a/projects/ui-watchtower/README.md +++ b/projects/ui-watchtower/README.md @@ -1,3 +1,11 @@ +

+ + + + UI Watchtower + +

+ # ui-watchtower The framework-independent engine behind From 94a91d1a809eed8162b136d65b205938f21c600a Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 10 Nov 2026 15:51:38 +0100 Subject: [PATCH 12/21] update readmes --- README.md | 28 ++++-- docs/ADAPTERS.md | 124 +++++++++++++++++++++++++++ projects/ngx-ui-watchtower/DESIGN.md | 4 +- projects/ngx-ui-watchtower/README.md | 13 ++- projects/ui-watchtower/README.md | 84 +++++------------- 5 files changed, 174 insertions(+), 79 deletions(-) create mode 100644 docs/ADAPTERS.md diff --git a/README.md b/README.md index 21c45be..0b43c0e 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,25 @@ # UI Watchtower -Source of [`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower), -a telemetry library for Angular apps: scenarios (user end-to-end journeys or specific functional workflows through an app), BI events and -logs, with an optional AWS CloudWatch RUM sink. It builds on -[`@absaoss-cps/ui-watchtower`](projects/ui-watchtower/README.md), the -framework-independent engine, which lives here too. +Telemetry for frontend applications, in three parts: -How to use the library: [projects/ngx-ui-watchtower/README.md](projects/ngx-ui-watchtower/README.md) -(also the npm page). Without Angular, and to write an adapter for another -framework: [projects/ui-watchtower/README.md](projects/ui-watchtower/README.md). -How it is designed: [projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watchtower/DESIGN.md). +- **Scenarios** — did a user journey or a workflow succeed, and how long did + it take? +- **BI events** — which features are used? +- **Logs** — what exactly happened? + +Events go to AWS CloudWatch RUM or another destination you choose; logs go +to a backend you provide. + +It comes as two packages, released together at one version: + +| Package | Use it in | Guide | +| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | +| [`@absaoss-cps/ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ui-watchtower) | Any frontend: plain JavaScript or TypeScript, or any framework. Framework adapters are built on it ([how](docs/ADAPTERS.md)). | [README](projects/ui-watchtower/README.md) | +| [`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower) | Angular applications. A thin adapter over `@absaoss-cps/ui-watchtower`: Angular providers, injectable services and Observables around the same engine. | [README](projects/ngx-ui-watchtower/README.md) | + +Each guide is also the package's npm page. How it is designed: +[projects/ngx-ui-watchtower/DESIGN.md](projects/ngx-ui-watchtower/DESIGN.md). ## Layout @@ -36,6 +45,7 @@ projects/ngx-ui-watchtower/ README.md usage guide, published to npm DESIGN.md full design package.json the published package's manifest +docs/ ADAPTERS.md: how to write a framework adapter scripts/ the public API snapshot, the packaging smoke test, and publishing ``` diff --git a/docs/ADAPTERS.md b/docs/ADAPTERS.md new file mode 100644 index 0000000..e50c402 --- /dev/null +++ b/docs/ADAPTERS.md @@ -0,0 +1,124 @@ +# Writing a framework adapter + +`@absaoss-cps/ui-watchtower` works on its own, in any frontend. An adapter +is optional: it only makes the library feel native to one framework. +[`@absaoss-cps/ngx-ui-watchtower`](../projects/ngx-ui-watchtower/README.md), the +Angular adapter, is a complete example. + +## What an adapter is + +An adapter is wiring, never a second engine. It reads the framework's +configuration, creates the core's objects, hands them out the framework's +way (dependency injection, context, hooks) and disposes them when the +framework tears down. Scenarios, BI events, logging, redaction and the +broadcast protocol stay in the core. If an adapter needs a behaviour the +core lacks, add it to the core, where every adapter and every plain +application gets it. + +An adapter needs only the package's public entry points: no deep import, no +private file. + +## Steps + +1. **One engine per application, or per fragment.** Create it with + `uwtCreateTelemetry` at startup, from the framework's configuration, and + share it through the framework's context or dependency injection, never + one per component. If the framework's dependency injection resolves each + concern on its own, as Angular's does, construct `UwtScenarioTelemetry`, + `UwtBITelemetry` and `UwtLogTelemetry` instead, from + `uwtResolveTelemetryConfig(overrides)`, one destination and one log store. +2. **The platform.** Pass `document: null` when rendering on a server, and + `runOutside` if the framework waits for pending timers before it is + stable, as Angular's zone does. +3. **Failures.** Pass a `report`, such as `uwtConsoleReporter` in + development. Telemetry calls never throw; `uwtCreateTelemetry` does, when + the identity, destination or log store is missing. +4. **Reactive APIs.** Adapt `scenarios.onSettled(listener)`, which returns + an unsubscribe function, to the framework's primitive: an Observable, a + signal, a store. +5. **Destinations.** Accept any `UwtTelemetrySink`, and offer the core's: + the RUM sink, started with an unawaited `init()`; for a fragment, the + broadcast sink and log provider sharing one `uwtConnectBroadcastChannel()`; + for a shell, a `UwtTelemetryBroadcastHost`. Pass each the engine's + `enabled`, and the shared connection `open: enabled`. +6. **Teardown.** When the application or fragment is destroyed, dispose the + engine and then what you created for it. Every `dispose()` can be called + more than once. +7. **Names.** Re-export the four registries, so applications declare their + names on your package's module path and reach the same registries: + + ```ts + export type { + UwtBIEventNames, + UwtLoggerNames, + UwtScenarioNames, + UwtScenarioSteps + } from '@absaoss-cps/ui-watchtower'; + ``` + +8. **The dependency.** Depend on `@absaoss-cps/ui-watchtower` with a caret + range, as a dependency rather than a peer, so installing the adapter + installs the core. + +## How the Angular adapter does it + +| Step | In `@absaoss-cps/ngx-ui-watchtower` | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Engine | Three root services, one per concern, each building its core class from injected config: [services/](../projects/ngx-ui-watchtower/src/lib/services/). A missing provider still fails where Angular reports it. | +| Platform | `uwtBrowserDocument()` returns `null` outside a browser (`PLATFORM_ID`); `uwtRunOutsideZone()` wraps `NgZone.runOutsideAngular`: [uwt-telemetry-safe.util.ts](../projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts) | +| Failures | `uwtDevModeReporter`: failures and dropped data in development mode only, configuration problems always, prefixed `[ngx-ui-watchtower]` (same file) | +| Reactive | `settled$` is a `Subject` fed by `onSettled` and completed on destroy: [uwt-scenario-telemetry.service.ts](../projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts); `traceScenario` passes the reporter to the core's `/rxjs` | +| Destinations | `@Injectable()` subclasses of the core's sinks, host and log provider, built from `inject()`. In `'broadcast'` mode one `UwtBroadcastConnectionRef` per injector gives the sink and log provider one channel: [uwt-broadcast.messages.ts](../projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts) | +| Teardown | The scenario and logger services and every destination subclass call `dispose()` from `ngOnDestroy`; the BI service holds nothing to release | +| Names | [public-api.ts](../projects/ngx-ui-watchtower/src/public-api.ts) re-exports the registries, so `declare module '@absaoss-cps/ngx-ui-watchtower'` keeps working | +| Dependency | `"@absaoss-cps/ui-watchtower": "^"` in its `package.json`, moved by Release Please | + +## Testing an adapter + +The core's own tests cover the engine and the protocol. An adapter's tests +cover its wiring: + +- **One engine per scope.** Everything the framework hands out in one + application shares one engine, destination and log store. +- **Teardown.** Destroying the application disposes everything the adapter + created; destroying it twice does nothing more. +- **Off and on the server.** With telemetry off, or rendering on a server, + nothing listens to the page, no channel opens and no RUM client starts. +- **Names.** A type test that declares names on the adapter's module path: + declared names compile, others don't, and plain strings compile while + nothing is declared. See + [type-tests/](../projects/ngx-ui-watchtower/type-tests/). +- **Shells and fragments.** An adapter's fragment and shell must exchange + the recorded messages: + [uwt-broadcast.wire.spec.ts](../projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts) + holds every pairing to `uwt-broadcast.wire.fixture.json`. + +## Adding an adapter to this repository + +An adapter can live anywhere and depend on the published core. To release it +from this repository, together with the core, at the same version: + +1. **The package.** `projects//` with its `package.json` (the + version the core has, and the core as a `^` dependency), README and + LICENSE, built into `dist/` by `npm run build`, after the core. +2. **Release Please.** Add the package to `release-please-config.json`, with + a `component`, and add that component to the `linked-versions` group. Add + its path to `.release-please-manifest.json`, at the current version. +3. **Publishing.** Add it to `PACKAGES` in + [scripts/publish-packages.mjs](../scripts/publish-packages.mjs), after the + core, with `dependsOn: '@absaoss-cps/ui-watchtower'`. Add its + `--release_created` output to `.github/workflows/release-please.yml`, and + to the publish job's condition. +4. **Checks.** Add it to `PACKAGES` in + [scripts/api-snapshot.mjs](../scripts/api-snapshot.mjs) and run + `npm run api:update`; add a consumer to + [scripts/smoke/smoke.mjs](../scripts/smoke/smoke.mjs). The release + scenarios in [scripts/release-check.mjs](../scripts/release-check.mjs) and + the workflow test in + [scripts/publish-packages.test.mjs](../scripts/publish-packages.test.mjs) + name the core and the Angular package; extend them to the new one. +5. **Docs.** Add a row to the root README's package table. + +Start with `PACKAGES`: `npm run test:release` then fails, in CI too, until +the config, the manifest, the linked group, the package's `package.json` and +the release job's outputs all match it. diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index fa572d6..e7f288e 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -212,8 +212,8 @@ providers: [ compiles against the core's built declarations, not its source, so it sees the core exactly as an application does. An application upgrading from 0.4 changes nothing. Another framework's adapter needs only the core's - public entry points, as this package does; the core's README has a short - guide. + public entry points, as this package does; + `docs/ADAPTERS.md` is a short guide. - **What the core owns, this package re-exports.** The engine, the models and name registries, the configuration types and defaults (`uwtResolveTelemetryConfig` completes every `with*` feature), redaction, diff --git a/projects/ngx-ui-watchtower/README.md b/projects/ngx-ui-watchtower/README.md index 9c9e245..a0c39c9 100644 --- a/projects/ngx-ui-watchtower/README.md +++ b/projects/ngx-ui-watchtower/README.md @@ -16,6 +16,12 @@ Telemetry for Angular apps, in three parts: Events go to AWS CloudWatch RUM. Logs go to a backend you provide. +It is a thin Angular adapter over +[`@absaoss-cps/ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ui-watchtower), +the framework-independent engine that does the work, and can be used in any +frontend. This package adds what Angular expects: providers, injectable +services, Observables. + The library's names are prefixed `Uwt`, short for UI Watchtower — `UwtLoggerService`, `provideUwtTelemetry`, `UWT_LOG_API_PROVIDER`. @@ -26,10 +32,9 @@ npm install @absaoss-cps/ngx-ui-watchtower npm install aws-rum-web # only for the AWS RUM sink ``` -Requires Angular 21 or 22 and RxJS 7.8. It brings -[`@absaoss-cps/ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ui-watchtower), -the framework-independent engine it builds on, with it: there is nothing more -to install or configure. +Requires Angular 21 or 22 and RxJS 7.8. It installs +`@absaoss-cps/ui-watchtower` with it: there is nothing more to install or +configure. Upgrading from 0.4 needs no code or configuration change: the imports, providers, services, name declarations and behavior stay the same. diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md index 8edf187..3831fc4 100644 --- a/projects/ui-watchtower/README.md +++ b/projects/ui-watchtower/README.md @@ -8,13 +8,22 @@ # ui-watchtower -The framework-independent engine behind -[`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower): -scenario health telemetry, business/UX events and application logs, for any -JavaScript or TypeScript application. +Telemetry for any frontend, in three parts: -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. +- **Scenarios** — did a user journey or a workflow succeed, and how long did + it take? +- **BI events** — which features are used? +- **Logs** — what exactly happened? + +It needs no framework and no adapter: install it and call it directly, from +plain JavaScript or TypeScript or from an application built with any +framework. It imports nothing framework-specific and touches no browser +global when imported, so it also loads in Node, on a server and in a worker. + +Adapters are optional. For Angular there is +[`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower), +which wraps this package in providers and injectable services. Adapters for +other frameworks can be added the same way. ## Install @@ -144,9 +153,9 @@ const telemetry = uwtCreateTelemetry({ }); ``` -Either side can be an Angular application using -`@absaoss-cps/ngx-ui-watchtower`; they exchange the same messages. Give each -fragment its own `application` name, and the shell's `eventNamespace`. +Each side can use a different framework, or none: they exchange the same +messages. Give each fragment its own `application` name, and the shell's +`eventNamespace`. Who owns what: @@ -185,9 +194,7 @@ declare module '@absaoss-cps/ui-watchtower' { export {}; ``` -Until you declare any, plain strings are accepted. An Angular application -augments `@absaoss-cps/ngx-ui-watchtower` instead, which re-exports these -registries: either module path reaches the same ones. +Until you declare any, plain strings are accepted. ## Seeing what telemetry skips @@ -214,57 +221,12 @@ import { traceScenario } from '@absaoss-cps/ui-watchtower/rxjs'; customers$.pipe(traceScenario(scenario)).subscribe(); ``` -## Writing an adapter for another framework - -`@absaoss-cps/ngx-ui-watchtower` is one adapter. Another framework's needs -only the public entry points above: no deep import, no private file. - -1. **One engine per application, or per fragment.** Create it with - `uwtCreateTelemetry` at startup, from the framework's configuration, and - share it through the framework's context or dependency injection, never - one per component. If the framework resolves each concern on its own, as - Angular's DI does, construct `UwtScenarioTelemetry`, `UwtBITelemetry` and - `UwtLogTelemetry` instead, from `uwtResolveTelemetryConfig(overrides)`, - one destination and one log store. -2. **The platform.** Pass `document: null` when rendering on a server, and - `runOutside` if the framework waits for pending timers before it is - stable, as Angular's zone does. -3. **Failures.** Pass a `report`, such as `uwtConsoleReporter` in - development. Telemetry calls never throw; `uwtCreateTelemetry` does, when - the identity, destination or log store is missing. -4. **Reactive APIs.** Adapt `scenarios.onSettled(listener)`, which returns - an unsubscribe function, to the framework's primitive: an Observable, a - signal, a store. -5. **Destinations.** Accept any `UwtTelemetrySink`, and offer the core's: - the RUM sink, started with an unawaited `init()`; for a fragment, the - broadcast sink and log provider sharing one `uwtConnectBroadcastChannel()`; - for a shell, a `UwtTelemetryBroadcastHost`. Pass each the engine's - `enabled`, and the shared connection `open: enabled`. -6. **Teardown.** When the application or fragment is destroyed, dispose the - engine and then what you created for it. Every `dispose()` can be called - more than once. -7. **Names.** Re-export the four registries, so applications augment your - package's module path: - - ```ts - export type { - UwtBIEventNames, - UwtLoggerNames, - UwtScenarioNames, - UwtScenarioSteps - } from '@absaoss-cps/ui-watchtower'; - ``` - -8. **The dependency.** Depend on `@absaoss-cps/ui-watchtower` with a caret - range, as a dependency rather than a peer, so installing the adapter - installs the core. - ## API | Group | Exports | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Engine | `uwtCreateTelemetry`, `UwtTelemetry`, `UwtTelemetryOptions`, `UwtTelemetryDocument` | -| Concerns | `UwtScenarioTelemetry`, `UwtBITelemetry`, `UwtLogTelemetry` and their options, for an adapter composing them itself | +| Concerns | `UwtScenarioTelemetry`, `UwtBITelemetry`, `UwtLogTelemetry` and their options, to compose them yourself | | Configuration | `UwtTelemetryIdentity`, the per-concern config types, `UWT_DEFAULT_TELEMETRY_CONFIG`, `uwtResolveTelemetryConfig` | | Models | scenario, step, BI event and log record types; `UWT_LOG_LEVEL_ORDER`, `UWT_TELEMETRY_EVENT_TYPE`, `uwtEventTypes` | | Names | `UwtScenarioNames`, `UwtScenarioSteps`, `UwtBIEventNames`, `UwtLoggerNames` and their `Uwt*Name` types | @@ -276,12 +238,6 @@ only the public entry points above: no deep import, no private file. | `/rxjs` | `traceScenario`, `UwtTraceScenarioOptions` | | `/rum` | `UwtRumTelemetrySink` and its options; `UwtRumCredentialsProvider`, `UwtRumBootstrap`, `UwtRumAppMonitorConfig`, `UwtRumCredentials` | -## Versions - -This package and `@absaoss-cps/ngx-ui-watchtower` are released together, -always at the same version. The Angular package depends on this one, never -the other way round, and installs it for you. - ## License Apache-2.0 From 6b053f5fe92a8aa63893cf63c4ac58b86e26ddb3 Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 10 Nov 2026 16:05:59 +0100 Subject: [PATCH 13/21] perf: copy settled scenarios only while settled has subscribers --- projects/ngx-ui-watchtower/DESIGN.md | 3 +- .../uwt-scenario-telemetry.service.spec.ts | 57 +++++++++++++++++++ .../uwt-scenario-telemetry.service.ts | 25 ++++++-- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index e7f288e..e3964e6 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -96,7 +96,8 @@ fails where it always did: the logger needs a log provider but no destination, scenarios need a destination and no log provider. The services are root singletons, so each concern exists once per injector, and all of them share the one destination and log provider. `settled$` is the core's -`onSettled` as an Observable that completes on destroy; `traceScenario` is the +`onSettled` as an Observable that completes on destroy. It listens to the +core only while subscribed, so no record is copied for nobody; `traceScenario` is the core's `./rxjs` operator, reporting through this package's dev-mode reporter. Framework-free applications compose the same three classes with `uwtCreateTelemetry`. diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts index 0981901..5eafc9d 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts @@ -18,6 +18,7 @@ import { withRedaction, withScenarios } from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; +import * as uwtTelemetrySafeUtil from '../../../../../ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { UwtScenarioTelemetryService } from './uwt-scenario-telemetry.service'; @@ -2065,6 +2066,62 @@ describe('UwtScenarioTelemetryService', () => { expect(completed).toBe(true); }); + it('should complete a subscriber that arrives after destroy', () => { + service.ngOnDestroy(); + + let completed = false; + service.settled$.subscribe({ complete: () => (completed = true) }); + + expect(completed).toBe(true); + }); + + describe('copies of the record', () => { + let copy: jest.SpyInstance; + + const recordCopies = () => + copy.mock.calls.filter( + ([value]) => + (value as { scenarioName?: unknown } | null)?.scenarioName !== + undefined + ).length; + + beforeEach(() => { + copy = jest.spyOn(uwtTelemetrySafeUtil, 'uwtDeepClone'); + }); + afterEach(() => copy.mockRestore()); + + it('should make none while nobody subscribes', () => { + service.start({ name: 'a' }).complete(); + + expect(recordCopies()).toBe(0); + }); + + it('should make one per record for any number of subscribers, sharing it', () => { + const seen: UwtScenarioRecord[] = []; + service.settled$.subscribe((r) => seen.push(r)); + service.settled$.subscribe((r) => seen.push(r)); + + service.start({ name: 'a' }).complete(); + + expect(recordCopies()).toBe(1); + expect(seen).toHaveLength(2); + expect(seen[0]).toBe(seen[1]); + }); + + it('should stop once the last subscriber leaves', () => { + const first = service.settled$.subscribe(); + const second = service.settled$.subscribe(); + first.unsubscribe(); + + service.start({ name: 'a' }).complete(); + expect(recordCopies()).toBe(1); + + second.unsubscribe(); + service.start({ name: 'b' }).complete(); + expect(recordCopies()).toBe(1); + }); + }); + it('should not let a throwing subscriber break the scenario', () => { // RxJS's Subject.next() already isolates a throwing subscriber, not // uwtSafeVoid — this confirms the guarantee holds either way. diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts index 731717d..9102378 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts @@ -72,11 +72,27 @@ export class UwtScenarioTelemetryService implements OnDestroy { * ``` */ readonly settled$: Observable = - this._settled$.asObservable(); + new Observable((subscriber) => { + const subscription = this._settled$.subscribe(subscriber); + // Listens to the core only while observed: the core copies each record + // for its listeners, wasted work when nobody reads it. + if (!this.destroyed) { + this.stopListening ??= this.scenarios.onSettled((record) => + this._settled$.next(record) + ); + } + return () => { + subscription.unsubscribe(); + if (!this._settled$.observed) { + this.stopListening?.(); + this.stopListening = undefined; + } + }; + }); - constructor() { - this.scenarios.onSettled((record) => this._settled$.next(record)); - } + /** Stops the core's listener; set while {@link settled$} has subscribers. */ + private stopListening?: () => void; + private destroyed = false; /** * Starts a new scenario. @@ -145,6 +161,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { /** @inheritdoc */ ngOnDestroy(): void { + this.destroyed = true; this.scenarios.dispose(); this._settled$.complete(); } From ec409611355a641fc4210537d51c4c2b8ab7d051 Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 10 Nov 2026 16:39:23 +0100 Subject: [PATCH 14/21] try fix logo theming --- README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0b43c0e..23b9431 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@

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

# UI Watchtower From fd5906c9a6f1877a721a2d2bbebb30fd8acb8f59 Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 10 Nov 2026 16:47:34 +0100 Subject: [PATCH 15/21] rename to ngx coverage report --- .github/workflows/checks.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 1a6336b..b2041ed 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -62,12 +62,12 @@ jobs: - name: Run unit tests with coverage run: npm run test:coverage - - name: Comment coverage on PR + - name: Comment ngx coverage on PR uses: ArtiomTr/jest-coverage-report-action@v2 if: github.event_name == 'pull_request' continue-on-error: true with: - custom-title: Coverage report + custom-title: Ngx coverage report coverage-file: ./coverage/report.json # Tests already ran above; the action only formats the report. skip-step: all From 292208fcbbe112eaff5a5a0368d8645aef425a0f Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 10 Nov 2026 17:05:06 +0100 Subject: [PATCH 16/21] increase coverage --- README.md | 2 +- jest.config.js | 5 +- projects/ui-watchtower/jest.config.js | 5 +- .../uwt-rum-telemetry.sink.behaviour.spec.ts | 650 ++++++++++++++++++ .../uwt-telemetry/uwt-telemetry.spec.ts | 133 ++++ .../uwt-scenario.behaviour.spec.ts | 290 ++++++++ .../sinks/uwt-broadcast/uwt-broadcast.spec.ts | 271 +++++++- 7 files changed, 1352 insertions(+), 4 deletions(-) create mode 100644 projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.ts create mode 100644 projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts diff --git a/README.md b/README.md index 23b9431..0a49ef2 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ application does. Tests and the typecheck read the core's source. | `npm run test` | Runs the unit tests of both packages (Jest), then `test:release` | | `npm run test:core` | Runs the core's unit tests only: plain Jest, no Angular | | `npm run test:release` | Tests the publish script and the release workflow's wiring | -| `npm run test:coverage` | Runs the unit tests with coverage, as CI does | +| `npm run test:coverage` | Runs the unit tests with coverage, as CI does; fails below 90% | | `npm run typecheck` | Builds the core, then type-checks every tsconfig and type test | | `npm run api:check` | Compares the built public APIs with their committed snapshots | | `npm run api:update` | Writes those snapshots again, for an intended API change | diff --git a/jest.config.js b/jest.config.js index 4dad6eb..5f0a6d6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -36,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/projects/ui-watchtower/jest.config.js b/projects/ui-watchtower/jest.config.js index f1dee2c..f9fe823 100644 --- a/projects/ui-watchtower/jest.config.js +++ b/projects/ui-watchtower/jest.config.js @@ -22,5 +22,8 @@ module.exports = { '{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/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.ts b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.ts new file mode 100644 index 0000000..196c0e9 --- /dev/null +++ b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.ts @@ -0,0 +1,650 @@ +/** + * @jest-environment jsdom + */ +import { uwtSessionElapsed } from '@absaoss-cps/ui-watchtower'; +import { UwtRumBootstrap } from '../uwt-rum-credentials/uwt-rum-credentials'; +import { + UwtRumTelemetrySink, + UwtRumTelemetrySinkOptions +} from './uwt-rum-telemetry.sink'; + +const mockClient = { + recordEvent: jest.fn(), + recordError: jest.fn(), + recordPageView: jest.fn(), + addSessionAttributes: jest.fn(), + setEventMetadataHook: jest.fn(), + setAwsCredentials: jest.fn(), + getSessionId: jest.fn(), + getUserId: jest.fn(), + pinUserId: jest.fn(), + startSession: jest.fn(), + dispatch: jest.fn(), + dispatchBeacon: jest.fn(), + disable: jest.fn() +}; +/** The arguments of every client built, and what building one does. */ +const mockConstructed: unknown[][] = []; +const mockConstruction: { fail: boolean; then?: () => void } = { fail: false }; + +jest.mock('aws-rum-web', () => ({ + AwsRum: function (...args: unknown[]) { + if (mockConstruction.fail) { + throw new Error('SDK exploded'); + } + mockConstructed.push(args); + mockConstruction.then?.(); + return mockClient; + } +})); + +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; +const SESSION_START = 'com.amazon.rum.session_start_event'; + +/** What a broker returns: settings, and credentials expiring in an hour. */ +function bootstrap( + config: Record = {}, + expiresInMs = HOUR +): UwtRumBootstrap { + return { + config: { + applicationId: 'app-monitor', + applicationVersion: '1.0.0', + region: 'eu-west-1', + ...config + } as UwtRumBootstrap['config'], + credentials: { + accessKeyId: 'a', + secretAccessKey: 's', + sessionToken: 't', + expiration: new Date(Date.now() + expiresInMs).toISOString() + } + }; +} + +/** A page with an origin and its cookies. */ +function page(origin = 'https://shop.example') { + return { + cookie: '', + defaultView: { location: { origin }, navigator: { cookieEnabled: true } } + }; +} + +/** The config the last client was built with. */ +function builtConfig(): Record { + return mockConstructed[mockConstructed.length - 1][3] as Record< + string, + unknown + >; +} + +/** The metadata hook the client was given. */ +function metadataHook(): (type: string) => Record { + return mockClient.setEventMetadataHook.mock.calls[0][0]; +} + +describe('the core RUM sink, in detail', () => { + let load: jest.Mock; + let sinks: UwtRumTelemetrySink[]; + + function sink( + options: Partial = {} + ): UwtRumTelemetrySink { + const created = new UwtRumTelemetrySink({ + identity: { application: 'cart', environment: 'test', version: '1' }, + credentialsProvider: { load }, + ...options + }); + sinks.push(created); + return created; + } + + beforeEach(() => { + Object.values(mockClient).forEach((fn) => fn.mockReset()); + mockClient.getSessionId.mockReturnValue('session-1'); + mockConstructed.length = 0; + mockConstruction.fail = false; + mockConstruction.then = undefined; + load = jest.fn(async () => bootstrap()); + sinks = []; + localStorage.clear(); + }); + + afterEach(() => { + sinks.forEach((created) => created.dispose()); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('settings the SDK cannot use', () => { + it.each([ + [ + { pagesToExclude: 'admin' }, + 'pagesToExclude (expected an array of RegExp)' + ], + [{ telemetries: 'errors' }, 'telemetries (expected an array)'], + [ + { telemetries: [['http', { urlsToExclude: ['/files'] }]] }, + 'http.urlsToExclude (expected an array of RegExp)' + ], + [ + { telemetries: [['http', { addXRayTraceIdHeader: 'yes' }]] }, + 'http.addXRayTraceIdHeader (expected a boolean or an array of RegExp)' + ], + [ + { telemetries: [['errors', { ignore: 'never' }]] }, + 'errors.ignore (expected a function)' + ] + ])('should keep RUM off for %j', async (config, problem) => { + const report = jest.fn(); + load.mockResolvedValue(bootstrap(config)); + const created = sink({ report }); + + await created.init(); + created.record('com.uwt.bi', {}); + + expect(mockConstructed).toHaveLength(0); + expect(report).toHaveBeenCalledWith( + 'config', + `RUM not started, settings of the wrong type: ${problem}` + ); + }); + + it('should accept valid telemetry options, and pass them on without the unset ones', async () => { + const ignore = () => false; + const pattern = /\/api\//; + load.mockResolvedValue( + bootstrap({ + telemetries: [ + 'errors', + ['errors', { ignore }], + [ + 'http', + { + urlsToExclude: [pattern], + addXRayTraceIdHeader: true, + urlsToInclude: null, + interactionId: undefined + } + ], + ['performance', 'not options'] + ] + }) + ); + + await sink().init(); + + expect(builtConfig().telemetries).toEqual([ + 'errors', + ['errors', { ignore }], + ['http', { urlsToExclude: [pattern], addXRayTraceIdHeader: true }], + ['performance', 'not options'] + ]); + }); + }); + + describe('the client config', () => { + it('should trace same-origin requests by default, never a look-alike host', async () => { + await sink({ document: page('https://shop.example') }).init(); + + const http = (builtConfig().telemetries as unknown[])[2] as [ + string, + { addXRayTraceIdHeader: RegExp[] } + ]; + const [sameOrigin] = http[1].addXRayTraceIdHeader; + expect(sameOrigin.test('https://shop.example/api/orders')).toBe(true); + expect(sameOrigin.test('https://shop.example')).toBe(true); + expect(sameOrigin.test('https://shop.example.attacker.example/')).toBe( + false + ); + expect(builtConfig()).toMatchObject({ + allowCookies: true, + enableXRay: true, + endpoint: 'https://dataplane.rum.eu-west-1.amazonaws.com' + }); + }); + + it("should leave unset settings to the SDK's defaults, and pass set ones on", async () => { + load.mockResolvedValue( + bootstrap({ + sessionSampleRate: 0.5, + client: 'my-client', + releaseId: null, + debug: undefined + }) + ); + + await sink().init(); + + expect(builtConfig()).toMatchObject({ + sessionSampleRate: 0.5, + client: 'my-client' + }); + expect(builtConfig()).not.toHaveProperty('releaseId'); + expect(builtConfig()).not.toHaveProperty('debug'); + }); + }); + + describe('startup', () => { + it('should turn RUM off for the page when the broker declines', async () => { + load.mockResolvedValue(null); + const created = sink(); + + created.record('com.uwt.early', {}); + await created.init(); + created.record('com.uwt.late', {}); + + expect(mockConstructed).toHaveLength(0); + expect(created.getSessionId()).toBeUndefined(); + }); + + it('should retry a failing broker with growing delays, then start and replay', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + jest.spyOn(Math, 'random').mockReturnValue(1); + const report = jest.fn(); + load + .mockRejectedValueOnce(new Error('broker down')) + .mockRejectedValueOnce(new Error('still down')); + const created = sink({ report }); + + created.record('com.uwt.early', {}); + await created.init(); + await jest.advanceTimersByTimeAsync(1000); + expect(load).toHaveBeenCalledTimes(2); + await jest.advanceTimersByTimeAsync(2000); + + expect(load).toHaveBeenCalledTimes(3); + expect(mockClient.recordEvent).toHaveBeenCalledWith( + 'com.uwt.early', + {}, + undefined + ); + expect( + report.mock.calls.filter(([, message]) => message.includes('retrying')) + ).toEqual([ + [ + 'warn', + 'RUM init failed, retrying', + expect.objectContaining({ message: 'broker down' }) + ] + ]); + }); + + it('should give up after five minutes of failures, and drop what it held', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + jest.spyOn(Math, 'random').mockReturnValue(1); + const report = jest.fn(); + load.mockRejectedValue(new Error('down')); + const created = sink({ report }); + + created.record('com.uwt.early', {}); + await created.init(); + await jest.advanceTimersByTimeAsync(6 * MINUTE); + const attempts = load.mock.calls.length; + await jest.advanceTimersByTimeAsync(10 * MINUTE); + + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM init failed, monitoring disabled', + expect.objectContaining({ message: 'down' }) + ); + expect(load).toHaveBeenCalledTimes(attempts); + expect(mockConstructed).toHaveLength(0); + }); + + it('should turn RUM off when the client fails to start, reporting only to a reporter', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const report = jest.fn(); + mockConstruction.fail = true; + + await sink({ report }).init(); + await sink().init(); + + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM init failed, monitoring disabled', + expect.objectContaining({ message: 'SDK exploded' }) + ); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should build no client when disposed while starting', async () => { + const created = sink(); + + const starting = created.init(); + created.dispose(); + await starting; + + expect(mockConstructed).toHaveLength(0); + }); + + it('should hold the latest 100 events from before startup', async () => { + const created = sink(); + + for (let i = 0; i < 105; i++) { + created.record(`com.uwt.e${i}`, {}); + } + await created.init(); + + expect(mockClient.recordEvent).toHaveBeenCalledTimes(100); + expect(mockClient.recordEvent.mock.calls[0][0]).toBe('com.uwt.e5'); + }); + }); + + describe('records', () => { + it('should drop reserved and null metadata, and send none when nothing is left', async () => { + const created = sink(); + await created.init(); + + created.record('com.uwt.a', {}, { 'aws:x': 'a', keep: 1, gone: null }); + created.record('com.uwt.b', {}, { 'aws:only': 'a' }); + + expect(mockClient.recordEvent.mock.calls).toEqual([ + ['com.uwt.a', {}, { keep: 1 }], + ['com.uwt.b', {}, undefined] + ]); + }); + + it("should fold a fragment's application into an error's name, not its own", async () => { + const created = sink(); + await created.init(); + const error = { name: 'TypeError', message: 'boom' }; + + created.recordError(error, { application: 'checkout' }); + created.recordError(error, { application: 'cart' }); + created.recordError(error); + + expect(mockClient.recordError.mock.calls).toEqual([ + [{ name: '[checkout] TypeError', message: 'boom' }], + [error], + [error] + ]); + }); + + it('should replay page views and errors recorded before startup', async () => { + const created = sink(); + + created.recordPageView('/customers/:id'); + created.recordError( + { name: 'Error', message: 'early' }, + { application: 'checkout' } + ); + await created.init(); + created.recordPageView('/orders'); + + expect(mockClient.recordPageView.mock.calls).toEqual([ + ['/customers/:id'], + ['/orders'] + ]); + expect(mockClient.recordError).toHaveBeenCalledWith({ + name: '[checkout] Error', + message: 'early' + }); + }); + }); + + describe('the signed-in user', () => { + it('should pin a user signed in before startup, once the client starts', async () => { + const created = sink(); + + created.setUserId('u-1'); + await created.init(); + + expect(created.getUserId()).toBe('u-1'); + expect(mockClient.pinUserId).toHaveBeenCalledWith('u-1'); + }); + + it('should start an anonymous session for a sign-out before startup', async () => { + const created = sink(); + + created.setUserId('u-1'); + created.setUserId(undefined); + await created.init(); + + expect(mockClient.pinUserId).not.toHaveBeenCalled(); + expect(mockClient.startSession).toHaveBeenCalledWith({ + userId: expect.any(String) + }); + }); + + it('should send what the client holds before the user changes, and ignore repeats', async () => { + load.mockResolvedValue( + bootstrap({ eventCacheSize: 300, batchLimit: 100 }) + ); + const created = sink(); + await created.init(); + + created.setUserId('u-1'); + expect(mockClient.dispatch).toHaveBeenCalledTimes(3); + expect(mockClient.pinUserId).toHaveBeenCalledWith('u-1'); + + created.setUserId('u-1'); + expect(mockClient.dispatch).toHaveBeenCalledTimes(3); + + created.setUserId(undefined); + expect(mockClient.dispatch).toHaveBeenCalledTimes(6); + expect(mockClient.startSession).toHaveBeenCalledTimes(1); + + created.setUserId(''); + expect(mockClient.startSession).toHaveBeenCalledTimes(1); + }); + + it('should send one batch when the batch settings are unusable', async () => { + load.mockResolvedValue(bootstrap({ batchLimit: 0 })); + const created = sink(); + await created.init(); + + created.setUserId('u-1'); + + expect(mockClient.dispatch).toHaveBeenCalledTimes(1); + }); + + it('should not send early when the client already has that user, as after a reload', async () => { + mockClient.getUserId.mockReturnValue('u-1'); + const created = sink(); + await created.init(); + + created.setUserId('u-1'); + + expect(mockClient.dispatch).not.toHaveBeenCalled(); + expect(mockClient.pinUserId).toHaveBeenCalledWith('u-1'); + }); + + it('should stamp signedIn on events only once the app has said who is signed in', async () => { + const created = sink(); + await created.init(); + const hook = metadataHook(); + + expect(hook('com.uwt.bi')).toEqual({ + application: 'cart', + environment: 'test', + appVersion: '1' + }); + created.setUserId('u-1'); + expect(hook('com.uwt.bi')).toMatchObject({ signedIn: true }); + created.setUserId(undefined); + expect(hook('com.uwt.bi')).toMatchObject({ signedIn: false }); + }); + }); + + describe('session starts', () => { + it('should record when the client starts a session', async () => { + await sink().init(); + + metadataHook()(SESSION_START); + + expect(uwtSessionElapsed('session-1')).toEqual(expect.any(Number)); + }); + + it('should record nothing when the client keeps no session cookie', async () => { + load.mockResolvedValue(bootstrap({ allowCookies: false })); + await sink().init(); + + metadataHook()(SESSION_START); + + expect(uwtSessionElapsed('session-1')).toBeUndefined(); + }); + + it.each([ + ['the shared cookie', {}, 'cwr_s=1'], + [ + 'its own cookie', + { cookieAttributes: { unique: true } }, + 'cwr_s_app-monitor=1' + ] + ])( + 'should record the session the client starts while starting, in %s', + async (_, config, cookie) => { + const document = page(); + mockConstruction.then = () => { + document.cookie = cookie; + }; + load.mockResolvedValue(bootstrap(config)); + + await sink({ document }).init(); + + expect(uwtSessionElapsed('session-1')).toEqual(expect.any(Number)); + } + ); + + it('should not recurse when reading the session id starts another one', async () => { + await sink().init(); + const hook = metadataHook(); + mockClient.getSessionId.mockImplementationOnce(() => { + hook(SESSION_START); + return 'session-2'; + }); + + hook(SESSION_START); + + expect(mockClient.getSessionId).toHaveBeenCalledTimes(1); + expect(uwtSessionElapsed('session-2')).toEqual(expect.any(Number)); + }); + }); + + describe('flush', () => { + it('should send through the client, by beacon when asked', async () => { + const created = sink(); + await created.init(); + + created.flush(); + created.flush(true); + + expect(mockClient.dispatch).toHaveBeenCalledTimes(1); + expect(mockClient.dispatchBeacon).toHaveBeenCalledTimes(1); + }); + + it('should report events lost when the page goes before the client starts', () => { + load.mockReturnValue(new Promise(() => undefined)); + const report = jest.fn(); + const created = sink({ report }); + + created.flush(true); + expect(report).not.toHaveBeenCalled(); + created.record('com.uwt.early', {}); + created.flush(true); + + expect(report).toHaveBeenCalledWith( + 'warn', + '1 RUM event(s) lost: page unloaded before RUM finished initializing' + ); + }); + }); + + describe('credentials', () => { + beforeEach(() => + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }) + ); + + it('should refresh credentials five minutes before they expire', async () => { + await sink().init(); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + + expect(load).toHaveBeenCalledTimes(2); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should turn RUM off when a refresh is declined', async () => { + const created = sink(); + await created.init(); + load.mockResolvedValueOnce(null); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + created.record('com.uwt.late', {}); + + expect(mockClient.disable).toHaveBeenCalled(); + expect(mockClient.recordEvent).not.toHaveBeenCalled(); + }); + + it('should keep its credentials and retry when a refresh omits them, or fails', async () => { + const report = jest.fn(); + await sink({ report }).init(); + load + .mockResolvedValueOnce({ config: bootstrap().config }) + .mockRejectedValueOnce(new Error('broker down')); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM credential refresh', + expect.objectContaining({ + message: expect.stringContaining('omitted credentials') + }) + ); + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM credential refresh failed', + expect.objectContaining({ message: 'broker down' }) + ); + await jest.advanceTimersByTimeAsync(30 * 1000); + + expect(load).toHaveBeenCalledTimes(4); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should retry soon, not loop, on credentials already expired', async () => { + load.mockResolvedValueOnce(bootstrap({}, -1000)); + await sink().init(); + expect(jest.getTimerCount()).toBe(1); + + await jest.advanceTimersByTimeAsync(30 * 1000); + + expect(load).toHaveBeenCalledTimes(2); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should schedule nothing once disposed during a refresh', async () => { + const created = sink(); + await created.init(); + let answer: (value: UwtRumBootstrap) => void = () => undefined; + load.mockReturnValueOnce(new Promise((resolve) => (answer = resolve))); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + created.dispose(); + answer(bootstrap()); + await jest.advanceTimersByTimeAsync(0); + + expect(jest.getTimerCount()).toBe(0); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(1); + }); + + it('should not retry a failed refresh once disposed', async () => { + const created = sink(); + await created.init(); + let fail: (error: Error) => void = () => undefined; + load.mockReturnValueOnce( + new Promise((_resolve, reject) => (fail = reject)) + ); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + created.dispose(); + fail(new Error('broker down')); + await jest.advanceTimersByTimeAsync(0); + + expect(jest.getTimerCount()).toBe(0); + }); + }); +}); diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts index df8d829..f6f639d 100644 --- a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts @@ -13,6 +13,7 @@ import { } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { UWT_REDACTED } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import * as uwtTelemetrySafeUtil from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; import { UwtTelemetryOptions, uwtCreateTelemetry } from './uwt-telemetry'; /** Keeps what the engine sends, so a test can assert on it. */ @@ -512,5 +513,137 @@ describe('uwtCreateTelemetry', () => { expect(sink.userId).toBeUndefined(); expect(logApi.records).toEqual([]); }); + + it('should dispose each concern only once', () => { + const { telemetry, logApi } = engine(); + + telemetry.logging.dispose(); + telemetry.logging.dispose(); + telemetry.scenarios.dispose(); + telemetry.scenarios.dispose(); + + expect(logApi.flushCount).toBe(1); + }); + }); + + describe('BI event details', () => { + it('should drop an event without a name', () => { + const { telemetry, sink } = engine(); + + telemetry.bi.track('' as never); + + expect(sink.events).toEqual([]); + }); + + it('should write events to the console under debugBI', () => { + localStorage.setItem('debugBI', 'true'); + const log = jest.spyOn(console, 'log').mockImplementation(() => {}); + const { telemetry } = engine(); + + telemetry.bi.track('export_clicked'); + + expect(log).toHaveBeenCalledWith( + '[cart][bi] export_clicked -> com.cart.bi', + expect.objectContaining({ eventName: 'export_clicked' }) + ); + localStorage.removeItem('debugBI'); + }); + + it('should forget expired events first, then the oldest, once it remembers dedupMaxKeys', () => { + jest.useFakeTimers(); + const { telemetry, sink } = engine({ + bi: { dedupWindowMs: 1000, dedupMaxKeys: 2 } + }); + const track = (name: string) => telemetry.bi.track(name as never); + + track('a'); + track('b'); + jest.advanceTimersByTime(1001); + track('c'); // a and b have expired: forgotten + track('d'); + track('e'); // nothing expired: c, the oldest, is forgotten + track('d'); // still remembered: a duplicate + track('c'); // forgotten, so recorded again + + expect(sink.events.map((e) => e.payload.eventName)).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'c' + ]); + }); + }); + + describe('logging details', () => { + it('should hand out a logger that does nothing while off', () => { + const { telemetry, logApi } = engine({ enabled: false }); + const logger = telemetry.logging.getLogger('checkout'); + + logger.log('a'); + logger.warn('b'); + logger.error('c'); + + expect(logApi.records).toEqual([]); + }); + + it('should write records to the console under debugLogger, with logger, context and correlation id', () => { + localStorage.setItem('debugLogger', 'true'); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { telemetry } = engine(); + + telemetry.logging + .getLogger('checkout') + .warn('Slow payment', { context: 'pay', correlationId: 'sc-1' }); + + expect(warn).toHaveBeenCalledWith( + '[cart][checkout][pay] Slow payment (sc-1)', + expect.objectContaining({ message: 'Slow payment' }) + ); + localStorage.removeItem('debugLogger'); + }); + }); + + describe('scenario details', () => { + it('should report a suspicious number of scenarios in flight', () => { + const report = jest.fn(); + const { telemetry } = engine({ report }); + + for (let i = 0; i <= 50; i++) { + telemetry.scenarios.start({ name: 'checkout' }); + } + + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('High number of active scenarios (51)') + ); + }); + + it('should call no listener, and report, when a record cannot be copied for them', () => { + const report = jest.fn(); + const listener = jest.fn(); + const { telemetry, sink } = engine({ report }); + telemetry.scenarios.onSettled(listener); + const copy = uwtTelemetrySafeUtil.uwtDeepClone; + jest + .spyOn(uwtTelemetrySafeUtil, 'uwtDeepClone') + .mockImplementation((value) => { + if ((value as { scenarioName?: unknown })?.scenarioName) { + throw new Error('cannot copy'); + } + return copy(value); + }); + + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(listener).not.toHaveBeenCalled(); + expect(sink.events).toHaveLength(1); + expect(report).toHaveBeenCalledWith( + 'error', + 'scenarioTelemetry.notify failed', + expect.any(Error) + ); + }); }); }); diff --git a/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts new file mode 100644 index 0000000..489b272 --- /dev/null +++ b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts @@ -0,0 +1,290 @@ +/** + * @jest-environment jsdom + */ +import { UWT_DEFAULT_TELEMETRY_CONFIG } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UwtScenarioTelemetryConfig } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { UwtScenarioRecord } from '../../models/uwt-scenario.models/uwt-scenario.models'; +import { UWT_TELEMETRY_EVENT_TYPE } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UWT_DEFAULT_REDACT_CONFIG } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { UwtScenario, UwtScenarioDeps } from './uwt-scenario'; + +/** Captures what scenarios emitted. */ +class RecordingSink extends UwtTelemetrySink { + readonly events: { eventType: string; payload: Record }[] = + []; + + record(eventType: string, payload: object): void { + this.events.push({ + eventType, + payload: payload as Record + }); + } + + recordError(): void {} + + getSessionId(): string | undefined { + return 'session-1'; + } + + setUserId(): void {} + + getUserId(): string | undefined { + return 'user-1'; + } + + flush(): void {} +} + +const MAX_TIMEOUT_MS = 2_147_483_647; + +describe('UwtScenario, in detail', () => { + let sink: RecordingSink; + let settled: UwtScenarioRecord[]; + + function scenario( + name: string, + config: Partial = {}, + overrides: Partial & { timeoutMs?: number } = {} + ): UwtScenario { + const { timeoutMs, ...deps } = overrides; + return new UwtScenario( + { name, timeoutMs }, + { + identity: { application: 'shop', environment: 'test', version: '1' }, + scenarioTelemetryConfig: { + ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario, + ...config + }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink, + onSettled: (_id, record) => settled.push(record), + ...deps + } + ); + } + + beforeEach(() => { + sink = new RecordingSink(); + settled = []; + localStorage.clear(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('delta', () => { + it('should grow while running, then hold the duration', () => { + const running = scenario('load'); + expect(running.delta).toBeGreaterThanOrEqual(0); + + running.complete(); + + expect(Math.round(running.delta)).toBe(settled[0].delta); + }); + + it('should be 0, with an empty record, for an inert scenario', () => { + const inert = scenario('load', {}, { inert: true }); + + inert.step('fetch'); + inert.complete(); + + expect(inert.delta).toBe(0); + expect(settled).toEqual([]); + expect(inert.toRecord()).toMatchObject({ + scenarioName: 'load', + delta: 0, + stepCount: 0, + steps: [] + }); + }); + }); + + describe('aggregates', () => { + it('should count calls, ignore an end without a start, and count a call still open at the end', () => { + const load = scenario('load'); + + load.aggregateEnd('render'); + load.aggregateStart('fetch'); + load.aggregateStart('fetch'); + load.aggregateEnd('fetch'); + load.aggregateStart('fetch'); + load.complete(); + + expect(settled[0].aggregates).toEqual([ + { name: 'fetch', elapsed: expect.any(Number), callCount: 2 } + ]); + }); + }); + + describe('the record', () => { + it('should carry the outcome: status code, message and reason', () => { + scenario('search').incomplete({ + statusCode: 404, + message: 'no results', + reason: 'empty' + }); + + expect(settled[0]).toMatchObject({ + status: 'incomplete', + statusCode: 404, + message: 'no results', + reason: 'empty' + }); + }); + + it('should flag a scenario with more steps than allowed', () => { + const busy = scenario('busy', { maxSteps: 2 }); + + busy.step('a').step('b').step('c'); + busy.complete(); + + expect(settled[0].exceededStepsLimit).toBe(true); + }); + + it("should keep a step's message and reason, when it ends or fails", () => { + const load = scenario('load'); + + load.step('fetch').endStep({ message: 'cached', reason: 'warm' }); + load.step('parse').failStep(new Error('bad json'), { reason: 'format' }); + load.complete(); + + const steps = settled[0].steps; + expect(steps.find((s) => s.name === 'fetch')).toMatchObject({ + message: 'cached', + reason: 'warm' + }); + expect(steps.find((s) => s.name === 'parse')).toMatchObject({ + status: 'failure', + reason: 'format' + }); + }); + }); + + describe('step events', () => { + it('should send each step as it ends, with the session and user, when enabled', () => { + const load = scenario('load', { emitLifecycleEvents: true }); + + load.step('fetch'); + load.step('render'); + load.complete(); + + const steps = sink.events.filter( + (e) => e.eventType === UWT_TELEMETRY_EVENT_TYPE.scenarioStep + ); + expect(steps.map((e) => e.payload.name)).toContain('fetch'); + expect(steps[0].payload).toMatchObject({ + scenarioName: 'load', + application: 'shop', + sessionId: 'session-1', + userId: 'user-1' + }); + }); + }); + + describe('user timings', () => { + let timings: { + mark: jest.Mock; + measure: jest.Mock; + clearMarks: jest.Mock; + clearMeasures: jest.Mock; + }; + + beforeEach(() => { + timings = { + mark: jest.fn(), + measure: jest.fn(), + clearMarks: jest.fn(), + clearMeasures: jest.fn() + }; + // jsdom has no User Timing API: stand-ins, removed after each test. + for (const [name, fn] of Object.entries(timings)) { + Object.defineProperty(performance, name, { + value: fn, + configurable: true, + writable: true + }); + } + }); + + afterEach(() => { + for (const name of Object.keys(timings)) { + delete (performance as unknown as Record)[name]; + } + }); + + it('should mark and measure each step and the whole scenario, then clear its marks', () => { + const load = new UwtScenario( + { name: 'load', feature: 'customers' }, + { + identity: { application: 'shop', environment: 'test', version: '1' }, + scenarioTelemetryConfig: { + ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario, + userTimings: true + }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink, + onSettled: () => undefined + } + ); + + load.step('fetch'); + load.step('render'); + load.complete(); + + const measured = timings.measure.mock.calls.map(([name]) => name); + expect(measured).toEqual( + expect.arrayContaining(['load [fetch]', 'load (customers)']) + ); + expect(timings.mark).toHaveBeenCalled(); + expect(timings.clearMarks).toHaveBeenCalled(); + }); + + it('should clear the marks of a scenario that never settles, after the fallback delay', () => { + jest.useFakeTimers({ doNotFake: ['performance'] }); + const endless = scenario( + 'endless', + { userTimings: true, markCleanupFallbackMs: 1000 }, + { timeoutMs: 0 } + ); + + endless.step('waiting'); + expect(timings.clearMarks).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1000); + + expect(timings.clearMarks).toHaveBeenCalled(); + expect(endless.isSettled).toBe(false); + }); + }); + + describe('timeouts', () => { + it('should time out after longer than one timer can wait', () => { + jest.useFakeTimers(); + const long = scenario('long', {}, { timeoutMs: MAX_TIMEOUT_MS + 1000 }); + + jest.advanceTimersByTime(MAX_TIMEOUT_MS); + expect(long.isSettled).toBe(false); + jest.advanceTimersByTime(1000); + + expect(long.status).toBe('timeout'); + }); + }); + + describe('debug output', () => { + it('should write each settled scenario to the console under debugScenario', () => { + localStorage.setItem('debugScenario', 'true'); + const log = jest.spyOn(console, 'log').mockImplementation(() => {}); + + scenario('checkout').complete(); + + expect(log).toHaveBeenCalledWith( + expect.stringMatching( + /^\[shop\]\[scenario\] checkout success in \d+ms -> / + ), + expect.objectContaining({ scenarioName: 'checkout' }) + ); + }); + }); +}); diff --git a/projects/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 0bb45f8..b611e0c 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 @@ -134,6 +144,53 @@ describe('the core broadcast classes', () => { }); }); + describe('the forwarding sink', () => { + it("should forward with its realm's origin, and adopt the identity the host announces", async () => { + const shell = new ChannelStub(UWT_DEFAULT_BROADCAST_CHANNEL); + const heard: Record[] = []; + shell.onmessage = ({ data }) => + heard.push(data as Record); + const sink = new UwtBroadcastTelemetrySink({ identity }); + + sink.record('com.uwt.bi', { eventName: 'x' }, { feature: 'cart' }); + sink.recordError({ name: 'Error', message: 'boom' }); + sink.setUserId('u-1'); + sink.flush(true); + shell.postMessage({ + kind: 'identity', + sessionId: 'shell-session', + userId: 'shell-user' + }); + shell.postMessage({ kind: 'nonsense' }); + await settle(); + + const origin = { + application: 'cart', + environment: 'test', + appVersion: '1' + }; + expect(heard).toEqual([ + { kind: 'identity-request' }, + { + kind: 'event', + eventType: 'com.uwt.bi', + payload: { eventName: 'x' }, + metadata: { feature: 'cart', ...origin } + }, + { + kind: 'error', + error: { name: 'Error', message: 'boom' }, + metadata: origin + }, + { kind: 'user', userId: 'u-1' }, + { kind: 'flush', beacon: true } + ]); + expect(sink.getSessionId()).toBe('shell-session'); + expect(sink.getUserId()).toBe('shell-user'); + sink.dispose(); + }); + }); + describe('a log query', () => { it('should resolve to [] when no host answers in time, its timer scheduled through runOutside', async () => { jest.useFakeTimers(); @@ -231,6 +288,218 @@ describe('the core broadcast classes', () => { 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 { From ad7922b242c9381cc2143bc186ef955ca5870d6a Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 10 Nov 2026 17:50:03 +0100 Subject: [PATCH 17/21] version packages independently --- .release-please-manifest.json | 1 - README.md | 77 ++++--- docs/ADAPTERS.md | 16 +- projects/ngx-ui-watchtower/DESIGN.md | 15 +- projects/ngx-ui-watchtower/package.json | 2 +- projects/ui-watchtower/package.json | 2 +- release-please-config.json | 12 +- scripts/publish-packages.mjs | 7 +- scripts/publish-packages.test.mjs | 65 +++--- scripts/release-check.mjs | 286 +++++++++++++++--------- scripts/smoke/smoke.mjs | 3 +- 11 files changed, 294 insertions(+), 192 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6c05ea8..8c7507b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,3 @@ { - "projects/ui-watchtower": "0.4.0", "projects/ngx-ui-watchtower": "0.4.0" } diff --git a/README.md b/README.md index 0a49ef2..31cee4a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Telemetry for frontend applications, in three parts: Events go to AWS CloudWatch RUM or another destination you choose; logs go to a backend you provide. -It comes as two packages, released together at one version: +It comes as two packages, each with its own version: | Package | Use it in | Guide | | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | @@ -46,8 +46,9 @@ docs/ ADAPTERS.md: how to write a framework adapter scripts/ the public API snapshot, the packaging smoke test, and publishing ``` -The Angular package depends on the core, never the other way round. Both -share one version and are released together (see [Releases](#releases)). +The Angular package depends on the core, never the other way round. Each +package has its own version; a core release also releases the Angular +package, with its dependency moved (see [Releases](#releases)). ## Getting started @@ -136,20 +137,29 @@ Squash-merge pull requests: the title then becomes the commit message on ## Releases Releases are automated with [release-please](https://github.com/googleapis/release-please) -— don't publish by hand. Both packages share one version and always release -together: a change to either releases both. - -1. Every push to `main` updates one release PR. `feat` bumps the minor - version, `fix`, `perf` and `revert` the patch; until 1.0, a breaking change - bumps the minor. Other types don't trigger a release. -2. The release PR holds both version bumps, each package's `CHANGELOG.md` - entry, and the Angular package's dependency on the core, moved to `^` the - new version. -3. Merging it tags both releases, `ui-watchtower-v` and +— don't publish by hand. Each package has its own version and changelog, and +is released when it changes: + +| A change to | Releases | +| ------------------- | ------------------------------------------------------------------------------------------ | +| The Angular package | The Angular package | +| The core | The core, and a patch of the Angular package that moves its dependency to `^` the new core | +| Both | Each, with its own bump | + +1. Every push to `main` updates one release PR, for every package with + changes. `feat` bumps the minor version, `fix`, `perf` and `revert` the + patch; until 1.0, a breaking change bumps the minor. Other types don't + trigger a release. +2. The release PR holds each released package's version bump and + `CHANGELOG.md` entry. When the core is released, Release Please's + `node-workspace` plugin also moves the Angular package's dependency on the + core and releases it as a patch, so it always depends on the core it was + built against. +3. Merging it tags each release, `ui-watchtower-v` or `ngx-ui-watchtower-v` (until 0.4.0, the Angular package's tags - were `v`), and creates their GitHub releases. GitHub Actions then - publishes the core, and only after it the Angular package, through trusted - publishing — no npm token. + were `v`), and creates its GitHub release. GitHub Actions then + publishes what was released, the core before the Angular package, through + trusted publishing — no npm token. **A failed publish** stops before the next package, so the Angular package never depends on a core that isn't on npm. To finish a partial release, run @@ -163,32 +173,41 @@ gh workflow run release-please.yml --ref ngx-ui-watchtower-v package only if its release tag points at that commit and npm doesn't have the version yet; any other answer from npm stops it. -**The core's first publish is by hand.** npm sets up trusted publishing only -for a package that exists. Before the first release PR is merged, an -`@absaoss-cps` owner publishes `@absaoss-cps/ui-watchtower` once, at the -version in `.release-please-manifest.json`, from `main`: +**The core's first release is 0.1.0, and its first publish is by hand.** The +core's `initial-version` in `release-please-config.json` sets that version. +npm sets up trusted publishing only for a package that exists. Before the +first release PR that releases the core is merged, an `@absaoss-cps` owner +publishes the core from that PR's branch: ```bash npm login +git switch release-please--branches--main npm ci && npm run build npm publish dist/ui-watchtower ``` Then, on npmjs.com, in the package's settings, add the trusted publisher the Angular package has: GitHub Actions, `AbsaOSS/ui-watchtower`, workflow -`release-please.yml`. If the release PR is merged first, publishing stops at -the core; do the above, then run the workflow on the release tag. +`release-please.yml`. After the merge, publishing finds 0.1.0 on npm, skips +it, and publishes the Angular package. If the release PR is merged first, +publishing stops at the core; publish it as above from the +`ui-watchtower-v0.1.0` tag, then run the workflow on the release tag. `bootstrap-sha` in `release-please-config.json` is the commit tagged -`v0.4.0`: it keeps what that release shipped out of the first linked -changelogs, and is read only until both packages have a release. If another -`v` release reaches `main` first, set it to that release's commit. -`npm run release:check` compares the two. +`v0.4.0`: it keeps what that release shipped out of the first changelogs, +and is read only until both packages have a release. If another `v` release +reaches `main` first, set it to that release's commit. `npm run release:check` +compares the two. `npm run release:check` replays releases through the Release Please version -the workflow runs, on this config: one changed package releases both, the -dependency moves with them, and merging creates the tags publishing looks -for. It installs Release Please, so it needs a network. +the workflow runs, on this config: + +- the first release, with the core at 0.1.0; +- an Angular-only change releasing only the Angular package; +- a core change also releasing the Angular package, with its dependency moved; +- merging creating the tags publishing looks for. + +It installs Release Please, so it needs a network. ## License diff --git a/docs/ADAPTERS.md b/docs/ADAPTERS.md index e50c402..d51d0d3 100644 --- a/docs/ADAPTERS.md +++ b/docs/ADAPTERS.md @@ -96,14 +96,16 @@ cover its wiring: ## Adding an adapter to this repository An adapter can live anywhere and depend on the published core. To release it -from this repository, together with the core, at the same version: +from this repository, with a version of its own; each core release then also +releases it, with its dependency on the core moved: -1. **The package.** `projects//` with its `package.json` (the - version the core has, and the core as a `^` dependency), README and +1. **The package.** `projects//` with its `package.json` (its own + version, and `^` the core's current version as a dependency), README and LICENSE, built into `dist/` by `npm run build`, after the core. 2. **Release Please.** Add the package to `release-please-config.json`, with - a `component`, and add that component to the `linked-versions` group. Add - its path to `.release-please-manifest.json`, at the current version. + a `component` and an `initial-version` (e.g. `0.1.0`), its first release. + Release Please adds it to `.release-please-manifest.json` when it + releases. 3. **Publishing.** Add it to `PACKAGES` in [scripts/publish-packages.mjs](../scripts/publish-packages.mjs), after the core, with `dependsOn: '@absaoss-cps/ui-watchtower'`. Add its @@ -120,5 +122,5 @@ from this repository, together with the core, at the same version: 5. **Docs.** Add a row to the root README's package table. Start with `PACKAGES`: `npm run test:release` then fails, in CI too, until -the config, the manifest, the linked group, the package's `package.json` and -the release job's outputs all match it. +the config (or the manifest), the package's `package.json` and the release +job's outputs all match it. diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index e3964e6..5b0b574 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -249,13 +249,14 @@ providers: [ application, prerendered as a fragment without `aws-rum-web` — which then holds no RUM code — and as a shell with `/rum`, whose RUM client is a chunk loaded on demand. -- **Both packages share one version and release together.** Release Please - links their versions: a change to either releases both, and the same - release PR moves this package's dependency on the core to `^` the new - version. The release workflow publishes the core first and this package - only after it, so this package never depends on a core version npm doesn't - have. Publishing skips what npm already has, so running the workflow again - on the release tag completes a partial release. +- **Each package has its own version.** A change to this package releases + only this package. A core release also releases this package as a patch: + Release Please's `node-workspace` plugin moves its dependency on the core + to `^` the new version in the same release PR, so it always depends on the + core it was built against. The release workflow publishes the core first + and this package only after it, so this package never depends on a core + version npm doesn't have. Publishing skips what npm already has, so running + the workflow again on the release tag completes a partial release. - **Test doubles live in the specs that use them**, declared inline, never in a shared file and never exported. The one browser gap needing a stub is `BroadcastChannel`, which jsdom doesn't implement. diff --git a/projects/ngx-ui-watchtower/package.json b/projects/ngx-ui-watchtower/package.json index 11db2d8..58bd169 100644 --- a/projects/ngx-ui-watchtower/package.json +++ b/projects/ngx-ui-watchtower/package.json @@ -20,7 +20,7 @@ "homepage": "https://github.com/AbsaOSS/ui-watchtower", "license": "Apache-2.0", "dependencies": { - "@absaoss-cps/ui-watchtower": "^0.4.0", + "@absaoss-cps/ui-watchtower": "^0.1.0", "tslib": "^2.8.1" }, "sideEffects": false, diff --git a/projects/ui-watchtower/package.json b/projects/ui-watchtower/package.json index 1e00897..bc739c4 100644 --- a/projects/ui-watchtower/package.json +++ b/projects/ui-watchtower/package.json @@ -1,6 +1,6 @@ { "name": "@absaoss-cps/ui-watchtower", - "version": "0.4.0", + "version": "0.1.0", "description": "Framework-independent telemetry engine: application logs, scenario health telemetry and business/UX events, for any JavaScript or TypeScript application.", "repository": { "type": "git", diff --git a/release-please-config.json b/release-please-config.json index 552c6f8..677b0c1 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -20,19 +20,13 @@ "packages": { "projects/ui-watchtower": { "package-name": "@absaoss-cps/ui-watchtower", - "component": "ui-watchtower" + "component": "ui-watchtower", + "initial-version": "0.1.0" }, "projects/ngx-ui-watchtower": { "package-name": "@absaoss-cps/ngx-ui-watchtower", "component": "ngx-ui-watchtower" } }, - "plugins": [ - { "type": "node-workspace", "merge": false }, - { - "type": "linked-versions", - "groupName": "ui-watchtower", - "components": ["ui-watchtower", "ngx-ui-watchtower"] - } - ] + "plugins": [{ "type": "node-workspace" }] } diff --git a/scripts/publish-packages.mjs b/scripts/publish-packages.mjs index 5b47d10..dda2b61 100644 --- a/scripts/publish-packages.mjs +++ b/scripts/publish-packages.mjs @@ -103,7 +103,10 @@ function tagPointsAt(run, release, head) { return listing.split('\n').some((line) => line.split('\t')[0] === head); } -/** Linked versions: the adapter depends on the core it was released with. */ +/** + * The adapter depends on `^` the core it was built against: Release Please's + * node-workspace plugin moves that range with every core release. + */ function checkDependencyRange(release, releases) { if (!release.dependsOn) { return; @@ -113,7 +116,7 @@ function checkDependencyRange(release, releases) { if (range !== `^${dependency.version}`) { throw new Error( `${release.name}@${release.version} depends on ${dependency.name}@${range}, ` + - `not on ^${dependency.version}, the version released with it` + `not on ^${dependency.version}, the version it was built against` ); } } diff --git a/scripts/publish-packages.test.mjs b/scripts/publish-packages.test.mjs index 4a13200..57bc418 100644 --- a/scripts/publish-packages.test.mjs +++ b/scripts/publish-packages.test.mjs @@ -17,8 +17,8 @@ import { PACKAGES, publishPackages } from './publish-packages.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const [CORE, NGX] = PACKAGES.map((pkg) => pkg.name); const HEAD = 'a1b2c3d'; -const CORE_TAG = 'ui-watchtower-v0.5.0'; -const NGX_TAG = 'ngx-ui-watchtower-v0.5.0'; +const CORE_TAG = 'ui-watchtower-v0.6.0'; +const NGX_TAG = 'ngx-ui-watchtower-v0.5.2'; const roots = []; after(() => @@ -26,7 +26,8 @@ after(() => ); /** - * Both packages built at 0.5.0; `tags` point at HEAD, `annotated` too as + * Each package built at its own version: the core at 0.6.0, the Angular + * package at 0.5.2 on `^0.6.0`. `tags` point at HEAD, `annotated` too as * annotated tags, `elsewhere` at another commit; the registry holds `onNpm`. `failing` makes a command fail: * `{ 'view ': '', 'publish ': true }`. */ @@ -36,14 +37,14 @@ function workspace({ elsewhere = [], onNpm = [], failing = {}, - range = '^0.5.0', + range = '^0.6.0', built = {} } = {}) { const root = mkdtempSync(join(tmpdir(), 'uwt-publish-')); roots.push(root); const manifests = { - [CORE]: { name: CORE, version: '0.5.0' }, - [NGX]: { name: NGX, version: '0.5.0', dependencies: { [CORE]: range } }, + [CORE]: { name: CORE, version: '0.6.0' }, + [NGX]: { name: NGX, version: '0.5.2', dependencies: { [CORE]: range } }, ...built }; for (const pkg of PACKAGES) { @@ -137,32 +138,32 @@ describe('publishing a release', () => { assert.deepEqual(release.published, [CORE, NGX]); }); - it('publishes a coordinated release of both packages, the core first', () => { - const release = workspace({ onNpm: [`${CORE}@0.4.0`, `${NGX}@0.4.0`] }); + it('publishes a release of both packages, the core first', () => { + const release = workspace({ onNpm: [`${CORE}@0.5.0`, `${NGX}@0.5.1`] }); publishPackages(release); assert.deepEqual(release.published, [CORE, NGX]); assert.deepEqual(release.lines, [ - `${CORE}@0.5.0: published`, - `${NGX}@0.5.0: published` + `${CORE}@0.6.0: published`, + `${NGX}@0.5.2: published` ]); }); it('retries a partial release: skips the core already on npm, publishes the Angular package', () => { - const release = workspace({ onNpm: [`${CORE}@0.5.0`] }); + const release = workspace({ onNpm: [`${CORE}@0.6.0`] }); publishPackages(release); assert.deepEqual(release.published, [NGX]); assert.deepEqual(release.lines, [ - `${CORE}@0.5.0: skipped, already on npm`, - `${NGX}@0.5.0: published` + `${CORE}@0.6.0: skipped, already on npm`, + `${NGX}@0.5.2: published` ]); }); it('can run again after a complete release, publishing nothing', () => { - const release = workspace({ onNpm: [`${CORE}@0.5.0`, `${NGX}@0.5.0`] }); + const release = workspace({ onNpm: [`${CORE}@0.6.0`, `${NGX}@0.5.2`] }); publishPackages(release); @@ -192,18 +193,18 @@ describe('publishing a release', () => { assert.throws( () => publishPackages(release), - /npm publish @absaoss-cps\/ui-watchtower@0\.5\.0 failed/ + /npm publish @absaoss-cps\/ui-watchtower@0\.6\.0 failed/ ); assert.deepEqual(release.published, []); assert.ok(!release.commands.some((command) => command.includes(NGX))); }); it('stops on any npm answer other than not found, taking it for neither published nor missing', () => { - const release = workspace({ failing: { [`view ${CORE}@0.5.0`]: 'E401' } }); + const release = workspace({ failing: { [`view ${CORE}@0.6.0`]: 'E401' } }); assert.throws( () => publishPackages(release), - /npm view @absaoss-cps\/ui-watchtower@0\.5\.0 failed: E401/ + /npm view @absaoss-cps\/ui-watchtower@0\.6\.0 failed: E401/ ); assert.deepEqual(release.published, []); }); @@ -212,7 +213,7 @@ describe('publishing a release', () => { const release = workspace({ tags: [NGX_TAG], elsewhere: [CORE_TAG], - onNpm: [`${CORE}@0.5.0`] + onNpm: [`${CORE}@0.6.0`] }); publishPackages(release); @@ -220,7 +221,7 @@ describe('publishing a release', () => { assert.deepEqual(release.published, [NGX]); assert.equal( release.lines[0], - `${CORE}@0.5.0: skipped, not released at this commit` + `${CORE}@0.6.0: skipped, not released at this commit` ); }); @@ -229,24 +230,24 @@ describe('publishing a release', () => { assert.throws( () => publishPackages(release), - /needs @absaoss-cps\/ui-watchtower@0\.5\.0, which is not on npm/ + /needs @absaoss-cps\/ui-watchtower@0\.6\.0, which is not on npm/ ); assert.deepEqual(release.published, []); }); - it('publishes nothing when the Angular package does not depend on the core released with it', () => { - const release = workspace({ range: '^0.4.0' }); + it('publishes nothing when the Angular package does not depend on the core it was built against', () => { + const release = workspace({ range: '^0.5.0' }); assert.throws( () => publishPackages(release), - /depends on @absaoss-cps\/ui-watchtower@\^0\.4\.0/ + /depends on @absaoss-cps\/ui-watchtower@\^0\.5\.0/ ); assert.deepEqual(release.published, []); }); it('publishes nothing from a directory that is not the built package', () => { const release = workspace({ - built: { [NGX]: { name: NGX, version: '0.5.0', private: true } } + built: { [NGX]: { name: NGX, version: '0.5.2', private: true } } }); assert.throws( @@ -269,18 +270,24 @@ describe('the release wiring', () => { it('publishes the packages Release Please releases, under the same names and tags', () => { const manifest = JSON.parse(read('.release-please-manifest.json')); - const linked = config.plugins.find( - (plugin) => plugin.type === 'linked-versions' + const plugins = config.plugins.map((plugin) => plugin.type); + + assert.ok( + plugins.includes('node-workspace'), + "node-workspace moves the adapters' dependency on the core" + ); + assert.ok( + !plugins.includes('linked-versions'), + 'each package versioned on its own' ); for (const pkg of PACKAGES) { assert.equal(config.packages[pkg.path].component, pkg.component); assert.equal(config.packages[pkg.path]['package-name'], pkg.name); assert.equal(JSON.parse(read(`${pkg.path}/package.json`)).name, pkg.name); - assert.ok(pkg.path in manifest, `${pkg.path} is in the manifest`); assert.ok( - linked.components.includes(pkg.component), - `${pkg.component} is linked` + pkg.path in manifest || config.packages[pkg.path]['initial-version'], + `${pkg.path} is in the manifest, or has an initial-version` ); assert.match(pkg.dist, /^dist\//); } diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs index 5b3bab0..b77a2a9 100644 --- a/scripts/release-check.mjs +++ b/scripts/release-check.mjs @@ -74,25 +74,39 @@ const commit = (sha, message, ...files) => ({ sha, message, files }); const file = (pkg, name = 'src/index.ts') => `${pkg.path}/${name}`; const json = (files, path) => JSON.parse(files[path]); -/** The release files at one linked `version`: what a release leaves behind. */ -function filesAt(version) { +/** + * The release files with each package at its version in `versions` (by + * component), and a dependent on `^` its dependency's: what a release leaves + * behind. + */ +function filesAt(versions) { const files = { 'release-please-config.json': read('release-please-config.json'), '.release-please-manifest.json': JSON.stringify( - Object.fromEntries(PACKAGES.map((pkg) => [pkg.path, version])) + Object.fromEntries( + PACKAGES.map((pkg) => [pkg.path, versions[pkg.component]]) + ) ) }; for (const pkg of PACKAGES) { const packageJson = JSON.parse(read(`${pkg.path}/package.json`)); - packageJson.version = version; + packageJson.version = versions[pkg.component]; if (pkg.dependsOn) { - packageJson.dependencies[pkg.dependsOn] = `^${version}`; + const dependency = PACKAGES.find((other) => other.name === pkg.dependsOn); + packageJson.dependencies[pkg.dependsOn] = + `^${versions[dependency.component]}`; } files[`${pkg.path}/package.json`] = JSON.stringify(packageJson, null, 2); } return files; } +/** The minor release after `version`: what a `feat` brings before 1.0. */ +function minorAfter(version) { + const [major, minor] = version.split('.'); + return `${major}.${Number(minor) + 1}.0`; +} + /** The repository after merging `pullRequest`. */ function merge(files, pullRequest) { const merged = { ...files }; @@ -205,32 +219,93 @@ async function main() { ]; return { merged, versions, range }; }; - const linkedAt = (version, ...changes) => ({ - files: filesAt(version), + /** A package's changelog entry for `version`: from its heading to the next. */ + const entry = (merged, pkg, version) => { + const changelog = merged[`${pkg.path}/CHANGELOG.md`] ?? ''; + const heading = new RegExp( + `^## \\[?${version.replace(/\./g, '\\.')}\\]?[ (]`, + 'm' + ); + const start = changelog.search(heading); + if (start < 0) { + return ''; + } + const end = changelog.indexOf('\n## ', start + 1); + return changelog.slice(start, end < 0 ? undefined : end); + }; + /** The releases merging `pullRequest` creates, as `path tag sha`. */ + const releasesAfter = async (state, pullRequest) => { + const title = pullRequest.title.toString(); + const releases = await ( + await manifestOf({ + files: merge(state.files, pullRequest), + releases: state.releases, + commits: [commit('m1', title), ...state.commits], + pulls: [ + { + headBranchName: pullRequest.headRefName, + baseBranchName: 'main', + number: 1, + title, + body: pullRequest.body.toString(), + labels: pullRequest.labels, + files: [], + sha: 'm1' + } + ] + }) + ).buildReleases(); + return releases + .map((created) => `${created.path} ${created.tag} ${created.sha}`) + .sort(); + }; + /** The releases expected for `[package, version]` pairs. */ + const tagsOf = (...released) => + released + .map(([pkg, version]) => `${pkg.path} ${pkg.component}-v${version} m1`) + .sort(); + /** Each package released at its version in `versions`, then `changes`. */ + const releasedAt = (versions, ...changes) => ({ + files: filesAt(versions), releases: PACKAGES.map((pkg) => - release(`${pkg.component}-v${version}`, 'r1') + release(`${pkg.component}-v${versions[pkg.component]}`, 'r1') ), commits: [ ...changes, - commit('r1', 'chore: release main', file(CORE, 'package.json')) + commit( + 'r1', + 'chore: release main', + ...PACKAGES.map((pkg) => file(pkg, 'package.json')) + ) ] }); checkConfigKeys(configSchema, parser); - const current = manifest[CORE.path]; check( - PACKAGES.every((pkg) => manifest[pkg.path] === current), - 'both packages at one version in the manifest' + PACKAGES.every( + (pkg) => + pkg.path in manifest || config.packages[pkg.path]?.['initial-version'] + ), + 'every package in the manifest, or given an initial-version' + ); + check( + !config.plugins.some((plugin) => plugin.type === 'linked-versions'), + 'independent versions: no linked-versions group' ); console.log( - `✔ config: only keys Release Please defines; both packages at ${current}` + '✔ config: only keys Release Please defines; each package versioned on its own' ); - // The first linked release, replayed from the workspace as it is: the core - // never released, the Angular package's last release tagged v - // (before linking) at bootstrap-sha, which keeps what that release already - // shipped out of the new changelogs. While git has that tag, bootstrap-sha - // must be its commit; once the packages are linked, the tag is gone. + // The first release, replayed from the workspace as it is. The core: its + // initial-version until it has a release of its own. The Angular package: + // its last release tagged v (before its tags named the component) + // at bootstrap-sha, which keeps what that release shipped out of the new + // changelog. While git has that tag, bootstrap-sha must be its commit. + const current = manifest[NGX.path]; + const coreFirst = manifest[CORE.path] + ? minorAfter(manifest[CORE.path]) + : config.packages[CORE.path]['initial-version']; + const ngxNext = minorAfter(current); const lastTagged = spawnSync( 'git', ['rev-parse', '--verify', '--quiet', `v${current}^{commit}`], @@ -268,86 +343,126 @@ async function main() { commit('c0', 'feat: a feature already released', file(NGX)) ] }; - const [major, minor] = current.split('.'); - const next = `${major}.${Number(minor) + 1}.0`; const firstPullRequest = await pullRequestFor(first); - check(firstPullRequest, 'a release PR for the first linked release'); + check(firstPullRequest, 'a release PR for the first release'); const firstRelease = outcome(first.files, firstPullRequest); - // Each package's new changelog entry: from its heading to the one before. - const changelogs = PACKAGES.map((pkg) => { - const changelog = firstRelease.merged[`${pkg.path}/CHANGELOG.md`] ?? ''; - const start = changelog.indexOf(`## [${next}]`); - const end = changelog.indexOf('\n## ', start + 1); - return start < 0 ? '' : changelog.slice(start, end < 0 ? undefined : end); - }); + const coreEntry = entry(firstRelease.merged, CORE, coreFirst); + const ngxEntry = entry(firstRelease.merged, NGX, ngxNext); check( - firstRelease.versions[CORE.component] === next && - firstRelease.versions[NGX.component] === next, - `both packages released at ${next}, got ${JSON.stringify(firstRelease.versions)}` + firstRelease.versions[CORE.component] === coreFirst && + firstRelease.versions[NGX.component] === ngxNext, + `the core at ${coreFirst} and the Angular package at ${ngxNext}, got ${JSON.stringify(firstRelease.versions)}` ); check( - firstRelease.range === `^${next}`, - `the Angular package on ^${next}, got ${firstRelease.range}` + firstRelease.range === `^${coreFirst}`, + `the Angular package on ^${coreFirst}, got ${firstRelease.range}` + ); + const firstManifest = json( + firstRelease.merged, + '.release-please-manifest.json' ); check( - PACKAGES.every( - (pkg) => - json(firstRelease.merged, '.release-please-manifest.json')[pkg.path] === - next - ), - `the manifest at ${next}` + firstManifest[CORE.path] === coreFirst && + firstManifest[NGX.path] === ngxNext, + `the manifest at ${coreFirst} and ${ngxNext}, got ${JSON.stringify(firstManifest)}` + ); + check( + coreEntry && ngxEntry, + `a ${coreFirst} entry in the core's changelog and a ${ngxNext} one in the Angular package's` ); - check(changelogs.every(Boolean), `a ${next} entry in both changelogs`); check( - changelogs[0].includes('add the framework-independent engine') && - !changelogs[0].includes('flush logs on page hide'), + coreEntry.includes('add the framework-independent engine') && + !coreEntry.includes('flush logs on page hide'), "only the core's own changes in its changelog" ); check( - changelogs[1].includes('flush logs on page hide') && - !changelogs.some((changelog) => changelog.includes('already released')), + ngxEntry.includes('flush logs on page hide') && + ![coreEntry, ngxEntry].some((text) => text.includes('already released')), 'nothing from the release before in the new entries' ); + const firstTags = await releasesAfter(first, firstPullRequest); + const firstExpected = tagsOf([CORE, coreFirst], [NGX, ngxNext]); + check( + JSON.stringify(firstTags) === JSON.stringify(firstExpected), + `merging it to create ${firstExpected.join(', ')}; got ${firstTags.join(', ')}` + ); console.log( - `✔ first linked release: both packages ${current} → ${next}, the Angular package on ^${next}, two changelogs` + - (lastTagged ? `, from v${current}'s commit` : '') + `✔ first release: the core at ${coreFirst}, the Angular package ${current} → ${ngxNext} on ^${coreFirst}, two changelogs` + + (lastTagged ? `, from v${current}'s commit` : '') + + `; merging creates ${CORE.component}-v${coreFirst} and ${NGX.component}-v${ngxNext}` ); - for (const [what, change, expected] of [ + // Later releases, from packages at versions of their own. + const start = { [CORE.component]: '0.6.0', [NGX.component]: '0.5.1' }; + for (const [what, changes, expected] of [ [ - 'an Angular-only fix', - commit('d1', 'fix: an Angular-only fix', file(NGX)), - '0.5.1' + 'an Angular-only fix releases only the Angular package', + [commit('d1', 'fix: an Angular-only fix', file(NGX))], + { core: '0.6.0', ngx: '0.5.2', released: [NGX] } ], [ - 'a core-only feature', - commit('d1', 'feat: a core-only feature', file(CORE)), - '0.6.0' + 'a core feature releases the core, and the Angular package to move its dependency', + [commit('d1', 'feat: a core feature', file(CORE))], + { core: '0.7.0', ngx: '0.5.2', released: [CORE, NGX] } ], [ - 'a breaking change before 1.0', - commit('d1', 'feat!: drop an option', file(CORE)), - '0.6.0' + 'a breaking core change before 1.0 does the same', + [commit('d1', 'feat!: drop an option', file(CORE))], + { core: '0.7.0', ngx: '0.5.2', released: [CORE, NGX] } + ], + [ + 'changes to both give each its own bump', + [ + commit('d2', 'feat: an Angular feature', file(NGX)), + commit('d1', 'fix: a core fix', file(CORE)) + ], + { core: '0.6.1', ngx: '0.6.0', released: [CORE, NGX] } ] ]) { - const state = linkedAt('0.5.0', change); + const state = releasedAt(start, ...changes); const pullRequest = await pullRequestFor(state); - check(pullRequest, `a release PR for ${what}`); - const { versions, range } = outcome(state.files, pullRequest); + check(pullRequest, `a release PR: ${what}`); + const { merged, versions, range } = outcome(state.files, pullRequest); + check( + versions[CORE.component] === expected.core && + versions[NGX.component] === expected.ngx, + `${what}: the core at ${expected.core} and the Angular package at ${expected.ngx}, got ${JSON.stringify(versions)}` + ); check( - versions[CORE.component] === expected && - versions[NGX.component] === expected, - `${what} to release both packages at ${expected}, got ${JSON.stringify(versions)}` + range === `^${expected.core}`, + `${what}: the Angular package on ^${expected.core}, got ${range}` + ); + if (expected.released.includes(CORE)) { + const ngxEntry = entry(merged, NGX, expected.ngx); + check( + ngxEntry.includes( + `${CORE.name} bumped from ^${start[CORE.component]} to ^${expected.core}` + ), + `${what}: the dependency update in the Angular package's changelog, got ${JSON.stringify(ngxEntry)}` + ); + } + const tags = await releasesAfter(state, pullRequest); + const tagsExpected = tagsOf( + ...expected.released.map((pkg) => [ + pkg, + pkg === CORE ? expected.core : expected.ngx + ]) ); check( - range === `^${expected}`, - `the Angular package on ^${expected} after ${what}, got ${range}` + JSON.stringify(tags) === JSON.stringify(tagsExpected), + `${what}: merging to create ${tagsExpected.join(', ')}; got ${tags.join(', ')}` + ); + const core = + expected.core === start[CORE.component] + ? `core unchanged at ${expected.core}` + : `core ${start[CORE.component]} → ${expected.core}`; + console.log( + `✔ ${what}: ${core}, Angular ${start[NGX.component]} → ${expected.ngx} on ^${expected.core}` ); - console.log(`✔ ${what} releases both packages: 0.5.0 → ${expected}`); } - const quiet = linkedAt( - '0.5.0', + const quiet = releasedAt( + start, commit('e4', 'docs: explain a setting', file(CORE, 'README.md')), commit('e3', 'refactor: tidy the engine', file(CORE)), commit('e2', 'test: cover a case', file(NGX, 'src/a.spec.ts')), @@ -360,43 +475,6 @@ async function main() { console.log( '✔ no release from docs, refactors or tests, nor from changes outside the packages' ); - - const merged = merge(first.files, firstPullRequest); - const releases = await ( - await manifestOf({ - files: merged, - releases: first.releases, - commits: [ - commit('m1', firstPullRequest.title.toString()), - ...first.commits - ], - pulls: [ - { - headBranchName: firstPullRequest.headRefName, - baseBranchName: 'main', - number: 1, - title: firstPullRequest.title.toString(), - body: firstPullRequest.body.toString(), - labels: firstPullRequest.labels, - files: [], - sha: 'm1' - } - ] - }) - ).buildReleases(); - const created = releases - .map((created) => `${created.path} ${created.tag} ${created.sha}`) - .sort(); - const expected = PACKAGES.map( - (pkg) => `${pkg.path} ${pkg.component}-v${next} m1` - ).sort(); - check( - JSON.stringify(created) === JSON.stringify(expected), - `merging it to create ${expected.join(', ')}; got ${created.join(', ')}` - ); - console.log( - `✔ merging the release PR creates ${PACKAGES.map((pkg) => `${pkg.component}-v${next}`).join(' and ')} at the merge commit: the tags publishing looks for` - ); } try { diff --git a/scripts/smoke/smoke.mjs b/scripts/smoke/smoke.mjs index 29fcf65..a37b9d1 100644 --- a/scripts/smoke/smoke.mjs +++ b/scripts/smoke/smoke.mjs @@ -243,11 +243,10 @@ try { const ngx = pack('dist/ngx-ui-watchtower'); checkTarball(core, ['aws-rum-web', 'rxjs']); checkTarball(ngx, ['aws-rum-web']); - check(core.version === ngx.version, 'both packages at one version'); const coreRange = ngx.manifest.dependencies['@absaoss-cps/ui-watchtower']; check( coreRange?.replace(/^[\^~]/, '') === core.version, - `the Angular package to depend on the core it is released with (${core.version}), not ${coreRange}` + `the Angular package to depend on the core it was built against (${core.version}), not ${coreRange}` ); check( !ngx.manifest.peerDependencies['@absaoss-cps/ui-watchtower'], From 22c31b3298b69d75e50731433a5699af54b32cf6 Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 10 Nov 2026 17:57:39 +0100 Subject: [PATCH 18/21] docs update --- README.md | 1 + projects/ngx-ui-watchtower/DESIGN.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 31cee4a..ccff32f 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ publishes the core from that PR's branch: ```bash npm login +git fetch origin git switch release-please--branches--main npm ci && npm run build npm publish dist/ui-watchtower diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index 5b0b574..7304a17 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -97,7 +97,8 @@ destination, scenarios need a destination and no log provider. The services are root singletons, so each concern exists once per injector, and all of them share the one destination and log provider. `settled$` is the core's `onSettled` as an Observable that completes on destroy. It listens to the -core only while subscribed, so no record is copied for nobody; `traceScenario` is the +core only while subscribed, so no record is copied while nobody is +subscribed; `traceScenario` is the core's `./rxjs` operator, reporting through this package's dev-mode reporter. Framework-free applications compose the same three classes with `uwtCreateTelemetry`. From c4cce01fc95082fdba32d557ceade7436cf0bdf5 Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 10 Nov 2026 18:56:41 +0100 Subject: [PATCH 19/21] address feedback --- docs/ADAPTERS.md | 3 +- projects/ui-watchtower/README.md | 5 +-- .../uwt-telemetry/uwt-telemetry.spec.ts | 12 ++++++- .../lib/engine/uwt-telemetry/uwt-telemetry.ts | 10 ++++-- .../uwt-broadcast-log-api.provider.ts | 15 ++++++--- .../uwt-broadcast-telemetry.sink.ts | 15 +++++---- .../sinks/uwt-broadcast/uwt-broadcast.spec.ts | 33 +++++++++++++++++++ 7 files changed, 76 insertions(+), 17 deletions(-) diff --git a/docs/ADAPTERS.md b/docs/ADAPTERS.md index d51d0d3..cc660e9 100644 --- a/docs/ADAPTERS.md +++ b/docs/ADAPTERS.md @@ -32,7 +32,8 @@ private file. stable, as Angular's zone does. 3. **Failures.** Pass a `report`, such as `uwtConsoleReporter` in development. Telemetry calls never throw; `uwtCreateTelemetry` does, when - the identity, destination or log store is missing. + the identity (its application, environment or version), destination or + log store is missing. 4. **Reactive APIs.** Adapt `scenarios.onSettled(listener)`, which returns an unsubscribe function, to the framework's primitive: an Observable, a signal, a store. diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md index 3831fc4..cd8c462 100644 --- a/projects/ui-watchtower/README.md +++ b/projects/ui-watchtower/README.md @@ -100,8 +100,9 @@ it records nothing and never settles), lookups find nothing, loggers do nothing and `query()` resolves `[]`. Pass the switch on to what you created for the engine, so that off it requests no credentials, downloads no SDK and opens no channel: `enabled` to the RUM sink and the broadcast classes, and -`open` to `uwtConnectBroadcastChannel()`. A sink and log provider sharing a -connection ignore their own `enabled`: the connection decides. +`open` to `uwtConnectBroadcastChannel()`. Off, a sink or log provider sends +nothing even over a shared connection, but only `open: false` keeps the +channel itself from opening. ## AWS CloudWatch RUM diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts index f6f639d..8bf2a41 100644 --- a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts @@ -343,7 +343,17 @@ describe('uwtCreateTelemetry', () => { expect(() => uwtCreateTelemetry({} as unknown as UwtTelemetryOptions) ).toThrow( - '[ui-watchtower] uwtCreateTelemetry needs identity.application, destination, logApi' + '[ui-watchtower] uwtCreateTelemetry needs identity.application, identity.environment, identity.version, destination, logApi' + ); + }); + + it('should throw for an identity without its environment and version, which every record carries', () => { + expect(() => + create({ + identity: { application: 'cart' } as UwtTelemetryOptions['identity'] + }) + ).toThrow( + '[ui-watchtower] uwtCreateTelemetry needs identity.environment, identity.version' ); }); }); diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts index 8eddfe5..04bbc60 100644 --- a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts @@ -115,7 +115,8 @@ export interface UwtTelemetry { * * @param options identity, destination, log store and overrides * @returns the engine - * @throws when the identity, destination or log store is missing + * @throws when the identity (its application, environment and version), the + * destination or the log store is missing * * @group Utils */ @@ -187,8 +188,13 @@ export function uwtCreateTelemetry(options: UwtTelemetryOptions): UwtTelemetry { /** Configuration errors throw: telemetry fails open, its setup does not. */ function assertOptions(options: UwtTelemetryOptions): void { + const identity = options?.identity; const missing = [ - !options?.identity?.application && 'identity.application', + !identity?.application && 'identity.application', + // Strings, as every record carries them: a shell's host rejects a + // forwarded log record without them. + typeof identity?.environment !== 'string' && 'identity.environment', + typeof identity?.version !== 'string' && 'identity.version', typeof options?.destination?.record !== 'function' && 'destination', typeof options?.logApi?.send !== 'function' && 'logApi' ].filter(Boolean); diff --git a/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts b/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts index c0e7ea3..53068af 100644 --- a/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts +++ b/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts @@ -42,13 +42,15 @@ export interface UwtBroadcastLogApiProviderOptions { channelName?: string; /** * `false` forwards nothing, and every query resolves to `[]` at once — - * on a server, or while telemetry is off. Default `true`. + * on a server, or while telemetry is off — even over a shared + * {@link connection}. Default `true`. */ enabled?: boolean; /** * A connection to share with the realm's {@link UwtBroadcastTelemetrySink}, - * instead of opening one of its own. Left open by - * {@link UwtBroadcastLogApiProvider.dispose}: whoever opened it closes it. + * instead of opening one of its own. Not used while {@link enabled} is + * `false`. Left open by {@link UwtBroadcastLogApiProvider.dispose}: whoever + * opened it closes it. */ connection?: UwtBroadcastConnection; /** @@ -91,9 +93,12 @@ export class UwtBroadcastLogApiProvider implements UwtLogApiProvider { constructor(options: UwtBroadcastLogApiProviderOptions = {}) { this.report = options.report; - this.ownsConnection = !options.connection; + // Off wins over a shared connection: this provider then has a closed one + // of its own, and the shared one stays open for its other users. + const shared = options.enabled === false ? undefined : options.connection; + this.ownsConnection = !shared; this.connection = - options.connection ?? + shared ?? uwtConnectBroadcastChannel({ channelName: options.channelName, open: options.enabled !== false, diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts index a3f6864..807cea5 100644 --- a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts @@ -28,15 +28,15 @@ export interface UwtBroadcastTelemetrySinkOptions { */ channelName?: string; /** - * `false` forwards nothing — on a server, or while telemetry is off. - * Default `true`. + * `false` forwards nothing — on a server, or while telemetry is off — + * even over a shared {@link connection}. Default `true`. */ enabled?: boolean; /** * A connection to share with the realm's * {@link UwtBroadcastLogApiProvider}, instead of opening one of its own. - * Left open by {@link UwtBroadcastTelemetrySink.dispose}: whoever opened - * it closes it. + * Not used while {@link enabled} is `false`. Left open by + * {@link UwtBroadcastTelemetrySink.dispose}: whoever opened it closes it. */ connection?: UwtBroadcastConnection; /** Receives failures; see {@link UwtReporter}. */ @@ -79,9 +79,12 @@ export class UwtBroadcastTelemetrySink extends UwtTelemetrySink { constructor(options: UwtBroadcastTelemetrySinkOptions) { super(); this.report = options.report; - this.ownsConnection = !options.connection; + // Off wins over a shared connection: this sink then has a closed one of + // its own, and the shared one stays open for its other users. + const shared = options.enabled === false ? undefined : options.connection; + this.ownsConnection = !shared; this.connection = - options.connection ?? + shared ?? uwtConnectBroadcastChannel({ channelName: options.channelName, open: options.enabled !== false, 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 b611e0c..36aedff 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 @@ -142,6 +142,39 @@ 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', () => { From 4a30d76851cba959ec4190012bf60abb33a880d9 Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 10 Nov 2026 20:10:12 +0100 Subject: [PATCH 20/21] address more feedback --- projects/ngx-ui-watchtower/DESIGN.md | 4 +- projects/ngx-ui-watchtower/README.md | 5 +- .../uwt-rum-internal.util.spec.ts | 163 ++++++++++++++++++ .../uwt-logger.service.spec.ts | 29 +++- .../uwt-telemetry-safe.util.spec.ts | 14 ++ .../uwt-telemetry-safe.util.ts | 2 +- projects/ui-watchtower/README.md | 7 +- .../uwt-log-telemetry/uwt-log-telemetry.ts | 12 +- .../uwt-telemetry/uwt-telemetry.spec.ts | 16 +- .../lib/engine/uwt-telemetry/uwt-telemetry.ts | 8 +- .../uwt-broadcast-log-api.provider.ts | 18 +- .../uwt-scenario.behaviour.spec.ts | 19 ++ .../lib/scenario/uwt-scenario/uwt-scenario.ts | 3 +- .../uwt-broadcast-telemetry.sink.ts | 23 ++- .../sinks/uwt-broadcast/uwt-broadcast.spec.ts | 34 ++++ 15 files changed, 326 insertions(+), 31 deletions(-) create mode 100644 projects/ngx-ui-watchtower/rum/src/lib/uwt-rum-internal.util/uwt-rum-internal.util.spec.ts diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index 7304a17..b3b58a6 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -243,7 +243,9 @@ providers: [ 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: its - Angular helpers and the reporter. + Angular helpers and the reporter. A spec (`uwt-rum-internal.util.spec.ts`) + runs both copies against the same cases, and fails if their exports or + behaviour differ, so they can't drift apart. - **What is published is tested as published.** `npm run smoke` installs both tarballs into consumers outside the workspace: a framework-free one with neither Angular nor `aws-rum-web` installed, and an Angular diff --git a/projects/ngx-ui-watchtower/README.md b/projects/ngx-ui-watchtower/README.md index a0c39c9..1831450 100644 --- a/projects/ngx-ui-watchtower/README.md +++ b/projects/ngx-ui-watchtower/README.md @@ -34,7 +34,10 @@ npm install aws-rum-web # only for the AWS RUM sink Requires Angular 21 or 22 and RxJS 7.8. It installs `@absaoss-cps/ui-watchtower` with it: there is nothing more to install or -configure. +configure. If your application also imports the core directly, don't install +a version this package doesn't accept: npm would keep two copies, and name +declarations and `instanceof` checks would no longer match between them. Let +npm pick one version for both. Upgrading from 0.4 needs no code or configuration change: the imports, providers, services, name declarations and behavior stay the same. 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/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts index 8469578..91fcf3b 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable, OnDestroy } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { UWT_REDACTED, @@ -854,4 +854,31 @@ describe('UwtLoggerService', () => { expect(() => window.dispatchEvent(new Event('pagehide'))).not.toThrow(); }); }); + + describe('teardown', () => { + it('should still ship a line logged by a service destroyed after the logger service', () => { + // Angular destroys a service before those that inject it, so this one + // logs after UwtLoggerService has been destroyed. + @Injectable({ providedIn: 'root' }) + class ClosingService implements OnDestroy { + private readonly logger = inject(UwtLoggerService).getLogger('closing'); + + ngOnDestroy(): void { + this.logger.log('closing'); + } + } + TestBed.inject(ClosingService); + + TestBed.resetTestingModule(); + + expect(transport.records.map((r) => r.message)).toContain('closing'); + }); + + it('should keep answering queries after the logger service is destroyed', async () => { + logger.log('before'); + service.ngOnDestroy(); + + await expect(service.query({})).resolves.toHaveLength(1); + }); + }); }); diff --git a/projects/ngx-ui-watchtower/src/lib/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 ae141ff..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()); } /** diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md index cd8c462..823a1df 100644 --- a/projects/ui-watchtower/README.md +++ b/projects/ui-watchtower/README.md @@ -82,9 +82,10 @@ Configuration errors still throw, when the engine is created. | `runOutside` | Schedules timers outside a framework's change detection | `dispose()` stops watching the page, settles scenarios still in flight as -abandoned and flushes; the engine does nothing after that, and leaves no -listener or timer behind. The destination and log store you passed are -yours to close. +abandoned and flushes. Scenarios and BI events record nothing after that, and +no listener or timer is left behind. Loggers you hold keep writing to your log +store, since something may still log while your application shuts down. The +destination and log store you passed are yours to close. ### Turning telemetry off diff --git a/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts index 6236029..38fa65d 100644 --- a/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts +++ b/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts @@ -162,7 +162,7 @@ export class UwtLogTelemetry { * @returns the matching records, or `[]` when none can be read */ async query(filter: UwtLogQuery = {}): Promise { - if (!this.enabled || this.disposed) { + if (!this.enabled) { return []; } const pending = uwtSafe( @@ -218,8 +218,11 @@ export class UwtLogTelemetry { /** * Stops watching the page and gives the log store its last chance to - * ship what it holds; every logger writes nothing from then on. Calling - * it again does nothing. + * ship what it holds. Loggers keep writing to the log store, and + * {@link query} keeps reading it: it is the application's, and logging + * holds no timer, listener or channel to release. That matters when + * something logs while the application shuts down, after this was + * disposed. Calling it again does nothing. */ dispose(): void { if (this.disposed) { @@ -263,9 +266,6 @@ export class UwtLogTelemetry { message: string, detail?: UwtLogDetail ): void { - if (this.disposed) { - return; - } uwtSafeVoid( `logger.${level}`, () => { diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts index 8bf2a41..9659438 100644 --- a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts @@ -503,17 +503,15 @@ describe('uwtCreateTelemetry', () => { it('should leave everything inert afterwards, with no timer or listener', () => { jest.useFakeTimers(); const listener = jest.fn(); - const { telemetry, sink, logApi } = engine({ + const { telemetry, sink } = engine({ scenarios: { defaultTimeoutMs: 60_000 } }); telemetry.scenarios.onSettled(listener); - const logger = telemetry.logging.getLogger('checkout'); telemetry.dispose(); const late = telemetry.scenarios.start({ name: 'checkout' }); late.complete(); telemetry.bi.track('export_clicked'); - logger.error('late'); telemetry.setUserId('user-1'); expect(late.isSettled).toBe(false); @@ -521,7 +519,17 @@ describe('uwtCreateTelemetry', () => { expect(listener).not.toHaveBeenCalled(); expect(sink.events).toEqual([]); expect(sink.userId).toBeUndefined(); - expect(logApi.records).toEqual([]); + }); + + it("should keep loggers writing to the log store afterwards: it is the application's, and holds no resource", async () => { + const { telemetry, logApi } = engine(); + const logger = telemetry.logging.getLogger('checkout'); + + telemetry.dispose(); + logger.error('late'); + + expect(logApi.records.map((r) => r.message)).toEqual(['late']); + await expect(telemetry.logging.query({})).resolves.toHaveLength(1); }); it('should dispose each concern only once', () => { diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts index 04bbc60..f610681 100644 --- a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts @@ -86,9 +86,11 @@ export interface UwtTelemetry { flush(beacon?: boolean): void; /** * Stops watching the page, settles scenarios still in flight as abandoned - * and flushes, then does nothing more: every concern is inert from then - * on, and no listener or timer of this engine is left. Calling it again - * does nothing. The destination and the log store stay yours to close. + * and flushes. Scenarios and BI events are inert from then on, and no + * listener or timer of this engine is left. Loggers you hold keep writing + * to the log store, which stays yours, like the destination: something may + * still log while the application shuts down. Calling it again does + * nothing. */ dispose(): void; } diff --git a/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts b/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts index 53068af..78abce5 100644 --- a/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts +++ b/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts @@ -90,6 +90,8 @@ export class UwtBroadcastLogApiProvider implements UwtLogApiProvider { private readonly runInside: (callback: () => T) => T; private readonly report?: UwtReporter; private readonly pending = new Map(); + /** Set by {@link dispose}: nothing is sent or asked after, even on a shared connection. */ + private disposed = false; constructor(options: UwtBroadcastLogApiProviderOptions = {}) { this.report = options.report; @@ -114,12 +116,14 @@ export class UwtBroadcastLogApiProvider implements UwtLogApiProvider { /** @inheritdoc */ send(record: UwtLogRecord): void { - this.connection.post({ kind: 'log', record }); + if (!this.disposed) { + this.connection.post({ kind: 'log', record }); + } } /** @inheritdoc */ query(filter: UwtLogQuery): Promise { - if (!this.connection.isOpen()) { + if (this.disposed || !this.connection.isOpen()) { return Promise.resolve([]); } @@ -138,14 +142,20 @@ export class UwtBroadcastLogApiProvider implements UwtLogApiProvider { /** @inheritdoc */ flush(): void { - this.connection.post({ kind: 'log-flush' }); + if (!this.disposed) { + this.connection.post({ kind: 'log-flush' }); + } } /** * Resolves every unanswered query to `[]`, stops listening, and closes - * the channel if this provider opened it. Calling it again does nothing. + * the channel if this provider opened it. A shared connection stays open + * for its other users, but this provider sends nothing over it any more, + * and every later query resolves to `[]` at once. Calling it again does + * nothing. */ dispose(): void { + this.disposed = true; for (const id of [...this.pending.keys()]) { this.answer(id, []); } diff --git a/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts index 489b272..1685b15 100644 --- a/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts +++ b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts @@ -270,6 +270,25 @@ describe('UwtScenario, in detail', () => { expect(long.status).toBe('timeout'); }); + + it('should schedule its timeout once, though runOutside hands nothing back', () => { + jest.useFakeTimers(); + const outside = jest.fn((callback: () => unknown) => { + callback(); + }); + + scenario( + 'slow', + {}, + { + timeoutMs: 1000, + runOutside: outside as unknown as UwtScenarioDeps['runOutside'] + } + ); + + expect(outside).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(1); + }); }); describe('debug output', () => { diff --git a/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts index c91e1cf..6efbae0 100644 --- a/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts +++ b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts @@ -848,7 +848,8 @@ export class UwtScenario { } private runOutside(callback: () => T): T { - return this.deps.runOutside?.(callback) ?? callback(); + const { runOutside } = this.deps; + return runOutside ? runOutside(callback) : callback(); } /** diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts index 807cea5..fe9f7b5 100644 --- a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts @@ -10,6 +10,7 @@ import { import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; import { UwtBroadcastConnection, + UwtBroadcastMessage, uwtConnectBroadcastChannel, uwtIsBroadcastMessage } from './uwt-broadcast.messages'; @@ -59,6 +60,8 @@ export class UwtBroadcastTelemetrySink extends UwtTelemetrySink { private readonly ownsConnection: boolean; private readonly stopListening: () => void; private readonly report?: UwtReporter; + /** Set by {@link dispose}: nothing is forwarded after, even on a shared connection. */ + private disposed = false; /** The host's session id, once it has answered. */ private sessionId?: string; @@ -109,7 +112,7 @@ export class UwtBroadcastTelemetrySink extends UwtTelemetrySink { payload: object, metadata?: UwtTelemetryMetadata ): void { - this.connection.post({ + this.post({ kind: 'event', eventType, payload, @@ -119,7 +122,7 @@ export class UwtBroadcastTelemetrySink extends UwtTelemetrySink { /** @inheritdoc */ recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata): void { - this.connection.post({ + this.post({ kind: 'error', error, metadata: { ...metadata, ...this.origin } @@ -140,7 +143,7 @@ export class UwtBroadcastTelemetrySink extends UwtTelemetrySink { /** @inheritdoc */ setUserId(userId: string | undefined): void { this.userId = userId; - this.connection.post({ kind: 'user', userId }); + this.post({ kind: 'user', userId }); } /** @inheritdoc */ @@ -150,20 +153,28 @@ export class UwtBroadcastTelemetrySink extends UwtTelemetrySink { /** @inheritdoc */ flush(beacon = false): void { - this.connection.post({ kind: 'flush', beacon }); + this.post({ kind: 'flush', beacon }); } /** - * Stops listening, and closes the channel if this sink opened it. Calling - * it again does nothing. + * Stops listening and forwarding, and closes the channel if this sink + * opened it. A shared connection stays open for its other users, but this + * sink sends nothing over it any more. Calling it again does nothing. */ dispose(): void { + this.disposed = true; this.stopListening(); if (this.ownsConnection) { this.connection.close(); } } + private post(message: UwtBroadcastMessage): void { + if (!this.disposed) { + this.connection.post(message); + } + } + private onMessage(data: unknown): void { uwtSafeVoid( 'broadcastSink.receive', 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 36aedff..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 @@ -134,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 }); From 380e134df09c8e0ea2cd8907bfa8b7eaa9bc9a17 Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 10 Nov 2026 20:48:09 +0100 Subject: [PATCH 21/21] after dispose, keep scenarios, BI events and logs --- .../uwt-scenario-telemetry.service.spec.ts | 40 +++++++++++- projects/ui-watchtower/README.md | 9 +-- .../uwt-bi-telemetry/uwt-bi-telemetry.ts | 10 +-- .../uwt-scenario-telemetry.ts | 13 ++-- .../uwt-telemetry/uwt-telemetry.spec.ts | 61 +++++++++++++++---- .../lib/engine/uwt-telemetry/uwt-telemetry.ts | 15 +++-- 6 files changed, 113 insertions(+), 35 deletions(-) diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts index 5eafc9d..e5e23b0 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts @@ -1,4 +1,4 @@ -import { Injectable, NgZone } from '@angular/core'; +import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { UWT_REDACTED, @@ -2149,6 +2149,44 @@ describe('UwtScenarioTelemetryService', () => { }); }); + describe('teardown', () => { + it('should still record a scenario that a service starts and ends in its own ngOnDestroy', () => { + // Angular destroys a service before those that inject it, so this one + // starts its scenario after UwtScenarioTelemetryService was destroyed. + @Injectable({ providedIn: 'root' }) + class ClosingService implements OnDestroy { + private readonly scenarios = inject(UwtScenarioTelemetryService); + + ngOnDestroy(): void { + this.scenarios.start({ name: 'closing' }).complete(); + } + } + TestBed.inject(ClosingService); + + TestBed.resetTestingModule(); + + expect(onlyScenarioRecord()).toMatchObject({ + scenarioName: 'closing', + status: 'success' + }); + }); + + it('should still time out a scenario started after destroy and never ended, as before', () => { + jest.useFakeTimers(); + configure({ defaultTimeoutMs: 5_000 }); + + service.ngOnDestroy(); + service.start({ name: 'late' }); + jest.advanceTimersByTime(5_000); + + expect(onlyScenarioRecord()).toMatchObject({ + scenarioName: 'late', + status: 'timeout' + }); + jest.useRealTimers(); + }); + }); + describe('failure isolation', () => { let consoleError: jest.SpyInstance; diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md index 823a1df..f272257 100644 --- a/projects/ui-watchtower/README.md +++ b/projects/ui-watchtower/README.md @@ -82,10 +82,11 @@ Configuration errors still throw, when the engine is created. | `runOutside` | Schedules timers outside a framework's change detection | `dispose()` stops watching the page, settles scenarios still in flight as -abandoned and flushes. Scenarios and BI events record nothing after that, and -no listener or timer is left behind. Loggers you hold keep writing to your log -store, since something may still log while your application shuts down. The -destination and log store you passed are yours to close. +abandoned and flushes; no listener is left behind, and nothing the engine had +scheduled. That is all it changes. Something may still run while your +application shuts down, and the destination and log store you passed are +yours, so scenarios, BI events and logs started afterwards work as before. You +close the destination and the log store. ### Turning telemetry off diff --git a/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts index c8f8368..1e4c881 100644 --- a/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts +++ b/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts @@ -85,7 +85,6 @@ export class UwtBITelemetry { private readonly eventTypes: UwtTelemetryEventTypes; private readonly lastEmittedAt = new Map(); - private disposed = false; constructor(options: UwtBITelemetryOptions) { this.identity = options.identity; @@ -111,7 +110,7 @@ export class UwtBITelemetry { metadata?: UwtTelemetryMetadata, detail?: UwtBIEventDetail ): void { - if (!this.enabled || this.disposed) { + if (!this.enabled) { return; } uwtSafeVoid( @@ -173,9 +172,12 @@ export class UwtBITelemetry { ); } - /** Tracks nothing from now on, and forgets recent events. Calling it again does nothing. */ + /** + * Forgets recent events. Tracking still works afterwards: it holds no + * timer, listener or channel, and something may still track while an + * application shuts down. Calling it again does nothing more. + */ dispose(): void { - this.disposed = true; this.lastEmittedAt.clear(); } diff --git a/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts index 5a8fa17..f75e729 100644 --- a/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts +++ b/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts @@ -131,10 +131,10 @@ export class UwtScenarioTelemetry { * @param options the scenario name and optional classification, timeout and * attributes * @returns an independent scenario instance; an inert one while telemetry - * is off or once this is disposed + * is off */ start(options: UwtScenarioOptions): UwtScenario { - if (!this.enabled || this.disposed) { + if (!this.enabled) { // Same return type, nothing recorded, timed, registered or sent. return new UwtScenario(options, { identity: this.identity, @@ -145,6 +145,9 @@ export class UwtScenarioTelemetry { inert: true }); } + // Nothing here depends on whether this was disposed: something may still + // start a scenario while an application shuts down, and the destination is + // the caller's. Such a scenario is a normal one, with its own timeout. const scenario = new UwtScenario(options, { identity: this.identity, scenarioTelemetryConfig: this.config, @@ -251,9 +254,9 @@ export class UwtScenarioTelemetry { /** * Stops watching the page, settles every scenario still in flight as - * abandoned and flushes the sink, then calls no listener again. Every - * later {@link start} returns an inert scenario. Calling it again does - * nothing. + * abandoned and flushes the sink, then forgets the listeners. Nothing else + * changes: a scenario started later is a normal one, with its own timeout. + * Calling it again does nothing. */ dispose(): void { if (this.disposed) { diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts index 9659438..333ed18 100644 --- a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts @@ -500,25 +500,60 @@ describe('uwtCreateTelemetry', () => { expect(logApi.flushCount).toBe(1); }); - it('should leave everything inert afterwards, with no timer or listener', () => { - jest.useFakeTimers(); + it('should change nothing else afterwards: BI events, the user and flushes still reach the destination', () => { + const { telemetry, sink } = engine(); + telemetry.dispose(); + const flushedByDispose = sink.flushes.length; + + telemetry.bi.track('export_clicked'); + telemetry.setUserId('user-1'); + telemetry.flush(false); + + expect(sink.ofType('com.cart.bi')).toEqual([ + expect.objectContaining({ eventName: 'export_clicked' }) + ]); + expect(sink.userId).toBe('user-1'); + expect(sink.flushes.slice(flushedByDispose)).toEqual([false]); + }); + + it('should forget the listeners it had, and watch the page no more', () => { const listener = jest.fn(); - const { telemetry, sink } = engine({ - scenarios: { defaultTimeoutMs: 60_000 } - }); + const { telemetry, sink } = engine(); telemetry.scenarios.onSettled(listener); + const flushesBefore = sink.flushes.length; telemetry.dispose(); - const late = telemetry.scenarios.start({ name: 'checkout' }); - late.complete(); - telemetry.bi.track('export_clicked'); - telemetry.setUserId('user-1'); + window.dispatchEvent(new Event('pagehide')); + telemetry.scenarios.start({ name: 'checkout' }).complete(); - expect(late.isSettled).toBe(false); - expect(jest.getTimerCount()).toBe(0); + expect(sink.flushes).toHaveLength(flushesBefore + 1); // dispose's own expect(listener).not.toHaveBeenCalled(); - expect(sink.events).toEqual([]); - expect(sink.userId).toBeUndefined(); + }); + + it('should treat a scenario started afterwards as a normal one: recorded when ended', () => { + const { telemetry, sink } = engine(); + telemetry.dispose(); + + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(sink.ofType('com.cart.scenario')).toEqual([ + expect.objectContaining({ scenarioName: 'checkout', status: 'success' }) + ]); + }); + + it('should time out a scenario started afterwards and never ended, as any other', () => { + jest.useFakeTimers(); + const { telemetry, sink } = engine({ + scenarios: { defaultTimeoutMs: 5_000 } + }); + telemetry.dispose(); + + telemetry.scenarios.start({ name: 'checkout' }); + jest.advanceTimersByTime(5_000); + + expect(sink.ofType('com.cart.scenario')).toEqual([ + expect.objectContaining({ scenarioName: 'checkout', status: 'timeout' }) + ]); }); it("should keep loggers writing to the log store afterwards: it is the application's, and holds no resource", async () => { diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts index f610681..1c24211 100644 --- a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts @@ -86,11 +86,11 @@ export interface UwtTelemetry { flush(beacon?: boolean): void; /** * Stops watching the page, settles scenarios still in flight as abandoned - * and flushes. Scenarios and BI events are inert from then on, and no - * listener or timer of this engine is left. Loggers you hold keep writing - * to the log store, which stays yours, like the destination: something may - * still log while the application shuts down. Calling it again does - * nothing. + * and flushes. No listener of this engine is left, and nothing it had + * scheduled. That is all it changes: something may still run while an + * application shuts down, and the destination and the log store are + * yours, so scenarios, BI events and logs started afterwards work as + * before. Calling it again does nothing. */ dispose(): void; } @@ -151,14 +151,13 @@ export function uwtCreateTelemetry(options: UwtTelemetryOptions): UwtTelemetry { }); let disposed = false; - const live = () => enabled && !disposed; return { scenarios, bi, logging, setUserId(userId) { - if (live()) { + if (enabled) { uwtSafeVoid( 'telemetry.setUserId', () => destination.setUserId(userId), @@ -167,7 +166,7 @@ export function uwtCreateTelemetry(options: UwtTelemetryOptions): UwtTelemetry { } }, flush(beacon) { - if (live()) { + if (enabled) { uwtSafeVoid('telemetry.flush', () => destination.flush(beacon), report); uwtSafeVoidMaybeAsync( 'telemetry.logFlush',