From 0d8907e8489d29bdd0d0ba4bdac6fa14bc28245a Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 11 Nov 2026 13:56:46 +0100 Subject: [PATCH 1/8] 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 | 327 ++++++++++ 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, 1947 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..116f843 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json @@ -0,0 +1,254 @@ +[ + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session", + "userId": "" + } + }, + { + "from": "fragment", + "message": { + "kind": "identity-request" + } + }, + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session", + "userId": "" + } + }, + { + "from": "fragment", + "message": { + "eventType": "com.uwt.scenario", + "kind": "event", + "metadata": { + "appVersion": "1.0.0", + "application": "realm", + "environment": "test" + }, + "payload": { + "aggregates": "", + "application": "realm", + "delta": "", + "elapsed": "", + "endTime": "", + "feature": "", + "metadata": { + "cart": "c-1", + "items": 2 + }, + "operation": "", + "parentScenarioId": "", + "previousStep": "pay", + "route": "", + "scenarioId": "", + "scenarioName": "checkout", + "sessionId": "shell-session", + "startTime": "", + "status": "success", + "stepCount": 1, + "steps": [ + { + "elapsed": "", + "endOffset": "", + "name": "scenario-start", + "startOffset": "", + "status": "success", + "stepDelta": "" + }, + { + "elapsed": "", + "endOffset": "", + "metadata": { + "method": "card" + }, + "name": "pay", + "startOffset": "", + "status": "success", + "stepDelta": "" + }, + { + "elapsed": "", + "endOffset": "", + "metadata": { + "items": 2 + }, + "name": "scenario-end", + "startOffset": "", + "status": "success", + "stepDelta": "" + } + ], + "userId": "" + } + } + }, + { + "from": "fragment", + "message": { + "eventType": "com.uwt.bi", + "kind": "event", + "metadata": { + "appVersion": "1.0.0", + "application": "realm", + "environment": "test" + }, + "payload": { + "application": "realm", + "eventName": "export_clicked", + "eventTime": "", + "feature": "", + "metadata": { + "format": "csv" + }, + "scenarioId": "", + "sessionId": "shell-session", + "userId": "" + } + } + }, + { + "from": "fragment", + "message": { + "kind": "log", + "record": { + "application": "realm", + "context": "", + "correlationId": "", + "environment": "test", + "error": "", + "level": "warn", + "logger": "checkout", + "message": "Slow payment", + "metadata": { + "ms": 900 + }, + "sessionId": "shell-session", + "timestamp": "", + "userId": "", + "version": "1.0.0" + } + } + }, + { + "from": "fragment", + "message": { + "kind": "log", + "record": { + "application": "realm", + "context": "", + "correlationId": "", + "environment": "test", + "error": { + "message": "Declined", + "name": "Error", + "stack": "" + }, + "level": "error", + "logger": "checkout", + "message": "Payment failed", + "metadata": "", + "sessionId": "shell-session", + "timestamp": "", + "userId": "", + "version": "1.0.0" + } + } + }, + { + "from": "fragment", + "message": { + "kind": "user", + "userId": "user-1" + } + }, + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session", + "userId": "user-1" + } + }, + { + "from": "fragment", + "message": { + "beacon": true, + "kind": "flush" + } + }, + { + "from": "fragment", + "message": { + "kind": "log-flush" + } + }, + { + "from": "fragment", + "message": { + "filter": { + "limit": 1, + "logger": "checkout" + }, + "id": "", + "kind": "log-query" + } + }, + { + "from": "shell", + "message": { + "id": "", + "kind": "log-query-result", + "records": [ + { + "application": "realm", + "environment": "test", + "level": "warn", + "logger": "checkout", + "message": "Slow payment", + "metadata": { + "ms": 900 + }, + "sessionId": "shell-session", + "timestamp": "", + "version": "1.0.0" + } + ] + } + }, + { + "from": "fragment", + "message": { + "eventType": "com.uwt.bi", + "kind": "event", + "metadata": { + "appVersion": "1.0.0", + "application": "realm", + "environment": "test" + }, + "payload": { + "application": "realm", + "eventName": "theme_changed", + "eventTime": "", + "feature": "", + "metadata": "", + "scenarioId": "", + "sessionId": "shell-session", + "userId": "user-1" + } + } + }, + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session-2", + "userId": "user-1" + } + } +] diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts new file mode 100644 index 0000000..65a5fe0 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts @@ -0,0 +1,327 @@ +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; + +/** A stack trace names files and lines, which differ by machine and by edit. */ +const VARIES = (key: string): boolean => TIMED.test(key) || key === 'stack'; + +/** + * What can differ between runs made comparable: ids numbered by first + * appearance, and times, durations and stack traces by their type. + */ +function stable(posted: Posted[]): Posted[] { + const uuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const ids = new Map(); + const walk = (value: unknown, key = ''): unknown => { + if (VARIES(key) && value !== '') { + return `<${typeof value}>`; + } + if (typeof value === 'string' && uuid.test(value)) { + if (!ids.has(value)) { + ids.set(value, ``); + } + return ids.get(value); + } + if (Array.isArray(value)) { + return value.map((v) => walk(v)); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, walk(v, k)]) + ); + } + return value; + }; + return walk(posted) as Posted[]; +} + +/** The shell's real destination, with a session and user it controls. */ +@Injectable() +class ShellSink extends UwtTelemetrySink { + 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 7d6b45d482e2b5c3c845d1ca196978b6fabba2dc Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 11 Nov 2026 13:56:47 +0100 Subject: [PATCH 2/8] 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 | 30 +++ .../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, 521 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..58bd169 100644 --- a/projects/ngx-ui-watchtower/package.json +++ b/projects/ngx-ui-watchtower/package.json @@ -20,6 +20,7 @@ "homepage": "https://github.com/AbsaOSS/ui-watchtower", "license": "Apache-2.0", "dependencies": { + "@absaoss-cps/ui-watchtower": "^0.1.0", "tslib": "^2.8.1" }, "sideEffects": false, diff --git a/projects/ngx-ui-watchtower/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..bc739c4 --- /dev/null +++ b/projects/ui-watchtower/package.json @@ -0,0 +1,30 @@ +{ + "name": "@absaoss-cps/ui-watchtower", + "version": "0.1.0", + "description": "Framework-independent telemetry engine: application logs, scenario health telemetry and business/UX events, for any JavaScript or TypeScript application.", + "repository": { + "type": "git", + "url": "https://github.com/AbsaOSS/ui-watchtower" + }, + "homepage": "https://github.com/AbsaOSS/ui-watchtower", + "license": "Apache-2.0", + "peerDependencies": { + "aws-rum-web": "^3.2.1", + "rxjs": "^7.8.2" + }, + "peerDependenciesMeta": { + "aws-rum-web": { + "optional": true + }, + "rxjs": { + "optional": true + } + }, + "dependencies": { + "tslib": "^2.8.1" + }, + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/projects/ui-watchtower/public-api.snapshot.json b/projects/ui-watchtower/public-api.snapshot.json new file mode 100644 index 0000000..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 b6deed51c30f51b17c81c835b253095a9bc67623 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 11 Nov 2026 13:56:49 +0100 Subject: [PATCH 3/8] 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/uwt-broadcast.wire.spec.ts | 20 +- .../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 | 241 ++--- .../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 + 74 files changed, 3599 insertions(+), 2276 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.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts index 65a5fe0..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'; 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..6efbae0 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts +++ b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.ts @@ -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,9 @@ export class UwtScenario { this.markCleanupTimer = UwtScenario.clearHandle(this.markCleanupTimer); } - private runOutsideAngular(callback: () => T): T { - return this.deps.runOutsideAngular?.(callback) ?? callback(); + private runOutside(callback: () => T): T { + const { runOutside } = this.deps; + return runOutside ? runOutside(callback) : callback(); } /** @@ -862,12 +882,16 @@ export class UwtScenario { if (this.inert) { return this; } - uwtSafeVoid(`scenario.${operation}`, () => { - if (this.isSettled) { - return; - } - apply(); - }); + uwtSafeVoid( + `scenario.${operation}`, + () => { + if (this.isSettled) { + return; + } + apply(); + }, + this.deps.report + ); return this; } @@ -887,8 +911,11 @@ export class UwtScenario { ? this.eventTypes.scenario : this.eventTypes.scenarioStep; - uwtDebugWrite('debugScenario', () => - writeToConsole(summary, eventType, emission.payload) + uwtDebugWrite( + 'debugScenario', + () => writeToConsole(summary, eventType, emission.payload), + undefined, + this.deps.report ); this.deps.sink.record(eventType, emission.payload); } diff --git a/projects/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 5a1e40129902e83f5cd67dd954af54a30adaf03b Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 11 Nov 2026 13:56:51 +0100 Subject: [PATCH 4/8] refactor: 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 | 88 ++- .../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 | 32 +- .../uwt-logger.service/uwt-logger.service.ts | 245 +----- .../uwt-scenario-telemetry.service.spec.ts | 97 ++- .../uwt-scenario-telemetry.service.ts | 185 ++--- .../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 | 6 +- .../ui-watchtower/public-api.snapshot.json | 141 +++- .../uwt-rum-telemetry.sink.behaviour.spec.ts | 650 ++++++++++++++++ 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 | 254 +++++++ .../uwt-log-telemetry/uwt-log-telemetry.ts | 380 ++++++++++ .../uwt-scenario-telemetry.ts | 332 +++++++++ .../uwt-telemetry-page/uwt-telemetry-page.ts | 56 ++ .../uwt-telemetry/uwt-telemetry.spec.ts | 702 ++++++++++++++++++ .../lib/engine/uwt-telemetry/uwt-telemetry.ts | 207 ++++++ .../uwt-noop-log-api.provider.spec.ts | 0 .../uwt-noop-log-api.provider.ts | 26 + .../uwt-scenario.behaviour.spec.ts | 309 ++++++++ .../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 + 48 files changed, 4536 insertions(+), 1252 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/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.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 create mode 100644 projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.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..e3964e6 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -89,6 +89,19 @@ flowchart TD Everything is wired through Angular DI, so a destination can be swapped in production and stubbed in tests. +The three services are Angular facades over the core's engine. Each holds the +core's class for its concern (`UwtScenarioTelemetry`, `UwtBITelemetry`, +`UwtLogTelemetry`), built from its own injections, so a missing provider still +fails where it always did: the logger needs a log provider but no +destination, scenarios need a destination and no log provider. The services +are root singletons, so each concern exists once per injector, and all of +them share the one destination and log provider. `settled$` is the core's +`onSettled` as an Observable that completes on destroy. It listens to the +core only while subscribed, so no record is copied for nobody; `traceScenario` is the +core's `./rxjs` operator, reporting through this package's dev-mode +reporter. Framework-free applications compose the same three classes with +`uwtCreateTelemetry`. + ### Entry points Two, each pulling in only what it needs: @@ -198,24 +211,51 @@ providers: [ the same names. The dependency runs one way: the core imports neither Angular nor this package, which a lint rule enforces. The library build compiles against the core's built declarations, not its source, so it sees - the core exactly as an application does. -- **What the core owns, this package re-exports.** The models and name - registries, the configuration types and defaults, redaction, `UwtScenario` - and the `UwtTelemetrySink` and `UwtLogApiProvider` contracts are the - core's. This package keeps the injection tokens, the services and - providers, and the destinations that need Angular. `UwtTelemetrySink` is a - plain abstract class in the core, not `@Injectable()`: Angular uses it as - the destination's token as before, and a custom sink still extends it. + the core exactly as an application does. An application upgrading from + 0.4 changes nothing. Another framework's adapter needs only the core's + public entry points, as this package does; + `docs/ADAPTERS.md` is a short guide. +- **What the core owns, this package re-exports.** The engine, the models + and name registries, the configuration types and defaults + (`uwtResolveTelemetryConfig` completes every `with*` feature), redaction, + `UwtScenario`, the `UwtTelemetrySink` and `UwtLogApiProvider` contracts and + their no-op implementations are the core's. This package keeps the + injection tokens, the services and providers, and the destinations that + need Angular. `UwtTelemetrySink` is a plain abstract class in the core, not + `@Injectable()`: Angular uses it as the destination's token as before, and + a custom sink still extends it. Every class an application provides stays + `@Injectable()` here — `UwtNoopTelemetrySink` and `UwtNoopLogApiProvider` + are decorated subclasses of the core's — so the published declarations + keep their Angular metadata. - **Every export is explicit.** The barrels list their exports one by one — in a published package each exported name is a compatibility promise. Id generation, the clock, the fail-open wrappers, User Timings and the broadcast plumbing stay internal so they can change freely. Redaction is the exception: a custom sink needs `uwtRedactMetadata`, `uwtNormalizeError`, `uwtScrubString` and `uwtRedactConfigFor`. +- **The RUM sink is the core's too.** Its logic — the lazy `aws-rum-web` + import, startup retries, credential refresh, the pre-init buffer, the + settings check — lives in `@absaoss-cps/ui-watchtower/rum`. This package's + `/rum` keeps `UWT_RUM_CREDENTIALS_PROVIDER`, `provideUwtTelemetryRumSink()` + and `UwtRumTelemetrySink`, an injectable subclass of the core's. - **The secondary entry keeps private copies of a few utilities.** ng-packagr fixes each entry point's `rootDir` to its own `src`, so `@absaoss-cps/ngx-ui-watchtower/rum` can't import the main entry's - internals by relative path and carries small copies of what it needs. + internals by relative path and carries small copies of what it needs: its + Angular helpers and the reporter. +- **What is published is tested as published.** `npm run smoke` installs + both tarballs into consumers outside the workspace: a framework-free one + with neither Angular nor `aws-rum-web` installed, and an Angular + application, prerendered as a fragment without `aws-rum-web` — which then + holds no RUM code — and as a shell with `/rum`, whose RUM client is a chunk + loaded on demand. +- **Both packages share one version and release together.** Release Please + links their versions: a change to either releases both, and the same + release PR moves this package's dependency on the core to `^` the new + version. The release workflow publishes the core first and this package + only after it, so this package never depends on a core version npm doesn't + have. Publishing skips what npm already has, so running the workflow again + on the release tag completes a partial release. - **Test doubles live in the specs that use them**, declared inline, never in a shared file and never exported. The one browser gap needing a stub is `BroadcastChannel`, which jsdom doesn't implement. @@ -1591,12 +1631,15 @@ convention, for an application that wants the pre-fallback behavior back. ## 11. Error handling and performance - **Telemetry never breaks the application.** Every public entry point is - wrapped and never rethrows. The core prints nothing by itself: it hands a - suppressed error, or data it had to drop, to the `UwtReporter` it is - given. This package gives it one that writes to the console, prefixed + wrapped and never rethrows. The core hands a suppressed error (`'error'`), + or data it had to drop (`'warn'`), to the `UwtReporter` it is given. This + package gives it one that writes to the console, prefixed `[ngx-ui-watchtower]`, in development mode only, so bugs surface in - development and production stays silent. Even a broken `console.error` - cannot turn a suppressed failure into a throw. + development and production stays silent. What cannot work as configured + (`'config'`) — a second broadcast host, a host in a forwarding realm, RUM + settings of the wrong type — is printed in production too, as it always + was; without a reporter, the core prints it itself. Even a broken + `console.error` cannot turn a suppressed failure into a throw. - **Fail-open.** A broker outage, expired credentials or an SDK throw all leave the application behaving exactly as with healthy telemetry. A failed broker request at startup is retried with backoff for five minutes. A @@ -2166,6 +2209,23 @@ providers: [ Code inside a fragment does not change: same services, same calls. One session, one budget, one bundle. +| Realm | Destination | RUM and `UWT_RUM_CREDENTIALS_PROVIDER` | `UWT_LOG_API_PROVIDER` | +| -------------- | --------------------------------------------------- | -------------------------------------- | -------------------------------------- | +| Standalone app | Its own: `provideUwtTelemetryRumSink()`, or another | The app's | The app's | +| Shell | Its own, plus `provideUwtTelemetryBroadcastHost()` | The shell's: the only ones on the page | The shell's, also for fragments' logs | +| Fragment | `provideUwtTelemetrySink('broadcast')` | None: the shell's records for it | Bound by `'broadcast'`, to the shell's | + +Either side may be built without Angular. The protocol — the messages, the +host, the forwarding sink and log provider — lives in +`@absaoss-cps/ui-watchtower`, and this package's classes are injectable +subclasses of the core's. An Angular shell hosts a framework-free fragment, +and a framework-free shell an Angular one, exchanging the same messages: the +wire test holds every pairing to the fixture recorded before the core was +extracted. A fragment wired by `provideUwtTelemetrySink('broadcast')` opens one +channel for its sink and log provider, closed with its injector. The host +takes part — counting toward the duplicate check, electing a leader — only in +a browser with telemetry on, so server-side renders never see each other. + ### Connecting a fragment Two providers, and that is the whole integration: diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts index 480bc35..5424240 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts @@ -1,9 +1,5 @@ import { Injectable } from '@angular/core'; -import { - UwtLogApiProvider, - UwtLogQuery, - UwtLogRecord -} from '@absaoss-cps/ui-watchtower'; +import { UwtNoopLogApiProvider as CoreNoopLogApiProvider } from '@absaoss-cps/ui-watchtower'; /** * Log API provider that discards every record and finds none. @@ -27,12 +23,4 @@ import { * @group Services */ @Injectable() -export class UwtNoopLogApiProvider implements UwtLogApiProvider { - /** @inheritdoc */ - send(_record: UwtLogRecord): void {} - - /** @inheritdoc */ - query(_filter: UwtLogQuery): Promise { - return Promise.resolve([]); - } -} +export class UwtNoopLogApiProvider extends CoreNoopLogApiProvider {} diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts index 4fe5f43..0d32998 100644 --- a/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts +++ b/projects/ngx-ui-watchtower/src/lib/providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers.ts @@ -13,6 +13,7 @@ import { UWT_BROADCAST_CHANNEL } from '../../sinks/uwt-broadcast/uwt-broadcast.m import { UwtNoopTelemetrySink } from '../../sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; import { UWT_DEFAULT_TELEMETRY_CONFIG, + uwtResolveTelemetryConfig, UwtBITelemetryConfig, UwtLogConfig, UwtRedactConfig, @@ -60,7 +61,7 @@ export function withLogging( providers: [ { provide: UWT_LOG_CONFIG, - useValue: { ...UWT_DEFAULT_TELEMETRY_CONFIG.logs, ...config } + useValue: uwtResolveTelemetryConfig({ logging: config }).logging } ] }; @@ -81,7 +82,7 @@ export function withScenarios( providers: [ { provide: UWT_SCENARIO_TELEMETRY_CONFIG, - useValue: { ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario, ...config } + useValue: uwtResolveTelemetryConfig({ scenarios: config }).scenarios } ] }; @@ -102,7 +103,7 @@ export function withBIEvents( providers: [ { provide: UWT_BI_TELEMETRY_CONFIG, - useValue: { ...UWT_DEFAULT_TELEMETRY_CONFIG.bi, ...config } + useValue: uwtResolveTelemetryConfig({ bi: config }).bi } ] }; @@ -123,24 +124,11 @@ export function withBIEvents( export function withRedaction( config: Partial = {} ): UwtTelemetryFeature { - const defaults = UWT_DEFAULT_TELEMETRY_CONFIG.redact; return { providers: [ { provide: UWT_REDACT_CONFIG, - useValue: { - ...defaults, - ...config, - extraKeyPatterns: [ - ...(config.extraKeyPatterns ?? defaults.extraKeyPatterns) - ], - extraValuePatterns: [ - ...(config.extraValuePatterns ?? defaults.extraValuePatterns) - ], - extraValueTransforms: [ - ...(config.extraValueTransforms ?? defaults.extraValueTransforms) - ] - } + useValue: uwtResolveTelemetryConfig({ redaction: config }).redaction } ] }; diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts index 917d5bc..65d7bf9 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.spec.ts @@ -1,512 +1,66 @@ -import { - defer, - EMPTY, - first, - type Observable, - firstValueFrom, - lastValueFrom, - of, - retry, - Subject, - switchMap, - take, - throwError -} from 'rxjs'; +import { isDevMode } from '@angular/core'; +import { of } from 'rxjs'; import { UWT_DEFAULT_REDACT_CONFIG, UWT_DEFAULT_TELEMETRY_CONFIG, - UwtScenario, - UwtScenarioRecord + UwtNoopTelemetrySink, + UwtScenario } from '@absaoss-cps/ui-watchtower'; -import { UwtNoopTelemetrySink } from '../../sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; import { traceScenario } from './uwt-scenario-operators'; -describe('traceScenario operator', () => { - let scenario: jest.Mocked; +jest.mock('@angular/core', () => ({ + ...jest.requireActual('@angular/core'), + isDevMode: jest.fn(() => true) +})); + +const isDevModeMock = isDevMode as jest.Mock; + +/** The operator itself is tested in the core; this is what this package adds. */ +describe('traceScenario, as this package exports it', () => { + const throwingMapper = (): never => { + throw new Error('mapper broke'); + }; + let consoleError: jest.SpyInstance; + + function scenario(): UwtScenario { + return new UwtScenario( + { name: 'load-customers' }, + { + identity: { application: 'app', environment: 'test', version: '1' }, + scenarioTelemetryConfig: { ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink: new UwtNoopTelemetrySink(), + onSettled: () => undefined + } + ); + } beforeEach(() => { - scenario = { - complete: jest.fn(), - fail: jest.fn(), - cancel: jest.fn(), - isSettled: false - } as unknown as jest.Mocked; + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + isDevModeMock.mockReturnValue(true); }); - it('should complete scenario on successful stream completion', (done) => { - of(['user1', 'user2']) - .pipe(traceScenario(scenario)) - .subscribe({ - next: (val) => { - expect(val).toEqual(['user1', 'user2']); - }, - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith(undefined); - expect(scenario.fail).not.toHaveBeenCalled(); - done(); - } - }); + afterEach(() => { + jest.restoreAllMocks(); }); - it('should complete scenario with mapped outcome using function argument', (done) => { - of(['item1', 'item2', 'item3']) - .pipe( - traceScenario(scenario, (items) => ({ - metadata: { itemCount: items.length } - })) - ) - .subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith({ - metadata: { itemCount: 3 } - }); - done(); - } - }); - }); - - it('should complete scenario with mapped outcome using options object', (done) => { - of({ status: 200, data: 'ok' }) - .pipe( - traceScenario(scenario, { - outcome: (res) => ({ - statusCode: res.status, - metadata: { result: res.data } - }) - }) - ) - .subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith({ - statusCode: 200, - metadata: { result: 'ok' } - }); - done(); - } - }); - }); - - it('should complete scenario with no outcome when the stream completes with no emissions', (done) => { - EMPTY.pipe( - traceScenario(scenario, () => ({ metadata: { unreachable: true } })) - ).subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith(undefined); - expect(scenario.fail).not.toHaveBeenCalled(); - done(); - } - }); - }); - - it('should fail scenario when the stream errors and re-throw the error', (done) => { - const error = new Error('network down'); - throwError(() => error) - .pipe(traceScenario(scenario)) - .subscribe({ - error: (err) => { - expect(err).toBe(error); - expect(scenario.fail).toHaveBeenCalledWith({ error }); - expect(scenario.complete).not.toHaveBeenCalled(); - done(); - } - }); - }); + it('should report a throwing outcome mapper on the console, prefixed, in development mode', () => { + const traced = scenario(); - it('should still settle the scenario when the outcome mapper throws', (done) => { - const consoleError = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); + of(1).pipe(traceScenario(traced, throwingMapper)).subscribe(); - of('value') - .pipe( - traceScenario(scenario, () => { - throw new Error('mapper is broken'); - }) - ) - .subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenCalledWith(undefined); - expect(scenario.fail).not.toHaveBeenCalled(); - expect(consoleError).toHaveBeenCalledWith( - expect.stringContaining('failed'), - expect.any(Error) - ); - consoleError.mockRestore(); - done(); - } - }); - }); - - it('should not leak lastValue/hasValue into a resubscription of the same piped observable', (done) => { - let subscriptionCount = 0; - const source = defer(() => { - subscriptionCount++; - return subscriptionCount === 1 ? of('a') : EMPTY; - }); - - const traced$ = source.pipe( - traceScenario(scenario, (value) => ({ metadata: { value } })) + expect(traced.status).toBe('success'); + expect(consoleError).toHaveBeenCalledWith( + '[ngx-ui-watchtower] scenario.traceOutcome failed', + expect.any(Error) ); - - traced$.subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenNthCalledWith(1, { - metadata: { value: 'a' } - }); - - traced$.subscribe({ - complete: () => { - expect(scenario.complete).toHaveBeenNthCalledWith(2, undefined); - done(); - } - }); - } - }); }); - describe('teardown without a source complete/error', () => { - it('should cancel the scenario on a manual unsubscribe', () => { - const source = new Subject(); - const subscription = source.pipe(traceScenario(scenario)).subscribe(); - - subscription.unsubscribe(); - - expect(scenario.cancel).toHaveBeenCalledTimes(1); - expect(scenario.complete).not.toHaveBeenCalled(); - expect(scenario.fail).not.toHaveBeenCalled(); - }); - - it('should cancel a superseded scenario when switchMap moves to the next inner observable', () => { - const supersededScenario = { - complete: jest.fn(), - fail: jest.fn(), - cancel: jest.fn(), - isSettled: false - } as unknown as jest.Mocked; - const pendingFirstRequest = new Subject(); - const secondRequest = of('b'); - const trigger = new Subject(); - - trigger - .pipe( - switchMap((n) => - (n === 1 ? pendingFirstRequest : secondRequest).pipe( - traceScenario(n === 1 ? supersededScenario : scenario) - ) - ) - ) - .subscribe(); - - trigger.next(1); // subscribes traceScenario(supersededScenario), still pending - trigger.next(2); // switchMap unsubscribes it before it ever settles - - expect(supersededScenario.cancel).toHaveBeenCalledTimes(1); - expect(supersededScenario.complete).not.toHaveBeenCalled(); - expect(supersededScenario.fail).not.toHaveBeenCalled(); - expect(scenario.complete).toHaveBeenCalledWith(undefined); - }); - - it('should cancel with the configured cancelOutcome on unsubscribe', () => { - const source = new Subject(); - const subscription = source - .pipe( - traceScenario(scenario, { cancelOutcome: { reason: 'superseded' } }) - ) - .subscribe(); - - subscription.unsubscribe(); - - expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); - }); - - it('should accept cancelOutcome as a function, evaluated at unsubscribe time', () => { - const source = new Subject(); - const subscription = source - .pipe( - traceScenario(scenario, { - cancelOutcome: () => ({ reason: 'superseded' }) - }) - ) - .subscribe(); - - subscription.unsubscribe(); - - expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); - }); - - it('should still cancel, with no outcome, when cancelOutcome throws', () => { - const consoleError = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); - const source = new Subject(); - const subscription = source - .pipe( - traceScenario(scenario, { - cancelOutcome: () => { - throw new Error('cancelOutcome is broken'); - } - }) - ) - .subscribe(); - - subscription.unsubscribe(); - - expect(scenario.cancel).toHaveBeenCalledWith(undefined); - consoleError.mockRestore(); - }); - - it('should record a reason for a switchMap-superseded scenario, not just a bare cancel', () => { - const supersededScenario = { - complete: jest.fn(), - fail: jest.fn(), - cancel: jest.fn(), - isSettled: false - } as unknown as jest.Mocked; - const pendingFirstRequest = new Subject(); - const secondRequest = of('b'); - const trigger = new Subject(); - - trigger - .pipe( - switchMap((n) => - (n === 1 ? pendingFirstRequest : secondRequest).pipe( - traceScenario(n === 1 ? supersededScenario : scenario, { - cancelOutcome: { reason: 'superseded' } - }) - ) - ) - ) - .subscribe(); - - trigger.next(1); - trigger.next(2); - - expect(supersededScenario.cancel).toHaveBeenCalledWith({ - reason: 'superseded' - }); - }); - - it('should not cancel a scenario that already completed normally', (done) => { - of('value') - .pipe(traceScenario(scenario)) - .subscribe({ - complete: () => { - (scenario as unknown as { isSettled: boolean }).isSettled = true; - expect(scenario.cancel).not.toHaveBeenCalled(); - done(); - } - }); - }); - - it('should not cancel a scenario that already failed', (done) => { - const error = new Error('boom'); - throwError(() => error) - .pipe(traceScenario(scenario)) - .subscribe({ - error: () => { - (scenario as unknown as { isSettled: boolean }).isSettled = true; - expect(scenario.cancel).not.toHaveBeenCalled(); - done(); - } - }); - }); - }); - - describe('with a real scenario', () => { - let settled: UwtScenarioRecord[]; - - const countRows = (rows: string[]) => ({ - metadata: { count: rows.length } - }); - - function createScenario(name = 'load-customers'): UwtScenario { - return new UwtScenario( - { name }, - { - identity: { - application: 'test-app', - environment: 'test', - version: '1.0.0' - }, - scenarioTelemetryConfig: { - ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario - }, - redact: UWT_DEFAULT_REDACT_CONFIG, - sink: new UwtNoopTelemetrySink(), - onSettled: (_id, record) => settled.push(record) - } - ); - } - - beforeEach(() => { - settled = []; - }); - - describe('single-value sources', () => { - it('should settle as success with subscribe()', () => { - const real = createScenario(); - - of(['a', 'b']).pipe(traceScenario(real, countRows)).subscribe(); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with lastValueFrom', async () => { - const real = createScenario(); - - await lastValueFrom( - of(['a', 'b']).pipe(traceScenario(real, countRows)) - ); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with firstValueFrom', async () => { - const real = createScenario(); - - const rows = await firstValueFrom( - of(['a', 'b']).pipe(traceScenario(real, countRows)) - ); - - expect(rows).toEqual(['a', 'b']); - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with take(1) after the operator', () => { - const real = createScenario(); - - of(['a', 'b']) - .pipe(traceScenario(real, countRows), take(1)) - .subscribe(); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with first() after the operator', () => { - const real = createScenario(); - - of(['a', 'b']) - .pipe(traceScenario(real, countRows), first()) - .subscribe(); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with take(1) before the operator', () => { - const real = createScenario(); - - of(['a', 'b']) - .pipe(take(1), traceScenario(real, countRows)) - .subscribe(); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - - it('should settle as success with take(1) before the operator under firstValueFrom', async () => { - const real = createScenario(); - - await firstValueFrom( - of(['a', 'b']).pipe(take(1), traceScenario(real, countRows)) - ); - - expect(real.status).toBe('success'); - expect(settled[0].metadata).toEqual({ count: 2 }); - }); - }); - - describe('switchMap superseding an inner stream', () => { - it('should abandon a stream superseded before it emitted', () => { - const superseded = createScenario('superseded'); - const latest = createScenario('latest'); - const pendingFirstRequest = new Subject(); - const trigger = new Subject(); - - trigger - .pipe( - switchMap((n) => - n === 1 - ? pendingFirstRequest.pipe(traceScenario(superseded)) - : of(['b']).pipe(traceScenario(latest)) - ) - ) - .subscribe(); - - trigger.next(1); - trigger.next(2); - - expect(superseded.status).toBe('abandoned'); - expect(latest.status).toBe('success'); - }); - - it('should settle as success a stream superseded after it emitted', () => { - const superseded = createScenario('superseded'); - const latest = createScenario('latest'); - const firstRequest = new Subject(); - const trigger = new Subject(); - - trigger - .pipe( - switchMap((n) => - n === 1 - ? firstRequest.pipe(traceScenario(superseded, countRows)) - : of(['b']).pipe(traceScenario(latest)) - ) - ) - .subscribe(); - - trigger.next(1); - firstRequest.next(['a']); // emitted, but never completed - trigger.next(2); - - expect(superseded.status).toBe('success'); - expect(superseded.toRecord().metadata).toEqual({ count: 1 }); - expect(latest.status).toBe('success'); - }); - }); - - it("should abandon a stream torn down after a value with valueThenTeardown: 'cancel'", () => { - const real = createScenario('poll-status'); - - of(['a'], ['b']) - .pipe(traceScenario(real, { valueThenTeardown: 'cancel' }), take(1)) - .subscribe(); - - expect(real.status).toBe('abandoned'); - }); - - describe('retry placement', () => { - function failOnce(): Observable { - let attempts = 0; - return defer(() => - attempts++ === 0 - ? throwError(() => new Error('transient')) - : of(['a']) - ); - } - - it('should settle as success when retry() comes before the operator', () => { - const real = createScenario(); - - failOnce().pipe(retry(1), traceScenario(real)).subscribe(); - - expect(real.status).toBe('success'); - }); - - it('should settle as failure when retry() comes after the operator, even if the retry succeeds', () => { - const real = createScenario(); + it('should stay silent in production mode', () => { + isDevModeMock.mockReturnValue(false); - failOnce().pipe(traceScenario(real), retry(1)).subscribe(); + of(1).pipe(traceScenario(scenario(), throwingMapper)).subscribe(); - // Documented limitation: the first error settles the scenario and - // the operator cannot see the downstream retry. - expect(real.status).toBe('failure'); - }); - }); + expect(consoleError).not.toHaveBeenCalled(); }); }); diff --git a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts index 3b731a1..0303e4f 100644 --- a/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts +++ b/projects/ngx-ui-watchtower/src/lib/scenario/uwt-scenario-operators/uwt-scenario-operators.ts @@ -1,38 +1,13 @@ -import { defer } from 'rxjs'; -import type { MonoTypeOperatorFunction, Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import type { MonoTypeOperatorFunction } from 'rxjs'; import type { UwtScenario, UwtScenarioOutcome } from '@absaoss-cps/ui-watchtower'; -import { uwtSafe } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; - -/** - * Options for configuring scenario completion within an RxJS stream. - * - * @group Interfaces - */ -export interface UwtTraceScenarioOptions { - /** - * Optional mapper that returns an outcome (metadata, statusCode, message) - * based on the emitted value when the Observable completes successfully. - */ - outcome?: (value: T) => UwtScenarioOutcome | void; - - /** - * Outcome used when the stream is unsubscribed before completing or - * erroring — e.g. a `switchMap` moving on to a newer value. - */ - cancelOutcome?: UwtScenarioOutcome | (() => UwtScenarioOutcome | void); - - /** - * What a downstream unsubscribe means once a value has been delivered. - * Default `'complete'`, so `firstValueFrom`, `take(1)` and `first()` settle - * as success. Use `'cancel'` for multi-value streams (polling, progress), - * where stopping after a value is abandonment. - */ - valueThenTeardown?: 'complete' | 'cancel'; -} +import { + traceScenario as coreTraceScenario, + UwtTraceScenarioOptions +} from '@absaoss-cps/ui-watchtower/rxjs'; +import { uwtDevModeReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; /** * An RxJS pipeable operator that automatically settles a {@link UwtScenario} @@ -60,60 +35,5 @@ export function traceScenario( options?: UwtTraceScenarioOptions | ((value: T) => UwtScenarioOutcome | void) ): MonoTypeOperatorFunction { - const outcomeMapper = - typeof options === 'function' ? options : options?.outcome; - const cancelOutcome = - typeof options === 'function' ? undefined : options?.cancelOutcome; - const valueThenTeardown = - (typeof options === 'function' ? undefined : options?.valueThenTeardown) ?? - 'complete'; - - return (source: Observable): Observable => - defer(() => { - let lastValue: T | undefined; - let hasValue = false; - - const complete = () => { - const outcome = - hasValue && outcomeMapper - ? uwtSafe( - 'scenario.traceOutcome', - () => outcomeMapper(lastValue as T), - undefined - ) - : undefined; - scenario.complete(outcome || undefined); - }; - - const unsubscribe = () => { - if (scenario.isSettled) { - return; - } - // The consumer got a value and stopped listening, as - // firstValueFrom, take(1) and first() do: treat it as completion. - if (hasValue && valueThenTeardown === 'complete') { - complete(); - return; - } - const resolved = - typeof cancelOutcome === 'function' - ? uwtSafe('scenario.traceCancelOutcome', cancelOutcome, undefined) - : cancelOutcome; - scenario.cancel(resolved || undefined); - }; - - return source.pipe( - tap({ - next: (value) => { - lastValue = value; - hasValue = true; - }, - complete, - error: (error: unknown) => { - scenario.fail({ error }); - }, - unsubscribe - }) - ); - }); + return coreTraceScenario(scenario, options, uwtDevModeReporter); } diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts index fd95a92..a5d9a50 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-bi-telemetry.service/uwt-bi-telemetry.service.ts @@ -1,11 +1,8 @@ import { inject, Injectable } from '@angular/core'; import { - UwtBIEvent, UwtBIEventDetail, UwtBIEventName, - uwtEventTypes, - uwtNow, - uwtRedactConfigFor, + UwtBITelemetry, UwtTelemetryMetadata, UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; @@ -15,15 +12,7 @@ import { UWT_TELEMETRY_IDENTITY } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UWT_BI_TELEMETRY_CONFIG } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; -import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; -import { - uwtRedactMetadata, - uwtScrubString -} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { - uwtSafe, - uwtSafeVoid -} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { uwtDevModeReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; /** * Business and UX event tracking — feature adoption, interaction analysis, @@ -60,18 +49,15 @@ import { */ @Injectable({ providedIn: 'root' }) export class UwtBITelemetryService { - private readonly identity = inject(UWT_TELEMETRY_IDENTITY); - private readonly biConfig = inject(UWT_BI_TELEMETRY_CONFIG); - private readonly redact = uwtRedactConfigFor( - inject(UWT_REDACT_CONFIG), - this.biConfig.redact - ); - - private readonly sink = inject(UwtTelemetrySink); - private readonly enabled = uwtTelemetryEnabled(); - - private readonly eventTypes = uwtEventTypes(this.identity.eventNamespace); - private readonly lastEmittedAt = new Map(); + /** The core's BI telemetry, configured from this injector. */ + private readonly bi = new UwtBITelemetry({ + identity: inject(UWT_TELEMETRY_IDENTITY), + config: inject(UWT_BI_TELEMETRY_CONFIG), + redact: inject(UWT_REDACT_CONFIG), + sink: inject(UwtTelemetrySink), + enabled: uwtTelemetryEnabled(), + report: uwtDevModeReporter + }); /** * Records a business or UX event. @@ -87,123 +73,6 @@ export class UwtBITelemetryService { metadata?: UwtTelemetryMetadata, detail?: UwtBIEventDetail ): void { - if (!this.enabled) { - return; - } - uwtSafeVoid('biTelemetry.track', () => { - if (!eventName) { - return; - } - - const redactedFeature = detail?.feature - ? uwtScrubString(detail.feature, this.redact) - : undefined; - const redactedMetadata = uwtRedactMetadata(metadata, this.redact); - - if ( - this.isDuplicate(eventName, redactedMetadata, { - ...detail, - feature: redactedFeature - }) - ) { - return; - } - - const event: UwtBIEvent = { - eventName, - eventTime: new Date().toISOString(), - scenarioId: detail?.scenarioId, - feature: redactedFeature, - metadata: redactedMetadata, - application: this.identity.application, - sessionId: uwtSafe( - 'biTelemetry.getSessionId', - () => this.sink.getSessionId(), - undefined - ), - userId: uwtSafe( - 'biTelemetry.getUserId', - () => this.sink.getUserId(), - undefined - ) - }; - - uwtDebugWrite('debugBI', () => - writeToConsole(eventName, this.eventTypes.bi, event) - ); - - this.sink.record(this.eventTypes.bi, event); - }); - } - - private isDuplicate( - eventName: string, - metadata: UwtTelemetryMetadata | undefined, - detail?: UwtBIEventDetail - ): boolean { - const key = JSON.stringify([ - eventName, - detail?.scenarioId ?? '', - detail?.feature ?? '', - this.metadataKey(metadata) - ]); - const now = uwtNow(); - const last = this.lastEmittedAt.get(key); - - if (last !== undefined && now - last < this.biConfig.dedupWindowMs) { - this.lastEmittedAt.delete(key); - this.lastEmittedAt.set(key, last); - return true; - } - - if (this.lastEmittedAt.size >= this.biConfig.dedupMaxKeys) { - for (const [staleKey, at] of this.lastEmittedAt) { - if (now - at >= this.biConfig.dedupWindowMs) { - this.lastEmittedAt.delete(staleKey); - } - } - - if (this.lastEmittedAt.size >= this.biConfig.dedupMaxKeys) { - const oldestKey = this.lastEmittedAt.keys().next().value; - if (oldestKey !== undefined) { - this.lastEmittedAt.delete(oldestKey); - } - } - } - - this.lastEmittedAt.delete(key); - this.lastEmittedAt.set(key, now); - return false; - } - - /** - * A stable string encoding a flat metadata object's content, for use in - * the dedup key — otherwise two same-named events with different metadata - * would collide and the second would be silently dropped. Keys are sorted - * so property order doesn't affect the result. Callers pass already- - * redacted metadata — a caller's raw values must never end up as a `Map` - * key living in this root service's memory for the page lifetime. - */ - private metadataKey(metadata: UwtTelemetryMetadata | undefined): string { - if (!metadata) { - return ''; - } - return JSON.stringify( - Object.keys(metadata) - .sort() - .map((k) => [k, metadata[k]]) - ); + this.bi.track(eventName, metadata, detail); } } - -/** - * Prefixed with the application - in a composed - * page every realm writes to the one console. - */ -function writeToConsole( - eventName: string, - eventType: string, - event: UwtBIEvent -): void { - console.log(`[${event.application}][bi] ${eventName} -> ${eventType}`, event); -} diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts index 271e2cd..91fcf3b 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.spec.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable, OnDestroy } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { UWT_REDACTED, @@ -18,7 +18,8 @@ import { withRedaction } from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import * as uwtTelemetryRedactUtil from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +// The logging logic lives in the core, which calls its own redaction module. +import * as uwtTelemetryRedactUtil from '../../../../../ui-watchtower/src/lib/utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; import { UwtLoggerService } from './uwt-logger.service'; /** Captures what the library emitted, so a test can assert on it. */ @@ -853,4 +854,31 @@ describe('UwtLoggerService', () => { expect(() => window.dispatchEvent(new Event('pagehide'))).not.toThrow(); }); }); + + describe('teardown', () => { + it('should still ship a line logged by a service destroyed after the logger service', () => { + // Angular destroys a service before those that inject it, so this one + // logs after UwtLoggerService has been destroyed. + @Injectable({ providedIn: 'root' }) + class ClosingService implements OnDestroy { + private readonly logger = inject(UwtLoggerService).getLogger('closing'); + + ngOnDestroy(): void { + this.logger.log('closing'); + } + } + TestBed.inject(ClosingService); + + TestBed.resetTestingModule(); + + expect(transport.records.map((r) => r.message)).toContain('closing'); + }); + + it('should keep answering queries after the logger service is destroyed', async () => { + logger.log('before'); + service.ngOnDestroy(); + + await expect(service.query({})).resolves.toHaveLength(1); + }); + }); }); diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts index 707f063..b568848 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-logger.service/uwt-logger.service.ts @@ -1,14 +1,10 @@ -import { DOCUMENT } from '@angular/common'; import { inject, Injectable, OnDestroy } from '@angular/core'; import { - UWT_LOG_LEVEL_ORDER, - UwtLogDetail, UwtLogger, UwtLoggerName, - UwtLogLevel, UwtLogQuery, UwtLogRecord, - uwtRedactConfigFor, + UwtLogTelemetry, UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; import { @@ -18,26 +14,11 @@ import { } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UWT_LOG_CONFIG } from '../../config/uwt-log.config/uwt-log.config'; import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; -import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; import { - uwtNormalizeError, - uwtRedactMetadata, - uwtScrubString -} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; -import { - uwtIsBrowser, - uwtSafe, - uwtSafeVoid, - uwtSafeVoidMaybeAsync + uwtBrowserDocument, + uwtDevModeReporter } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -/** Handed out while telemetry is off: every call does nothing. */ -const INERT_LOGGER: UwtLogger = Object.freeze({ - log: () => undefined, - warn: () => undefined, - error: () => undefined -}); - /** * Structured application logging. * @@ -77,64 +58,22 @@ const INERT_LOGGER: UwtLogger = Object.freeze({ */ @Injectable({ providedIn: 'root' }) export class UwtLoggerService implements OnDestroy { - private readonly identity = inject(UWT_TELEMETRY_IDENTITY); - private readonly logsConfig = inject(UWT_LOG_CONFIG); - private readonly redact = uwtRedactConfigFor( - inject(UWT_REDACT_CONFIG), - this.logsConfig.redact - ); - - /** Enrichment only (`sessionId`/`userId`, optional RUM mirroring). */ - private readonly sink = inject(UwtTelemetrySink, { optional: true }); - private readonly apiProvider = inject(UWT_LOG_API_PROVIDER); - private readonly document = inject(DOCUMENT); - private readonly enabled = uwtTelemetryEnabled(); - - /** Page listeners: only in a browser, and only while telemetry is on. */ - private readonly listensToPage = uwtIsBrowser() && this.enabled; - - private readonly onPageHide = () => this.flushProvider(); - private readonly onVisibilityChange = () => { - if (this.document.visibilityState === 'hidden') { - this.flushProvider(); - } - }; - - /** - * One logger per name, so {@link getLogger} is idempotent. - * - * Unbounded growth is not a concern the way it is for active scenarios: - * names come from the closed {@link UwtLoggerNames} vocabulary, so this - * map is bounded by what the application declared at build time. - */ - private readonly loggers = new Map(); - - constructor() { - if (this.listensToPage) { - this.document.defaultView?.addEventListener('pagehide', this.onPageHide); - - this.document.addEventListener( - 'visibilitychange', - this.onVisibilityChange - ); - } - } + /** The core's logging, configured from this injector. */ + private readonly logging = new UwtLogTelemetry({ + identity: inject(UWT_TELEMETRY_IDENTITY), + config: inject(UWT_LOG_CONFIG), + redact: inject(UWT_REDACT_CONFIG), + // Enrichment only (`sessionId`/`userId`, optional RUM mirroring). + sink: inject(UwtTelemetrySink, { optional: true }), + logApi: inject(UWT_LOG_API_PROVIDER), + document: uwtBrowserDocument(), + enabled: uwtTelemetryEnabled(), + report: uwtDevModeReporter + }); /** @inheritdoc */ ngOnDestroy(): void { - if (this.listensToPage) { - this.document.defaultView?.removeEventListener( - 'pagehide', - this.onPageHide - ); - this.document.removeEventListener( - 'visibilitychange', - this.onVisibilityChange - ); - } - if (this.enabled) { - this.flushProvider(); - } + this.logging.dispose(); } /** @@ -158,17 +97,8 @@ export class UwtLoggerService implements OnDestroy { * @param filter narrows what is returned * @returns the matching records, or `[]` when none can be read */ - async query(filter: UwtLogQuery = {}): Promise { - if (!this.enabled) { - return []; - } - const pending = uwtSafe( - 'logger.query', - () => this.apiProvider.query(filter), - undefined - ); - - return (await pending?.catch(() => undefined)) ?? []; + query(filter: UwtLogQuery = {}): Promise { + return this.logging.query(filter); } /** @@ -203,143 +133,6 @@ export class UwtLoggerService implements OnDestroy { * @returns the logger for that name, created once and reused */ getLogger(name: UwtLoggerName): UwtLogger { - if (!this.enabled) { - return INERT_LOGGER; - } - const cached = uwtSafe( - 'logger.getLogger', - () => this.loggers.get(name), - undefined - ); - if (cached) { - return cached; - } - - const logger = this.createLogger(name); - uwtSafeVoid('logger.register', () => this.loggers.set(name, logger)); - return logger; - } - - /** - * Builds the logger for one name. - * - * Private, and reached only through {@link getLogger}, so every logger - * handed out is registered and named. The name is captured here rather - * than read from each call's detail, which is why a record's `logger` - * cannot disagree with the logger that wrote it. - * - * @param name the logger name stamped onto every record it writes - * @returns a logger bound to that name - */ - private createLogger(name: UwtLoggerName): UwtLogger { - return { - log: (message, detail) => this.emit('log', name, message, detail), - warn: (message, detail) => this.emit('warn', name, message, detail), - error: (message, detail) => this.emit('error', name, message, detail) - }; + return this.logging.getLogger(name); } - - /** Severity floor for one logger — its own override, or the global one. */ - private minLevelFor(logger: UwtLoggerName): UwtLogLevel { - const { levels, minLevel } = this.logsConfig; - return levels?.[logger] ?? minLevel; - } - - private emit( - level: UwtLogLevel, - logger: UwtLoggerName, - message: string, - detail?: UwtLogDetail - ): void { - uwtSafeVoid(`logger.${level}`, () => { - if ( - UWT_LOG_LEVEL_ORDER[level] < - UWT_LOG_LEVEL_ORDER[this.minLevelFor(logger)] - ) { - return; - } - - const record = this.buildRecord(level, logger, message, detail); - - uwtDebugWrite('debugLogger', () => writeToConsole(record), record.logger); - - this.deliver(record); - - if (level === 'error' && this.logsConfig.mirrorErrorsToRum && this.sink) { - const mirrored = - record.error ?? - uwtNormalizeError(new Error(record.message), this.redact); - if (mirrored) { - this.sink.recordError(mirrored); - } - } - }); - } - - /** Guards against a throwing or secretly-async, rejecting provider. */ - private deliver(record: UwtLogRecord): void { - uwtSafeVoidMaybeAsync('logger.deliver', () => - this.apiProvider.send(record) - ); - } - - /** Gives the provider its chance to ship whatever it has queued itself. */ - private flushProvider(): void { - uwtSafeVoidMaybeAsync('logger.providerFlush', () => - this.apiProvider.flush?.() - ); - } - - private buildRecord( - level: UwtLogLevel, - logger: UwtLoggerName, - message: string, - detail?: UwtLogDetail - ): UwtLogRecord { - const redact = this.redact; - const sink = this.sink; - const identity = this.identity; - - return { - timestamp: new Date().toISOString(), - level, - message: uwtScrubString(String(message ?? ''), redact), - logger, - context: detail?.context - ? uwtScrubString(detail.context, redact) - : undefined, - metadata: uwtRedactMetadata(detail?.metadata, redact), - error: uwtNormalizeError(detail?.error, redact), - correlationId: detail?.correlationId - ? uwtScrubString(detail.correlationId, redact) - : undefined, - application: identity.application, - environment: identity.environment, - version: identity.version, - ...(sink && { - userId: uwtSafe('getUserId', () => sink.getUserId(), undefined), - sessionId: uwtSafe('getSessionId', () => sink.getSessionId(), undefined) - }) - }; - } -} - -/** - * Prints the record exactly as the transport receives it. - * - * Prefixed with the application - in a composed - * page every realm writes to the one console - then the logger name and - * the context, each when present. - */ -function writeToConsole(record: UwtLogRecord): void { - const scope = [record.logger, record.context] - .filter(Boolean) - .map((part) => `[${part}]`) - .join(''); - const suffix = record.correlationId ? ` (${record.correlationId})` : ''; - - console[record.level]( - `[${record.application}]${scope} ${record.message}${suffix}`, - record - ); } diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts index 0981901..e5e23b0 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.spec.ts @@ -1,4 +1,4 @@ -import { Injectable, NgZone } from '@angular/core'; +import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { UWT_REDACTED, @@ -18,6 +18,7 @@ import { withRedaction, withScenarios } from '../../providers/uwt-telemetry-common.providers/uwt-telemetry-common.providers'; +import * as uwtTelemetrySafeUtil from '../../../../../ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; import { UWT_LOG_API_PROVIDER } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; import { UwtScenarioTelemetryService } from './uwt-scenario-telemetry.service'; @@ -2065,6 +2066,62 @@ describe('UwtScenarioTelemetryService', () => { expect(completed).toBe(true); }); + it('should complete a subscriber that arrives after destroy', () => { + service.ngOnDestroy(); + + let completed = false; + service.settled$.subscribe({ complete: () => (completed = true) }); + + expect(completed).toBe(true); + }); + + describe('copies of the record', () => { + let copy: jest.SpyInstance; + + const recordCopies = () => + copy.mock.calls.filter( + ([value]) => + (value as { scenarioName?: unknown } | null)?.scenarioName !== + undefined + ).length; + + beforeEach(() => { + copy = jest.spyOn(uwtTelemetrySafeUtil, 'uwtDeepClone'); + }); + afterEach(() => copy.mockRestore()); + + it('should make none while nobody subscribes', () => { + service.start({ name: 'a' }).complete(); + + expect(recordCopies()).toBe(0); + }); + + it('should make one per record for any number of subscribers, sharing it', () => { + const seen: UwtScenarioRecord[] = []; + service.settled$.subscribe((r) => seen.push(r)); + service.settled$.subscribe((r) => seen.push(r)); + + service.start({ name: 'a' }).complete(); + + expect(recordCopies()).toBe(1); + expect(seen).toHaveLength(2); + expect(seen[0]).toBe(seen[1]); + }); + + it('should stop once the last subscriber leaves', () => { + const first = service.settled$.subscribe(); + const second = service.settled$.subscribe(); + first.unsubscribe(); + + service.start({ name: 'a' }).complete(); + expect(recordCopies()).toBe(1); + + second.unsubscribe(); + service.start({ name: 'b' }).complete(); + expect(recordCopies()).toBe(1); + }); + }); + it('should not let a throwing subscriber break the scenario', () => { // RxJS's Subject.next() already isolates a throwing subscriber, not // uwtSafeVoid — this confirms the guarantee holds either way. @@ -2092,6 +2149,44 @@ describe('UwtScenarioTelemetryService', () => { }); }); + describe('teardown', () => { + it('should still record a scenario that a service starts and ends in its own ngOnDestroy', () => { + // Angular destroys a service before those that inject it, so this one + // starts its scenario after UwtScenarioTelemetryService was destroyed. + @Injectable({ providedIn: 'root' }) + class ClosingService implements OnDestroy { + private readonly scenarios = inject(UwtScenarioTelemetryService); + + ngOnDestroy(): void { + this.scenarios.start({ name: 'closing' }).complete(); + } + } + TestBed.inject(ClosingService); + + TestBed.resetTestingModule(); + + expect(onlyScenarioRecord()).toMatchObject({ + scenarioName: 'closing', + status: 'success' + }); + }); + + it('should still time out a scenario started after destroy and never ended, as before', () => { + jest.useFakeTimers(); + configure({ defaultTimeoutMs: 5_000 }); + + service.ngOnDestroy(); + service.start({ name: 'late' }); + jest.advanceTimersByTime(5_000); + + expect(onlyScenarioRecord()).toMatchObject({ + scenarioName: 'late', + status: 'timeout' + }); + jest.useRealTimers(); + }); + }); + describe('failure isolation', () => { let consoleError: jest.SpyInstance; diff --git a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts index ccb9798..9102378 100644 --- a/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts +++ b/projects/ngx-ui-watchtower/src/lib/services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service.ts @@ -1,13 +1,11 @@ -import { DOCUMENT } from '@angular/common'; -import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; +import { inject, Injectable, OnDestroy } from '@angular/core'; import { Observable, Subject } from 'rxjs'; import { - uwtDeepClone, - uwtRedactConfigFor, UwtScenario, UwtScenarioName, UwtScenarioOptions, UwtScenarioRecord, + UwtScenarioTelemetry, UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; import { @@ -17,24 +15,11 @@ import { } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; import { UWT_SCENARIO_TELEMETRY_CONFIG } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; import { + uwtBrowserDocument, uwtDevModeReporter, - uwtIsBrowser, - uwtIsDevMode, - uwtSafeVoid + uwtRunOutsideZone } from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -/** - * How many scenarios may be in flight before a development-mode warning - * suggests a leak. - * - * Concurrent scenarios are legitimate — several journeys, tabs or panels - * can run at once — so this is a smell threshold, not a limit: nothing is - * dropped or rejected when it is crossed. Set well above any plausible - * real concurrency, so crossing it might mean scenarios with `timeoutMs: 0` or - * long lifecycles are being started and never settled. - */ -const ACTIVE_SCENARIO_WARN_THRESHOLD = 50; - /** * Creates and tracks scenarios — user journeys whose health this telemetry * layer measures. @@ -59,31 +44,17 @@ const ACTIVE_SCENARIO_WARN_THRESHOLD = 50; */ @Injectable({ providedIn: 'root' }) export class UwtScenarioTelemetryService implements OnDestroy { - private readonly identity = inject(UWT_TELEMETRY_IDENTITY); - private readonly scenarioTelemetryConfig = inject( - UWT_SCENARIO_TELEMETRY_CONFIG - ); - - private readonly redact = uwtRedactConfigFor( - inject(UWT_REDACT_CONFIG), - this.scenarioTelemetryConfig.redact - ); - - private readonly sink = inject(UwtTelemetrySink); - private readonly document = inject(DOCUMENT); - private readonly enabled = uwtTelemetryEnabled(); - - /** Page listeners: only in a browser, and only while telemetry is on. */ - private readonly listensToPage = uwtIsBrowser() && this.enabled; - private readonly zone = inject(NgZone, { optional: true }); - - private readonly active = new Map(); - private readonly onPageHide = () => this.flushActive(); - private readonly onVisibilityChange = () => { - if (this.document.visibilityState === 'hidden') { - this.flushSink(); - } - }; + /** The core's scenario telemetry, configured from this injector. */ + private readonly scenarios = new UwtScenarioTelemetry({ + identity: inject(UWT_TELEMETRY_IDENTITY), + config: inject(UWT_SCENARIO_TELEMETRY_CONFIG), + redact: inject(UWT_REDACT_CONFIG), + sink: inject(UwtTelemetrySink), + document: uwtBrowserDocument(), + enabled: uwtTelemetryEnabled(), + runOutside: uwtRunOutsideZone(), + report: uwtDevModeReporter + }); private readonly _settled$ = new Subject(); @@ -101,18 +72,27 @@ export class UwtScenarioTelemetryService implements OnDestroy { * ``` */ readonly settled$: Observable = - this._settled$.asObservable(); - - constructor() { - if (this.listensToPage) { - this.document.defaultView?.addEventListener('pagehide', this.onPageHide); + new Observable((subscriber) => { + const subscription = this._settled$.subscribe(subscriber); + // Listens to the core only while observed: the core copies each record + // for its listeners, wasted work when nobody reads it. + if (!this.destroyed) { + this.stopListening ??= this.scenarios.onSettled((record) => + this._settled$.next(record) + ); + } + return () => { + subscription.unsubscribe(); + if (!this._settled$.observed) { + this.stopListening?.(); + this.stopListening = undefined; + } + }; + }); - this.document.addEventListener( - 'visibilitychange', - this.onVisibilityChange - ); - } - } + /** Stops the core's listener; set while {@link settled$} has subscribers. */ + private stopListening?: () => void; + private destroyed = false; /** * Starts a new scenario. @@ -122,48 +102,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { * @returns an independent scenario instance */ start(options: UwtScenarioOptions): UwtScenario { - if (!this.enabled) { - // Same return type, nothing recorded, timed, registered or sent. - return new UwtScenario(options, { - identity: this.identity, - scenarioTelemetryConfig: this.scenarioTelemetryConfig, - redact: this.redact, - sink: this.sink, - onSettled: () => undefined, - inert: true - }); - } - const scenario = new UwtScenario(options, { - identity: this.identity, - scenarioTelemetryConfig: this.scenarioTelemetryConfig, - redact: this.redact, - sink: this.sink, - runOutside: (callback) => - this.zone?.runOutsideAngular(callback) ?? callback(), - report: uwtDevModeReporter, - onSettled: (scenarioId, record) => { - this.active.delete(scenarioId); - if (!this._settled$.observed) { - return; - } - // Cloned so a settled$ subscriber can't mutate the same object - // that's about to be (or already was) shipped to the sink. - uwtSafeVoid('scenarioTelemetry.notify', () => - this._settled$.next(uwtDeepClone(record)) - ); - } - }); - - uwtSafeVoid('scenarioTelemetry.register', () => { - this.active.set(scenario.id, scenario); - if (uwtIsDevMode() && this.active.size > ACTIVE_SCENARIO_WARN_THRESHOLD) { - console.warn( - `[ngx-ui-watchtower] High number of active scenarios (${this.active.size}). Ensure scenarios with timeoutMs: 0 or long lifecycles are settled on destroy.` - ); - } - }); - - return scenario; + return this.scenarios.start(options); } /** @@ -173,7 +112,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { * @returns the scenario, or `undefined` if unknown or already settled */ find(scenarioId: string): UwtScenario | undefined { - return this.active.get(scenarioId); + return this.scenarios.find(scenarioId); } /** @@ -189,7 +128,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { * @returns matching active scenarios, in start order */ findByName(name: UwtScenarioName): UwtScenario[] { - return this.getActive().filter((scenario) => scenario.name === name); + return this.scenarios.findByName(name); } /** @@ -208,8 +147,7 @@ export class UwtScenarioTelemetryService implements OnDestroy { name: UwtScenarioName, scenarioId: string ): UwtScenario | undefined { - const scenario = this.find(scenarioId); - return scenario?.name === name ? scenario : undefined; + return this.scenarios.findByNameAndId(name, scenarioId); } /** @@ -218,52 +156,13 @@ export class UwtScenarioTelemetryService implements OnDestroy { * @returns the active scenarios, in start order */ getActive(): UwtScenario[] { - return [...this.active.values()]; + return this.scenarios.getActive(); } /** @inheritdoc */ ngOnDestroy(): void { - if (this.listensToPage) { - this.document.defaultView?.removeEventListener( - 'pagehide', - this.onPageHide - ); - this.document.removeEventListener( - 'visibilitychange', - this.onVisibilityChange - ); - } - if (this.enabled) { - this.flushActive(); - } + this.destroyed = true; + this.scenarios.dispose(); this._settled$.complete(); } - - /** - * Dispatches what the sink holds, leaving running scenarios alone. - * - * Uses the beacon transport since the page may not survive a normal request. - */ - private flushSink(): void { - uwtSafeVoid('scenarioTelemetry.flushSink', () => this.sink.flush(true)); - } - - /** - * Settles every in-flight scenario as abandoned and asks the sink to send - * what it holds using a transport that survives unload. - * - * Scenario emission is synchronous, so everything settled here reaches the - * sink before the beacon goes out. - */ - private flushActive(): void { - uwtSafeVoid('scenarioTelemetry.flush', () => { - for (const scenario of this.getActive()) { - scenario.settle('abandoned', { - reason: 'page-hidden', - metadata: { abandonedBy: 'page-hidden' } - }); - } - this.sink.flush(true); - }); - } } diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts index 017233a..98a6dea 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts @@ -1,9 +1,5 @@ import { Injectable } from '@angular/core'; -import { - UwtTelemetryError, - UwtTelemetryMetadata, - UwtTelemetrySink -} from '@absaoss-cps/ui-watchtower'; +import { UwtNoopTelemetrySink as CoreNoopTelemetrySink } from '@absaoss-cps/ui-watchtower'; /** * Sink that discards everything. @@ -15,37 +11,4 @@ import { * @group Services */ @Injectable() -export class UwtNoopTelemetrySink extends UwtTelemetrySink { - private userId?: string; - - /** @inheritdoc */ - record( - _eventType: string, - _payload: object, - _metadata?: UwtTelemetryMetadata - ): void {} - - /** @inheritdoc */ - recordError( - _error: UwtTelemetryError, - _metadata?: UwtTelemetryMetadata - ): void {} - - /** @inheritdoc */ - getSessionId(): string | undefined { - return undefined; - } - - /** @inheritdoc */ - setUserId(userId: string | undefined): void { - this.userId = userId; - } - - /** @inheritdoc */ - getUserId(): string | undefined { - return this.userId; - } - - /** @inheritdoc */ - flush(_beacon?: boolean): void {} -} +export class UwtNoopTelemetrySink extends CoreNoopTelemetrySink {} diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts deleted file mode 100644 index 89a42e5..0000000 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-debug-flag.util/uwt-debug-flag.util.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - UwtDebugFlag, - uwtDebugWrite as coreDebugWrite -} from '@absaoss-cps/ui-watchtower'; -import { uwtDevModeReporter } from '../uwt-telemetry-safe.util/uwt-telemetry-safe.util'; - -/** - * Writes a debug line to the console, but only when `flag` is on, and never - * at the caller's expense: the core's, reporting a failing write through - * {@link uwtDevModeReporter}. - * - * @param flag the debug switch that has to be on - * @param write performs the console call; never runs when the flag is off - * @param name the name to test against a list-valued flag, e.g. a logger - * name for `debugLogger` - */ -export function uwtDebugWrite( - flag: UwtDebugFlag, - write: () => void, - name?: string -): void { - coreDebugWrite(flag, write, name, uwtDevModeReporter); -} diff --git a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts index 57dacc2..103f03e 100644 --- a/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts +++ b/projects/ngx-ui-watchtower/src/lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util.ts @@ -1,5 +1,5 @@ -import { isPlatformBrowser } from '@angular/common'; -import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { inject, isDevMode, NgZone, PLATFORM_ID } from '@angular/core'; import { UwtReporter, uwtSafe as coreSafe, @@ -24,6 +24,25 @@ export function uwtIsBrowser(): boolean { return isPlatformBrowser(inject(PLATFORM_ID)); } +/** + * The page telemetry watches: the document in a browser, nothing in a + * server-side render. Must be called from an injection context. + */ +export function uwtBrowserDocument(): Document | null { + const document = inject(DOCUMENT); + return uwtIsBrowser() ? document : null; +} + +/** + * Runs a callback outside Angular's zone when there is one, so telemetry's + * long-lived timers never keep the application from becoming stable. Must + * be called from an injection context. + */ +export function uwtRunOutsideZone(): (callback: () => T) => T { + const zone = inject(NgZone, { optional: true }); + return (callback) => zone?.runOutsideAngular(callback) ?? callback(); +} + /** * How this package reports what telemetry handles silently: to the console, * prefixed, and only in development mode. Everything the core does on this diff --git a/projects/ngx-ui-watchtower/src/public-api.ts b/projects/ngx-ui-watchtower/src/public-api.ts index f9078e4..9bac8c6 100644 --- a/projects/ngx-ui-watchtower/src/public-api.ts +++ b/projects/ngx-ui-watchtower/src/public-api.ts @@ -110,7 +110,7 @@ export { UwtNoopTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-noop-telemet export { traceScenario } from './lib/scenario/uwt-scenario-operators/uwt-scenario-operators'; export { uwtClassifyTelemetryEvent } from '@absaoss-cps/ui-watchtower'; export type { UwtTelemetrySinkEvent } from '@absaoss-cps/ui-watchtower'; -export type { UwtTraceScenarioOptions } from './lib/scenario/uwt-scenario-operators/uwt-scenario-operators'; +export type { UwtTraceScenarioOptions } from '@absaoss-cps/ui-watchtower/rxjs'; export { uwtIsDebugEnabled, uwtUuid } from '@absaoss-cps/ui-watchtower'; export type { UwtDebugFlag } from '@absaoss-cps/ui-watchtower'; export { uwtMarkSessionStart } from '@absaoss-cps/ui-watchtower'; diff --git a/projects/ngx-ui-watchtower/tsconfig.lib.json b/projects/ngx-ui-watchtower/tsconfig.lib.json index db304db..d8e13ec 100644 --- a/projects/ngx-ui-watchtower/tsconfig.lib.json +++ b/projects/ngx-ui-watchtower/tsconfig.lib.json @@ -16,7 +16,8 @@ // The core as published: its built declarations, not its source. Build // it first (npm run build does). "paths": { - "@absaoss-cps/ui-watchtower": ["../../dist/ui-watchtower"] + "@absaoss-cps/ui-watchtower": ["../../dist/ui-watchtower"], + "@absaoss-cps/ui-watchtower/rxjs": ["../../dist/ui-watchtower/rxjs"] } }, "include": ["src/**/*.ts"], diff --git a/projects/ngx-ui-watchtower/type-tests/compat.ts b/projects/ngx-ui-watchtower/type-tests/compat.ts index 97cf617..1b2731b 100644 --- a/projects/ngx-ui-watchtower/type-tests/compat.ts +++ b/projects/ngx-ui-watchtower/type-tests/compat.ts @@ -7,7 +7,7 @@ * registry is augmented here, so every name is a plain string; see * `augmented.ts` for registered names. */ -import { ApplicationConfig, inject, Injectable } from '@angular/core'; +import { ApplicationConfig, inject, Injectable, NgModule } from '@angular/core'; import { Observable, of } from 'rxjs'; import { provideUwtTelemetry, @@ -340,3 +340,27 @@ export interface Everything { stepName: UwtStepName; error: UwtTelemetryError; } + +/** + * Every injectable class an application may list as a provider in a + * decorator. That each keeps its Angular metadata (`ɵfac`, `ɵprov`) is held + * by the API snapshot; the compiler's own check skips library classes. + */ +@NgModule({ + providers: [ + UwtScenarioTelemetryService, + UwtBITelemetryService, + UwtLoggerService, + UwtNoopTelemetrySink, + UwtNoopLogApiProvider, + UwtBroadcastTelemetrySink, + UwtBroadcastLogApiProvider, + UwtTelemetryBroadcastHost, + UwtRumTelemetrySink, + AppSink, + AppLogBackend, + AppRumCredentials, + { provide: UwtTelemetrySink, useClass: UwtNoopTelemetrySink } + ] +}) +export class AppTelemetryModule {} diff --git a/projects/ui-watchtower/README.md b/projects/ui-watchtower/README.md index a58f076..24a61f0 100644 --- a/projects/ui-watchtower/README.md +++ b/projects/ui-watchtower/README.md @@ -5,20 +5,72 @@ The framework-independent engine behind scenario health telemetry, business/UX events and application logs, for any JavaScript or TypeScript application. -> **Work in progress.** The engine is moving here from the Angular package in -> stages: the models, scenarios, redaction and contracts are here; the engine -> that wires them together is next. Until it lands, use -> `@absaoss-cps/ngx-ui-watchtower`; it installs this package by itself. - It imports no framework and touches no browser global when imported, so it loads in Node, on a server and in a worker as well as in a browser. +> **Work in progress.** The broadcast and AWS RUM destinations are still +> moving here from the Angular package. + ## Install ```bash npm install @absaoss-cps/ui-watchtower +npm install rxjs # only for @absaoss-cps/ui-watchtower/rxjs +``` + +## Use + +Create one engine per application, with its identity, where telemetry goes +and where logs go: + +```ts +import { + uwtCreateTelemetry, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink +} from '@absaoss-cps/ui-watchtower'; + +const telemetry = uwtCreateTelemetry({ + identity: { application: 'cart', environment: 'prod', version: '1.4.0' }, + destination: new UwtNoopTelemetrySink(), // or your own UwtTelemetrySink + logApi: new UwtNoopLogApiProvider(), // or your own UwtLogApiProvider + logging: { minLevel: 'warn' } // optional overrides, per concern +}); + +// Scenarios: a user journey, its steps and outcome. +const scenario = telemetry.scenarios.start({ name: 'checkout' }); +scenario.step('pay'); +scenario.complete({ metadata: { items: 2 } }); + +// BI events. +telemetry.bi.track('export_clicked', { format: 'csv' }); + +// Logs, by named logger. +telemetry.logging.getLogger('checkout').warn('Slow payment'); + +// When the application, or a fragment, goes away. +telemetry.dispose(); ``` +Every call is fail-open: telemetry never throws into your application. +Configuration errors still throw, when the engine is created. + +| Option | What it does | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `identity` | Application, environment and version, carried by every record | +| `destination` | A `UwtTelemetrySink`: where scenarios, BI events and mirrored errors go | +| `logApi` | A `UwtLogApiProvider`: where logs go, and are read back from | +| `scenarios`, `logging`, `bi`, `redaction` | Overrides of each concern's defaults | +| `enabled` | `false` makes everything inert: nothing is recorded, sent or logged | +| `document` | The page whose lifecycle settles and flushes telemetry; the global one by default, `null` for none | +| `report` | Receives what telemetry skips or drops; nothing is reported without one | +| `runOutside` | Schedules timers outside a framework's change detection | + +`dispose()` stops watching the page, settles scenarios still in flight as +abandoned and flushes; the engine does nothing after that, and leaves no +listener or timer behind. The destination and log store you passed are +yours to close. + ## Declare your names Scenario, step, event and logger names are typed, so a typo is a compile @@ -50,9 +102,8 @@ registries: either module path reaches the same ones. ## Seeing what telemetry skips -Telemetry never throws into your application, and the engine prints nothing -by itself. Hand it a `UwtReporter` where you want to see what it skipped or -dropped, typically in development: +The engine prints nothing by itself. Hand it a `UwtReporter` where you want +to see what it skipped or dropped, typically in development: ```ts import { UwtReporter } from '@absaoss-cps/ui-watchtower'; @@ -61,17 +112,31 @@ const report: UwtReporter = (level, message, ...details) => console[level](`[my-app] ${message}`, ...details); ``` +## RxJS + +`traceScenario` settles a scenario from an Observable's outcome. It lives in +its own entry point, so the main one never loads RxJS: + +```ts +import { traceScenario } from '@absaoss-cps/ui-watchtower/rxjs'; + +customers$.pipe(traceScenario(scenario)).subscribe(); +``` + ## API | Group | Exports | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| Configuration | `UwtTelemetryIdentity`, `UwtScenarioTelemetryConfig`, `UwtLogConfig`, `UwtBITelemetryConfig`, `UWT_DEFAULT_TELEMETRY_CONFIG` | +| Engine | `uwtCreateTelemetry`, `UwtTelemetry`, `UwtTelemetryOptions`, `UwtTelemetryDocument` | +| Concerns | `UwtScenarioTelemetry`, `UwtBITelemetry`, `UwtLogTelemetry` and their options, for an adapter composing them itself | +| Configuration | `UwtTelemetryIdentity`, the per-concern config types, `UWT_DEFAULT_TELEMETRY_CONFIG`, `uwtResolveTelemetryConfig` | | Models | scenario, step, BI event and log record types; `UWT_LOG_LEVEL_ORDER`, `UWT_TELEMETRY_EVENT_TYPE`, `uwtEventTypes` | | Names | `UwtScenarioNames`, `UwtScenarioSteps`, `UwtBIEventNames`, `UwtLoggerNames` and their `Uwt*Name` types | | Scenarios | `UwtScenario` | -| Contracts | `UwtTelemetrySink` (a destination), `UwtLogApiProvider` and `UwtLogQuery` (a log backend) | +| Contracts | `UwtTelemetrySink` and `UwtNoopTelemetrySink`; `UwtLogApiProvider`, `UwtLogQuery` and `UwtNoopLogApiProvider` | | Redaction | `uwtRedactMetadata`, `uwtScrubString`, `uwtNormalizeError`, `uwtRedactConfigFor`, `UwtRedactConfig`, `UWT_DEFAULT_REDACT_CONFIG`, `UWT_REDACTED` | -| Utilities | `uwtClassifyTelemetryEvent`, `uwtIsDebugEnabled`, `uwtMarkSessionStart`, `uwtUuid`, `UwtReporter` | +| Utilities | `uwtClassifyTelemetryEvent`, `uwtIsDebugEnabled`, `uwtMarkSessionStart`, `uwtUuid`, `uwtSafe*`, `UwtReporter` | +| `/rxjs` | `traceScenario`, `UwtTraceScenarioOptions` | ## License diff --git a/projects/ui-watchtower/jest.config.js b/projects/ui-watchtower/jest.config.js index 992afc8..05504b1 100644 --- a/projects/ui-watchtower/jest.config.js +++ b/projects/ui-watchtower/jest.config.js @@ -7,18 +7,19 @@ module.exports = { displayName: 'ui-watchtower', rootDir: __dirname, - roots: ['/src'], + roots: ['/src', '/rxjs'], testEnvironment: 'node', transform: { '^.+\\.ts$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] }, moduleNameMapper: { - '^@absaoss-cps/ui-watchtower$': '/src/public-api.ts' + '^@absaoss-cps/ui-watchtower$': '/src/public-api.ts', + '^@absaoss-cps/ui-watchtower/rxjs$': '/rxjs/src/public-api.ts' }, coverageDirectory: '/../../coverage/ui-watchtower', collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.spec.ts', - '!src/**/public-api.ts' + '{src,rxjs}/**/*.ts', + '!**/*.spec.ts', + '!**/public-api.ts' ] }; diff --git a/projects/ui-watchtower/package.json b/projects/ui-watchtower/package.json index bc739c4..4b08307 100644 --- a/projects/ui-watchtower/package.json +++ b/projects/ui-watchtower/package.json @@ -1,6 +1,6 @@ { "name": "@absaoss-cps/ui-watchtower", - "version": "0.1.0", + "version": "0.4.0", "description": "Framework-independent telemetry engine: application logs, scenario health telemetry and business/UX events, for any JavaScript or TypeScript application.", "repository": { "type": "git", @@ -9,13 +9,9 @@ "homepage": "https://github.com/AbsaOSS/ui-watchtower", "license": "Apache-2.0", "peerDependencies": { - "aws-rum-web": "^3.2.1", "rxjs": "^7.8.2" }, "peerDependenciesMeta": { - "aws-rum-web": { - "optional": true - }, "rxjs": { "optional": true } diff --git a/projects/ui-watchtower/public-api.snapshot.json b/projects/ui-watchtower/public-api.snapshot.json index 1989156..b38bf28 100644 --- a/projects/ui-watchtower/public-api.snapshot.json +++ b/projects/ui-watchtower/public-api.snapshot.json @@ -40,24 +40,36 @@ "UwtBIEventNames": [ "interface" ], + "UwtBITelemetry": [ + "class", + "new (options: UwtBITelemetryOptions): UwtBITelemetry", + "dispose(): void", + "track(eventName: string, metadata?: UwtTelemetryMetadata | undefined, detail?: UwtBIEventDetail | undefined): void" + ], "UwtBITelemetryConfig": [ "interface", "dedupMaxKeys: number", "dedupWindowMs: number", "redact: boolean" ], + "UwtBITelemetryOptions": [ + "interface", + "config: UwtBITelemetryConfig", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "redact: UwtRedactConfig", + "report?: UwtReporter | undefined", + "sink: UwtTelemetrySink" + ], "uwtClassifyTelemetryEvent": [ "function(eventType: string, payload: object): UwtTelemetrySinkEvent" ], + "uwtCreateTelemetry": [ + "function(options: UwtTelemetryOptions): UwtTelemetry" + ], "UwtDebugFlag": [ "type = 'debugLogger' | 'debugScenario' | 'debugBI'" ], - "uwtDebugWrite": [ - "function(flag: UwtDebugFlag, write: () => void, name?: string | undefined, report?: UwtReporter | undefined): void" - ], - "uwtDeepClone": [ - "function(value: T): T" - ], "uwtEventTypes": [ "function(namespace?: string | undefined): UwtTelemetryEventTypes" ], @@ -124,15 +136,46 @@ "userId?: string | undefined", "version: string" ], + "UwtLogTelemetry": [ + "class", + "new (options: UwtLogTelemetryOptions): UwtLogTelemetry", + "dispose(): void", + "getLogger(name: string): UwtLogger", + "query(filter?: UwtLogQuery | undefined): Promise" + ], + "UwtLogTelemetryOptions": [ + "interface", + "config: UwtLogConfig", + "document?: UwtTelemetryDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "logApi: UwtLogApiProvider", + "redact: UwtRedactConfig", + "report?: UwtReporter | undefined", + "sink?: UwtTelemetrySink | null | undefined" + ], "uwtMarkSessionStart": [ "function(sessionId: string, now?: number | undefined): void" ], + "UwtNoopLogApiProvider": [ + "class", + "new (): UwtNoopLogApiProvider", + "query(_filter: UwtLogQuery): Promise", + "send(_record: UwtLogRecord): void" + ], + "UwtNoopTelemetrySink": [ + "class", + "new (): UwtNoopTelemetrySink", + "flush(_beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "record(_eventType: string, _payload: object, _metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(_error: UwtTelemetryError, _metadata?: UwtTelemetryMetadata | undefined): void", + "setUserId(userId: string | undefined): void" + ], "uwtNormalizeError": [ "function(error: unknown, config?: UwtRedactConfig | undefined, report?: UwtReporter | undefined): UwtTelemetryError | undefined" ], - "uwtNow": [ - "function(): number" - ], "UwtPiiValuePattern": [ "type = 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'" ], @@ -157,6 +200,9 @@ "UwtReporter": [ "type = (level: 'warn' | 'error', message: string, ...details: unknown[]) => void" ], + "uwtResolveTelemetryConfig": [ + "function(overrides?: UwtTelemetryConfigOverrides | undefined): UwtTelemetryConfig" + ], "uwtSafe": [ "function(operation: string, fn: () => T, fallback: T, report?: UwtReporter | undefined): T" ], @@ -292,6 +338,17 @@ "UwtScenarioStepStatus": [ "type = 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'" ], + "UwtScenarioTelemetry": [ + "class", + "new (options: UwtScenarioTelemetryOptions): UwtScenarioTelemetry", + "dispose(): void", + "find(scenarioId: string): UwtScenario | undefined", + "findByName(name: string): UwtScenario[]", + "findByNameAndId(name: string, scenarioId: string): UwtScenario | undefined", + "getActive(): UwtScenario[]", + "onSettled(listener: (record: UwtScenarioRecord) => void): () => void", + "start(options: UwtScenarioOptions): UwtScenario" + ], "UwtScenarioTelemetryConfig": [ "interface", "defaultTimeoutMs: number", @@ -301,6 +358,17 @@ "redact: boolean", "userTimings: boolean" ], + "UwtScenarioTelemetryOptions": [ + "interface", + "config: UwtScenarioTelemetryConfig", + "document?: UwtTelemetryDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "redact: UwtRedactConfig", + "report?: UwtReporter | undefined", + "runOutside?: ((callback: () => T) => T) | undefined", + "sink: UwtTelemetrySink" + ], "uwtScrubString": [ "function(value: string, config: UwtRedactConfig, report?: UwtReporter | undefined): string" ], @@ -310,12 +378,42 @@ "UwtStepName": [ "type = string" ], + "UwtTelemetry": [ + "interface", + "dispose(): void", + "flush(beacon?: boolean | undefined): void", + "readonly bi: UwtBITelemetry", + "readonly logging: UwtLogTelemetry", + "readonly scenarios: UwtScenarioTelemetry", + "setUserId(userId: string | undefined): void" + ], "UwtTelemetryAttribution": [ "interface", "application: string", "sessionId?: string | undefined", "userId?: string | undefined" ], + "UwtTelemetryConfig": [ + "interface", + "bi: UwtBITelemetryConfig", + "logging: UwtLogConfig", + "redaction: UwtRedactConfig", + "scenarios: UwtScenarioTelemetryConfig" + ], + "UwtTelemetryConfigOverrides": [ + "interface", + "bi?: Partial | undefined", + "logging?: Partial | undefined", + "redaction?: Partial | undefined", + "scenarios?: Partial | undefined" + ], + "UwtTelemetryDocument": [ + "interface", + "addEventListener(type: 'visibilitychange', listener: () => void): void", + "readonly defaultView: UwtListenable<'pagehide'> | null", + "readonly visibilityState: string", + "removeEventListener(type: 'visibilitychange', listener: () => void): void" + ], "UwtTelemetryError": [ "interface", "message: string", @@ -338,6 +436,20 @@ "UwtTelemetryMetadata": [ "type = { [x: string]: string | number | boolean | null; }" ], + "UwtTelemetryOptions": [ + "interface", + "bi?: Partial | undefined", + "destination: UwtTelemetrySink", + "document?: UwtTelemetryDocument | null | undefined", + "enabled?: boolean | undefined", + "identity: UwtTelemetryIdentity", + "logApi: UwtLogApiProvider", + "logging?: Partial | undefined", + "redaction?: Partial | undefined", + "report?: UwtReporter | undefined", + "runOutside?: ((callback: () => T) => T) | undefined", + "scenarios?: Partial | undefined" + ], "UwtTelemetrySink": [ "abstract class", "new (): UwtTelemetrySink", @@ -354,5 +466,16 @@ "uwtUuid": [ "function(): string" ] + }, + "@absaoss-cps/ui-watchtower/rxjs": { + "traceScenario": [ + "function(scenario: UwtScenario, options?: UwtTraceScenarioOptions | ((value: T) => void | UwtScenarioOutcome) | undefined, report?: UwtReporter | undefined): MonoTypeOperatorFunction" + ], + "UwtTraceScenarioOptions": [ + "interface", + "cancelOutcome?: UwtScenarioOutcome | (() => void | UwtScenarioOutcome) | undefined", + "outcome?: ((value: T) => void | UwtScenarioOutcome) | undefined", + "valueThenTeardown?: 'complete' | 'cancel' | undefined" + ] } } diff --git a/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.ts b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.ts new file mode 100644 index 0000000..196c0e9 --- /dev/null +++ b/projects/ui-watchtower/rum/src/lib/uwt-rum-telemetry.sink/uwt-rum-telemetry.sink.behaviour.spec.ts @@ -0,0 +1,650 @@ +/** + * @jest-environment jsdom + */ +import { uwtSessionElapsed } from '@absaoss-cps/ui-watchtower'; +import { UwtRumBootstrap } from '../uwt-rum-credentials/uwt-rum-credentials'; +import { + UwtRumTelemetrySink, + UwtRumTelemetrySinkOptions +} from './uwt-rum-telemetry.sink'; + +const mockClient = { + recordEvent: jest.fn(), + recordError: jest.fn(), + recordPageView: jest.fn(), + addSessionAttributes: jest.fn(), + setEventMetadataHook: jest.fn(), + setAwsCredentials: jest.fn(), + getSessionId: jest.fn(), + getUserId: jest.fn(), + pinUserId: jest.fn(), + startSession: jest.fn(), + dispatch: jest.fn(), + dispatchBeacon: jest.fn(), + disable: jest.fn() +}; +/** The arguments of every client built, and what building one does. */ +const mockConstructed: unknown[][] = []; +const mockConstruction: { fail: boolean; then?: () => void } = { fail: false }; + +jest.mock('aws-rum-web', () => ({ + AwsRum: function (...args: unknown[]) { + if (mockConstruction.fail) { + throw new Error('SDK exploded'); + } + mockConstructed.push(args); + mockConstruction.then?.(); + return mockClient; + } +})); + +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; +const SESSION_START = 'com.amazon.rum.session_start_event'; + +/** What a broker returns: settings, and credentials expiring in an hour. */ +function bootstrap( + config: Record = {}, + expiresInMs = HOUR +): UwtRumBootstrap { + return { + config: { + applicationId: 'app-monitor', + applicationVersion: '1.0.0', + region: 'eu-west-1', + ...config + } as UwtRumBootstrap['config'], + credentials: { + accessKeyId: 'a', + secretAccessKey: 's', + sessionToken: 't', + expiration: new Date(Date.now() + expiresInMs).toISOString() + } + }; +} + +/** A page with an origin and its cookies. */ +function page(origin = 'https://shop.example') { + return { + cookie: '', + defaultView: { location: { origin }, navigator: { cookieEnabled: true } } + }; +} + +/** The config the last client was built with. */ +function builtConfig(): Record { + return mockConstructed[mockConstructed.length - 1][3] as Record< + string, + unknown + >; +} + +/** The metadata hook the client was given. */ +function metadataHook(): (type: string) => Record { + return mockClient.setEventMetadataHook.mock.calls[0][0]; +} + +describe('the core RUM sink, in detail', () => { + let load: jest.Mock; + let sinks: UwtRumTelemetrySink[]; + + function sink( + options: Partial = {} + ): UwtRumTelemetrySink { + const created = new UwtRumTelemetrySink({ + identity: { application: 'cart', environment: 'test', version: '1' }, + credentialsProvider: { load }, + ...options + }); + sinks.push(created); + return created; + } + + beforeEach(() => { + Object.values(mockClient).forEach((fn) => fn.mockReset()); + mockClient.getSessionId.mockReturnValue('session-1'); + mockConstructed.length = 0; + mockConstruction.fail = false; + mockConstruction.then = undefined; + load = jest.fn(async () => bootstrap()); + sinks = []; + localStorage.clear(); + }); + + afterEach(() => { + sinks.forEach((created) => created.dispose()); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('settings the SDK cannot use', () => { + it.each([ + [ + { pagesToExclude: 'admin' }, + 'pagesToExclude (expected an array of RegExp)' + ], + [{ telemetries: 'errors' }, 'telemetries (expected an array)'], + [ + { telemetries: [['http', { urlsToExclude: ['/files'] }]] }, + 'http.urlsToExclude (expected an array of RegExp)' + ], + [ + { telemetries: [['http', { addXRayTraceIdHeader: 'yes' }]] }, + 'http.addXRayTraceIdHeader (expected a boolean or an array of RegExp)' + ], + [ + { telemetries: [['errors', { ignore: 'never' }]] }, + 'errors.ignore (expected a function)' + ] + ])('should keep RUM off for %j', async (config, problem) => { + const report = jest.fn(); + load.mockResolvedValue(bootstrap(config)); + const created = sink({ report }); + + await created.init(); + created.record('com.uwt.bi', {}); + + expect(mockConstructed).toHaveLength(0); + expect(report).toHaveBeenCalledWith( + 'config', + `RUM not started, settings of the wrong type: ${problem}` + ); + }); + + it('should accept valid telemetry options, and pass them on without the unset ones', async () => { + const ignore = () => false; + const pattern = /\/api\//; + load.mockResolvedValue( + bootstrap({ + telemetries: [ + 'errors', + ['errors', { ignore }], + [ + 'http', + { + urlsToExclude: [pattern], + addXRayTraceIdHeader: true, + urlsToInclude: null, + interactionId: undefined + } + ], + ['performance', 'not options'] + ] + }) + ); + + await sink().init(); + + expect(builtConfig().telemetries).toEqual([ + 'errors', + ['errors', { ignore }], + ['http', { urlsToExclude: [pattern], addXRayTraceIdHeader: true }], + ['performance', 'not options'] + ]); + }); + }); + + describe('the client config', () => { + it('should trace same-origin requests by default, never a look-alike host', async () => { + await sink({ document: page('https://shop.example') }).init(); + + const http = (builtConfig().telemetries as unknown[])[2] as [ + string, + { addXRayTraceIdHeader: RegExp[] } + ]; + const [sameOrigin] = http[1].addXRayTraceIdHeader; + expect(sameOrigin.test('https://shop.example/api/orders')).toBe(true); + expect(sameOrigin.test('https://shop.example')).toBe(true); + expect(sameOrigin.test('https://shop.example.attacker.example/')).toBe( + false + ); + expect(builtConfig()).toMatchObject({ + allowCookies: true, + enableXRay: true, + endpoint: 'https://dataplane.rum.eu-west-1.amazonaws.com' + }); + }); + + it("should leave unset settings to the SDK's defaults, and pass set ones on", async () => { + load.mockResolvedValue( + bootstrap({ + sessionSampleRate: 0.5, + client: 'my-client', + releaseId: null, + debug: undefined + }) + ); + + await sink().init(); + + expect(builtConfig()).toMatchObject({ + sessionSampleRate: 0.5, + client: 'my-client' + }); + expect(builtConfig()).not.toHaveProperty('releaseId'); + expect(builtConfig()).not.toHaveProperty('debug'); + }); + }); + + describe('startup', () => { + it('should turn RUM off for the page when the broker declines', async () => { + load.mockResolvedValue(null); + const created = sink(); + + created.record('com.uwt.early', {}); + await created.init(); + created.record('com.uwt.late', {}); + + expect(mockConstructed).toHaveLength(0); + expect(created.getSessionId()).toBeUndefined(); + }); + + it('should retry a failing broker with growing delays, then start and replay', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + jest.spyOn(Math, 'random').mockReturnValue(1); + const report = jest.fn(); + load + .mockRejectedValueOnce(new Error('broker down')) + .mockRejectedValueOnce(new Error('still down')); + const created = sink({ report }); + + created.record('com.uwt.early', {}); + await created.init(); + await jest.advanceTimersByTimeAsync(1000); + expect(load).toHaveBeenCalledTimes(2); + await jest.advanceTimersByTimeAsync(2000); + + expect(load).toHaveBeenCalledTimes(3); + expect(mockClient.recordEvent).toHaveBeenCalledWith( + 'com.uwt.early', + {}, + undefined + ); + expect( + report.mock.calls.filter(([, message]) => message.includes('retrying')) + ).toEqual([ + [ + 'warn', + 'RUM init failed, retrying', + expect.objectContaining({ message: 'broker down' }) + ] + ]); + }); + + it('should give up after five minutes of failures, and drop what it held', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + jest.spyOn(Math, 'random').mockReturnValue(1); + const report = jest.fn(); + load.mockRejectedValue(new Error('down')); + const created = sink({ report }); + + created.record('com.uwt.early', {}); + await created.init(); + await jest.advanceTimersByTimeAsync(6 * MINUTE); + const attempts = load.mock.calls.length; + await jest.advanceTimersByTimeAsync(10 * MINUTE); + + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM init failed, monitoring disabled', + expect.objectContaining({ message: 'down' }) + ); + expect(load).toHaveBeenCalledTimes(attempts); + expect(mockConstructed).toHaveLength(0); + }); + + it('should turn RUM off when the client fails to start, reporting only to a reporter', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const report = jest.fn(); + mockConstruction.fail = true; + + await sink({ report }).init(); + await sink().init(); + + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM init failed, monitoring disabled', + expect.objectContaining({ message: 'SDK exploded' }) + ); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should build no client when disposed while starting', async () => { + const created = sink(); + + const starting = created.init(); + created.dispose(); + await starting; + + expect(mockConstructed).toHaveLength(0); + }); + + it('should hold the latest 100 events from before startup', async () => { + const created = sink(); + + for (let i = 0; i < 105; i++) { + created.record(`com.uwt.e${i}`, {}); + } + await created.init(); + + expect(mockClient.recordEvent).toHaveBeenCalledTimes(100); + expect(mockClient.recordEvent.mock.calls[0][0]).toBe('com.uwt.e5'); + }); + }); + + describe('records', () => { + it('should drop reserved and null metadata, and send none when nothing is left', async () => { + const created = sink(); + await created.init(); + + created.record('com.uwt.a', {}, { 'aws:x': 'a', keep: 1, gone: null }); + created.record('com.uwt.b', {}, { 'aws:only': 'a' }); + + expect(mockClient.recordEvent.mock.calls).toEqual([ + ['com.uwt.a', {}, { keep: 1 }], + ['com.uwt.b', {}, undefined] + ]); + }); + + it("should fold a fragment's application into an error's name, not its own", async () => { + const created = sink(); + await created.init(); + const error = { name: 'TypeError', message: 'boom' }; + + created.recordError(error, { application: 'checkout' }); + created.recordError(error, { application: 'cart' }); + created.recordError(error); + + expect(mockClient.recordError.mock.calls).toEqual([ + [{ name: '[checkout] TypeError', message: 'boom' }], + [error], + [error] + ]); + }); + + it('should replay page views and errors recorded before startup', async () => { + const created = sink(); + + created.recordPageView('/customers/:id'); + created.recordError( + { name: 'Error', message: 'early' }, + { application: 'checkout' } + ); + await created.init(); + created.recordPageView('/orders'); + + expect(mockClient.recordPageView.mock.calls).toEqual([ + ['/customers/:id'], + ['/orders'] + ]); + expect(mockClient.recordError).toHaveBeenCalledWith({ + name: '[checkout] Error', + message: 'early' + }); + }); + }); + + describe('the signed-in user', () => { + it('should pin a user signed in before startup, once the client starts', async () => { + const created = sink(); + + created.setUserId('u-1'); + await created.init(); + + expect(created.getUserId()).toBe('u-1'); + expect(mockClient.pinUserId).toHaveBeenCalledWith('u-1'); + }); + + it('should start an anonymous session for a sign-out before startup', async () => { + const created = sink(); + + created.setUserId('u-1'); + created.setUserId(undefined); + await created.init(); + + expect(mockClient.pinUserId).not.toHaveBeenCalled(); + expect(mockClient.startSession).toHaveBeenCalledWith({ + userId: expect.any(String) + }); + }); + + it('should send what the client holds before the user changes, and ignore repeats', async () => { + load.mockResolvedValue( + bootstrap({ eventCacheSize: 300, batchLimit: 100 }) + ); + const created = sink(); + await created.init(); + + created.setUserId('u-1'); + expect(mockClient.dispatch).toHaveBeenCalledTimes(3); + expect(mockClient.pinUserId).toHaveBeenCalledWith('u-1'); + + created.setUserId('u-1'); + expect(mockClient.dispatch).toHaveBeenCalledTimes(3); + + created.setUserId(undefined); + expect(mockClient.dispatch).toHaveBeenCalledTimes(6); + expect(mockClient.startSession).toHaveBeenCalledTimes(1); + + created.setUserId(''); + expect(mockClient.startSession).toHaveBeenCalledTimes(1); + }); + + it('should send one batch when the batch settings are unusable', async () => { + load.mockResolvedValue(bootstrap({ batchLimit: 0 })); + const created = sink(); + await created.init(); + + created.setUserId('u-1'); + + expect(mockClient.dispatch).toHaveBeenCalledTimes(1); + }); + + it('should not send early when the client already has that user, as after a reload', async () => { + mockClient.getUserId.mockReturnValue('u-1'); + const created = sink(); + await created.init(); + + created.setUserId('u-1'); + + expect(mockClient.dispatch).not.toHaveBeenCalled(); + expect(mockClient.pinUserId).toHaveBeenCalledWith('u-1'); + }); + + it('should stamp signedIn on events only once the app has said who is signed in', async () => { + const created = sink(); + await created.init(); + const hook = metadataHook(); + + expect(hook('com.uwt.bi')).toEqual({ + application: 'cart', + environment: 'test', + appVersion: '1' + }); + created.setUserId('u-1'); + expect(hook('com.uwt.bi')).toMatchObject({ signedIn: true }); + created.setUserId(undefined); + expect(hook('com.uwt.bi')).toMatchObject({ signedIn: false }); + }); + }); + + describe('session starts', () => { + it('should record when the client starts a session', async () => { + await sink().init(); + + metadataHook()(SESSION_START); + + expect(uwtSessionElapsed('session-1')).toEqual(expect.any(Number)); + }); + + it('should record nothing when the client keeps no session cookie', async () => { + load.mockResolvedValue(bootstrap({ allowCookies: false })); + await sink().init(); + + metadataHook()(SESSION_START); + + expect(uwtSessionElapsed('session-1')).toBeUndefined(); + }); + + it.each([ + ['the shared cookie', {}, 'cwr_s=1'], + [ + 'its own cookie', + { cookieAttributes: { unique: true } }, + 'cwr_s_app-monitor=1' + ] + ])( + 'should record the session the client starts while starting, in %s', + async (_, config, cookie) => { + const document = page(); + mockConstruction.then = () => { + document.cookie = cookie; + }; + load.mockResolvedValue(bootstrap(config)); + + await sink({ document }).init(); + + expect(uwtSessionElapsed('session-1')).toEqual(expect.any(Number)); + } + ); + + it('should not recurse when reading the session id starts another one', async () => { + await sink().init(); + const hook = metadataHook(); + mockClient.getSessionId.mockImplementationOnce(() => { + hook(SESSION_START); + return 'session-2'; + }); + + hook(SESSION_START); + + expect(mockClient.getSessionId).toHaveBeenCalledTimes(1); + expect(uwtSessionElapsed('session-2')).toEqual(expect.any(Number)); + }); + }); + + describe('flush', () => { + it('should send through the client, by beacon when asked', async () => { + const created = sink(); + await created.init(); + + created.flush(); + created.flush(true); + + expect(mockClient.dispatch).toHaveBeenCalledTimes(1); + expect(mockClient.dispatchBeacon).toHaveBeenCalledTimes(1); + }); + + it('should report events lost when the page goes before the client starts', () => { + load.mockReturnValue(new Promise(() => undefined)); + const report = jest.fn(); + const created = sink({ report }); + + created.flush(true); + expect(report).not.toHaveBeenCalled(); + created.record('com.uwt.early', {}); + created.flush(true); + + expect(report).toHaveBeenCalledWith( + 'warn', + '1 RUM event(s) lost: page unloaded before RUM finished initializing' + ); + }); + }); + + describe('credentials', () => { + beforeEach(() => + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }) + ); + + it('should refresh credentials five minutes before they expire', async () => { + await sink().init(); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + + expect(load).toHaveBeenCalledTimes(2); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should turn RUM off when a refresh is declined', async () => { + const created = sink(); + await created.init(); + load.mockResolvedValueOnce(null); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + created.record('com.uwt.late', {}); + + expect(mockClient.disable).toHaveBeenCalled(); + expect(mockClient.recordEvent).not.toHaveBeenCalled(); + }); + + it('should keep its credentials and retry when a refresh omits them, or fails', async () => { + const report = jest.fn(); + await sink({ report }).init(); + load + .mockResolvedValueOnce({ config: bootstrap().config }) + .mockRejectedValueOnce(new Error('broker down')); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM credential refresh', + expect.objectContaining({ + message: expect.stringContaining('omitted credentials') + }) + ); + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(report).toHaveBeenCalledWith( + 'warn', + 'RUM credential refresh failed', + expect.objectContaining({ message: 'broker down' }) + ); + await jest.advanceTimersByTimeAsync(30 * 1000); + + expect(load).toHaveBeenCalledTimes(4); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should retry soon, not loop, on credentials already expired', async () => { + load.mockResolvedValueOnce(bootstrap({}, -1000)); + await sink().init(); + expect(jest.getTimerCount()).toBe(1); + + await jest.advanceTimersByTimeAsync(30 * 1000); + + expect(load).toHaveBeenCalledTimes(2); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should schedule nothing once disposed during a refresh', async () => { + const created = sink(); + await created.init(); + let answer: (value: UwtRumBootstrap) => void = () => undefined; + load.mockReturnValueOnce(new Promise((resolve) => (answer = resolve))); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + created.dispose(); + answer(bootstrap()); + await jest.advanceTimersByTimeAsync(0); + + expect(jest.getTimerCount()).toBe(0); + expect(mockClient.setAwsCredentials).toHaveBeenCalledTimes(1); + }); + + it('should not retry a failed refresh once disposed', async () => { + const created = sink(); + await created.init(); + let fail: (error: Error) => void = () => undefined; + load.mockReturnValueOnce( + new Promise((_resolve, reject) => (fail = reject)) + ); + + await jest.advanceTimersByTimeAsync(HOUR - 5 * MINUTE); + created.dispose(); + fail(new Error('broker down')); + await jest.advanceTimersByTimeAsync(0); + + expect(jest.getTimerCount()).toBe(0); + }); + }); +}); diff --git a/projects/ui-watchtower/rxjs/ng-package.json b/projects/ui-watchtower/rxjs/ng-package.json new file mode 100644 index 0000000..e09fb3f --- /dev/null +++ b/projects/ui-watchtower/rxjs/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.spec.ts b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.spec.ts new file mode 100644 index 0000000..59c3752 --- /dev/null +++ b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.spec.ts @@ -0,0 +1,514 @@ +import { + defer, + EMPTY, + first, + type Observable, + firstValueFrom, + lastValueFrom, + of, + retry, + Subject, + switchMap, + take, + throwError +} from 'rxjs'; +import { + UWT_DEFAULT_REDACT_CONFIG, + UWT_DEFAULT_TELEMETRY_CONFIG, + UwtNoopTelemetrySink, + UwtScenario, + UwtScenarioRecord +} from '@absaoss-cps/ui-watchtower'; +import { traceScenario } from './uwt-scenario-operators'; + +describe('traceScenario operator', () => { + let scenario: jest.Mocked; + + beforeEach(() => { + scenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + }); + + it('should complete scenario on successful stream completion', (done) => { + of(['user1', 'user2']) + .pipe(traceScenario(scenario)) + .subscribe({ + next: (val) => { + expect(val).toEqual(['user1', 'user2']); + }, + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should complete scenario with mapped outcome using function argument', (done) => { + of(['item1', 'item2', 'item3']) + .pipe( + traceScenario(scenario, (items) => ({ + metadata: { itemCount: items.length } + })) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith({ + metadata: { itemCount: 3 } + }); + done(); + } + }); + }); + + it('should complete scenario with mapped outcome using options object', (done) => { + of({ status: 200, data: 'ok' }) + .pipe( + traceScenario(scenario, { + outcome: (res) => ({ + statusCode: res.status, + metadata: { result: res.data } + }) + }) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith({ + statusCode: 200, + metadata: { result: 'ok' } + }); + done(); + } + }); + }); + + it('should complete scenario with no outcome when the stream completes with no emissions', (done) => { + EMPTY.pipe( + traceScenario(scenario, () => ({ metadata: { unreachable: true } })) + ).subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should fail scenario when the stream errors and re-throw the error', (done) => { + const error = new Error('network down'); + throwError(() => error) + .pipe(traceScenario(scenario)) + .subscribe({ + error: (err) => { + expect(err).toBe(error); + expect(scenario.fail).toHaveBeenCalledWith({ error }); + expect(scenario.complete).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should still settle the scenario when the outcome mapper throws', (done) => { + const report = jest.fn(); + + of('value') + .pipe( + traceScenario( + scenario, + () => { + throw new Error('mapper is broken'); + }, + report + ) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + expect(report).toHaveBeenCalledWith( + 'error', + 'scenario.traceOutcome failed', + expect.any(Error) + ); + done(); + } + }); + }); + + it('should not leak lastValue/hasValue into a resubscription of the same piped observable', (done) => { + let subscriptionCount = 0; + const source = defer(() => { + subscriptionCount++; + return subscriptionCount === 1 ? of('a') : EMPTY; + }); + + const traced$ = source.pipe( + traceScenario(scenario, (value) => ({ metadata: { value } })) + ); + + traced$.subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenNthCalledWith(1, { + metadata: { value: 'a' } + }); + + traced$.subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenNthCalledWith(2, undefined); + done(); + } + }); + } + }); + }); + + describe('teardown without a source complete/error', () => { + it('should cancel the scenario on a manual unsubscribe', () => { + const source = new Subject(); + const subscription = source.pipe(traceScenario(scenario)).subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledTimes(1); + expect(scenario.complete).not.toHaveBeenCalled(); + expect(scenario.fail).not.toHaveBeenCalled(); + }); + + it('should cancel a superseded scenario when switchMap moves to the next inner observable', () => { + const supersededScenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + const pendingFirstRequest = new Subject(); + const secondRequest = of('b'); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + (n === 1 ? pendingFirstRequest : secondRequest).pipe( + traceScenario(n === 1 ? supersededScenario : scenario) + ) + ) + ) + .subscribe(); + + trigger.next(1); // subscribes traceScenario(supersededScenario), still pending + trigger.next(2); // switchMap unsubscribes it before it ever settles + + expect(supersededScenario.cancel).toHaveBeenCalledTimes(1); + expect(supersededScenario.complete).not.toHaveBeenCalled(); + expect(supersededScenario.fail).not.toHaveBeenCalled(); + expect(scenario.complete).toHaveBeenCalledWith(undefined); + }); + + it('should cancel with the configured cancelOutcome on unsubscribe', () => { + const source = new Subject(); + const subscription = source + .pipe( + traceScenario(scenario, { cancelOutcome: { reason: 'superseded' } }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); + }); + + it('should accept cancelOutcome as a function, evaluated at unsubscribe time', () => { + const source = new Subject(); + const subscription = source + .pipe( + traceScenario(scenario, { + cancelOutcome: () => ({ reason: 'superseded' }) + }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); + }); + + it('should still cancel, with no outcome, when cancelOutcome throws', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const source = new Subject(); + const subscription = source + .pipe( + traceScenario(scenario, { + cancelOutcome: () => { + throw new Error('cancelOutcome is broken'); + } + }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith(undefined); + consoleError.mockRestore(); + }); + + it('should record a reason for a switchMap-superseded scenario, not just a bare cancel', () => { + const supersededScenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked; + const pendingFirstRequest = new Subject(); + const secondRequest = of('b'); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + (n === 1 ? pendingFirstRequest : secondRequest).pipe( + traceScenario(n === 1 ? supersededScenario : scenario, { + cancelOutcome: { reason: 'superseded' } + }) + ) + ) + ) + .subscribe(); + + trigger.next(1); + trigger.next(2); + + expect(supersededScenario.cancel).toHaveBeenCalledWith({ + reason: 'superseded' + }); + }); + + it('should not cancel a scenario that already completed normally', (done) => { + of('value') + .pipe(traceScenario(scenario)) + .subscribe({ + complete: () => { + (scenario as unknown as { isSettled: boolean }).isSettled = true; + expect(scenario.cancel).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should not cancel a scenario that already failed', (done) => { + const error = new Error('boom'); + throwError(() => error) + .pipe(traceScenario(scenario)) + .subscribe({ + error: () => { + (scenario as unknown as { isSettled: boolean }).isSettled = true; + expect(scenario.cancel).not.toHaveBeenCalled(); + done(); + } + }); + }); + }); + + describe('with a real scenario', () => { + let settled: UwtScenarioRecord[]; + + const countRows = (rows: string[]) => ({ + metadata: { count: rows.length } + }); + + function createScenario(name = 'load-customers'): UwtScenario { + return new UwtScenario( + { name }, + { + identity: { + application: 'test-app', + environment: 'test', + version: '1.0.0' + }, + scenarioTelemetryConfig: { + ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario + }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink: new UwtNoopTelemetrySink(), + onSettled: (_id, record) => settled.push(record) + } + ); + } + + beforeEach(() => { + settled = []; + }); + + describe('single-value sources', () => { + it('should settle as success with subscribe()', () => { + const real = createScenario(); + + of(['a', 'b']).pipe(traceScenario(real, countRows)).subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with lastValueFrom', async () => { + const real = createScenario(); + + await lastValueFrom( + of(['a', 'b']).pipe(traceScenario(real, countRows)) + ); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with firstValueFrom', async () => { + const real = createScenario(); + + const rows = await firstValueFrom( + of(['a', 'b']).pipe(traceScenario(real, countRows)) + ); + + expect(rows).toEqual(['a', 'b']); + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with take(1) after the operator', () => { + const real = createScenario(); + + of(['a', 'b']) + .pipe(traceScenario(real, countRows), take(1)) + .subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with first() after the operator', () => { + const real = createScenario(); + + of(['a', 'b']) + .pipe(traceScenario(real, countRows), first()) + .subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with take(1) before the operator', () => { + const real = createScenario(); + + of(['a', 'b']) + .pipe(take(1), traceScenario(real, countRows)) + .subscribe(); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + + it('should settle as success with take(1) before the operator under firstValueFrom', async () => { + const real = createScenario(); + + await firstValueFrom( + of(['a', 'b']).pipe(take(1), traceScenario(real, countRows)) + ); + + expect(real.status).toBe('success'); + expect(settled[0].metadata).toEqual({ count: 2 }); + }); + }); + + describe('switchMap superseding an inner stream', () => { + it('should abandon a stream superseded before it emitted', () => { + const superseded = createScenario('superseded'); + const latest = createScenario('latest'); + const pendingFirstRequest = new Subject(); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + n === 1 + ? pendingFirstRequest.pipe(traceScenario(superseded)) + : of(['b']).pipe(traceScenario(latest)) + ) + ) + .subscribe(); + + trigger.next(1); + trigger.next(2); + + expect(superseded.status).toBe('abandoned'); + expect(latest.status).toBe('success'); + }); + + it('should settle as success a stream superseded after it emitted', () => { + const superseded = createScenario('superseded'); + const latest = createScenario('latest'); + const firstRequest = new Subject(); + const trigger = new Subject(); + + trigger + .pipe( + switchMap((n) => + n === 1 + ? firstRequest.pipe(traceScenario(superseded, countRows)) + : of(['b']).pipe(traceScenario(latest)) + ) + ) + .subscribe(); + + trigger.next(1); + firstRequest.next(['a']); // emitted, but never completed + trigger.next(2); + + expect(superseded.status).toBe('success'); + expect(superseded.toRecord().metadata).toEqual({ count: 1 }); + expect(latest.status).toBe('success'); + }); + }); + + it("should abandon a stream torn down after a value with valueThenTeardown: 'cancel'", () => { + const real = createScenario('poll-status'); + + of(['a'], ['b']) + .pipe(traceScenario(real, { valueThenTeardown: 'cancel' }), take(1)) + .subscribe(); + + expect(real.status).toBe('abandoned'); + }); + + describe('retry placement', () => { + function failOnce(): Observable { + let attempts = 0; + return defer(() => + attempts++ === 0 + ? throwError(() => new Error('transient')) + : of(['a']) + ); + } + + it('should settle as success when retry() comes before the operator', () => { + const real = createScenario(); + + failOnce().pipe(retry(1), traceScenario(real)).subscribe(); + + expect(real.status).toBe('success'); + }); + + it('should settle as failure when retry() comes after the operator, even if the retry succeeds', () => { + const real = createScenario(); + + failOnce().pipe(traceScenario(real), retry(1)).subscribe(); + + // Documented limitation: the first error settles the scenario and + // the operator cannot see the downstream retry. + expect(real.status).toBe('failure'); + }); + }); + }); +}); diff --git a/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.ts b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.ts new file mode 100644 index 0000000..6edb052 --- /dev/null +++ b/projects/ui-watchtower/rxjs/src/lib/uwt-scenario-operators/uwt-scenario-operators.ts @@ -0,0 +1,129 @@ +import { defer } from 'rxjs'; +import type { MonoTypeOperatorFunction, Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { + UwtReporter, + UwtScenario, + UwtScenarioOutcome, + uwtSafe +} from '@absaoss-cps/ui-watchtower'; + +/** + * Options for configuring scenario completion within an RxJS stream. + * + * @group Interfaces + */ +export interface UwtTraceScenarioOptions { + /** + * Optional mapper that returns an outcome (metadata, statusCode, message) + * based on the emitted value when the Observable completes successfully. + */ + outcome?: (value: T) => UwtScenarioOutcome | void; + + /** + * Outcome used when the stream is unsubscribed before completing or + * erroring — e.g. a `switchMap` moving on to a newer value. + */ + cancelOutcome?: UwtScenarioOutcome | (() => UwtScenarioOutcome | void); + + /** + * What a downstream unsubscribe means once a value has been delivered. + * Default `'complete'`, so `firstValueFrom`, `take(1)` and `first()` settle + * as success. Use `'cancel'` for multi-value streams (polling, progress), + * where stopping after a value is abandonment. + */ + valueThenTeardown?: 'complete' | 'cancel'; +} + +/** + * An RxJS pipeable operator that automatically settles a {@link UwtScenario} + * based on the stream's outcome: `complete()` on completion, `fail({ error })` + * on error. + * + * @example + * ```typescript + * this.http.get('/api/customers') + * .pipe( + * traceScenario(scenario, { + * outcome: (customers) => ({ metadata: { count: customers.length } }) + * }) + * ) + * .subscribe(); + * ``` + * + * @param scenario the scenario instance to track + * @param options optional outcome mapper or configuration + * @param report receives what a throwing outcome mapper threw; see + * {@link UwtReporter} + * + * @group Utils + */ +export function traceScenario( + scenario: UwtScenario, + options?: + UwtTraceScenarioOptions | ((value: T) => UwtScenarioOutcome | void), + report?: UwtReporter +): MonoTypeOperatorFunction { + const outcomeMapper = + typeof options === 'function' ? options : options?.outcome; + const cancelOutcome = + typeof options === 'function' ? undefined : options?.cancelOutcome; + const valueThenTeardown = + (typeof options === 'function' ? undefined : options?.valueThenTeardown) ?? + 'complete'; + + return (source: Observable): Observable => + defer(() => { + let lastValue: T | undefined; + let hasValue = false; + + const complete = () => { + const outcome = + hasValue && outcomeMapper + ? uwtSafe( + 'scenario.traceOutcome', + () => outcomeMapper(lastValue as T), + undefined, + report + ) + : undefined; + scenario.complete(outcome || undefined); + }; + + const unsubscribe = () => { + if (scenario.isSettled) { + return; + } + // The consumer got a value and stopped listening, as + // firstValueFrom, take(1) and first() do: treat it as completion. + if (hasValue && valueThenTeardown === 'complete') { + complete(); + return; + } + const resolved = + typeof cancelOutcome === 'function' + ? uwtSafe( + 'scenario.traceCancelOutcome', + cancelOutcome, + undefined, + report + ) + : cancelOutcome; + scenario.cancel(resolved || undefined); + }; + + return source.pipe( + tap({ + next: (value) => { + lastValue = value; + hasValue = true; + }, + complete, + error: (error: unknown) => { + scenario.fail({ error }); + }, + unsubscribe + }) + ); + }); +} diff --git a/projects/ui-watchtower/rxjs/src/public-api.ts b/projects/ui-watchtower/rxjs/src/public-api.ts new file mode 100644 index 0000000..e8e1ca7 --- /dev/null +++ b/projects/ui-watchtower/rxjs/src/public-api.ts @@ -0,0 +1,9 @@ +/* + * Public API Surface of ui-watchtower/rxjs + * + * RxJS integration, in its own entry point so the main one never loads + * RxJS: `rxjs` is an optional peer, needed only here. + */ + +export { traceScenario } from './lib/uwt-scenario-operators/uwt-scenario-operators'; +export type { UwtTraceScenarioOptions } from './lib/uwt-scenario-operators/uwt-scenario-operators'; diff --git a/projects/ui-watchtower/rxjs/tsconfig.lib.json b/projects/ui-watchtower/rxjs/tsconfig.lib.json new file mode 100644 index 0000000..c2d230e --- /dev/null +++ b/projects/ui-watchtower/rxjs/tsconfig.lib.json @@ -0,0 +1,16 @@ +/* Type-checks the ./rxjs entry point; ng-packagr builds it with the main tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib-core-rxjs", + // Includes the main entry point: it is read from source here. + "rootDir": "..", + "lib": ["ES2022", "DOM"], + "declaration": true, + "skipLibCheck": true, + "isolatedModules": false, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"] +} diff --git a/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts b/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts index b9d11f7..3b4b858 100644 --- a/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts +++ b/projects/ui-watchtower/src/lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config.ts @@ -60,3 +60,67 @@ export const UWT_DEFAULT_TELEMETRY_CONFIG: { bi: UWT_DEFAULT_BI_TELEMETRY_CONFIG, redact: UWT_DEFAULT_REDACT_CONFIG }; + +/** + * Overrides for each concern's configuration: whatever is left out takes the + * library default. + * + * @group Interfaces + */ +export interface UwtTelemetryConfigOverrides { + scenarios?: Partial; + logging?: Partial; + bi?: Partial; + redaction?: Partial; +} + +/** + * Each concern's configuration, complete. + * + * @group Interfaces + */ +export interface UwtTelemetryConfig { + scenarios: UwtScenarioTelemetryConfig; + logging: UwtLogConfig; + bi: UwtBITelemetryConfig; + redaction: UwtRedactConfig; +} + +/** + * Completes each concern's configuration with the library defaults: every + * override over its default, shallowly. Redaction's pattern and transform + * lists are copied into fresh arrays, so callers never share array identity + * with the defaults or each other. + * + * The one place defaults are applied, for the engine and every adapter. + * + * @param overrides what the application sets + * @returns the configuration an engine runs with + * + * @group Utils + */ +export function uwtResolveTelemetryConfig( + overrides: UwtTelemetryConfigOverrides = {} +): UwtTelemetryConfig { + const defaults = UWT_DEFAULT_TELEMETRY_CONFIG; + const redaction = overrides.redaction ?? {}; + return { + scenarios: { ...defaults.scenario, ...overrides.scenarios }, + logging: { ...defaults.logs, ...overrides.logging }, + bi: { ...defaults.bi, ...overrides.bi }, + redaction: { + ...defaults.redact, + ...redaction, + extraKeyPatterns: [ + ...(redaction.extraKeyPatterns ?? defaults.redact.extraKeyPatterns) + ], + extraValuePatterns: [ + ...(redaction.extraValuePatterns ?? defaults.redact.extraValuePatterns) + ], + extraValueTransforms: [ + ...(redaction.extraValueTransforms ?? + defaults.redact.extraValueTransforms) + ] + } + }; +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts new file mode 100644 index 0000000..1e4c881 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-bi-telemetry/uwt-bi-telemetry.ts @@ -0,0 +1,254 @@ +import { UwtBITelemetryConfig } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { + UwtBIEvent, + UwtBIEventDetail, + UwtBIEventName +} from '../../models/uwt-bi.models/uwt-bi.models'; +import { + uwtEventTypes, + UwtTelemetryEventTypes, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; +import { + UwtRedactConfig, + uwtRedactConfigFor, + uwtRedactMetadata, + uwtScrubString +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + uwtNow, + UwtReporter, + uwtSafe, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; + +/** + * What {@link UwtBITelemetry} needs. + * + * @group Interfaces + */ +export interface UwtBITelemetryOptions { + /** The application's identity, shared by every concern. */ + identity: UwtTelemetryIdentity; + /** BI event settings, complete; see {@link uwtResolveTelemetryConfig}. */ + config: UwtBITelemetryConfig; + /** + * Redaction settings shared by every concern. `config.redact: false` + * turns their configurable part off for BI events. + */ + redact: UwtRedactConfig; + /** Where events go. */ + sink: UwtTelemetrySink; + /** `false` makes {@link UwtBITelemetry.track} do nothing. Default `true`. */ + enabled?: boolean; + /** Receives what is handled silently; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Business and UX event tracking — feature adoption, interaction analysis, + * funnel steps. + * + * Unlike scenarios, BI events are discrete: no duration, no lifecycle. Event + * names and attributes come entirely from the application. + * + * Repeated identical events within a short window are collapsed, absorbing + * double-fires from a `click` handler also bound to `keydown`, or from a + * user clicking twice. + * + * Console output is off unless the `debugBI` LocalStorage flag is set: + * + * ```js + * localStorage.setItem('debugBI', 'true'); + * ``` + * + * @example + * ```typescript + * telemetry.bi.track('export_clicked', { + * exportType: format, + * source: 'customer-table' + * }); + * ``` + * + * @group Classes + */ +export class UwtBITelemetry { + private readonly identity: UwtTelemetryIdentity; + private readonly config: UwtBITelemetryConfig; + private readonly redact: UwtRedactConfig; + private readonly sink: UwtTelemetrySink; + private readonly enabled: boolean; + private readonly report?: UwtReporter; + + private readonly eventTypes: UwtTelemetryEventTypes; + private readonly lastEmittedAt = new Map(); + + constructor(options: UwtBITelemetryOptions) { + this.identity = options.identity; + this.config = options.config; + this.redact = uwtRedactConfigFor(options.redact, options.config.redact); + this.sink = options.sink; + this.enabled = options.enabled !== false; + this.report = options.report; + this.eventTypes = uwtEventTypes(options.identity.eventNamespace); + } + + /** + * Records a business or UX event. + * + * @param eventName the application's own event name, e.g. `export_clicked`, + * as declared in {@link UwtBIEventNames}. Treat it as a metric dimension: + * keep the cardinality low and never interpolate an identifier into it. + * @param metadata flat attributes describing the interaction + * @param detail optional scenario correlation and feature + */ + track( + eventName: UwtBIEventName, + metadata?: UwtTelemetryMetadata, + detail?: UwtBIEventDetail + ): void { + if (!this.enabled) { + return; + } + uwtSafeVoid( + 'biTelemetry.track', + () => { + if (!eventName) { + return; + } + + const redactedFeature = detail?.feature + ? uwtScrubString(detail.feature, this.redact, this.report) + : undefined; + const redactedMetadata = uwtRedactMetadata( + metadata, + this.redact, + this.report + ); + + if ( + this.isDuplicate(eventName, redactedMetadata, { + ...detail, + feature: redactedFeature + }) + ) { + return; + } + + const event: UwtBIEvent = { + eventName, + eventTime: new Date().toISOString(), + scenarioId: detail?.scenarioId, + feature: redactedFeature, + metadata: redactedMetadata, + application: this.identity.application, + sessionId: uwtSafe( + 'biTelemetry.getSessionId', + () => this.sink.getSessionId(), + undefined, + this.report + ), + userId: uwtSafe( + 'biTelemetry.getUserId', + () => this.sink.getUserId(), + undefined, + this.report + ) + }; + + uwtDebugWrite( + 'debugBI', + () => writeToConsole(eventName, this.eventTypes.bi, event), + undefined, + this.report + ); + + this.sink.record(this.eventTypes.bi, event); + }, + this.report + ); + } + + /** + * Forgets recent events. Tracking still works afterwards: it holds no + * timer, listener or channel, and something may still track while an + * application shuts down. Calling it again does nothing more. + */ + dispose(): void { + this.lastEmittedAt.clear(); + } + + private isDuplicate( + eventName: string, + metadata: UwtTelemetryMetadata | undefined, + detail?: UwtBIEventDetail + ): boolean { + const key = JSON.stringify([ + eventName, + detail?.scenarioId ?? '', + detail?.feature ?? '', + this.metadataKey(metadata) + ]); + const now = uwtNow(); + const last = this.lastEmittedAt.get(key); + + if (last !== undefined && now - last < this.config.dedupWindowMs) { + this.lastEmittedAt.delete(key); + this.lastEmittedAt.set(key, last); + return true; + } + + if (this.lastEmittedAt.size >= this.config.dedupMaxKeys) { + for (const [staleKey, at] of this.lastEmittedAt) { + if (now - at >= this.config.dedupWindowMs) { + this.lastEmittedAt.delete(staleKey); + } + } + + if (this.lastEmittedAt.size >= this.config.dedupMaxKeys) { + const oldestKey = this.lastEmittedAt.keys().next().value; + if (oldestKey !== undefined) { + this.lastEmittedAt.delete(oldestKey); + } + } + } + + this.lastEmittedAt.delete(key); + this.lastEmittedAt.set(key, now); + return false; + } + + /** + * A stable string encoding a flat metadata object's content, for use in + * the dedup key — otherwise two same-named events with different metadata + * would collide and the second would be silently dropped. Keys are sorted + * so property order doesn't affect the result. Callers pass already- + * redacted metadata — a caller's raw values must never end up as a `Map` + * key living in this long-lived object's memory for the page lifetime. + */ + private metadataKey(metadata: UwtTelemetryMetadata | undefined): string { + if (!metadata) { + return ''; + } + return JSON.stringify( + Object.keys(metadata) + .sort() + .map((k) => [k, metadata[k]]) + ); + } +} + +/** + * Prefixed with the application - in a composed + * page every realm writes to the one console. + */ +function writeToConsole( + eventName: string, + eventType: string, + event: UwtBIEvent +): void { + console.log(`[${event.application}][bi] ${eventName} -> ${eventType}`, event); +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts new file mode 100644 index 0000000..38fa65d --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-log-telemetry/uwt-log-telemetry.ts @@ -0,0 +1,380 @@ +import { UwtLogConfig } from '../../config/uwt-log.config/uwt-log.config'; +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { + UWT_LOG_LEVEL_ORDER, + UwtLogDetail, + UwtLogger, + UwtLoggerName, + UwtLogLevel, + UwtLogRecord +} from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { uwtDebugWrite } from '../../utils/uwt-debug-flag.util/uwt-debug-flag.util'; +import { + uwtNormalizeError, + UwtRedactConfig, + uwtRedactConfigFor, + uwtRedactMetadata, + uwtScrubString +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + UwtReporter, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + uwtListenToPage, + UwtTelemetryDocument, + uwtTelemetryDocument +} from '../uwt-telemetry-page/uwt-telemetry-page'; + +/** Handed out while telemetry is off: every call does nothing. */ +const INERT_LOGGER: UwtLogger = Object.freeze({ + log: () => undefined, + warn: () => undefined, + error: () => undefined +}); + +/** + * What {@link UwtLogTelemetry} needs. + * + * @group Interfaces + */ +export interface UwtLogTelemetryOptions { + /** The application's identity, shared by every concern. */ + identity: UwtTelemetryIdentity; + /** Logging settings, complete; see {@link uwtResolveTelemetryConfig}. */ + config: UwtLogConfig; + /** + * Redaction settings shared by every concern. `config.redact: false` + * turns their configurable part off for log records. + */ + redact: UwtRedactConfig; + /** The application's log store: where records go, and are read back from. */ + logApi: UwtLogApiProvider; + /** + * The telemetry destination, for enrichment only: records carry its + * `sessionId` and `userId`, and `config.mirrorErrorsToRum` reports errors + * to it. Optional. + */ + sink?: UwtTelemetrySink | null; + /** `false` makes every logger and query inert. Default `true`. */ + enabled?: boolean; + /** + * The page whose lifecycle flushes the log store: on `pagehide`, and when + * the page is hidden. Defaults to the global `document` where there is + * one; `null` listens to nothing. + */ + document?: UwtTelemetryDocument | null; + /** Receives what is handled silently; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Structured application logging. + * + * Log records go to the application's {@link UwtLogApiProvider}, **not** to + * the telemetry destination — RUM is a sampled, session-capped analytics + * stream, while logs need full fidelity and their own retention. + * + * Console output is off unless the `debugLogger` LocalStorage flag is set, in + * any environment: + * + * ```js + * localStorage.setItem('debugLogger', 'true'); + * ``` + * + * Every record carries a logger name, so this writes nothing itself — + * {@link getLogger} hands back the {@link UwtLogger} you call. + * + * @example + * ```typescript + * const logger = telemetry.logging.getLogger('checkout'); + * + * try { + * // … + * } catch (error) { + * logger.error('Failed to load customer data', { + * error, + * correlationId: scenario.id + * }); + * } + * ``` + * + * @group Classes + */ +export class UwtLogTelemetry { + private readonly identity: UwtTelemetryIdentity; + private readonly config: UwtLogConfig; + private readonly redact: UwtRedactConfig; + private readonly logApi: UwtLogApiProvider; + private readonly sink: UwtTelemetrySink | null; + private readonly enabled: boolean; + private readonly report?: UwtReporter; + private readonly stopListening: () => void; + private disposed = false; + + /** + * One logger per name, so {@link getLogger} is idempotent. + * + * Unbounded growth is not a concern the way it is for active scenarios: + * names come from the closed {@link UwtLoggerNames} vocabulary, so this + * map is bounded by what the application declared at build time. + */ + private readonly loggers = new Map(); + + constructor(options: UwtLogTelemetryOptions) { + this.identity = options.identity; + this.config = options.config; + this.redact = uwtRedactConfigFor(options.redact, options.config.redact); + this.logApi = options.logApi; + this.sink = options.sink ?? null; + this.enabled = options.enabled !== false; + this.report = options.report; + // Page listeners: only while telemetry is on. + this.stopListening = this.enabled + ? uwtListenToPage( + uwtTelemetryDocument(options.document), + () => this.flushProvider(), + () => this.flushProvider() + ) + : () => undefined; + } + + /** + * Reads records back from the application's log store. Useful for + * pulling one journey together — every line written during a scenario + * shares its id as the correlation id: + * + * @example + * ```typescript + * const lines = await telemetry.logging.query({ correlationId: scenario.id }); + * ``` + * + * Fail-open: a provider that throws or rejects resolves to `[]`. + * + * @param filter narrows what is returned + * @returns the matching records, or `[]` when none can be read + */ + async query(filter: UwtLogQuery = {}): Promise { + if (!this.enabled) { + return []; + } + const pending = uwtSafe( + 'logger.query', + () => this.logApi.query(filter), + undefined, + this.report + ); + + return (await pending?.catch(() => undefined)) ?? []; + } + + /** + * Returns the named logger for one part of the application — the only + * way to write a log record. + * + * The name lands on every record as `logger`, and four things key off + * it: {@link UwtLogConfig.levels} per-logger severity floors, the + * `debugLogger` flag's comma-separated filter, `query({ logger })` + * against the log store, and the console line's prefix. A record + * without one is reachable by none of them, which is why there is no + * unnamed `log`/`warn`/`error` here. + * + * The name is identity, not a label: asking twice returns the very same + * logger, the way a file name always refers to one file. + * + * @param name the logger name, declared in {@link UwtLoggerNames} + * @returns the logger for that name, created once and reused; an inert + * one while telemetry is off + */ + getLogger(name: UwtLoggerName): UwtLogger { + if (!this.enabled) { + return INERT_LOGGER; + } + const cached = uwtSafe( + 'logger.getLogger', + () => this.loggers.get(name), + undefined, + this.report + ); + if (cached) { + return cached; + } + + const logger = this.createLogger(name); + uwtSafeVoid( + 'logger.register', + () => this.loggers.set(name, logger), + this.report + ); + return logger; + } + + /** + * Stops watching the page and gives the log store its last chance to + * ship what it holds. Loggers keep writing to the log store, and + * {@link query} keeps reading it: it is the application's, and logging + * holds no timer, listener or channel to release. That matters when + * something logs while the application shuts down, after this was + * disposed. Calling it again does nothing. + */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.stopListening(); + if (this.enabled) { + this.flushProvider(); + } + } + + /** + * Builds the logger for one name. + * + * Private, and reached only through {@link getLogger}, so every logger + * handed out is registered and named. The name is captured here rather + * than read from each call's detail, which is why a record's `logger` + * cannot disagree with the logger that wrote it. + * + * @param name the logger name stamped onto every record it writes + * @returns a logger bound to that name + */ + private createLogger(name: UwtLoggerName): UwtLogger { + return { + log: (message, detail) => this.emit('log', name, message, detail), + warn: (message, detail) => this.emit('warn', name, message, detail), + error: (message, detail) => this.emit('error', name, message, detail) + }; + } + + /** Severity floor for one logger — its own override, or the global one. */ + private minLevelFor(logger: UwtLoggerName): UwtLogLevel { + const { levels, minLevel } = this.config; + return levels?.[logger] ?? minLevel; + } + + private emit( + level: UwtLogLevel, + logger: UwtLoggerName, + message: string, + detail?: UwtLogDetail + ): void { + uwtSafeVoid( + `logger.${level}`, + () => { + if ( + UWT_LOG_LEVEL_ORDER[level] < + UWT_LOG_LEVEL_ORDER[this.minLevelFor(logger)] + ) { + return; + } + + const record = this.buildRecord(level, logger, message, detail); + + uwtDebugWrite( + 'debugLogger', + () => writeToConsole(record), + record.logger, + this.report + ); + + this.deliver(record); + + if (level === 'error' && this.config.mirrorErrorsToRum && this.sink) { + const mirrored = + record.error ?? + uwtNormalizeError( + new Error(record.message), + this.redact, + this.report + ); + if (mirrored) { + this.sink.recordError(mirrored); + } + } + }, + this.report + ); + } + + /** Guards against a throwing or secretly-async, rejecting provider. */ + private deliver(record: UwtLogRecord): void { + uwtSafeVoidMaybeAsync( + 'logger.deliver', + () => this.logApi.send(record), + this.report + ); + } + + /** Gives the provider its chance to ship whatever it has queued itself. */ + private flushProvider(): void { + uwtSafeVoidMaybeAsync( + 'logger.providerFlush', + () => this.logApi.flush?.(), + this.report + ); + } + + private buildRecord( + level: UwtLogLevel, + logger: UwtLoggerName, + message: string, + detail?: UwtLogDetail + ): UwtLogRecord { + const { identity, redact, report, sink } = this; + + return { + timestamp: new Date().toISOString(), + level, + message: uwtScrubString(String(message ?? ''), redact, report), + logger, + context: detail?.context + ? uwtScrubString(detail.context, redact, report) + : undefined, + metadata: uwtRedactMetadata(detail?.metadata, redact, report), + error: uwtNormalizeError(detail?.error, redact, report), + correlationId: detail?.correlationId + ? uwtScrubString(detail.correlationId, redact, report) + : undefined, + application: identity.application, + environment: identity.environment, + version: identity.version, + ...(sink && { + userId: uwtSafe('getUserId', () => sink.getUserId(), undefined, report), + sessionId: uwtSafe( + 'getSessionId', + () => sink.getSessionId(), + undefined, + report + ) + }) + }; + } +} + +/** + * Prints the record exactly as the transport receives it. + * + * Prefixed with the application - in a composed + * page every realm writes to the one console - then the logger name and + * the context, each when present. + */ +function writeToConsole(record: UwtLogRecord): void { + const scope = [record.logger, record.context] + .filter(Boolean) + .map((part) => `[${part}]`) + .join(''); + const suffix = record.correlationId ? ` (${record.correlationId})` : ''; + + console[record.level]( + `[${record.application}]${scope} ${record.message}${suffix}`, + record + ); +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts new file mode 100644 index 0000000..f75e729 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry.ts @@ -0,0 +1,332 @@ +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UwtScenarioTelemetryConfig } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { + UwtScenarioName, + UwtScenarioOptions, + UwtScenarioRecord +} from '../../models/uwt-scenario.models/uwt-scenario.models'; +import { UwtScenario } from '../../scenario/uwt-scenario/uwt-scenario'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { + UwtRedactConfig, + uwtRedactConfigFor +} from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { + uwtDeepClone, + uwtReport, + UwtReporter, + uwtSafe, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + uwtListenToPage, + UwtTelemetryDocument, + uwtTelemetryDocument +} from '../uwt-telemetry-page/uwt-telemetry-page'; + +/** + * How many scenarios may be in flight before a warning suggests a leak. + * + * Concurrent scenarios are legitimate — several journeys, tabs or panels + * can run at once — so this is a smell threshold, not a limit: nothing is + * dropped or rejected when it is crossed. Set well above any plausible + * real concurrency, so crossing it might mean scenarios with `timeoutMs: 0` or + * long lifecycles are being started and never settled. + */ +const ACTIVE_SCENARIO_WARN_THRESHOLD = 50; + +/** + * What {@link UwtScenarioTelemetry} needs. + * + * @group Interfaces + */ +export interface UwtScenarioTelemetryOptions { + /** The application's identity, shared by every concern. */ + identity: UwtTelemetryIdentity; + /** Scenario settings, complete; see {@link uwtResolveTelemetryConfig}. */ + config: UwtScenarioTelemetryConfig; + /** + * Redaction settings shared by every concern. `config.redact: false` + * turns their configurable part off for scenarios. + */ + redact: UwtRedactConfig; + /** Where settled scenarios and their steps go. */ + sink: UwtTelemetrySink; + /** `false` makes every scenario inert. Default `true`. */ + enabled?: boolean; + /** + * The page whose lifecycle settles and flushes scenarios: in-flight ones + * are settled as abandoned on `pagehide`, and the sink is flushed when the + * page is hidden. Defaults to the global `document` where there is one; + * `null` listens to nothing. + */ + document?: UwtTelemetryDocument | null; + /** + * Runs timer scheduling outside the host framework's change detection, + * when it has one: Angular passes `NgZone.runOutsideAngular`. + */ + runOutside?: (callback: () => T) => T; + /** Receives what is handled silently; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Creates and tracks scenarios — user journeys whose health telemetry + * measures. + * + * Each {@link start} call returns an independent {@link UwtScenario}; there is + * no "current" scenario, so overlapping journeys are tracked separately. + * Scenarios still in flight when the page unloads are settled as + * `abandoned`, and so are those still running when this is disposed. + * + * @example + * ```typescript + * const scenario = telemetry.scenarios.start({ name: 'load-dashboard' }); + * try { + * scenario.step('fetch-widgets'); + * const widgets = await api.widgets(); + * scenario.complete({ metadata: { widgetCount: widgets.length } }); + * } catch (error) { + * scenario.fail({ error }); + * } + * ``` + * + * @group Classes + */ +export class UwtScenarioTelemetry { + private readonly identity: UwtTelemetryIdentity; + private readonly config: UwtScenarioTelemetryConfig; + private readonly redact: UwtRedactConfig; + private readonly sink: UwtTelemetrySink; + private readonly enabled: boolean; + private readonly runOutside?: (callback: () => T) => T; + private readonly report?: UwtReporter; + + private readonly active = new Map(); + private readonly listeners = new Set<(record: UwtScenarioRecord) => void>(); + private readonly stopListening: () => void; + private disposed = false; + + constructor(options: UwtScenarioTelemetryOptions) { + this.identity = options.identity; + this.config = options.config; + this.redact = uwtRedactConfigFor(options.redact, options.config.redact); + this.sink = options.sink; + this.enabled = options.enabled !== false; + this.runOutside = options.runOutside; + this.report = options.report; + // Page listeners: only while telemetry is on. + this.stopListening = this.enabled + ? uwtListenToPage( + uwtTelemetryDocument(options.document), + () => this.flushActive(), + () => this.flushSink() + ) + : () => undefined; + } + + /** + * Starts a new scenario. + * + * @param options the scenario name and optional classification, timeout and + * attributes + * @returns an independent scenario instance; an inert one while telemetry + * is off + */ + start(options: UwtScenarioOptions): UwtScenario { + if (!this.enabled) { + // Same return type, nothing recorded, timed, registered or sent. + return new UwtScenario(options, { + identity: this.identity, + scenarioTelemetryConfig: this.config, + redact: this.redact, + sink: this.sink, + onSettled: () => undefined, + inert: true + }); + } + // Nothing here depends on whether this was disposed: something may still + // start a scenario while an application shuts down, and the destination is + // the caller's. Such a scenario is a normal one, with its own timeout. + const scenario = new UwtScenario(options, { + identity: this.identity, + scenarioTelemetryConfig: this.config, + redact: this.redact, + sink: this.sink, + runOutside: this.runOutside, + report: this.report, + onSettled: (scenarioId, record) => { + this.active.delete(scenarioId); + this.notify(record); + } + }); + + uwtSafeVoid( + 'scenarioTelemetry.register', + () => { + this.active.set(scenario.id, scenario); + if (this.active.size > ACTIVE_SCENARIO_WARN_THRESHOLD) { + uwtReport( + this.report, + 'warn', + `High number of active scenarios (${this.active.size}). Ensure scenarios with timeoutMs: 0 or long lifecycles are settled on destroy.` + ); + } + }, + this.report + ); + + return scenario; + } + + /** + * Looks up a scenario that has not yet settled. + * + * @param scenarioId the id to look up + * @returns the scenario, or `undefined` if unknown or already settled + */ + find(scenarioId: string): UwtScenario | undefined { + return this.active.get(scenarioId); + } + + /** + * Every active scenario with the given name. + * + * `name` is a metric dimension, not a concurrency key - any number of + * scenarios can legitimately share one (two tabs, two dashboard panels, + * two independently-edited rows). Use this to inspect what's currently + * running under a name and decide for yourself, e.g. before starting + * another one. + * + * @param name the scenario name to match + * @returns matching active scenarios, in start order + */ + findByName(name: UwtScenarioName): UwtScenario[] { + return this.getActive().filter((scenario) => scenario.name === name); + } + + /** + * Looks up an active scenario by id, asserting it has the expected name. + * + * The same lookup as {@link find}, with an extra check that its `name` + * matches - a defensive assertion for a caller that already expects a + * specific scenario name at this id, rather than a bare id lookup. + * + * @param name the expected scenario name + * @param scenarioId the id to look up + * @returns the scenario, or `undefined` if not active or its name + * doesn't match + */ + findByNameAndId( + name: UwtScenarioName, + scenarioId: string + ): UwtScenario | undefined { + const scenario = this.find(scenarioId); + return scenario?.name === name ? scenario : undefined; + } + + /** + * Every scenario currently in flight. + * + * @returns the active scenarios, in start order + */ + getActive(): UwtScenario[] { + return [...this.active.values()]; + } + + /** + * Calls `listener` with every scenario record as it settles. + * + * Useful for reacting to outcomes without wrapping every call site — a + * debug overlay, a retry prompt, a test harness asserting on journeys. + * Listeners share one copy of each record, so none of them can change + * what the sink receives; a throwing listener affects no other. + * + * @param listener called once per settled scenario + * @returns stops calling `listener` + */ + onSettled(listener: (record: UwtScenarioRecord) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Stops watching the page, settles every scenario still in flight as + * abandoned and flushes the sink, then forgets the listeners. Nothing else + * changes: a scenario started later is a normal one, with its own timeout. + * Calling it again does nothing. + */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.stopListening(); + if (this.enabled) { + this.flushActive(); + } + this.listeners.clear(); + } + + private notify(record: UwtScenarioRecord): void { + if (!this.listeners.size) { + return; + } + // Copied so a listener can't mutate the same object that's about to be + // (or already was) shipped to the sink. + const copy = uwtSafe( + 'scenarioTelemetry.notify', + () => uwtDeepClone(record), + undefined, + this.report + ); + if (!copy) { + return; + } + for (const listener of [...this.listeners]) { + uwtSafeVoid( + 'scenarioTelemetry.notify', + () => listener(copy), + this.report + ); + } + } + + /** + * Dispatches what the sink holds, leaving running scenarios alone. + * + * Uses the beacon transport since the page may not survive a normal request. + */ + private flushSink(): void { + uwtSafeVoid( + 'scenarioTelemetry.flushSink', + () => this.sink.flush(true), + this.report + ); + } + + /** + * Settles every in-flight scenario as abandoned and asks the sink to send + * what it holds using a transport that survives unload. + * + * Scenario emission is synchronous, so everything settled here reaches the + * sink before the beacon goes out. + */ + private flushActive(): void { + uwtSafeVoid( + 'scenarioTelemetry.flush', + () => { + for (const scenario of this.getActive()) { + scenario.settle('abandoned', { + reason: 'page-hidden', + metadata: { abandonedBy: 'page-hidden' } + }); + } + this.sink.flush(true); + }, + this.report + ); + } +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry-page/uwt-telemetry-page.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry-page/uwt-telemetry-page.ts new file mode 100644 index 0000000..e6d89fa --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry-page/uwt-telemetry-page.ts @@ -0,0 +1,56 @@ +/** Something telemetry can listen to: a page's window, or its document. */ +interface UwtListenable { + addEventListener(type: TEvent, listener: () => void): void; + removeEventListener(type: TEvent, listener: () => void): void; +} + +/** + * The part of a page telemetry watches: whether it is visible, and its + * window's `pagehide`. A browser `Document` is one; so is a test double. + * Typed structurally, so using the engine needs no DOM typings. + * + * @group Interfaces + */ +export interface UwtTelemetryDocument extends UwtListenable<'visibilitychange'> { + readonly visibilityState: string; + readonly defaultView: UwtListenable<'pagehide'> | null; +} + +/** + * The page to watch: the one given, the global `document` where there is one + * when none is, and nothing for `null`. + */ +export function uwtTelemetryDocument( + document: UwtTelemetryDocument | null | undefined +): UwtTelemetryDocument | null { + return document === undefined ? (globalThis.document ?? null) : document; +} + +/** + * Calls `onPageHide` when the page is going away (`pagehide`) and `onHidden` + * when it moves to the background (`visibilitychange` to hidden). + * + * @param document the page, or `null` to listen to nothing + * @returns removes both listeners + */ +export function uwtListenToPage( + document: UwtTelemetryDocument | null, + onPageHide: () => void, + onHidden: () => void +): () => void { + if (!document) { + return () => undefined; + } + const view = document.defaultView; + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + onHidden(); + } + }; + view?.addEventListener('pagehide', onPageHide); + document.addEventListener('visibilitychange', onVisibilityChange); + return () => { + view?.removeEventListener('pagehide', onPageHide); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; +} diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts new file mode 100644 index 0000000..333ed18 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.spec.ts @@ -0,0 +1,702 @@ +/** + * @jest-environment jsdom + */ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { UwtScenarioRecord } from '../../models/uwt-scenario.models/uwt-scenario.models'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UWT_REDACTED } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import * as uwtTelemetrySafeUtil from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UwtTelemetryOptions, uwtCreateTelemetry } from './uwt-telemetry'; + +/** Keeps what the engine sends, so a test can assert on it. */ +class RecordingSink extends UwtTelemetrySink { + readonly events: { eventType: string; payload: Record }[] = + []; + + readonly errors: UwtTelemetryError[] = []; + readonly flushes: (boolean | undefined)[] = []; + userId: string | undefined; + + record(eventType: string, payload: object): void { + this.events.push({ + eventType, + payload: payload as Record + }); + } + + recordError(error: UwtTelemetryError, _meta?: UwtTelemetryMetadata): void { + this.errors.push(error); + } + + getSessionId(): string | undefined { + return 'session-1'; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(beacon?: boolean): void { + this.flushes.push(beacon); + } + + ofType(eventType: string): Record[] { + return this.events + .filter((e) => e.eventType === eventType) + .map((e) => e.payload); + } +} + +/** A log store kept in memory. */ +class RecordingLogApi implements UwtLogApiProvider { + readonly records: UwtLogRecord[] = []; + flushCount = 0; + + send(record: UwtLogRecord): void { + this.records.push(record); + } + + query(filter: UwtLogQuery): Promise { + return Promise.resolve( + this.records.filter((r) => !filter.logger || r.logger === filter.logger) + ); + } + + flush(): void { + this.flushCount++; + } +} + +const identity = { + application: 'cart', + environment: 'test', + version: '1.0.0', + eventNamespace: 'com.cart' +}; + +function create(overrides: Partial = {}) { + const sink = new RecordingSink(); + const logApi = new RecordingLogApi(); + const telemetry = uwtCreateTelemetry({ + identity, + destination: sink, + logApi, + scenarios: { defaultTimeoutMs: 0 }, + ...overrides + }); + return { telemetry, sink, logApi }; +} + +describe('uwtCreateTelemetry', () => { + let engines: { dispose(): void }[]; + + beforeEach(() => { + engines = []; + }); + + afterEach(() => { + engines.forEach((engine) => engine.dispose()); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + function engine(overrides: Partial = {}) { + const created = create(overrides); + engines.push(created.telemetry); + return created; + } + + describe('scenarios', () => { + it('should send a settled scenario to the destination, with its steps and identity', () => { + const { telemetry, sink } = engine(); + + telemetry.scenarios.start({ name: 'checkout' }).step('pay').complete(); + + const [record] = sink.ofType('com.cart.scenario'); + expect(record).toMatchObject({ + scenarioName: 'checkout', + status: 'success', + application: 'cart', + sessionId: 'session-1' + }); + expect((record.steps as { name: string }[]).map((s) => s.name)).toEqual([ + 'scenario-start', + 'pay', + 'scenario-end' + ]); + }); + + it('should track what is in flight until it settles', () => { + const { telemetry } = engine(); + + const a = telemetry.scenarios.start({ name: 'checkout' }); + const b = telemetry.scenarios.start({ name: 'search' }); + expect(telemetry.scenarios.getActive()).toEqual([a, b]); + expect(telemetry.scenarios.findByName('checkout')).toEqual([a]); + expect( + telemetry.scenarios.findByNameAndId('search', a.id) + ).toBeUndefined(); + + a.complete(); + expect(telemetry.scenarios.find(a.id)).toBeUndefined(); + expect(telemetry.scenarios.getActive()).toEqual([b]); + }); + + it('should give every settled listener one shared copy, so none can change what is sent', () => { + const { telemetry, sink } = engine(); + const seen: UwtScenarioRecord[] = []; + telemetry.scenarios.onSettled((record) => { + seen.push(record); + record.metadata = { tampered: true }; + }); + telemetry.scenarios.onSettled((record) => seen.push(record)); + + telemetry.scenarios + .start({ name: 'checkout' }) + .complete({ metadata: { real: true } }); + + expect(seen).toHaveLength(2); + expect(seen[0]).toBe(seen[1]); + expect(sink.ofType('com.cart.scenario')[0].metadata).toEqual({ + real: true + }); + }); + + it('should keep calling other listeners when one throws, and report it', () => { + const report = jest.fn(); + const { telemetry } = engine({ report }); + const seen: string[] = []; + telemetry.scenarios.onSettled(() => { + throw new Error('listener broke'); + }); + telemetry.scenarios.onSettled((record) => seen.push(record.scenarioName)); + + expect(() => + telemetry.scenarios.start({ name: 'checkout' }).complete() + ).not.toThrow(); + + expect(seen).toEqual(['checkout']); + expect(report).toHaveBeenCalledWith( + 'error', + 'scenarioTelemetry.notify failed', + expect.any(Error) + ); + }); + + it('should stop calling a listener once it unsubscribes', () => { + const { telemetry } = engine(); + const listener = jest.fn(); + const stop = telemetry.scenarios.onSettled(listener); + + stop(); + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should settle a scenario that outlives its timeout, scheduling the timer through runOutside', () => { + jest.useFakeTimers(); + let outside = 0; + const runOutside = (callback: () => T): T => { + outside++; + return callback(); + }; + const { telemetry, sink } = engine({ + runOutside, + scenarios: { defaultTimeoutMs: 1_000 } + }); + + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + expect(outside).toBe(1); + + jest.advanceTimersByTime(1_000); + + expect(scenario.status).toBe('timeout'); + expect(sink.ofType('com.cart.scenario')[0].status).toBe('timeout'); + }); + }); + + describe('BI events', () => { + it('should send an event, redacted, and collapse an identical one fired straight after', () => { + const { telemetry, sink } = engine(); + + telemetry.bi.track('export_clicked', { token: 'x', format: 'csv' }); + telemetry.bi.track('export_clicked', { token: 'x', format: 'csv' }); + + const events = sink.ofType('com.cart.bi'); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventName: 'export_clicked', + application: 'cart', + metadata: { token: UWT_REDACTED, format: 'csv' } + }); + }); + }); + + describe('logging', () => { + it('should send records at or above the floor, stamped and redacted', async () => { + const { telemetry, logApi } = engine({ + logging: { minLevel: 'warn', levels: { audit: 'log' } } + }); + + telemetry.logging.getLogger('checkout').log('dropped'); + telemetry.logging + .getLogger('checkout') + .warn('Slow', { metadata: { token: 'x' } }); + telemetry.logging.getLogger('audit').log('kept'); + + expect(logApi.records.map((r) => r.message)).toEqual(['Slow', 'kept']); + expect(logApi.records[0]).toMatchObject({ + level: 'warn', + logger: 'checkout', + application: 'cart', + environment: 'test', + version: '1.0.0', + sessionId: 'session-1', + metadata: { token: UWT_REDACTED } + }); + expect(await telemetry.logging.query({ logger: 'audit' })).toHaveLength( + 1 + ); + }); + + it('should hand out the same logger for the same name', () => { + const { telemetry } = engine(); + expect(telemetry.logging.getLogger('checkout')).toBe( + telemetry.logging.getLogger('checkout') + ); + }); + + it('should mirror errors to the destination when asked', () => { + const { telemetry, sink } = engine({ + logging: { mirrorErrorsToRum: true } + }); + + telemetry.logging.getLogger('checkout').error('Payment failed'); + + expect(sink.errors).toEqual([ + expect.objectContaining({ message: 'Payment failed' }) + ]); + }); + + it('should read back nothing, rather than fail, from a store that throws', async () => { + const { telemetry, logApi } = engine(); + jest.spyOn(logApi, 'query').mockImplementation(() => { + throw new Error('store is down'); + }); + + expect(await telemetry.logging.query()).toEqual([]); + }); + }); + + describe('configuration', () => { + it('should apply redaction overrides to every concern', () => { + const { telemetry, sink, logApi } = engine({ + redaction: { extraKeyPatterns: [/^internal/i] } + }); + + telemetry.bi.track('export_clicked', { internalRef: 'x' }); + telemetry.logging + .getLogger('checkout') + .warn('x', { metadata: { internalRef: 'x' } }); + + expect(sink.ofType('com.cart.bi')[0].metadata).toEqual({ + internalRef: UWT_REDACTED + }); + expect(logApi.records[0].metadata).toEqual({ + internalRef: UWT_REDACTED + }); + }); + + it('should turn off configurable redaction for one concern only', () => { + const { telemetry, sink } = engine({ + bi: { redact: false }, + redaction: { scanValuePatterns: ['email'] } + }); + + telemetry.bi.track('export_clicked', { note: 'jane@example.com' }); + telemetry.scenarios + .start({ name: 'checkout', metadata: { note: 'jane@example.com' } }) + .complete(); + + expect(sink.ofType('com.cart.bi')[0].metadata).toEqual({ + note: 'jane@example.com' + }); + expect(sink.ofType('com.cart.scenario')[0].metadata).toEqual({ + note: UWT_REDACTED + }); + }); + + it('should throw when the identity, destination or log store is missing', () => { + expect(() => + uwtCreateTelemetry({} as unknown as UwtTelemetryOptions) + ).toThrow( + '[ui-watchtower] uwtCreateTelemetry needs identity.application, identity.environment, identity.version, destination, logApi' + ); + }); + + it('should throw for an identity without its environment and version, which every record carries', () => { + expect(() => + create({ + identity: { application: 'cart' } as UwtTelemetryOptions['identity'] + }) + ).toThrow( + '[ui-watchtower] uwtCreateTelemetry needs identity.environment, identity.version' + ); + }); + }); + + describe('failures', () => { + it('should never let a broken destination reach the application, and report it', () => { + const report = jest.fn(); + const { telemetry, sink } = engine({ report }); + jest.spyOn(sink, 'record').mockImplementation(() => { + throw new Error('destination is down'); + }); + + expect(() => { + telemetry.scenarios.start({ name: 'checkout' }).complete(); + telemetry.bi.track('export_clicked'); + }).not.toThrow(); + + expect(report).toHaveBeenCalledWith( + 'error', + 'scenario.success failed', + expect.any(Error) + ); + expect(report).toHaveBeenCalledWith( + 'error', + 'biTelemetry.track failed', + expect.any(Error) + ); + }); + + it('should print nothing without a reporter', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const { telemetry, sink } = engine(); + jest.spyOn(sink, 'record').mockImplementation(() => { + throw new Error('destination is down'); + }); + + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(consoleError).not.toHaveBeenCalled(); + }); + }); + + describe('disabled', () => { + it('should record, send, log and watch nothing', async () => { + jest.useFakeTimers(); + const listen = jest.spyOn(document, 'addEventListener'); + const { telemetry, sink, logApi } = engine({ + enabled: false, + scenarios: { defaultTimeoutMs: 1_000 } + }); + + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + scenario.step('pay').complete(); + telemetry.bi.track('export_clicked'); + telemetry.logging.getLogger('checkout').error('x'); + telemetry.setUserId('user-1'); + telemetry.flush(); + + expect(scenario.status).toBeUndefined(); + expect(sink.events).toEqual([]); + expect(sink.userId).toBeUndefined(); + expect(sink.flushes).toEqual([]); + expect(logApi.records).toEqual([]); + expect(await telemetry.logging.query()).toEqual([]); + expect(listen).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + }); + }); + + describe('the page', () => { + function hide(state: 'hidden' | 'visible'): void { + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue(state); + document.dispatchEvent(new Event('visibilitychange')); + } + + it('should settle what is in flight as abandoned, and flush, when the page goes away', () => { + const { telemetry, sink, logApi } = engine(); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + + window.dispatchEvent(new Event('pagehide')); + + expect(scenario.status).toBe('abandoned'); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + + it('should flush, without settling, when the page is hidden', () => { + const { telemetry, sink, logApi } = engine(); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + + hide('hidden'); + + expect(scenario.isSettled).toBe(false); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + + it('should watch no page given null', () => { + const { telemetry, sink } = engine({ document: null }); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + + window.dispatchEvent(new Event('pagehide')); + hide('hidden'); + + expect(scenario.isSettled).toBe(false); + expect(sink.flushes).toEqual([]); + }); + }); + + describe('setUserId and flush', () => { + it('should pass the user to the destination, and flush it and the log store', () => { + const { telemetry, sink, logApi } = engine(); + + telemetry.setUserId('user-1'); + telemetry.flush(true); + + expect(sink.userId).toBe('user-1'); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + }); + + describe('dispose', () => { + it('should settle what is in flight as abandoned, flush once, and stop watching the page', () => { + jest.useFakeTimers(); + const { telemetry, sink, logApi } = engine({ + scenarios: { defaultTimeoutMs: 60_000 } + }); + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + expect(jest.getTimerCount()).toBe(1); + + telemetry.dispose(); + telemetry.dispose(); + + expect(scenario.status).toBe('abandoned'); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + expect(jest.getTimerCount()).toBe(0); + + window.dispatchEvent(new Event('pagehide')); + expect(sink.flushes).toEqual([true]); + expect(logApi.flushCount).toBe(1); + }); + + it('should change nothing else afterwards: BI events, the user and flushes still reach the destination', () => { + const { telemetry, sink } = engine(); + telemetry.dispose(); + const flushedByDispose = sink.flushes.length; + + telemetry.bi.track('export_clicked'); + telemetry.setUserId('user-1'); + telemetry.flush(false); + + expect(sink.ofType('com.cart.bi')).toEqual([ + expect.objectContaining({ eventName: 'export_clicked' }) + ]); + expect(sink.userId).toBe('user-1'); + expect(sink.flushes.slice(flushedByDispose)).toEqual([false]); + }); + + it('should forget the listeners it had, and watch the page no more', () => { + const listener = jest.fn(); + const { telemetry, sink } = engine(); + telemetry.scenarios.onSettled(listener); + const flushesBefore = sink.flushes.length; + + telemetry.dispose(); + window.dispatchEvent(new Event('pagehide')); + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(sink.flushes).toHaveLength(flushesBefore + 1); // dispose's own + expect(listener).not.toHaveBeenCalled(); + }); + + it('should treat a scenario started afterwards as a normal one: recorded when ended', () => { + const { telemetry, sink } = engine(); + telemetry.dispose(); + + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(sink.ofType('com.cart.scenario')).toEqual([ + expect.objectContaining({ scenarioName: 'checkout', status: 'success' }) + ]); + }); + + it('should time out a scenario started afterwards and never ended, as any other', () => { + jest.useFakeTimers(); + const { telemetry, sink } = engine({ + scenarios: { defaultTimeoutMs: 5_000 } + }); + telemetry.dispose(); + + telemetry.scenarios.start({ name: 'checkout' }); + jest.advanceTimersByTime(5_000); + + expect(sink.ofType('com.cart.scenario')).toEqual([ + expect.objectContaining({ scenarioName: 'checkout', status: 'timeout' }) + ]); + }); + + it("should keep loggers writing to the log store afterwards: it is the application's, and holds no resource", async () => { + const { telemetry, logApi } = engine(); + const logger = telemetry.logging.getLogger('checkout'); + + telemetry.dispose(); + logger.error('late'); + + expect(logApi.records.map((r) => r.message)).toEqual(['late']); + await expect(telemetry.logging.query({})).resolves.toHaveLength(1); + }); + + it('should dispose each concern only once', () => { + const { telemetry, logApi } = engine(); + + telemetry.logging.dispose(); + telemetry.logging.dispose(); + telemetry.scenarios.dispose(); + telemetry.scenarios.dispose(); + + expect(logApi.flushCount).toBe(1); + }); + }); + + describe('BI event details', () => { + it('should drop an event without a name', () => { + const { telemetry, sink } = engine(); + + telemetry.bi.track('' as never); + + expect(sink.events).toEqual([]); + }); + + it('should write events to the console under debugBI', () => { + localStorage.setItem('debugBI', 'true'); + const log = jest.spyOn(console, 'log').mockImplementation(() => {}); + const { telemetry } = engine(); + + telemetry.bi.track('export_clicked'); + + expect(log).toHaveBeenCalledWith( + '[cart][bi] export_clicked -> com.cart.bi', + expect.objectContaining({ eventName: 'export_clicked' }) + ); + localStorage.removeItem('debugBI'); + }); + + it('should forget expired events first, then the oldest, once it remembers dedupMaxKeys', () => { + jest.useFakeTimers(); + const { telemetry, sink } = engine({ + bi: { dedupWindowMs: 1000, dedupMaxKeys: 2 } + }); + const track = (name: string) => telemetry.bi.track(name as never); + + track('a'); + track('b'); + jest.advanceTimersByTime(1001); + track('c'); // a and b have expired: forgotten + track('d'); + track('e'); // nothing expired: c, the oldest, is forgotten + track('d'); // still remembered: a duplicate + track('c'); // forgotten, so recorded again + + expect(sink.events.map((e) => e.payload.eventName)).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'c' + ]); + }); + }); + + describe('logging details', () => { + it('should hand out a logger that does nothing while off', () => { + const { telemetry, logApi } = engine({ enabled: false }); + const logger = telemetry.logging.getLogger('checkout'); + + logger.log('a'); + logger.warn('b'); + logger.error('c'); + + expect(logApi.records).toEqual([]); + }); + + it('should write records to the console under debugLogger, with logger, context and correlation id', () => { + localStorage.setItem('debugLogger', 'true'); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { telemetry } = engine(); + + telemetry.logging + .getLogger('checkout') + .warn('Slow payment', { context: 'pay', correlationId: 'sc-1' }); + + expect(warn).toHaveBeenCalledWith( + '[cart][checkout][pay] Slow payment (sc-1)', + expect.objectContaining({ message: 'Slow payment' }) + ); + localStorage.removeItem('debugLogger'); + }); + }); + + describe('scenario details', () => { + it('should report a suspicious number of scenarios in flight', () => { + const report = jest.fn(); + const { telemetry } = engine({ report }); + + for (let i = 0; i <= 50; i++) { + telemetry.scenarios.start({ name: 'checkout' }); + } + + expect(report).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('High number of active scenarios (51)') + ); + }); + + it('should call no listener, and report, when a record cannot be copied for them', () => { + const report = jest.fn(); + const listener = jest.fn(); + const { telemetry, sink } = engine({ report }); + telemetry.scenarios.onSettled(listener); + const copy = uwtTelemetrySafeUtil.uwtDeepClone; + jest + .spyOn(uwtTelemetrySafeUtil, 'uwtDeepClone') + .mockImplementation((value) => { + if ((value as { scenarioName?: unknown })?.scenarioName) { + throw new Error('cannot copy'); + } + return copy(value); + }); + + telemetry.scenarios.start({ name: 'checkout' }).complete(); + + expect(listener).not.toHaveBeenCalled(); + expect(sink.events).toHaveLength(1); + expect(report).toHaveBeenCalledWith( + 'error', + 'scenarioTelemetry.notify failed', + expect.any(Error) + ); + }); + }); +}); diff --git a/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts new file mode 100644 index 0000000..1c24211 --- /dev/null +++ b/projects/ui-watchtower/src/lib/engine/uwt-telemetry/uwt-telemetry.ts @@ -0,0 +1,207 @@ +import { + UwtTelemetryConfigOverrides, + UwtTelemetryIdentity, + uwtResolveTelemetryConfig +} from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UwtLogApiProvider } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { + UwtReporter, + uwtSafeVoid, + uwtSafeVoidMaybeAsync +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UwtBITelemetry } from '../uwt-bi-telemetry/uwt-bi-telemetry'; +import { UwtLogTelemetry } from '../uwt-log-telemetry/uwt-log-telemetry'; +import { UwtScenarioTelemetry } from '../uwt-scenario-telemetry/uwt-scenario-telemetry'; +import { + UwtTelemetryDocument, + uwtTelemetryDocument +} from '../uwt-telemetry-page/uwt-telemetry-page'; + +/** + * What {@link uwtCreateTelemetry} needs: who the application is, where + * telemetry and logs go, and anything to change from the defaults. + * + * @group Interfaces + */ +export interface UwtTelemetryOptions extends UwtTelemetryConfigOverrides { + /** The application's identity, carried by every record and event. */ + identity: UwtTelemetryIdentity; + /** + * Where scenarios, BI events and mirrored errors go — exactly one. + * `new UwtNoopTelemetrySink()` sends them nowhere. + */ + destination: UwtTelemetrySink; + /** + * The application's log store. `new UwtNoopLogApiProvider()` keeps no + * logs, said explicitly. + */ + logApi: UwtLogApiProvider; + /** + * `false` makes everything inert: nothing is recorded, redacted, timed, + * sent or logged, and nothing listens to the page. Default `true`. + */ + enabled?: boolean; + /** + * The page whose lifecycle settles and flushes telemetry. Defaults to the + * global `document` where there is one; `null` listens to nothing, as on + * a server. + */ + document?: UwtTelemetryDocument | null; + /** + * Runs timer scheduling outside the host framework's change detection, + * when it has one. + */ + runOutside?: (callback: () => T) => T; + /** + * Receives what telemetry handles silently: failures it skipped, data it + * dropped. Nothing is reported without one; see {@link UwtReporter}. + */ + report?: UwtReporter; +} + +/** + * A configured telemetry engine: one per application, or per fragment in a + * composed page. + * + * @group Interfaces + */ +export interface UwtTelemetry { + /** Scenarios — user journeys and their health. */ + readonly scenarios: UwtScenarioTelemetry; + /** Business and UX events. */ + readonly bi: UwtBITelemetry; + /** Structured application logs. */ + readonly logging: UwtLogTelemetry; + /** + * Attributes telemetry from now on to an application user, or to nobody + * (`undefined`, or `''`). Passed to the destination. + */ + setUserId(userId: string | undefined): void; + /** + * Sends what the destination and the log store hold. + * + * @param beacon use a transport that survives page unload + */ + flush(beacon?: boolean): void; + /** + * Stops watching the page, settles scenarios still in flight as abandoned + * and flushes. No listener of this engine is left, and nothing it had + * scheduled. That is all it changes: something may still run while an + * application shuts down, and the destination and the log store are + * yours, so scenarios, BI events and logs started afterwards work as + * before. Calling it again does nothing. + */ + dispose(): void; +} + +/** + * Creates a telemetry engine: scenarios, BI events and logging, all sending + * to one destination and one log store. Needs no framework. + * + * @example + * ```typescript + * const telemetry = uwtCreateTelemetry({ + * identity: { application: 'cart', environment: 'prod', version: '1.4.0' }, + * destination: new UwtNoopTelemetrySink(), + * logApi: new UwtNoopLogApiProvider(), + * logging: { minLevel: 'warn' } + * }); + * + * const scenario = telemetry.scenarios.start({ name: 'checkout' }); + * scenario.step('pay'); + * scenario.complete(); + * + * telemetry.dispose(); + * ``` + * + * @param options identity, destination, log store and overrides + * @returns the engine + * @throws when the identity (its application, environment and version), the + * destination or the log store is missing + * + * @group Utils + */ +export function uwtCreateTelemetry(options: UwtTelemetryOptions): UwtTelemetry { + assertOptions(options); + const { identity, destination, logApi, report } = options; + const enabled = options.enabled !== false; + const config = uwtResolveTelemetryConfig(options); + const document = uwtTelemetryDocument(options.document); + const shared = { identity, redact: config.redaction, enabled, report }; + + const scenarios = new UwtScenarioTelemetry({ + ...shared, + config: config.scenarios, + sink: destination, + document, + runOutside: options.runOutside + }); + const bi = new UwtBITelemetry({ + ...shared, + config: config.bi, + sink: destination + }); + const logging = new UwtLogTelemetry({ + ...shared, + config: config.logging, + logApi, + sink: destination, + document + }); + + let disposed = false; + + return { + scenarios, + bi, + logging, + setUserId(userId) { + if (enabled) { + uwtSafeVoid( + 'telemetry.setUserId', + () => destination.setUserId(userId), + report + ); + } + }, + flush(beacon) { + if (enabled) { + uwtSafeVoid('telemetry.flush', () => destination.flush(beacon), report); + uwtSafeVoidMaybeAsync( + 'telemetry.logFlush', + () => logApi.flush?.(), + report + ); + } + }, + dispose() { + if (disposed) { + return; + } + disposed = true; + scenarios.dispose(); + logging.dispose(); + bi.dispose(); + } + }; +} + +/** Configuration errors throw: telemetry fails open, its setup does not. */ +function assertOptions(options: UwtTelemetryOptions): void { + const identity = options?.identity; + const missing = [ + !identity?.application && 'identity.application', + // Strings, as every record carries them: a shell's host rejects a + // forwarded log record without them. + typeof identity?.environment !== 'string' && 'identity.environment', + typeof identity?.version !== 'string' && 'identity.version', + typeof options?.destination?.record !== 'function' && 'destination', + typeof options?.logApi?.send !== 'function' && 'logApi' + ].filter(Boolean); + if (missing.length) { + throw new Error( + `[ui-watchtower] uwtCreateTelemetry needs ${missing.join(', ')}` + ); + } +} diff --git a/projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts b/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts similarity index 100% rename from projects/ngx-ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts rename to projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.spec.ts diff --git a/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts b/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts new file mode 100644 index 0000000..676b518 --- /dev/null +++ b/projects/ui-watchtower/src/lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider.ts @@ -0,0 +1,26 @@ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../uwt-log-api.provider/uwt-log-api.provider'; + +/** + * Log API provider that discards every record and finds none. + * + * The explicit way to say an application has no log backend — never a + * default. Mostly for a shell that hosts fragments but logs nothing: the + * broadcast host requires a log provider, so that one can't be forgotten. + * + * Bound in a shell, it discards its fragments' log records too. + * + * @group Services + */ +export class UwtNoopLogApiProvider implements UwtLogApiProvider { + /** @inheritdoc */ + send(_record: UwtLogRecord): void {} + + /** @inheritdoc */ + query(_filter: UwtLogQuery): Promise { + return Promise.resolve([]); + } +} diff --git a/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts new file mode 100644 index 0000000..1685b15 --- /dev/null +++ b/projects/ui-watchtower/src/lib/scenario/uwt-scenario/uwt-scenario.behaviour.spec.ts @@ -0,0 +1,309 @@ +/** + * @jest-environment jsdom + */ +import { UWT_DEFAULT_TELEMETRY_CONFIG } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UwtScenarioTelemetryConfig } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { UwtScenarioRecord } from '../../models/uwt-scenario.models/uwt-scenario.models'; +import { UWT_TELEMETRY_EVENT_TYPE } from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtTelemetrySink } from '../../sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UWT_DEFAULT_REDACT_CONFIG } from '../../utils/uwt-telemetry-redact.util/uwt-telemetry-redact.util'; +import { UwtScenario, UwtScenarioDeps } from './uwt-scenario'; + +/** Captures what scenarios emitted. */ +class RecordingSink extends UwtTelemetrySink { + readonly events: { eventType: string; payload: Record }[] = + []; + + record(eventType: string, payload: object): void { + this.events.push({ + eventType, + payload: payload as Record + }); + } + + recordError(): void {} + + getSessionId(): string | undefined { + return 'session-1'; + } + + setUserId(): void {} + + getUserId(): string | undefined { + return 'user-1'; + } + + flush(): void {} +} + +const MAX_TIMEOUT_MS = 2_147_483_647; + +describe('UwtScenario, in detail', () => { + let sink: RecordingSink; + let settled: UwtScenarioRecord[]; + + function scenario( + name: string, + config: Partial = {}, + overrides: Partial & { timeoutMs?: number } = {} + ): UwtScenario { + const { timeoutMs, ...deps } = overrides; + return new UwtScenario( + { name, timeoutMs }, + { + identity: { application: 'shop', environment: 'test', version: '1' }, + scenarioTelemetryConfig: { + ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario, + ...config + }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink, + onSettled: (_id, record) => settled.push(record), + ...deps + } + ); + } + + beforeEach(() => { + sink = new RecordingSink(); + settled = []; + localStorage.clear(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('delta', () => { + it('should grow while running, then hold the duration', () => { + const running = scenario('load'); + expect(running.delta).toBeGreaterThanOrEqual(0); + + running.complete(); + + expect(Math.round(running.delta)).toBe(settled[0].delta); + }); + + it('should be 0, with an empty record, for an inert scenario', () => { + const inert = scenario('load', {}, { inert: true }); + + inert.step('fetch'); + inert.complete(); + + expect(inert.delta).toBe(0); + expect(settled).toEqual([]); + expect(inert.toRecord()).toMatchObject({ + scenarioName: 'load', + delta: 0, + stepCount: 0, + steps: [] + }); + }); + }); + + describe('aggregates', () => { + it('should count calls, ignore an end without a start, and count a call still open at the end', () => { + const load = scenario('load'); + + load.aggregateEnd('render'); + load.aggregateStart('fetch'); + load.aggregateStart('fetch'); + load.aggregateEnd('fetch'); + load.aggregateStart('fetch'); + load.complete(); + + expect(settled[0].aggregates).toEqual([ + { name: 'fetch', elapsed: expect.any(Number), callCount: 2 } + ]); + }); + }); + + describe('the record', () => { + it('should carry the outcome: status code, message and reason', () => { + scenario('search').incomplete({ + statusCode: 404, + message: 'no results', + reason: 'empty' + }); + + expect(settled[0]).toMatchObject({ + status: 'incomplete', + statusCode: 404, + message: 'no results', + reason: 'empty' + }); + }); + + it('should flag a scenario with more steps than allowed', () => { + const busy = scenario('busy', { maxSteps: 2 }); + + busy.step('a').step('b').step('c'); + busy.complete(); + + expect(settled[0].exceededStepsLimit).toBe(true); + }); + + it("should keep a step's message and reason, when it ends or fails", () => { + const load = scenario('load'); + + load.step('fetch').endStep({ message: 'cached', reason: 'warm' }); + load.step('parse').failStep(new Error('bad json'), { reason: 'format' }); + load.complete(); + + const steps = settled[0].steps; + expect(steps.find((s) => s.name === 'fetch')).toMatchObject({ + message: 'cached', + reason: 'warm' + }); + expect(steps.find((s) => s.name === 'parse')).toMatchObject({ + status: 'failure', + reason: 'format' + }); + }); + }); + + describe('step events', () => { + it('should send each step as it ends, with the session and user, when enabled', () => { + const load = scenario('load', { emitLifecycleEvents: true }); + + load.step('fetch'); + load.step('render'); + load.complete(); + + const steps = sink.events.filter( + (e) => e.eventType === UWT_TELEMETRY_EVENT_TYPE.scenarioStep + ); + expect(steps.map((e) => e.payload.name)).toContain('fetch'); + expect(steps[0].payload).toMatchObject({ + scenarioName: 'load', + application: 'shop', + sessionId: 'session-1', + userId: 'user-1' + }); + }); + }); + + describe('user timings', () => { + let timings: { + mark: jest.Mock; + measure: jest.Mock; + clearMarks: jest.Mock; + clearMeasures: jest.Mock; + }; + + beforeEach(() => { + timings = { + mark: jest.fn(), + measure: jest.fn(), + clearMarks: jest.fn(), + clearMeasures: jest.fn() + }; + // jsdom has no User Timing API: stand-ins, removed after each test. + for (const [name, fn] of Object.entries(timings)) { + Object.defineProperty(performance, name, { + value: fn, + configurable: true, + writable: true + }); + } + }); + + afterEach(() => { + for (const name of Object.keys(timings)) { + delete (performance as unknown as Record)[name]; + } + }); + + it('should mark and measure each step and the whole scenario, then clear its marks', () => { + const load = new UwtScenario( + { name: 'load', feature: 'customers' }, + { + identity: { application: 'shop', environment: 'test', version: '1' }, + scenarioTelemetryConfig: { + ...UWT_DEFAULT_TELEMETRY_CONFIG.scenario, + userTimings: true + }, + redact: UWT_DEFAULT_REDACT_CONFIG, + sink, + onSettled: () => undefined + } + ); + + load.step('fetch'); + load.step('render'); + load.complete(); + + const measured = timings.measure.mock.calls.map(([name]) => name); + expect(measured).toEqual( + expect.arrayContaining(['load [fetch]', 'load (customers)']) + ); + expect(timings.mark).toHaveBeenCalled(); + expect(timings.clearMarks).toHaveBeenCalled(); + }); + + it('should clear the marks of a scenario that never settles, after the fallback delay', () => { + jest.useFakeTimers({ doNotFake: ['performance'] }); + const endless = scenario( + 'endless', + { userTimings: true, markCleanupFallbackMs: 1000 }, + { timeoutMs: 0 } + ); + + endless.step('waiting'); + expect(timings.clearMarks).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1000); + + expect(timings.clearMarks).toHaveBeenCalled(); + expect(endless.isSettled).toBe(false); + }); + }); + + describe('timeouts', () => { + it('should time out after longer than one timer can wait', () => { + jest.useFakeTimers(); + const long = scenario('long', {}, { timeoutMs: MAX_TIMEOUT_MS + 1000 }); + + jest.advanceTimersByTime(MAX_TIMEOUT_MS); + expect(long.isSettled).toBe(false); + jest.advanceTimersByTime(1000); + + expect(long.status).toBe('timeout'); + }); + + it('should schedule its timeout once, though runOutside hands nothing back', () => { + jest.useFakeTimers(); + const outside = jest.fn((callback: () => unknown) => { + callback(); + }); + + scenario( + 'slow', + {}, + { + timeoutMs: 1000, + runOutside: outside as unknown as UwtScenarioDeps['runOutside'] + } + ); + + expect(outside).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(1); + }); + }); + + describe('debug output', () => { + it('should write each settled scenario to the console under debugScenario', () => { + localStorage.setItem('debugScenario', 'true'); + const log = jest.spyOn(console, 'log').mockImplementation(() => {}); + + scenario('checkout').complete(); + + expect(log).toHaveBeenCalledWith( + expect.stringMatching( + /^\[shop\]\[scenario\] checkout success in \d+ms -> / + ), + expect.objectContaining({ scenarioName: 'checkout' }) + ); + }); + }); +}); diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts similarity index 94% rename from projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts rename to projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts index 95fac2c..59fd3e3 100644 --- a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts +++ b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.spec.ts @@ -1,5 +1,5 @@ import { UwtNoopTelemetrySink } from './uwt-noop-telemetry.sink'; -import { UwtTelemetrySink } from '@absaoss-cps/ui-watchtower'; +import { UwtTelemetrySink } from '../uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; describe('UwtNoopTelemetrySink', () => { let sink: UwtTelemetrySink; diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts new file mode 100644 index 0000000..4f13ce7 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink.ts @@ -0,0 +1,50 @@ +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtTelemetrySink } from '../uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; + +/** + * Sink that discards everything. + * + * The explicit way to send telemetry nowhere — never a default. Debug flags, + * logging and scenario mechanics all still work, with no AWS account or + * `aws-rum-web` dependency needed. Angular selects it with + * `provideUwtTelemetrySink('noop')`. + * + * @group Services + */ +export class UwtNoopTelemetrySink extends UwtTelemetrySink { + private userId?: string; + + /** @inheritdoc */ + record( + _eventType: string, + _payload: object, + _metadata?: UwtTelemetryMetadata + ): void {} + + /** @inheritdoc */ + recordError( + _error: UwtTelemetryError, + _metadata?: UwtTelemetryMetadata + ): void {} + + /** @inheritdoc */ + getSessionId(): string | undefined { + return undefined; + } + + /** @inheritdoc */ + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(_beacon?: boolean): void {} +} diff --git a/projects/ui-watchtower/src/public-api.spec.ts b/projects/ui-watchtower/src/public-api.spec.ts index b167832..c7fa8b2 100644 --- a/projects/ui-watchtower/src/public-api.spec.ts +++ b/projects/ui-watchtower/src/public-api.spec.ts @@ -1,4 +1,9 @@ import * as core from './public-api'; +import { + UwtLogRecord, + UwtNoopTelemetrySink, + uwtCreateTelemetry +} from './public-api'; /** * The core must import, and run, where there is no browser: in Node, on a @@ -10,11 +15,40 @@ describe('the core outside a browser', () => { expect(typeof window).toBe('undefined'); expect(typeof document).toBe('undefined'); expect(typeof localStorage).toBe('undefined'); - expect(Object.keys(core)).toContain('uwtMarkSessionStart'); + expect(Object.keys(core)).toContain('uwtCreateTelemetry'); }); it('should keep no session start, and not throw, without storage', () => { expect(() => core.uwtMarkSessionStart('s1', 1_000)).not.toThrow(); expect(core.uwtSessionElapsed('s1', 2_000)).toBeUndefined(); }); + + it('should run scenarios, BI events and logging, and dispose, with no page', async () => { + const sent: { eventType: string; payload: object }[] = []; + const destination = new (class extends UwtNoopTelemetrySink { + override record(eventType: string, payload: object): void { + sent.push({ eventType, payload }); + } + })(); + const records: UwtLogRecord[] = []; + const telemetry = uwtCreateTelemetry({ + identity: { application: 'worker', environment: 'test', version: '1' }, + destination, + logApi: { + send: (record) => records.push(record), + query: async () => records + } + }); + + telemetry.scenarios.start({ name: 'import' }).step('parse').complete(); + telemetry.bi.track('file_imported', { rows: 3 }); + telemetry.logging.getLogger('import').log('Imported'); + + expect(sent.map((e) => e.eventType)).toEqual([ + 'com.uwt.scenario', + 'com.uwt.bi' + ]); + expect(await telemetry.logging.query()).toHaveLength(1); + expect(() => telemetry.dispose()).not.toThrow(); + }); }); diff --git a/projects/ui-watchtower/src/public-api.ts b/projects/ui-watchtower/src/public-api.ts index 585701e..7cf1fbc 100644 --- a/projects/ui-watchtower/src/public-api.ts +++ b/projects/ui-watchtower/src/public-api.ts @@ -5,9 +5,30 @@ * nothing touches a browser global when imported. */ +// The engine +export { uwtCreateTelemetry } from './lib/engine/uwt-telemetry/uwt-telemetry'; +export type { + UwtTelemetry, + UwtTelemetryOptions +} from './lib/engine/uwt-telemetry/uwt-telemetry'; +export { UwtScenarioTelemetry } from './lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry'; +export type { UwtScenarioTelemetryOptions } from './lib/engine/uwt-scenario-telemetry/uwt-scenario-telemetry'; +export { UwtBITelemetry } from './lib/engine/uwt-bi-telemetry/uwt-bi-telemetry'; +export type { UwtBITelemetryOptions } from './lib/engine/uwt-bi-telemetry/uwt-bi-telemetry'; +export { UwtLogTelemetry } from './lib/engine/uwt-log-telemetry/uwt-log-telemetry'; +export type { UwtLogTelemetryOptions } from './lib/engine/uwt-log-telemetry/uwt-log-telemetry'; +export type { UwtTelemetryDocument } from './lib/engine/uwt-telemetry-page/uwt-telemetry-page'; + // Configuration -export { UWT_DEFAULT_TELEMETRY_CONFIG } from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; -export type { UwtTelemetryIdentity } from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +export { + UWT_DEFAULT_TELEMETRY_CONFIG, + uwtResolveTelemetryConfig +} from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +export type { + UwtTelemetryConfig, + UwtTelemetryConfigOverrides, + UwtTelemetryIdentity +} from './lib/config/uwt-telemetry-common.config/uwt-telemetry-common.config'; export type { UwtBITelemetryConfig } from './lib/config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; export type { UwtLogConfig } from './lib/config/uwt-log.config/uwt-log.config'; export type { UwtScenarioTelemetryConfig } from './lib/config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; @@ -58,19 +79,27 @@ export type { // Scenarios export { UwtScenario } from './lib/scenario/uwt-scenario/uwt-scenario'; -// Contracts a destination or a log backend implements +// Contracts a destination or a log backend implements, and the explicit +// "nowhere" of each export { UwtTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +export { UwtNoopTelemetrySink } from './lib/sinks/uwt-telemetry/uwt-noop-telemetry.sink/uwt-noop-telemetry.sink'; export type { UwtLogApiProvider, UwtLogQuery } from './lib/providers/uwt-log-api.provider/uwt-log-api.provider'; +export { UwtNoopLogApiProvider } from './lib/providers/uwt-noop-log-api.provider/uwt-noop-log-api.provider'; // Utilities an application or a destination needs export { uwtClassifyTelemetryEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; export type { UwtTelemetrySinkEvent } from './lib/utils/uwt-telemetry-event.util/uwt-telemetry-event.util'; export { uwtIsDebugEnabled } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; export type { UwtDebugFlag } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; -export { uwtUuid } from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +export { + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync, + uwtUuid +} from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; export type { UwtReporter } from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; export { uwtMarkSessionStart } from './lib/utils/uwt-session-start.util/uwt-session-start.util'; export { @@ -89,11 +118,3 @@ export type { // Used by @absaoss-cps/ngx-ui-watchtower while the engine moves into this // package; removed from the public API once that code lives here too. export { uwtSessionElapsed } from './lib/utils/uwt-session-start.util/uwt-session-start.util'; -export { - uwtDeepClone, - uwtNow, - uwtSafe, - uwtSafeVoid, - uwtSafeVoidMaybeAsync -} from './lib/utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; -export { uwtDebugWrite } from './lib/utils/uwt-debug-flag.util/uwt-debug-flag.util'; diff --git a/projects/ui-watchtower/tsconfig.spec.json b/projects/ui-watchtower/tsconfig.spec.json index f0716fe..4271b51 100644 --- a/projects/ui-watchtower/tsconfig.spec.json +++ b/projects/ui-watchtower/tsconfig.spec.json @@ -2,9 +2,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/spec-core", - "rootDir": "./src", + "rootDir": ".", "types": ["jest", "node"], "isolatedModules": false }, - "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] + "include": ["src/**/*.spec.ts", "rxjs/**/*.spec.ts", "src/**/*.d.ts"] } diff --git a/projects/ui-watchtower/type-tests/augmented.ts b/projects/ui-watchtower/type-tests/augmented.ts index 67ff10b..d009bdc 100644 --- a/projects/ui-watchtower/type-tests/augmented.ts +++ b/projects/ui-watchtower/type-tests/augmented.ts @@ -9,6 +9,7 @@ * rejected is caught too. */ import { + UwtTelemetry, UwtBIEvent, UwtBIEventName, UwtLogConfig, @@ -114,3 +115,27 @@ export const options: UwtScenarioOptions = { name: 'chekout' }; export const query: UwtLogQuery = { logger: 'chekout' }; // @ts-expect-error not a registered logger export const levels: UwtLogConfig['levels'] = { chekout: 'log' }; + +/** Through the engine: registered names accepted, everything else rejected. */ +export async function engine(telemetry: UwtTelemetry): Promise { + const scenario = telemetry.scenarios.start({ name: 'checkout' }); + telemetry.scenarios.findByName('load-dashboard'); + telemetry.scenarios.findByNameAndId('checkout', scenario.id); + telemetry.scenarios.onSettled((_record) => { + expectType>(); + }); + telemetry.bi.track('export_clicked'); + telemetry.logging.getLogger('checkout').warn('careful'); + await telemetry.logging.query({ logger: 'checkout' }); + + // @ts-expect-error not a registered scenario + telemetry.scenarios.start({ name: 'chekout' }); + // @ts-expect-error not a registered scenario + telemetry.scenarios.findByName('chekout'); + // @ts-expect-error not a registered business event + telemetry.bi.track('export_clikced'); + // @ts-expect-error not a registered logger + telemetry.logging.getLogger('chekout'); + // @ts-expect-error not a registered logger + await telemetry.logging.query({ logger: 'chekout' }); +} diff --git a/projects/ui-watchtower/type-tests/compat.ts b/projects/ui-watchtower/type-tests/compat.ts index 810cfc9..7769c5d 100644 --- a/projects/ui-watchtower/type-tests/compat.ts +++ b/projects/ui-watchtower/type-tests/compat.ts @@ -5,6 +5,11 @@ * here, so every name is a plain string; see `augmented.ts` for registered * names. */ +import { firstValueFrom, Observable, of } from 'rxjs'; +import { + traceScenario, + UwtTraceScenarioOptions +} from '@absaoss-cps/ui-watchtower/rxjs'; import { UWT_DEFAULT_EVENT_NAMESPACE, UWT_DEFAULT_REDACT_CONFIG, @@ -12,8 +17,11 @@ import { UWT_LOG_LEVEL_ORDER, UWT_REDACTED, UWT_TELEMETRY_EVENT_TYPE, + UwtBITelemetry, UwtBITelemetryConfig, + UwtBITelemetryOptions, uwtClassifyTelemetryEvent, + uwtCreateTelemetry, UwtDebugFlag, uwtEventTypes, uwtIsDebugEnabled, @@ -22,20 +30,35 @@ import { UwtLogLevel, UwtLogQuery, UwtLogRecord, + UwtLogTelemetry, + UwtLogTelemetryOptions, uwtMarkSessionStart, uwtNormalizeError, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink, UwtPiiValuePattern, UwtRedactConfig, uwtRedactConfigFor, uwtRedactMetadata, UwtReporter, + uwtResolveTelemetryConfig, + uwtSafe, + uwtSafeVoid, + uwtSafeVoidMaybeAsync, UwtScenario, UwtScenarioRecord, + UwtScenarioTelemetry, UwtScenarioTelemetryConfig, + UwtScenarioTelemetryOptions, uwtScrubString, + UwtTelemetry, + UwtTelemetryConfig, + UwtTelemetryConfigOverrides, + UwtTelemetryDocument, UwtTelemetryError, UwtTelemetryIdentity, UwtTelemetryMetadata, + UwtTelemetryOptions, UwtTelemetrySink, UwtTelemetrySinkEvent, uwtUuid @@ -131,3 +154,87 @@ export function utilities(): unknown[] { UWT_REDACTED ]; } + +/** The engine, as a framework-free application creates and uses it. */ +export async function engine( + page: UwtTelemetryDocument | null +): Promise { + const overrides: UwtTelemetryConfigOverrides = { + logging: { minLevel: 'warn' }, + redaction: { scanValuePatterns: ['email'] } + }; + const options: UwtTelemetryOptions = { + identity, + destination: new UwtNoopTelemetrySink(), + logApi: new UwtNoopLogApiProvider(), + document: page, + runOutside: (callback) => callback(), + report, + ...overrides + }; + const telemetry: UwtTelemetry = uwtCreateTelemetry(options); + const config: UwtTelemetryConfig = uwtResolveTelemetryConfig(overrides); + + const scenario: UwtScenario = telemetry.scenarios.start({ name: 'checkout' }); + const stop = telemetry.scenarios.onSettled((record: UwtScenarioRecord) => + report('warn', record.scenarioName) + ); + const traced: UwtTraceScenarioOptions = { + outcome: (count) => ({ metadata: { count } }) + }; + const piped: Observable = of(1).pipe( + traceScenario(scenario, traced, report) + ); + await firstValueFrom(piped); + telemetry.bi.track( + 'export_clicked', + { format: 'csv' }, + { scenarioId: scenario.id } + ); + telemetry.logging.getLogger('checkout').warn('careful'); + const lines: UwtLogRecord[] = await telemetry.logging.query({ limit: 1 }); + telemetry.setUserId('user-1'); + telemetry.flush(true); + stop(); + telemetry.dispose(); + uwtSafeVoid('app.cleanup', () => undefined, report); + uwtSafeVoidMaybeAsync('app.async', async () => undefined, report); + return uwtSafe( + 'app.count', + () => lines.length + config.bi.dedupWindowMs, + 0, + report + ); +} + +/** Each concern on its own, as an adapter composes them. */ +export function concerns( + sink: UwtTelemetrySink, + logApi: UwtLogApiProvider +): unknown[] { + const config = uwtResolveTelemetryConfig(); + const shared = { identity, redact: config.redaction, report }; + const scenarioOptions: UwtScenarioTelemetryOptions = { + ...shared, + config: config.scenarios, + sink, + document: null + }; + const biOptions: UwtBITelemetryOptions = { + ...shared, + config: config.bi, + sink + }; + const logOptions: UwtLogTelemetryOptions = { + ...shared, + config: config.logging, + logApi, + sink, + document: null + }; + return [ + new UwtScenarioTelemetry(scenarioOptions), + new UwtBITelemetry(biOptions), + new UwtLogTelemetry(logOptions) + ]; +} diff --git a/tsconfig.json b/tsconfig.json index 24ff742..524f134 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,9 @@ "@absaoss-cps/ui-watchtower": [ "./projects/ui-watchtower/src/public-api.ts" ], + "@absaoss-cps/ui-watchtower/rxjs": [ + "./projects/ui-watchtower/rxjs/src/public-api.ts" + ], "@absaoss-cps/ngx-ui-watchtower": [ "./projects/ngx-ui-watchtower/src/public-api.ts" ], From 9f2d399eb2f09361b38b55cca0e1daf2de280e2b Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 11 Nov 2026 13:56:51 +0100 Subject: [PATCH 5/8] refactor: move the shell/fragment broadcast protocol into the core --- projects/ngx-ui-watchtower/DESIGN.md | 48 +- 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 | 191 ++++++++ .../sinks/uwt-broadcast/uwt-broadcast-host.ts | 414 +++++++++++++++++ .../uwt-broadcast-telemetry.sink.ts | 191 ++++++++ .../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, 2410 insertions(+), 1301 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 e3964e6..690f281 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -96,8 +96,7 @@ fails where it always did: the logger needs a log provider but no destination, scenarios need a destination and no log provider. The services are root singletons, so each concern exists once per injector, and all of them share the one destination and log provider. `settled$` is the core's -`onSettled` as an Observable that completes on destroy. It listens to the -core only while subscribed, so no record is copied for nobody; `traceScenario` is the +`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`. @@ -211,10 +210,7 @@ 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. An application upgrading from - 0.4 changes nothing. Another framework's adapter needs only the core's - public entry points, as this package does; - `docs/ADAPTERS.md` is a short guide. + the core exactly as an application does. - **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, @@ -233,29 +229,10 @@ 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: its - Angular helpers and the reporter. -- **What is published is tested as published.** `npm run smoke` installs - both tarballs into consumers outside the workspace: a framework-free one - with neither Angular nor `aws-rum-web` installed, and an Angular - application, prerendered as a fragment without `aws-rum-web` — which then - holds no RUM code — and as a shell with `/rum`, whose RUM client is a chunk - loaded on demand. -- **Both packages share one version and release together.** Release Please - links their versions: a change to either releases both, and the same - release PR moves this package's dependency on the core to `^` the new - version. The release workflow publishes the core first and this package - only after it, so this package never depends on a core version npm doesn't - have. Publishing skips what npm already has, so running the workflow again - on the release tag completes a partial release. + internals by relative path and carries small copies of what it needs. - **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. @@ -1631,15 +1608,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. 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 + 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. 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. + 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 @@ -2209,12 +2183,6 @@ 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 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..78abce5 --- /dev/null +++ b/projects/ui-watchtower/src/lib/providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider.ts @@ -0,0 +1,191 @@ +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtBroadcastConnection, + uwtConnectBroadcastChannel, + uwtIsBroadcastLogRecord, + uwtIsBroadcastMessage +} from '../../sinks/uwt-broadcast/uwt-broadcast.messages'; +import { + UwtReporter, + uwtSafeVoid, + uwtUuid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { + UwtLogApiProvider, + UwtLogQuery +} from '../uwt-log-api.provider/uwt-log-api.provider'; + +/** + * How long a follower waits for the host to answer a log query before + * resolving to `[]`, in milliseconds. + * + * @group Utils + */ +export const UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS = 10_000; + +/** A log query waiting for the host's answer. */ +interface UwtPendingLogQuery { + resolve: (records: UwtLogRecord[]) => void; + timer: ReturnType; +} + +/** + * What {@link UwtBroadcastLogApiProvider} needs; all optional. + * + * @group Interfaces + */ +export interface UwtBroadcastLogApiProviderOptions { + /** + * The channel's name; must match the host's. Default + * {@link UWT_DEFAULT_BROADCAST_CHANNEL}. + */ + channelName?: string; + /** + * `false` forwards nothing, and every query resolves to `[]` at once — + * on a server, or while telemetry is off — even over a shared + * {@link connection}. Default `true`. + */ + enabled?: boolean; + /** + * A connection to share with the realm's {@link UwtBroadcastTelemetrySink}, + * instead of opening one of its own. Not used while {@link enabled} is + * `false`. Left open by {@link UwtBroadcastLogApiProvider.dispose}: whoever + * opened it closes it. + */ + connection?: UwtBroadcastConnection; + /** + * Schedules the query timeout outside the host framework's change + * detection: Angular passes `NgZone.runOutsideAngular`. + */ + runOutside?: (callback: () => T) => T; + /** + * Hands a query's answer back inside the host framework's change + * detection: Angular passes `NgZone.run`. + */ + runInside?: (callback: () => T) => T; + /** Receives failures; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Log API provider for a follower realm — the log counterpart of + * {@link UwtBroadcastTelemetrySink}. + * + * Ships nothing itself. Records are forwarded over the `BroadcastChannel` + * to the realm running a {@link UwtTelemetryBroadcastHost}, which hands + * them to its own log provider: one log backend, one set of credentials, + * for the whole composed page. + * + * `query()` is answered by the host's provider too. It resolves to `[]` + * when no host answers within {@link UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS}, + * and at once when no channel could be opened. + * + * @group Services + */ +export class UwtBroadcastLogApiProvider implements UwtLogApiProvider { + private readonly connection: UwtBroadcastConnection; + private readonly ownsConnection: boolean; + private readonly stopListening: () => void; + private readonly runOutside: (callback: () => T) => T; + private readonly runInside: (callback: () => T) => T; + private readonly report?: UwtReporter; + private readonly pending = new Map(); + /** Set by {@link dispose}: nothing is sent or asked after, even on a shared connection. */ + private disposed = false; + + constructor(options: UwtBroadcastLogApiProviderOptions = {}) { + this.report = options.report; + // Off wins over a shared connection: this provider then has a closed one + // of its own, and the shared one stays open for its other users. + const shared = options.enabled === false ? undefined : options.connection; + this.ownsConnection = !shared; + this.connection = + shared ?? + uwtConnectBroadcastChannel({ + channelName: options.channelName, + open: options.enabled !== false, + operation: 'broadcastLogApi', + report: options.report + }); + this.runOutside = options.runOutside ?? ((callback) => callback()); + this.runInside = options.runInside ?? ((callback) => callback()); + this.stopListening = this.connection.onMessage((data) => + this.onMessage(data) + ); + } + + /** @inheritdoc */ + send(record: UwtLogRecord): void { + if (!this.disposed) { + this.connection.post({ kind: 'log', record }); + } + } + + /** @inheritdoc */ + query(filter: UwtLogQuery): Promise { + if (this.disposed || !this.connection.isOpen()) { + return Promise.resolve([]); + } + + return new Promise((resolve) => { + const id = uwtUuid(); + const timer = this.runOutside(() => + setTimeout( + () => this.answer(id, []), + UWT_BROADCAST_LOG_QUERY_TIMEOUT_MS + ) + ); + this.pending.set(id, { resolve, timer }); + this.connection.post({ kind: 'log-query', id, filter }); + }); + } + + /** @inheritdoc */ + flush(): void { + if (!this.disposed) { + this.connection.post({ kind: 'log-flush' }); + } + } + + /** + * Resolves every unanswered query to `[]`, stops listening, and closes + * the channel if this provider opened it. A shared connection stays open + * for its other users, but this provider sends nothing over it any more, + * and every later query resolves to `[]` at once. Calling it again does + * nothing. + */ + dispose(): void { + this.disposed = true; + for (const id of [...this.pending.keys()]) { + this.answer(id, []); + } + this.stopListening(); + if (this.ownsConnection) { + this.connection.close(); + } + } + + private onMessage(data: unknown): void { + uwtSafeVoid( + 'broadcastLogApi.receive', + () => { + if (!uwtIsBroadcastMessage(data) || data.kind !== 'log-query-result') { + return; + } + // Other followers' answers arrive here too; answer() ignores them. + this.answer(data.id, data.records.filter(uwtIsBroadcastLogRecord)); + }, + this.report + ); + } + + private answer(id: string, records: UwtLogRecord[]): void { + const query = this.pending.get(id); + if (!query) { + return; + } + this.pending.delete(id); + clearTimeout(query.timer); + this.runInside(() => query.resolve(records)); + } +} diff --git a/projects/ui-watchtower/src/lib/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..fe9f7b5 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast-telemetry.sink.ts @@ -0,0 +1,191 @@ +import { UwtTelemetryIdentity } from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { + UwtReporter, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; +import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { + UwtBroadcastConnection, + UwtBroadcastMessage, + uwtConnectBroadcastChannel, + uwtIsBroadcastMessage +} from './uwt-broadcast.messages'; + +/** + * What {@link UwtBroadcastTelemetrySink} needs. + * + * @group Interfaces + */ +export interface UwtBroadcastTelemetrySinkOptions { + /** This realm's identity, stamped onto everything it forwards. */ + identity: UwtTelemetryIdentity; + /** + * The channel's name; must match the host's. Default + * {@link UWT_DEFAULT_BROADCAST_CHANNEL}. + */ + channelName?: string; + /** + * `false` forwards nothing — on a server, or while telemetry is off — + * even over a shared {@link connection}. Default `true`. + */ + enabled?: boolean; + /** + * A connection to share with the realm's + * {@link UwtBroadcastLogApiProvider}, instead of opening one of its own. + * Not used while {@link enabled} is `false`. Left open by + * {@link UwtBroadcastTelemetrySink.dispose}: whoever opened it closes it. + */ + connection?: UwtBroadcastConnection; + /** Receives failures; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Telemetry sink for a follower realm — a micro-frontend fragment, a webview + * panel, any context with its own JavaScript realm but the same origin. + * + * Creates no AWS client of its own. Events are forwarded over a + * `BroadcastChannel` to the realm running a {@link UwtTelemetryBroadcastHost}, + * which records them through the one real sink. Application code sees no + * difference — it uses the same engine and calls the same methods. + * + * @group Services + */ +export class UwtBroadcastTelemetrySink extends UwtTelemetrySink { + private readonly connection: UwtBroadcastConnection; + private readonly ownsConnection: boolean; + private readonly stopListening: () => void; + private readonly report?: UwtReporter; + /** Set by {@link dispose}: nothing is forwarded after, even on a shared connection. */ + private disposed = false; + + /** The host's session id, once it has answered. */ + private sessionId?: string; + private userId?: string; + + /** + * This realm's own identity, stamped onto everything it forwards. + * + * The host records through its own RUM client, so without this, forwarded + * events would all be labelled as the host's realm. + */ + private readonly origin: { + application: string; + environment: string; + appVersion: string; + }; + + constructor(options: UwtBroadcastTelemetrySinkOptions) { + super(); + this.report = options.report; + // Off wins over a shared connection: this sink then has a closed one of + // its own, and the shared one stays open for its other users. + const shared = options.enabled === false ? undefined : options.connection; + this.ownsConnection = !shared; + this.connection = + shared ?? + uwtConnectBroadcastChannel({ + channelName: options.channelName, + open: options.enabled !== false, + operation: 'broadcastSink', + report: options.report + }); + this.origin = { + application: options.identity.application, + environment: options.identity.environment, + appVersion: options.identity.version + }; + + this.stopListening = this.connection.onMessage((data) => + this.onMessage(data) + ); + this.connection.post({ kind: 'identity-request' }); + } + + /** @inheritdoc */ + record( + eventType: string, + payload: object, + metadata?: UwtTelemetryMetadata + ): void { + this.post({ + kind: 'event', + eventType, + payload, + metadata: { ...metadata, ...this.origin } + }); + } + + /** @inheritdoc */ + recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata): void { + this.post({ + kind: 'error', + error, + metadata: { ...metadata, ...this.origin } + }); + } + + /** + * The host's session id. + * + * Returns `undefined` until the host answers. + * + * @returns the shared session id, when known + */ + getSessionId(): string | undefined { + return this.sessionId; + } + + /** @inheritdoc */ + setUserId(userId: string | undefined): void { + this.userId = userId; + this.post({ kind: 'user', userId }); + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(beacon = false): void { + this.post({ kind: 'flush', beacon }); + } + + /** + * Stops listening and forwarding, and closes the channel if this sink + * opened it. A shared connection stays open for its other users, but this + * sink sends nothing over it any more. Calling it again does nothing. + */ + dispose(): void { + this.disposed = true; + this.stopListening(); + if (this.ownsConnection) { + this.connection.close(); + } + } + + private post(message: UwtBroadcastMessage): void { + if (!this.disposed) { + this.connection.post(message); + } + } + + private onMessage(data: unknown): void { + uwtSafeVoid( + 'broadcastSink.receive', + () => { + if (!uwtIsBroadcastMessage(data) || data.kind !== 'identity') { + return; + } + this.sessionId = data.sessionId; + this.userId = data.userId; + }, + this.report + ); + } +} diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.spec.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.spec.ts new file mode 100644 index 0000000..f66da99 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.spec.ts @@ -0,0 +1,369 @@ +/** + * @jest-environment jsdom + */ +import { + uwtElectBroadcastHostLeader, + uwtIsBroadcastMessage +} from './uwt-broadcast.messages'; + +/** Minimal Web Locks API stub: grants each named lock to one requester at a time. */ +class LockManagerStub { + private readonly held = new Set(); + private readonly queues = new Map void)[]>(); + + request(name: string, callback: () => Promise): Promise { + return new Promise((resolve) => { + const grant = () => { + this.held.add(name); + callback().then(() => { + this.held.delete(name); + resolve(); + this.queues.get(name)?.shift()?.(); + }); + }; + + if (this.held.has(name)) { + const queue = this.queues.get(name) ?? []; + queue.push(grant); + this.queues.set(name, queue); + } else { + grant(); + } + }); + } + + /** Installs the stub as `navigator.locks`. */ + static install(): void { + Object.defineProperty(globalThis.navigator, 'locks', { + value: new LockManagerStub(), + configurable: true + }); + } + + static uninstall(): void { + delete (globalThis.navigator as { locks?: unknown }).locks; + } +} + +describe('the broadcast protocol', () => { + describe('uwtElectBroadcastHostLeader', () => { + afterEach(() => LockManagerStub.uninstall()); + + it('should elect immediately when the Locks API is unavailable', () => { + const onElected = jest.fn(); + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should elect once the lock is granted', () => { + LockManagerStub.install(); + const onElected = jest.fn(); + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should fail open and still elect when request() itself rejects', async () => { + Object.defineProperty(globalThis.navigator, 'locks', { + value: { request: () => Promise.reject(new Error('not-fully-active')) }, + configurable: true + }); + const onElected = jest.fn(); + + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected); + // Let the rejection's microtask settle. + await Promise.resolve(); + await Promise.resolve(); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should fail open and still elect when request() itself throws synchronously', () => { + Object.defineProperty(globalThis.navigator, 'locks', { + value: { + request: () => { + throw new Error('locks unavailable in this context'); + } + }, + configurable: true + }); + const onElected = jest.fn(); + + expect(() => + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElected) + ).not.toThrow(); + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should not elect a requester released while still queued, and should let the next requester through', async () => { + LockManagerStub.install(); + const onElectedHolder = jest.fn(); + const onElectedA = jest.fn(); + const onElectedB = jest.fn(); + + const releaseHolder = uwtElectBroadcastHostLeader( + 'ngx-ui-watchtower', + onElectedHolder + ); + const releaseA = uwtElectBroadcastHostLeader( + 'ngx-ui-watchtower', + onElectedA + ); + uwtElectBroadcastHostLeader('ngx-ui-watchtower', onElectedB); + + // A is released before ever being granted the lock. + releaseA(); + // The current holder releases, so the lock passes down the queue. + releaseHolder(); + for (let i = 0; i < 6; i++) { + await Promise.resolve(); + } + + expect(onElectedA).not.toHaveBeenCalled(); + expect(onElectedB).toHaveBeenCalledTimes(1); + }); + }); + + describe('uwtIsBroadcastMessage', () => { + it.each([null, undefined, 'string', 42, ['array']])( + 'should reject the non-object payload %p', + (data) => { + expect(uwtIsBroadcastMessage(data)).toBe(false); + } + ); + + it('should reject a payload with an unknown kind', () => { + expect(uwtIsBroadcastMessage({ kind: 'other library' })).toBe(false); + }); + + it('should accept a well-formed event message', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'event', + eventType: 'com.uwt.bi', + payload: { eventName: 'x' } + }) + ).toBe(true); + }); + + it.each([ + { eventType: 'com.uwt.bi' }, + { payload: {} }, + { eventType: 1, payload: {} }, + { eventType: 'com.uwt.bi', payload: 'not-an-object' }, + { + eventType: 'com.uwt.bi', + payload: {}, + metadata: { nested: {} } + }, + { + eventType: 'com.uwt.bi', + payload: {}, + metadata: { list: [1, 2] } + }, + { + eventType: 'com.uwt.bi', + payload: {}, + metadata: 'not-an-object' + } + ])('should reject a malformed event message %p', (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'event', ...fields })).toBe(false); + }); + + it('should accept an event message with flat-primitive metadata', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'event', + eventType: 'com.uwt.bi', + payload: {}, + metadata: { count: 3, ok: true, note: 'x', blank: null } + }) + ).toBe(true); + }); + + it('should accept an event message with no metadata', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'event', + eventType: 'com.uwt.bi', + payload: {} + }) + ).toBe(true); + }); + + it('should accept a well-formed error message', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom' } + }) + ).toBe(true); + }); + + it.each([ + {}, + { error: { name: 'Error' } }, + { error: { message: 'boom' } }, + { error: 'boom' }, + { error: { name: 'Error', message: 'boom', stack: 42 } }, + { + error: { name: 'Error', message: 'boom' }, + metadata: { nested: {} } + } + ])('should reject a malformed error message %p', (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'error', ...fields })).toBe(false); + }); + + it('should accept an error message with flat-primitive metadata', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom' }, + metadata: { origin: 'checkout' } + }) + ).toBe(true); + }); + + it('should accept an error message with a string stack', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom', stack: 'at foo.ts:1' } + }) + ).toBe(true); + }); + + it('should accept a well-formed flush message', () => { + expect(uwtIsBroadcastMessage({ kind: 'flush', beacon: true })).toBe(true); + }); + + it('should reject a flush message with a non-boolean beacon', () => { + expect(uwtIsBroadcastMessage({ kind: 'flush', beacon: 'true' })).toBe( + false + ); + }); + + it.each([{ userId: 'u-1' }, { userId: undefined }, {}])( + 'should accept a well-formed user message %p', + (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'user', ...fields })).toBe(true); + } + ); + + it('should reject a user message with a non-string, non-undefined userId', () => { + expect(uwtIsBroadcastMessage({ kind: 'user', userId: 42 })).toBe(false); + }); + + const logRecord = { + timestamp: '2026-01-01T00:00:00.000Z', + level: 'warn', + message: 'Careful', + application: 'cart', + environment: 'test', + version: '1.0.0' + }; + + it('should accept a well-formed log message', () => { + expect(uwtIsBroadcastMessage({ kind: 'log', record: logRecord })).toBe( + true + ); + expect( + uwtIsBroadcastMessage({ + kind: 'log', + record: { + ...logRecord, + logger: 'checkout', + context: 'Cart', + correlationId: 'c-1', + sessionId: 's', + userId: 'u', + metadata: { count: 1 }, + error: { name: 'Error', message: 'boom' } + } + }) + ).toBe(true); + }); + + it.each([ + { level: 'debug' }, + { level: 'toString' }, + { application: undefined }, + { message: 42 }, + { logger: 7 }, + { metadata: { nested: {} } }, + { error: { name: 'Error' } } + ])('should reject a log message whose record has %p', (fields) => { + expect( + uwtIsBroadcastMessage({ + kind: 'log', + record: { ...logRecord, ...fields } + }) + ).toBe(false); + }); + + it('should accept log-flush, log-query and log-query-result messages', () => { + expect(uwtIsBroadcastMessage({ kind: 'log-flush' })).toBe(true); + expect( + uwtIsBroadcastMessage({ + kind: 'log-query', + id: 'q-1', + filter: { correlationId: 'c-1', minLevel: 'warn', limit: 5 } + }) + ).toBe(true); + expect( + uwtIsBroadcastMessage({ + kind: 'log-query-result', + id: 'q-1', + records: [] + }) + ).toBe(true); + }); + + it.each([ + { id: '', filter: {} }, + { id: 'q-1', filter: null }, + { id: 'q-1', filter: { limit: 'ten' } }, + { id: 'q-1', filter: { minLevel: 'verbose' } } + ])('should reject a malformed log-query message %p', (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'log-query', ...fields })).toBe( + false + ); + }); + + it('should reject a log-query-result without a records array', () => { + expect( + uwtIsBroadcastMessage({ + kind: 'log-query-result', + id: 'q-1', + records: 'none' + }) + ).toBe(false); + }); + + it('should accept an identity-request message with no other fields', () => { + expect(uwtIsBroadcastMessage({ kind: 'identity-request' })).toBe(true); + }); + + it.each([ + {}, + { sessionId: 's-1' }, + { userId: 'u-1' }, + { + sessionId: 's-1', + userId: 'u-1' + } + ])('should accept an identity message %p', (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'identity', ...fields })).toBe(true); + }); + + it.each([{ sessionId: 42 }, { userId: 42 }, { sessionId: 42, userId: 42 }])( + 'should reject an identity message with a non-string, non-undefined field %p', + (fields) => { + expect(uwtIsBroadcastMessage({ kind: 'identity', ...fields })).toBe( + false + ); + } + ); + }); +}); diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts new file mode 100644 index 0000000..76eb481 --- /dev/null +++ b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.messages.ts @@ -0,0 +1,430 @@ +import { + UWT_LOG_LEVEL_ORDER, + UwtLogRecord +} from '../../models/uwt-log.models/uwt-log.models'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtLogQuery } from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { + UwtReporter, + uwtSafeVoid +} from '../../utils/uwt-telemetry-safe.util/uwt-telemetry-safe.util'; + +/** + * Default `BroadcastChannel` name used between telemetry realms. + * + * Named for the package that introduced it. Kept: realms deployed + * separately, on different versions, must still meet on it. + * + * @group Utils + */ +export const UWT_DEFAULT_BROADCAST_CHANNEL = 'ngx-ui-watchtower'; + +/** + * Everything that travels between telemetry realms. + * + * Structured-cloned by `BroadcastChannel`, so every payload must be plain + * data. + */ +export type UwtBroadcastMessage = + | { + kind: 'event'; + eventType: string; + payload: object; + metadata?: UwtTelemetryMetadata; + } + | { + kind: 'error'; + error: UwtTelemetryError; + metadata?: UwtTelemetryMetadata; + } + | { kind: 'user'; userId: string | undefined } + | { kind: 'flush'; beacon: boolean } + | { kind: 'identity-request' } + /** The host announcing shared identity. Always sends both fields, even when only one changed. */ + | { kind: 'identity'; sessionId?: string; userId?: string } + /** A follower's log record, for the host to hand to its log API provider. */ + | { kind: 'log'; record: UwtLogRecord } + /** A follower asking the host to flush its log API provider. */ + | { kind: 'log-flush' } + /** A follower reading records back through the host's log API provider. */ + | { kind: 'log-query'; id: string; filter: UwtLogQuery } + /** + * The host's answer to one `log-query`, matched by `id`. The records come + * from the host's backend unchecked; the asking follower keeps only those + * that pass {@link uwtIsBroadcastLogRecord}. + */ + | { kind: 'log-query-result'; id: string; records: unknown[] }; + +/** Every `kind` the union above accepts, for {@link uwtIsBroadcastMessage}. */ +const MESSAGE_KINDS: ReadonlySet = new Set([ + 'event', + 'error', + 'user', + 'flush', + 'identity-request', + 'identity', + 'log', + 'log-flush', + 'log-query', + 'log-query-result' +] as const); + +const LOG_LEVELS: ReadonlySet = new Set( + Object.keys(UWT_LOG_LEVEL_ORDER) +); + +/** Minimal `BroadcastChannel` surface this library relies on. */ +export interface UwtBroadcastChannelLike { + postMessage(message: unknown): void; + close(): void; + onmessage: ((event: { data: unknown }) => void) | null; +} + +/** + * Opens a broadcast channel, when the browser provides the API. + * + * Feature-detected: unavailable under jsdom (tests) and in server-side + * rendering. + */ +export function uwtOpenBroadcastChannel( + name: string +): UwtBroadcastChannelLike | undefined { + const Channel = ( + globalThis as { + BroadcastChannel?: new (name: string) => UwtBroadcastChannelLike; + } + ).BroadcastChannel; + + if (typeof Channel !== 'function') { + return undefined; + } + + try { + return new Channel(name); + } catch { + return undefined; + } +} + +/** Minimal Web Locks API surface this library relies on. */ +interface UwtLockManagerLike { + request(name: string, callback: () => Promise): Promise; +} + +/** + * Elects exactly one leader among same-origin realms on this channel, via + * the Web Locks API — prevents two shell tabs from both recording every + * forwarded message. Fails open (elects immediately) when Web Locks is + * unavailable. + * + * @returns releases the lock so the next queued realm can become leader. + */ +export function uwtElectBroadcastHostLeader( + channelName: string, + onElected: () => void +): () => void { + const locks = (globalThis as { navigator?: { locks?: UwtLockManagerLike } }) + .navigator?.locks; + + if (!locks) { + onElected(); + return () => undefined; + } + + let released = false; + let release: () => void = () => { + released = true; + }; + + const failOpen = () => { + if (!released) { + onElected(); + } + }; + + try { + locks + .request( + `ngx-ui-watchtower-host:${channelName}`, + () => + new Promise((resolve) => { + if (released) { + resolve(); + return; + } + release = () => { + released = true; + resolve(); + }; + onElected(); + }) + ) + .catch(failOpen); + } catch { + failOpen(); + } + + return () => release(); +} + +/** + * A realm's live connection to the shared broadcast channel. + * + * @group Interfaces + */ +export interface UwtBroadcastConnection { + /** The channel name in use. */ + readonly channelName: string; + + /** Whether a channel is open — false without `BroadcastChannel`, when not opened, and once closed. */ + isOpen(): boolean; + + /** Sends a message to every other realm on this channel. No-op if unavailable. */ + post(message: UwtBroadcastMessage): void; + + /** + * Adds a handler for incoming messages. No-op if no channel is open. + * + * @returns removes the handler + */ + onMessage(handler: (data: unknown) => void): () => void; + + /** Closes the channel and stops any further delivery. Safe to call more than once. */ + close(): void; +} + +/** + * Where, and whether, to connect. + * + * @group Interfaces + */ +export interface UwtBroadcastConnectOptions { + /** + * The channel's name; every realm on it must use the same one. Default + * {@link UWT_DEFAULT_BROADCAST_CHANNEL}. + */ + channelName?: string; + /** + * `false` opens no channel, so every call is a no-op: on a server, or + * while telemetry is off. Default `true`. + */ + open?: boolean; + /** Names this connection in failure reports. Default `broadcast`. */ + operation?: string; + /** Receives failures; see {@link UwtReporter}. */ + report?: UwtReporter; +} + +/** + * Opens this realm's connection to the shared broadcast channel: + * feature-detects `BroadcastChannel`, and wraps every operation in the + * library's fail-open guard. One connection can serve several handlers — + * a realm's broadcast sink and log provider share one. + * + * @param options the channel name, and whether to open it at all + * @returns the connection; a no-op one when no channel could be opened + * + * @group Utils + */ +export function uwtConnectBroadcastChannel( + options: UwtBroadcastConnectOptions = {} +): UwtBroadcastConnection { + const channelName = options.channelName ?? UWT_DEFAULT_BROADCAST_CHANNEL; + const operation = options.operation ?? 'broadcast'; + const { report } = options; + const handlers = new Set<(data: unknown) => void>(); + let channel = + options.open === false ? undefined : uwtOpenBroadcastChannel(channelName); + + if (channel) { + channel.onmessage = (event) => { + for (const handler of [...handlers]) { + uwtSafeVoid(`${operation}.receive`, () => handler(event.data), report); + } + }; + } + + return { + channelName, + isOpen() { + return channel !== undefined; + }, + post(message) { + uwtSafeVoid( + `${operation}.post`, + () => channel?.postMessage(message), + report + ); + }, + onMessage(handler) { + if (!channel) { + return () => undefined; + } + handlers.add(handler); + return () => { + handlers.delete(handler); + }; + }, + close() { + uwtSafeVoid( + `${operation}.close`, + () => { + handlers.clear(); + channel?.close(); + channel = undefined; + }, + report + ); + } + }; +} + +/** + * Whether `value` is a valid `metadata` field: absent, or a non-array + * object whose values are all finite numbers, strings, booleans, or null — + * the same flat-primitives contract `uwtRedactMetadata` enforces on the way + * out. Without this, a same-origin sender (not necessarily this library's + * own sink) could post a nested object on the channel that reaches a + * receiving sink's own sanitizer — e.g. `UwtRumTelemetrySink.sanitize()` — + * unchecked, since that step trusts the shape rather than re-validating it. + */ +function isValidBroadcastMetadata(value: unknown): boolean { + if (value === undefined) { + return true; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + return Object.values(value).every( + (v) => + v === null || + typeof v === 'string' || + typeof v === 'boolean' || + (typeof v === 'number' && Number.isFinite(v)) + ); +} + +function isOptionalString(value: unknown): boolean { + return value === undefined || typeof value === 'string'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isValidTelemetryError(value: unknown): boolean { + return ( + isRecord(value) && + typeof value.name === 'string' && + typeof value.message === 'string' && + isOptionalString(value.stack) + ); +} + +function isLogLevel(value: unknown): boolean { + return typeof value === 'string' && LOG_LEVELS.has(value); +} + +/** + * Whether `value` has the shape of a {@link UwtLogRecord}: the required + * strings, a known level, optional strings where the record has them, and + * metadata and error held to the same rules as on an `event` or `error` + * message. + */ +export function uwtIsBroadcastLogRecord(value: unknown): value is UwtLogRecord { + return ( + isRecord(value) && + typeof value.timestamp === 'string' && + isLogLevel(value.level) && + typeof value.message === 'string' && + typeof value.application === 'string' && + typeof value.environment === 'string' && + typeof value.version === 'string' && + isOptionalString(value.logger) && + isOptionalString(value.context) && + isOptionalString(value.correlationId) && + isOptionalString(value.userId) && + isOptionalString(value.sessionId) && + isValidBroadcastMetadata(value.metadata) && + (value.error === undefined || isValidTelemetryError(value.error)) + ); +} + +function isValidLogQuery(value: unknown): boolean { + return ( + isRecord(value) && + isOptionalString(value.correlationId) && + isOptionalString(value.logger) && + (value.minLevel === undefined || isLogLevel(value.minLevel)) && + isOptionalString(value.from) && + isOptionalString(value.to) && + (value.limit === undefined || + (typeof value.limit === 'number' && Number.isFinite(value.limit))) + ); +} + +/** + * Narrows an incoming `BroadcastChannel` payload to a telemetry message. + * + * Checks each kind's required fields, not just `kind`, so a same-named + * message from something else on the channel is rejected. + */ +export function uwtIsBroadcastMessage( + data: unknown +): data is UwtBroadcastMessage { + if (typeof data !== 'object' || data === null) { + return false; + } + + const message = data as Record; + const kind = message.kind as UwtBroadcastMessage['kind']; + if (!MESSAGE_KINDS.has(kind)) { + return false; + } + + switch (kind) { + case 'event': + return ( + typeof message.eventType === 'string' && + typeof message.payload === 'object' && + message.payload !== null && + isValidBroadcastMetadata(message.metadata) + ); + case 'error': + return ( + isValidTelemetryError(message.error) && + isValidBroadcastMetadata(message.metadata) + ); + case 'flush': + return typeof message.beacon === 'boolean'; + case 'user': + // Absent or `undefined` userId both mean "clear it". + return message.userId === undefined || typeof message.userId === 'string'; + case 'identity': + return ( + (message.sessionId === undefined || + typeof message.sessionId === 'string') && + (message.userId === undefined || typeof message.userId === 'string') + ); + case 'identity-request': + case 'log-flush': + return true; + case 'log': + return uwtIsBroadcastLogRecord(message.record); + case 'log-query': + return ( + typeof message.id === 'string' && + message.id !== '' && + isValidLogQuery(message.filter) + ); + case 'log-query-result': + return ( + typeof message.id === 'string' && + message.id !== '' && + Array.isArray(message.records) + ); + } +} diff --git a/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts b/projects/ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.spec.ts new file mode 100644 index 0000000..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 b7f4eb2ad4cc05fb27b9d8a6e5b80807f9695038 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 12 Nov 2026 20:32:36 +0100 Subject: [PATCH 6/8] address feedback --- README.md | 4 ++-- package.json | 4 ++-- .../public-api.snapshot.json | 20 ------------------- scripts/api-snapshot.mjs | 8 ++++++-- 4 files changed, 10 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index f8f6293..91ba9b6 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ The package is built to `dist/ngx-ui-watchtower`. | `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 api:check` | Builds, then compares the public API with its snapshot | +| `npm run api:update` | Builds, then rewrites the snapshot, for an intended change | | `npm run lint` | ESLint, no warnings allowed | | `npm run format` | Formats with Prettier | | `npm run format:check` | Checks formatting without writing | diff --git a/package.json b/package.json index 076ed36..0bffd83 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "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" + "api:check": "npm run build && node scripts/api-snapshot.mjs", + "api:update": "npm run build && 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 index b18d4f0..87fb6e9 100644 --- a/projects/ngx-ui-watchtower/public-api.snapshot.json +++ b/projects/ngx-ui-watchtower/public-api.snapshot.json @@ -91,15 +91,11 @@ "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", @@ -108,8 +104,6 @@ "UwtBroadcastTelemetrySink": [ "class", "new (): UwtBroadcastTelemetrySink", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "flush(beacon?: boolean | undefined): void", "getSessionId(): string | undefined", "getUserId(): string | undefined", @@ -165,8 +159,6 @@ "UwtLoggerService": [ "class", "new (): UwtLoggerService", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "getLogger(name: string): UwtLogger", "ngOnDestroy(): void", "query(filter?: UwtLogQuery | undefined): Promise" @@ -205,16 +197,12 @@ "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", @@ -384,8 +372,6 @@ "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", @@ -409,8 +395,6 @@ "UwtTelemetryBroadcastHost": [ "class", "new (): UwtTelemetryBroadcastHost", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "get received: number", "ngOnDestroy(): void" ], @@ -450,8 +434,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", @@ -556,8 +538,6 @@ "UwtRumTelemetrySink": [ "class", "new (): UwtRumTelemetrySink", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "flush(beacon?: boolean | undefined): void", "getSessionId(): string | undefined", "getUserId(): string | undefined", diff --git a/scripts/api-snapshot.mjs b/scripts/api-snapshot.mjs index 2316d49..140fc26 100644 --- a/scripts/api-snapshot.mjs +++ b/scripts/api-snapshot.mjs @@ -8,8 +8,9 @@ * 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. + * Run through `npm run api:check` and `npm run api:update`, which build + * first, so a stale dist/ never decides the result. Update only for an + * intended API change, and review the diff. */ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -84,10 +85,13 @@ function signatures(type, kind, prefix) { function members(type, prefix = '') { const lines = checker.getPropertiesOfType(type).flatMap((member) => { const flags = modifiers(member); + // `ɵ` members (ɵfac, ɵprov, ɵcmp…) are Angular's internals: not public + // API, and free to change between minor versions. if ( flags & ts.ModifierFlags.Private || member.name.startsWith('#') || member.name.startsWith('__#') || + member.name.startsWith('ɵ') || member.name === 'prototype' ) { return []; From 0c4ff53f9ce9decdd31db8756b6b0975db9276c9 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 12 Nov 2026 20:41:31 +0100 Subject: [PATCH 7/8] more to prev --- .github/workflows/checks.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 98f94c7..d0eadcb 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -33,10 +33,7 @@ jobs: - name: Install dependencies run: npm ci - - name: Build library - run: npm run build - - - name: Check the public API + - name: Build library and check the public API run: npm run api:check test: From d967ae0ad9dec3a51e579d4f0392a2c0a61991f4 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 12 Nov 2026 20:54:12 +0100 Subject: [PATCH 8/8] pin to commit hash --- .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 280d18f..6ecd23b 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -63,7 +63,7 @@ jobs: run: npm run test:coverage - name: Comment coverage on PR - uses: ArtiomTr/jest-coverage-report-action@v2 + uses: ArtiomTr/jest-coverage-report-action@262a7bb0b20c4d1d6b6b026af0f008f78da72788 # v2.3.1 if: github.event_name == 'pull_request' continue-on-error: true with: @@ -73,7 +73,7 @@ jobs: skip-step: all - name: Comment core coverage on PR - uses: ArtiomTr/jest-coverage-report-action@v2 + uses: ArtiomTr/jest-coverage-report-action@262a7bb0b20c4d1d6b6b026af0f008f78da72788 # v2.3.1 if: github.event_name == 'pull_request' continue-on-error: true with: