diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7db30e8..dd058ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -250,4 +250,4 @@ Before opening a pull request, verify: - [ ] New skill's description does not conflict with or shadow existing skills - [ ] Skill added to the catalog table in `README.md` - [ ] Evals exist (or a note explains why they are not applicable) -- [ ] `skills-ref validate ./skills/my-skill` passes (install: `pip install skills-ref`) +- [ ] `uvx --from skills-ref agentskills validate ./skills/my-skill` passes (the `skills-ref` package installs an `agentskills` command and needs Python 3.11+; with pip: `pip install skills-ref`, then `agentskills validate ./skills/my-skill`) diff --git a/README.md b/README.md index 4040f52..359937a 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ deprecated entities; audit unused steps and PageObject methods. | | **[test-unit-write](./skills/test-unit-write/)** | Generate unit tests from scratch following language-specific standards. Analyzes source, selects mock strategies, and produces tests covering happy paths, failure conditions, and edge cases. | | **[test-unit-review](./skills/test-unit-review/)** | Systematically audit unit test suites. Runs test runner, checks isolation/scope/naming/assertions/coverage standards, and reports findings by severity (Blocker / Important / Nit). | | **[token-saving](./skills/token-saving/)** | Always-active response discipline — enforces brevity, no filler openers or closers, structured output, and a What/Why/How footer on code responses. Suspends on explicit "full detail" requests. | +| **[ui-watchtower-integration](./skills/ui-watchtower-integration/)** | Measure what matters in an Angular app that already uses `@absaoss-cps/ngx-ui-watchtower` — asks what the team wants to learn, scans the code, proposes scenarios and BI events marked by source (you / suggested), implements after confirmation, and tests each outcome. | +| **[ui-watchtower-setup](./skills/ui-watchtower-setup/)** | Install and wire `@absaoss-cps/ngx-ui-watchtower` in an Angular app — destination per app role (CloudWatch RUM, micro-frontend shell or fragment, no-op), credentials, typed vocabulary, migration of a hand-written RumService, and route-navigation tracking as a tested proof. | ## Agent Roster diff --git a/docs/README.md b/docs/README.md index d0aff01..74cab52 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,6 +67,8 @@ Task-specific guidance for using individual skills. Start by picking your use ca | [Token Saving](./guides/token-saving.md) | Keeping AI responses concise — how the token-saving skill works and when it applies | | [PR Review](./guides/pr-review.md) | How the PR review skill works, what sections it applies, and how to trigger it | | [Create Repository](./create-repository.md) | Creating a new AbsaOSS/absa-group repo from the template with standard guardrails | +| [UI Watchtower Setup](./guides/ui-watchtower-setup.md) | Install and wire the ngx-ui-watchtower telemetry library in an Angular app — destination per app role, RUM credentials, typed vocabulary, route-navigation proof, tests | +| [UI Watchtower Integration](./guides/ui-watchtower-integration.md) | Measure an app's own journeys and interactions with ngx-ui-watchtower — asks what you want to learn, proposes, implements after confirmation, tests | ### QA And Testing Skills diff --git a/docs/guides/ui-watchtower-integration.md b/docs/guides/ui-watchtower-integration.md new file mode 100644 index 0000000..c9609f5 --- /dev/null +++ b/docs/guides/ui-watchtower-integration.md @@ -0,0 +1,73 @@ +# UI Watchtower Integration Skill + +The `ui-watchtower-integration` skill measures what matters in a specific Angular app with +[`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower): +**scenarios** (did a user journey succeed, and how long did it take) and **BI events** (did a +meaningful thing happen). + +It starts from what you want to learn, not only from what it finds in the code: it asks first, +proposes a plan that marks which items you asked for and which it suggests, and implements only what +you confirm. Run it again whenever the app grows — each pass adds to what is already instrumented. + +The library must already be installed and wired — use [UI Watchtower Setup](./ui-watchtower-setup.md) +first. + +--- + +## What it does + +| Step | Action | +|---|---| +| Check | Confirms the library is installed with a destination, otherwise stops and points to the setup skill; reads what is already instrumented so a re-run only adds | +| Ask | One short set of questions: what the telemetry should answer, which journeys matter and what success means, which interactions to count, known pain points, what must not be tracked, scope of this pass | +| Scan | Finds candidate journeys, clicks and journey-ending errors in the code, within that scope | +| Propose | A merged plan with a **Source** column (`you` / `suggested`) and a section for requests it had to change (e.g. no raw search text, no ids in names) with the alternative | +| Implement | Only what you confirmed — names in the schema, the right settle status for each outcome, cleanup on destroy; the scenario and BI services used directly where needed, never merged behind a facade | +| Test & validate | A test per outcome through a recording sink; build, unit tests, a real type-check, a live check with the debug flags | + +--- + +## How to trigger it + +``` +instrument the checkout flow as a scenario +we want to know if customer search is slow — add telemetry +which journeys should we measure in this app? +track how often people use the export +add BI events for the main buttons +``` + +--- + +## What you'll be asked + +1. Which questions should this telemetry answer? +2. Which user journeys matter most, and what counts as success? +3. Which interactions or features do you want usage numbers for? +4. Known pain points, incidents or support tickets to watch? +5. Anything that must not be tracked? +6. Scope of this pass — the whole app or one area? + +Questions your request already answers are skipped. "Just suggest something" works too: the proposal +then says it is based on the code only. + +--- + +## Out of scope + +- Installing or wiring the library → [UI Watchtower Setup](./ui-watchtower-setup.md) +- CloudWatch dashboards, RUM extended metrics and alarms +- A backend correlation-header interceptor (offered as a follow-up) +- Non-Angular applications + +--- + +## Installation + +```bash +npx skills add https://github.com/AbsaOSS/agentic-toolkit -g --skill ui-watchtower-integration +# Companion: holds the ngx-ui-watchtower API reference both watchtower skills load +npx skills add https://github.com/AbsaOSS/agentic-toolkit -g --skill shared +``` + +See [Getting Started](../getting-started.md) for the full install guide. diff --git a/docs/guides/ui-watchtower-setup.md b/docs/guides/ui-watchtower-setup.md new file mode 100644 index 0000000..97d21bd --- /dev/null +++ b/docs/guides/ui-watchtower-setup.md @@ -0,0 +1,78 @@ +# UI Watchtower Setup Skill + +The `ui-watchtower-setup` skill installs and wires +[`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower) — a +telemetry library for Angular apps — in an existing application, and proves the pipeline works with +one real scenario: route-navigation tracking. + +It is the first of two skills. Once the app is wired, use +[UI Watchtower Integration](./ui-watchtower-integration.md) to measure the app's own journeys and +interactions. + +--- + +## What it does + +| Step | Action | +|---|---| +| Detect | Reads Angular version, bootstrap style, SSR, package manager, test runner, existing RUM code, and the app's role (standalone, micro-frontend shell, or fragment) | +| Install | Adds the package (and `aws-rum-web` only where this app sends to RUM) | +| Wire | Registers exactly one destination for the app's role; a credentials provider for RUM; a log provider only if the app has a log backend; an opaque user id on sign-in and sign-out | +| Vocabulary | Creates `telemetry.schema.ts` so names are compile-checked metric dimensions | +| Migrate | If the app already sends RUM events itself: inventory, keep wire parity, retire the old service only after comparing what reaches CloudWatch | +| Prove | Adds route-navigation tracking with a test, and RUM page views — both under the route template (`/customers/:id`), never the resolved URL | +| Test & validate | Fixes specs that now need telemetry providers; build, unit tests, a real type-check, and a live check with the debug flags | +| Hand over | Points to the integration skill; for a shell, hands over the fragment contract | + +--- + +## How to trigger it + +``` +add ngx-ui-watchtower to this Angular app +set up CloudWatch RUM with the watchtower library +replace our RumService with ngx-ui-watchtower +wire telemetry into our micro-frontend shell +our fragment should forward telemetry to the shell +``` + +--- + +## What you'll be asked + +Only what the code can't answer: the `application` name to report, whether there is a log backend, +the endpoint that returns RUM settings and short-lived AWS credentials — or that RUM isn't ready +yet, in which case the app is wired with an explicit "ship nowhere" destination to swap later — and, +if the app has sign-in but no obvious opaque user id, which one to use. + +If the library is already partly wired, only the missing pieces are added. + +--- + +## Requirements + +- An Angular version the library supports (the skill checks its `peerDependencies` on npm and stops + rather than forcing an install) +- A Node.js version your Angular version supports +- Access to the npm registry + +--- + +## Out of scope + +- Measuring the app's own journeys and interactions → [UI Watchtower Integration](./ui-watchtower-integration.md) +- CloudWatch dashboards, RUM extended metrics and alarms +- Fragment teams' own setup when your app is a shell — the skill hands you their contract +- Non-Angular applications + +--- + +## Installation + +```bash +npx skills add https://github.com/AbsaOSS/agentic-toolkit -g --skill ui-watchtower-setup +# Companion: holds the ngx-ui-watchtower API reference both watchtower skills load +npx skills add https://github.com/AbsaOSS/agentic-toolkit -g --skill shared +``` + +See [Getting Started](../getting-started.md) for the full install guide. diff --git a/skills/shared/SKILL.md b/skills/shared/SKILL.md index 9fbddb7..9d5ff47 100644 --- a/skills/shared/SKILL.md +++ b/skills/shared/SKILL.md @@ -2,8 +2,8 @@ name: shared description: > Internal library — not a task skill. Holds shared Python modules (skills/shared/lib/) - and shared reference docs (skills/shared/references/) that other living-doc and BDD - skills import or load, e.g. the canonical AC-ID grammar and entity-ID assignment logic. + and shared reference docs (skills/shared/references/) that other skills import or load, + e.g. the canonical AC-ID grammar, entity-ID assignment logic and the ngx-ui-watchtower API. Do not activate this for a user request; it has no standalone behavior of its own. Install it alongside any skill whose own SKILL.md names it as a required companion — the consuming skill's install instructions list the exact command. diff --git a/skills/shared/references/ngx-ui-watchtower-api.md b/skills/shared/references/ngx-ui-watchtower-api.md new file mode 100644 index 0000000..142247f --- /dev/null +++ b/skills/shared/references/ngx-ui-watchtower-api.md @@ -0,0 +1,358 @@ +# ngx-ui-watchtower API reference + +The public surface of `@absaoss-cps/ngx-ui-watchtower` (latest release; if the installed typings +differ, they win). Import only from the two entry points below — no deep imports. + +## Contents + +1. [Imports by entry point](#1-imports-by-entry-point) +2. [Providers](#2-providers) +3. [Identity and configuration defaults](#3-identity-and-configuration-defaults) +4. [Scenarios](#4-scenarios) +5. [BI events](#5-bi-events) +6. [Logging](#6-logging) +7. [RUM sink and credentials](#7-rum-sink-and-credentials) +8. [Custom destination](#8-custom-destination) +9. [What reaches the wire](#9-what-reaches-the-wire) +10. [Debug flags](#10-debug-flags) +11. [Testing](#11-testing) + +## 1. Imports by entry point + +```ts +// '@absaoss-cps/ngx-ui-watchtower' — everything except RUM +import { + // setup + provideUwtTelemetry, withScenarios, withBIEvents, withLogging, withRedaction, + withTelemetryEnabled, UWT_TELEMETRY_ENABLED, // 0.2.0+: turn all telemetry off + provideUwtTelemetrySink, // 'broadcast' | 'noop' + provideUwtTelemetryDestination, // a custom sink + provideUwtTelemetryBroadcastHost, // shells only + UwtTelemetryBroadcastHost, // shells: `received` counter for verification + UWT_BROADCAST_CHANNEL, UWT_DEFAULT_BROADCAST_CHANNEL, + UwtBroadcastLogApiProvider, // bound by 'broadcast'; never bind it yourself + // scenarios + UwtScenarioTelemetryService, UwtScenario, traceScenario, + // BI + UwtBITelemetryService, + // logging + UwtLoggerService, UWT_LOG_API_PROVIDER, UwtNoopLogApiProvider, UWT_LOG_LEVEL_ORDER, + // sinks + UwtTelemetrySink, UwtNoopTelemetrySink, uwtClassifyTelemetryEvent, + // debug + uwtIsDebugEnabled, + // redaction helpers (for custom sinks) + uwtRedactMetadata, uwtNormalizeError, uwtScrubString, uwtRedactConfigFor +} from '@absaoss-cps/ngx-ui-watchtower'; +import type { + UwtTelemetryIdentity, UwtTelemetryMetadata, UwtTelemetryError, UwtScenarioOptions, + UwtScenarioOutcome, UwtScenarioRecord, UwtScenarioStatus, UwtTraceScenarioOptions, + UwtBIEventName, UwtLogApiProvider, UwtLogQuery, UwtLogRecord, UwtLogDetail, + // registries augmented by telemetry.schema.ts + UwtScenarioNames, UwtScenarioSteps, UwtBIEventNames, UwtLoggerNames +} from '@absaoss-cps/ngx-ui-watchtower'; + +// '@absaoss-cps/ngx-ui-watchtower/rum' — the whole entry point, seven names +import { + provideUwtTelemetryRumSink, UwtRumTelemetrySink, UWT_RUM_CREDENTIALS_PROVIDER +} from '@absaoss-cps/ngx-ui-watchtower/rum'; +import type { + UwtRumCredentialsProvider, UwtRumBootstrap, UwtRumCredentials, UwtRumAppMonitorConfig +} from '@absaoss-cps/ngx-ui-watchtower/rum'; +``` + +Use `import type` for the credential types so a credentials provider carries no runtime dependency +on the `/rum` entry. + +## 2. Providers + +All return `EnvironmentProviders`; they work in `ApplicationConfig.providers` and NgModule +`providers` alike. + +| Call | Registers | +|--------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------| +| `provideUwtTelemetry(identity, ...features)` | Identity + default config for every concern; features override one concern each (later wins). **No destination.** | +| `provideUwtTelemetryRumSink()` (`/rum`) | The AWS CloudWatch RUM destination. Needs `UWT_RUM_CREDENTIALS_PROVIDER`. Starts the client from a non-awaited initializer, so a slow broker never delays first paint | +| `provideUwtTelemetrySink('noop')` | Explicit "ship nowhere" destination | +| `provideUwtTelemetrySink('broadcast', { channelName? })` | Forwards to a shell's broadcast host. Also binds `UWT_LOG_API_PROVIDER` to `UwtBroadcastLogApiProvider`. `channelName` is bound with `useValue` (read eagerly) — fragments should use a lazy `UWT_BROADCAST_CHANNEL` factory instead (see micro-frontends.md) | +| `provideUwtTelemetryDestination(SinkClass, { init? })` | Any custom sink; `init(sink)` runs once from an initializer, fail-open | +| `provideUwtTelemetryBroadcastHost(channelName?)` | Shell-side receiver for fragments; constructed eagerly. Injects `UWT_LOG_API_PROVIDER` — a host without a log provider fails at bootstrap | + +Exactly one destination per realm: two different ones throw at bootstrap; the same one listed twice +is fine. Who injects `UWT_LOG_API_PROVIDER`: only `UwtLoggerService` and the broadcast host. + +**Turning telemetry off (0.2.0+)** — `withTelemetryEnabled(value | () => boolean)`, a feature of +`provideUwtTelemetry` (token `UWT_TELEMETRY_ENABLED`, default `true`). The factory runs in an +injection context; the value is read once at startup (no runtime toggling, e.g. for consent). The +providers stay the same — a destination and, where required, a log provider are still needed, so +wiring fails the same way everywhere. When off: + +- `start()` returns an inert `UwtScenario` (own `id`, the given `name`, chainable) that records + nothing and **never settles**; `find` / `findByName` / `getActive` find nothing, `settled$` never + emits, `traceScenario` passes values and errors through. +- `track()` does nothing; `getLogger()` returns an inert logger; `query()` resolves `[]` without + calling the log provider. +- RUM sink never starts (no credentials fetch, no SDK download), the broadcast sink opens no + channel, the broadcast host neither listens nor joins leader election, destination `init` is + skipped. The bundle doesn't shrink. + +`provideUwtTelemetrySink('noop')` is not the same: it only stops shipping. + +## 3. Identity and configuration defaults + +```ts +interface UwtTelemetryIdentity { + application: string; // unique per app and per fragment; becomes a session attribute + environment: string; // 'dev' | 'qa' | 'prod' … + version: string; + eventNamespace?: string; // default 'com.uwt' → com.uwt.scenario, com.uwt.scenario.step, com.uwt.bi +} +``` + +| Feature | Options and defaults | +|-------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| `withScenarios()` | `defaultTimeoutMs: 30_000` (0 disables), `emitLifecycleEvents: false`, `maxSteps: 50`, `userTimings: false`, `markCleanupFallbackMs: 300_000`, `redact: true` | +| `withBIEvents()` | `dedupWindowMs: 400`, `dedupMaxKeys: 100`, `redact: true` | +| `withLogging()` | `minLevel: 'log'` (`'log' \| 'warn' \| 'error'`), `levels?` per logger name, `mirrorErrorsToRum: false`, `redact: true` | +| `withRedaction()` | `extraKeyPatterns: []`, `maxStringLength: 1024`, `maxKeys: 50`, `maxStackLength: 2048`, `includeStack: true`, `stripUrlQuery: true`, `scanValuePatterns: []` (`'email' \| 'creditCard' \| 'ssn' \| 'ipv4' \| 'phone'`), `extraValuePatterns: []`, `extraValueTransforms: []` | + +Always on, even with `redact: false`: the credential key denylist (`password`, `secret`, `token`, +`auth`, `credential`, `cookie`, `api-key`, `bearer`, `jwt`, `signature`, `session-key`, `ssn` — +substring, case-insensitive), the size caps, error normalization to `{ name, message, stack? }`, and +`extraValueTransforms`. URL query strings and fragments are stripped; **path segments are not** — +so an `HttpErrorResponse` passed as `error` sends its message, which quotes the request URL path +(ids included). Send its class and status instead (`safeHttpFailure` in the integration patterns). + +## 4. Scenarios + +```ts +const scenario = inject(UwtScenarioTelemetryService).start({ + name: 'load-customers', // declared in UwtScenarioNames + feature?: 'customers', // free-form classification (plain string) + operation?: 'list', // free-form classification (plain string) + route?: '/customers/:id', // a TEMPLATE, never a resolved URL + parentScenarioId?: string, + startedAt?: number, // epoch ms (Date.now()) — backdate to the click + timeoutMs?: number, // default 30_000; 0 disables + metadata?: UwtTelemetryMetadata +}); +``` + +| Member | Notes | +|-----------------------------------------------|-----------------------------------------------------------------------------------------------| +| `step(name, metadata?)` | Opens a step (declared in `UwtScenarioSteps`); closes the previous one | +| `endStep(detail?)` / `failStep(error, detail?)` | Close the open step explicitly | +| `aggregateStart(name)` / `aggregateEnd(name)` | Sum repeated work (per-row formatting) into one total + call count instead of N steps | +| `setData(metadata)` | Merge attributes onto the record (re-capped at `maxKeys`) | +| `complete(outcome?)` | → `success` | +| `fail(outcome?)` | → `failure`; pass `{ error, statusCode? }` | +| `cancel(outcome?)` | → `abandoned`; pass `{ reason }`; stamps `metadata.abandonedBy: 'caller'` | +| `incomplete(outcome?)` | → `incomplete` | +| `settle(status, outcome?, error?)` | For adapters mapping an external status; prefer the named methods | +| `id`, `name`, `status`, `isSettled`, `delta` | `id` is the correlation id | +| `toRecord()` | Deep-cloned snapshot; `status` undefined while running | + +`UwtScenarioOutcome = { message?, reason?, metadata?, statusCode?, error? }`. `reason` is a short, +low-cardinality grouping key (`'superseded'`, `'no-results'`). Settling is absorbing: the first +settle wins, later calls are ignored. Timeout settles as `timeout` automatically. + +`traceScenario(scenario, options?)` — RxJS operator: `complete()` on source completion, +`fail({ error })` on error, `cancel(cancelOutcome)` on unsubscribe before either. So anything +downstream that stops after the first value — `take(1)`/`first()` after it, `firstValueFrom()` on +it — records `abandoned`; limit upstream and await with `lastValueFrom()`. + +```ts +interface UwtTraceScenarioOptions { + outcome?: (value: T) => UwtScenarioOutcome | void; // from the last emitted value + cancelOutcome?: UwtScenarioOutcome | (() => UwtScenarioOutcome | void); +} +``` + +Service extras: `find(id)`, `findByName(name)`, `getActive()`, and `settled$` (an observable of +deep-cloned settled records). On `pagehide` every in-flight scenario settles as `abandoned` +(`abandonedBy: 'page-hidden'`); on `visibilitychange` to hidden the sink flushes and scenarios keep +running. More than 50 active scenarios logs a dev-mode leak warning. + +## 5. BI events + +```ts +inject(UwtBITelemetryService).track( + 'export_clicked', // declared in UwtBIEventNames + { format: 'csv' }, // flat metadata + { scenarioId: scenario.id, feature: 'reports' } // optional: join to a journey; eventType? override +); +``` + +What the sink receives is `record('{ns}.bi', event)` — `com.uwt.bi` by default; the `eventType` +option replaces the type — with + +```ts +event = { eventName, eventTime /* ISO string */, metadata?, scenarioId?, feature?, application } +``` + +The name is in **`eventName`** (not `name`). A test with a recording sink asserts on +`payload.eventName` and `payload.metadata`. + +Identical events (name + scenarioId + eventType + feature + metadata) within 400 ms collapse into +one — no click throttling needed. Type any parameter that carries an event name as `UwtBIEventName`, +not `string`, or it stops compiling once the registry is augmented. + +## 6. Logging + +Only with a log backend bound to `UWT_LOG_API_PROVIDER`. + +```ts +private readonly logger = inject(UwtLoggerService).getLogger('checkout'); // declared in UwtLoggerNames + +this.logger.log('Submitting order'); +this.logger.warn('Retrying payment', { metadata: { attempt: 2 } }); +this.logger.error('Payment failed', { error, correlationId: scenario.id, context: 'CheckoutService' }); +// never a raw HttpErrorResponse as `error` — its message quotes the request URL (§3) + +inject(UwtLoggerService).query({ correlationId: scenario.id }); // → your provider's query() +``` + +`UwtLogDetail = { context?, metadata?, error?, correlationId? }`. Loggers are named — there is no +unnamed `log()` on the service. `correlationId = scenario.id` is what joins log lines to a journey. + +```ts +interface UwtLogApiProvider { + send(record: UwtLogRecord): void; // must not throw; batching/retries/auth are yours + query(filter: UwtLogQuery): Promise; + flush?(): void; // called when the page is hidden or closed +} +interface UwtLogQuery { correlationId?; logger?; minLevel?; from?; to?; limit? } +``` + +`UwtNoopLogApiProvider` is the explicit "no log backend": discards records, `query()` returns `[]`. + +## 7. RUM sink and credentials + +```ts +interface UwtRumCredentialsProvider { + /** Called at startup and again ~5 min before each credential expiry. `null` disables RUM. */ + load(): Promise; +} +interface UwtRumBootstrap { config: UwtRumAppMonitorConfig; credentials?: UwtRumCredentials } +interface UwtRumCredentials { accessKeyId; secretAccessKey; sessionToken; expiration } // all strings +``` + +`UwtRumAppMonitorConfig` requires `applicationId`, `region`, `applicationVersion`; it mirrors the +`aws-rum-web` config otherwise (`sessionSampleRate`, `sessionEventLimit` (default 200), +`telemetries`, `allowCookies`, `disableAutoPageView`, `pageIdFormat`, `pagesToInclude/Exclude`, +`enableXRay`, `endpoint`, …). Deliberately absent: `identityPoolId`/`guestRoleArn` (Cognito — use +your broker), `endpointUrl`, `sessionId`, `userId`. + +Return values of `load()`: + +| Return | Effect | +|-----------------------------|----------------------------------------------------------------------------------------------| +| `null` | RUM off for the session; mid-session it tears the running client down | +| throws / rejects | At startup: like `null`. On a refresh: keeps the current credentials and retries in 30 s | +| `{ config, credentials }` | Normal path | +| `{ config }` only | Only for an app monitor allowing unauthenticated access | + +`UwtRumTelemetrySink.recordPageView(pageId)` exists for apps that set `disableAutoPageView: true` +and record page ids (route templates) themselves — never alongside automatic page views. + +## 8. Custom destination + +```ts +@Injectable() +export class MyBackendSink extends UwtTelemetrySink { + record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata): void { + const event = uwtClassifyTelemetryEvent(eventType, payload); // kind: scenario | scenario-step | bi | unknown + // …send; never throw, never block + } + recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata): void {} + getSessionId(): string | undefined { return undefined; } + setUserId(userId: string | undefined): void {} // undefined and '' both mean signed out + getUserId(): string | undefined { return undefined; } + flush(beacon?: boolean): void {} +} + +providers: [provideUwtTelemetryDestination(MyBackendSink, { init: (s) => s.start() })] +``` + +Payloads arrive already redacted; the library guards every call into the sink. + +## 9. What reaches the wire + +| Event type (`{ns}` = `com.uwt`) | When | Payload | +|---------------------------------|---------------------------------------------------------|-------------------------------------------| +| `{ns}.scenario` | Once per scenario, at settlement | `UwtScenarioRecord` | +| `{ns}.scenario.step` | Per closed step — only with `emitLifecycleEvents: true` | step + scenario identity | +| `{ns}.bi` | Per `track()` call (after dedup) | `UwtBIEvent` | + +A settled scenario record (abridged): + +```json +{ + "scenarioId": "250514ab-…", "scenarioName": "table-page-load", "feature": "table", + "status": "abandoned", "reason": "superseded", + "startTime": "2026-09-05T11:11:04.211Z", "endTime": "2026-09-05T11:11:04.273Z", + "delta": 62, "elapsed": 3037, "stepCount": 1, + "steps": [ + { "name": "scenario-start", "startOffset": 0, "stepDelta": 0, "status": "success" }, + { "name": "fetch", "startOffset": 1, "stepDelta": 61, "status": "abandoned", "reason": "superseded" }, + { "name": "scenario-end", "startOffset": 62, "stepDelta": 0, "status": "abandoned" } + ], + "previousStep": "fetch", "metadata": { "abandonedBy": "caller" }, + "application": "my-app", "sessionId": "…", "userId": "…" +} +``` + +`delta` is the duration; `elapsed` is a timeline position (ms since page load). `scenario-start` / +`scenario-end` are written by the library and don't count toward `stepCount`. `sessionId` / `userId` +appear once the RUM client has started and a user is signed in. + +## 10. Debug flags + +`localStorage` keys `debugScenario`, `debugBI`, `debugLogger` (`'true'` or `'1'`; `debugLogger` also +accepts a comma-separated list of logger names). Read on every emit — no reload. `debugScenario` +also mirrors scenarios to `performance.mark/measure` (DevTools → Performance → Timings). One console +line per event actually sent: `[][scenario|bi|log] …`, second argument = the exact +object handed to the sink or log provider. `uwtIsDebugEnabled(flag, name?)` reads the same flags. + +## 11. Testing + +Minimal providers: + +```ts +providers: [ + provideUwtTelemetry( + { application: 'my-app-test', environment: 'test', version: '0.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }) + ), + provideUwtTelemetrySink('noop'), + { provide: UWT_LOG_API_PROVIDER, useClass: UwtNoopLogApiProvider } // only if UwtLoggerService is used +] +``` + +Recording sink, to assert on what is sent (all six methods): + +```ts +const events: { eventType: string; payload: Record }[] = []; // reset in beforeEach: events.length = 0 + +@Injectable() +class RecordingSink extends UwtTelemetrySink { + record(eventType: string, payload: object): void { + events.push({ eventType, payload: payload as Record }); + } + recordError(): void {} + getSessionId(): string | undefined { return 'session-1'; } + setUserId(): void {} + getUserId(): string | undefined { return undefined; } + flush(): void {} +} + +providers: [ + provideUwtTelemetry({ application: 'my-app-test', environment: 'test', version: '0.0.0' }), + provideUwtTelemetryDestination(RecordingSink) +] + +// expect(events).toEqual([ +// expect.objectContaining({ eventType: 'com.uwt.scenario', +// payload: expect.objectContaining({ scenarioName: 'load-customers', status: 'success' }) }) +// ]); +``` diff --git a/skills/ui-watchtower-integration/SKILL.md b/skills/ui-watchtower-integration/SKILL.md new file mode 100644 index 0000000..593c60b --- /dev/null +++ b/skills/ui-watchtower-integration/SKILL.md @@ -0,0 +1,198 @@ +--- +name: ui-watchtower-integration +description: > + Instruments an Angular app that already uses @absaoss-cps/ngx-ui-watchtower with scenarios (user + journeys) and BI events driven by the team's own questions: asks what they want to learn, scans + the code for candidates, proposes a merged plan, implements after confirmation, and tests it. Use + for "measure checkout", "track exports", "add journey/BI telemetry". NOT for: installing or wiring + the library (→ ui-watchtower-setup), dashboards, non-Angular apps. +license: Apache-2.0 +compatibility: > + Requires an Angular application where @absaoss-cps/ngx-ui-watchtower is already installed and + wired (see ui-watchtower-setup). +--- + +# ui-watchtower-integration + +Measures what matters in a specific app, with +[`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower) +already in place: + +| Concern | Answers | API | +|---|---|---| +| **Scenarios** | Did this user journey succeed, and how long did it take? | `UwtScenarioTelemetryService.start()` → `UwtScenario` | +| **BI events** | Did this meaningful thing happen? | `UwtBITelemetryService.track()` | + +These are **two separate concerns with two separate services**. A scenario measures a journey: it +has a duration and settles with a status. A BI event only counts that something happened. Inject +`UwtScenarioTelemetryService` and/or `UwtBITelemetryService` directly in the component or service +where the telemetry is needed, and never merge them behind an `AppTelemetryService`, a facade or a +single `track…` API unless the user explicitly asks for one — a merged API hides which concern a call +is and which status it settles with. + +It asks the team what they want to learn before proposing, marks each item as theirs or suggested, +and can be re-run as the app grows — each pass only adds. + +## Reference files — load on demand + +| Load | When | +|---|---| +| [ngx-ui-watchtower-api](../shared/references/ngx-ui-watchtower-api.md) ([remote](https://github.com/AbsaOSS/agentic-toolkit/blob/master/skills/shared/references/ngx-ui-watchtower-api.md)) | Before writing code — exact imports, signatures, test harness | +| `references/instrumentation-patterns.md` | Steps 3–6 — finding candidates, settle statuses, RxJS traps, cleanup, BI rules, starter catalogue | + +## Workflow + +Copy this checklist and track progress: + +``` +- [ ] Step 1: Check that setup is done (read-only) +- [ ] Step 2: Ask the user what they want to learn +- [ ] Step 3: Scan the code for candidates +- [ ] Step 4: Present a merged proposal +- [ ] Step 5: Wait for confirmation +- [ ] Step 6: Implement what was confirmed +- [ ] Step 7: Tests for every outcome +- [ ] Step 8: Validate +``` + +### Step 1 · Check that setup is done (read-only) + +Look for the package in `package.json`, and `provideUwtTelemetry(...)` plus exactly one destination +in the app's providers. If either is missing, stop and tell the user to run +**`ui-watchtower-setup`** first — instrumenting an unwired app fails at bootstrap with `NG0201`. + +If only `telemetry.schema.ts` is missing, don't stop — create it in Step 6. + +Read what already exists so this pass only adds: the names in `telemetry.schema.ts`, and their uses +(search each name, every injection of `UwtScenarioTelemetryService` / `UwtBITelemetryService`, and +`traceScenario(`). Note the test runner and package manager. If the app already wraps these services in +its own telemetry service, list that under "Already instrumented", don't extend it or copy the +pattern, and say so in the proposal; write the new code against the two services directly unless the +user tells you to follow the wrapper. + +### Step 2 · Ask the user what they want to learn + +Before reading the code for candidates, ask — in **one** short message, skipping anything the request +already answers: + +1. **Which questions should this telemetry answer?** e.g. "is checkout getting slower", "do people + use the export", "where do new users drop off". +2. **Which user journeys matter most**, and what counts as success for each? +3. **Which interactions or features** do you want usage numbers for? +4. **Known pain points** — slow screens, incidents, support tickets worth watching? +5. **Anything that must not be tracked** — sensitive screens, fields, user groups? +6. **Scope of this pass** — the whole app, or one area? + +Why ask first: the code can show where waits and clicks are, but not which of them anyone will ever +look at. Telemetry nobody needs still spends the 200-event RUM session budget and adds noise. + +If the user can't answer or says "just suggest", continue with code-derived candidates only and say +so in the proposal. + +### Step 3 · Scan the code for candidates + +Always do this scan, even when the user gave a complete list of what to track: their list says what +they care about, only the code says how it can be wired and what else is worth measuring. Load +`references/instrumentation-patterns.md`, read the routes, the components and services in scope, +and the HTTP calls behind them. Within the scope from Step 2, find candidates with the table in §1 +and classify each one as a scenario, a BI event, or both. + +Then map each user request to concrete code: which component or service, which call starts the +journey, which outcomes it can have. A request you can't find in the code goes under "Requested but +changed or not feasible". Candidates the user didn't mention are not optional extras: they become +`suggested` rows, so the proposal never contains only what was asked for. + +### Step 4 · Present a merged proposal + +Use this table shape even when nobody is there to answer — it is what the user reviews, and prose +loses the `Source` column. `Source` says where each item came from — `you` (the user asked for it, or it answers +one of their questions) or `suggested` (found in the code). Put the user's items first. + +```markdown +## Proposed telemetry for + +Scanned: +Answers: +Already instrumented (unchanged): + +### Scenarios (UwtScenarioTelemetryService) +| Source | Name | Where (file) | Steps | success when | other outcomes | +|---|---|---|---|---|---| +| you | customer-search | customers/customer-search.ts | query | results shown | incomplete: no-results; fail: HTTP error; cancel: superseded/left | + +### BI events (UwtBITelemetryService) +| Source | Name | Where (file) | Metadata (flat, no PII) | +|---|---|---|---| +| suggested | export_clicked | customers/customers.ts | format | + +### Requested but changed or not feasible +- "" — → + +### Deliberately not instrumented +- — +``` + +When a request conflicts with the library's rules, keep the user's intent and change the shape — +never silently drop it and never implement it as asked. The usual conflicts: an id or free text in a +name or metadata, per-keystroke events, personal data, a metric the frontend can't measure (server +latency, percentiles — those are computed in AWS from `delta`). + +### Step 5 · Wait for confirmation + +Stop. The user may add, drop or reshape items; apply their changes and show the final list if it +changed materially. Implement nothing before this. + +### Step 6 · Implement what was confirmed + +Follow `references/instrumentation-patterns.md`. + +**Use the two services directly.** In each component or service that needs telemetry, `inject()` the +service for the concern at hand — `UwtScenarioTelemetryService`, `UwtBITelemetryService`, or both — +and call it there. Do not create an `AppTelemetryService`, facade, base class or one `track…` API over +both, and do not move the calls into a shared service. Single-purpose helpers are fine (for example +`safeHttpFailure`); a shared abstraction only if the user explicitly asks. + +1. Add every confirmed name to `telemetry.schema.ts` (scenarios and steps kebab-case, BI events + snake_case, one JSDoc line each). Never interpolate ids into names. +2. Settle every journey with the right status (§2) and shape it with §3; never pass a raw + `HttpErrorResponse` (§2, `safeHttpFailure`). +3. Avoid the RxJS traps (§4) and settle open scenarios on destroy (§5). +4. BI events follow §6; names, metadata and privacy follow §7. +5. Only if the app has a log backend (`UwtLoggerNames` declared): at a journey-ending error, also + `logger.error(message, { error: safeHttpFailure(e).error, correlationId: scenario.id })` so log + lines join the scenario. + +### Step 7 · Tests for every outcome + +Use a recording sink (API reference, "Testing") and assert what reaches the sink — event type, +`scenarioName`, `status`, `reason`, metadata — for each outcome the proposal claims (success, +incomplete, failure with status, abandoned on leave). Specs that render instrumented components need +the telemetry providers or they fail with `NG0201`. Check one test bites: remove its settle call, +confirm it fails, restore. + +### Step 8 · Validate + +Run, through the app's package manager: the **build**, the **unit tests**, and a **real type-check** +(`tsc -p tsconfig.app.json --noEmit` and the spec tsconfig — Vitest never type-checks). Then a live +check with `localStorage.setItem('debugScenario', 'true')` and `debugBI`: one line per settled +scenario with the right status, leaving mid-journey gives `abandoned`, and a repeated identical +click gives one BI event. + +Report what was instrumented (by source), what was proposed but not confirmed, and anything changed +from the user's original request and why. + +## Gotchas + +- **Telemetry never throws into the app** — don't wrap calls in try/catch. +- **App behaviour never depends on telemetry results.** Where `withTelemetryEnabled` turns telemetry + off, scenarios never settle, `settled$` never emits, `find` / `getActive` find nothing and + `query()` resolves `[]`. If the live check shows no debug lines, check that switch first. + +## Out of scope + +- Installing or wiring the library, credentials, destinations, route-navigation tracking → + `ui-watchtower-setup`. +- CloudWatch dashboards, extended metrics, alarms. +- A backend correlation-header interceptor (offer as a follow-up). +- Non-Angular applications. diff --git a/skills/ui-watchtower-integration/evals/evals.json b/skills/ui-watchtower-integration/evals/evals.json new file mode 100644 index 0000000..7354db6 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/evals.json @@ -0,0 +1,258 @@ +{ + "skill_name": "ui-watchtower-integration", + "evals": [ + { + "id": "vague-request-asks-first", + "category": "happy-path", + "prompt": "Add telemetry for our main user journeys. The project is in evals/files/wired-angular.", + "expected_output": "Skill confirms setup is done, asks the user what the telemetry should answer (questions, key journeys and success criteria, interactions, pain points, exclusions, scope) before proposing, then presents a proposal whose items carry a Source column, implements only after confirmation, and does not re-add route navigation.", + "files": [ + "evals/files/wired-angular/angular.json", + "evals/files/wired-angular/package.json", + "evals/files/wired-angular/src/app/app.config.ts", + "evals/files/wired-angular/src/app/app.routes.ts", + "evals/files/wired-angular/src/app/app.spec.ts", + "evals/files/wired-angular/src/app/app.ts", + "evals/files/wired-angular/src/app/customers/customer-detail.ts", + "evals/files/wired-angular/src/app/customers/customers.ts", + "evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.ts", + "evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.ts", + "evals/files/wired-angular/src/app/telemetry/telemetry.schema.ts", + "evals/files/wired-angular/src/index.html", + "evals/files/wired-angular/src/main.ts", + "evals/files/wired-angular/src/styles.css", + "evals/files/wired-angular/tsconfig.app.json", + "evals/files/wired-angular/tsconfig.json", + "evals/files/wired-angular/tsconfig.spec.json" + ], + "expectations": [ + "Checks that the library is installed and wired (package, providers, destination) before anything else", + "Asks the user, before proposing, what the telemetry should answer \u2014 covering at least the questions to answer, the important journeys and what counts as success, and anything that must not be tracked", + "Presents a proposal whose items have a Source column distinguishing items the user asked for from suggested ones (here all suggested, and says so)", + "Waits for confirmation before implementing (implements only after the assumed confirmation)", + "Does not add a second route-navigation scenario or change the existing wiring", + "New scenario and BI names are added to the existing telemetry.schema.ts", + "Adds tests that assert the emitted status per outcome through a recording sink" + ] + }, + { + "id": "user-supplied-requirements", + "category": "happy-path", + "prompt": "We want to know whether the customer list is slow to load or failing, and how often people export it to CSV. Watchtower is already set up. The project is in evals/files/wired-angular.", + "expected_output": "Skill uses the stated requirements as the core of the proposal (marked as from the user), asks only what is still open (e.g. exclusions, scope), may add clearly marked suggestions, and implements a customers-load scenario with correct statuses and an export BI event with low-cardinality metadata, plus tests.", + "files": [ + "evals/files/wired-angular/angular.json", + "evals/files/wired-angular/package.json", + "evals/files/wired-angular/src/app/app.config.ts", + "evals/files/wired-angular/src/app/app.routes.ts", + "evals/files/wired-angular/src/app/app.spec.ts", + "evals/files/wired-angular/src/app/app.ts", + "evals/files/wired-angular/src/app/customers/customer-detail.ts", + "evals/files/wired-angular/src/app/customers/customers.ts", + "evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.ts", + "evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.ts", + "evals/files/wired-angular/src/app/telemetry/telemetry.schema.ts", + "evals/files/wired-angular/src/index.html", + "evals/files/wired-angular/src/main.ts", + "evals/files/wired-angular/src/styles.css", + "evals/files/wired-angular/tsconfig.app.json", + "evals/files/wired-angular/tsconfig.json", + "evals/files/wired-angular/tsconfig.spec.json" + ], + "expectations": [ + "Does not ask the user to state, confirm or re-describe the journeys or interactions the prompt already named (the customer-list load and the CSV export), and does not ask which journeys matter without referring to them; questions about additional journeys, scope, exclusions or success criteria are allowed", + "The proposal contains a customer-list load scenario and an export BI event, both marked as coming from the user", + "Any additional items are clearly marked as suggestions and are not required for the user's two questions", + "The load scenario settles as success with a row count, incomplete for an empty list, failure with the HTTP statusCode on HTTP error (never the raw HttpErrorResponse, whose message quotes the request URL), and abandoned when the user leaves mid-load", + "The export event uses a snake_case name declared in UwtBIEventNames and only low-cardinality metadata (e.g. format), no ids", + "Adds tests for each outcome through a recording sink" + ] + }, + { + "id": "conflicting-request", + "category": "regression", + "prompt": "Track every keystroke in the customer search box together with what was typed, and put the customer id into the scenario name so we can see load time per customer. The project is in evals/files/wired-angular.", + "expected_output": "Skill keeps the user's intent but changes the shape: lists both requests under 'Requested but changed or not feasible' with reasons (raw text is PII and unbounded, per-keystroke events burn the session budget, ids in names split metrics), and proposes a debounced search event without raw text (e.g. queryLength, resultCount) and a single scenario name with the route template or a low-cardinality attribute instead of the id.", + "files": [ + "evals/files/wired-angular/angular.json", + "evals/files/wired-angular/package.json", + "evals/files/wired-angular/src/app/app.config.ts", + "evals/files/wired-angular/src/app/app.routes.ts", + "evals/files/wired-angular/src/app/app.spec.ts", + "evals/files/wired-angular/src/app/app.ts", + "evals/files/wired-angular/src/app/customers/customer-detail.ts", + "evals/files/wired-angular/src/app/customers/customers.ts", + "evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.ts", + "evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.ts", + "evals/files/wired-angular/src/app/telemetry/telemetry.schema.ts", + "evals/files/wired-angular/src/index.html", + "evals/files/wired-angular/src/main.ts", + "evals/files/wired-angular/src/styles.css", + "evals/files/wired-angular/tsconfig.app.json", + "evals/files/wired-angular/tsconfig.json", + "evals/files/wired-angular/tsconfig.spec.json" + ], + "expectations": [ + "Lists both requests under a 'requested but changed / not feasible' section with the reason for each", + "Does not send the typed text: the search event is debounced and carries only low-cardinality data such as query length or result count", + "Does not interpolate the customer id into any scenario, step or BI name, and does not put the raw id in metadata", + "Offers an alternative that still answers the user's question (e.g. one scenario name with the route template, percentiles by delta computed AWS-side)", + "Implements only the changed versions, after the assumed confirmation" + ] + }, + { + "id": "regression-settle-status-and-rxjs", + "category": "regression", + "prompt": "Instrument the customer list loading as a scenario, including when it fails or the user leaves the page. The project is in evals/files/wired-angular.", + "expected_output": "Skill starts a customers-load scenario, settles complete on success, incomplete({ reason: 'no-results' }) on an empty list, fail with the HTTP statusCode and no raw HttpErrorResponse (its message quotes the request URL) on HTTP error, and cancel on leaving; nothing downstream of traceScenario that stops after the first value (no take(1)/first() after it, no firstValueFrom on it); names declared in telemetry.schema.ts; tests for each outcome.", + "files": [ + "evals/files/wired-angular/angular.json", + "evals/files/wired-angular/package.json", + "evals/files/wired-angular/src/app/app.config.ts", + "evals/files/wired-angular/src/app/app.routes.ts", + "evals/files/wired-angular/src/app/app.spec.ts", + "evals/files/wired-angular/src/app/app.ts", + "evals/files/wired-angular/src/app/customers/customer-detail.ts", + "evals/files/wired-angular/src/app/customers/customers.ts", + "evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.ts", + "evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.ts", + "evals/files/wired-angular/src/app/telemetry/telemetry.schema.ts", + "evals/files/wired-angular/src/index.html", + "evals/files/wired-angular/src/main.ts", + "evals/files/wired-angular/src/styles.css", + "evals/files/wired-angular/tsconfig.app.json", + "evals/files/wired-angular/tsconfig.json", + "evals/files/wired-angular/tsconfig.spec.json" + ], + "expectations": [ + "Navigating away mid-load settles as cancel/abandoned, not fail", + "An empty result settles as incomplete with a low-cardinality reason, not success or fail", + "An HTTP error settles as fail with its statusCode, and the raw HttpErrorResponse (whose message quotes the request URL, ids included) is not passed as the error", + "Leaving the page mid-load settles the open scenario as abandoned with a stable reason (ngOnDestroy cancel, or traceScenario's cancelOutcome on teardown)", + "Nothing downstream of traceScenario stops after the first value: no take(1)/first() after it and no firstValueFrom() on the traced observable (take(1) upstream of it is fine)", + "Declares the scenario and step names in telemetry.schema.ts; no id or URL interpolated into any name", + "Adds tests asserting the emitted status for each outcome using a recording sink" + ] + }, + { + "id": "re-run-adds-only", + "category": "regression", + "prompt": "Add telemetry for the customer list screen. The project is in evals/files/wired-instrumented-angular.", + "expected_output": "Skill reads the existing schema and instrumentation, recognises that customers-load is already measured, and proposes only additions for the screen (e.g. export, search) \u2014 each with a Source label \u2014 without a second load scenario or changes to the existing one; new names go into the existing schema, with tests.", + "files": [ + "evals/files/wired-instrumented-angular/angular.json", + "evals/files/wired-instrumented-angular/package.json", + "evals/files/wired-instrumented-angular/src/app/app.config.ts", + "evals/files/wired-instrumented-angular/src/app/app.routes.ts", + "evals/files/wired-instrumented-angular/src/app/app.spec.ts", + "evals/files/wired-instrumented-angular/src/app/app.ts", + "evals/files/wired-instrumented-angular/src/app/customers/customer-detail.ts", + "evals/files/wired-instrumented-angular/src/app/customers/customers.spec.ts", + "evals/files/wired-instrumented-angular/src/app/customers/customers.ts", + "evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.ts", + "evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.ts", + "evals/files/wired-instrumented-angular/src/app/telemetry/telemetry.schema.ts", + "evals/files/wired-instrumented-angular/src/index.html", + "evals/files/wired-instrumented-angular/src/main.ts", + "evals/files/wired-instrumented-angular/src/styles.css", + "evals/files/wired-instrumented-angular/tsconfig.app.json", + "evals/files/wired-instrumented-angular/tsconfig.json", + "evals/files/wired-instrumented-angular/tsconfig.spec.json" + ], + "expectations": [ + "Reads the existing telemetry.schema.ts and instrumentation and notes that customers-load is already measured", + "Does not add a second load scenario for the customer list or re-instrument the existing customers-load", + "The proposal lists only new items, each with a Source label (here suggested, since the user named no specific question), and says what already exists", + "Adds new names to the existing schema without removing or renaming existing entries", + "Leaves the existing customers-load instrumentation and its tests unchanged", + "New events carry only low-cardinality metadata (no raw search text, no ids), and recording-sink tests assert them", + "A search added on this screen is debounced (no scenario or event per keystroke)" + ] + }, + { + "id": "precondition-missing", + "category": "negative", + "prompt": "Measure how long the customer list takes to load. The project is in evals/files/unwired-angular.", + "expected_output": "Skill finds that ngx-ui-watchtower is not installed or wired, stops, and points the user to ui-watchtower-setup without instrumenting anything.", + "files": [ + "evals/files/unwired-angular/package.json", + "evals/files/unwired-angular/src/app/app.config.ts", + "evals/files/unwired-angular/src/app/customers/customers.ts" + ], + "expectations": [ + "Detects that the library is not installed or wired (no package, no providers, no schema)", + "Stops and points the user to the ui-watchtower-setup skill", + "Does not install packages, add providers or instrument any code" + ] + }, + { + "id": "no-merged-telemetry-wrapper", + "category": "regression", + "prompt": "We want to know whether placing an order is slow or failing, and how often people use the coupon field. Watchtower is already set up. The project is in evals/files/wired-checkout-angular.", + "expected_output": "Skill treats the order as a scenario (UwtScenarioTelemetryService) and the coupon use as a BI event (UwtBITelemetryService), says so for each item in its proposal, and injects the right service directly in the cart and order-summary components. It creates no AppTelemetryService, facade, base class or single track-style API over both services, even though two components need telemetry.", + "files": [ + "evals/files/wired-checkout-angular/README.md", + "evals/files/wired-checkout-angular/angular.json", + "evals/files/wired-checkout-angular/package.json", + "evals/files/wired-checkout-angular/src/app/app.config.ts", + "evals/files/wired-checkout-angular/src/app/app.routes.ts", + "evals/files/wired-checkout-angular/src/app/app.spec.ts", + "evals/files/wired-checkout-angular/src/app/app.ts", + "evals/files/wired-checkout-angular/src/app/checkout/cart.ts", + "evals/files/wired-checkout-angular/src/app/checkout/order-confirmation.ts", + "evals/files/wired-checkout-angular/src/app/checkout/order-summary.ts", + "evals/files/wired-checkout-angular/src/app/telemetry/route-navigation-telemetry.service.ts", + "evals/files/wired-checkout-angular/src/app/telemetry/rum-credentials.provider.ts", + "evals/files/wired-checkout-angular/src/app/telemetry/telemetry.schema.ts", + "evals/files/wired-checkout-angular/src/index.html", + "evals/files/wired-checkout-angular/src/main.ts", + "evals/files/wired-checkout-angular/src/styles.css", + "evals/files/wired-checkout-angular/tsconfig.app.json", + "evals/files/wired-checkout-angular/tsconfig.json", + "evals/files/wired-checkout-angular/tsconfig.spec.json" + ], + "expectations": [ + "The proposal says, for every item, whether it is a scenario, a BI event, or both, and which service it uses (UwtScenarioTelemetryService or UwtBITelemetryService), for example through separate tables headed by the service", + "Every component or service that records telemetry injects UwtScenarioTelemetryService and/or UwtBITelemetryService directly (an inject of the service appears in each file that uses it)", + "Nothing merges the two: no class, base class, facade or function injects both services and exposes methods for both, and there is no AppTelemetryService, TelemetryFacade or similar under any name; single-purpose helpers such as an HTTP-error mapper are allowed", + "Placing the order is a scenario that settles with the right statuses (success, failure with the HTTP status code, abandoned when the user leaves), and the coupon use is a BI event; neither concern is expressed through the other service", + "New names are declared in telemetry.schema.ts (scenarios and steps kebab-case, BI events snake_case)", + "Tests use a recording sink and assert each scenario outcome the proposal claims and the BI event (through its eventName)" + ] + }, + { + "id": "user-list-still-gets-code-research", + "category": "regression", + "prompt": "Add a place_order_clicked BI event and an order-placement scenario, and a coupon_applied BI event. Watchtower is already set up. The project is in evals/files/wired-checkout-angular.", + "expected_output": "Although the user named every item, the skill still scans the app and the proposal shows what it read, maps each requested item to the real component, and adds suggested items the user did not mention (for example the cart load).", + "files": [ + "evals/files/wired-checkout-angular/README.md", + "evals/files/wired-checkout-angular/angular.json", + "evals/files/wired-checkout-angular/package.json", + "evals/files/wired-checkout-angular/src/app/app.config.ts", + "evals/files/wired-checkout-angular/src/app/app.routes.ts", + "evals/files/wired-checkout-angular/src/app/app.spec.ts", + "evals/files/wired-checkout-angular/src/app/app.ts", + "evals/files/wired-checkout-angular/src/app/checkout/cart.ts", + "evals/files/wired-checkout-angular/src/app/checkout/order-confirmation.ts", + "evals/files/wired-checkout-angular/src/app/checkout/order-summary.ts", + "evals/files/wired-checkout-angular/src/app/telemetry/route-navigation-telemetry.service.ts", + "evals/files/wired-checkout-angular/src/app/telemetry/rum-credentials.provider.ts", + "evals/files/wired-checkout-angular/src/app/telemetry/telemetry.schema.ts", + "evals/files/wired-checkout-angular/src/index.html", + "evals/files/wired-checkout-angular/src/main.ts", + "evals/files/wired-checkout-angular/src/styles.css", + "evals/files/wired-checkout-angular/tsconfig.app.json", + "evals/files/wired-checkout-angular/tsconfig.json", + "evals/files/wired-checkout-angular/tsconfig.spec.json" + ], + "expectations": [ + "The proposal has a 'Scanned' line (or equivalent) naming files, components or HTTP calls that were actually read, and they exist in the project", + "Each item the user named is mapped to the real file and call that triggers it (cart.ts for the coupon, order-summary.ts for placing the order)", + "The proposal contains at least one row with Source 'suggested' for something the user did not name (for example the cart load, GET /api/cart, as a scenario), and that row points to a real file and call", + "User-named items come first and are marked 'you'; suggested items are clearly separated and are not required for the user's request", + "Where a requested name or shape conflicts with the library rules or the code (for example place_order_clicked firing before the request settles, or a click duplicating the scenario), the proposal says so under 'Requested but changed or not feasible' or keeps the user's intent explicitly" + ] + } + ] +} diff --git a/skills/ui-watchtower-integration/evals/files/unwired-angular/package.json b/skills/ui-watchtower-integration/evals/files/unwired-angular/package.json new file mode 100644 index 0000000..cb25dc7 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-angular/package.json @@ -0,0 +1,23 @@ +{ + "name": "customer-portal", + "version": "1.4.0", + "scripts": { "start": "ng serve", "build": "ng build", "test": "ng test" }, + "private": true, + "dependencies": { + "@angular/common": "^22.2.0", + "@angular/compiler": "^22.2.0", + "@angular/core": "^22.2.0", + "@angular/platform-browser": "^22.2.0", + "@angular/router": "^22.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^22.2.0", + "@angular/cli": "^22.2.0", + "@angular/compiler-cli": "^22.2.0", + "jsdom": "^28.0.0", + "typescript": "~6.0.2", + "vitest": "^4.0.8" + } +} diff --git a/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.config.ts b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.config.ts new file mode 100644 index 0000000..9dba522 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.config.ts @@ -0,0 +1,8 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes), provideHttpClient()] +}; diff --git a/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/customers/customers.ts b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/customers/customers.ts new file mode 100644 index 0000000..b2b9cd7 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/customers/customers.ts @@ -0,0 +1,36 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, inject, OnInit, signal } from '@angular/core'; + +interface Customer { + id: string; + name: string; +} + +@Component({ + selector: 'app-customers', + template: ` + @if (error()) { +

Could not load customers.

+ } + @for (c of customers(); track c.id) { +

{{ c.name }}

+ } + + ` +}) +export class Customers implements OnInit { + private readonly http = inject(HttpClient); + protected readonly customers = signal([]); + protected readonly error = signal(false); + + ngOnInit(): void { + this.http.get('/api/customers').subscribe({ + next: (rows) => this.customers.set(rows), + error: () => this.error.set(true) + }); + } + + exportCsv(): void { + window.location.href = '/api/customers/export?format=csv'; + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/angular.json b/skills/ui-watchtower-integration/evals/files/wired-angular/angular.json new file mode 100644 index 0000000..28790e8 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/angular.json @@ -0,0 +1,73 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm" + }, + "newProjectRoot": "projects", + "projects": { + "customer-portal": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "allowedCommonJsDependencies": [ + "shimmer" + ], + "styles": [ + "src/styles.css" + ], + "security": { + "allowedHosts": [] + } + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "customer-portal:build:production" + }, + "development": { + "buildTarget": "customer-portal:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test" + } + } + } + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/package.json b/skills/ui-watchtower-integration/evals/files/wired-angular/package.json new file mode 100644 index 0000000..a84c3e7 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/package.json @@ -0,0 +1,30 @@ +{ + "name": "customer-portal", + "version": "1.4.0", + "scripts": { + "start": "ng serve", + "build": "ng build", + "test": "ng test" + }, + "private": true, + "dependencies": { + "@absaoss-cps/ngx-ui-watchtower": "latest", + "@angular/common": "^22.2.0", + "@angular/compiler": "^22.2.0", + "@angular/core": "^22.2.0", + "@angular/platform-browser": "^22.2.0", + "@angular/router": "^22.2.0", + "aws-rum-web": "^3.2.1", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@types/node": "^20.17.19", + "@angular/build": "^22.2.0", + "@angular/cli": "^22.2.0", + "@angular/compiler-cli": "^22.2.0", + "jsdom": "^28.0.0", + "typescript": "~6.0.2", + "vitest": "^4.0.8" + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.config.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.config.ts new file mode 100644 index 0000000..eccc9a5 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.config.ts @@ -0,0 +1,52 @@ +import { ApplicationConfig, inject, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; +import { provideUwtTelemetry } from '@absaoss-cps/ngx-ui-watchtower'; +import { + UWT_RUM_CREDENTIALS_PROVIDER, + UwtRumTelemetrySink, + provideUwtTelemetryRumSink +} from '@absaoss-cps/ngx-ui-watchtower/rum'; +import { routes } from './app.routes'; +import { + provideRouteNavigationTelemetry, + ROUTE_PAGE_VIEW_RECORDER +} from './telemetry/route-navigation-telemetry.service'; +import { AppRumCredentialsProvider } from './telemetry/rum-credentials.provider'; +import './telemetry/telemetry.schema'; // side-effect import: the telemetry vocabulary + +/** Reported as the telemetry `version`; keep in step with package.json. */ +const APP_VERSION = '1.4.0'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes, withComponentInputBinding()), + // Route-navigation telemetry, started before any navigation (incl. the initial one). + provideRouteNavigationTelemetry(), + provideHttpClient(), + // No features (withScenarios / withBIEvents / withLogging / withRedaction): + // the library defaults fit, and there is no log backend. + provideUwtTelemetry({ + application: 'customer-portal', + // The app has no environment files; dev server vs. production build is + // the only distinction available at runtime. + environment: isDevMode() ? 'dev' : 'prod', + version: APP_VERSION + }), + // This app's one destination: AWS CloudWatch RUM. Settings and short-lived + // credentials come from GET /rum/init; a failed fetch only disables RUM + // for the session. + provideUwtTelemetryRumSink(), + { provide: UWT_RUM_CREDENTIALS_PROVIDER, useExisting: AppRumCredentialsProvider }, + // Page views under the route template (/customers/:customerId), never the + // resolved path; the credentials provider sets disableAutoPageView: true. + { + provide: ROUTE_PAGE_VIEW_RECORDER, + useFactory: () => { + const rum = inject(UwtRumTelemetrySink); + return (route: string) => rum.recordPageView(route); + } + } + ] +}; diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.routes.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.routes.ts new file mode 100644 index 0000000..097aa25 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.routes.ts @@ -0,0 +1,13 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { path: '', pathMatch: 'full', redirectTo: 'customers' }, + { + path: 'customers', + loadComponent: () => import('./customers/customers').then((m) => m.Customers) + }, + { + path: 'customers/:customerId', + loadComponent: () => import('./customers/customer-detail').then((m) => m.CustomerDetail) + } +]; diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.spec.ts new file mode 100644 index 0000000..cca39b4 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.spec.ts @@ -0,0 +1,29 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { + provideUwtTelemetry, + provideUwtTelemetrySink, + withScenarios +} from '@absaoss-cps/ngx-ui-watchtower'; +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + providers: [ + provideRouter([]), + // App starts route-navigation telemetry in its constructor. + provideUwtTelemetry( + { application: 'customer-portal-test', environment: 'test', version: '0.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }) // no scenario timers left running in tests + ), + provideUwtTelemetrySink('noop') + ] + }).compileComponents(); + }); + + it('should create the app', () => { + expect(TestBed.createComponent(App).componentInstance).toBeTruthy(); + }); +}); diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.ts new file mode 100644 index 0000000..ab99d7a --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.ts @@ -0,0 +1,20 @@ +import { Component, inject } from '@angular/core'; +import { RouterLink, RouterOutlet } from '@angular/router'; +import { RouteNavigationTelemetryService } from './telemetry/route-navigation-telemetry.service'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, RouterLink], + template: ` + + + ` +}) +export class App { + private readonly routeTelemetry = inject(RouteNavigationTelemetryService); + + /** Starts the navigation measurement at the click, not at NavigationStart. */ + protected markNavigationIntent(): void { + this.routeTelemetry.markNavigationIntent(); + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/customers/customer-detail.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/customers/customer-detail.ts new file mode 100644 index 0000000..8855ad4 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/customers/customer-detail.ts @@ -0,0 +1,9 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'app-customer-detail', + template: `

Customer {{ customerId() }}

` +}) +export class CustomerDetail { + readonly customerId = input.required(); +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/customers/customers.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/customers/customers.ts new file mode 100644 index 0000000..2fe86d8 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/customers/customers.ts @@ -0,0 +1,44 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, inject, OnInit, signal } from '@angular/core'; + +interface Customer { + id: string; + name: string; +} + +@Component({ + selector: 'app-customers', + template: ` + + @if (error()) { +

Could not load customers.

+ } + @for (c of customers(); track c.id) { +

{{ c.name }}

+ } + + ` +}) +export class Customers implements OnInit { + private readonly http = inject(HttpClient); + protected readonly customers = signal([]); + protected readonly error = signal(false); + + ngOnInit(): void { + this.http.get('/api/customers').subscribe({ + next: (rows) => this.customers.set(rows), + error: () => this.error.set(true) + }); + } + + search(query: string): void { + this.http.get('/api/customers', { params: { q: query } }).subscribe({ + next: (rows) => this.customers.set(rows), + error: () => this.error.set(true) + }); + } + + exportCsv(): void { + window.location.href = '/api/customers/export?format=csv'; + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.ts new file mode 100644 index 0000000..9b41eed --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.ts @@ -0,0 +1,368 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + DestroyRef, + EnvironmentProviders, + inject, + Injectable, + InjectionToken, + NgZone, + PLATFORM_ID, + provideEnvironmentInitializer +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + ActivatedRouteSnapshot, + Event as RouterEvent, + NavigationCancel, + NavigationCancellationCode, + NavigationEnd, + NavigationError, + NavigationSkipped, + NavigationSkippedCode, + NavigationStart, + PRIMARY_OUTLET, + Router, + RoutesRecognized +} from '@angular/router'; +import { + UwtScenario, + UwtScenarioTelemetryService +} from '@absaoss-cps/ngx-ui-watchtower'; +// Side-effect import: the vocabulary declaring the names used below. +import './telemetry.schema'; + +/** + * Records one page view per completed navigation, under the route template. Bind it to + * `UwtRumTelemetrySink.recordPageView` only where RUM is the destination, with + * `disableAutoPageView: true` — automatic page views report the resolved path (`/customers/42`). + */ +export const ROUTE_PAGE_VIEW_RECORDER = new InjectionToken<(route: string) => void>( + 'ROUTE_PAGE_VIEW_RECORDER' +); + +const NAVIGATION_SCENARIO = 'route-navigation'; + +/** Beyond this, a recorded click is assumed not to have caused the navigation. */ +const INTENT_MAX_AGE_MS = 2_000; + +/** No end event in this window (a guard, resolver or chunk that never settles) → `timeout`. */ +const NAVIGATION_TIMEOUT_MS = 30_000; + +/** `route` of a navigation that failed before its URL matched a route. */ +const UNRECOGNIZED_ROUTE = '(unrecognized)'; + +/** Route `data` key naming a `matcher` route's segment — a matcher has no `path`. */ +const TELEMETRY_PATH_DATA_KEY = 'telemetryPath'; + +/** + * Low-cardinality causes from the router's `code`. Its `reason` text is never sent: empty in + * production, and it can contain URLs in development. + */ +const CANCEL_REASON: Partial> = { + [NavigationCancellationCode.Redirect]: 'redirect', + [NavigationCancellationCode.SupersededByNewNavigation]: 'superseded', + [NavigationCancellationCode.NoDataFromResolver]: 'no-data-from-resolver', + [NavigationCancellationCode.GuardRejected]: 'guard-rejected', + [NavigationCancellationCode.Aborted]: 'aborted' +}; + +/** Expected dead ends (`incomplete`), not the user leaving (`abandoned`). */ +const INCOMPLETE_CODES = new Set([ + NavigationCancellationCode.GuardRejected, + NavigationCancellationCode.NoDataFromResolver +]); + +/** As {@link CANCEL_REASON}, for a navigation the router never ran. */ +const SKIP_REASON: Partial> = { + [NavigationSkippedCode.IgnoredSameUrlNavigation]: 'same-url', + [NavigationSkippedCode.IgnoredByUrlHandlingStrategy]: 'url-handling-strategy' +}; + +/** `code` is optional, and a future Angular may add one not mapped here. */ +function causeOf( + causes: Partial>, + code: TCode | undefined, + fallback: string +): string { + return (code === undefined ? undefined : causes[code]) ?? fallback; +} + +/** Matched template from the primary outlet's `routeConfig` chain: `/customers/:id`. */ +function routeTemplateOf(root: ActivatedRouteSnapshot): string { + const parts: string[] = []; + for ( + let route: ActivatedRouteSnapshot | null = root; + route; + route = route.children.find((child) => child.outlet === PRIMARY_OUTLET) ?? null + ) { + const config = route.routeConfig; + if (!config) { + continue; + } + const path = config.matcher + ? ((config.data?.[TELEMETRY_PATH_DATA_KEY] as string | undefined) ?? '(matcher)') + : config.path; + if (path) { + parts.push(path); + } + } + return `/${parts.join('/')}`; +} + +/** A class-like name (`ChunkLoadError`); any other `name` could be free text. */ +const SAFE_ERROR_NAME = /^[A-Z][A-Za-z0-9]{0,63}$/; + +/** + * Router, resolver and lazy-chunk errors can quote URLs, parameters or user data, and path + * segments are not redacted — so keep only a class-like name and an HTTP status. + */ +function safeNavigationFailure( + error: unknown, + recognized: boolean +): { error: Error; statusCode?: number } { + const safe = new Error( + recognized ? 'Navigation failed' : 'Navigation failed before the URL matched a route' + ); + const { name, status } = (error ?? {}) as { name?: unknown; status?: unknown }; + if (typeof name === 'string' && SAFE_ERROR_NAME.test(name)) { + safe.name = name; + } + return { error: safe, statusCode: typeof status === 'number' ? status : undefined }; +} + +interface TrackedNavigation { + /** Epoch ms: the click, `NavigationStart`, or the start of a redirect chain. */ + startedAt: number; + /** Template a guard or resolver redirected away from. */ + redirectedFrom?: string; + /** Matched template, known from `RoutesRecognized`. */ + route?: string; + /** `RoutesRecognized` time — guards, resolvers and lazy components run after it. */ + recognizedAt?: number; + /** Already recorded (e.g. at its timeout): a late end adds only its page view. */ + recorded?: boolean; + timeoutHandle?: ReturnType; +} + +/** What a guard or resolver redirect carries into the next navigation. */ +type RedirectCarry = Pick; + +/** + * One `route-navigation` scenario per navigation, recorded when it ends — `route` can't change + * after a scenario starts, and only the end knows the final template — or at its timeout if it + * hasn't ended. Backdated to the click or the first `NavigationStart`. Start it with + * {@link provideRouteNavigationTelemetry}; call {@link markNavigationIntent} from click handlers + * that navigate. + */ +@Injectable({ providedIn: 'root' }) +export class RouteNavigationTelemetryService { + private readonly router = inject(Router); + private readonly scenarioTelemetry = inject(UwtScenarioTelemetryService); + private readonly recordPageView = inject(ROUTE_PAGE_VIEW_RECORDER, { optional: true }); + private readonly destroyRef = inject(DestroyRef); + private readonly zone = inject(NgZone); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + /** By navigation id, never one "current" field: navigations supersede each other. */ + private readonly navigations = new Map(); + + private pendingRedirect?: RedirectCarry; + private navigationIntentAt?: number; + + private started = false; + + /** Safe to call more than once. */ + start(): void { + if (this.started) { + return; + } + this.started = true; + + this.router.events + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => this.onRouterEvent(event)); + this.destroyRef.onDestroy(() => { + for (const navigation of this.navigations.values()) { + clearTimeout(navigation.timeoutHandle); + } + }); + } + + /** `NavigationStart` comes after event handling and change detection; count that wait too. */ + markNavigationIntent(): void { + this.navigationIntentAt = Date.now(); + } + + private onRouterEvent(event: RouterEvent): void { + if (event instanceof NavigationStart) { + const startedAt = this.navigationStartedAt(); + const redirect = this.consumePendingRedirect(); + const navigation: TrackedNavigation = { + startedAt: redirect?.startedAt ?? startedAt, + redirectedFrom: redirect?.redirectedFrom, + recorded: redirect?.recorded + }; + this.navigations.set(event.id, navigation); + if (!navigation.recorded) { + this.armTimeout(event.id, navigation); + } + return; + } + + if (event instanceof RoutesRecognized) { + const navigation = this.navigations.get(event.id); + if (navigation) { + navigation.route = routeTemplateOf(event.state.root); + navigation.recognizedAt = Date.now(); + } + return; + } + + if (event instanceof NavigationEnd) { + const navigation = this.take(event.id); + if (navigation?.route) { + this.record(navigation, navigation.route, (scenario, metadata) => + scenario.complete({ metadata }) + ); + this.recordPageView?.(navigation.route); + } + return; + } + + if (event instanceof NavigationCancel) { + const navigation = this.take(event.id); + if (!navigation) { + return; + } + if (event.code === NavigationCancellationCode.Redirect) { + // Restarts under a new id: carry the start so the journey stays one scenario. + this.pendingRedirect = { + startedAt: navigation.startedAt, + redirectedFrom: navigation.redirectedFrom ?? navigation.route, + recorded: navigation.recorded + }; + return; + } + // Superseded before its URL was recognized: nothing to attribute it to. + if (!navigation.route) { + return; + } + const reason = causeOf(CANCEL_REASON, event.code, 'navigation-cancelled'); + this.record(navigation, navigation.route, (scenario, metadata) => + event.code !== undefined && INCOMPLETE_CODES.has(event.code) + ? scenario.incomplete({ reason, metadata }) + : scenario.cancel({ reason, metadata }) + ); + return; + } + + if (event instanceof NavigationSkipped) { + // A redirect back to the current URL ends here, not in a NavigationStart: release the carried + // redirect, and drop the click intent so it can't backdate an unrelated navigation. + this.navigationIntentAt = undefined; + const reason = causeOf(SKIP_REASON, event.code, 'navigation-skipped'); + const redirect = this.consumePendingRedirect(); + if (redirect?.redirectedFrom) { + this.record(redirect, redirect.redirectedFrom, (scenario, metadata) => + scenario.cancel({ reason, metadata }) + ); + } + const navigation = this.take(event.id); + if (navigation?.route) { + this.record(navigation, navigation.route, (scenario, metadata) => + scenario.cancel({ reason, metadata }) + ); + } + return; + } + + if (event instanceof NavigationError) { + const navigation = this.take(event.id); + if (navigation) { + const failure = safeNavigationFailure(event.error, navigation.route !== undefined); + this.record(navigation, navigation.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => + scenario.fail({ ...failure, metadata }) + ); + } + } + } + + /** Starts and settles the scenario at once, backdated to the journey's start; once per journey. */ + private record( + navigation: TrackedNavigation, + route: string, + settle: (scenario: UwtScenario, metadata: Record) => void + ): void { + if (navigation.recorded) { + return; + } + navigation.recorded = true; + const metadata: Record = {}; + if (navigation.redirectedFrom) { + metadata['redirectedFrom'] = navigation.redirectedFrom; + } + if (navigation.recognizedAt !== undefined) { + metadata['resolveMs'] = Date.now() - navigation.recognizedAt; + } + settle( + this.scenarioTelemetry.start({ + name: NAVIGATION_SCENARIO, + route, + startedAt: navigation.startedAt + }), + metadata + ); + } + + /** The click time if fresh enough, else now; consumes the click. */ + private navigationStartedAt(): number { + const at = this.navigationIntentAt; + this.navigationIntentAt = undefined; + const now = Date.now(); + + return at === undefined || now - at > INTENT_MAX_AGE_MS ? now : at; + } + + private consumePendingRedirect(): RedirectCarry | undefined { + const redirect = this.pendingRedirect; + this.pendingRedirect = undefined; + return redirect; + } + + /** + * Records a hung navigation once as `timeout`; a late end then adds only its page view. Browser + * only and outside Angular, so it never delays app stability or server rendering. + */ + private armTimeout(navigationId: number, navigation: TrackedNavigation): void { + if (!this.isBrowser) { + return; + } + const remainingMs = Math.max(0, navigation.startedAt + NAVIGATION_TIMEOUT_MS - Date.now()); + navigation.timeoutHandle = this.zone.runOutsideAngular(() => + setTimeout(() => { + const hung = this.navigations.get(navigationId); + if (hung) { + this.record(hung, hung.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => + scenario.settle('timeout', { reason: 'navigation-timeout', metadata }) + ); + } + }, remainingMs) + ); + } + + private take(navigationId: number): TrackedNavigation | undefined { + const navigation = this.navigations.get(navigationId); + this.navigations.delete(navigationId); + clearTimeout(navigation?.timeoutHandle); + return navigation; + } +} + +/** + * Starts route-navigation telemetry before any navigation runs — including a blocking initial + * navigation (`withEnabledBlockingInitialNavigation()`), which starts before the root component + * exists. Add it next to `provideRouter(...)`. + */ +export function provideRouteNavigationTelemetry(): EnvironmentProviders { + return provideEnvironmentInitializer(() => inject(RouteNavigationTelemetryService).start()); +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.ts new file mode 100644 index 0000000..7164cf2 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.ts @@ -0,0 +1,182 @@ +import { Injectable } from '@angular/core'; +import type { + UwtRumAppMonitorConfig, + UwtRumBootstrap, + UwtRumCredentialsProvider +} from '@absaoss-cps/ngx-ui-watchtower/rum'; + +/** The app's broker: RUM app-monitor settings plus short-lived AWS credentials. */ +const RUM_BROKER_URL = '/rum/init'; + +/** + * Expected answer — adapt to the real one: + * + * ```json + * { + * "enabled": true, + * "config": { "applicationId": "…", "region": "eu-west-1", "applicationVersion": "1.4.0", + * "sessionSampleRate": 1, "telemetries": ["errors", "performance", "http"] }, + * "credentials": { "accessKeyId": "…", "secretAccessKey": "…", "sessionToken": "…", + * "expiration": "2026-10-08T12:00:00Z" } + * } + * ``` + */ +const REQUIRED_CONFIG = ['applicationId', 'region', 'applicationVersion'] as const; +const REQUIRED_CREDENTIALS = [ + 'accessKeyId', + 'secretAccessKey', + 'sessionToken', + 'expiration' +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Optional settings a broker can send as JSON, kept only when correctly typed. Functions, + * `RegExp`s and plugins (`pagesToInclude`, `eventPluginsToLoad`, …) can't travel as JSON. + */ +const NUMBER_SETTINGS = [ + 'sessionLengthSeconds', + 'sessionEventLimit', + 'userIdRetentionDays', + 'batchLimit', + 'dispatchInterval', + 'eventCacheSize', + 'candidatesCacheSize', + 'retries', + 'routeChangeComplete', + 'routeChangeTimeout' +] as const; +const BOOLEAN_SETTINGS = [ + 'suppressSessionStartEvent', + 'enableXRay', + 'enableW3CTraceId', + 'recordResourceUrl', + 'useBeacon', + 'signing', + 'allowCookies', + 'debug', + 'enableRumClient' +] as const; +const STRING_SETTINGS = ['endpoint', 'alias', 'client', 'releaseId'] as const; +const PAGE_ID_FORMATS = ['PATH', 'HASH', 'PATH_AND_HASH']; + +function isFlatRecord( + value: unknown, + isEntry: (entry: unknown) => entry is T +): value is Record { + return isRecord(value) && Object.values(value).every(isEntry); +} +const isPrimitive = (v: unknown): v is string | number | boolean => + ['string', 'number', 'boolean'].includes(typeof v); +const isString = (v: unknown): v is string => typeof v === 'string'; +const isStringOrBoolean = (v: unknown): v is string | boolean => + typeof v === 'string' || typeof v === 'boolean'; +/** A telemetry name (`'errors'`) or a `[name]` / `[name, options]` tuple (`['http', { … }]`). */ +const isTelemetry = (t: unknown): boolean => + isString(t) || + (Array.isArray(t) && + (t.length === 1 || t.length === 2) && + isString(t[0]) && + (t.length === 1 || isRecord(t[1]))); + +function optionalSettings(config: Record): Partial { + const settings: Record = {}; + const keep = (key: string, ok: boolean): void => { + if (ok) { + settings[key] = config[key]; + } + }; + for (const key of NUMBER_SETTINGS) { + keep(key, typeof config[key] === 'number' && Number.isFinite(config[key])); + } + for (const key of BOOLEAN_SETTINGS) { + keep(key, typeof config[key] === 'boolean'); + } + for (const key of STRING_SETTINGS) { + keep(key, isString(config[key]) && config[key] !== ''); + } + const rate = config['sessionSampleRate']; + keep('sessionSampleRate', typeof rate === 'number' && rate >= 0 && rate <= 1); + keep('pageIdFormat', PAGE_ID_FORMATS.includes(config['pageIdFormat'] as string)); + keep('sessionAttributes', isFlatRecord(config['sessionAttributes'], isPrimitive)); + keep('applicationAttributes', isFlatRecord(config['applicationAttributes'], isPrimitive)); + keep('headers', isFlatRecord(config['headers'], isString)); + keep('cookieAttributes', isFlatRecord(config['cookieAttributes'], isStringOrBoolean)); + const telemetries = config['telemetries']; + keep('telemetries', Array.isArray(telemetries) && telemetries.every(isTelemetry)); + const compression = config['compressionStrategy']; + if (isRecord(compression) && typeof compression['enabled'] === 'boolean') { + settings['compressionStrategy'] = { enabled: compression['enabled'] }; + } + return settings as Partial; +} + +function hasStrings( + value: unknown, + keys: readonly K[] +): value is Record & Record { + return ( + isRecord(value) && + keys.every((key) => typeof value[key] === 'string' && value[key] !== '') + ); +} + +/** + * Called at startup and ~5 min before each credential expiry, so the browser never holds a + * long-lived AWS identity. + */ +@Injectable({ providedIn: 'root' }) +export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { + /** + * `null` (RUM off for the session) when the broker turns RUM off or answers with anything + * incomplete, malformed or expired. A failed request throws instead: on a refresh the library + * then keeps the current credentials and retries in 30 s, where `null` would end RUM for the + * session. At startup both just leave RUM off. + */ + async load(): Promise { + const response = await fetch(RUM_BROKER_URL, { + headers: { Accept: 'application/json' }, + cache: 'no-store' // live AWS credentials + }); + if (!response.ok) { + throw new Error(`RUM broker answered HTTP ${response.status}`); + } + // A body that isn't JSON (a gateway's error page) rejects here: also a failed request. + const body: unknown = await response.json(); + + // Validate, don't cast: a half-filled answer would start a client that silently fails. + if (!isRecord(body) || body['enabled'] !== true) { + return null; + } + const { config, credentials } = body; + if ( + !hasStrings(config, REQUIRED_CONFIG) || + !hasStrings(credentials, REQUIRED_CREDENTIALS) || + // Unparseable or already expired: RUM would reject every dispatch. + !(Date.parse(credentials.expiration) > Date.now()) + ) { + return null; + } + + return { + config: { + ...optionalSettings(config), + applicationId: config.applicationId, + region: config.region, + applicationVersion: config.applicationVersion, + // Always: page views come from the route service, under the template — automatic ones + // would send the resolved path (/customers/42). + disableAutoPageView: true + }, + credentials: { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + expiration: credentials.expiration + } + }; + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/telemetry.schema.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/telemetry.schema.ts new file mode 100644 index 0000000..a464f78 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/telemetry.schema.ts @@ -0,0 +1,17 @@ +/** + * This application's telemetry vocabulary. + * + * Scenario, step and business event names are metric dimensions, declared + * once here rather than as free text at each call site. Once a registry has + * an entry, a name not listed in it is a compile error. + * + * No `UwtLoggerNames`: this app has no log backend. + */ +declare module '@absaoss-cps/ngx-ui-watchtower' { + interface UwtScenarioNames { + /** A router navigation, from click to activated route, under the final route template. */ + 'route-navigation': true; + } +} + +export {}; diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/index.html b/skills/ui-watchtower-integration/evals/files/wired-angular/src/index.html new file mode 100644 index 0000000..1e743a7 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/index.html @@ -0,0 +1,12 @@ + + + + + Customer portal + + + + + + + diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/main.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/main.ts new file mode 100644 index 0000000..190f341 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/styles.css b/skills/ui-watchtower-integration/evals/files/wired-angular/src/styles.css new file mode 100644 index 0000000..4b438c4 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/styles.css @@ -0,0 +1 @@ +/* Global styles */ diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.app.json b/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.app.json new file mode 100644 index 0000000..d5d9680 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.app.json @@ -0,0 +1,18 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "rootDir": "./src", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.json b/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.json new file mode 100644 index 0000000..d2fbb9c --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.json @@ -0,0 +1,31 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.spec.json b/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.spec.json new file mode 100644 index 0000000..4c1202f --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.spec.json @@ -0,0 +1,16 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "rootDir": "./src", + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/README.md b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/README.md new file mode 100644 index 0000000..20e279d --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/README.md @@ -0,0 +1,10 @@ +# wired-checkout-angular + +A small web shop that is already wired for `@absaoss-cps/ngx-ui-watchtower` (setup is done: providers, RUM +destination, route-navigation tracking, typed names file) but has **no scenario or BI instrumentation of its +own**. Three components in separate files make HTTP calls: the cart (loads the cart, applies a coupon), the +order summary (places the order) and a static confirmation page. + +Used by the `no-merged-telemetry-wrapper` eval of `ui-watchtower-integration`: several call sites that need +telemetry make a shared telemetry service tempting; the skill must use the scenario and BI services directly +where they are needed. diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/angular.json b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/angular.json new file mode 100644 index 0000000..d2677f8 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/angular.json @@ -0,0 +1,73 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm" + }, + "newProjectRoot": "projects", + "projects": { + "web-shop": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "allowedCommonJsDependencies": [ + "shimmer" + ], + "styles": [ + "src/styles.css" + ], + "security": { + "allowedHosts": [] + } + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "web-shop:build:production" + }, + "development": { + "buildTarget": "web-shop:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test" + } + } + } + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/package.json b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/package.json new file mode 100644 index 0000000..82beb82 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/package.json @@ -0,0 +1,30 @@ +{ + "name": "web-shop", + "version": "1.4.0", + "scripts": { + "start": "ng serve", + "build": "ng build", + "test": "ng test" + }, + "private": true, + "dependencies": { + "@absaoss-cps/ngx-ui-watchtower": "latest", + "@angular/common": "^22.2.0", + "@angular/compiler": "^22.2.0", + "@angular/core": "^22.2.0", + "@angular/platform-browser": "^22.2.0", + "@angular/router": "^22.2.0", + "aws-rum-web": "^3.2.1", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@types/node": "^20.17.19", + "@angular/build": "^22.2.0", + "@angular/cli": "^22.2.0", + "@angular/compiler-cli": "^22.2.0", + "jsdom": "^28.0.0", + "typescript": "~6.0.2", + "vitest": "^4.0.8" + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.config.ts b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.config.ts new file mode 100644 index 0000000..749f081 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.config.ts @@ -0,0 +1,52 @@ +import { ApplicationConfig, inject, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; +import { provideUwtTelemetry } from '@absaoss-cps/ngx-ui-watchtower'; +import { + UWT_RUM_CREDENTIALS_PROVIDER, + UwtRumTelemetrySink, + provideUwtTelemetryRumSink +} from '@absaoss-cps/ngx-ui-watchtower/rum'; +import { routes } from './app.routes'; +import { + provideRouteNavigationTelemetry, + ROUTE_PAGE_VIEW_RECORDER +} from './telemetry/route-navigation-telemetry.service'; +import { AppRumCredentialsProvider } from './telemetry/rum-credentials.provider'; +import './telemetry/telemetry.schema'; // side-effect import: the telemetry vocabulary + +/** Reported as the telemetry `version`; keep in step with package.json. */ +const APP_VERSION = '1.4.0'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes, withComponentInputBinding()), + // Route-navigation telemetry, started before any navigation (incl. the initial one). + provideRouteNavigationTelemetry(), + provideHttpClient(), + // No features (withScenarios / withBIEvents / withLogging / withRedaction): + // the library defaults fit, and there is no log backend. + provideUwtTelemetry({ + application: 'web-shop', + // The app has no environment files; dev server vs. production build is + // the only distinction available at runtime. + environment: isDevMode() ? 'dev' : 'prod', + version: APP_VERSION + }), + // This app's one destination: AWS CloudWatch RUM. Settings and short-lived + // credentials come from GET /rum/init; a failed fetch only disables RUM + // for the session. + provideUwtTelemetryRumSink(), + { provide: UWT_RUM_CREDENTIALS_PROVIDER, useExisting: AppRumCredentialsProvider }, + // Page views under the route template (/summary), never the + // resolved path; the credentials provider sets disableAutoPageView: true. + { + provide: ROUTE_PAGE_VIEW_RECORDER, + useFactory: () => { + const rum = inject(UwtRumTelemetrySink); + return (route: string) => rum.recordPageView(route); + } + } + ] +}; diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.routes.ts b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.routes.ts new file mode 100644 index 0000000..3448edc --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.routes.ts @@ -0,0 +1,14 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { path: '', pathMatch: 'full', redirectTo: 'cart' }, + { path: 'cart', loadComponent: () => import('./checkout/cart').then((m) => m.Cart) }, + { + path: 'summary', + loadComponent: () => import('./checkout/order-summary').then((m) => m.OrderSummary) + }, + { + path: 'confirmation', + loadComponent: () => import('./checkout/order-confirmation').then((m) => m.OrderConfirmation) + } +]; diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.spec.ts new file mode 100644 index 0000000..fa9ea43 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.spec.ts @@ -0,0 +1,29 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { + provideUwtTelemetry, + provideUwtTelemetrySink, + withScenarios +} from '@absaoss-cps/ngx-ui-watchtower'; +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + providers: [ + provideRouter([]), + // App starts route-navigation telemetry in its constructor. + provideUwtTelemetry( + { application: 'web-shop-test', environment: 'test', version: '0.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }) // no scenario timers left running in tests + ), + provideUwtTelemetrySink('noop') + ] + }).compileComponents(); + }); + + it('should create the app', () => { + expect(TestBed.createComponent(App).componentInstance).toBeTruthy(); + }); +}); diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.ts b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.ts new file mode 100644 index 0000000..5777efc --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.ts @@ -0,0 +1,20 @@ +import { Component, inject } from '@angular/core'; +import { RouterLink, RouterOutlet } from '@angular/router'; +import { RouteNavigationTelemetryService } from './telemetry/route-navigation-telemetry.service'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, RouterLink], + template: ` + + + ` +}) +export class App { + private readonly routeTelemetry = inject(RouteNavigationTelemetryService); + + /** Starts the navigation measurement at the click, not at NavigationStart. */ + protected markNavigationIntent(): void { + this.routeTelemetry.markNavigationIntent(); + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/checkout/cart.ts b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/checkout/cart.ts new file mode 100644 index 0000000..34ee4d2 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/checkout/cart.ts @@ -0,0 +1,49 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, inject, OnInit, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +interface CartLine { + sku: string; + name: string; + quantity: number; +} + +@Component({ + selector: 'app-cart', + imports: [RouterLink], + template: ` + @if (error()) { +

Could not load the cart.

+ } + @for (line of lines(); track line.sku) { +

{{ line.quantity }} x {{ line.name }}

+ } + + + @if (couponRejected()) { +

That coupon is not valid.

+ } + Continue to order summary + ` +}) +export class Cart implements OnInit { + private readonly http = inject(HttpClient); + protected readonly lines = signal([]); + protected readonly error = signal(false); + protected readonly couponRejected = signal(false); + + ngOnInit(): void { + this.http.get('/api/cart').subscribe({ + next: (rows) => this.lines.set(rows), + error: () => this.error.set(true) + }); + } + + applyCoupon(code: string): void { + this.couponRejected.set(false); + this.http.post('/api/cart/coupon', { code }).subscribe({ + next: (rows) => this.lines.set(rows), + error: () => this.couponRejected.set(true) + }); + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/checkout/order-confirmation.ts b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/checkout/order-confirmation.ts new file mode 100644 index 0000000..cbdb01d --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/checkout/order-confirmation.ts @@ -0,0 +1,7 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-order-confirmation', + template: `

Thanks for your order

` +}) +export class OrderConfirmation {} diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/checkout/order-summary.ts b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/checkout/order-summary.ts new file mode 100644 index 0000000..2a99566 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/checkout/order-summary.ts @@ -0,0 +1,32 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, inject, signal } from '@angular/core'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-order-summary', + template: ` +

Order summary

+ @if (error()) { +

Could not place the order.

+ } + + ` +}) +export class OrderSummary { + private readonly http = inject(HttpClient); + private readonly router = inject(Router); + protected readonly placing = signal(false); + protected readonly error = signal(false); + + placeOrder(): void { + this.placing.set(true); + this.error.set(false); + this.http.post<{ id: string }>('/api/orders', {}).subscribe({ + next: () => void this.router.navigate(['/confirmation']), + error: () => { + this.placing.set(false); + this.error.set(true); + } + }); + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/telemetry/route-navigation-telemetry.service.ts b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/telemetry/route-navigation-telemetry.service.ts new file mode 100644 index 0000000..9b41eed --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/telemetry/route-navigation-telemetry.service.ts @@ -0,0 +1,368 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + DestroyRef, + EnvironmentProviders, + inject, + Injectable, + InjectionToken, + NgZone, + PLATFORM_ID, + provideEnvironmentInitializer +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + ActivatedRouteSnapshot, + Event as RouterEvent, + NavigationCancel, + NavigationCancellationCode, + NavigationEnd, + NavigationError, + NavigationSkipped, + NavigationSkippedCode, + NavigationStart, + PRIMARY_OUTLET, + Router, + RoutesRecognized +} from '@angular/router'; +import { + UwtScenario, + UwtScenarioTelemetryService +} from '@absaoss-cps/ngx-ui-watchtower'; +// Side-effect import: the vocabulary declaring the names used below. +import './telemetry.schema'; + +/** + * Records one page view per completed navigation, under the route template. Bind it to + * `UwtRumTelemetrySink.recordPageView` only where RUM is the destination, with + * `disableAutoPageView: true` — automatic page views report the resolved path (`/customers/42`). + */ +export const ROUTE_PAGE_VIEW_RECORDER = new InjectionToken<(route: string) => void>( + 'ROUTE_PAGE_VIEW_RECORDER' +); + +const NAVIGATION_SCENARIO = 'route-navigation'; + +/** Beyond this, a recorded click is assumed not to have caused the navigation. */ +const INTENT_MAX_AGE_MS = 2_000; + +/** No end event in this window (a guard, resolver or chunk that never settles) → `timeout`. */ +const NAVIGATION_TIMEOUT_MS = 30_000; + +/** `route` of a navigation that failed before its URL matched a route. */ +const UNRECOGNIZED_ROUTE = '(unrecognized)'; + +/** Route `data` key naming a `matcher` route's segment — a matcher has no `path`. */ +const TELEMETRY_PATH_DATA_KEY = 'telemetryPath'; + +/** + * Low-cardinality causes from the router's `code`. Its `reason` text is never sent: empty in + * production, and it can contain URLs in development. + */ +const CANCEL_REASON: Partial> = { + [NavigationCancellationCode.Redirect]: 'redirect', + [NavigationCancellationCode.SupersededByNewNavigation]: 'superseded', + [NavigationCancellationCode.NoDataFromResolver]: 'no-data-from-resolver', + [NavigationCancellationCode.GuardRejected]: 'guard-rejected', + [NavigationCancellationCode.Aborted]: 'aborted' +}; + +/** Expected dead ends (`incomplete`), not the user leaving (`abandoned`). */ +const INCOMPLETE_CODES = new Set([ + NavigationCancellationCode.GuardRejected, + NavigationCancellationCode.NoDataFromResolver +]); + +/** As {@link CANCEL_REASON}, for a navigation the router never ran. */ +const SKIP_REASON: Partial> = { + [NavigationSkippedCode.IgnoredSameUrlNavigation]: 'same-url', + [NavigationSkippedCode.IgnoredByUrlHandlingStrategy]: 'url-handling-strategy' +}; + +/** `code` is optional, and a future Angular may add one not mapped here. */ +function causeOf( + causes: Partial>, + code: TCode | undefined, + fallback: string +): string { + return (code === undefined ? undefined : causes[code]) ?? fallback; +} + +/** Matched template from the primary outlet's `routeConfig` chain: `/customers/:id`. */ +function routeTemplateOf(root: ActivatedRouteSnapshot): string { + const parts: string[] = []; + for ( + let route: ActivatedRouteSnapshot | null = root; + route; + route = route.children.find((child) => child.outlet === PRIMARY_OUTLET) ?? null + ) { + const config = route.routeConfig; + if (!config) { + continue; + } + const path = config.matcher + ? ((config.data?.[TELEMETRY_PATH_DATA_KEY] as string | undefined) ?? '(matcher)') + : config.path; + if (path) { + parts.push(path); + } + } + return `/${parts.join('/')}`; +} + +/** A class-like name (`ChunkLoadError`); any other `name` could be free text. */ +const SAFE_ERROR_NAME = /^[A-Z][A-Za-z0-9]{0,63}$/; + +/** + * Router, resolver and lazy-chunk errors can quote URLs, parameters or user data, and path + * segments are not redacted — so keep only a class-like name and an HTTP status. + */ +function safeNavigationFailure( + error: unknown, + recognized: boolean +): { error: Error; statusCode?: number } { + const safe = new Error( + recognized ? 'Navigation failed' : 'Navigation failed before the URL matched a route' + ); + const { name, status } = (error ?? {}) as { name?: unknown; status?: unknown }; + if (typeof name === 'string' && SAFE_ERROR_NAME.test(name)) { + safe.name = name; + } + return { error: safe, statusCode: typeof status === 'number' ? status : undefined }; +} + +interface TrackedNavigation { + /** Epoch ms: the click, `NavigationStart`, or the start of a redirect chain. */ + startedAt: number; + /** Template a guard or resolver redirected away from. */ + redirectedFrom?: string; + /** Matched template, known from `RoutesRecognized`. */ + route?: string; + /** `RoutesRecognized` time — guards, resolvers and lazy components run after it. */ + recognizedAt?: number; + /** Already recorded (e.g. at its timeout): a late end adds only its page view. */ + recorded?: boolean; + timeoutHandle?: ReturnType; +} + +/** What a guard or resolver redirect carries into the next navigation. */ +type RedirectCarry = Pick; + +/** + * One `route-navigation` scenario per navigation, recorded when it ends — `route` can't change + * after a scenario starts, and only the end knows the final template — or at its timeout if it + * hasn't ended. Backdated to the click or the first `NavigationStart`. Start it with + * {@link provideRouteNavigationTelemetry}; call {@link markNavigationIntent} from click handlers + * that navigate. + */ +@Injectable({ providedIn: 'root' }) +export class RouteNavigationTelemetryService { + private readonly router = inject(Router); + private readonly scenarioTelemetry = inject(UwtScenarioTelemetryService); + private readonly recordPageView = inject(ROUTE_PAGE_VIEW_RECORDER, { optional: true }); + private readonly destroyRef = inject(DestroyRef); + private readonly zone = inject(NgZone); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + /** By navigation id, never one "current" field: navigations supersede each other. */ + private readonly navigations = new Map(); + + private pendingRedirect?: RedirectCarry; + private navigationIntentAt?: number; + + private started = false; + + /** Safe to call more than once. */ + start(): void { + if (this.started) { + return; + } + this.started = true; + + this.router.events + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => this.onRouterEvent(event)); + this.destroyRef.onDestroy(() => { + for (const navigation of this.navigations.values()) { + clearTimeout(navigation.timeoutHandle); + } + }); + } + + /** `NavigationStart` comes after event handling and change detection; count that wait too. */ + markNavigationIntent(): void { + this.navigationIntentAt = Date.now(); + } + + private onRouterEvent(event: RouterEvent): void { + if (event instanceof NavigationStart) { + const startedAt = this.navigationStartedAt(); + const redirect = this.consumePendingRedirect(); + const navigation: TrackedNavigation = { + startedAt: redirect?.startedAt ?? startedAt, + redirectedFrom: redirect?.redirectedFrom, + recorded: redirect?.recorded + }; + this.navigations.set(event.id, navigation); + if (!navigation.recorded) { + this.armTimeout(event.id, navigation); + } + return; + } + + if (event instanceof RoutesRecognized) { + const navigation = this.navigations.get(event.id); + if (navigation) { + navigation.route = routeTemplateOf(event.state.root); + navigation.recognizedAt = Date.now(); + } + return; + } + + if (event instanceof NavigationEnd) { + const navigation = this.take(event.id); + if (navigation?.route) { + this.record(navigation, navigation.route, (scenario, metadata) => + scenario.complete({ metadata }) + ); + this.recordPageView?.(navigation.route); + } + return; + } + + if (event instanceof NavigationCancel) { + const navigation = this.take(event.id); + if (!navigation) { + return; + } + if (event.code === NavigationCancellationCode.Redirect) { + // Restarts under a new id: carry the start so the journey stays one scenario. + this.pendingRedirect = { + startedAt: navigation.startedAt, + redirectedFrom: navigation.redirectedFrom ?? navigation.route, + recorded: navigation.recorded + }; + return; + } + // Superseded before its URL was recognized: nothing to attribute it to. + if (!navigation.route) { + return; + } + const reason = causeOf(CANCEL_REASON, event.code, 'navigation-cancelled'); + this.record(navigation, navigation.route, (scenario, metadata) => + event.code !== undefined && INCOMPLETE_CODES.has(event.code) + ? scenario.incomplete({ reason, metadata }) + : scenario.cancel({ reason, metadata }) + ); + return; + } + + if (event instanceof NavigationSkipped) { + // A redirect back to the current URL ends here, not in a NavigationStart: release the carried + // redirect, and drop the click intent so it can't backdate an unrelated navigation. + this.navigationIntentAt = undefined; + const reason = causeOf(SKIP_REASON, event.code, 'navigation-skipped'); + const redirect = this.consumePendingRedirect(); + if (redirect?.redirectedFrom) { + this.record(redirect, redirect.redirectedFrom, (scenario, metadata) => + scenario.cancel({ reason, metadata }) + ); + } + const navigation = this.take(event.id); + if (navigation?.route) { + this.record(navigation, navigation.route, (scenario, metadata) => + scenario.cancel({ reason, metadata }) + ); + } + return; + } + + if (event instanceof NavigationError) { + const navigation = this.take(event.id); + if (navigation) { + const failure = safeNavigationFailure(event.error, navigation.route !== undefined); + this.record(navigation, navigation.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => + scenario.fail({ ...failure, metadata }) + ); + } + } + } + + /** Starts and settles the scenario at once, backdated to the journey's start; once per journey. */ + private record( + navigation: TrackedNavigation, + route: string, + settle: (scenario: UwtScenario, metadata: Record) => void + ): void { + if (navigation.recorded) { + return; + } + navigation.recorded = true; + const metadata: Record = {}; + if (navigation.redirectedFrom) { + metadata['redirectedFrom'] = navigation.redirectedFrom; + } + if (navigation.recognizedAt !== undefined) { + metadata['resolveMs'] = Date.now() - navigation.recognizedAt; + } + settle( + this.scenarioTelemetry.start({ + name: NAVIGATION_SCENARIO, + route, + startedAt: navigation.startedAt + }), + metadata + ); + } + + /** The click time if fresh enough, else now; consumes the click. */ + private navigationStartedAt(): number { + const at = this.navigationIntentAt; + this.navigationIntentAt = undefined; + const now = Date.now(); + + return at === undefined || now - at > INTENT_MAX_AGE_MS ? now : at; + } + + private consumePendingRedirect(): RedirectCarry | undefined { + const redirect = this.pendingRedirect; + this.pendingRedirect = undefined; + return redirect; + } + + /** + * Records a hung navigation once as `timeout`; a late end then adds only its page view. Browser + * only and outside Angular, so it never delays app stability or server rendering. + */ + private armTimeout(navigationId: number, navigation: TrackedNavigation): void { + if (!this.isBrowser) { + return; + } + const remainingMs = Math.max(0, navigation.startedAt + NAVIGATION_TIMEOUT_MS - Date.now()); + navigation.timeoutHandle = this.zone.runOutsideAngular(() => + setTimeout(() => { + const hung = this.navigations.get(navigationId); + if (hung) { + this.record(hung, hung.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => + scenario.settle('timeout', { reason: 'navigation-timeout', metadata }) + ); + } + }, remainingMs) + ); + } + + private take(navigationId: number): TrackedNavigation | undefined { + const navigation = this.navigations.get(navigationId); + this.navigations.delete(navigationId); + clearTimeout(navigation?.timeoutHandle); + return navigation; + } +} + +/** + * Starts route-navigation telemetry before any navigation runs — including a blocking initial + * navigation (`withEnabledBlockingInitialNavigation()`), which starts before the root component + * exists. Add it next to `provideRouter(...)`. + */ +export function provideRouteNavigationTelemetry(): EnvironmentProviders { + return provideEnvironmentInitializer(() => inject(RouteNavigationTelemetryService).start()); +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/telemetry/rum-credentials.provider.ts b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/telemetry/rum-credentials.provider.ts new file mode 100644 index 0000000..7164cf2 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/telemetry/rum-credentials.provider.ts @@ -0,0 +1,182 @@ +import { Injectable } from '@angular/core'; +import type { + UwtRumAppMonitorConfig, + UwtRumBootstrap, + UwtRumCredentialsProvider +} from '@absaoss-cps/ngx-ui-watchtower/rum'; + +/** The app's broker: RUM app-monitor settings plus short-lived AWS credentials. */ +const RUM_BROKER_URL = '/rum/init'; + +/** + * Expected answer — adapt to the real one: + * + * ```json + * { + * "enabled": true, + * "config": { "applicationId": "…", "region": "eu-west-1", "applicationVersion": "1.4.0", + * "sessionSampleRate": 1, "telemetries": ["errors", "performance", "http"] }, + * "credentials": { "accessKeyId": "…", "secretAccessKey": "…", "sessionToken": "…", + * "expiration": "2026-10-08T12:00:00Z" } + * } + * ``` + */ +const REQUIRED_CONFIG = ['applicationId', 'region', 'applicationVersion'] as const; +const REQUIRED_CREDENTIALS = [ + 'accessKeyId', + 'secretAccessKey', + 'sessionToken', + 'expiration' +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Optional settings a broker can send as JSON, kept only when correctly typed. Functions, + * `RegExp`s and plugins (`pagesToInclude`, `eventPluginsToLoad`, …) can't travel as JSON. + */ +const NUMBER_SETTINGS = [ + 'sessionLengthSeconds', + 'sessionEventLimit', + 'userIdRetentionDays', + 'batchLimit', + 'dispatchInterval', + 'eventCacheSize', + 'candidatesCacheSize', + 'retries', + 'routeChangeComplete', + 'routeChangeTimeout' +] as const; +const BOOLEAN_SETTINGS = [ + 'suppressSessionStartEvent', + 'enableXRay', + 'enableW3CTraceId', + 'recordResourceUrl', + 'useBeacon', + 'signing', + 'allowCookies', + 'debug', + 'enableRumClient' +] as const; +const STRING_SETTINGS = ['endpoint', 'alias', 'client', 'releaseId'] as const; +const PAGE_ID_FORMATS = ['PATH', 'HASH', 'PATH_AND_HASH']; + +function isFlatRecord( + value: unknown, + isEntry: (entry: unknown) => entry is T +): value is Record { + return isRecord(value) && Object.values(value).every(isEntry); +} +const isPrimitive = (v: unknown): v is string | number | boolean => + ['string', 'number', 'boolean'].includes(typeof v); +const isString = (v: unknown): v is string => typeof v === 'string'; +const isStringOrBoolean = (v: unknown): v is string | boolean => + typeof v === 'string' || typeof v === 'boolean'; +/** A telemetry name (`'errors'`) or a `[name]` / `[name, options]` tuple (`['http', { … }]`). */ +const isTelemetry = (t: unknown): boolean => + isString(t) || + (Array.isArray(t) && + (t.length === 1 || t.length === 2) && + isString(t[0]) && + (t.length === 1 || isRecord(t[1]))); + +function optionalSettings(config: Record): Partial { + const settings: Record = {}; + const keep = (key: string, ok: boolean): void => { + if (ok) { + settings[key] = config[key]; + } + }; + for (const key of NUMBER_SETTINGS) { + keep(key, typeof config[key] === 'number' && Number.isFinite(config[key])); + } + for (const key of BOOLEAN_SETTINGS) { + keep(key, typeof config[key] === 'boolean'); + } + for (const key of STRING_SETTINGS) { + keep(key, isString(config[key]) && config[key] !== ''); + } + const rate = config['sessionSampleRate']; + keep('sessionSampleRate', typeof rate === 'number' && rate >= 0 && rate <= 1); + keep('pageIdFormat', PAGE_ID_FORMATS.includes(config['pageIdFormat'] as string)); + keep('sessionAttributes', isFlatRecord(config['sessionAttributes'], isPrimitive)); + keep('applicationAttributes', isFlatRecord(config['applicationAttributes'], isPrimitive)); + keep('headers', isFlatRecord(config['headers'], isString)); + keep('cookieAttributes', isFlatRecord(config['cookieAttributes'], isStringOrBoolean)); + const telemetries = config['telemetries']; + keep('telemetries', Array.isArray(telemetries) && telemetries.every(isTelemetry)); + const compression = config['compressionStrategy']; + if (isRecord(compression) && typeof compression['enabled'] === 'boolean') { + settings['compressionStrategy'] = { enabled: compression['enabled'] }; + } + return settings as Partial; +} + +function hasStrings( + value: unknown, + keys: readonly K[] +): value is Record & Record { + return ( + isRecord(value) && + keys.every((key) => typeof value[key] === 'string' && value[key] !== '') + ); +} + +/** + * Called at startup and ~5 min before each credential expiry, so the browser never holds a + * long-lived AWS identity. + */ +@Injectable({ providedIn: 'root' }) +export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { + /** + * `null` (RUM off for the session) when the broker turns RUM off or answers with anything + * incomplete, malformed or expired. A failed request throws instead: on a refresh the library + * then keeps the current credentials and retries in 30 s, where `null` would end RUM for the + * session. At startup both just leave RUM off. + */ + async load(): Promise { + const response = await fetch(RUM_BROKER_URL, { + headers: { Accept: 'application/json' }, + cache: 'no-store' // live AWS credentials + }); + if (!response.ok) { + throw new Error(`RUM broker answered HTTP ${response.status}`); + } + // A body that isn't JSON (a gateway's error page) rejects here: also a failed request. + const body: unknown = await response.json(); + + // Validate, don't cast: a half-filled answer would start a client that silently fails. + if (!isRecord(body) || body['enabled'] !== true) { + return null; + } + const { config, credentials } = body; + if ( + !hasStrings(config, REQUIRED_CONFIG) || + !hasStrings(credentials, REQUIRED_CREDENTIALS) || + // Unparseable or already expired: RUM would reject every dispatch. + !(Date.parse(credentials.expiration) > Date.now()) + ) { + return null; + } + + return { + config: { + ...optionalSettings(config), + applicationId: config.applicationId, + region: config.region, + applicationVersion: config.applicationVersion, + // Always: page views come from the route service, under the template — automatic ones + // would send the resolved path (/customers/42). + disableAutoPageView: true + }, + credentials: { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + expiration: credentials.expiration + } + }; + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/telemetry/telemetry.schema.ts b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/telemetry/telemetry.schema.ts new file mode 100644 index 0000000..a464f78 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/telemetry/telemetry.schema.ts @@ -0,0 +1,17 @@ +/** + * This application's telemetry vocabulary. + * + * Scenario, step and business event names are metric dimensions, declared + * once here rather than as free text at each call site. Once a registry has + * an entry, a name not listed in it is a compile error. + * + * No `UwtLoggerNames`: this app has no log backend. + */ +declare module '@absaoss-cps/ngx-ui-watchtower' { + interface UwtScenarioNames { + /** A router navigation, from click to activated route, under the final route template. */ + 'route-navigation': true; + } +} + +export {}; diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/index.html b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/index.html new file mode 100644 index 0000000..2fbc851 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/index.html @@ -0,0 +1,12 @@ + + + + + Web shop + + + + + + + diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/main.ts b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/main.ts new file mode 100644 index 0000000..190f341 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/styles.css b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/styles.css new file mode 100644 index 0000000..4b438c4 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/styles.css @@ -0,0 +1 @@ +/* Global styles */ diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/tsconfig.app.json b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/tsconfig.app.json new file mode 100644 index 0000000..d5d9680 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/tsconfig.app.json @@ -0,0 +1,18 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "rootDir": "./src", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/tsconfig.json b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/tsconfig.json new file mode 100644 index 0000000..d2fbb9c --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/tsconfig.json @@ -0,0 +1,31 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/tsconfig.spec.json b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/tsconfig.spec.json new file mode 100644 index 0000000..4c1202f --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-checkout-angular/tsconfig.spec.json @@ -0,0 +1,16 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "rootDir": "./src", + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/angular.json b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/angular.json new file mode 100644 index 0000000..28790e8 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/angular.json @@ -0,0 +1,73 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm" + }, + "newProjectRoot": "projects", + "projects": { + "customer-portal": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "allowedCommonJsDependencies": [ + "shimmer" + ], + "styles": [ + "src/styles.css" + ], + "security": { + "allowedHosts": [] + } + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "customer-portal:build:production" + }, + "development": { + "buildTarget": "customer-portal:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test" + } + } + } + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/package.json b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/package.json new file mode 100644 index 0000000..a84c3e7 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/package.json @@ -0,0 +1,30 @@ +{ + "name": "customer-portal", + "version": "1.4.0", + "scripts": { + "start": "ng serve", + "build": "ng build", + "test": "ng test" + }, + "private": true, + "dependencies": { + "@absaoss-cps/ngx-ui-watchtower": "latest", + "@angular/common": "^22.2.0", + "@angular/compiler": "^22.2.0", + "@angular/core": "^22.2.0", + "@angular/platform-browser": "^22.2.0", + "@angular/router": "^22.2.0", + "aws-rum-web": "^3.2.1", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@types/node": "^20.17.19", + "@angular/build": "^22.2.0", + "@angular/cli": "^22.2.0", + "@angular/compiler-cli": "^22.2.0", + "jsdom": "^28.0.0", + "typescript": "~6.0.2", + "vitest": "^4.0.8" + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.config.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.config.ts new file mode 100644 index 0000000..eccc9a5 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.config.ts @@ -0,0 +1,52 @@ +import { ApplicationConfig, inject, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; +import { provideUwtTelemetry } from '@absaoss-cps/ngx-ui-watchtower'; +import { + UWT_RUM_CREDENTIALS_PROVIDER, + UwtRumTelemetrySink, + provideUwtTelemetryRumSink +} from '@absaoss-cps/ngx-ui-watchtower/rum'; +import { routes } from './app.routes'; +import { + provideRouteNavigationTelemetry, + ROUTE_PAGE_VIEW_RECORDER +} from './telemetry/route-navigation-telemetry.service'; +import { AppRumCredentialsProvider } from './telemetry/rum-credentials.provider'; +import './telemetry/telemetry.schema'; // side-effect import: the telemetry vocabulary + +/** Reported as the telemetry `version`; keep in step with package.json. */ +const APP_VERSION = '1.4.0'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes, withComponentInputBinding()), + // Route-navigation telemetry, started before any navigation (incl. the initial one). + provideRouteNavigationTelemetry(), + provideHttpClient(), + // No features (withScenarios / withBIEvents / withLogging / withRedaction): + // the library defaults fit, and there is no log backend. + provideUwtTelemetry({ + application: 'customer-portal', + // The app has no environment files; dev server vs. production build is + // the only distinction available at runtime. + environment: isDevMode() ? 'dev' : 'prod', + version: APP_VERSION + }), + // This app's one destination: AWS CloudWatch RUM. Settings and short-lived + // credentials come from GET /rum/init; a failed fetch only disables RUM + // for the session. + provideUwtTelemetryRumSink(), + { provide: UWT_RUM_CREDENTIALS_PROVIDER, useExisting: AppRumCredentialsProvider }, + // Page views under the route template (/customers/:customerId), never the + // resolved path; the credentials provider sets disableAutoPageView: true. + { + provide: ROUTE_PAGE_VIEW_RECORDER, + useFactory: () => { + const rum = inject(UwtRumTelemetrySink); + return (route: string) => rum.recordPageView(route); + } + } + ] +}; diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.routes.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.routes.ts new file mode 100644 index 0000000..097aa25 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.routes.ts @@ -0,0 +1,13 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { path: '', pathMatch: 'full', redirectTo: 'customers' }, + { + path: 'customers', + loadComponent: () => import('./customers/customers').then((m) => m.Customers) + }, + { + path: 'customers/:customerId', + loadComponent: () => import('./customers/customer-detail').then((m) => m.CustomerDetail) + } +]; diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.spec.ts new file mode 100644 index 0000000..cca39b4 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.spec.ts @@ -0,0 +1,29 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { + provideUwtTelemetry, + provideUwtTelemetrySink, + withScenarios +} from '@absaoss-cps/ngx-ui-watchtower'; +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + providers: [ + provideRouter([]), + // App starts route-navigation telemetry in its constructor. + provideUwtTelemetry( + { application: 'customer-portal-test', environment: 'test', version: '0.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }) // no scenario timers left running in tests + ), + provideUwtTelemetrySink('noop') + ] + }).compileComponents(); + }); + + it('should create the app', () => { + expect(TestBed.createComponent(App).componentInstance).toBeTruthy(); + }); +}); diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.ts new file mode 100644 index 0000000..ab99d7a --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.ts @@ -0,0 +1,20 @@ +import { Component, inject } from '@angular/core'; +import { RouterLink, RouterOutlet } from '@angular/router'; +import { RouteNavigationTelemetryService } from './telemetry/route-navigation-telemetry.service'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, RouterLink], + template: ` + + + ` +}) +export class App { + private readonly routeTelemetry = inject(RouteNavigationTelemetryService); + + /** Starts the navigation measurement at the click, not at NavigationStart. */ + protected markNavigationIntent(): void { + this.routeTelemetry.markNavigationIntent(); + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customer-detail.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customer-detail.ts new file mode 100644 index 0000000..8855ad4 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customer-detail.ts @@ -0,0 +1,9 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'app-customer-detail', + template: `

Customer {{ customerId() }}

` +}) +export class CustomerDetail { + readonly customerId = input.required(); +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customers.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customers.spec.ts new file mode 100644 index 0000000..d260d14 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customers.spec.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { + provideUwtTelemetry, + provideUwtTelemetryDestination, + UwtTelemetrySink, + withScenarios +} from '@absaoss-cps/ngx-ui-watchtower'; +import { Customers } from './customers'; + +const events: { eventType: string; payload: Record }[] = []; + +@Injectable() +class RecordingSink extends UwtTelemetrySink { + override record(eventType: string, payload: object): void { + events.push({ eventType, payload: payload as Record }); + } + override recordError(): void {} + override getSessionId(): string | undefined { + return 'session-1'; + } + override setUserId(): void {} + override getUserId(): string | undefined { + return undefined; + } + override flush(): void {} +} + +describe('Customers customers-load scenario', () => { + let http: HttpTestingController; + + beforeEach(() => { + events.length = 0; + TestBed.configureTestingModule({ + imports: [Customers], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideUwtTelemetry( + { application: 'customer-portal-test', environment: 'test', version: '0.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }) + ), + provideUwtTelemetryDestination(RecordingSink) + ] + }); + http = TestBed.inject(HttpTestingController); + }); + + function load() { + const fixture = TestBed.createComponent(Customers); + fixture.detectChanges(); + return { fixture, request: http.expectOne('/api/customers') }; + } + + const loads = () => + events.map((e) => e.payload).filter((p) => p['scenarioName'] === 'customers-load'); + + it('records success with the row count', () => { + load().request.flush([{ id: '1', name: 'Ada' }]); + expect(loads()).toEqual([ + expect.objectContaining({ status: 'success', metadata: expect.objectContaining({ count: 1 }) }) + ]); + }); + + it('records an empty list as incomplete', () => { + load().request.flush([]); + expect(loads()).toEqual([expect.objectContaining({ status: 'incomplete', reason: 'no-results' })]); + }); + + it('records an HTTP error as failure with its status', () => { + load().request.flush('boom', { status: 503, statusText: 'Unavailable' }); + expect(loads()).toEqual([expect.objectContaining({ status: 'failure', statusCode: 503 })]); + // HttpErrorResponse.message quotes the request URL; only its class and status may be sent. + expect(JSON.stringify(events)).not.toContain('/api/customers'); + }); + + it('records leaving mid-load as abandoned', () => { + load().fixture.destroy(); + expect(loads()).toEqual([ + expect.objectContaining({ status: 'abandoned', reason: 'component-destroyed' }) + ]); + }); +}); diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customers.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customers.ts new file mode 100644 index 0000000..f9b2e64 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customers.ts @@ -0,0 +1,88 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { + UwtScenario, + UwtScenarioOutcome, + UwtScenarioTelemetryService +} from '@absaoss-cps/ngx-ui-watchtower'; +import '../telemetry/telemetry.schema'; + +/** A class-like name (`TimeoutError`); any other `name` could be free text. */ +const SAFE_ERROR_NAME = /^[A-Z][A-Za-z0-9]{0,63}$/; + +/** Error messages can quote URLs, ids or user data — keep only a class-like name and an HTTP status. */ +function safeHttpFailure(error: unknown): UwtScenarioOutcome { + const { name, status } = (error ?? {}) as { name?: unknown; status?: unknown }; + if (typeof status === 'number') { + return { + error: Object.assign(new Error('HTTP request failed'), { name: 'HttpErrorResponse' }), + statusCode: status + }; + } + const safe = new Error('Journey failed'); + if (typeof name === 'string' && SAFE_ERROR_NAME.test(name)) { + safe.name = name; + } + return { error: safe }; +} + +interface Customer { + id: string; + name: string; +} + +@Component({ + selector: 'app-customers', + template: ` + + @if (error()) { +

Could not load customers.

+ } + @for (c of customers(); track c.id) { +

{{ c.name }}

+ } + + ` +}) +export class Customers implements OnInit, OnDestroy { + private readonly http = inject(HttpClient); + private readonly scenarios = inject(UwtScenarioTelemetryService); + protected readonly customers = signal([]); + protected readonly error = signal(false); + private loadScenario?: UwtScenario; + + ngOnInit(): void { + const scenario = this.scenarios.start({ name: 'customers-load', feature: 'customers' }); + this.loadScenario = scenario; + scenario.step('fetch'); + this.http.get('/api/customers').subscribe({ + next: (rows) => { + this.customers.set(rows); + if (rows.length === 0) { + scenario.incomplete({ reason: 'no-results' }); + return; + } + scenario.complete({ metadata: { count: rows.length } }); + }, + error: (error: unknown) => { + this.error.set(true); + scenario.fail(safeHttpFailure(error)); + } + }); + } + + ngOnDestroy(): void { + this.loadScenario?.cancel({ reason: 'component-destroyed' }); + } + + search(query: string): void { + this.http.get('/api/customers', { params: { q: query } }).subscribe({ + next: (rows) => this.customers.set(rows), + error: () => this.error.set(true) + }); + } + + exportCsv(): void { + window.location.href = '/api/customers/export?format=csv'; + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.ts new file mode 100644 index 0000000..9b41eed --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.ts @@ -0,0 +1,368 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + DestroyRef, + EnvironmentProviders, + inject, + Injectable, + InjectionToken, + NgZone, + PLATFORM_ID, + provideEnvironmentInitializer +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + ActivatedRouteSnapshot, + Event as RouterEvent, + NavigationCancel, + NavigationCancellationCode, + NavigationEnd, + NavigationError, + NavigationSkipped, + NavigationSkippedCode, + NavigationStart, + PRIMARY_OUTLET, + Router, + RoutesRecognized +} from '@angular/router'; +import { + UwtScenario, + UwtScenarioTelemetryService +} from '@absaoss-cps/ngx-ui-watchtower'; +// Side-effect import: the vocabulary declaring the names used below. +import './telemetry.schema'; + +/** + * Records one page view per completed navigation, under the route template. Bind it to + * `UwtRumTelemetrySink.recordPageView` only where RUM is the destination, with + * `disableAutoPageView: true` — automatic page views report the resolved path (`/customers/42`). + */ +export const ROUTE_PAGE_VIEW_RECORDER = new InjectionToken<(route: string) => void>( + 'ROUTE_PAGE_VIEW_RECORDER' +); + +const NAVIGATION_SCENARIO = 'route-navigation'; + +/** Beyond this, a recorded click is assumed not to have caused the navigation. */ +const INTENT_MAX_AGE_MS = 2_000; + +/** No end event in this window (a guard, resolver or chunk that never settles) → `timeout`. */ +const NAVIGATION_TIMEOUT_MS = 30_000; + +/** `route` of a navigation that failed before its URL matched a route. */ +const UNRECOGNIZED_ROUTE = '(unrecognized)'; + +/** Route `data` key naming a `matcher` route's segment — a matcher has no `path`. */ +const TELEMETRY_PATH_DATA_KEY = 'telemetryPath'; + +/** + * Low-cardinality causes from the router's `code`. Its `reason` text is never sent: empty in + * production, and it can contain URLs in development. + */ +const CANCEL_REASON: Partial> = { + [NavigationCancellationCode.Redirect]: 'redirect', + [NavigationCancellationCode.SupersededByNewNavigation]: 'superseded', + [NavigationCancellationCode.NoDataFromResolver]: 'no-data-from-resolver', + [NavigationCancellationCode.GuardRejected]: 'guard-rejected', + [NavigationCancellationCode.Aborted]: 'aborted' +}; + +/** Expected dead ends (`incomplete`), not the user leaving (`abandoned`). */ +const INCOMPLETE_CODES = new Set([ + NavigationCancellationCode.GuardRejected, + NavigationCancellationCode.NoDataFromResolver +]); + +/** As {@link CANCEL_REASON}, for a navigation the router never ran. */ +const SKIP_REASON: Partial> = { + [NavigationSkippedCode.IgnoredSameUrlNavigation]: 'same-url', + [NavigationSkippedCode.IgnoredByUrlHandlingStrategy]: 'url-handling-strategy' +}; + +/** `code` is optional, and a future Angular may add one not mapped here. */ +function causeOf( + causes: Partial>, + code: TCode | undefined, + fallback: string +): string { + return (code === undefined ? undefined : causes[code]) ?? fallback; +} + +/** Matched template from the primary outlet's `routeConfig` chain: `/customers/:id`. */ +function routeTemplateOf(root: ActivatedRouteSnapshot): string { + const parts: string[] = []; + for ( + let route: ActivatedRouteSnapshot | null = root; + route; + route = route.children.find((child) => child.outlet === PRIMARY_OUTLET) ?? null + ) { + const config = route.routeConfig; + if (!config) { + continue; + } + const path = config.matcher + ? ((config.data?.[TELEMETRY_PATH_DATA_KEY] as string | undefined) ?? '(matcher)') + : config.path; + if (path) { + parts.push(path); + } + } + return `/${parts.join('/')}`; +} + +/** A class-like name (`ChunkLoadError`); any other `name` could be free text. */ +const SAFE_ERROR_NAME = /^[A-Z][A-Za-z0-9]{0,63}$/; + +/** + * Router, resolver and lazy-chunk errors can quote URLs, parameters or user data, and path + * segments are not redacted — so keep only a class-like name and an HTTP status. + */ +function safeNavigationFailure( + error: unknown, + recognized: boolean +): { error: Error; statusCode?: number } { + const safe = new Error( + recognized ? 'Navigation failed' : 'Navigation failed before the URL matched a route' + ); + const { name, status } = (error ?? {}) as { name?: unknown; status?: unknown }; + if (typeof name === 'string' && SAFE_ERROR_NAME.test(name)) { + safe.name = name; + } + return { error: safe, statusCode: typeof status === 'number' ? status : undefined }; +} + +interface TrackedNavigation { + /** Epoch ms: the click, `NavigationStart`, or the start of a redirect chain. */ + startedAt: number; + /** Template a guard or resolver redirected away from. */ + redirectedFrom?: string; + /** Matched template, known from `RoutesRecognized`. */ + route?: string; + /** `RoutesRecognized` time — guards, resolvers and lazy components run after it. */ + recognizedAt?: number; + /** Already recorded (e.g. at its timeout): a late end adds only its page view. */ + recorded?: boolean; + timeoutHandle?: ReturnType; +} + +/** What a guard or resolver redirect carries into the next navigation. */ +type RedirectCarry = Pick; + +/** + * One `route-navigation` scenario per navigation, recorded when it ends — `route` can't change + * after a scenario starts, and only the end knows the final template — or at its timeout if it + * hasn't ended. Backdated to the click or the first `NavigationStart`. Start it with + * {@link provideRouteNavigationTelemetry}; call {@link markNavigationIntent} from click handlers + * that navigate. + */ +@Injectable({ providedIn: 'root' }) +export class RouteNavigationTelemetryService { + private readonly router = inject(Router); + private readonly scenarioTelemetry = inject(UwtScenarioTelemetryService); + private readonly recordPageView = inject(ROUTE_PAGE_VIEW_RECORDER, { optional: true }); + private readonly destroyRef = inject(DestroyRef); + private readonly zone = inject(NgZone); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + /** By navigation id, never one "current" field: navigations supersede each other. */ + private readonly navigations = new Map(); + + private pendingRedirect?: RedirectCarry; + private navigationIntentAt?: number; + + private started = false; + + /** Safe to call more than once. */ + start(): void { + if (this.started) { + return; + } + this.started = true; + + this.router.events + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => this.onRouterEvent(event)); + this.destroyRef.onDestroy(() => { + for (const navigation of this.navigations.values()) { + clearTimeout(navigation.timeoutHandle); + } + }); + } + + /** `NavigationStart` comes after event handling and change detection; count that wait too. */ + markNavigationIntent(): void { + this.navigationIntentAt = Date.now(); + } + + private onRouterEvent(event: RouterEvent): void { + if (event instanceof NavigationStart) { + const startedAt = this.navigationStartedAt(); + const redirect = this.consumePendingRedirect(); + const navigation: TrackedNavigation = { + startedAt: redirect?.startedAt ?? startedAt, + redirectedFrom: redirect?.redirectedFrom, + recorded: redirect?.recorded + }; + this.navigations.set(event.id, navigation); + if (!navigation.recorded) { + this.armTimeout(event.id, navigation); + } + return; + } + + if (event instanceof RoutesRecognized) { + const navigation = this.navigations.get(event.id); + if (navigation) { + navigation.route = routeTemplateOf(event.state.root); + navigation.recognizedAt = Date.now(); + } + return; + } + + if (event instanceof NavigationEnd) { + const navigation = this.take(event.id); + if (navigation?.route) { + this.record(navigation, navigation.route, (scenario, metadata) => + scenario.complete({ metadata }) + ); + this.recordPageView?.(navigation.route); + } + return; + } + + if (event instanceof NavigationCancel) { + const navigation = this.take(event.id); + if (!navigation) { + return; + } + if (event.code === NavigationCancellationCode.Redirect) { + // Restarts under a new id: carry the start so the journey stays one scenario. + this.pendingRedirect = { + startedAt: navigation.startedAt, + redirectedFrom: navigation.redirectedFrom ?? navigation.route, + recorded: navigation.recorded + }; + return; + } + // Superseded before its URL was recognized: nothing to attribute it to. + if (!navigation.route) { + return; + } + const reason = causeOf(CANCEL_REASON, event.code, 'navigation-cancelled'); + this.record(navigation, navigation.route, (scenario, metadata) => + event.code !== undefined && INCOMPLETE_CODES.has(event.code) + ? scenario.incomplete({ reason, metadata }) + : scenario.cancel({ reason, metadata }) + ); + return; + } + + if (event instanceof NavigationSkipped) { + // A redirect back to the current URL ends here, not in a NavigationStart: release the carried + // redirect, and drop the click intent so it can't backdate an unrelated navigation. + this.navigationIntentAt = undefined; + const reason = causeOf(SKIP_REASON, event.code, 'navigation-skipped'); + const redirect = this.consumePendingRedirect(); + if (redirect?.redirectedFrom) { + this.record(redirect, redirect.redirectedFrom, (scenario, metadata) => + scenario.cancel({ reason, metadata }) + ); + } + const navigation = this.take(event.id); + if (navigation?.route) { + this.record(navigation, navigation.route, (scenario, metadata) => + scenario.cancel({ reason, metadata }) + ); + } + return; + } + + if (event instanceof NavigationError) { + const navigation = this.take(event.id); + if (navigation) { + const failure = safeNavigationFailure(event.error, navigation.route !== undefined); + this.record(navigation, navigation.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => + scenario.fail({ ...failure, metadata }) + ); + } + } + } + + /** Starts and settles the scenario at once, backdated to the journey's start; once per journey. */ + private record( + navigation: TrackedNavigation, + route: string, + settle: (scenario: UwtScenario, metadata: Record) => void + ): void { + if (navigation.recorded) { + return; + } + navigation.recorded = true; + const metadata: Record = {}; + if (navigation.redirectedFrom) { + metadata['redirectedFrom'] = navigation.redirectedFrom; + } + if (navigation.recognizedAt !== undefined) { + metadata['resolveMs'] = Date.now() - navigation.recognizedAt; + } + settle( + this.scenarioTelemetry.start({ + name: NAVIGATION_SCENARIO, + route, + startedAt: navigation.startedAt + }), + metadata + ); + } + + /** The click time if fresh enough, else now; consumes the click. */ + private navigationStartedAt(): number { + const at = this.navigationIntentAt; + this.navigationIntentAt = undefined; + const now = Date.now(); + + return at === undefined || now - at > INTENT_MAX_AGE_MS ? now : at; + } + + private consumePendingRedirect(): RedirectCarry | undefined { + const redirect = this.pendingRedirect; + this.pendingRedirect = undefined; + return redirect; + } + + /** + * Records a hung navigation once as `timeout`; a late end then adds only its page view. Browser + * only and outside Angular, so it never delays app stability or server rendering. + */ + private armTimeout(navigationId: number, navigation: TrackedNavigation): void { + if (!this.isBrowser) { + return; + } + const remainingMs = Math.max(0, navigation.startedAt + NAVIGATION_TIMEOUT_MS - Date.now()); + navigation.timeoutHandle = this.zone.runOutsideAngular(() => + setTimeout(() => { + const hung = this.navigations.get(navigationId); + if (hung) { + this.record(hung, hung.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => + scenario.settle('timeout', { reason: 'navigation-timeout', metadata }) + ); + } + }, remainingMs) + ); + } + + private take(navigationId: number): TrackedNavigation | undefined { + const navigation = this.navigations.get(navigationId); + this.navigations.delete(navigationId); + clearTimeout(navigation?.timeoutHandle); + return navigation; + } +} + +/** + * Starts route-navigation telemetry before any navigation runs — including a blocking initial + * navigation (`withEnabledBlockingInitialNavigation()`), which starts before the root component + * exists. Add it next to `provideRouter(...)`. + */ +export function provideRouteNavigationTelemetry(): EnvironmentProviders { + return provideEnvironmentInitializer(() => inject(RouteNavigationTelemetryService).start()); +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.ts new file mode 100644 index 0000000..7164cf2 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.ts @@ -0,0 +1,182 @@ +import { Injectable } from '@angular/core'; +import type { + UwtRumAppMonitorConfig, + UwtRumBootstrap, + UwtRumCredentialsProvider +} from '@absaoss-cps/ngx-ui-watchtower/rum'; + +/** The app's broker: RUM app-monitor settings plus short-lived AWS credentials. */ +const RUM_BROKER_URL = '/rum/init'; + +/** + * Expected answer — adapt to the real one: + * + * ```json + * { + * "enabled": true, + * "config": { "applicationId": "…", "region": "eu-west-1", "applicationVersion": "1.4.0", + * "sessionSampleRate": 1, "telemetries": ["errors", "performance", "http"] }, + * "credentials": { "accessKeyId": "…", "secretAccessKey": "…", "sessionToken": "…", + * "expiration": "2026-10-08T12:00:00Z" } + * } + * ``` + */ +const REQUIRED_CONFIG = ['applicationId', 'region', 'applicationVersion'] as const; +const REQUIRED_CREDENTIALS = [ + 'accessKeyId', + 'secretAccessKey', + 'sessionToken', + 'expiration' +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Optional settings a broker can send as JSON, kept only when correctly typed. Functions, + * `RegExp`s and plugins (`pagesToInclude`, `eventPluginsToLoad`, …) can't travel as JSON. + */ +const NUMBER_SETTINGS = [ + 'sessionLengthSeconds', + 'sessionEventLimit', + 'userIdRetentionDays', + 'batchLimit', + 'dispatchInterval', + 'eventCacheSize', + 'candidatesCacheSize', + 'retries', + 'routeChangeComplete', + 'routeChangeTimeout' +] as const; +const BOOLEAN_SETTINGS = [ + 'suppressSessionStartEvent', + 'enableXRay', + 'enableW3CTraceId', + 'recordResourceUrl', + 'useBeacon', + 'signing', + 'allowCookies', + 'debug', + 'enableRumClient' +] as const; +const STRING_SETTINGS = ['endpoint', 'alias', 'client', 'releaseId'] as const; +const PAGE_ID_FORMATS = ['PATH', 'HASH', 'PATH_AND_HASH']; + +function isFlatRecord( + value: unknown, + isEntry: (entry: unknown) => entry is T +): value is Record { + return isRecord(value) && Object.values(value).every(isEntry); +} +const isPrimitive = (v: unknown): v is string | number | boolean => + ['string', 'number', 'boolean'].includes(typeof v); +const isString = (v: unknown): v is string => typeof v === 'string'; +const isStringOrBoolean = (v: unknown): v is string | boolean => + typeof v === 'string' || typeof v === 'boolean'; +/** A telemetry name (`'errors'`) or a `[name]` / `[name, options]` tuple (`['http', { … }]`). */ +const isTelemetry = (t: unknown): boolean => + isString(t) || + (Array.isArray(t) && + (t.length === 1 || t.length === 2) && + isString(t[0]) && + (t.length === 1 || isRecord(t[1]))); + +function optionalSettings(config: Record): Partial { + const settings: Record = {}; + const keep = (key: string, ok: boolean): void => { + if (ok) { + settings[key] = config[key]; + } + }; + for (const key of NUMBER_SETTINGS) { + keep(key, typeof config[key] === 'number' && Number.isFinite(config[key])); + } + for (const key of BOOLEAN_SETTINGS) { + keep(key, typeof config[key] === 'boolean'); + } + for (const key of STRING_SETTINGS) { + keep(key, isString(config[key]) && config[key] !== ''); + } + const rate = config['sessionSampleRate']; + keep('sessionSampleRate', typeof rate === 'number' && rate >= 0 && rate <= 1); + keep('pageIdFormat', PAGE_ID_FORMATS.includes(config['pageIdFormat'] as string)); + keep('sessionAttributes', isFlatRecord(config['sessionAttributes'], isPrimitive)); + keep('applicationAttributes', isFlatRecord(config['applicationAttributes'], isPrimitive)); + keep('headers', isFlatRecord(config['headers'], isString)); + keep('cookieAttributes', isFlatRecord(config['cookieAttributes'], isStringOrBoolean)); + const telemetries = config['telemetries']; + keep('telemetries', Array.isArray(telemetries) && telemetries.every(isTelemetry)); + const compression = config['compressionStrategy']; + if (isRecord(compression) && typeof compression['enabled'] === 'boolean') { + settings['compressionStrategy'] = { enabled: compression['enabled'] }; + } + return settings as Partial; +} + +function hasStrings( + value: unknown, + keys: readonly K[] +): value is Record & Record { + return ( + isRecord(value) && + keys.every((key) => typeof value[key] === 'string' && value[key] !== '') + ); +} + +/** + * Called at startup and ~5 min before each credential expiry, so the browser never holds a + * long-lived AWS identity. + */ +@Injectable({ providedIn: 'root' }) +export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { + /** + * `null` (RUM off for the session) when the broker turns RUM off or answers with anything + * incomplete, malformed or expired. A failed request throws instead: on a refresh the library + * then keeps the current credentials and retries in 30 s, where `null` would end RUM for the + * session. At startup both just leave RUM off. + */ + async load(): Promise { + const response = await fetch(RUM_BROKER_URL, { + headers: { Accept: 'application/json' }, + cache: 'no-store' // live AWS credentials + }); + if (!response.ok) { + throw new Error(`RUM broker answered HTTP ${response.status}`); + } + // A body that isn't JSON (a gateway's error page) rejects here: also a failed request. + const body: unknown = await response.json(); + + // Validate, don't cast: a half-filled answer would start a client that silently fails. + if (!isRecord(body) || body['enabled'] !== true) { + return null; + } + const { config, credentials } = body; + if ( + !hasStrings(config, REQUIRED_CONFIG) || + !hasStrings(credentials, REQUIRED_CREDENTIALS) || + // Unparseable or already expired: RUM would reject every dispatch. + !(Date.parse(credentials.expiration) > Date.now()) + ) { + return null; + } + + return { + config: { + ...optionalSettings(config), + applicationId: config.applicationId, + region: config.region, + applicationVersion: config.applicationVersion, + // Always: page views come from the route service, under the template — automatic ones + // would send the resolved path (/customers/42). + disableAutoPageView: true + }, + credentials: { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + expiration: credentials.expiration + } + }; + } +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/telemetry.schema.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/telemetry.schema.ts new file mode 100644 index 0000000..ea05ff9 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/telemetry.schema.ts @@ -0,0 +1,25 @@ +/** + * This application's telemetry vocabulary. + * + * Scenario, step and business event names are metric dimensions, declared + * once here rather than as free text at each call site. Once a registry has + * an entry, a name not listed in it is a compile error. + * + * No `UwtLoggerNames`: this app has no log backend. + */ +declare module '@absaoss-cps/ngx-ui-watchtower' { + interface UwtScenarioNames { + /** A router navigation, from click to activated route, under the final route template. */ + 'route-navigation': true; + + /** Loading the customer list, from request to rows shown. */ + 'customers-load': true; + } + + interface UwtScenarioSteps { + /** Fetching data from the API. */ + fetch: true; + } +} + +export {}; diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/index.html b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/index.html new file mode 100644 index 0000000..1e743a7 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/index.html @@ -0,0 +1,12 @@ + + + + + Customer portal + + + + + + + diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/main.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/main.ts new file mode 100644 index 0000000..190f341 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/styles.css b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/styles.css new file mode 100644 index 0000000..4b438c4 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/styles.css @@ -0,0 +1 @@ +/* Global styles */ diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.app.json b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.app.json new file mode 100644 index 0000000..d5d9680 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.app.json @@ -0,0 +1,18 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "rootDir": "./src", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.json b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.json new file mode 100644 index 0000000..d2fbb9c --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.json @@ -0,0 +1,31 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.spec.json b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.spec.json new file mode 100644 index 0000000..4c1202f --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.spec.json @@ -0,0 +1,16 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "rootDir": "./src", + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} diff --git a/skills/ui-watchtower-integration/evals/trigger-eval.json b/skills/ui-watchtower-integration/evals/trigger-eval.json new file mode 100644 index 0000000..2bac81b --- /dev/null +++ b/skills/ui-watchtower-integration/evals/trigger-eval.json @@ -0,0 +1,54 @@ +[ + { + "query": "Instrument the checkout flow as a scenario and add BI events for the main buttons — watchtower is already set up.", + "should_trigger": true + }, + { + "query": "We want to know if customer search is slow; add telemetry for it (ngx-ui-watchtower is already wired).", + "should_trigger": true + }, + { + "query": "Which user journeys should we measure in this app? Telemetry is wired with ngx-ui-watchtower — help us add them.", + "should_trigger": true + }, + { + "query": "Track how often people use the new reporting export, using the watchtower BI events.", + "should_trigger": true + }, + { + "query": "Measure the file upload journey in our Angular app, including failures and cancellations, with ngx-ui-watchtower scenarios.", + "should_trigger": true + }, + { + "query": "Add journey telemetry for the onboarding wizard; the watchtower setup is already done.", + "should_trigger": true + }, + { + "query": "Install and wire @absaoss-cps/ngx-ui-watchtower with the CloudWatch RUM sink in our Angular project.", + "should_trigger": false + }, + { + "query": "Replace our hand-written RumService with @absaoss-cps/ngx-ui-watchtower.", + "should_trigger": false + }, + { + "query": "Wire UI telemetry into our micro-frontend shell so the fragments can forward to it.", + "should_trigger": false + }, + { + "query": "Build a CloudWatch dashboard for our RUM metrics.", + "should_trigger": false + }, + { + "query": "Add Google Analytics to the React marketing site.", + "should_trigger": false + }, + { + "query": "Fix the failing Jest test in the payments service.", + "should_trigger": false + }, + { + "query": "Turn off ngx-ui-watchtower telemetry in our dev and test environments; keep it on in prod.", + "should_trigger": false + } +] diff --git a/skills/ui-watchtower-integration/references/instrumentation-patterns.md b/skills/ui-watchtower-integration/references/instrumentation-patterns.md new file mode 100644 index 0000000..3d50117 --- /dev/null +++ b/skills/ui-watchtower-integration/references/instrumentation-patterns.md @@ -0,0 +1,251 @@ +# Instrumentation patterns + +How to choose, implement and settle scenarios and BI events. + +## Contents + +1. [Finding candidates](#1-finding-candidates) +2. [Choosing the settle status](#2-choosing-the-settle-status) +3. [Scenario shapes](#3-scenario-shapes) +4. [RxJS traps](#4-rxjs-traps) +5. [Cleanup on destroy](#5-cleanup-on-destroy) +6. [BI events](#6-bi-events) +7. [Names, metadata and privacy](#7-names-metadata-and-privacy) +8. [Starter catalogue](#8-starter-catalogue) + +## 1. Finding candidates + +| Look for | Becomes | Service | +|------------------------------------------------------------------|-------------------------------------------------------------|-------------------------------| +| An `await` / subscription the user waits on before seeing a result | Scenario (route load, gating fetch, submit, upload, export) | `UwtScenarioTelemetryService` | +| A meaningful or irreversible click; a setting change | BI event | `UwtBITelemetryService` | +| A `catch` / `error` handler that ends a journey | That scenario's `fail(safeHttpFailure(error))` (§2) | the scenario | +| A guard, empty result, feature flag routing elsewhere | That scenario's `incomplete({ reason })` | the scenario | +| Component teardown, supersession by a newer request | That scenario's `cancel({ reason })` | the scenario | + +**Scenario, BI, or both.** Ask what question the telemetry answers: "did it work and how long did it +take" is a scenario; "did it happen, and how often" is a BI event. A click inside a journey is both — +the scenario for the journey, a BI event for the click, linked with `{ scenarioId: scenario.id }`. +Each call site injects only the service(s) it uses (`inject(UwtScenarioTelemetryService)`, +`inject(UwtBITelemetryService)`); there is no shared telemetry service between them. + +Skip: anything that fires continuously (scroll, mousemove, per-keystroke), purely decorative UI, +and work the user never waits for. + +## 2. Choosing the settle status + +| Status | Call | Means | Response | +|--------------|------------------------|-----------------------------------------------------------------------------|-----------------------------------| +| `success` | `complete()` | The journey reached its goal | — | +| `failure` | `fail(outcome)` | A defect: the journey broke (HTTP: `safeHttpFailure`, below) | Investigate; alert on rate | +| `abandoned` | `cancel({ reason })` | No longer relevant: navigated away, superseded, component destroyed | Engagement signal, **not** a bug | +| `incomplete` | `incomplete(outcome?)` | An expected path that didn't reach the goal: no results, guard declined | Product signal | +| `timeout` | automatic | Never settled within `timeoutMs` | Usually a missed settle path | + +Keeping these apart is what makes the failure rate usable for alerting. Prefer the named methods +over `settle(status, …)` so every failure path is findable by searching for `.fail(`. The first +settle wins: a `catch` calling `fail()` followed by a `finally` calling `complete()` records the +failure. + +On `complete`, add the one number that says how much happened (rows returned, file size) when the +screen has one: it tells a fast empty list from a fast full one. + +Use a small, consistent set of `reason` strings — they are grouping keys: `'component-destroyed'`, +`'superseded'`, `'user-cancelled'`, `'no-results'`, `'guard-rejected'`. + +Pass a `statusCode` with `fail()` so failures group by category — but **never a raw +`HttpErrorResponse`** (to `fail()` or `logger.error()`): its message quotes the request URL +(`/api/customers/42`), and redaction never scrubs path segments. Any other error's message is free +text and is sent as is (only URL query strings are stripped). Keep a class-like name and the status: + +```ts +/** A class-like name (`TimeoutError`); any other `name` could be free text. */ +const SAFE_ERROR_NAME = /^[A-Z][A-Za-z0-9]{0,63}$/; + +/** Error messages can quote URLs, ids or user data — keep only a class-like name and an HTTP status. */ +function safeHttpFailure(error: unknown): UwtScenarioOutcome { + const { name, status } = (error ?? {}) as { name?: unknown; status?: unknown }; + if (typeof status === 'number') { + return { + error: Object.assign(new Error('HTTP request failed'), { name: 'HttpErrorResponse' }), + statusCode: status + }; + } + const safe = new Error('Journey failed'); + if (typeof name === 'string' && SAFE_ERROR_NAME.test(name)) { + safe.name = name; + } + return { error: safe }; +} +``` + +## 3. Scenario shapes + +**Promise / async**: + +```ts +async load(): Promise { + const scenario = this.scenarios.start({ name: 'load-customers', feature: 'customers' }); + try { + scenario.step('fetch'); + const rows = await firstValueFrom(this.api.customers()); + scenario.step('render'); + this.rows.set(rows); + if (rows.length === 0) { + scenario.incomplete({ reason: 'no-results' }); + return; + } + scenario.complete({ metadata: { count: rows.length } }); + } catch (error) { + scenario.fail(safeHttpFailure(error)); + } +} +``` + +**Observable** — `traceScenario` settles on completion, error or early unsubscribe. Like any cold +observable, nothing happens until something subscribes: return it to a caller that subscribes (a +template's `async` pipe, a component's `subscribe()`), or await it with `lastValueFrom()`. Start the +scenario inside `defer()` so each subscription measures its own journey from when it subscribes. On +error `traceScenario` calls `fail({ error })` with the raw error and no status, so for HTTP sources +settle the failure first with `catchError` upstream — the first settle wins: + +```ts +loadCustomers(): Observable { + return defer(() => { + const scenario = this.scenarios.start({ name: 'load-customers', feature: 'customers' }); + return this.api.customers().pipe( + catchError((error) => { + scenario.fail(safeHttpFailure(error)); + return throwError(() => error); + }), + traceScenario(scenario, { outcome: (rows) => ({ metadata: { count: rows.length } }) }) + ); + }); +} +``` + +**Backdating** — the journey starts at the click, not when the async handler runs: + +```ts +onExportClick(): void { this.clickedAt = Date.now(); /* … */ } +// later +this.scenarios.start({ name: 'export-download', startedAt: this.clickedAt }); +``` + +`startedAt` is epoch ms, clamped to the page lifetime; an unusable value falls back to now. + +**Repeated work inside one journey** — aggregate instead of N steps: + +```ts +for (const row of rows) { + scenario.aggregateStart('format-row'); + format(row); + scenario.aggregateEnd('format-row'); +} +``` + +**Child journeys** — pass `parentScenarioId: parent.id`. Across realms (fragments), pass the id +string, never the `UwtScenario` object. + +**Measuring what the user saw** — `complete()` stops the clock when the JavaScript finishes; for a +render-heavy journey, complete after the next frame: +`requestAnimationFrame(() => requestAnimationFrame(() => scenario.complete()))`. + +## 4. RxJS traps + +**Nothing downstream of `traceScenario` may stop after the first value.** `traceScenario` settles +`success` when the source *completes*; anything after it that unsubscribes on the first value — +`take(1)` or `first()` placed after it, or `firstValueFrom()` on the traced observable — tears it +down before completion arrives, and the scenario records `abandoned`. This holds even for +`HttpClient`, which completes right after its one value: `firstValueFrom` unsubscribes first. + +```ts +// Records abandoned: +await firstValueFrom(this.api.customers().pipe(traceScenario(scenario))); +this.events$.pipe(traceScenario(scenario), take(1)); + +// Records success: limit upstream, await completion. +await lastValueFrom(this.api.customers().pipe(traceScenario(scenario))); +this.events$.pipe(take(1), traceScenario(scenario)); +``` + +**`switchMap` supersession** — give `traceScenario` a `cancelOutcome`; `switchMap` tears the +previous request down before your projector runs, so cancelling it there is too late: + +```ts +query$.pipe( + debounceTime(300), + switchMap((q) => { + const scenario = this.scenarios.start({ name: 'search', feature: 'catalogue' }); + scenario.step('query'); + return this.api.search(q).pipe( + catchError((error) => { + scenario.fail(safeHttpFailure(error)); + return EMPTY; // keep the search stream alive after a failed request + }), + traceScenario(scenario, { cancelOutcome: { reason: 'superseded' } }) + ); + }) +); +``` + +Managing scenarios by hand? Cancel the previous one at the top of the projector, before starting the +next. + +## 5. Cleanup on destroy + +Every component that starts a scenario settles it on destroy; otherwise it sits in the active +registry until its timeout (and forever with `timeoutMs: 0`). + +```ts +ngOnDestroy(): void { + this.loadScenario?.cancel({ reason: 'component-destroyed' }); +} + +// several, keyed by entity +ngOnDestroy(): void { + for (const s of this.uploads.values()) s.cancel({ reason: 'component-destroyed' }); + this.uploads.clear(); +} +``` + +Cancelling an already-settled scenario is a harmless no-op, so this needs no "is it still open" +check. + +## 6. BI events + +- One event per meaningful action, named `snake_case` in `UwtBIEventNames`. +- Inside a journey, link it: `track('export_clicked', { format }, { scenarioId: scenario.id })`. +- Debounce high-frequency sources (search boxes, sliders) before `track()` or before starting a + scenario — e.g. `debounceTime(400)`; one per settled input, never one per keystroke. Repeated + identical clicks within 400 ms are already collapsed by the library. +- Call `UwtBITelemetryService.track()` directly where the action happens; don't route it through an + app telemetry service, and don't merge it with the scenario service. If a function of yours takes an + event name, type that parameter `UwtBIEventName`. + +## 7. Names, metadata and privacy + +- Never interpolate data into a scenario, step or BI name. Put the variable part in metadata, and + only if it is low-cardinality (a format, a tab name, a count) — never a record id or free text. +- `route` is a template (`/customers/:id`). +- Metadata: flat `string | number | boolean | null`; max 50 keys across the whole scenario. +- No personal data: no emails, names, usernames, account numbers, free-text input. Use opaque ids. +- Code you write that touches `window`, `navigator`, `document` or `localStorage` runs during server + rendering too — guard it: + `this.isBrowser ? { language: navigator.language } : {}` with + `isBrowser = isPlatformBrowser(inject(PLATFORM_ID))`. + +## 8. Starter catalogue + +Adapt to the app's real vocabulary and to what the user asked for. Route navigation is already +tracked by `ui-watchtower-setup`. + +| Scenario | Steps | Settles | +|-------------------|--------------------------------|---------------------------------------------------------------------------------------------| +| `-load` | `fetch`, `render` | `complete`; `incomplete({reason:'no-results'})`; `fail(safeHttpFailure(e))`; cancel on destroy/supersede | +| `
-submit` | `validate`, `submit` | `complete`; `incomplete({reason:'validation-failed'})`; `fail(safeHttpFailure(e))` | +| `file-upload` | `read`, `upload`, `process` | `complete({metadata:{fileSize}})`; `cancel({reason:'user-cancelled'})`; `fail(safeHttpFailure(e))` | +| `export-download` | `request`, `transfer` | `complete`; `cancel({reason:'user-cancelled'})`; `fail(safeHttpFailure(e))` | + +BI: `export_clicked`, `filter_applied`, `search_submitted` (debounced), `theme_changed`, +`sidebar_toggled`, `sign_out_clicked`. diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md new file mode 100644 index 0000000..62a1616 --- /dev/null +++ b/skills/ui-watchtower-setup/SKILL.md @@ -0,0 +1,339 @@ +--- +name: ui-watchtower-setup +description: > + Installs and wires the @absaoss-cps/ngx-ui-watchtower telemetry library in an Angular app: + destination per app role (AWS CloudWatch RUM, micro-frontend shell or fragment, no-op), + credentials, log provider, typed name vocabulary, route-navigation tracking as proof, migration of + a hand-written RumService, and tests. Use when asked to add, install, set up or wire + ngx-ui-watchtower, UI telemetry or CloudWatch RUM in an Angular app, replace a RumService, + connect a micro-frontend shell or fragment, or turn telemetry off in some environments. NOT for: instrumenting the app's own journeys or BI + events (→ ui-watchtower-integration), dashboards, non-Angular apps. +license: Apache-2.0 +compatibility: > + Requires an Angular application within the peer range of a published + @absaoss-cps/ngx-ui-watchtower release — the latest, or an older compatible one (checked at run + time with npm view) — and npm registry access. +--- + +# ui-watchtower-setup + +Sets up [`@absaoss-cps/ngx-ui-watchtower`](https://www.npmjs.com/package/@absaoss-cps/ngx-ui-watchtower) +in an existing Angular application: install, wire the providers for the app's role, declare the +name vocabulary, prove the pipeline with route-navigation tracking, test, verify. + +**Scope:** infrastructure plus exactly one working scenario (route navigation). Measuring the app's +own journeys and interactions is the follow-up skill, `ui-watchtower-integration` — hand over to it at +the end (Step 9). + +The library has three concerns: **scenarios** (did a user journey succeed, how long did it take), +**BI events** (did a meaningful thing happen) and **logs** (what happened in one run). Scenarios and +BI events go to **one destination** per JavaScript realm (normally AWS CloudWatch RUM). Logs always go +to the app's own log backend, never to the destination. + +## Reference files — load on demand + +| Load | When | +|---|---| +| [ngx-ui-watchtower-api](../shared/references/ngx-ui-watchtower-api.md) ([remote](https://github.com/AbsaOSS/agentic-toolkit/blob/master/skills/shared/references/ngx-ui-watchtower-api.md)) | Always, before writing code — exact imports, signatures, defaults, test harness | +| `references/rum-migration.md` | The app already sends RUM events itself (`aws-rum-web`, a `RumService`) | +| `references/micro-frontends.md` | The app hosts embedded fragments, or is a fragment inside a shell | + +Templates in `assets/` are copied into the app and adapted: `telemetry.schema.ts`, +`rum-credentials.provider.ts`, `log-api.provider.ts`, `route-navigation-telemetry.service.ts`, and the +specs for the last two (`*.spec.ts`). + +## Workflow + +Copy this checklist and track progress: + +``` +- [ ] Step 1: Detect the app's shape (read-only) +- [ ] Step 2: Install the package +- [ ] Step 3: Wire providers for this realm's role +- [ ] Step 4: Declare the name vocabulary +- [ ] Step 5: Migrate an existing RUM integration (only if one exists) +- [ ] Step 6: Proof — route-navigation tracking +- [ ] Step 7: Update and add tests +- [ ] Step 8: Validate (build, tests, type-check, live debug output) +- [ ] Step 9: Hand over (the report ends with the pointer to ui-watchtower-integration) +``` + +### Step 1 · Detect (read-only) + +Read before writing anything — extend the app, don't restructure it. + +1. **Angular version.** Not Angular → stop. Compare the app's `@angular/core` and `rxjs` with the + library's range from the registry, never from memory: + `npm view @absaoss-cps/ngx-ui-watchtower@latest peerDependencies`. + - Inside → install the latest version. + - Older → the newest release whose peers fit (`npm view … versions`, skipping prereleases such + as `-stage` or `-rc`), pinned; none → stop. + - Newer → stop and tell the user. Never `--force` or `--legacy-peer-deps`. + - Note the chosen release's `aws-rum-web` range too — Step 2 installs within it. +2. **Package manager** — from the lockfile (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`), a + `packageManager` field, or `cli.packageManager` in `angular.json`. Use it for every install and + script run; never default to npm. +3. **Bootstrap style** — standalone (`app.config.ts`, `bootstrapApplication`) or NgModule + (`providers` in `app.module.ts`). Providers go in the same place either way. +4. **SSR** — `app.config.server.ts`, `server.ts`, `@angular/ssr`. Affects only code *you* write + (Step 3); the library is SSR-safe. +5. **Test runner** — Jest, Karma or Vitest, and which specs build the root providers or render + components that will inject telemetry services (they need providers in Step 7). +6. **Existing telemetry** — search for `aws-rum-web`, `recordEvent`, `recordPageView`, `pinUserId`, + `addSessionAttributes`, a `RumService`, or another analytics SDK. If RUM is already wired, Step 5 + applies. If ngx-ui-watchtower is partly wired, run only the steps for what is missing; if + nothing is, offer `ui-watchtower-integration` — unless the request is itself a wiring change + (e.g. turning telemetry off in some environments, Step 3), which you make here. +7. **Realm role** — standalone app, **shell** that embeds fragments (Web Fragments, Module + Federation, iframes on the same origin), or **fragment** inside a shell. Look for + ``, fragment gateways, remote-entry config. A Module Federation remote loaded + through `loadChildren`/`loadComponent` runs in the host's injector — give it no providers of + its own. +8. **Router** — `provideRouter` / `RouterModule.forRoot`; note any `matcher` routes, or that there is + no router at all (Step 6). +9. **Sign-in** — an auth service, OIDC/MSAL library or session store, and where sign-in and + sign-out complete. Note which **opaque** user id it exposes (a subject or object id — never an + email or username). + +Ask the user only what the code cannot answer, in one message: + +- The `application` name to report (unique per app and per fragment), if not obvious. +- **Log backend**: does one exist to send log records to (endpoint, existing service)? +- **RUM**: the endpoint that returns the RUM app-monitor config and short-lived AWS credentials + (typically a backend "broker"), or whether RUM is not ready yet (→ `'noop'` for now). +- **User id**: if the app has sign-in but exposes no opaque id, which one to use — or skip it. + +### Step 2 · Install + +```bash +# npm / Yarn / pnpm — use the one detected in Step 1 +npm install @absaoss-cps/ngx-ui-watchtower@latest # or the pinned version chosen in Step 1 +npm install "aws-rum-web@" # only if THIS realm sends to RUM (standalone app or shell) +``` + +When `aws-rum-web` is installed, add `"allowedCommonJsDependencies": ["shimmer"]` to the app's build +options in `angular.json` (a CommonJS transitive dependency; otherwise every build warns). If a strict +`tsc` run fails inside `aws-rum-web`'s bundled `rrweb` typings, set `"skipLibCheck": true` in the +app tsconfig(s). + +### Step 3 · Wire providers + +`provideUwtTelemetry(identity)` registers **configuration only**. Every realm must also bind +**exactly one destination**; injecting a telemetry service without one fails at bootstrap with +`NG0201` — by design; fix the wiring, never add a fallback provider "to make it start". Two +different destinations also fail bootstrap. + +**Destination by realm role:** + +| Role | Destination | Also | +|---|---|---| +| Standalone app, sends to RUM | `provideUwtTelemetryRumSink()` (from `/rum`) | `UWT_RUM_CREDENTIALS_PROVIDER` → `assets/rum-credentials.provider.ts` | +| Shell hosting fragments | `provideUwtTelemetryRumSink()` | + `provideUwtTelemetryBroadcastHost(channelId)` + a log provider — `references/micro-frontends.md` | +| Fragment inside a shell | `provideUwtTelemetrySink('broadcast')` + lazy `UWT_BROADCAST_CHANNEL` factory | nothing else — `references/micro-frontends.md` | +| RUM not available yet | `provideUwtTelemetrySink('noop')` | explicit opt-out; everything runs, nothing ships; switching to RUM later takes more than this line — Step 9. Not for turning telemetry off per environment — see below | +| Own backend | `provideUwtTelemetryDestination(MySink)` (a class extending `UwtTelemetrySink`) | see the API reference, "Custom destination" | + +**Log provider:** + +| App has a log backend? | Do | +|---|---| +| Yes | Implement `UwtLogApiProvider` from `assets/log-api.provider.ts` (it uses `fetch`; switch to `HttpClient` if the backend needs auth an interceptor adds), bind `{ provide: UWT_LOG_API_PROVIDER, useExisting: … }`, declare logger names in `UwtLoggerNames` | +| No | Don't inject `UwtLoggerService`, don't pass `withLogging()`, don't declare `UwtLoggerNames`. A **shell** still binds `{ provide: UWT_LOG_API_PROVIDER, useClass: UwtNoopLogApiProvider }` — the host requires one | + +**Identity** — `application`, `environment`, `version` (plus optional `eventNamespace`, default +`com.uwt`; leave it unless Step 5 needs it). Reuse the app's environment files where they exist; +if you derive a value from browser globals (e.g. hostname), guard it — this also runs during server +rendering: + +```ts +// standalone: app.config.ts — NgModule: the same entries in AppModule `providers` +import { ApplicationConfig } from '@angular/core'; +import { provideUwtTelemetry } from '@absaoss-cps/ngx-ui-watchtower'; +import { + UWT_RUM_CREDENTIALS_PROVIDER, + provideUwtTelemetryRumSink +} from '@absaoss-cps/ngx-ui-watchtower/rum'; +import { AppRumCredentialsProvider } from './telemetry/rum-credentials.provider'; +import { environment } from '../environments/environment'; +import './telemetry/telemetry.schema'; // side-effect import — Step 4 + +export const appConfig: ApplicationConfig = { + providers: [ + // …existing providers, unchanged + provideUwtTelemetry({ + application: 'my-app', + environment: environment.name, // e.g. 'dev' | 'qa' | 'prod' + version: environment.version + }), + provideUwtTelemetryRumSink(), + { provide: UWT_RUM_CREDENTIALS_PROVIDER, useExisting: AppRumCredentialsProvider } + ] +}; +``` + +- Register the providers **unconditionally** in the shared config. Do not split them between browser + and server configs and do not wrap them in a platform check — every browser-touching path in the + library is already a no-op on the server. +- Start with **no** `withScenarios` / `withBIEvents` / `withLogging` / `withRedaction` features. Add + one only for a stated reason, and record the reason in a comment. +- **Telemetry off in some environments** (only when the user asks): keep every provider exactly as + it is and add one feature, `withTelemetryEnabled(...)` (0.2.0+; on an older pin from Step 1, tell + the user it isn't available). Pass a value from the app's environment files, or a factory that + reads config already available at bootstrap — it is read once, at startup: + + ```ts + provideUwtTelemetry(identity, withTelemetryEnabled(environment.name !== 'dev')), + ``` + + Off means fully inert: no credentials fetch, no RUM SDK download, no broadcast channel, no page + listeners; app code keeps working unchanged. Never do this by omitting or wrapping providers per + environment or swapping in `'noop'` — wiring errors would then only show up in some environments, + and `'noop'` still times, redacts and listens. In a shell with fragments, see + `references/micro-frontends.md`. +- Credentials: copy `assets/rum-credentials.provider.ts`, point it at the broker endpoint and adapt + the response check. It returns `null` (RUM off) for a disabled, incomplete or expired answer + and throws when the request fails, so a refresh retries instead of ending RUM. It sets + `disableAutoPageView: true` (page views come from Step 6). Don't write an initializer — the sink + calls it. It uses `fetch`; switch to `HttpClient` if an interceptor adds the broker's auth. +- **User identity** (only when the app has sign-in): inject `UwtTelemetrySink` as a field of the + sign-in service (not inside a callback — `NG0203`), then `setUserId(opaqueId)` on sign-in and + `setUserId(undefined)` on sign-out. Never an email or username. Fragments leave this to the shell. + +### Step 4 · Declare the vocabulary + +Copy `assets/telemetry.schema.ts` to the app (e.g. `src/app/telemetry/telemetry.schema.ts`). It +augments the library's name registries, so scenario, step, BI event (and logger) names become +compile-checked. Setup declares only the route-navigation names; `ui-watchtower-integration` adds the +app's own later. + +- A name is a **metric dimension**: a typo or an interpolated id silently starts a second metric + series. Never build a name from data (`` step(`load-${id}`) `` is wrong). +- Kebab-case for scenario and step names (`route-navigation`, `customers-load`, `fetch`); snake_case for BI + events (`export_clicked`). One JSDoc line per name saying what it measures. +- Keep the side-effect import `import './telemetry/telemetry.schema';` where the providers are + configured (`app.config.ts` or `AppModule`). + +### Step 5 · Existing RUM integration (conditional) + +If Step 1 found the app sending RUM events itself, follow `references/rum-migration.md` before +Step 6 — replacing a hand-written `RumService` naively breaks existing dashboards. Personal data the +old code sends today is fixed immediately, even if the migration has to wait. + +### Step 6 · Proof — route-navigation tracking + +One real scenario proves the whole pipeline. Copy `assets/route-navigation-telemetry.service.ts` +next to the schema and add `provideRouteNavigationTelemetry()` next to `provideRouter(...)` (a +`start()` in the root component would miss a blocking initial navigation). It works unchanged for +static, `:param`, lazy and redirected routes; a `matcher` route needs +`data: { telemetryPath: 'files/:path' }`. Keep the asset's behaviour — its comments explain it. +To start the clock at the click rather than at `NavigationStart`, call +`markNavigationIntent()` first in click handlers that navigate — most useful where the handler does +work before `router.navigate…`. + +**Page views** — where RUM is the destination, bind `ROUTE_PAGE_VIEW_RECORDER` so page views use +the route template (automatic ones would send `/customers/42`; with `'noop'`, bind nothing): + +```ts +import { inject } from '@angular/core'; +import { UwtRumTelemetrySink } from '@absaoss-cps/ngx-ui-watchtower/rum'; +import { ROUTE_PAGE_VIEW_RECORDER } from './telemetry/route-navigation-telemetry.service'; + +{ + provide: ROUTE_PAGE_VIEW_RECORDER, + useFactory: () => { + const rum = inject(UwtRumTelemetrySink); + return (route: string) => rum.recordPageView(route); + } +} +``` + +In a fragment, skip this step by default — the shell's router measures navigations that change the +page URL. Add it only if the fragment has internal routes the shell never routes. + +**No router:** skip this step and the binding, and remove `disableAutoPageView: true` from the +credentials provider — RUM's one automatic page view per load is then right. + +### Step 7 · Tests + +- Every existing spec that now (directly or through a component) injects a telemetry service needs + providers, or it fails with `NG0201`: + + ```ts + providers: [ + provideUwtTelemetry( + { application: 'my-app-test', environment: 'test', version: '0.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }) // no scenario timers left running in tests + ), + provideUwtTelemetrySink('noop'), + // only where UwtLoggerService is injected: + { provide: UWT_LOG_API_PROVIDER, useClass: UwtNoopLogApiProvider } + ] + ``` +- Copy the two spec templates next to their assets: `route-navigation-telemetry.service.spec.ts` + (a recording sink and its own route table — add the app's own route shapes, e.g. its `:param` + routes) and, for RUM, `rum-credentials.provider.spec.ts` (adapt the broker answer to the real + one). They are written for Vitest. Under Jest, replace `vi.` with `jest.` (fake timers take + `doNotFake` instead of `toFake`; `vi.stubGlobal('fetch', f)` becomes `globalThis.fetch = f`, and + jsdom under Jest has no `Response`, so answer with `{ ok, status, json: async () => body }`); + under Karma, use Jasmine's clock and spies. Check the tests bite: remove the `complete()` call in + the service, confirm a test fails, restore it. +- jsdom has no `BroadcastChannel`: the broadcast sink and host degrade to no-ops in tests. Expected — + don't polyfill it. + +### Step 8 · Validate + +Run, through the detected package manager, until all are green: + +1. **Build** the app (including the server build when SSR is on). +2. **Unit tests.** +3. **Type-check:** `tsc -p tsconfig.app.json --noEmit` and the spec tsconfig — passing tests prove + nothing about types (Vitest never type-checks), and a solution-style root tsconfig checks nothing. +4. **Live check** in the browser with the debug flags (no reload needed): + + ```js + localStorage.setItem('debugScenario', 'true'); + localStorage.setItem('debugLogger', 'true'); // only with a log backend + ``` + + Each console line is `[][] …`, and its second argument is the exact object + handed to the sink. Confirm one `route-navigation` line per navigation with the right status and a + template `route`, and that nothing is logged during server rendering. Where + `withTelemetryEnabled` turns telemetry off, no lines appear — check in an environment where it + is on, then confirm the off one stays silent. + +Don't finish until build, tests and type-check are green. The `assets/` templates use current +Angular router and DI APIs; if the app's Angular version has changed one, adapt the template to the +app's version — never silence the error. + +### Step 9 · Hand over + +Report what was wired (role, destination, credentials endpoint, log provider, user identity) and +any follow-ups. +With `'noop'`, hand over the switch to RUM as a checklist — it is more than one line: install +`aws-rum-web` and the `shimmer` allowance (Step 2), replace the `'noop'` line with +`provideUwtTelemetryRumSink()` plus the credentials provider (Step 3), and bind +`ROUTE_PAGE_VIEW_RECORDER` (Step 6) — without it, RUM records no page views at all. +End the report with the next step, every time and however short the report is — it is the only way +the user learns that their own journeys are still unmeasured: **to measure the app's own journeys and interactions, use the +`ui-watchtower-integration` skill** — it asks what they want to learn, proposes, and implements. For +a shell, also hand over the fragment contract from `references/micro-frontends.md`. + +## Gotchas + +- **`/rum` is a separate entry point for a reason.** Import RUM symbols only from + `@absaoss-cps/ngx-ui-watchtower/rum`, and never re-export them from a shared barrel — that pulls + `aws-rum-web` into every consumer's build. No deep imports (`…/src/lib/…`) either. +- **Telemetry never throws into the app** — every entry point fails open. Don't wrap calls in + try/catch. +- **Redaction never scrubs URL path segments** (only query strings and fragments; `scanValuePatterns` + only scrubs metadata and message values). Keep personal data out of URLs and report route + templates, never resolved paths. + +## Out of scope + +- Instrumenting the app's own journeys and BI events → `ui-watchtower-integration`. +- CloudWatch dashboards, RUM extended metrics, alarms, Logs Insights queries. +- Sending the scenario id to the backend as a correlation header — the library ships no HTTP + interceptor on purpose. Offer it as a follow-up, restricted to the app's own API origins. +- The fragment teams' own setup when this app is a shell — hand them the fragment contract. +- Upgrading Angular, OpenTelemetry, and non-Angular frameworks. diff --git a/skills/ui-watchtower-setup/assets/log-api.provider.ts b/skills/ui-watchtower-setup/assets/log-api.provider.ts new file mode 100644 index 0000000..befe3b0 --- /dev/null +++ b/skills/ui-watchtower-setup/assets/log-api.provider.ts @@ -0,0 +1,60 @@ +import { isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; +import type { + UwtLogApiProvider, + UwtLogQuery, + UwtLogRecord +} from '@absaoss-cps/ngx-ui-watchtower'; + +/** The app's log-ingestion and log-query endpoint. Adapt to the real backend. */ +const LOG_API_URL = '/api/logs'; + +/** + * Sends this application's log records to its own log backend. + * + * Only for apps that have a log backend. Without one, bind + * `UwtNoopLogApiProvider` (shells) or leave logging out entirely. + * + * Bound with `{ provide: UWT_LOG_API_PROVIDER, useExisting: AppLogApiProvider }`. + * Batching, retries and authentication belong here; the library calls + * `flush()` when the page is hidden or closed. + */ +@Injectable({ providedIn: 'root' }) +export class AppLogApiProvider implements UwtLogApiProvider { + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + /** @inheritdoc */ + send(record: UwtLogRecord): void { + // Server-side rendering has no relative URL to post to. + if (!this.isBrowser) { + return; + } + fetch(LOG_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(record), + // Lets the request outlive a page that is being closed. Browsers cap all in-flight + // keepalive bodies at 64 KiB together, so batch records before production use. + keepalive: true + }).catch(() => undefined); + } + + /** @inheritdoc */ + async query(filter: UwtLogQuery): Promise { + if (!this.isBrowser) { + return []; + } + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(filter)) { + if (value !== undefined) { + params.set(key, String(value)); + } + } + try { + const response = await fetch(`${LOG_API_URL}?${params}`); + return response.ok ? ((await response.json()) as UwtLogRecord[]) : []; + } catch { + return []; + } + } +} diff --git a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts new file mode 100644 index 0000000..d6c3043 --- /dev/null +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts @@ -0,0 +1,379 @@ +import { Component, inject, Injectable, provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { + ActivatedRouteSnapshot, + provideRouter, + Router, + RouterOutlet, + UrlSegment, + withEnabledBlockingInitialNavigation +} from '@angular/router'; +import { + provideUwtTelemetry, + provideUwtTelemetryDestination, + UwtTelemetrySink, + withScenarios +} from '@absaoss-cps/ngx-ui-watchtower'; +import { + provideRouteNavigationTelemetry, + RouteNavigationTelemetryService, + ROUTE_PAGE_VIEW_RECORDER +} from './route-navigation-telemetry.service'; + +const events: { eventType: string; payload: Record }[] = []; +const pageViews: string[] = []; + +@Injectable() +class RecordingSink extends UwtTelemetrySink { + override record(eventType: string, payload: object): void { + events.push({ eventType, payload: payload as Record }); + } + override recordError(): void {} + override getSessionId(): string | undefined { return 'session-1'; } + override setUserId(): void {} + override getUserId(): string | undefined { return undefined; } + override flush(): void {} +} + +@Component({ template: '' }) +class Page {} + +let releaseSlowGuard: (value: boolean) => void = () => undefined; +let slowGuardEntered: () => void = () => undefined; +let lazyLoadEntered: () => void = () => undefined; +let releaseLazyLoad: () => void = () => undefined; +let releaseHangGuard: (value: boolean) => void = () => undefined; + +function filesMatcher(segments: UrlSegment[]) { + return segments[0]?.path === 'files' + ? { consumed: segments, posParams: { path: segments[segments.length - 1] } } + : null; +} + +const scenarios = () => events.map((e) => e.payload); + +describe('RouteNavigationTelemetryService', () => { + let router: Router; + + beforeEach(() => { + events.length = 0; + pageViews.length = 0; + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { path: '', pathMatch: 'full', redirectTo: 'customers' }, + { path: 'customers', component: Page }, + { path: 'customers/:id', component: Page }, + { + path: 'orders', + loadChildren: () => Promise.resolve([{ path: ':orderId', component: Page }]) + }, + { path: 'blocked', component: Page, canActivate: [() => false] }, + { + path: 'old', + component: Page, + canActivate: [() => inject(Router).parseUrl('/customers')] + }, + { + path: 'legacy/:id', + component: Page, + canActivate: [ + (route: ActivatedRouteSnapshot) => + inject(Router).parseUrl(`/customers/${route.paramMap.get('id')}`) + ] + }, + { + path: 'broken', + component: Page, + resolve: { data: () => Promise.reject(new Error('resolver failed for cust-SECRET-7')) } + }, + { + path: 'http-broken', + component: Page, + resolve: { + data: () => + Promise.reject({ name: 'HttpErrorResponse', status: 503, message: 'GET /api/cust-SECRET-7 failed' }) + } + }, + { + path: 'odd-name', + component: Page, + resolve: { + data: () => Promise.reject({ name: 'Lookup failed for cust-SECRET-7 at /x', message: 'm' }) + } + }, + { + path: 'slow', + component: Page, + canActivate: [ + () => { + slowGuardEntered(); + return new Promise((resolve) => (releaseSlowGuard = resolve)); + } + ] + }, + { matcher: filesMatcher, component: Page, data: { telemetryPath: 'files/:path' } }, + { + path: 'hang', + component: Page, + canActivate: [() => new Promise((resolve) => (releaseHangGuard = resolve))] + }, + { path: 'never-loads', loadChildren: () => new Promise(() => undefined) }, + { + path: 'slow-lazy', + loadChildren: () => { + lazyLoadEntered(); + return new Promise((resolve) => (releaseLazyLoad = () => resolve([]))); + } + } + ]), + provideUwtTelemetry( + { application: 'my-app-test', environment: 'test', version: '0.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }) + ), + provideUwtTelemetryDestination(RecordingSink), + { provide: ROUTE_PAGE_VIEW_RECORDER, useValue: (route: string) => pageViews.push(route) } + ] + }); + TestBed.inject(RouteNavigationTelemetryService).start(); + router = TestBed.inject(Router); + }); + + it('records a static route without query, matrix or fragment, plus one page view', async () => { + await router.navigateByUrl('/customers;view=grid?tab=open#top'); + expect(events).toEqual([ + expect.objectContaining({ + eventType: 'com.uwt.scenario', + payload: expect.objectContaining({ + scenarioName: 'route-navigation', + status: 'success', + route: '/customers', + metadata: expect.objectContaining({ resolveMs: expect.any(Number) }) + }) + }) + ]); + expect(pageViews).toEqual(['/customers']); + }); + + it('reports a :param route as its template, never the id', async () => { + await router.navigateByUrl('/customers/cust-SECRET-7'); + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'success', route: '/customers/:id' }) + ]); + expect(pageViews).toEqual(['/customers/:id']); + expect(JSON.stringify(events)).not.toContain('SECRET'); + }); + + it('reports a lazy loadChildren route as its template on first load', async () => { + await router.navigateByUrl('/orders/A-77'); + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'success', route: '/orders/:orderId' }) + ]); + }); + + it('reports a config redirect as the target template', async () => { + await router.navigateByUrl('/'); + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'success', route: '/customers' }) + ]); + }); + + it('records a guard rejection as incomplete, without the router reason text', async () => { + await router.navigateByUrl('/blocked'); + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'incomplete', reason: 'guard-rejected', route: '/blocked' }) + ]); + expect(scenarios()[0]['message']).toBeUndefined(); + expect(pageViews).toEqual([]); + }); + + it('records a guard redirect as one scenario under the final template', async () => { + await router.navigateByUrl('/old'); + expect(scenarios()).toEqual([ + expect.objectContaining({ + status: 'success', + route: '/customers', + metadata: expect.objectContaining({ redirectedFrom: '/old' }) + }) + ]); + expect(pageViews).toEqual(['/customers']); + }); + + it('records a parameterised guard redirect under the final template, without ids', async () => { + await router.navigateByUrl('/legacy/cust-SECRET-7'); + expect(scenarios()).toEqual([ + expect.objectContaining({ + status: 'success', + route: '/customers/:id', + metadata: expect.objectContaining({ redirectedFrom: '/legacy/:id' }) + }) + ]); + expect(JSON.stringify(events)).not.toContain('SECRET'); + }); + + it('records an unmatched URL as a failure with no URL anywhere in the payload', async () => { + await router.navigateByUrl('/no-such-page/cust-SECRET-7').catch(() => false); + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'failure', route: '(unrecognized)' }) + ]); + const wire = JSON.stringify(events); + expect(wire).not.toContain('no-such-page'); + expect(wire).not.toContain('SECRET'); + }); + + it('replaces a matched route\'s error with a generic one, keeping only its name', async () => { + await router.navigateByUrl('/broken').catch(() => false); + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'failure', route: '/broken' }) + ]); + expect(JSON.stringify(events)).not.toContain('SECRET'); + expect(JSON.stringify(events)).not.toContain('resolver failed'); + }); + + it('keeps the HTTP status of a resolver error, not its message', async () => { + await router.navigateByUrl('/http-broken').catch(() => false); + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'failure', route: '/http-broken', statusCode: 503 }) + ]); + expect(JSON.stringify(events)).not.toContain('SECRET'); + expect(JSON.stringify(events)).toContain('HttpErrorResponse'); + }); + + it('drops an error name that is not class-like', async () => { + await router.navigateByUrl('/odd-name').catch(() => false); + expect(scenarios()).toEqual([expect.objectContaining({ status: 'failure', route: '/odd-name' })]); + expect(JSON.stringify(events)).not.toContain('SECRET'); + }); + + it('records nothing more for a navigation to the current URL', async () => { + await router.navigateByUrl('/customers'); + await router.navigateByUrl('/customers'); + expect(scenarios()).toEqual([expect.objectContaining({ status: 'success', route: '/customers' })]); + }); + + it('releases a guard redirect back to the current URL as abandoned under its source template', async () => { + await router.navigateByUrl('/customers'); + events.length = 0; + await router.navigateByUrl('/old'); + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'abandoned', reason: 'same-url', route: '/old' }) + ]); + }); + + it('names a matcher route from its data', async () => { + await router.navigateByUrl('/files/a/b/report.pdf'); + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'success', route: '/files/:path' }) + ]); + }); + + it('records nothing for a navigation superseded before its URL was recognized', async () => { + const entered = new Promise((resolve) => (lazyLoadEntered = resolve)); + const first = router.navigateByUrl('/slow-lazy/x').catch(() => false); + await entered; + await router.navigateByUrl('/customers'); + releaseLazyLoad(); + await first; + expect(scenarios()).toEqual([expect.objectContaining({ status: 'success', route: '/customers' })]); + }); + + it('records a superseded navigation as abandoned, and the new one as success', async () => { + // Supersede only once the first navigation is recognized and waiting in its guard. + const entered = new Promise((resolve) => (slowGuardEntered = resolve)); + const first = router.navigateByUrl('/slow'); + await entered; + await router.navigateByUrl('/customers'); + releaseSlowGuard(true); + await first; + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'abandoned', reason: 'superseded', route: '/slow' }), + expect.objectContaining({ status: 'success', route: '/customers' }) + ]); + // The router's reason text ("Navigation ID 1 is not equal to …") never reaches telemetry. + expect(scenarios()[0]['message']).toBeUndefined(); + }); + + describe('with a fake clock', () => { + // The scenario clock reads both Date and performance; freeze both. + beforeEach(() => vi.useFakeTimers({ toFake: ['Date', 'performance'] })); + afterEach(() => vi.useRealTimers()); + + it('keeps the start time across a guard redirect', async () => { + TestBed.inject(RouteNavigationTelemetryService).markNavigationIntent(); + vi.advanceTimersByTime(60); + await router.navigateByUrl('/old'); + expect((scenarios()[0] as { delta: number }).delta).toBe(60); + }); + + it('backdates to a recent navigation intent', async () => { + TestBed.inject(RouteNavigationTelemetryService).markNavigationIntent(); + vi.advanceTimersByTime(60); + await router.navigateByUrl('/customers'); + expect((scenarios()[0] as { delta: number }).delta).toBe(60); + }); + + it('ignores a navigation intent older than 2 s', async () => { + TestBed.inject(RouteNavigationTelemetryService).markNavigationIntent(); + vi.advanceTimersByTime(2_001); + await router.navigateByUrl('/customers'); + expect((scenarios()[0] as { delta: number }).delta).toBe(0); + }); + }); + + describe('a navigation that never ends', () => { + beforeEach(() => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })); + afterEach(() => vi.useRealTimers()); + + it('records a hung guard once as timeout under its template, and only its page view when it ends late', async () => { + const pending = router.navigateByUrl('/hang'); + await vi.advanceTimersByTimeAsync(29_000); + expect(scenarios()).toEqual([]); + await vi.advanceTimersByTimeAsync(1_000); + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'timeout', reason: 'navigation-timeout', route: '/hang' }) + ]); + expect(pageViews).toEqual([]); + releaseHangGuard(true); + await pending; + expect(scenarios()).toHaveLength(1); + expect(pageViews).toEqual(['/hang']); + }); + + it('records a lazy route that never loads as timeout under (unrecognized)', async () => { + void router.navigateByUrl('/never-loads/x'); + await vi.advanceTimersByTimeAsync(30_000); + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'timeout', route: '(unrecognized)' }) + ]); + }); + + it('clears the timer when a navigation ends', async () => { + await router.navigateByUrl('/customers'); + expect(vi.getTimerCount()).toBe(0); + }); + }); + + it('records a blocking initial navigation, which runs before the root component exists', async () => { + @Component({ selector: 'telemetry-test-root', imports: [RouterOutlet], template: '' }) + class Root {} + document.body.innerHTML = ''; + history.replaceState(null, '', '/customers/cust-SECRET-7'); + const app = await bootstrapApplication(Root, { + providers: [ + provideZonelessChangeDetection(), + provideRouter([{ path: 'customers/:id', component: Page }], withEnabledBlockingInitialNavigation()), + provideUwtTelemetry({ application: 'my-app-test', environment: 'test', version: '0.0.0' }), + provideUwtTelemetryDestination(RecordingSink), + provideRouteNavigationTelemetry() + ] + }); + try { + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'success', route: '/customers/:id' }) + ]); + } finally { + app.destroy(); + } + }); +}); diff --git a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts new file mode 100644 index 0000000..9b41eed --- /dev/null +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts @@ -0,0 +1,368 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + DestroyRef, + EnvironmentProviders, + inject, + Injectable, + InjectionToken, + NgZone, + PLATFORM_ID, + provideEnvironmentInitializer +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + ActivatedRouteSnapshot, + Event as RouterEvent, + NavigationCancel, + NavigationCancellationCode, + NavigationEnd, + NavigationError, + NavigationSkipped, + NavigationSkippedCode, + NavigationStart, + PRIMARY_OUTLET, + Router, + RoutesRecognized +} from '@angular/router'; +import { + UwtScenario, + UwtScenarioTelemetryService +} from '@absaoss-cps/ngx-ui-watchtower'; +// Side-effect import: the vocabulary declaring the names used below. +import './telemetry.schema'; + +/** + * Records one page view per completed navigation, under the route template. Bind it to + * `UwtRumTelemetrySink.recordPageView` only where RUM is the destination, with + * `disableAutoPageView: true` — automatic page views report the resolved path (`/customers/42`). + */ +export const ROUTE_PAGE_VIEW_RECORDER = new InjectionToken<(route: string) => void>( + 'ROUTE_PAGE_VIEW_RECORDER' +); + +const NAVIGATION_SCENARIO = 'route-navigation'; + +/** Beyond this, a recorded click is assumed not to have caused the navigation. */ +const INTENT_MAX_AGE_MS = 2_000; + +/** No end event in this window (a guard, resolver or chunk that never settles) → `timeout`. */ +const NAVIGATION_TIMEOUT_MS = 30_000; + +/** `route` of a navigation that failed before its URL matched a route. */ +const UNRECOGNIZED_ROUTE = '(unrecognized)'; + +/** Route `data` key naming a `matcher` route's segment — a matcher has no `path`. */ +const TELEMETRY_PATH_DATA_KEY = 'telemetryPath'; + +/** + * Low-cardinality causes from the router's `code`. Its `reason` text is never sent: empty in + * production, and it can contain URLs in development. + */ +const CANCEL_REASON: Partial> = { + [NavigationCancellationCode.Redirect]: 'redirect', + [NavigationCancellationCode.SupersededByNewNavigation]: 'superseded', + [NavigationCancellationCode.NoDataFromResolver]: 'no-data-from-resolver', + [NavigationCancellationCode.GuardRejected]: 'guard-rejected', + [NavigationCancellationCode.Aborted]: 'aborted' +}; + +/** Expected dead ends (`incomplete`), not the user leaving (`abandoned`). */ +const INCOMPLETE_CODES = new Set([ + NavigationCancellationCode.GuardRejected, + NavigationCancellationCode.NoDataFromResolver +]); + +/** As {@link CANCEL_REASON}, for a navigation the router never ran. */ +const SKIP_REASON: Partial> = { + [NavigationSkippedCode.IgnoredSameUrlNavigation]: 'same-url', + [NavigationSkippedCode.IgnoredByUrlHandlingStrategy]: 'url-handling-strategy' +}; + +/** `code` is optional, and a future Angular may add one not mapped here. */ +function causeOf( + causes: Partial>, + code: TCode | undefined, + fallback: string +): string { + return (code === undefined ? undefined : causes[code]) ?? fallback; +} + +/** Matched template from the primary outlet's `routeConfig` chain: `/customers/:id`. */ +function routeTemplateOf(root: ActivatedRouteSnapshot): string { + const parts: string[] = []; + for ( + let route: ActivatedRouteSnapshot | null = root; + route; + route = route.children.find((child) => child.outlet === PRIMARY_OUTLET) ?? null + ) { + const config = route.routeConfig; + if (!config) { + continue; + } + const path = config.matcher + ? ((config.data?.[TELEMETRY_PATH_DATA_KEY] as string | undefined) ?? '(matcher)') + : config.path; + if (path) { + parts.push(path); + } + } + return `/${parts.join('/')}`; +} + +/** A class-like name (`ChunkLoadError`); any other `name` could be free text. */ +const SAFE_ERROR_NAME = /^[A-Z][A-Za-z0-9]{0,63}$/; + +/** + * Router, resolver and lazy-chunk errors can quote URLs, parameters or user data, and path + * segments are not redacted — so keep only a class-like name and an HTTP status. + */ +function safeNavigationFailure( + error: unknown, + recognized: boolean +): { error: Error; statusCode?: number } { + const safe = new Error( + recognized ? 'Navigation failed' : 'Navigation failed before the URL matched a route' + ); + const { name, status } = (error ?? {}) as { name?: unknown; status?: unknown }; + if (typeof name === 'string' && SAFE_ERROR_NAME.test(name)) { + safe.name = name; + } + return { error: safe, statusCode: typeof status === 'number' ? status : undefined }; +} + +interface TrackedNavigation { + /** Epoch ms: the click, `NavigationStart`, or the start of a redirect chain. */ + startedAt: number; + /** Template a guard or resolver redirected away from. */ + redirectedFrom?: string; + /** Matched template, known from `RoutesRecognized`. */ + route?: string; + /** `RoutesRecognized` time — guards, resolvers and lazy components run after it. */ + recognizedAt?: number; + /** Already recorded (e.g. at its timeout): a late end adds only its page view. */ + recorded?: boolean; + timeoutHandle?: ReturnType; +} + +/** What a guard or resolver redirect carries into the next navigation. */ +type RedirectCarry = Pick; + +/** + * One `route-navigation` scenario per navigation, recorded when it ends — `route` can't change + * after a scenario starts, and only the end knows the final template — or at its timeout if it + * hasn't ended. Backdated to the click or the first `NavigationStart`. Start it with + * {@link provideRouteNavigationTelemetry}; call {@link markNavigationIntent} from click handlers + * that navigate. + */ +@Injectable({ providedIn: 'root' }) +export class RouteNavigationTelemetryService { + private readonly router = inject(Router); + private readonly scenarioTelemetry = inject(UwtScenarioTelemetryService); + private readonly recordPageView = inject(ROUTE_PAGE_VIEW_RECORDER, { optional: true }); + private readonly destroyRef = inject(DestroyRef); + private readonly zone = inject(NgZone); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + /** By navigation id, never one "current" field: navigations supersede each other. */ + private readonly navigations = new Map(); + + private pendingRedirect?: RedirectCarry; + private navigationIntentAt?: number; + + private started = false; + + /** Safe to call more than once. */ + start(): void { + if (this.started) { + return; + } + this.started = true; + + this.router.events + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => this.onRouterEvent(event)); + this.destroyRef.onDestroy(() => { + for (const navigation of this.navigations.values()) { + clearTimeout(navigation.timeoutHandle); + } + }); + } + + /** `NavigationStart` comes after event handling and change detection; count that wait too. */ + markNavigationIntent(): void { + this.navigationIntentAt = Date.now(); + } + + private onRouterEvent(event: RouterEvent): void { + if (event instanceof NavigationStart) { + const startedAt = this.navigationStartedAt(); + const redirect = this.consumePendingRedirect(); + const navigation: TrackedNavigation = { + startedAt: redirect?.startedAt ?? startedAt, + redirectedFrom: redirect?.redirectedFrom, + recorded: redirect?.recorded + }; + this.navigations.set(event.id, navigation); + if (!navigation.recorded) { + this.armTimeout(event.id, navigation); + } + return; + } + + if (event instanceof RoutesRecognized) { + const navigation = this.navigations.get(event.id); + if (navigation) { + navigation.route = routeTemplateOf(event.state.root); + navigation.recognizedAt = Date.now(); + } + return; + } + + if (event instanceof NavigationEnd) { + const navigation = this.take(event.id); + if (navigation?.route) { + this.record(navigation, navigation.route, (scenario, metadata) => + scenario.complete({ metadata }) + ); + this.recordPageView?.(navigation.route); + } + return; + } + + if (event instanceof NavigationCancel) { + const navigation = this.take(event.id); + if (!navigation) { + return; + } + if (event.code === NavigationCancellationCode.Redirect) { + // Restarts under a new id: carry the start so the journey stays one scenario. + this.pendingRedirect = { + startedAt: navigation.startedAt, + redirectedFrom: navigation.redirectedFrom ?? navigation.route, + recorded: navigation.recorded + }; + return; + } + // Superseded before its URL was recognized: nothing to attribute it to. + if (!navigation.route) { + return; + } + const reason = causeOf(CANCEL_REASON, event.code, 'navigation-cancelled'); + this.record(navigation, navigation.route, (scenario, metadata) => + event.code !== undefined && INCOMPLETE_CODES.has(event.code) + ? scenario.incomplete({ reason, metadata }) + : scenario.cancel({ reason, metadata }) + ); + return; + } + + if (event instanceof NavigationSkipped) { + // A redirect back to the current URL ends here, not in a NavigationStart: release the carried + // redirect, and drop the click intent so it can't backdate an unrelated navigation. + this.navigationIntentAt = undefined; + const reason = causeOf(SKIP_REASON, event.code, 'navigation-skipped'); + const redirect = this.consumePendingRedirect(); + if (redirect?.redirectedFrom) { + this.record(redirect, redirect.redirectedFrom, (scenario, metadata) => + scenario.cancel({ reason, metadata }) + ); + } + const navigation = this.take(event.id); + if (navigation?.route) { + this.record(navigation, navigation.route, (scenario, metadata) => + scenario.cancel({ reason, metadata }) + ); + } + return; + } + + if (event instanceof NavigationError) { + const navigation = this.take(event.id); + if (navigation) { + const failure = safeNavigationFailure(event.error, navigation.route !== undefined); + this.record(navigation, navigation.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => + scenario.fail({ ...failure, metadata }) + ); + } + } + } + + /** Starts and settles the scenario at once, backdated to the journey's start; once per journey. */ + private record( + navigation: TrackedNavigation, + route: string, + settle: (scenario: UwtScenario, metadata: Record) => void + ): void { + if (navigation.recorded) { + return; + } + navigation.recorded = true; + const metadata: Record = {}; + if (navigation.redirectedFrom) { + metadata['redirectedFrom'] = navigation.redirectedFrom; + } + if (navigation.recognizedAt !== undefined) { + metadata['resolveMs'] = Date.now() - navigation.recognizedAt; + } + settle( + this.scenarioTelemetry.start({ + name: NAVIGATION_SCENARIO, + route, + startedAt: navigation.startedAt + }), + metadata + ); + } + + /** The click time if fresh enough, else now; consumes the click. */ + private navigationStartedAt(): number { + const at = this.navigationIntentAt; + this.navigationIntentAt = undefined; + const now = Date.now(); + + return at === undefined || now - at > INTENT_MAX_AGE_MS ? now : at; + } + + private consumePendingRedirect(): RedirectCarry | undefined { + const redirect = this.pendingRedirect; + this.pendingRedirect = undefined; + return redirect; + } + + /** + * Records a hung navigation once as `timeout`; a late end then adds only its page view. Browser + * only and outside Angular, so it never delays app stability or server rendering. + */ + private armTimeout(navigationId: number, navigation: TrackedNavigation): void { + if (!this.isBrowser) { + return; + } + const remainingMs = Math.max(0, navigation.startedAt + NAVIGATION_TIMEOUT_MS - Date.now()); + navigation.timeoutHandle = this.zone.runOutsideAngular(() => + setTimeout(() => { + const hung = this.navigations.get(navigationId); + if (hung) { + this.record(hung, hung.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => + scenario.settle('timeout', { reason: 'navigation-timeout', metadata }) + ); + } + }, remainingMs) + ); + } + + private take(navigationId: number): TrackedNavigation | undefined { + const navigation = this.navigations.get(navigationId); + this.navigations.delete(navigationId); + clearTimeout(navigation?.timeoutHandle); + return navigation; + } +} + +/** + * Starts route-navigation telemetry before any navigation runs — including a blocking initial + * navigation (`withEnabledBlockingInitialNavigation()`), which starts before the root component + * exists. Add it next to `provideRouter(...)`. + */ +export function provideRouteNavigationTelemetry(): EnvironmentProviders { + return provideEnvironmentInitializer(() => inject(RouteNavigationTelemetryService).start()); +} diff --git a/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts b/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts new file mode 100644 index 0000000..73ef009 --- /dev/null +++ b/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts @@ -0,0 +1,111 @@ +import { AppRumCredentialsProvider } from './rum-credentials.provider'; + +const valid = { + enabled: true, + config: { applicationId: 'app-1', region: 'eu-west-1', applicationVersion: '1.4.0', sessionSampleRate: 1 }, + credentials: { + accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token', + expiration: new Date(Date.now() + 3_600_000).toISOString() + } +}; + +function answer(body: unknown, status = 200): void { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response(typeof body === 'string' ? body : JSON.stringify(body), { status }) + )); +} + +describe('AppRumCredentialsProvider', () => { + afterEach(() => vi.unstubAllGlobals()); + const load = () => new AppRumCredentialsProvider().load(); + + it('maps a complete answer and turns automatic page views off', async () => { + answer(valid); + await expect(load()).resolves.toEqual({ + config: { + applicationId: 'app-1', region: 'eu-west-1', applicationVersion: '1.4.0', + sessionSampleRate: 1, disableAutoPageView: true + }, + credentials: valid.credentials + }); + }); + + it.each([ + ['empty config and credentials', { enabled: true, config: {}, credentials: {} }], + ['missing credentials', { enabled: true, config: valid.config }], + ['empty region', { ...valid, config: { ...valid.config, region: '' } }], + ['non-string token', { ...valid, credentials: { ...valid.credentials, sessionToken: 42 } }], + ['unparseable expiration', { ...valid, credentials: { ...valid.credentials, expiration: 'soon' } }], + ['already expired credentials', { ...valid, credentials: { ...valid.credentials, expiration: '2020-01-01T00:00:00Z' } }], + ['disabled', { ...valid, enabled: false }], + ['enabled as a string', { ...valid, enabled: 'true' }], + ['an array body', [valid]], + ['null body', null] + ])('returns null for %s', async (_label, body) => { + answer(body); + await expect(load()).resolves.toBeNull(); + }); + + it.each([ + ['invalid JSON', () => answer('')], + ['an error status', () => answer(valid, 503)], + ['a network failure', () => vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('offline')))] + ])('rejects on %s', async (_label, arrange) => { + arrange(); + await expect(load()).rejects.toThrow(); + }); + + it('carries the typed optional settings through', async () => { + const settings = { + telemetries: ['errors', 'performance', ['http', { recordAllRequests: true }]], + sessionAttributes: { tenant: 'emea', beta: true, tier: 2 }, + applicationAttributes: { team: 'desk' }, + allowCookies: true, + sessionEventLimit: 500, + pageIdFormat: 'PATH_AND_HASH', + endpoint: 'https://dataplane.rum.eu-west-1.amazonaws.com', + headers: { 'x-tenant': 'emea' }, + cookieAttributes: { secure: true, sameSite: 'Strict' }, + compressionStrategy: { enabled: true } + }; + answer({ ...valid, config: { ...valid.config, ...settings } }); + expect((await load())?.config).toEqual({ + ...valid.config, + ...settings, + disableAutoPageView: true + }); + }); + + it('drops wrongly typed and unknown settings, and keeps automatic page views off', async () => { + answer({ + ...valid, + config: { + ...valid.config, + disableAutoPageView: false, + allowCookies: 'yes', + sessionEventLimit: '500', + pageIdFormat: 'FULL_URL', + sessionAttributes: { nested: { a: 1 } }, + telemetries: [42], + identityPoolId: 'eu-west-1:pool' + } + }); + expect((await load())?.config).toEqual({ ...valid.config, disableAutoPageView: true }); + }); + + it.each([ + ['a number entry', [42]], + ['a tuple without a name', [[42]]], + ['a tuple with a non-object option', [['http', 'all']]], + ['an empty tuple', [[]]], + ['a tuple with extra entries', [['http', {}, 'extra']]] + ])('drops malformed telemetries: %s', async (_label, telemetries) => { + answer({ ...valid, config: { ...valid.config, telemetries } }); + expect((await load())?.config).not.toHaveProperty('telemetries'); + }); + + it('drops an out-of-range sample rate instead of passing it on', async () => { + answer({ ...valid, config: { ...valid.config, sessionSampleRate: 5 } }); + expect((await load())?.config.sessionSampleRate).toBeUndefined(); + }); +}); diff --git a/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts new file mode 100644 index 0000000..7164cf2 --- /dev/null +++ b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts @@ -0,0 +1,182 @@ +import { Injectable } from '@angular/core'; +import type { + UwtRumAppMonitorConfig, + UwtRumBootstrap, + UwtRumCredentialsProvider +} from '@absaoss-cps/ngx-ui-watchtower/rum'; + +/** The app's broker: RUM app-monitor settings plus short-lived AWS credentials. */ +const RUM_BROKER_URL = '/rum/init'; + +/** + * Expected answer — adapt to the real one: + * + * ```json + * { + * "enabled": true, + * "config": { "applicationId": "…", "region": "eu-west-1", "applicationVersion": "1.4.0", + * "sessionSampleRate": 1, "telemetries": ["errors", "performance", "http"] }, + * "credentials": { "accessKeyId": "…", "secretAccessKey": "…", "sessionToken": "…", + * "expiration": "2026-10-08T12:00:00Z" } + * } + * ``` + */ +const REQUIRED_CONFIG = ['applicationId', 'region', 'applicationVersion'] as const; +const REQUIRED_CREDENTIALS = [ + 'accessKeyId', + 'secretAccessKey', + 'sessionToken', + 'expiration' +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Optional settings a broker can send as JSON, kept only when correctly typed. Functions, + * `RegExp`s and plugins (`pagesToInclude`, `eventPluginsToLoad`, …) can't travel as JSON. + */ +const NUMBER_SETTINGS = [ + 'sessionLengthSeconds', + 'sessionEventLimit', + 'userIdRetentionDays', + 'batchLimit', + 'dispatchInterval', + 'eventCacheSize', + 'candidatesCacheSize', + 'retries', + 'routeChangeComplete', + 'routeChangeTimeout' +] as const; +const BOOLEAN_SETTINGS = [ + 'suppressSessionStartEvent', + 'enableXRay', + 'enableW3CTraceId', + 'recordResourceUrl', + 'useBeacon', + 'signing', + 'allowCookies', + 'debug', + 'enableRumClient' +] as const; +const STRING_SETTINGS = ['endpoint', 'alias', 'client', 'releaseId'] as const; +const PAGE_ID_FORMATS = ['PATH', 'HASH', 'PATH_AND_HASH']; + +function isFlatRecord( + value: unknown, + isEntry: (entry: unknown) => entry is T +): value is Record { + return isRecord(value) && Object.values(value).every(isEntry); +} +const isPrimitive = (v: unknown): v is string | number | boolean => + ['string', 'number', 'boolean'].includes(typeof v); +const isString = (v: unknown): v is string => typeof v === 'string'; +const isStringOrBoolean = (v: unknown): v is string | boolean => + typeof v === 'string' || typeof v === 'boolean'; +/** A telemetry name (`'errors'`) or a `[name]` / `[name, options]` tuple (`['http', { … }]`). */ +const isTelemetry = (t: unknown): boolean => + isString(t) || + (Array.isArray(t) && + (t.length === 1 || t.length === 2) && + isString(t[0]) && + (t.length === 1 || isRecord(t[1]))); + +function optionalSettings(config: Record): Partial { + const settings: Record = {}; + const keep = (key: string, ok: boolean): void => { + if (ok) { + settings[key] = config[key]; + } + }; + for (const key of NUMBER_SETTINGS) { + keep(key, typeof config[key] === 'number' && Number.isFinite(config[key])); + } + for (const key of BOOLEAN_SETTINGS) { + keep(key, typeof config[key] === 'boolean'); + } + for (const key of STRING_SETTINGS) { + keep(key, isString(config[key]) && config[key] !== ''); + } + const rate = config['sessionSampleRate']; + keep('sessionSampleRate', typeof rate === 'number' && rate >= 0 && rate <= 1); + keep('pageIdFormat', PAGE_ID_FORMATS.includes(config['pageIdFormat'] as string)); + keep('sessionAttributes', isFlatRecord(config['sessionAttributes'], isPrimitive)); + keep('applicationAttributes', isFlatRecord(config['applicationAttributes'], isPrimitive)); + keep('headers', isFlatRecord(config['headers'], isString)); + keep('cookieAttributes', isFlatRecord(config['cookieAttributes'], isStringOrBoolean)); + const telemetries = config['telemetries']; + keep('telemetries', Array.isArray(telemetries) && telemetries.every(isTelemetry)); + const compression = config['compressionStrategy']; + if (isRecord(compression) && typeof compression['enabled'] === 'boolean') { + settings['compressionStrategy'] = { enabled: compression['enabled'] }; + } + return settings as Partial; +} + +function hasStrings( + value: unknown, + keys: readonly K[] +): value is Record & Record { + return ( + isRecord(value) && + keys.every((key) => typeof value[key] === 'string' && value[key] !== '') + ); +} + +/** + * Called at startup and ~5 min before each credential expiry, so the browser never holds a + * long-lived AWS identity. + */ +@Injectable({ providedIn: 'root' }) +export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { + /** + * `null` (RUM off for the session) when the broker turns RUM off or answers with anything + * incomplete, malformed or expired. A failed request throws instead: on a refresh the library + * then keeps the current credentials and retries in 30 s, where `null` would end RUM for the + * session. At startup both just leave RUM off. + */ + async load(): Promise { + const response = await fetch(RUM_BROKER_URL, { + headers: { Accept: 'application/json' }, + cache: 'no-store' // live AWS credentials + }); + if (!response.ok) { + throw new Error(`RUM broker answered HTTP ${response.status}`); + } + // A body that isn't JSON (a gateway's error page) rejects here: also a failed request. + const body: unknown = await response.json(); + + // Validate, don't cast: a half-filled answer would start a client that silently fails. + if (!isRecord(body) || body['enabled'] !== true) { + return null; + } + const { config, credentials } = body; + if ( + !hasStrings(config, REQUIRED_CONFIG) || + !hasStrings(credentials, REQUIRED_CREDENTIALS) || + // Unparseable or already expired: RUM would reject every dispatch. + !(Date.parse(credentials.expiration) > Date.now()) + ) { + return null; + } + + return { + config: { + ...optionalSettings(config), + applicationId: config.applicationId, + region: config.region, + applicationVersion: config.applicationVersion, + // Always: page views come from the route service, under the template — automatic ones + // would send the resolved path (/customers/42). + disableAutoPageView: true + }, + credentials: { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + expiration: credentials.expiration + } + }; + } +} diff --git a/skills/ui-watchtower-setup/assets/telemetry.schema.ts b/skills/ui-watchtower-setup/assets/telemetry.schema.ts new file mode 100644 index 0000000..15dae22 --- /dev/null +++ b/skills/ui-watchtower-setup/assets/telemetry.schema.ts @@ -0,0 +1,42 @@ +/** + * This application's telemetry vocabulary. + * + * Scenario, step and business event names are metric dimensions, declared + * once here rather than as free text at each call site. Once a registry has + * an entry, a name not listed in it is a compile error. + * + * Keep a side-effect import of this file (`import './telemetry.schema';`) + * where the telemetry providers are configured and in the router telemetry + * service, so it can never become unreferenced. + */ +declare module '@absaoss-cps/ngx-ui-watchtower' { + interface UwtScenarioNames { + /** A router navigation, from click to activated route, under the final route template. */ + 'route-navigation': true; + + // One entry per confirmed journey, kebab-case, with a line saying what it measures: + // /** Loading the customer list, from request to first render. */ + // 'customers-load': true; + } + + // Add once the first journey with steps is confirmed (kebab-case; steps and + // aggregates share this registry): + // interface UwtScenarioSteps { + // /** Fetching data from the API. */ + // fetch: true; + // } + + // Add once the first business event is confirmed (snake_case): + // interface UwtBIEventNames { + // /** The user exported the current result set. */ + // export_clicked: true; + // } + + // Only when the app has a log backend (UWT_LOG_API_PROVIDER bound to a real provider): + // interface UwtLoggerNames { + // /** Application lifecycle and routing. */ + // app: true; + // } +} + +export {}; diff --git a/skills/ui-watchtower-setup/evals/evals.json b/skills/ui-watchtower-setup/evals/evals.json new file mode 100644 index 0000000..a02a9ff --- /dev/null +++ b/skills/ui-watchtower-setup/evals/evals.json @@ -0,0 +1,251 @@ +{ + "skill_name": "ui-watchtower-setup", + "evals": [ + { + "id": "happy-path-standalone-angular", + "category": "happy-path", + "prompt": "Set up ngx-ui-watchtower in this Angular app. RUM credentials come from GET /rum/init. We have no log backend. The project is in evals/files/standalone-angular.", + "expected_output": "Skill detects a standalone Angular 22 app with a router and Vitest, installs the package and aws-rum-web, wires provideUwtTelemetry + provideUwtTelemetryRumSink + UWT_RUM_CREDENTIALS_PROVIDER, creates telemetry.schema.ts, adds route-navigation tracking as the single proof scenario (reporting /customers/:customerId as a template) with a recording-sink test, adds app.spec.ts providers only if App injects telemetry, validates, and hands over to ui-watchtower-integration for the app's own journeys.", + "files": [ + "evals/files/standalone-angular/package.json", + "evals/files/standalone-angular/angular.json", + "evals/files/standalone-angular/tsconfig.json", + "evals/files/standalone-angular/tsconfig.app.json", + "evals/files/standalone-angular/tsconfig.spec.json", + "evals/files/standalone-angular/src/index.html", + "evals/files/standalone-angular/src/styles.css", + "evals/files/standalone-angular/src/main.ts", + "evals/files/standalone-angular/src/app/app.config.ts", + "evals/files/standalone-angular/src/app/app.routes.ts", + "evals/files/standalone-angular/src/app/app.ts", + "evals/files/standalone-angular/src/app/app.spec.ts", + "evals/files/standalone-angular/src/app/customers/customers.ts", + "evals/files/standalone-angular/src/app/customers/customer-detail.ts" + ], + "expectations": [ + "Reads package.json and the app config before writing anything, and identifies the standalone bootstrap, the router and the test runner", + "Installs @absaoss-cps/ngx-ui-watchtower and aws-rum-web with the detected package manager", + "Adds allowedCommonJsDependencies: ['shimmer'] to the build options in angular.json", + "Adds provideUwtTelemetry({ application, environment, version }) and provideUwtTelemetryRumSink() to app.config.ts, importing RUM symbols only from '@absaoss-cps/ngx-ui-watchtower/rum'", + "Binds UWT_RUM_CREDENTIALS_PROVIDER to a credentials provider fetching /rum/init that returns null for an unusable answer and throws when the request fails", + "Does NOT inject UwtLoggerService, pass withLogging, or declare UwtLoggerNames (no log backend)", + "Creates telemetry.schema.ts augmenting UwtScenarioNames, and side-effect-imports it where the providers are configured", + "Adds route-navigation tracking (the asset service, started with provideRouteNavigationTelemetry() in the app providers) as the single proof scenario, with a recording-sink test, and does not instrument other journeys or BI events", + "Reports /customers/:customerId as a route template, never a resolved id (the asset used unchanged, or equivalent), and the route-navigation test asserts it", + "Binds ROUTE_PAGE_VIEW_RECORDER to UwtRumTelemetrySink.recordPageView, with disableAutoPageView: true in the credentials provider, so page views use the route template", + "Gives app.spec.ts telemetry providers if App now injects a telemetry service (otherwise NG0201), and leaves it unchanged if it doesn't", + "Runs (or, where it cannot execute, lists for the user) the build, the unit tests and a real type-check (tsc -p per tsconfig), not just ng test", + "Ends by pointing the user to ui-watchtower-integration for measuring the app's own journeys and interactions" + ] + }, + { + "id": "happy-path-ssr-shell-hosting-fragments", + "category": "happy-path", + "prompt": "Wire ngx-ui-watchtower into our SSR shell. It hosts the cart and catalogue fragments. The project is in evals/files/ssr-shell.", + "expected_output": "Skill recognises the SSR shell role, registers providers unconditionally in the shared app config, adds the RUM sink, the broadcast host with a per-page-load channel id published on window, binds UwtNoopLogApiProvider because there is no log backend, and produces the fragment contract for the fragment teams.", + "files": [ + "evals/files/ssr-shell/package.json", + "evals/files/ssr-shell/README.md", + "evals/files/ssr-shell/src/app/app.config.ts", + "evals/files/ssr-shell/src/app/app.config.server.ts", + "evals/files/ssr-shell/src/app/app.routes.ts", + "evals/files/ssr-shell/src/app/app.routes.server.ts", + "evals/files/ssr-shell/src/app/app.ts" + ], + "expectations": [ + "Identifies the app as a shell hosting fragments (from usage and the README)", + "Registers the telemetry providers in the shared app.config.ts, not split between browser and server configs, and without platform checks around them", + "Adds provideUwtTelemetryBroadcastHost(channelId) with a random channel id (that does not depend on crypto.randomUUID alone, which needs a secure context) generated once per page load and published as window.__uwtTelemetryChannel, guarded for server rendering", + "Binds UWT_LOG_API_PROVIDER to UwtNoopLogApiProvider because the README says there is no log backend", + "Points the credentials provider at GET /api/rum/credentials from the README", + "Provides the fragment contract (broadcast sink, lazy UWT_BROADCAST_CHANNEL factory reading window.top, distinct application name, never the RUM sink) as a hand-off rather than editing fragment code", + "Does not install aws-rum-web into any fragment" + ] + }, + { + "id": "happy-path-fragment", + "category": "happy-path", + "prompt": "Our cart fragment needs to send telemetry through the shell. Set up ngx-ui-watchtower here. The project is in evals/files/fragment.", + "expected_output": "Skill configures the fragment to forward: provideUwtTelemetry with its own application name, provideUwtTelemetrySink('broadcast'), and a lazy UWT_BROADCAST_CHANNEL useFactory reading window.top.__uwtTelemetryChannel. No RUM sink, no credentials, no broadcast host, no log provider, no aws-rum-web.", + "files": [ + "evals/files/fragment/package.json", + "evals/files/fragment/README.md", + "evals/files/fragment/src/app/app.config.ts", + "evals/files/fragment/src/app/app.routes.ts" + ], + "expectations": [ + "Uses provideUwtTelemetrySink('broadcast') as the only destination", + "Provides UWT_BROADCAST_CHANNEL with useFactory (lazy), not provideUwtTelemetrySink('broadcast', { channelName }) with an eagerly computed value", + "Guards the window.top read for server rendering and cross-origin access, and falls back to a private channel nobody hosts — never undefined, which would select the shared default channel", + "Uses an application name distinct from the shell (e.g. 'cart')", + "Does not add provideUwtTelemetryRumSink, UWT_RUM_CREDENTIALS_PROVIDER, provideUwtTelemetryBroadcastHost or UWT_LOG_API_PROVIDER", + "Does not install aws-rum-web" + ] + }, + { + "id": "rum-not-ready-noop", + "category": "happy-path", + "prompt": "Set up ngx-ui-watchtower in this Angular app. RUM isn't ready yet — we don't have a credentials endpoint. No log backend. The project is in evals/files/standalone-angular.", + "expected_output": "Skill wires provideUwtTelemetry with the explicit 'noop' destination, installs only the library (no aws-rum-web, no shimmer allowance, no credentials provider), still adds the schema, route-navigation proof and tests, and explains the one-line swap to the RUM sink later.", + "files": [ + "evals/files/standalone-angular/package.json", + "evals/files/standalone-angular/angular.json", + "evals/files/standalone-angular/tsconfig.json", + "evals/files/standalone-angular/tsconfig.app.json", + "evals/files/standalone-angular/tsconfig.spec.json", + "evals/files/standalone-angular/src/index.html", + "evals/files/standalone-angular/src/styles.css", + "evals/files/standalone-angular/src/main.ts", + "evals/files/standalone-angular/src/app/app.config.ts", + "evals/files/standalone-angular/src/app/app.routes.ts", + "evals/files/standalone-angular/src/app/app.ts", + "evals/files/standalone-angular/src/app/app.spec.ts", + "evals/files/standalone-angular/src/app/customers/customers.ts", + "evals/files/standalone-angular/src/app/customers/customer-detail.ts" + ], + "expectations": [ + "Uses provideUwtTelemetrySink('noop') as the only destination", + "Does not install aws-rum-web, add the shimmer allowance, import from '@absaoss-cps/ngx-ui-watchtower/rum', or add a credentials provider", + "Does not bind a page-view recorder (no RUM sink to record to)", + "Creates telemetry.schema.ts and adds route-navigation tracking with a recording-sink test", + "Gives app.spec.ts telemetry providers if App now injects a telemetry service (otherwise NG0201), and leaves it unchanged if it doesn't", + "Explains how to switch to RUM later: replace the noop line with provideUwtTelemetryRumSink() plus a UWT_RUM_CREDENTIALS_PROVIDER, bind ROUTE_PAGE_VIEW_RECORDER, and install aws-rum-web", + "Ends by pointing the user to ui-watchtower-integration" + ] + }, + { + "id": "telemetry-off-in-dev", + "category": "happy-path", + "prompt": "Set up ngx-ui-watchtower in this Angular app. RUM credentials come from GET /rum/init. We have no log backend. Developers don't want telemetry running on their local dev server — only in built deployments. The project is in evals/files/standalone-angular.", + "expected_output": "Skill wires the app as in the standalone happy path, with the same providers in every environment, and turns telemetry off on the dev server with one withTelemetryEnabled(...) feature on provideUwtTelemetry instead of conditional providers or a 'noop' swap.", + "files": [ + "evals/files/standalone-angular/package.json", + "evals/files/standalone-angular/angular.json", + "evals/files/standalone-angular/tsconfig.json", + "evals/files/standalone-angular/tsconfig.app.json", + "evals/files/standalone-angular/tsconfig.spec.json", + "evals/files/standalone-angular/src/index.html", + "evals/files/standalone-angular/src/styles.css", + "evals/files/standalone-angular/src/main.ts", + "evals/files/standalone-angular/src/app/app.config.ts", + "evals/files/standalone-angular/src/app/app.routes.ts", + "evals/files/standalone-angular/src/app/app.ts", + "evals/files/standalone-angular/src/app/app.spec.ts", + "evals/files/standalone-angular/src/app/customers/customers.ts", + "evals/files/standalone-angular/src/app/customers/customer-detail.ts" + ], + "expectations": [ + "Installs a release that exports withTelemetryEnabled (0.2.0 or later)", + "Passes withTelemetryEnabled(...) to provideUwtTelemetry, off on the dev server (e.g. from isDevMode() or the app's environment) and on otherwise, imported from '@absaoss-cps/ngx-ui-watchtower'", + "Registers provideUwtTelemetryRumSink() and UWT_RUM_CREDENTIALS_PROVIDER unconditionally — no per-environment provider lists, platform or environment checks around them, and no 'noop' destination for dev", + "Does not add environment checks to the credentials provider, the route-navigation service or other app code to stop telemetry in dev", + "Keeps tests on the 'noop' test providers (or a recording sink) without withTelemetryEnabled(false), so the route-navigation test still sees events", + "Explains that the live debug check shows nothing on the dev server while telemetry is off there, and how to check it where it is on", + "Ends by pointing the user to ui-watchtower-integration" + ] + }, + { + "id": "partially-wired-fills-gaps", + "category": "regression", + "prompt": "Someone started wiring ngx-ui-watchtower in this app but never finished. RUM credentials come from GET /rum/init; no log backend. Finish the setup. The project is in evals/files/partially-wired-angular.", + "expected_output": "Skill detects the existing provideUwtTelemetry and the missing destination, keeps the single existing configuration, adds the RUM sink and credentials provider, the schema, route-navigation proof and spec providers, and hands over — without duplicating anything already present.", + "files": [ + "evals/files/partially-wired-angular/angular.json", + "evals/files/partially-wired-angular/package.json", + "evals/files/partially-wired-angular/src/app/app.config.ts", + "evals/files/partially-wired-angular/src/app/app.routes.ts", + "evals/files/partially-wired-angular/src/app/app.spec.ts", + "evals/files/partially-wired-angular/src/app/app.ts", + "evals/files/partially-wired-angular/src/app/customers/customer-detail.ts", + "evals/files/partially-wired-angular/src/app/customers/customers.ts", + "evals/files/partially-wired-angular/src/index.html", + "evals/files/partially-wired-angular/src/main.ts", + "evals/files/partially-wired-angular/src/styles.css", + "evals/files/partially-wired-angular/tsconfig.app.json", + "evals/files/partially-wired-angular/tsconfig.json", + "evals/files/partially-wired-angular/tsconfig.spec.json" + ], + "expectations": [ + "Detects that the package and provideUwtTelemetry are already present and that no destination is bound", + "Keeps exactly one provideUwtTelemetry call (reuses or edits the existing one, does not add a second)", + "Adds provideUwtTelemetryRumSink() and a UWT_RUM_CREDENTIALS_PROVIDER fetching /rum/init, importing RUM symbols only from '/rum'", + "Creates telemetry.schema.ts and side-effect-imports it where the providers are configured", + "Adds route-navigation tracking with a recording-sink test, and telemetry providers in app.spec.ts only if App now injects a telemetry service", + "Ends by pointing the user to ui-watchtower-integration" + ] + }, + { + "id": "regression-existing-rum-parity", + "category": "regression", + "prompt": "Replace our hand-written RumService with ngx-ui-watchtower. The project is in evals/files/ngmodule-existing-rum.", + "expected_output": "Skill loads references/rum-migration.md, inventories every recordEvent/recordPageView/addSessionAttributes call with its event type, payload and consumers, picks a parity tool per row (the dashboard and metric filter need desk.order.* types), keeps RumService until the wire is diffed, flags the Cognito identity pool as unsupported and requiring a credential broker, replaces the email session attribute with an opaque user id, fixes the live per-keystroke raw search event right away (debounced, no raw text) even though the RUM switch waits for a broker, and records page views once, under the route template.", + "files": [ + "evals/files/ngmodule-existing-rum/package.json", + "evals/files/ngmodule-existing-rum/src/app/app.module.ts", + "evals/files/ngmodule-existing-rum/src/app/app-routing.module.ts", + "evals/files/ngmodule-existing-rum/src/app/app.component.ts", + "evals/files/ngmodule-existing-rum/src/app/services/rum.service.ts", + "evals/files/ngmodule-existing-rum/src/app/search/search.component.ts" + ], + "expectations": [ + "Builds an inventory table of every existing RUM call: event type, payload keys and consumers (the 'Desk usage' dashboard and 'order-failures' metric filter)", + "Chooses an explicit parity tool for the desk.order.* events (eventType override or raw sink.record) instead of silently switching them to com.uwt.bi", + "Does not delete RumService before the migrated paths are diffed against the inventory", + "Flags that identityPoolId/guestRoleArn (Cognito) is not supported and a backend credential broker is needed, and asks rather than inventing one", + "Does not run two RUM clients side by side", + "Keeps exactly one page-view path and does not start a second one: the old manual recordPageView stays the only recorder until the cutover, and its page ids carry no query string or fragment (the route-template recorder with disableAutoPageView: true is planned for the cutover)", + "Replaces the email session attribute with an opaque user id in the old service (planning sink.setUserId(opaqueId) for the cutover) and calls out that emails must not be sent", + "Replaces per-keystroke search tracking with a debounced event that carries no raw query text (for example queryLength)" + ] + }, + { + "id": "regression-no-barrel-reexport-of-rum", + "category": "regression", + "prompt": "Put all telemetry imports behind one barrel file src/app/telemetry/index.ts so components import from there. The project is in evals/files/standalone-angular.", + "expected_output": "Skill may add a barrel for app-owned telemetry code but refuses to re-export anything from '@absaoss-cps/ngx-ui-watchtower/rum', explaining that it would pull aws-rum-web into every consumer's build.", + "files": [ + "evals/files/standalone-angular/package.json", + "evals/files/standalone-angular/src/app/app.config.ts" + ], + "expectations": [ + "Does not re-export provideUwtTelemetryRumSink, UwtRumTelemetrySink or any '/rum' symbol from a shared barrel", + "Explains the reason (the /rum entry point keeps aws-rum-web out of builds that don't need it)", + "Does not introduce deep imports from '@absaoss-cps/ngx-ui-watchtower/src/...'" + ] + }, + { + "id": "unsupported-angular-version", + "category": "negative", + "prompt": "Set up ngx-ui-watchtower in this Angular app. RUM credentials come from GET /rum/init. We have no log backend. The project is in evals/files/future-angular.", + "expected_output": "Skill reads the app's Angular version (24) and the library's supported range from the registry (npm view …@latest peerDependencies), finds that Angular 24 is outside it, stops without installing or wiring anything, never suggests --force or --legacy-peer-deps, and tells the user the library doesn't support this Angular version yet.", + "files": [ + "evals/files/future-angular/package.json", + "evals/files/future-angular/src/main.ts", + "evals/files/future-angular/src/app/app.config.ts" + ], + "expectations": [ + "Reads the app's @angular/core version (^24) from package.json", + "Reads the library's supported Angular range from the registry (npm view @absaoss-cps/ngx-ui-watchtower peerDependencies or equivalent) instead of assuming one", + "Concludes that Angular 24 is outside the supported range", + "Does not install the package, add providers or change any project file", + "Does not suggest or use --force or --legacy-peer-deps", + "Tells the user the library doesn't support this Angular version yet, and what would unblock it (a library release that supports it)" + ] + }, + { + "id": "negative-non-angular", + "category": "negative", + "prompt": "Add ngx-ui-watchtower telemetry to our marketing site. The project is in evals/files/react-app.", + "expected_output": "Skill detects a React app, explains that the library is Angular-only, and does not install or wire anything.", + "files": [ + "evals/files/react-app/package.json", + "evals/files/react-app/src/main.tsx" + ], + "expectations": [ + "Detects that the app is React, not Angular", + "States that @absaoss-cps/ngx-ui-watchtower is for Angular applications only", + "Does not install the package or modify any file" + ] + } + ] +} diff --git a/skills/ui-watchtower-setup/evals/files/fragment/README.md b/skills/ui-watchtower-setup/evals/files/fragment/README.md new file mode 100644 index 0000000..d43eed6 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/fragment/README.md @@ -0,0 +1,4 @@ +# cart-fragment + +Embedded as `` inside the workspace shell, which already sends +telemetry to CloudWatch RUM with ngx-ui-watchtower and hosts the broadcast channel. diff --git a/skills/ui-watchtower-setup/evals/files/fragment/package.json b/skills/ui-watchtower-setup/evals/files/fragment/package.json new file mode 100644 index 0000000..eed8be4 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/fragment/package.json @@ -0,0 +1,22 @@ +{ + "name": "cart-fragment", + "version": "5.1.0", + "scripts": { "start": "ng serve", "build": "ng build", "test": "ng test" }, + "private": true, + "dependencies": { + "@angular/common": "^22.2.0", + "@angular/compiler": "^22.2.0", + "@angular/core": "^22.2.0", + "@angular/platform-browser": "^22.2.0", + "@angular/router": "^22.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^22.2.0", + "@angular/cli": "^22.2.0", + "@angular/compiler-cli": "^22.2.0", + "typescript": "~6.0.2", + "vitest": "^4.0.8" + } +} diff --git a/skills/ui-watchtower-setup/evals/files/fragment/src/app/app.config.ts b/skills/ui-watchtower-setup/evals/files/fragment/src/app/app.config.ts new file mode 100644 index 0000000..c5ee800 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/fragment/src/app/app.config.ts @@ -0,0 +1,7 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)] +}; diff --git a/skills/ui-watchtower-setup/evals/files/fragment/src/app/app.routes.ts b/skills/ui-watchtower-setup/evals/files/fragment/src/app/app.routes.ts new file mode 100644 index 0000000..dc39edb --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/fragment/src/app/app.routes.ts @@ -0,0 +1,3 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = []; diff --git a/skills/ui-watchtower-setup/evals/files/future-angular/package.json b/skills/ui-watchtower-setup/evals/files/future-angular/package.json new file mode 100644 index 0000000..a7b895f --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-angular/package.json @@ -0,0 +1,23 @@ +{ + "name": "customer-portal", + "version": "1.4.0", + "scripts": { "start": "ng serve", "build": "ng build", "test": "ng test" }, + "private": true, + "dependencies": { + "@angular/common": "^24.0.0", + "@angular/compiler": "^24.0.0", + "@angular/core": "^24.0.0", + "@angular/platform-browser": "^24.0.0", + "@angular/router": "^24.0.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^24.0.0", + "@angular/cli": "^24.0.0", + "@angular/compiler-cli": "^24.0.0", + "jsdom": "^28.0.0", + "typescript": "~6.0.2", + "vitest": "^4.0.8" + } +} diff --git a/skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.config.ts b/skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.config.ts new file mode 100644 index 0000000..9dba522 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.config.ts @@ -0,0 +1,8 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes), provideHttpClient()] +}; diff --git a/skills/ui-watchtower-setup/evals/files/future-angular/src/main.ts b/skills/ui-watchtower-setup/evals/files/future-angular/src/main.ts new file mode 100644 index 0000000..190f341 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-angular/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/package.json b/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/package.json new file mode 100644 index 0000000..eba4a59 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/package.json @@ -0,0 +1,25 @@ +{ + "name": "trading-desk", + "version": "3.2.0", + "scripts": { "start": "ng serve", "build": "ng build", "test": "ng test" }, + "private": true, + "dependencies": { + "@angular/common": "^22.2.0", + "@angular/compiler": "^22.2.0", + "@angular/core": "^22.2.0", + "@angular/platform-browser": "^22.2.0", + "@angular/router": "^22.2.0", + "aws-rum-web": "^3.2.1", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular-builders/jest": "^22.0.1", + "@angular/build": "^22.2.0", + "@angular/cli": "^22.2.0", + "@angular/compiler-cli": "^22.2.0", + "jest": "^30.3.0", + "jest-preset-angular": "^17.0.0", + "typescript": "~6.0.2" + } +} diff --git a/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/app-routing.module.ts b/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/app-routing.module.ts new file mode 100644 index 0000000..d7ea4db --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/app-routing.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +const routes: Routes = []; + +@NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule] +}) +export class AppRoutingModule {} diff --git a/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/app.component.ts b/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/app.component.ts new file mode 100644 index 0000000..7d72e0d --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/app.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + standalone: false, + template: `` +}) +export class AppComponent {} diff --git a/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/app.module.ts b/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/app.module.ts new file mode 100644 index 0000000..9d59604 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/app.module.ts @@ -0,0 +1,21 @@ +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { AppRoutingModule } from './app-routing.module'; +import { AppComponent } from './app.component'; +import { SearchComponent } from './search/search.component'; +import { RumService } from './services/rum.service'; + +@NgModule({ + declarations: [AppComponent, SearchComponent], + imports: [BrowserModule, AppRoutingModule], + providers: [ + { + provide: APP_INITIALIZER, + useFactory: (rum: RumService) => () => rum.init(), + deps: [RumService], + multi: true + } + ], + bootstrap: [AppComponent] +}) +export class AppModule {} diff --git a/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/search/search.component.ts b/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/search/search.component.ts new file mode 100644 index 0000000..cb50bf6 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/search/search.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { RumService } from '../services/rum.service'; + +@Component({ + selector: 'app-search', + standalone: false, + template: `` +}) +export class SearchComponent { + constructor(private readonly rum: RumService) {} + + onInput(value: string): void { + this.rum.trackSearch(value); + } +} diff --git a/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/services/rum.service.ts b/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/services/rum.service.ts new file mode 100644 index 0000000..14fe8e2 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/services/rum.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@angular/core'; +import { Router, NavigationEnd } from '@angular/router'; +import { AwsRum, AwsRumConfig } from 'aws-rum-web'; +import { filter } from 'rxjs'; + +/** + * Hand-written CloudWatch RUM integration. The "Desk usage" dashboard and the + * "order-failures" metric filter query the event types below. + */ +@Injectable({ providedIn: 'root' }) +export class RumService { + private rum?: AwsRum; + + constructor(private readonly router: Router) {} + + async init(): Promise { + const res = await fetch('/rum/config'); + const { applicationId, region, identityPoolId, guestRoleArn } = await res.json(); + const config: AwsRumConfig = { + sessionSampleRate: 1, + identityPoolId, + guestRoleArn, + telemetries: ['errors', 'performance', 'http'], + disableAutoPageView: true + }; + this.rum = new AwsRum(applicationId, '3.2.0', region, config); + + this.router.events + .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) + .subscribe((e) => this.rum?.recordPageView(e.urlAfterRedirects)); + } + + trackOrderSubmitted(instrument: string, quantity: number): void { + this.rum?.recordEvent('desk.order.submitted', { instrument, quantity }); + } + + trackOrderFailed(instrument: string, error: unknown): void { + this.rum?.recordEvent('desk.order.failed', { + instrument, + message: error instanceof Error ? error.message : String(error) + }); + } + + trackSearch(query: string): void { + this.rum?.recordEvent('desk.search', { query }); + } + + setUser(email: string | undefined): void { + if (email) { + this.rum?.addSessionAttributes({ user: email }); + } + } +} diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/angular.json b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/angular.json new file mode 100644 index 0000000..bbd3c48 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/angular.json @@ -0,0 +1,70 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm" + }, + "newProjectRoot": "projects", + "projects": { + "customer-portal": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "styles": [ + "src/styles.css" + ], + "security": { + "allowedHosts": [] + } + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "customer-portal:build:production" + }, + "development": { + "buildTarget": "customer-portal:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test" + } + } + } + } +} diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/package.json b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/package.json new file mode 100644 index 0000000..dad7717 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/package.json @@ -0,0 +1,25 @@ +{ + "name": "customer-portal", + "version": "1.4.0", + "scripts": { "start": "ng serve", "build": "ng build", "test": "ng test" }, + "private": true, + "dependencies": { + "@absaoss-cps/ngx-ui-watchtower": "latest", + "@angular/common": "^22.2.0", + "@angular/compiler": "^22.2.0", + "@angular/core": "^22.2.0", + "@angular/platform-browser": "^22.2.0", + "@angular/router": "^22.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@types/node": "^20.17.19", + "@angular/build": "^22.2.0", + "@angular/cli": "^22.2.0", + "@angular/compiler-cli": "^22.2.0", + "jsdom": "^28.0.0", + "typescript": "~6.0.2", + "vitest": "^4.0.8" + } +} diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.config.ts b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.config.ts new file mode 100644 index 0000000..69d22b2 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.config.ts @@ -0,0 +1,15 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; +import { provideUwtTelemetry } from '@absaoss-cps/ngx-ui-watchtower'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes, withComponentInputBinding()), + provideHttpClient(), + // TODO(telemetry): finish wiring — started in the last sprint + provideUwtTelemetry({ application: 'customer-portal', environment: 'prod', version: '1.4.0' }) + ] +}; diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.routes.ts b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.routes.ts new file mode 100644 index 0000000..097aa25 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.routes.ts @@ -0,0 +1,13 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { path: '', pathMatch: 'full', redirectTo: 'customers' }, + { + path: 'customers', + loadComponent: () => import('./customers/customers').then((m) => m.Customers) + }, + { + path: 'customers/:customerId', + loadComponent: () => import('./customers/customer-detail').then((m) => m.CustomerDetail) + } +]; diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.spec.ts b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.spec.ts new file mode 100644 index 0000000..8e82882 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + providers: [provideRouter([])] + }).compileComponents(); + }); + + it('should create the app', () => { + expect(TestBed.createComponent(App).componentInstance).toBeTruthy(); + }); +}); diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.ts b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.ts new file mode 100644 index 0000000..241dab5 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { RouterLink, RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, RouterLink], + template: ` + + + ` +}) +export class App {} diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/customers/customer-detail.ts b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/customers/customer-detail.ts new file mode 100644 index 0000000..8855ad4 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/customers/customer-detail.ts @@ -0,0 +1,9 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'app-customer-detail', + template: `

Customer {{ customerId() }}

` +}) +export class CustomerDetail { + readonly customerId = input.required(); +} diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/customers/customers.ts b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/customers/customers.ts new file mode 100644 index 0000000..b2b9cd7 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/customers/customers.ts @@ -0,0 +1,36 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, inject, OnInit, signal } from '@angular/core'; + +interface Customer { + id: string; + name: string; +} + +@Component({ + selector: 'app-customers', + template: ` + @if (error()) { +

Could not load customers.

+ } + @for (c of customers(); track c.id) { +

{{ c.name }}

+ } + + ` +}) +export class Customers implements OnInit { + private readonly http = inject(HttpClient); + protected readonly customers = signal([]); + protected readonly error = signal(false); + + ngOnInit(): void { + this.http.get('/api/customers').subscribe({ + next: (rows) => this.customers.set(rows), + error: () => this.error.set(true) + }); + } + + exportCsv(): void { + window.location.href = '/api/customers/export?format=csv'; + } +} diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/index.html b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/index.html new file mode 100644 index 0000000..1e743a7 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/index.html @@ -0,0 +1,12 @@ + + + + + Customer portal + + + + + + + diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/main.ts b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/main.ts new file mode 100644 index 0000000..190f341 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/styles.css b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/styles.css new file mode 100644 index 0000000..4b438c4 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/styles.css @@ -0,0 +1 @@ +/* Global styles */ diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.app.json b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.app.json new file mode 100644 index 0000000..d5d9680 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.app.json @@ -0,0 +1,18 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "rootDir": "./src", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.json b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.json new file mode 100644 index 0000000..d2fbb9c --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.json @@ -0,0 +1,31 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.spec.json b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.spec.json new file mode 100644 index 0000000..4c1202f --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.spec.json @@ -0,0 +1,16 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "rootDir": "./src", + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} diff --git a/skills/ui-watchtower-setup/evals/files/react-app/package.json b/skills/ui-watchtower-setup/evals/files/react-app/package.json new file mode 100644 index 0000000..0194db2 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/react-app/package.json @@ -0,0 +1,7 @@ +{ + "name": "marketing-site", + "version": "1.0.0", + "private": true, + "dependencies": { "react": "^19.1.0", "react-dom": "^19.1.0" }, + "devDependencies": { "vite": "^7.0.0", "typescript": "~6.0.2" } +} diff --git a/skills/ui-watchtower-setup/evals/files/react-app/src/main.tsx b/skills/ui-watchtower-setup/evals/files/react-app/src/main.tsx new file mode 100644 index 0000000..f06b79d --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/react-app/src/main.tsx @@ -0,0 +1,3 @@ +import { createRoot } from 'react-dom/client'; + +createRoot(document.getElementById('root')!).render(

Marketing

); diff --git a/skills/ui-watchtower-setup/evals/files/ssr-shell/README.md b/skills/ui-watchtower-setup/evals/files/ssr-shell/README.md new file mode 100644 index 0000000..bb2528d --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/ssr-shell/README.md @@ -0,0 +1,4 @@ +# workspace-shell + +Angular SSR shell hosting the `cart` and `catalogue` fragments (Web Fragments). +There is no log backend. RUM credentials are vended by `GET /api/rum/credentials`. diff --git a/skills/ui-watchtower-setup/evals/files/ssr-shell/package.json b/skills/ui-watchtower-setup/evals/files/ssr-shell/package.json new file mode 100644 index 0000000..ed2f96e --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/ssr-shell/package.json @@ -0,0 +1,26 @@ +{ + "name": "workspace-shell", + "version": "2.0.0", + "scripts": { "start": "ng serve", "build": "ng build", "test": "ng test" }, + "private": true, + "dependencies": { + "@angular/common": "^22.2.0", + "@angular/compiler": "^22.2.0", + "@angular/core": "^22.2.0", + "@angular/platform-browser": "^22.2.0", + "@angular/platform-server": "^22.2.0", + "@angular/router": "^22.2.0", + "@angular/ssr": "^22.2.0", + "express": "^5.1.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "web-fragments": "^0.8.2" + }, + "devDependencies": { + "@angular/build": "^22.2.0", + "@angular/cli": "^22.2.0", + "@angular/compiler-cli": "^22.2.0", + "typescript": "~6.0.2", + "vitest": "^4.0.8" + } +} diff --git a/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.config.server.ts b/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.config.server.ts new file mode 100644 index 0000000..1679665 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.config.server.ts @@ -0,0 +1,10 @@ +import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; + +const serverConfig: ApplicationConfig = { + providers: [provideServerRendering(withRoutes(serverRoutes))] +}; + +export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.config.ts b/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.config.ts new file mode 100644 index 0000000..b0d0d34 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.config.ts @@ -0,0 +1,8 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideClientHydration } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes), provideClientHydration()] +}; diff --git a/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.routes.server.ts b/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.routes.server.ts new file mode 100644 index 0000000..d3ced6e --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.routes.server.ts @@ -0,0 +1,3 @@ +import { RenderMode, ServerRoute } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [{ path: '**', renderMode: RenderMode.Server }]; diff --git a/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.routes.ts b/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.routes.ts new file mode 100644 index 0000000..dc39edb --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.routes.ts @@ -0,0 +1,3 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = []; diff --git a/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.ts b/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.ts new file mode 100644 index 0000000..9858681 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.ts @@ -0,0 +1,19 @@ +import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +/** + * The shell composes independently deployed fragments. Each fragment is its + * own Angular app, served through this shell's origin by a fragment gateway. + */ +@Component({ + selector: 'app-root', + imports: [RouterOutlet], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + template: ` +
Workspace
+ + + + ` +}) +export class App {} diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/angular.json b/skills/ui-watchtower-setup/evals/files/standalone-angular/angular.json new file mode 100644 index 0000000..bbd3c48 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/angular.json @@ -0,0 +1,70 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm" + }, + "newProjectRoot": "projects", + "projects": { + "customer-portal": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "styles": [ + "src/styles.css" + ], + "security": { + "allowedHosts": [] + } + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "customer-portal:build:production" + }, + "development": { + "buildTarget": "customer-portal:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test" + } + } + } + } +} diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/package.json b/skills/ui-watchtower-setup/evals/files/standalone-angular/package.json new file mode 100644 index 0000000..f6be4bb --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/package.json @@ -0,0 +1,24 @@ +{ + "name": "customer-portal", + "version": "1.4.0", + "scripts": { "start": "ng serve", "build": "ng build", "test": "ng test" }, + "private": true, + "dependencies": { + "@angular/common": "^22.2.0", + "@angular/compiler": "^22.2.0", + "@angular/core": "^22.2.0", + "@angular/platform-browser": "^22.2.0", + "@angular/router": "^22.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@types/node": "^20.17.19", + "@angular/build": "^22.2.0", + "@angular/cli": "^22.2.0", + "@angular/compiler-cli": "^22.2.0", + "jsdom": "^28.0.0", + "typescript": "~6.0.2", + "vitest": "^4.0.8" + } +} diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.config.ts b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.config.ts new file mode 100644 index 0000000..c25f261 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.config.ts @@ -0,0 +1,8 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes, withComponentInputBinding()), provideHttpClient()] +}; diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.routes.ts b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.routes.ts new file mode 100644 index 0000000..097aa25 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.routes.ts @@ -0,0 +1,13 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { path: '', pathMatch: 'full', redirectTo: 'customers' }, + { + path: 'customers', + loadComponent: () => import('./customers/customers').then((m) => m.Customers) + }, + { + path: 'customers/:customerId', + loadComponent: () => import('./customers/customer-detail').then((m) => m.CustomerDetail) + } +]; diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.spec.ts b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.spec.ts new file mode 100644 index 0000000..8e82882 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + providers: [provideRouter([])] + }).compileComponents(); + }); + + it('should create the app', () => { + expect(TestBed.createComponent(App).componentInstance).toBeTruthy(); + }); +}); diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.ts b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.ts new file mode 100644 index 0000000..241dab5 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { RouterLink, RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, RouterLink], + template: ` + + + ` +}) +export class App {} diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/customers/customer-detail.ts b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/customers/customer-detail.ts new file mode 100644 index 0000000..8855ad4 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/customers/customer-detail.ts @@ -0,0 +1,9 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'app-customer-detail', + template: `

Customer {{ customerId() }}

` +}) +export class CustomerDetail { + readonly customerId = input.required(); +} diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/customers/customers.ts b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/customers/customers.ts new file mode 100644 index 0000000..b2b9cd7 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/customers/customers.ts @@ -0,0 +1,36 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, inject, OnInit, signal } from '@angular/core'; + +interface Customer { + id: string; + name: string; +} + +@Component({ + selector: 'app-customers', + template: ` + @if (error()) { +

Could not load customers.

+ } + @for (c of customers(); track c.id) { +

{{ c.name }}

+ } + + ` +}) +export class Customers implements OnInit { + private readonly http = inject(HttpClient); + protected readonly customers = signal([]); + protected readonly error = signal(false); + + ngOnInit(): void { + this.http.get('/api/customers').subscribe({ + next: (rows) => this.customers.set(rows), + error: () => this.error.set(true) + }); + } + + exportCsv(): void { + window.location.href = '/api/customers/export?format=csv'; + } +} diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/src/index.html b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/index.html new file mode 100644 index 0000000..1e743a7 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/index.html @@ -0,0 +1,12 @@ + + + + + Customer portal + + + + + + + diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/src/main.ts b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/main.ts new file mode 100644 index 0000000..190f341 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/src/styles.css b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/styles.css new file mode 100644 index 0000000..4b438c4 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/src/styles.css @@ -0,0 +1 @@ +/* Global styles */ diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.app.json b/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.app.json new file mode 100644 index 0000000..d5d9680 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.app.json @@ -0,0 +1,18 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "rootDir": "./src", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.json b/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.json new file mode 100644 index 0000000..d2fbb9c --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.json @@ -0,0 +1,31 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.spec.json b/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.spec.json new file mode 100644 index 0000000..4c1202f --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.spec.json @@ -0,0 +1,16 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "rootDir": "./src", + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} diff --git a/skills/ui-watchtower-setup/evals/trigger-eval.json b/skills/ui-watchtower-setup/evals/trigger-eval.json new file mode 100644 index 0000000..626be63 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/trigger-eval.json @@ -0,0 +1,54 @@ +[ + { + "query": "Add ngx-ui-watchtower telemetry to this Angular app.", + "should_trigger": true + }, + { + "query": "Install and wire @absaoss-cps/ngx-ui-watchtower with the CloudWatch RUM sink in our Angular project.", + "should_trigger": true + }, + { + "query": "Replace our hand-written RumService with @absaoss-cps/ngx-ui-watchtower.", + "should_trigger": true + }, + { + "query": "Wire UI telemetry into our micro-frontend shell so the fragments can forward to it.", + "should_trigger": true + }, + { + "query": "Our cart fragment should send its telemetry through the shell — set up watchtower here.", + "should_trigger": true + }, + { + "query": "Set up the watchtower library with a no-op destination for now; RUM isn't ready yet.", + "should_trigger": true + }, + { + "query": "Turn off ngx-ui-watchtower telemetry in our dev and test environments; keep it on in prod.", + "should_trigger": true + }, + { + "query": "Instrument the checkout flow as a scenario and add BI events for the main buttons — watchtower is already set up.", + "should_trigger": false + }, + { + "query": "We want to know if customer search is slow; add telemetry for it (ngx-ui-watchtower is already wired).", + "should_trigger": false + }, + { + "query": "Build a CloudWatch dashboard for our RUM metrics.", + "should_trigger": false + }, + { + "query": "Add Google Analytics to the React marketing site.", + "should_trigger": false + }, + { + "query": "Write unit tests for the UwtScenario class inside the ngx-ui-watchtower library.", + "should_trigger": false + }, + { + "query": "Set up accessibility checks with axe-core in this Angular app.", + "should_trigger": false + } +] diff --git a/skills/ui-watchtower-setup/references/micro-frontends.md b/skills/ui-watchtower-setup/references/micro-frontends.md new file mode 100644 index 0000000..8e03a30 --- /dev/null +++ b/skills/ui-watchtower-setup/references/micro-frontends.md @@ -0,0 +1,148 @@ +# Micro-frontends: shell hosts, fragments forward + +Use when the app embeds fragments that run in their own JavaScript realm (Web Fragments, same-origin +iframes, separately bootstrapped Angular apps), or is such a fragment. Not for a Module Federation +remote loaded through `loadChildren`/`loadComponent`: it runs in the host's injector — give it no +providers of its own. + +## Why + +Each realm has its own Angular injector, so each would build its own AWS RUM client: one visitor +becomes N sessions with N event budgets, N credential fetches and N SDK downloads. So **exactly one +realm — the shell — owns the RUM client; fragments forward** to it over a same-origin +`BroadcastChannel`. Application code is identical in both; only the providers differ. + +## Shell + +```ts +import { inject } from '@angular/core'; +import { + UWT_LOG_API_PROVIDER, + UwtNoopLogApiProvider, + provideUwtTelemetry, + provideUwtTelemetryBroadcastHost +} from '@absaoss-cps/ngx-ui-watchtower'; +import { + UWT_RUM_CREDENTIALS_PROVIDER, + UwtRumTelemetrySink, + provideUwtTelemetryRumSink +} from '@absaoss-cps/ngx-ui-watchtower/rum'; +import { + provideRouteNavigationTelemetry, + ROUTE_PAGE_VIEW_RECORDER +} from './telemetry/route-navigation-telemetry.service'; + +declare global { + interface Window { + __uwtTelemetryChannel?: string; + } +} + +// One random channel per page load, at module scope so it exists before any fragment boots. +// randomUUID needs a secure context, hence the fallback; skipped during server rendering. +const channelId = + typeof window === 'undefined' + ? undefined + : `ngx-ui-watchtower-${crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`; +if (channelId) { + window.__uwtTelemetryChannel = channelId; +} + +providers: [ + provideUwtTelemetry({ application: 'shell', environment, version }), + provideUwtTelemetryRumSink(), + { provide: UWT_RUM_CREDENTIALS_PROVIDER, useExisting: AppRumCredentialsProvider }, + // Route tracking and page views under the route template (SKILL.md Step 6) + provideRouteNavigationTelemetry(), + { + provide: ROUTE_PAGE_VIEW_RECORDER, + useFactory: () => { + const rum = inject(UwtRumTelemetrySink); + return (route: string) => rum.recordPageView(route); + } + }, + provideUwtTelemetryBroadcastHost(channelId), + // Required by the host. Fragments' log records are shipped by the shell's log provider; + // with no log backend, say so explicitly: + { provide: UWT_LOG_API_PROVIDER, useClass: UwtNoopLogApiProvider } +]; +``` + +- **Provide the host now**, even if no fragment forwards yet — a host with no fragments just sits + idle, and it must be listening before the first fragment sends. +- **Per-tab channel id is required.** `BroadcastChannel` and the host's leader election are + origin-wide. On the shared default channel, a second tab's fragments get recorded through the + first tab's RUM client — wrong page, wrong context. Generate the id once per page load, never per + fragment or per navigation. +- Publish it on `window`, not `sessionStorage` (copied into duplicated tabs) or a query parameter + (fragments share the shell's `location`). + +## Fragment — the contract to hand to fragment teams + +```ts +import { + UWT_BROADCAST_CHANNEL, + provideUwtTelemetry, + provideUwtTelemetrySink +} from '@absaoss-cps/ngx-ui-watchtower'; + +declare global { + interface Window { + __uwtTelemetryChannel?: string; + } +} + +/** + * The shell's channel — or, with no shell, a cross-origin `top`, or during server rendering, a + * private channel nobody hosts, so nothing ships. Never `undefined`: the library would then use its + * shared default channel, where another same-origin shell may be listening. + */ +function shellChannelName(): string { + try { + const shell = typeof window === 'undefined' ? undefined : window.top?.__uwtTelemetryChannel; + if (shell) { + return shell; + } + } catch { + // cross-origin `top` + } + const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + return `ngx-ui-watchtower-unhosted-${suffix}`; +} + +providers: [ + provideUwtTelemetry({ application: 'cart', environment, version }), // must differ from the shell + provideUwtTelemetrySink('broadcast'), + { provide: UWT_BROADCAST_CHANNEL, useFactory: shellChannelName } // lazy — see below +]; +``` + +Use the `useFactory` provider, not `provideUwtTelemetrySink('broadcast', { channelName })`, so the +channel is read when the sink is created, not when the providers are built. + +**The shell must publish the id before any fragment boots** — the sink reads it once, so an early +fragment ships nothing for the whole page. The shell code above guarantees this with Web Fragments. + +| Must match the shell | `eventNamespace`; the channel name (read from `window.top`); `withTelemetryEnabled(...)` — the switch is per realm, so a fragment left on beside a shell that is off still opens a channel nobody hosts | +|----------------------|---------------------------------------------------------------------------------------------------------| +| **Must differ** | `application` — every forwarded event is stamped with it, which is how fragments are told apart | +| **Never provide** | `provideUwtTelemetryRumSink()`, `UWT_RUM_CREDENTIALS_PROVIDER`, `provideUwtTelemetryBroadcastHost()`, `UWT_LOG_API_PROVIDER` (`'broadcast'` already binds it to the forwarding provider) | +| **Never install** | `aws-rum-web` | + +Inside a fragment: + +- `getSessionId()` is `undefined` until the shell answers the identity handshake — don't assert on it + at bootstrap. Work is still correlated by `scenarioId`. +- Log lines go to the shell's log provider; `logger.query()` is answered by the shell. +- Don't call `setUserId()`: it is forwarded and changes the shell's user (and `undefined` starts a + new session). The shell owns user identity. +- Paint observation doesn't work (a hidden iframe never paints) — settle with `complete()`. + +## Verifying + +- In a fragment's realm, `window.top.__uwtTelemetryChannel` equals the shell's id, and on a first + page load the shell's `inject(UwtTelemetryBroadcastHost).received` counter grows. +- Two tabs have two different channel ids. +- With the debug flags on, lines are prefixed per realm: `[shell][scenario] …`, `[cart][bi] …`. +- `BroadcastChannel` is same-origin only: a fragment served from another origin forwards nothing, + silently. diff --git a/skills/ui-watchtower-setup/references/rum-migration.md b/skills/ui-watchtower-setup/references/rum-migration.md new file mode 100644 index 0000000..b817222 --- /dev/null +++ b/skills/ui-watchtower-setup/references/rum-migration.md @@ -0,0 +1,104 @@ +# Migrating an existing RUM integration + +Use when the app already calls `aws-rum-web` itself (a `RumService`, a wrapper around `AwsRum`). +The library emits its own event types (`com.uwt.scenario`, `com.uwt.scenario.step`, `com.uwt.bi`) +with its own payload shapes. Event types are a contract with whatever already queries them — +extended metrics, Logs Insights queries, dashboards — so a naive "delete RumService, call +`track()` instead" silently breaks them. **Migrate first, diff the wire, delete last.** + +## 1. Inventory — before changing anything + +```bash +rg -n "recordEvent|recordError|recordPageView|dispatch|pinUserId|addSessionAttributes|new AwsRum" src/ +``` + +Build this table and keep it in the PR description. The payload column matters — the event type +alone is not parity: + +| Call site | AWS API | Event type | Payload keys | Consumers (dashboard, metric, query) | Parity tool (§2) | +|-----------|---------|------------|--------------|--------------------------------------|------------------| +| | | | | | | + +Also note how the existing code obtains credentials (Cognito identity pool vs a backend broker), how +it handles sign-in/sign-out, and whether it records page views manually. + +**Fix live privacy leaks now, even if the migration is blocked.** Raw search text, an email as user +id, page ids with query strings: fix them in the old code right away (debounce and send +`queryLength`, an opaque id, strip query strings), mark the rows, and tell the dashboard owners. +Parity never outranks personal data. +Put each comment on its own line above the code it explains, and re-read every line you edited in +the old service when you are done: you cannot compile here, and a trailing `// …` before a closing +`);` silently swallows the bracket and breaks the only page-view path. + +## 2. Pick a parity tool per row + +Choose exactly one tool for every row and write it in the last column of the §1 table. A list of +options is not a choice: without one, the cutover has no agreed target and the wire diff (§5) has +nothing to compare against, even when the cutover itself has to wait for a broker. + +| Tool | Use when | Result | +|--------------------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------| +| `eventNamespace` in the identity | Existing types differ from the library's only by prefix | `eventNamespace: 'myapp'` → `myapp.scenario`, `myapp.scenario.step`, `myapp.bi`. Cheapest | +| `track(name, metadata, { eventType: 'legacy.type' })` | A dashboard needs one specific event type | Exact event type; payload becomes the `UwtBIEvent` envelope (`eventName`, `eventTime`, `metadata`, `application`, …). A migration escape hatch, not a pattern | +| `sink.record(eventType, payload)` (sink injected as a field) | Byte-exact payload parity is required | Raw passthrough, no redaction — never forward free text (an error becomes its name or code). Use sparingly | + +If a consumer can be updated instead (a query, a metric filter), prefer moving it to the library's +event types — record that decision in the table. + +## 3. Credentials + +- A backend broker → wrap it in `assets/rum-credentials.provider.ts`; keep the endpoint. +- A Cognito identity pool (`identityPoolId`, `guestRoleArn`) → not supported by the library's config + on purpose. Credentials must come from a broker that returns short-lived AWS credentials. If none + exists, stop and tell the user: a broker is a backend change, outside this skill. + +## 4. Cut over without ever running two RUM clients + +Two `AwsRum` instances mean two sessions per visitor, so the old client and +`provideUwtTelemetryRumSink()` must never start in the same build. Until the cutover, the old +`RumService` stays the **only** RUM client (the library can already be wired with `'noop'`, so the +vocabulary, route tracking and tests are in place). The cutover is one change, in this order: + +1. **Stop the old client** — remove whatever runs `RumService.init()` / `new AwsRum` (usually an + `APP_INITIALIZER`), and its page-view subscription. +2. **Wire the new destination** — replace `'noop'` with `provideUwtTelemetryRumSink()`, the + credentials provider and the page-view recorder. +3. **Turn the old service into an adapter** — inject `UwtTelemetrySink` (and the BI / scenario + services) as fields, and point each remaining method at the library: the migrated call, or + `this.sink.record(…)` for rows that need byte parity. Then move call sites to the library one at + a time. The class stays, without any `AwsRum` of its own, as the reference for §5 until every row + is ticked. + +## 5. Diff the wire + +Enable `debugScenario` and `debugBI`, drive each migrated path, and compare the console output (its +second argument is the exact object handed to the sink) against the inventory. Tick each row only +when type and payload match what its consumers need. + +## 6. Delete + +Only when every row is ticked: delete `RumService`, its providers and its tests; remove any direct +`aws-rum-web` imports outside the library. + +Ticking rows needs the app running in a browser. If you can't run it in this session (no install, +no browser), **don't delete**: stop initialising the old service (so there is never a second RUM +client), leave its file in place, and hand over "diff the wire, then delete `RumService`" as an +explicit follow-up with the inventory table attached. Deleting first throws away the reference the +diff needs. + +## Specific checks + +- **Page views.** Setup records one page view per navigation under the route template + (`disableAutoPageView: true` plus the route service's page-view recorder — Step 6). Drop any + manual `recordPageView` calls in the old code; never keep both. If the old setup relied on + automatic page views, its page ids were resolved paths (`/customers/42`) and become templates + (`/customers/:id`) — tell the owners of page-based dashboards. +- **Session attributes.** `application`, `environment` and `version` from the identity are attached + as session attributes. Port any other attributes via `sessionAttributes` in the app monitor config + returned by the credentials provider. +- **User identity.** Replace `pinUserId` with `setUserId` (SKILL.md Step 3). Sign-out starts a fresh + session; call it out if the old code behaved differently. +- **Errors.** The RUM client still captures unhandled JS errors itself. Handled errors the old code + sent with `recordError` become `scenario.fail(...)` (never a raw `HttpErrorResponse` — see the + integration patterns' `safeHttpFailure`), or a log line with + `withLogging({ mirrorErrorsToRum: true })` when the app has a log backend.