diff --git a/README.md b/README.md index 33008ab..ce863f1 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 f95fdc3..0a3e17d 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": "npm run build && node scripts/api-snapshot.mjs", "api:update": "npm run build && node scripts/api-snapshot.mjs --update" }, diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index 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/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"] +}