From 06bdae02247519b2e889c47590d3932af0eb8d6b Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 13:16:48 +0200 Subject: [PATCH 01/25] add skills --- CONTRIBUTING.md | 2 +- README.md | 2 + docs/README.md | 2 + docs/guides/ui-watchtower-integration.md | 71 ++++ docs/guides/ui-watchtower-setup.md | 75 ++++ skills/shared/SKILL.md | 4 +- .../references/ngx-ui-watchtower-api.md | 328 ++++++++++++++++++ skills/ui-watchtower-integration/SKILL.md | 192 ++++++++++ .../evals/evals.json | 194 +++++++++++ .../evals/files/unwired-angular/angular.json | 76 ++++ .../evals/files/unwired-angular/package.json | 23 ++ .../unwired-angular/src/app/app.config.ts | 8 + .../unwired-angular/src/app/app.routes.ts | 13 + .../files/unwired-angular/src/app/app.spec.ts | 16 + .../files/unwired-angular/src/app/app.ts | 12 + .../src/app/customers/customer-detail.ts | 9 + .../src/app/customers/customers.ts | 36 ++ .../files/unwired-angular/src/index.html | 12 + .../evals/files/unwired-angular/src/main.ts | 5 + .../files/unwired-angular/src/styles.css | 1 + .../files/unwired-angular/tsconfig.app.json | 17 + .../evals/files/unwired-angular/tsconfig.json | 31 ++ .../files/unwired-angular/tsconfig.spec.json | 15 + .../evals/files/wired-angular/angular.json | 79 +++++ .../evals/files/wired-angular/package.json | 29 ++ .../files/wired-angular/src/app/app.config.ts | 36 ++ .../files/wired-angular/src/app/app.routes.ts | 13 + .../files/wired-angular/src/app/app.spec.ts | 29 ++ .../evals/files/wired-angular/src/app/app.ts | 24 ++ .../src/app/customers/customer-detail.ts | 9 + .../src/app/customers/customers.ts | 44 +++ ...route-navigation-telemetry.service.spec.ts | 139 ++++++++ .../route-navigation-telemetry.service.ts | 296 ++++++++++++++++ .../app/telemetry/rum-credentials.provider.ts | 91 +++++ .../src/app/telemetry/telemetry.schema.ts | 25 ++ .../evals/files/wired-angular/src/index.html | 12 + .../evals/files/wired-angular/src/main.ts | 5 + .../evals/files/wired-angular/src/styles.css | 1 + .../files/wired-angular/tsconfig.app.json | 17 + .../evals/files/wired-angular/tsconfig.json | 31 ++ .../files/wired-angular/tsconfig.spec.json | 15 + .../wired-instrumented-angular/angular.json | 79 +++++ .../wired-instrumented-angular/package.json | 29 ++ .../src/app/app.config.ts | 36 ++ .../src/app/app.routes.ts | 13 + .../src/app/app.spec.ts | 29 ++ .../wired-instrumented-angular/src/app/app.ts | 24 ++ .../src/app/customers/customer-detail.ts | 9 + .../src/app/customers/customers.spec.ts | 83 +++++ .../src/app/customers/customers.ts | 65 ++++ ...route-navigation-telemetry.service.spec.ts | 139 ++++++++ .../route-navigation-telemetry.service.ts | 296 ++++++++++++++++ .../app/telemetry/rum-credentials.provider.ts | 91 +++++ .../src/app/telemetry/telemetry.schema.ts | 31 ++ .../wired-instrumented-angular/src/index.html | 12 + .../wired-instrumented-angular/src/main.ts | 5 + .../wired-instrumented-angular/src/styles.css | 1 + .../tsconfig.app.json | 17 + .../wired-instrumented-angular/tsconfig.json | 31 ++ .../tsconfig.spec.json | 15 + .../evals/trigger-eval.json | 50 +++ .../references/instrumentation-patterns.md | 199 +++++++++++ skills/ui-watchtower-setup/SKILL.md | 318 +++++++++++++++++ .../assets/log-api.provider.ts | 59 ++++ .../route-navigation-telemetry.service.ts | 296 ++++++++++++++++ .../assets/rum-credentials.provider.ts | 87 +++++ .../assets/telemetry.schema.ts | 47 +++ skills/ui-watchtower-setup/evals/evals.json | 199 +++++++++++ .../evals/files/fragment/README.md | 4 + .../evals/files/fragment/package.json | 22 ++ .../files/fragment/src/app/app.config.ts | 7 + .../files/fragment/src/app/app.routes.ts | 3 + .../files/ngmodule-existing-rum/package.json | 25 ++ .../src/app/app-routing.module.ts | 10 + .../src/app/app.component.ts | 8 + .../src/app/app.module.ts | 21 ++ .../src/app/search/search.component.ts | 15 + .../src/app/services/rum.service.ts | 53 +++ .../partially-wired-angular/angular.json | 76 ++++ .../partially-wired-angular/package.json | 24 ++ .../src/app/app.config.ts | 15 + .../src/app/app.routes.ts | 13 + .../src/app/app.spec.ts | 16 + .../partially-wired-angular/src/app/app.ts | 12 + .../src/app/customers/customer-detail.ts | 9 + .../src/app/customers/customers.ts | 36 ++ .../partially-wired-angular/src/index.html | 12 + .../files/partially-wired-angular/src/main.ts | 5 + .../partially-wired-angular/src/styles.css | 1 + .../partially-wired-angular/tsconfig.app.json | 17 + .../partially-wired-angular/tsconfig.json | 31 ++ .../tsconfig.spec.json | 15 + .../evals/files/react-app/package.json | 7 + .../evals/files/react-app/src/main.tsx | 3 + .../evals/files/ssr-shell/README.md | 4 + .../evals/files/ssr-shell/package.json | 26 ++ .../ssr-shell/src/app/app.config.server.ts | 10 + .../files/ssr-shell/src/app/app.config.ts | 8 + .../ssr-shell/src/app/app.routes.server.ts | 3 + .../files/ssr-shell/src/app/app.routes.ts | 3 + .../evals/files/ssr-shell/src/app/app.ts | 19 + .../files/standalone-angular/angular.json | 76 ++++ .../files/standalone-angular/package.json | 23 ++ .../standalone-angular/src/app/app.config.ts | 8 + .../standalone-angular/src/app/app.routes.ts | 13 + .../standalone-angular/src/app/app.spec.ts | 16 + .../files/standalone-angular/src/app/app.ts | 12 + .../src/app/customers/customer-detail.ts | 9 + .../src/app/customers/customers.ts | 36 ++ .../files/standalone-angular/src/index.html | 12 + .../files/standalone-angular/src/main.ts | 5 + .../files/standalone-angular/src/styles.css | 1 + .../standalone-angular/tsconfig.app.json | 17 + .../files/standalone-angular/tsconfig.json | 31 ++ .../standalone-angular/tsconfig.spec.json | 15 + .../evals/trigger-eval.json | 50 +++ .../references/micro-frontends.md | 134 +++++++ .../references/rum-migration.md | 88 +++++ 118 files changed, 5375 insertions(+), 3 deletions(-) create mode 100644 docs/guides/ui-watchtower-integration.md create mode 100644 docs/guides/ui-watchtower-setup.md create mode 100644 skills/shared/references/ngx-ui-watchtower-api.md create mode 100644 skills/ui-watchtower-integration/SKILL.md create mode 100644 skills/ui-watchtower-integration/evals/evals.json create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/angular.json create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/package.json create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.config.ts create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.routes.ts create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.spec.ts create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.ts create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/customers/customer-detail.ts create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/customers/customers.ts create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/index.html create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/main.ts create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/styles.css create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.app.json create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.json create mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.spec.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/angular.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/package.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.config.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.routes.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.spec.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/app/customers/customer-detail.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/app/customers/customers.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/telemetry.schema.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/index.html create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/main.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/styles.css create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.app.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.spec.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/angular.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/package.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.config.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.routes.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.spec.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customer-detail.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customers.spec.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customers.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/telemetry.schema.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/index.html create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/main.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/styles.css create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.app.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.spec.json create mode 100644 skills/ui-watchtower-integration/evals/trigger-eval.json create mode 100644 skills/ui-watchtower-integration/references/instrumentation-patterns.md create mode 100644 skills/ui-watchtower-setup/SKILL.md create mode 100644 skills/ui-watchtower-setup/assets/log-api.provider.ts create mode 100644 skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts create mode 100644 skills/ui-watchtower-setup/assets/rum-credentials.provider.ts create mode 100644 skills/ui-watchtower-setup/assets/telemetry.schema.ts create mode 100644 skills/ui-watchtower-setup/evals/evals.json create mode 100644 skills/ui-watchtower-setup/evals/files/fragment/README.md create mode 100644 skills/ui-watchtower-setup/evals/files/fragment/package.json create mode 100644 skills/ui-watchtower-setup/evals/files/fragment/src/app/app.config.ts create mode 100644 skills/ui-watchtower-setup/evals/files/fragment/src/app/app.routes.ts create mode 100644 skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/package.json create mode 100644 skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/app-routing.module.ts create mode 100644 skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/app.component.ts create mode 100644 skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/app.module.ts create mode 100644 skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/search/search.component.ts create mode 100644 skills/ui-watchtower-setup/evals/files/ngmodule-existing-rum/src/app/services/rum.service.ts create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/angular.json create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/package.json create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.config.ts create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.routes.ts create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.spec.ts create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/app.ts create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/customers/customer-detail.ts create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/app/customers/customers.ts create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/index.html create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/main.ts create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/src/styles.css create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.app.json create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.json create mode 100644 skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.spec.json create mode 100644 skills/ui-watchtower-setup/evals/files/react-app/package.json create mode 100644 skills/ui-watchtower-setup/evals/files/react-app/src/main.tsx create mode 100644 skills/ui-watchtower-setup/evals/files/ssr-shell/README.md create mode 100644 skills/ui-watchtower-setup/evals/files/ssr-shell/package.json create mode 100644 skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.config.server.ts create mode 100644 skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.config.ts create mode 100644 skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.routes.server.ts create mode 100644 skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.routes.ts create mode 100644 skills/ui-watchtower-setup/evals/files/ssr-shell/src/app/app.ts create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/angular.json create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/package.json create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.config.ts create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.routes.ts create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.spec.ts create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/app.ts create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/customers/customer-detail.ts create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/src/app/customers/customers.ts create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/src/index.html create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/src/main.ts create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/src/styles.css create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.app.json create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.json create mode 100644 skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.spec.json create mode 100644 skills/ui-watchtower-setup/evals/trigger-eval.json create mode 100644 skills/ui-watchtower-setup/references/micro-frontends.md create mode 100644 skills/ui-watchtower-setup/references/rum-migration.md 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..548d129 --- /dev/null +++ b/docs/guides/ui-watchtower-integration.md @@ -0,0 +1,71 @@ +# 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 | +| 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 +``` + +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..32ce32a --- /dev/null +++ b/docs/guides/ui-watchtower-setup.md @@ -0,0 +1,75 @@ +# 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 (route templates, not ids) with a test | +| 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 application on Angular 22.1.3 or later (the library's peer range) +- Node.js 22+ +- 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 +``` + +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..d79b04c --- /dev/null +++ b/skills/shared/references/ngx-ui-watchtower-api.md @@ -0,0 +1,328 @@ +# ngx-ui-watchtower API reference + +The public surface of `@absaoss-cps/ngx-ui-watchtower` an app or a custom sink uses (checked against +version 0.1.0; config tokens and redaction constants omitted). Import only +from the two entry points below — never from `…/src/lib/…` paths, and never re-export the `/rum` +entry from a shared barrel. + +## 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, + 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. + +## 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**. + +## 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. + +```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 +); +``` + +Identical events (name + scenarioId + eventType + feature + metadata) within 400 ms collapse into +one — no click throttling needed. A wrapper of your own should type its parameter `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' }); + +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 | +| `{ 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' }) }) +// ]); +``` + +jsdom implements no `BroadcastChannel`, so broadcast sinks and hosts are no-ops in unit tests. diff --git a/skills/ui-watchtower-integration/SKILL.md b/skills/ui-watchtower-integration/SKILL.md new file mode 100644 index 0000000..90b0897 --- /dev/null +++ b/skills/ui-watchtower-integration/SKILL.md @@ -0,0 +1,192 @@ +--- +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 22.1.3+ application where @absaoss-cps/ngx-ui-watchtower is already installed and + wired (see ui-watchtower-setup), and Node.js 22+. +--- + +# 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()` | + +The people who own the app know which questions the data must answer; the code shows where those +journeys live. This skill starts from the first and uses the second — it asks before it proposes, +and marks which items came from the user and which it suggested. It can be re-run as the app grows: +each pass adds to what is already instrumented. + +## 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` (the file augmenting the name registries) is missing, don't stop: +create it in Step 6 — a `declare module '@absaoss-cps/ngx-ui-watchtower'` block with the confirmed +names, side-effect-imported where the providers are configured. + +Also read what already exists — the schema's declared names and every `scenarios.start(` / +`track(` call — so this pass only adds or changes, never duplicates. Note the test runner and the +package manager. + +### 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 + +Load `references/instrumentation-patterns.md` (section 1). Within the scope from Step 2, find: + +- **Every wait the user actually sits through** → a scenario (a fetch that gates a screen, a submit, + an upload, an export). +- **Every meaningful or irreversible click** → a BI event. +- **Every `catch` that ends a journey** → that scenario's `fail({ error })`. + +Map each user answer to concrete code: which component or service, which call starts the journey, +which outcomes it can have. + +### Step 4 · Present a merged proposal + +Use this shape. `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 + +Answers: +Already instrumented (unchanged): + +### Scenarios +| 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 +| 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`: + +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. Each journey: start → steps → settle with the **right** status — `complete`, `incomplete` (an + expected dead end, e.g. no results), `fail` (a defect; pass `error` and `statusCode`), `cancel` + (the user left or a newer request superseded it). Timeout is automatic. +3. Settle open scenarios when their component is destroyed; under `switchMap`, give + `traceScenario` a `cancelOutcome`; never put `take(1)`/`first()` upstream of `traceScenario`. +4. BI events inside a journey pass `{ scenarioId: scenario.id }`. Debounce high-frequency sources + (search boxes, sliders) before starting a scenario or sending a BI event — one per settled + input, never one per keystroke. +5. Metadata: flat primitives, low cardinality, no personal data. +6. Only if the app has a log backend (`UwtLoggerNames` declared): at a journey-ending error, also + `logger.error(message, { 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`, a double-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 + +- **The right settle status is the whole point.** Leaving is `abandoned`, not `failure`; an empty + result is `incomplete`, not `success`. Collapsing them makes the failure rate useless for alerts. +- **Names and `route` are metric dimensions** — no ids, no resolved URLs. +- **Metadata is flat primitives**, at most 50 keys per scenario; objects and `undefined` are dropped. +- **The RUM session budget is 200 events**, page views and errors included — one event per settled + scenario, keep `emitLifecycleEvents` off, debounce chatty BI events. +- **Telemetry never throws into the app** — don't wrap calls in try/catch. +- **Percentiles and dashboards are AWS-side.** The frontend emits raw `delta` and low-cardinality + dimensions; it never computes metrics. + +## 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..6b73cc1 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/evals.json @@ -0,0 +1,194 @@ +{ + "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.spec.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 (providers, destination, schema) before anything else", + "Asks the user, before proposing, what the telemetry should answer — 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.spec.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 re-ask what the prompt already answered (which journeys and interactions); asks only remaining open points, if any", + "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 error and statusCode on HTTP error, 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.spec.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({ error, statusCode }) on HTTP error, and cancel on leaving; no take(1) upstream of traceScenario; 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.spec.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 the error and statusCode", + "Leaving the page mid-load settles the open scenario as abandoned with a stable reason (ngOnDestroy cancel, or traceScenario's cancelOutcome on teardown)", + "Does not put take(1) or first() upstream of traceScenario", + "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) — each with a Source label — 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.spec.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" + ] + } + ] +} diff --git a/skills/ui-watchtower-integration/evals/files/unwired-angular/angular.json b/skills/ui-watchtower-integration/evals/files/unwired-angular/angular.json new file mode 100644 index 0000000..c269d3b --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-angular/angular.json @@ -0,0 +1,76 @@ +{ + "$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", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "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/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/app.routes.ts b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.routes.ts new file mode 100644 index 0000000..097aa25 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-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/unwired-angular/src/app/app.spec.ts b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.spec.ts new file mode 100644 index 0000000..8e82882 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-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-integration/evals/files/unwired-angular/src/app/app.ts b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.ts new file mode 100644 index 0000000..241dab5 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-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-integration/evals/files/unwired-angular/src/app/customers/customer-detail.ts b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/customers/customer-detail.ts new file mode 100644 index 0000000..8855ad4 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-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/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/unwired-angular/src/index.html b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/index.html new file mode 100644 index 0000000..1e743a7 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/index.html @@ -0,0 +1,12 @@ + + + + + Customer portal + + + + + + + diff --git a/skills/ui-watchtower-integration/evals/files/unwired-angular/src/main.ts b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/main.ts new file mode 100644 index 0000000..190f341 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-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/unwired-angular/src/styles.css b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/styles.css new file mode 100644 index 0000000..4b438c4 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/styles.css @@ -0,0 +1 @@ +/* Global styles */ diff --git a/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.app.json b/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.app.json new file mode 100644 index 0000000..ef19921 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.app.json @@ -0,0 +1,17 @@ +/* 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", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.json b/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.json new file mode 100644 index 0000000..d2fbb9c --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-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/unwired-angular/tsconfig.spec.json b/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.spec.json new file mode 100644 index 0000000..d383706 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* 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", + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} 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..4a53f26 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/angular.json @@ -0,0 +1,79 @@ +{ + "$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" + ], + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "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..5382b0b --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/package.json @@ -0,0 +1,29 @@ +{ + "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": "^0.1.0", + "@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/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..78e04fb --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.config.ts @@ -0,0 +1,36 @@ +import { ApplicationConfig, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { provideUwtTelemetry } from '@absaoss-cps/ngx-ui-watchtower'; +import { + UWT_RUM_CREDENTIALS_PROVIDER, + provideUwtTelemetryRumSink +} from '@absaoss-cps/ngx-ui-watchtower/rum'; +import { routes } from './app.routes'; +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), + 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 } + ] +}; 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..5a215f4 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/app.ts @@ -0,0 +1,24 @@ +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); + + constructor() { + this.routeTelemetry.start(); + } + + /** 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.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts new file mode 100644 index 0000000..442d721 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts @@ -0,0 +1,139 @@ +import { Component, Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter, Router, Routes } from '@angular/router'; +import { + provideUwtTelemetry, + provideUwtTelemetryDestination, + UwtTelemetrySink, + withScenarios +} from '@absaoss-cps/ngx-ui-watchtower'; +import { RouteNavigationTelemetryService } from './route-navigation-telemetry.service'; + +const events: { eventType: string; payload: Record }[] = []; + +/** Records everything handed to the destination, so tests assert on the wire. */ +@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 Stub {} + +/** Mirrors the app's route shapes (redirect, static, `:param`) plus failure paths. */ +const routes: Routes = [ + { path: '', pathMatch: 'full', redirectTo: 'customers' }, + { path: 'customers', component: Stub }, + { path: 'customers/:customerId', component: Stub }, + { path: 'blocked', canActivate: [() => false], component: Stub }, + { + path: 'broken', + canActivate: [ + () => { + throw new Error('guard exploded'); + } + ], + component: Stub + } +]; + +function scenarios(): Record[] { + return events.filter((e) => e.eventType === 'com.uwt.scenario').map((e) => e.payload); +} + +function stepNames(record: Record): string[] { + return (record['steps'] as { name: string }[]).map((s) => s.name); +} + +describe('RouteNavigationTelemetryService', () => { + let router: Router; + + beforeEach(() => { + events.length = 0; + TestBed.configureTestingModule({ + providers: [ + provideRouter(routes), + provideUwtTelemetry( + { application: 'customer-portal-test', environment: 'test', version: '0.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }) + ), + provideUwtTelemetryDestination(RecordingSink) + ] + }); + router = TestBed.inject(Router); + TestBed.inject(RouteNavigationTelemetryService).start(); + }); + + it('reports a successful navigation under its route template, never the resolved id', async () => { + await router.navigateByUrl('/customers/42?tab=orders'); + + expect(scenarios()).toEqual([ + expect.objectContaining({ + scenarioName: 'route-navigation', + status: 'success', + route: '/customers/:customerId', + metadata: expect.objectContaining({ finalRoute: '/customers/:customerId' }) + }) + ]); + expect(stepNames(scenarios()[0])).toEqual( + expect.arrayContaining(['resolve-route', 'activate']) + ); + }); + + it('reports the redirect from the empty path under the target template', async () => { + await router.navigateByUrl('/'); + + expect(scenarios()).toEqual([ + expect.objectContaining({ + status: 'success', + route: '/customers', + metadata: expect.objectContaining({ finalRoute: '/customers' }) + }) + ]); + }); + + it('reports a guard rejection as incomplete, not as a failure', async () => { + await router.navigateByUrl('/blocked'); + + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'incomplete', reason: 'guard-rejected', route: '/blocked' }) + ]); + }); + + it('records nothing for a navigation superseded before the URL was recognized', async () => { + const first = router.navigateByUrl('/customers/1'); + const second = router.navigateByUrl('/customers/2'); + await Promise.all([first, second]); + + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'success', route: '/customers/:customerId' }) + ]); + }); + + it('reports a navigation error as a failure', async () => { + await router.navigateByUrl('/broken').catch(() => undefined); + + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'failure', route: '/broken' }) + ]); + }); + + it('reports an unknown URL under a placeholder template, not the raw URL', async () => { + await router.navigateByUrl('/nope/123').catch(() => undefined); + + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'failure', route: '(unrecognized)' }) + ]); + }); +}); 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..53c5975 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.ts @@ -0,0 +1,296 @@ +import { DestroyRef, inject, Injectable } 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'; + +/** Name shared by every route-navigation scenario. */ +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; + +/** + * Neither completed nor superseded within this window means a stuck chunk + * load, not a slow one. + */ +const NAVIGATION_TIMEOUT_MS = 30_000; + +/** `route` of a navigation whose URL matched no route. */ +const UNRECOGNIZED_ROUTE = '(unrecognized)'; + +/** + * Route `data` key naming a `matcher` route's segment in the template — a + * matcher has no `path` to report. + */ +const TELEMETRY_PATH_DATA_KEY = 'telemetryPath'; + +/** + * Stable, low-cardinality causes, derived from the router's `code`. The + * router's `reason` text is dev-mode only — empty in a production build — so + * it can't be the grouping key. + */ +const CANCEL_REASON: Partial> = { + [NavigationCancellationCode.Redirect]: 'redirect', + [NavigationCancellationCode.SupersededByNewNavigation]: 'superseded', + [NavigationCancellationCode.NoDataFromResolver]: 'no-data-from-resolver', + [NavigationCancellationCode.GuardRejected]: 'guard-rejected', + [NavigationCancellationCode.Aborted]: 'aborted' +}; + +/** + * Cancellations that are an expected dead end of the journey (`incomplete`), + * not the user leaving it (`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; +} + +/** + * The matched route's template, from the `routeConfig` chain of the primary + * outlet: `/customers/42` → `/customers/:id`. Exact after redirects and lazy + * `loadChildren`, because the router has done the matching. + */ +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('/')}`; +} + +/** One router navigation being tracked. */ +interface TrackedNavigation { + /** Epoch ms the journey started: the click, or `NavigationStart`. */ + startedAt: number; + /** Started once the URL is recognized — the route template is known only then. */ + scenario?: UwtScenario; +} + +/** + * Measures every router navigation as a `route-navigation` scenario. + * + * The scenario starts when the router has recognized the URL, so `route` is + * the matched template (`/customers/:id`), and is backdated to the click or + * `NavigationStart`, so the duration covers the whole wait. + * + * Call {@link start} once, from the root component's constructor. Call + * {@link markNavigationIntent} from navigation-link click handlers. + */ +@Injectable({ providedIn: 'root' }) +export class RouteNavigationTelemetryService { + private readonly router = inject(Router); + private readonly scenarioTelemetry = inject(UwtScenarioTelemetryService); + private readonly destroyRef = inject(DestroyRef); + + /** + * Keyed by navigation id, never a single "current" field: one navigation + * can supersede another, and a single field would attribute the wrong + * duration. + */ + private readonly navigations = new Map(); + + /** A redirected navigation's scenario, continued by the next navigation. */ + private pendingRedirectScenario?: UwtScenario; + + /** When the user last did something expected to start a navigation. */ + private navigationIntentAt?: number; + + private started = false; + + /** Begins tracking router navigations. 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)); + } + + /** + * Records that the user just did something expected to start a + * navigation. The router raises `NavigationStart` after event handling, + * guards and change detection; measuring from the click keeps that wait + * inside the recorded duration. + */ + markNavigationIntent(): void { + this.navigationIntentAt = Date.now(); + } + + private onRouterEvent(event: RouterEvent): void { + if (event instanceof NavigationStart) { + this.navigations.set(event.id, { + startedAt: this.navigationStartedAt(), + scenario: this.consumePendingRedirectScenario() + }); + return; + } + + if (event instanceof RoutesRecognized) { + const navigation = this.navigations.get(event.id); + if (navigation) { + navigation.scenario ??= this.startScenario( + navigation.startedAt, + routeTemplateOf(event.state.root) + ); + navigation.scenario.step('resolve-route'); + } + return; + } + + if (event instanceof NavigationEnd) { + this.settle(event.id, (scenario) => { + scenario.step('activate'); + scenario.complete({ + metadata: { + finalRoute: routeTemplateOf(this.router.routerState.snapshot.root) + } + }); + }); + return; + } + + if (event instanceof NavigationCancel) { + const scenario = this.navigations.get(event.id)?.scenario; + if (scenario && event.code === NavigationCancellationCode.Redirect) { + // A redirect restarts under a new id: continue this scenario on the + // next NavigationStart, so one journey stays one scenario. + this.navigations.delete(event.id); + this.pendingRedirectScenario = scenario; + return; + } + + const outcome = { + reason: causeOf(CANCEL_REASON, event.code, 'navigation-cancelled'), + message: event.reason || undefined + }; + this.settle(event.id, (scenario) => + event.code !== undefined && INCOMPLETE_CODES.has(event.code) + ? scenario.incomplete(outcome) + : scenario.cancel(outcome) + ); + return; + } + + if (event instanceof NavigationSkipped) { + // A redirect back to the current URL ends in NavigationSkipped instead + // of the NavigationStart the stashed scenario waits for. Release it, and + // drop the click intent so it can't backdate an unrelated navigation. + this.navigationIntentAt = undefined; + const outcome = { + reason: causeOf(SKIP_REASON, event.code, 'navigation-skipped'), + message: event.reason || undefined + }; + this.consumePendingRedirectScenario()?.cancel(outcome); + this.settle(event.id, (scenario) => scenario.cancel(outcome)); + return; + } + + if (event instanceof NavigationError) { + // An unmatched URL fails before recognition, so no scenario exists yet. + const navigation = this.navigations.get(event.id); + if (navigation && !navigation.scenario) { + navigation.scenario = this.startScenario( + navigation.startedAt, + UNRECOGNIZED_ROUTE + ); + } + this.settle(event.id, (scenario) => + scenario.fail({ error: event.error }) + ); + } + } + + private startScenario(startedAt: number, route: string): UwtScenario { + return this.scenarioTelemetry.start({ + name: NAVIGATION_SCENARIO, + route, + startedAt, + timeoutMs: NAVIGATION_TIMEOUT_MS + }); + } + + /** + * Consumes the click timestamp, if fresh enough. + * + * @returns epoch ms to backdate to: the click, or now + */ + 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 consumePendingRedirectScenario(): UwtScenario | undefined { + const scenario = this.pendingRedirectScenario; + this.pendingRedirectScenario = undefined; + return scenario; + } + + /** + * Ends tracking of one navigation. One that never reached recognition + * (superseded or skipped first) has no scenario and records nothing — it + * would only spend the session's event budget. + */ + private settle( + navigationId: number, + apply: (scenario: UwtScenario) => void + ): void { + const scenario = this.navigations.get(navigationId)?.scenario; + this.navigations.delete(navigationId); + if (scenario) { + apply(scenario); + } + } +} 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..2af7065 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@angular/core'; +import type { + UwtRumBootstrap, + UwtRumCredentialsProvider +} from '@absaoss-cps/ngx-ui-watchtower/rum'; + +/** + * Endpoint of the backend broker that returns the RUM app-monitor settings + * and short-lived AWS credentials (`GET /rum/init`). + */ +const RUM_BROKER_URL = '/rum/init'; + +/** + * Shape returned by `GET /rum/init`. + * + * ASSUMPTION (unconfirmed): the broker answers with this shape. If it + * differs, adjust this interface and the mapping in `load()` — a response + * that doesn't match makes `load()` return `null`, which only disables RUM + * for the session; the app keeps working. + */ +interface RumBrokerResponse { + enabled: boolean; + config?: { + applicationId: string; + region: string; + applicationVersion: string; + sessionSampleRate?: number; + }; + credentials?: { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + expiration: string; + }; +} + +/** + * Supplies CloudWatch RUM settings and credentials from this application's + * backend broker, so the browser never holds a long-lived AWS identity. + * + * Called at startup and again shortly before each credential expiry. + */ +@Injectable({ providedIn: 'root' }) +export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { + /** + * Fetches the current app-monitor settings and credentials. + * + * @returns the bootstrap payload, or `null` — RUM off for this session — + * when the broker disables RUM, is unreachable, or answers incompletely + */ + async load(): Promise { + let response: Response; + try { + response = await fetch(RUM_BROKER_URL, { + headers: { Accept: 'application/json' }, + // The response carries live, temporary AWS credentials. + cache: 'no-store' + }); + } catch { + // A network-level failure rejects before any response exists. + return null; + } + + if (!response.ok) { + return null; + } + + let init: RumBrokerResponse; + try { + init = (await response.json()) as RumBrokerResponse; + } catch { + return null; + } + + // Credentials are required: without them the client would start and then + // fail every dispatch silently. + if (!init?.enabled || !init.config || !init.credentials) { + return null; + } + + return { + config: { + applicationId: init.config.applicationId, + region: init.config.region, + applicationVersion: init.config.applicationVersion, + sessionSampleRate: init.config.sessionSampleRate + }, + credentials: init.credentials + }; + } +} 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..ba670d9 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-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. */ + 'route-navigation': true; + } + + interface UwtScenarioSteps { + /** From the recognized URL to activation: guards, resolvers, lazy components. */ + 'resolve-route': true; + + /** Activating the routed component. */ + activate: 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..ef19921 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.app.json @@ -0,0 +1,17 @@ +/* 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", + "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..d383706 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* 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", + "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..4a53f26 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/angular.json @@ -0,0 +1,79 @@ +{ + "$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" + ], + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "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..5382b0b --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/package.json @@ -0,0 +1,29 @@ +{ + "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": "^0.1.0", + "@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/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..78e04fb --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.config.ts @@ -0,0 +1,36 @@ +import { ApplicationConfig, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; +import { provideUwtTelemetry } from '@absaoss-cps/ngx-ui-watchtower'; +import { + UWT_RUM_CREDENTIALS_PROVIDER, + provideUwtTelemetryRumSink +} from '@absaoss-cps/ngx-ui-watchtower/rum'; +import { routes } from './app.routes'; +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), + 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 } + ] +}; 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..5a215f4 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/app.ts @@ -0,0 +1,24 @@ +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); + + constructor() { + this.routeTelemetry.start(); + } + + /** 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..e02b965 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customers.spec.ts @@ -0,0 +1,83 @@ +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 })]); + }); + + 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..9401032 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/customers/customers.ts @@ -0,0 +1,65 @@ +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { UwtScenario, UwtScenarioTelemetryService } from '@absaoss-cps/ngx-ui-watchtower'; +import '../telemetry/telemetry.schema'; + +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: HttpErrorResponse) => { + this.error.set(true); + scenario.fail({ error, statusCode: error.status }); + } + }); + } + + 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.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts new file mode 100644 index 0000000..442d721 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts @@ -0,0 +1,139 @@ +import { Component, Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter, Router, Routes } from '@angular/router'; +import { + provideUwtTelemetry, + provideUwtTelemetryDestination, + UwtTelemetrySink, + withScenarios +} from '@absaoss-cps/ngx-ui-watchtower'; +import { RouteNavigationTelemetryService } from './route-navigation-telemetry.service'; + +const events: { eventType: string; payload: Record }[] = []; + +/** Records everything handed to the destination, so tests assert on the wire. */ +@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 Stub {} + +/** Mirrors the app's route shapes (redirect, static, `:param`) plus failure paths. */ +const routes: Routes = [ + { path: '', pathMatch: 'full', redirectTo: 'customers' }, + { path: 'customers', component: Stub }, + { path: 'customers/:customerId', component: Stub }, + { path: 'blocked', canActivate: [() => false], component: Stub }, + { + path: 'broken', + canActivate: [ + () => { + throw new Error('guard exploded'); + } + ], + component: Stub + } +]; + +function scenarios(): Record[] { + return events.filter((e) => e.eventType === 'com.uwt.scenario').map((e) => e.payload); +} + +function stepNames(record: Record): string[] { + return (record['steps'] as { name: string }[]).map((s) => s.name); +} + +describe('RouteNavigationTelemetryService', () => { + let router: Router; + + beforeEach(() => { + events.length = 0; + TestBed.configureTestingModule({ + providers: [ + provideRouter(routes), + provideUwtTelemetry( + { application: 'customer-portal-test', environment: 'test', version: '0.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }) + ), + provideUwtTelemetryDestination(RecordingSink) + ] + }); + router = TestBed.inject(Router); + TestBed.inject(RouteNavigationTelemetryService).start(); + }); + + it('reports a successful navigation under its route template, never the resolved id', async () => { + await router.navigateByUrl('/customers/42?tab=orders'); + + expect(scenarios()).toEqual([ + expect.objectContaining({ + scenarioName: 'route-navigation', + status: 'success', + route: '/customers/:customerId', + metadata: expect.objectContaining({ finalRoute: '/customers/:customerId' }) + }) + ]); + expect(stepNames(scenarios()[0])).toEqual( + expect.arrayContaining(['resolve-route', 'activate']) + ); + }); + + it('reports the redirect from the empty path under the target template', async () => { + await router.navigateByUrl('/'); + + expect(scenarios()).toEqual([ + expect.objectContaining({ + status: 'success', + route: '/customers', + metadata: expect.objectContaining({ finalRoute: '/customers' }) + }) + ]); + }); + + it('reports a guard rejection as incomplete, not as a failure', async () => { + await router.navigateByUrl('/blocked'); + + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'incomplete', reason: 'guard-rejected', route: '/blocked' }) + ]); + }); + + it('records nothing for a navigation superseded before the URL was recognized', async () => { + const first = router.navigateByUrl('/customers/1'); + const second = router.navigateByUrl('/customers/2'); + await Promise.all([first, second]); + + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'success', route: '/customers/:customerId' }) + ]); + }); + + it('reports a navigation error as a failure', async () => { + await router.navigateByUrl('/broken').catch(() => undefined); + + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'failure', route: '/broken' }) + ]); + }); + + it('reports an unknown URL under a placeholder template, not the raw URL', async () => { + await router.navigateByUrl('/nope/123').catch(() => undefined); + + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'failure', route: '(unrecognized)' }) + ]); + }); +}); 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..53c5975 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.ts @@ -0,0 +1,296 @@ +import { DestroyRef, inject, Injectable } 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'; + +/** Name shared by every route-navigation scenario. */ +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; + +/** + * Neither completed nor superseded within this window means a stuck chunk + * load, not a slow one. + */ +const NAVIGATION_TIMEOUT_MS = 30_000; + +/** `route` of a navigation whose URL matched no route. */ +const UNRECOGNIZED_ROUTE = '(unrecognized)'; + +/** + * Route `data` key naming a `matcher` route's segment in the template — a + * matcher has no `path` to report. + */ +const TELEMETRY_PATH_DATA_KEY = 'telemetryPath'; + +/** + * Stable, low-cardinality causes, derived from the router's `code`. The + * router's `reason` text is dev-mode only — empty in a production build — so + * it can't be the grouping key. + */ +const CANCEL_REASON: Partial> = { + [NavigationCancellationCode.Redirect]: 'redirect', + [NavigationCancellationCode.SupersededByNewNavigation]: 'superseded', + [NavigationCancellationCode.NoDataFromResolver]: 'no-data-from-resolver', + [NavigationCancellationCode.GuardRejected]: 'guard-rejected', + [NavigationCancellationCode.Aborted]: 'aborted' +}; + +/** + * Cancellations that are an expected dead end of the journey (`incomplete`), + * not the user leaving it (`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; +} + +/** + * The matched route's template, from the `routeConfig` chain of the primary + * outlet: `/customers/42` → `/customers/:id`. Exact after redirects and lazy + * `loadChildren`, because the router has done the matching. + */ +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('/')}`; +} + +/** One router navigation being tracked. */ +interface TrackedNavigation { + /** Epoch ms the journey started: the click, or `NavigationStart`. */ + startedAt: number; + /** Started once the URL is recognized — the route template is known only then. */ + scenario?: UwtScenario; +} + +/** + * Measures every router navigation as a `route-navigation` scenario. + * + * The scenario starts when the router has recognized the URL, so `route` is + * the matched template (`/customers/:id`), and is backdated to the click or + * `NavigationStart`, so the duration covers the whole wait. + * + * Call {@link start} once, from the root component's constructor. Call + * {@link markNavigationIntent} from navigation-link click handlers. + */ +@Injectable({ providedIn: 'root' }) +export class RouteNavigationTelemetryService { + private readonly router = inject(Router); + private readonly scenarioTelemetry = inject(UwtScenarioTelemetryService); + private readonly destroyRef = inject(DestroyRef); + + /** + * Keyed by navigation id, never a single "current" field: one navigation + * can supersede another, and a single field would attribute the wrong + * duration. + */ + private readonly navigations = new Map(); + + /** A redirected navigation's scenario, continued by the next navigation. */ + private pendingRedirectScenario?: UwtScenario; + + /** When the user last did something expected to start a navigation. */ + private navigationIntentAt?: number; + + private started = false; + + /** Begins tracking router navigations. 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)); + } + + /** + * Records that the user just did something expected to start a + * navigation. The router raises `NavigationStart` after event handling, + * guards and change detection; measuring from the click keeps that wait + * inside the recorded duration. + */ + markNavigationIntent(): void { + this.navigationIntentAt = Date.now(); + } + + private onRouterEvent(event: RouterEvent): void { + if (event instanceof NavigationStart) { + this.navigations.set(event.id, { + startedAt: this.navigationStartedAt(), + scenario: this.consumePendingRedirectScenario() + }); + return; + } + + if (event instanceof RoutesRecognized) { + const navigation = this.navigations.get(event.id); + if (navigation) { + navigation.scenario ??= this.startScenario( + navigation.startedAt, + routeTemplateOf(event.state.root) + ); + navigation.scenario.step('resolve-route'); + } + return; + } + + if (event instanceof NavigationEnd) { + this.settle(event.id, (scenario) => { + scenario.step('activate'); + scenario.complete({ + metadata: { + finalRoute: routeTemplateOf(this.router.routerState.snapshot.root) + } + }); + }); + return; + } + + if (event instanceof NavigationCancel) { + const scenario = this.navigations.get(event.id)?.scenario; + if (scenario && event.code === NavigationCancellationCode.Redirect) { + // A redirect restarts under a new id: continue this scenario on the + // next NavigationStart, so one journey stays one scenario. + this.navigations.delete(event.id); + this.pendingRedirectScenario = scenario; + return; + } + + const outcome = { + reason: causeOf(CANCEL_REASON, event.code, 'navigation-cancelled'), + message: event.reason || undefined + }; + this.settle(event.id, (scenario) => + event.code !== undefined && INCOMPLETE_CODES.has(event.code) + ? scenario.incomplete(outcome) + : scenario.cancel(outcome) + ); + return; + } + + if (event instanceof NavigationSkipped) { + // A redirect back to the current URL ends in NavigationSkipped instead + // of the NavigationStart the stashed scenario waits for. Release it, and + // drop the click intent so it can't backdate an unrelated navigation. + this.navigationIntentAt = undefined; + const outcome = { + reason: causeOf(SKIP_REASON, event.code, 'navigation-skipped'), + message: event.reason || undefined + }; + this.consumePendingRedirectScenario()?.cancel(outcome); + this.settle(event.id, (scenario) => scenario.cancel(outcome)); + return; + } + + if (event instanceof NavigationError) { + // An unmatched URL fails before recognition, so no scenario exists yet. + const navigation = this.navigations.get(event.id); + if (navigation && !navigation.scenario) { + navigation.scenario = this.startScenario( + navigation.startedAt, + UNRECOGNIZED_ROUTE + ); + } + this.settle(event.id, (scenario) => + scenario.fail({ error: event.error }) + ); + } + } + + private startScenario(startedAt: number, route: string): UwtScenario { + return this.scenarioTelemetry.start({ + name: NAVIGATION_SCENARIO, + route, + startedAt, + timeoutMs: NAVIGATION_TIMEOUT_MS + }); + } + + /** + * Consumes the click timestamp, if fresh enough. + * + * @returns epoch ms to backdate to: the click, or now + */ + 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 consumePendingRedirectScenario(): UwtScenario | undefined { + const scenario = this.pendingRedirectScenario; + this.pendingRedirectScenario = undefined; + return scenario; + } + + /** + * Ends tracking of one navigation. One that never reached recognition + * (superseded or skipped first) has no scenario and records nothing — it + * would only spend the session's event budget. + */ + private settle( + navigationId: number, + apply: (scenario: UwtScenario) => void + ): void { + const scenario = this.navigations.get(navigationId)?.scenario; + this.navigations.delete(navigationId); + if (scenario) { + apply(scenario); + } + } +} 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..2af7065 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@angular/core'; +import type { + UwtRumBootstrap, + UwtRumCredentialsProvider +} from '@absaoss-cps/ngx-ui-watchtower/rum'; + +/** + * Endpoint of the backend broker that returns the RUM app-monitor settings + * and short-lived AWS credentials (`GET /rum/init`). + */ +const RUM_BROKER_URL = '/rum/init'; + +/** + * Shape returned by `GET /rum/init`. + * + * ASSUMPTION (unconfirmed): the broker answers with this shape. If it + * differs, adjust this interface and the mapping in `load()` — a response + * that doesn't match makes `load()` return `null`, which only disables RUM + * for the session; the app keeps working. + */ +interface RumBrokerResponse { + enabled: boolean; + config?: { + applicationId: string; + region: string; + applicationVersion: string; + sessionSampleRate?: number; + }; + credentials?: { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + expiration: string; + }; +} + +/** + * Supplies CloudWatch RUM settings and credentials from this application's + * backend broker, so the browser never holds a long-lived AWS identity. + * + * Called at startup and again shortly before each credential expiry. + */ +@Injectable({ providedIn: 'root' }) +export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { + /** + * Fetches the current app-monitor settings and credentials. + * + * @returns the bootstrap payload, or `null` — RUM off for this session — + * when the broker disables RUM, is unreachable, or answers incompletely + */ + async load(): Promise { + let response: Response; + try { + response = await fetch(RUM_BROKER_URL, { + headers: { Accept: 'application/json' }, + // The response carries live, temporary AWS credentials. + cache: 'no-store' + }); + } catch { + // A network-level failure rejects before any response exists. + return null; + } + + if (!response.ok) { + return null; + } + + let init: RumBrokerResponse; + try { + init = (await response.json()) as RumBrokerResponse; + } catch { + return null; + } + + // Credentials are required: without them the client would start and then + // fail every dispatch silently. + if (!init?.enabled || !init.config || !init.credentials) { + return null; + } + + return { + config: { + applicationId: init.config.applicationId, + region: init.config.region, + applicationVersion: init.config.applicationVersion, + sessionSampleRate: init.config.sessionSampleRate + }, + credentials: init.credentials + }; + } +} 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..e96c400 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/telemetry.schema.ts @@ -0,0 +1,31 @@ +/** + * 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. */ + 'route-navigation': true; + + /** Loading the customer list, from request to rows shown. */ + 'customers-load': true; + } + + interface UwtScenarioSteps { + /** From the recognized URL to activation: guards, resolvers, lazy components. */ + 'resolve-route': true; + + /** Activating the routed component. */ + activate: true; + + /** 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..ef19921 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.app.json @@ -0,0 +1,17 @@ +/* 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", + "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..d383706 --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* 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", + "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..171235b --- /dev/null +++ b/skills/ui-watchtower-integration/evals/trigger-eval.json @@ -0,0 +1,50 @@ +[ + { + "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 + } +] 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..2116a4e --- /dev/null +++ b/skills/ui-watchtower-integration/references/instrumentation-patterns.md @@ -0,0 +1,199 @@ +# Instrumentation patterns + +How to choose, implement and settle scenarios and BI events. Each rule here cost real debugging time +in an earlier integration. + +## 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 | +|------------------------------------------------------------------|-------------------------------------------------------------| +| An `await` / subscription the user waits on before seeing a result | Scenario (route load, gating fetch, submit, upload, export) | +| A meaningful or irreversible click; a setting change | BI event | +| A `catch` / `error` handler that ends a journey | That scenario's `fail({ error })` | +| A guard, empty result, feature flag routing elsewhere | That scenario's `incomplete({ reason })` | +| Component teardown, supersession by a newer request | That scenario's `cancel({ reason })` | + +Skip: anything that fires continuously (scroll, mousemove, per-keystroke), purely decorative UI, +and work the user never waits for. Every event spends the 200-event RUM session budget. + +## 2. Choosing the settle status + +| Status | Call | Means | Response | +|--------------|------------------------|-----------------------------------------------------------------------------|-----------------------------------| +| `success` | `complete()` | The journey reached its goal | — | +| `failure` | `fail({ error })` | A defect: the journey broke | 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. + +Use a small, consistent set of `reason` strings — they are grouping keys: `'component-destroyed'`, +`'superseded'`, `'user-cancelled'`, `'no-results'`, `'guard-rejected'`. + +Pass `statusCode` (HTTP status or business code) with `fail()` so failures group by category. + +## 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({ error, statusCode: (error as { status?: number }).status }); + } +} +``` + +**Observable** — `traceScenario` settles on completion, error or early unsubscribe: + +```ts +this.api.customers().pipe( + 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'); +} +``` + +An unmatched `aggregateEnd`, or a second `aggregateStart` before its end, is ignored — an early +`return` can't corrupt the total. + +**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()))`. Not inside a +fragment (its frames belong to a hidden iframe) — settle with `complete()` there. + +## 4. RxJS traps + +**`take(1)` / `first()` upstream of `traceScenario`** unsubscribes before the source's `complete` +reaches the operator, so the scenario never settles and records `timeout`. Settle explicitly instead: + +```ts +return this.upload(file).pipe( + take(1), + map((result) => { scenario.complete({ metadata: { fileSize: file.size } }); return result; }), + catchError((error) => { scenario.fail({ error }); return of(null); }) +); +``` + +**`switchMap` supersession**: `switchMap` unsubscribes the previous inner observable *before* the +projector for the new value runs, so `previous.cancel({ reason: 'superseded' })` inside the new +projector is too late — the teardown already cancelled it without a reason. Give the reason up +front: + +```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( + 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()` — e.g. + `debounceTime(400)`. Repeated identical clicks within 400 ms are already collapsed by the library. +- A thin app wrapper is fine; type its name 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({error})`; cancel on destroy/supersede | +| `
-submit` | `validate`, `submit` | `complete`; `incomplete({reason:'validation-failed'})`; `fail({error, statusCode})` | +| `file-upload` | `read`, `upload`, `process` | `complete({metadata:{fileSize}})`; `cancel({reason:'user-cancelled'})`; `fail({error})` | +| `export-download` | `request`, `transfer` | `complete`; `cancel({reason:'user-cancelled'})`; `fail({error})` | + +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..1e80f1e --- /dev/null +++ b/skills/ui-watchtower-setup/SKILL.md @@ -0,0 +1,318 @@ +--- +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, or + connect a micro-frontend shell or fragment. 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 22.1.3+ application (the library's peer range), Node.js 22+, and npm registry + access to install @absaoss-cps/ngx-ui-watchtower. +--- + +# 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`. + +## 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 +``` + +### Step 1 · Detect (read-only) + +Read before writing anything — extend the app, don't restructure it. + +1. **Angular version** in `package.json`. The library's peer range is `@angular/core` / + `@angular/common` `^22.1.3` and `rxjs` `^7.8.2`. Below that, or not Angular at all, stop and tell + the user; an Angular upgrade is out of scope. +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 + (`makeEnvironmentProviders` works in both). +4. **SSR** — `app.config.server.ts`, `server.ts`, `@angular/ssr`. Affects only code *you* write + (Step 3, Gotchas); 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 already partly wired, keep what exists and run only the steps + for what is missing (destination and credentials, `telemetry.schema.ts`, user identity, the + route-navigation proof, spec providers). If nothing is missing, say so and offer + `ui-watchtower-integration`. +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. +8. **Router** — `provideRouter` / `RouterModule.forRoot`; note any `matcher` routes (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 +npm install aws-rum-web # only if THIS realm sends to RUM (standalone app or shell) +``` + +`aws-rum-web` is an optional peer: a fragment or a `'noop'` app never needs it, because everything +RUM-related lives in the separate entry point `@absaoss-cps/ngx-ui-watchtower/rum`. + +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, so a forgotten destination is caught on the first run, not on an empty +dashboard. 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; swap the one line later | +| 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`, 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. +Anything you compute here runs during server rendering too, so guard browser globals: + +```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. +- Credentials: copy `assets/rum-credentials.provider.ts`, point it at the broker endpoint from Step 1 + and map its response. `load()` returns `null` on **any** failure (that disables RUM for the + session; the app keeps working). `provideUwtTelemetryRumSink()` calls it from its own + non-blocking initializer — don't write one. The asset uses `fetch`, which bypasses `HttpClient` + interceptors: if the broker needs auth that an interceptor adds, call it through `HttpClient` + (`firstValueFrom`) instead. +- **User identity** (only when Step 1 found sign-in): where sign-in completes, call + `inject(UwtTelemetrySink).setUserId(opaqueId)`; on sign-out, `setUserId(undefined)` — that starts + a fresh RUM session. Never an email or username. In a fragment, leave it to the shell: a + fragment's `setUserId` is forwarded and changes the shell's user. + +### 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`, `resolve-route`); snake_case for BI + events (`export_clicked`). One JSDoc line per name saying what it measures. +- Augmentation applies program-wide, but keep `import './telemetry/telemetry.schema';` in the files + that own the wiring (provider config, the router telemetry service) so it never becomes orphaned. +- Declare `UwtLoggerNames` only if the app has a log backend (Step 3). + +### Step 5 · Existing RUM integration (conditional) + +If Step 1 found the app sending RUM events itself, load `references/rum-migration.md` and follow it +**before** Step 6. The library emits its own event types and payloads; replacing a hand-written +`RumService` naively changes what reaches CloudWatch and breaks existing dashboards. Migrate first, +diff the wire, delete last — and keep exactly one page-view recorder. If you can't run the app to +diff the wire, don't delete the old service; leave that as a follow-up. + +### Step 6 · Proof — route-navigation tracking + +One real scenario proves the whole pipeline (providers, destination, vocabulary, tests) and is +useful in every app. Copy `assets/route-navigation-telemetry.service.ts` next to the schema and call +its `start()` once from the root component's constructor (or from an app initializer). It works +unchanged for static, `:param`, lazy (`loadChildren`) and redirected routes. Only a `matcher` route +needs one thing: a `data: { telemetryPath: 'files/:path' }` entry naming its segment (otherwise it +reports `(matcher)`). + +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. + +What the asset gets right, and why — keep these if you change it: + +1. **Starts at `RoutesRecognized`, backdated** to `NavigationStart` or the click — before + recognition the router hasn't matched the URL, so the route template isn't known, and a + scenario's `route` can't change later. An unmatched URL fails as `(unrecognized)`. +2. **Keyed by navigation id**, never one "current" field — one navigation can supersede another. +3. **Redirects continue the same scenario** (`NavigationCancel` with + `NavigationCancellationCode.Redirect` is followed by a new `NavigationStart`); + `NavigationSkipped` releases a stashed one, or it leaks into an unrelated navigation. +4. **Statuses:** a guard returning `false` or a resolver with no data is `incomplete`; superseded, + aborted or skipped is `abandoned`; an error is `failure`. Reasons come from the router's `code`, + never `event.reason` (empty in production builds). +5. **Click-intent backdating:** `markNavigationIntent()` from nav-link click handlers; a mark older + than 2 s is discarded. + +### Step 7 · Tests + +The library ships no test doubles on purpose — the harness is a few lines. + +- 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 } + ] + ``` +- Add a spec for the route-navigation service with a **recording sink** (pattern in the API + reference, "Testing"), using the app's real route shapes: a successful navigation records + `success` with the route **template** (`/customers/:id`, never a resolved id), and a guard + rejection records `incomplete` with reason `guard-rejected`, not `failure`. Check the test bites: + remove the `complete()` call and confirm it fails, then 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** for real: `tsc --noEmit` against a solution-style root tsconfig (`"files": []` + + `"references"`) checks nothing — run `tsc -p tsconfig.app.json --noEmit` and the spec tsconfig, or + `tsc --build`. Passing tests prove nothing about types: Vitest (esbuild) never type-checks, and + Jest only does when ts-jest diagnostics are on. +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. + +Don't finish until build, tests and type-check are green. + +### Step 9 · Hand over + +Report what was wired (role, destination, credentials endpoint, log provider, user identity) and +any follow-ups. +Then tell the user the next step: **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 + +- **No default destination** — `NG0201` at bootstrap means a destination (or, for the logger and the + broadcast host, a log provider) is missing. Fix the wiring; never add a fallback provider "to make + it start". +- **One destination per realm, never two.** Another destination *replaces* RUM; it never runs beside + it. A fragment never gets the RUM sink. +- **`/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. +- **`route` is a metric dimension** — a template (`/customers/:id`), never a resolved URL; one series + per customer is both a cardinality and a PII problem. +- **One page-view recorder.** The RUM client already records a page view per navigation; don't add a + second one. +- **Telemetry never throws into the app** — every entry point fails open. Don't wrap calls in + try/catch. +- **Redaction cannot be fully disabled**, and URL **path** segments are not scrubbed — only query + strings and fragments. Keep personal data out of URLs, or enable `withRedaction({ scanValuePatterns: + ['email'] })`. + +## 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..a74df0f --- /dev/null +++ b/skills/ui-watchtower-setup/assets/log-api.provider.ts @@ -0,0 +1,59 @@ +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. + 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.ts b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts new file mode 100644 index 0000000..53c5975 --- /dev/null +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts @@ -0,0 +1,296 @@ +import { DestroyRef, inject, Injectable } 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'; + +/** Name shared by every route-navigation scenario. */ +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; + +/** + * Neither completed nor superseded within this window means a stuck chunk + * load, not a slow one. + */ +const NAVIGATION_TIMEOUT_MS = 30_000; + +/** `route` of a navigation whose URL matched no route. */ +const UNRECOGNIZED_ROUTE = '(unrecognized)'; + +/** + * Route `data` key naming a `matcher` route's segment in the template — a + * matcher has no `path` to report. + */ +const TELEMETRY_PATH_DATA_KEY = 'telemetryPath'; + +/** + * Stable, low-cardinality causes, derived from the router's `code`. The + * router's `reason` text is dev-mode only — empty in a production build — so + * it can't be the grouping key. + */ +const CANCEL_REASON: Partial> = { + [NavigationCancellationCode.Redirect]: 'redirect', + [NavigationCancellationCode.SupersededByNewNavigation]: 'superseded', + [NavigationCancellationCode.NoDataFromResolver]: 'no-data-from-resolver', + [NavigationCancellationCode.GuardRejected]: 'guard-rejected', + [NavigationCancellationCode.Aborted]: 'aborted' +}; + +/** + * Cancellations that are an expected dead end of the journey (`incomplete`), + * not the user leaving it (`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; +} + +/** + * The matched route's template, from the `routeConfig` chain of the primary + * outlet: `/customers/42` → `/customers/:id`. Exact after redirects and lazy + * `loadChildren`, because the router has done the matching. + */ +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('/')}`; +} + +/** One router navigation being tracked. */ +interface TrackedNavigation { + /** Epoch ms the journey started: the click, or `NavigationStart`. */ + startedAt: number; + /** Started once the URL is recognized — the route template is known only then. */ + scenario?: UwtScenario; +} + +/** + * Measures every router navigation as a `route-navigation` scenario. + * + * The scenario starts when the router has recognized the URL, so `route` is + * the matched template (`/customers/:id`), and is backdated to the click or + * `NavigationStart`, so the duration covers the whole wait. + * + * Call {@link start} once, from the root component's constructor. Call + * {@link markNavigationIntent} from navigation-link click handlers. + */ +@Injectable({ providedIn: 'root' }) +export class RouteNavigationTelemetryService { + private readonly router = inject(Router); + private readonly scenarioTelemetry = inject(UwtScenarioTelemetryService); + private readonly destroyRef = inject(DestroyRef); + + /** + * Keyed by navigation id, never a single "current" field: one navigation + * can supersede another, and a single field would attribute the wrong + * duration. + */ + private readonly navigations = new Map(); + + /** A redirected navigation's scenario, continued by the next navigation. */ + private pendingRedirectScenario?: UwtScenario; + + /** When the user last did something expected to start a navigation. */ + private navigationIntentAt?: number; + + private started = false; + + /** Begins tracking router navigations. 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)); + } + + /** + * Records that the user just did something expected to start a + * navigation. The router raises `NavigationStart` after event handling, + * guards and change detection; measuring from the click keeps that wait + * inside the recorded duration. + */ + markNavigationIntent(): void { + this.navigationIntentAt = Date.now(); + } + + private onRouterEvent(event: RouterEvent): void { + if (event instanceof NavigationStart) { + this.navigations.set(event.id, { + startedAt: this.navigationStartedAt(), + scenario: this.consumePendingRedirectScenario() + }); + return; + } + + if (event instanceof RoutesRecognized) { + const navigation = this.navigations.get(event.id); + if (navigation) { + navigation.scenario ??= this.startScenario( + navigation.startedAt, + routeTemplateOf(event.state.root) + ); + navigation.scenario.step('resolve-route'); + } + return; + } + + if (event instanceof NavigationEnd) { + this.settle(event.id, (scenario) => { + scenario.step('activate'); + scenario.complete({ + metadata: { + finalRoute: routeTemplateOf(this.router.routerState.snapshot.root) + } + }); + }); + return; + } + + if (event instanceof NavigationCancel) { + const scenario = this.navigations.get(event.id)?.scenario; + if (scenario && event.code === NavigationCancellationCode.Redirect) { + // A redirect restarts under a new id: continue this scenario on the + // next NavigationStart, so one journey stays one scenario. + this.navigations.delete(event.id); + this.pendingRedirectScenario = scenario; + return; + } + + const outcome = { + reason: causeOf(CANCEL_REASON, event.code, 'navigation-cancelled'), + message: event.reason || undefined + }; + this.settle(event.id, (scenario) => + event.code !== undefined && INCOMPLETE_CODES.has(event.code) + ? scenario.incomplete(outcome) + : scenario.cancel(outcome) + ); + return; + } + + if (event instanceof NavigationSkipped) { + // A redirect back to the current URL ends in NavigationSkipped instead + // of the NavigationStart the stashed scenario waits for. Release it, and + // drop the click intent so it can't backdate an unrelated navigation. + this.navigationIntentAt = undefined; + const outcome = { + reason: causeOf(SKIP_REASON, event.code, 'navigation-skipped'), + message: event.reason || undefined + }; + this.consumePendingRedirectScenario()?.cancel(outcome); + this.settle(event.id, (scenario) => scenario.cancel(outcome)); + return; + } + + if (event instanceof NavigationError) { + // An unmatched URL fails before recognition, so no scenario exists yet. + const navigation = this.navigations.get(event.id); + if (navigation && !navigation.scenario) { + navigation.scenario = this.startScenario( + navigation.startedAt, + UNRECOGNIZED_ROUTE + ); + } + this.settle(event.id, (scenario) => + scenario.fail({ error: event.error }) + ); + } + } + + private startScenario(startedAt: number, route: string): UwtScenario { + return this.scenarioTelemetry.start({ + name: NAVIGATION_SCENARIO, + route, + startedAt, + timeoutMs: NAVIGATION_TIMEOUT_MS + }); + } + + /** + * Consumes the click timestamp, if fresh enough. + * + * @returns epoch ms to backdate to: the click, or now + */ + 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 consumePendingRedirectScenario(): UwtScenario | undefined { + const scenario = this.pendingRedirectScenario; + this.pendingRedirectScenario = undefined; + return scenario; + } + + /** + * Ends tracking of one navigation. One that never reached recognition + * (superseded or skipped first) has no scenario and records nothing — it + * would only spend the session's event budget. + */ + private settle( + navigationId: number, + apply: (scenario: UwtScenario) => void + ): void { + const scenario = this.navigations.get(navigationId)?.scenario; + this.navigations.delete(navigationId); + if (scenario) { + apply(scenario); + } + } +} 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..c958b0f --- /dev/null +++ b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@angular/core'; +import type { + UwtRumBootstrap, + UwtRumCredentialsProvider +} from '@absaoss-cps/ngx-ui-watchtower/rum'; + +/** + * Endpoint of the backend that returns the RUM app-monitor settings and + * short-lived AWS credentials. Adapt to the app's own broker. + */ +const RUM_BROKER_URL = '/rum/init'; + +/** + * Shape returned by the backend broker. Adapt to the real response; map it + * to `UwtRumBootstrap` in `load()`. + */ +interface RumBrokerResponse { + enabled: boolean; + config?: { + applicationId: string; + region: string; + applicationVersion: string; + sessionSampleRate?: number; + }; + credentials?: { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + expiration: string; + }; +} + +/** + * Supplies CloudWatch RUM settings and credentials from this application's + * backend broker, so the browser never holds a long-lived AWS identity. + * + * Called at startup and again shortly before each credential expiry. + */ +@Injectable({ providedIn: 'root' }) +export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { + /** + * Fetches the current app-monitor settings and credentials. + * + * @returns the bootstrap payload, or `null` — RUM off for this session — + * when the broker disables RUM, is unreachable, or answers incompletely + */ + async load(): Promise { + let response: Response; + try { + response = await fetch(RUM_BROKER_URL, { + headers: { Accept: 'application/json' }, + // The response carries live, temporary AWS credentials. + cache: 'no-store' + }); + } catch { + // A network-level failure rejects before any response exists. + return null; + } + + if (!response.ok) { + return null; + } + + let init: RumBrokerResponse; + try { + init = (await response.json()) as RumBrokerResponse; + } catch { + return null; + } + + // Credentials are required: without them the client would start and then + // fail every dispatch silently. + if (!init?.enabled || !init.config || !init.credentials) { + return null; + } + + return { + config: { + applicationId: init.config.applicationId, + region: init.config.region, + applicationVersion: init.config.applicationVersion, + sessionSampleRate: init.config.sessionSampleRate + }, + credentials: init.credentials + }; + } +} 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..c4ccf5f --- /dev/null +++ b/skills/ui-watchtower-setup/assets/telemetry.schema.ts @@ -0,0 +1,47 @@ +/** + * 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. */ + '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; + } + + interface UwtScenarioSteps { + /** From the recognized URL to activation: guards, resolvers, lazy components. */ + 'resolve-route': true; + + /** Activating the routed component. */ + activate: true; + + // Steps and aggregates share this registry: + // /** 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..2c963c6 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/evals.json @@ -0,0 +1,199 @@ +{ + "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, fixes app.spec.ts providers, 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 on any failure", + "Does NOT inject UwtLoggerService, pass withLogging, or declare UwtLoggerNames (no log backend)", + "Creates telemetry.schema.ts augmenting UwtScenarioNames and UwtScenarioSteps, and side-effect-imports it where the providers are configured", + "Adds route-navigation tracking (the asset service, started from the root component) 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", + "Adds telemetry providers to app.spec.ts so it does not fail with NG0201", + "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 crypto.randomUUID()-based channel id 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, returning undefined", + "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", + "Creates telemetry.schema.ts and adds route-navigation tracking with a recording-sink test", + "Adds telemetry providers to app.spec.ts so it does not fail with NG0201", + "Explains how to switch to RUM later: replace the noop line with provideUwtTelemetryRumSink() plus a UWT_RUM_CREDENTIALS_PROVIDER, and install aws-rum-web", + "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", + "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, removes per-keystroke search events in favour of a debounced event, and keeps exactly one page-view recorder.", + "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 recorder: either drops the manual recordPageView, or keeps it via UwtRumTelemetrySink.recordPageView with disableAutoPageView: true", + "Replaces the email session attribute with sink.setUserId(opaqueId) and calls out that emails must not be sent", + "Replaces per-keystroke search tracking with a debounced BI event without the raw query text" + ] + }, + { + "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": "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 (Angular 22.1+), 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 requires Angular 22.1+", + "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/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..c269d3b --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/angular.json @@ -0,0 +1,76 @@ +{ + "$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", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "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..166cf18 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-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": { + "@absaoss-cps/ngx-ui-watchtower": "^0.1.0", + "@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-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..47cc454 --- /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 } from '@angular/router'; +import { provideUwtTelemetry } from '@absaoss-cps/ngx-ui-watchtower'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + 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..ef19921 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.app.json @@ -0,0 +1,17 @@ +/* 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", + "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..d383706 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* 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", + "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..c269d3b --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/angular.json @@ -0,0 +1,76 @@ +{ + "$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", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "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..cb25dc7 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-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-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..9dba522 --- /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 } 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/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..ef19921 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.app.json @@ -0,0 +1,17 @@ +/* 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", + "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..d383706 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* 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", + "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..a9d0810 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/trigger-eval.json @@ -0,0 +1,50 @@ +[ + { + "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": "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..b561f94 --- /dev/null +++ b/skills/ui-watchtower-setup/references/micro-frontends.md @@ -0,0 +1,134 @@ +# 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. + +## 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 { + UWT_LOG_API_PROVIDER, + UwtNoopLogApiProvider, + provideUwtTelemetry, + provideUwtTelemetryBroadcastHost +} from '@absaoss-cps/ngx-ui-watchtower'; +import { + UWT_RUM_CREDENTIALS_PROVIDER, + provideUwtTelemetryRumSink +} from '@absaoss-cps/ngx-ui-watchtower/rum'; + +declare global { + interface Window { + __uwtTelemetryChannel?: string; + } +} + +// One channel per page load, at module scope so it exists before any fragment boots. Browser-only: +// this module is also evaluated during server rendering, where the host is a no-op anyway. +const channelId = + typeof window === 'undefined' ? undefined : `ngx-ui-watchtower-${crypto.randomUUID()}`; +if (channelId) { + window.__uwtTelemetryChannel = channelId; +} + +providers: [ + provideUwtTelemetry({ application: 'shell', environment, version }), + provideUwtTelemetryRumSink(), + { provide: UWT_RUM_CREDENTIALS_PROVIDER, useExisting: AppRumCredentialsProvider }, + 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. +- **Why a `window` global** and not `sessionStorage`: browsers copy `sessionStorage` into a + duplicated tab, which would put both tabs back on one channel. A query parameter doesn't work + either: a bound fragment shares the shell's `window.location`. +- Per-tab channels give correct attribution, not a separate RUM session per tab — the session is a + cookie shared across tabs, by design. + +## 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. `undefined` (no shell, server rendering, cross-origin `top`) makes the sink + * fall back to the library's default channel `'ngx-ui-watchtower'` — with no host listening there, + * nothing ships and nothing fails. + */ +function shellChannelName(): string | undefined { + if (typeof window === 'undefined') { + return undefined; + } + try { + return window.top?.__uwtTelemetryChannel; + } catch { + return undefined; + } +} + +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: +shellChannelName() })`: the option is bound with `useValue`, read when the providers array is built — +possibly before the shell has published the id. The factory runs when the sink is first constructed. + +| Must match the shell | `eventNamespace`; the channel name (read from `window.top`) | +|----------------------|---------------------------------------------------------------------------------------------------------| +| **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()`. +- A fragment with no shell, **or on a wrong channel**, behaves identically: everything runs, nothing + ships, no error. An unresolved channel is not a no-op by itself — it falls back to the default + channel, so if some shell *is* hosting on the default channel (one that never adopted per-tab ids), + the fragment's events go there. Verify the channel deliberately (below). +- Correlation across realms is a string: forward `scenario.id` and start a child scenario with + `parentScenarioId`. + +## Verifying + +- In a fragment's realm, `window.top.__uwtTelemetryChannel` equals the id the shell generated. +- Once fragments forward, the shell's `inject(UwtTelemetryBroadcastHost).received` counter increases. +- 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..fa61411 --- /dev/null +++ b/skills/ui-watchtower-setup/references/rum-migration.md @@ -0,0 +1,88 @@ +# 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) | +|-----------|---------|------------|--------------|--------------------------------------| +| | | | | | + +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. + +## 2. Pick a parity tool per row + +| 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 | +| `inject(UwtTelemetrySink).record(eventType, payload)` | Byte-exact payload parity is required | Raw passthrough — you own the shape. Use sparingly and comment why | + +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. Migrate with both paths alive + +Move call sites one at a time. Keep `RumService` until the last row is migrated. Do not run two RUM +clients at once: once `provideUwtTelemetryRumSink()` is wired, the old service's remaining calls +should go through the library's sink (`inject(UwtTelemetrySink).record(...)`) or be migrated — two +`AwsRum` instances would mean two sessions per visitor. + +## 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.** The RUM client records a page view on every `history.pushState`, which covers the + Angular router. If the old code recorded page views manually, drop that code — or keep it via + `UwtRumTelemetrySink.recordPageView(template)` **and** set `disableAutoPageView: true` in the app + monitor config. Never both. If routes carry ids, the automatic page id (with `pageIdFormat: + 'PATH'`) is the resolved path — a cardinality and PII problem; record templates manually instead. +- **Free-text and per-keystroke events** (a search box sending every keystroke): debounce them, and + don't port the raw text — it is unbounded cardinality and can contain personal data. Send a + low-cardinality attribute instead (query length, result count, which filter was used). If a + documented consumer genuinely needs the text, ask before porting it. +- **`aws:` metadata keys** are reserved and dropped by the RUM client; the sink filters them out. Any + the old code set were already being dropped — don't port them. +- **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 `inject(UwtTelemetrySink).setUserId(id)` / + `setUserId(undefined)`. Sign-out starts a fresh session with a new anonymous id; if the old code + behaved differently, call out the change. +- **Errors.** The RUM client still captures unhandled JS errors itself. Handled errors the old code + sent with `recordError` become `scenario.fail({ error })`, or a log line with + `withLogging({ mirrorErrorsToRum: true })` when the app has a log backend. From 62d2e6b3930e5b301067612b695f993ddfb6750a Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 13:18:49 +0200 Subject: [PATCH 02/25] update tsconfigs --- .../evals/files/unwired-angular/tsconfig.app.json | 1 + .../evals/files/unwired-angular/tsconfig.spec.json | 1 + .../evals/files/wired-angular/tsconfig.app.json | 1 + .../evals/files/wired-angular/tsconfig.spec.json | 1 + .../evals/files/wired-instrumented-angular/tsconfig.app.json | 1 + .../evals/files/wired-instrumented-angular/tsconfig.spec.json | 1 + .../evals/files/partially-wired-angular/tsconfig.app.json | 1 + .../evals/files/partially-wired-angular/tsconfig.spec.json | 1 + .../evals/files/standalone-angular/tsconfig.app.json | 1 + .../evals/files/standalone-angular/tsconfig.spec.json | 1 + 10 files changed, 10 insertions(+) diff --git a/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.app.json b/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.app.json index ef19921..d5d9680 100644 --- a/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.app.json +++ b/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.app.json @@ -4,6 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", + "rootDir": "./src", "types": [ "node" ] diff --git a/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.spec.json b/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.spec.json index d383706..4c1202f 100644 --- a/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.spec.json +++ b/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.spec.json @@ -4,6 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", + "rootDir": "./src", "types": [ "vitest/globals" ] 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 index ef19921..d5d9680 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.app.json +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.app.json @@ -4,6 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", + "rootDir": "./src", "types": [ "node" ] 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 index d383706..4c1202f 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.spec.json +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/tsconfig.spec.json @@ -4,6 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", + "rootDir": "./src", "types": [ "vitest/globals" ] 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 index ef19921..d5d9680 100644 --- 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 @@ -4,6 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", + "rootDir": "./src", "types": [ "node" ] 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 index d383706..4c1202f 100644 --- 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 @@ -4,6 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", + "rootDir": "./src", "types": [ "vitest/globals" ] 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 index ef19921..d5d9680 100644 --- 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 @@ -4,6 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", + "rootDir": "./src", "types": [ "node" ] 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 index d383706..4c1202f 100644 --- 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 @@ -4,6 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", + "rootDir": "./src", "types": [ "vitest/globals" ] 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 index ef19921..d5d9680 100644 --- a/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.app.json +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.app.json @@ -4,6 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", + "rootDir": "./src", "types": [ "node" ] 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 index d383706..4c1202f 100644 --- a/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.spec.json +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/tsconfig.spec.json @@ -4,6 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", + "rootDir": "./src", "types": [ "vitest/globals" ] From 99a4cb6d25a9e022046fd8d854ae7f14f28a41e8 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 13:42:59 +0200 Subject: [PATCH 03/25] update --- docs/guides/ui-watchtower-setup.md | 6 +- .../references/ngx-ui-watchtower-api.md | 5 +- skills/ui-watchtower-integration/SKILL.md | 4 +- .../evals/files/wired-angular/package.json | 2 +- .../wired-instrumented-angular/package.json | 2 +- skills/ui-watchtower-setup/SKILL.md | 29 +++++-- skills/ui-watchtower-setup/evals/evals.json | 34 ++++++++- .../evals/files/future-angular/angular.json | 76 +++++++++++++++++++ .../evals/files/future-angular/package.json | 23 ++++++ .../future-angular/src/app/app.config.ts | 8 ++ .../future-angular/src/app/app.routes.ts | 13 ++++ .../files/future-angular/src/app/app.spec.ts | 16 ++++ .../evals/files/future-angular/src/app/app.ts | 12 +++ .../src/app/customers/customer-detail.ts | 9 +++ .../src/app/customers/customers.ts | 36 +++++++++ .../evals/files/future-angular/src/index.html | 12 +++ .../evals/files/future-angular/src/main.ts | 5 ++ .../evals/files/future-angular/src/styles.css | 1 + .../files/future-angular/tsconfig.app.json | 18 +++++ .../evals/files/future-angular/tsconfig.json | 31 ++++++++ .../files/future-angular/tsconfig.spec.json | 16 ++++ .../partially-wired-angular/package.json | 2 +- 22 files changed, 342 insertions(+), 18 deletions(-) create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/angular.json create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/package.json create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.config.ts create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.routes.ts create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.spec.ts create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.ts create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/app/customers/customer-detail.ts create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/app/customers/customers.ts create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/index.html create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/main.ts create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/styles.css create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.app.json create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.json create mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.spec.json diff --git a/docs/guides/ui-watchtower-setup.md b/docs/guides/ui-watchtower-setup.md index 32ce32a..0a02cd0 100644 --- a/docs/guides/ui-watchtower-setup.md +++ b/docs/guides/ui-watchtower-setup.md @@ -51,8 +51,10 @@ If the library is already partly wired, only the missing pieces are added. ## Requirements -- An Angular application on Angular 22.1.3 or later (the library's peer range) -- Node.js 22+ +- An Angular version the library supports — its `peerDependencies`. The skill reads them from npm + at run time; for an older Angular it offers an older compatible release if one exists, and for a + newer Angular than the library supports yet it stops instead of forcing the install +- A Node.js version your Angular version supports - Access to the npm registry --- diff --git a/skills/shared/references/ngx-ui-watchtower-api.md b/skills/shared/references/ngx-ui-watchtower-api.md index d79b04c..67edb11 100644 --- a/skills/shared/references/ngx-ui-watchtower-api.md +++ b/skills/shared/references/ngx-ui-watchtower-api.md @@ -1,7 +1,8 @@ # ngx-ui-watchtower API reference -The public surface of `@absaoss-cps/ngx-ui-watchtower` an app or a custom sink uses (checked against -version 0.1.0; config tokens and redaction constants omitted). Import only +The public surface of `@absaoss-cps/ngx-ui-watchtower` an app or a custom sink uses (config tokens and +redaction constants omitted). Always install the latest version; if its typings differ from this +page, the typings win. Import only from the two entry points below — never from `…/src/lib/…` paths, and never re-export the `/rum` entry from a shared barrel. diff --git a/skills/ui-watchtower-integration/SKILL.md b/skills/ui-watchtower-integration/SKILL.md index 90b0897..4c5c9f7 100644 --- a/skills/ui-watchtower-integration/SKILL.md +++ b/skills/ui-watchtower-integration/SKILL.md @@ -8,8 +8,8 @@ description: > the library (→ ui-watchtower-setup), dashboards, non-Angular apps. license: Apache-2.0 compatibility: > - Requires an Angular 22.1.3+ application where @absaoss-cps/ngx-ui-watchtower is already installed and - wired (see ui-watchtower-setup), and Node.js 22+. + Requires an Angular application where @absaoss-cps/ngx-ui-watchtower is already installed and + wired (see ui-watchtower-setup). --- # ui-watchtower-integration diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/package.json b/skills/ui-watchtower-integration/evals/files/wired-angular/package.json index 5382b0b..cb0d6d3 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-angular/package.json +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/package.json @@ -8,7 +8,7 @@ }, "private": true, "dependencies": { - "@absaoss-cps/ngx-ui-watchtower": "^0.1.0", + "@absaoss-cps/ngx-ui-watchtower": "latest", "@angular/common": "^22.2.0", "@angular/compiler": "^22.2.0", "@angular/core": "^22.2.0", 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 index 5382b0b..cb0d6d3 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/package.json +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/package.json @@ -8,7 +8,7 @@ }, "private": true, "dependencies": { - "@absaoss-cps/ngx-ui-watchtower": "^0.1.0", + "@absaoss-cps/ngx-ui-watchtower": "latest", "@angular/common": "^22.2.0", "@angular/compiler": "^22.2.0", "@angular/core": "^22.2.0", diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index 1e80f1e..e1ff04c 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -10,8 +10,9 @@ description: > events (→ ui-watchtower-integration), dashboards, non-Angular apps. license: Apache-2.0 compatibility: > - Requires an Angular 22.1.3+ application (the library's peer range), Node.js 22+, and npm registry - access to install @absaoss-cps/ngx-ui-watchtower. + Requires an Angular application within the peer range of the latest + @absaoss-cps/ngx-ui-watchtower release (checked at run time with npm view), and npm registry + access. --- # ui-watchtower-setup @@ -60,9 +61,19 @@ Copy this checklist and track progress: Read before writing anything — extend the app, don't restructure it. -1. **Angular version** in `package.json`. The library's peer range is `@angular/core` / - `@angular/common` `^22.1.3` and `rxjs` `^7.8.2`. Below that, or not Angular at all, stop and tell - the user; an Angular upgrade is out of scope. +1. **Angular version vs the supported range.** Not Angular at all → stop. Otherwise read the + app's `@angular/core` and `rxjs` versions (installed, or else the ranges in `package.json`), and + the library's supported range from the registry — never from memory; it changes with releases: + `npm view @absaoss-cps/ngx-ui-watchtower@latest peerDependencies`. + - **Inside the range** → install the latest version (Step 2). + - **Older than the range** → find the newest version whose peers fit + (`npm view @absaoss-cps/ngx-ui-watchtower versions --json`, then `npm view …@ + peerDependencies` per candidate). If one exists, offer it, pinned; the API reference describes + the latest version, so the installed typings win where they differ. If none fits, stop: an + Angular upgrade is out of scope. + - **Newer than the range** → the library doesn't support this Angular yet. Stop and tell the + user. Never install with `--force` or `--legacy-peer-deps`: a peer range is the library's + compatibility contract with Angular's compiler and runtime. 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. @@ -99,10 +110,12 @@ Ask the user only what the code cannot answer, in one message: ```bash # npm / Yarn / pnpm — use the one detected in Step 1 -npm install @absaoss-cps/ngx-ui-watchtower +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) ``` +If the install reports a peer conflict, stop and report it — don't override it. + `aws-rum-web` is an optional peer: a fragment or a `'noop'` app never needs it, because everything RUM-related lives in the separate entry point `@absaoss-cps/ngx-ui-watchtower/rum`. @@ -278,7 +291,9 @@ Run, through the detected package manager, until all are green: 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. -Don't finish until build, tests and type-check are green. +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 diff --git a/skills/ui-watchtower-setup/evals/evals.json b/skills/ui-watchtower-setup/evals/evals.json index 2c963c6..49c1a56 100644 --- a/skills/ui-watchtower-setup/evals/evals.json +++ b/skills/ui-watchtower-setup/evals/evals.json @@ -180,18 +180,48 @@ "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/angular.json", + "evals/files/future-angular/tsconfig.json", + "evals/files/future-angular/tsconfig.app.json", + "evals/files/future-angular/tsconfig.spec.json", + "evals/files/future-angular/src/index.html", + "evals/files/future-angular/src/styles.css", + "evals/files/future-angular/src/main.ts", + "evals/files/future-angular/src/app/app.config.ts", + "evals/files/future-angular/src/app/app.routes.ts", + "evals/files/future-angular/src/app/app.ts", + "evals/files/future-angular/src/app/app.spec.ts", + "evals/files/future-angular/src/app/customers/customers.ts", + "evals/files/future-angular/src/app/customers/customer-detail.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 (Angular 22.1+), and does not install or wire anything.", + "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 requires Angular 22.1+", + "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/future-angular/angular.json b/skills/ui-watchtower-setup/evals/files/future-angular/angular.json new file mode 100644 index 0000000..c269d3b --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-angular/angular.json @@ -0,0 +1,76 @@ +{ + "$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", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "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/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/app/app.routes.ts b/skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.routes.ts new file mode 100644 index 0000000..097aa25 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-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/future-angular/src/app/app.spec.ts b/skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.spec.ts new file mode 100644 index 0000000..8e82882 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-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/future-angular/src/app/app.ts b/skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.ts new file mode 100644 index 0000000..241dab5 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-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/future-angular/src/app/customers/customer-detail.ts b/skills/ui-watchtower-setup/evals/files/future-angular/src/app/customers/customer-detail.ts new file mode 100644 index 0000000..8855ad4 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-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/future-angular/src/app/customers/customers.ts b/skills/ui-watchtower-setup/evals/files/future-angular/src/app/customers/customers.ts new file mode 100644 index 0000000..b2b9cd7 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-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/future-angular/src/index.html b/skills/ui-watchtower-setup/evals/files/future-angular/src/index.html new file mode 100644 index 0000000..1e743a7 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-angular/src/index.html @@ -0,0 +1,12 @@ + + + + + Customer portal + + + + + + + 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/future-angular/src/styles.css b/skills/ui-watchtower-setup/evals/files/future-angular/src/styles.css new file mode 100644 index 0000000..4b438c4 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-angular/src/styles.css @@ -0,0 +1 @@ +/* Global styles */ diff --git a/skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.app.json b/skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.app.json new file mode 100644 index 0000000..d5d9680 --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-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/future-angular/tsconfig.json b/skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.json new file mode 100644 index 0000000..d2fbb9c --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-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/future-angular/tsconfig.spec.json b/skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.spec.json new file mode 100644 index 0000000..4c1202f --- /dev/null +++ b/skills/ui-watchtower-setup/evals/files/future-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/partially-wired-angular/package.json b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/package.json index 166cf18..53a0792 100644 --- a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/package.json +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/package.json @@ -4,7 +4,7 @@ "scripts": { "start": "ng serve", "build": "ng build", "test": "ng test" }, "private": true, "dependencies": { - "@absaoss-cps/ngx-ui-watchtower": "^0.1.0", + "@absaoss-cps/ngx-ui-watchtower": "latest", "@angular/common": "^22.2.0", "@angular/compiler": "^22.2.0", "@angular/core": "^22.2.0", From 7bd870d0588c0a8c173b6f8a5b5883dc7b712307 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 14:57:27 +0200 Subject: [PATCH 04/25] address feedback --- docs/guides/ui-watchtower-setup.md | 2 +- .../references/ngx-ui-watchtower-api.md | 5 +- .../files/wired-angular/src/app/app.config.ts | 15 +- ...route-navigation-telemetry.service.spec.ts | 35 ++- .../route-navigation-telemetry.service.ts | 230 +++++++++++------- .../app/telemetry/rum-credentials.provider.ts | 96 +++++--- .../src/app/telemetry/telemetry.schema.ts | 10 +- .../src/app/app.config.ts | 15 +- ...route-navigation-telemetry.service.spec.ts | 35 ++- .../route-navigation-telemetry.service.ts | 230 +++++++++++------- .../app/telemetry/rum-credentials.provider.ts | 96 +++++--- .../src/app/telemetry/telemetry.schema.ts | 8 +- skills/ui-watchtower-setup/SKILL.md | 72 ++++-- .../route-navigation-telemetry.service.ts | 230 +++++++++++------- .../assets/rum-credentials.provider.ts | 90 +++++-- .../assets/telemetry.schema.ts | 19 +- skills/ui-watchtower-setup/evals/evals.json | 6 +- .../references/micro-frontends.md | 11 + .../references/rum-migration.md | 22 +- 19 files changed, 768 insertions(+), 459 deletions(-) diff --git a/docs/guides/ui-watchtower-setup.md b/docs/guides/ui-watchtower-setup.md index 0a02cd0..6ac32be 100644 --- a/docs/guides/ui-watchtower-setup.md +++ b/docs/guides/ui-watchtower-setup.md @@ -20,7 +20,7 @@ interactions. | 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 (route templates, not ids) with a test | +| 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 | diff --git a/skills/shared/references/ngx-ui-watchtower-api.md b/skills/shared/references/ngx-ui-watchtower-api.md index 67edb11..cce6e88 100644 --- a/skills/shared/references/ngx-ui-watchtower-api.md +++ b/skills/shared/references/ngx-ui-watchtower-api.md @@ -1,8 +1,9 @@ # ngx-ui-watchtower API reference The public surface of `@absaoss-cps/ngx-ui-watchtower` an app or a custom sink uses (config tokens and -redaction constants omitted). Always install the latest version; if its typings differ from this -page, the typings win. Import only +redaction constants omitted). Install the version `ui-watchtower-setup` selects from peer +compatibility — normally the latest. This page describes the latest release; if the installed +typings differ, the typings win. Import only from the two entry points below — never from `…/src/lib/…` paths, and never re-export the `/rum` entry from a shared barrel. 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 index 78e04fb..0301ecf 100644 --- 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 @@ -1,12 +1,14 @@ -import { ApplicationConfig, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { ApplicationConfig, inject, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; import { provideRouter } 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 { 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 @@ -31,6 +33,15 @@ export const appConfig: ApplicationConfig = { // credentials come from GET /rum/init; a failed fetch only disables RUM // for the session. provideUwtTelemetryRumSink(), - { provide: UWT_RUM_CREDENTIALS_PROVIDER, useExisting: AppRumCredentialsProvider } + { 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/telemetry/route-navigation-telemetry.service.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts index 442d721..69c1d2c 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts @@ -7,9 +7,13 @@ import { UwtTelemetrySink, withScenarios } from '@absaoss-cps/ngx-ui-watchtower'; -import { RouteNavigationTelemetryService } from './route-navigation-telemetry.service'; +import { + RouteNavigationTelemetryService, + ROUTE_PAGE_VIEW_RECORDER +} from './route-navigation-telemetry.service'; const events: { eventType: string; payload: Record }[] = []; +const pageViews: string[] = []; /** Records everything handed to the destination, so tests assert on the wire. */ @Injectable() @@ -52,15 +56,12 @@ function scenarios(): Record[] { return events.filter((e) => e.eventType === 'com.uwt.scenario').map((e) => e.payload); } -function stepNames(record: Record): string[] { - return (record['steps'] as { name: string }[]).map((s) => s.name); -} - describe('RouteNavigationTelemetryService', () => { let router: Router; beforeEach(() => { events.length = 0; + pageViews.length = 0; TestBed.configureTestingModule({ providers: [ provideRouter(routes), @@ -68,7 +69,8 @@ describe('RouteNavigationTelemetryService', () => { { application: 'customer-portal-test', environment: 'test', version: '0.0.0' }, withScenarios({ defaultTimeoutMs: 0 }) ), - provideUwtTelemetryDestination(RecordingSink) + provideUwtTelemetryDestination(RecordingSink), + { provide: ROUTE_PAGE_VIEW_RECORDER, useValue: (route: string) => pageViews.push(route) } ] }); router = TestBed.inject(Router); @@ -76,30 +78,24 @@ describe('RouteNavigationTelemetryService', () => { }); it('reports a successful navigation under its route template, never the resolved id', async () => { - await router.navigateByUrl('/customers/42?tab=orders'); + await router.navigateByUrl('/customers/cust-SECRET-7?tab=orders'); expect(scenarios()).toEqual([ expect.objectContaining({ scenarioName: 'route-navigation', status: 'success', - route: '/customers/:customerId', - metadata: expect.objectContaining({ finalRoute: '/customers/:customerId' }) + route: '/customers/:customerId' }) ]); - expect(stepNames(scenarios()[0])).toEqual( - expect.arrayContaining(['resolve-route', 'activate']) - ); + expect(pageViews).toEqual(['/customers/:customerId']); + expect(JSON.stringify(events)).not.toContain('SECRET'); }); it('reports the redirect from the empty path under the target template', async () => { await router.navigateByUrl('/'); expect(scenarios()).toEqual([ - expect.objectContaining({ - status: 'success', - route: '/customers', - metadata: expect.objectContaining({ finalRoute: '/customers' }) - }) + expect.objectContaining({ status: 'success', route: '/customers' }) ]); }); @@ -129,11 +125,12 @@ describe('RouteNavigationTelemetryService', () => { ]); }); - it('reports an unknown URL under a placeholder template, not the raw URL', async () => { - await router.navigateByUrl('/nope/123').catch(() => undefined); + it('reports an unknown URL under a placeholder template, with no URL in the payload', async () => { + await router.navigateByUrl('/nope/cust-SECRET-7').catch(() => undefined); expect(scenarios()).toEqual([ expect.objectContaining({ status: 'failure', route: '(unrecognized)' }) ]); + expect(JSON.stringify(events)).not.toContain('SECRET'); }); }); 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 index 53c5975..6369843 100644 --- 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 @@ -1,4 +1,4 @@ -import { DestroyRef, inject, Injectable } from '@angular/core'; +import { DestroyRef, inject, Injectable, InjectionToken } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRouteSnapshot, @@ -21,19 +21,34 @@ import { // 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 only where AWS CloudWatch RUM is the destination, together with + * `disableAutoPageView: true` in the app-monitor config — automatic page + * views would report the resolved path (`/customers/42`): + * + * ```ts + * { + * provide: ROUTE_PAGE_VIEW_RECORDER, + * useFactory: () => { + * const rum = inject(UwtRumTelemetrySink); + * return (route: string) => rum.recordPageView(route); + * } + * } + * ``` + */ +export const ROUTE_PAGE_VIEW_RECORDER = new InjectionToken<(route: string) => void>( + 'ROUTE_PAGE_VIEW_RECORDER' +); + /** Name shared by every route-navigation scenario. */ 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; -/** - * Neither completed nor superseded within this window means a stuck chunk - * load, not a slow one. - */ -const NAVIGATION_TIMEOUT_MS = 30_000; - -/** `route` of a navigation whose URL matched no route. */ +/** `route` of a navigation that failed before its URL matched a route. */ const UNRECOGNIZED_ROUTE = '(unrecognized)'; /** @@ -44,8 +59,8 @@ const TELEMETRY_PATH_DATA_KEY = 'telemetryPath'; /** * Stable, low-cardinality causes, derived from the router's `code`. The - * router's `reason` text is dev-mode only — empty in a production build — so - * it can't be the grouping key. + * router's `reason` text is never sent: it is empty in production builds and + * can contain URLs in development builds. */ const CANCEL_REASON: Partial> = { [NavigationCancellationCode.Redirect]: 'redirect', @@ -81,7 +96,7 @@ function causeOf( /** * The matched route's template, from the `routeConfig` chain of the primary - * outlet: `/customers/42` → `/customers/:id`. Exact after redirects and lazy + * outlet: `/customers/42` → `/customers/:id`. Exact after `redirectTo` and lazy * `loadChildren`, because the router has done the matching. */ function routeTemplateOf(root: ActivatedRouteSnapshot): string { @@ -105,20 +120,40 @@ function routeTemplateOf(root: ActivatedRouteSnapshot): string { return `/${parts.join('/')}`; } +/** + * The router's error for a URL that matched nothing quotes the URL; replace it + * so no path segment reaches telemetry. Its name is kept for grouping. + */ +function unrecognizedError(error: unknown): Error { + const generic = new Error('Navigation failed before the URL matched a route'); + if (error instanceof Error) { + generic.name = error.name; + } + return generic; +} + /** One router navigation being tracked. */ interface TrackedNavigation { - /** Epoch ms the journey started: the click, or `NavigationStart`. */ + /** Epoch ms the journey started: the click, `NavigationStart`, or the first navigation of a redirect chain. */ startedAt: number; - /** Started once the URL is recognized — the route template is known only then. */ - scenario?: UwtScenario; + /** Template of the route a guard or resolver redirected away from. */ + redirectedFrom?: string; + /** The matched route template, known from `RoutesRecognized` on. */ + route?: string; + /** Epoch ms of `RoutesRecognized`: guards, resolvers and lazy components run after it. */ + recognizedAt?: number; } +/** What a guard or resolver redirect carries into the next navigation. */ +type RedirectCarry = Pick; + /** - * Measures every router navigation as a `route-navigation` scenario. + * Measures every router navigation as one `route-navigation` scenario. * - * The scenario starts when the router has recognized the URL, so `route` is - * the matched template (`/customers/:id`), and is backdated to the click or - * `NavigationStart`, so the duration covers the whole wait. + * The scenario is recorded when the navigation ends, so `route` is always the + * final matched template — after `redirectTo`, lazy routes, and guard or + * resolver redirects — and backdated to the click or the first + * `NavigationStart`, so its duration covers the whole wait. * * Call {@link start} once, from the root component's constructor. Call * {@link markNavigationIntent} from navigation-link click handlers. @@ -127,6 +162,7 @@ interface TrackedNavigation { 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); /** @@ -136,8 +172,8 @@ export class RouteNavigationTelemetryService { */ private readonly navigations = new Map(); - /** A redirected navigation's scenario, continued by the next navigation. */ - private pendingRedirectScenario?: UwtScenario; + /** A redirected navigation, continued by the next `NavigationStart`. */ + private pendingRedirect?: RedirectCarry; /** When the user last did something expected to start a navigation. */ private navigationIntentAt?: number; @@ -168,9 +204,11 @@ export class RouteNavigationTelemetryService { private onRouterEvent(event: RouterEvent): void { if (event instanceof NavigationStart) { + const startedAt = this.navigationStartedAt(); + const redirect = this.consumePendingRedirect(); this.navigations.set(event.id, { - startedAt: this.navigationStartedAt(), - scenario: this.consumePendingRedirectScenario() + startedAt: redirect?.startedAt ?? startedAt, + redirectedFrom: redirect?.redirectedFrom }); return; } @@ -178,85 +216,109 @@ export class RouteNavigationTelemetryService { if (event instanceof RoutesRecognized) { const navigation = this.navigations.get(event.id); if (navigation) { - navigation.scenario ??= this.startScenario( - navigation.startedAt, - routeTemplateOf(event.state.root) - ); - navigation.scenario.step('resolve-route'); + navigation.route = routeTemplateOf(event.state.root); + navigation.recognizedAt = Date.now(); } return; } if (event instanceof NavigationEnd) { - this.settle(event.id, (scenario) => { - scenario.step('activate'); - scenario.complete({ - metadata: { - finalRoute: routeTemplateOf(this.router.routerState.snapshot.root) - } - }); - }); + 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 scenario = this.navigations.get(event.id)?.scenario; - if (scenario && event.code === NavigationCancellationCode.Redirect) { - // A redirect restarts under a new id: continue this scenario on the - // next NavigationStart, so one journey stays one scenario. - this.navigations.delete(event.id); - this.pendingRedirectScenario = scenario; + const navigation = this.take(event.id); + if (!navigation) { return; } - - const outcome = { - reason: causeOf(CANCEL_REASON, event.code, 'navigation-cancelled'), - message: event.reason || undefined - }; - this.settle(event.id, (scenario) => + if (event.code === NavigationCancellationCode.Redirect) { + // A guard or resolver redirect restarts under a new id: carry the + // start time, so one journey stays one scenario under its final route. + this.pendingRedirect = { + startedAt: navigation.startedAt, + redirectedFrom: navigation.redirectedFrom ?? navigation.route + }; + 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(outcome) - : scenario.cancel(outcome) + ? scenario.incomplete({ reason, metadata }) + : scenario.cancel({ reason, metadata }) ); return; } if (event instanceof NavigationSkipped) { // A redirect back to the current URL ends in NavigationSkipped instead - // of the NavigationStart the stashed scenario waits for. Release it, and + // of the NavigationStart the carried redirect waits for. Release it, and // drop the click intent so it can't backdate an unrelated navigation. this.navigationIntentAt = undefined; - const outcome = { - reason: causeOf(SKIP_REASON, event.code, 'navigation-skipped'), - message: event.reason || undefined - }; - this.consumePendingRedirectScenario()?.cancel(outcome); - this.settle(event.id, (scenario) => scenario.cancel(outcome)); + 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) { - // An unmatched URL fails before recognition, so no scenario exists yet. - const navigation = this.navigations.get(event.id); - if (navigation && !navigation.scenario) { - navigation.scenario = this.startScenario( - navigation.startedAt, - UNRECOGNIZED_ROUTE + const navigation = this.take(event.id); + if (navigation) { + const recognized = navigation.route !== undefined; + this.record(navigation, navigation.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => + scenario.fail({ + error: recognized ? event.error : unrecognizedError(event.error), + metadata + }) ); } - this.settle(event.id, (scenario) => - scenario.fail({ error: event.error }) - ); } } - private startScenario(startedAt: number, route: string): UwtScenario { - return this.scenarioTelemetry.start({ - name: NAVIGATION_SCENARIO, - route, - startedAt, - timeoutMs: NAVIGATION_TIMEOUT_MS - }); + /** + * Starts and settles the scenario in one go, backdated to the journey's + * start, once the final route template is known. + */ + private record( + navigation: TrackedNavigation, + route: string, + settle: (scenario: UwtScenario, metadata: Record) => void + ): void { + 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 + ); } /** @@ -272,25 +334,15 @@ export class RouteNavigationTelemetryService { return at === undefined || now - at > INTENT_MAX_AGE_MS ? now : at; } - private consumePendingRedirectScenario(): UwtScenario | undefined { - const scenario = this.pendingRedirectScenario; - this.pendingRedirectScenario = undefined; - return scenario; + private consumePendingRedirect(): RedirectCarry | undefined { + const redirect = this.pendingRedirect; + this.pendingRedirect = undefined; + return redirect; } - /** - * Ends tracking of one navigation. One that never reached recognition - * (superseded or skipped first) has no scenario and records nothing — it - * would only spend the session's event budget. - */ - private settle( - navigationId: number, - apply: (scenario: UwtScenario) => void - ): void { - const scenario = this.navigations.get(navigationId)?.scenario; + private take(navigationId: number): TrackedNavigation | undefined { + const navigation = this.navigations.get(navigationId); this.navigations.delete(navigationId); - if (scenario) { - apply(scenario); - } + return navigation; } } 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 index 2af7065..2c924f8 100644 --- 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 @@ -5,33 +5,46 @@ import type { } from '@absaoss-cps/ngx-ui-watchtower/rum'; /** - * Endpoint of the backend broker that returns the RUM app-monitor settings - * and short-lived AWS credentials (`GET /rum/init`). + * Endpoint of the backend that returns the RUM app-monitor settings and + * short-lived AWS credentials. Adapt to the app's own broker. */ const RUM_BROKER_URL = '/rum/init'; /** - * Shape returned by `GET /rum/init`. + * Shape expected from the backend broker. Adapt to the real response; `load()` + * checks every required field before using it. * - * ASSUMPTION (unconfirmed): the broker answers with this shape. If it - * differs, adjust this interface and the mapping in `load()` — a response - * that doesn't match makes `load()` return `null`, which only disables RUM - * for the session; the app keeps working. + * ```json + * { + * "enabled": true, + * "config": { "applicationId": "…", "region": "eu-west-1", "applicationVersion": "1.4.0", + * "sessionSampleRate": 1 }, + * "credentials": { "accessKeyId": "…", "secretAccessKey": "…", "sessionToken": "…", + * "expiration": "2026-10-08T12:00:00Z" } + * } + * ``` */ -interface RumBrokerResponse { - enabled: boolean; - config?: { - applicationId: string; - region: string; - applicationVersion: string; - sessionSampleRate?: number; - }; - credentials?: { - accessKeyId: string; - secretAccessKey: string; - sessionToken: string; - expiration: string; - }; +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); +} + +/** Whether `value` is an object whose `keys` are all non-empty strings. */ +function hasStrings( + value: unknown, + keys: readonly K[] +): value is Record & Record { + return ( + isRecord(value) && + keys.every((key) => typeof value[key] === 'string' && value[key] !== '') + ); } /** @@ -46,7 +59,8 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { * Fetches the current app-monitor settings and credentials. * * @returns the bootstrap payload, or `null` — RUM off for this session — - * when the broker disables RUM, is unreachable, or answers incompletely + * when the broker disables RUM, is unreachable, or answers with anything + * missing or malformed */ async load(): Promise { let response: Response; @@ -65,27 +79,45 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { return null; } - let init: RumBrokerResponse; + let body: unknown; try { - init = (await response.json()) as RumBrokerResponse; + body = await response.json(); } catch { return null; } - // Credentials are required: without them the client would start and then - // fail every dispatch silently. - if (!init?.enabled || !init.config || !init.credentials) { + // Validate, don't cast: a half-filled answer must turn RUM off, not start + // a client that fails every dispatch silently. + if (!isRecord(body) || body['enabled'] !== true) { + return null; + } + const { config, credentials } = body; + if ( + !hasStrings(config, REQUIRED_CONFIG) || + !hasStrings(credentials, REQUIRED_CREDENTIALS) || + Number.isNaN(Date.parse(credentials.expiration)) + ) { return null; } + const rate = config['sessionSampleRate']; return { config: { - applicationId: init.config.applicationId, - region: init.config.region, - applicationVersion: init.config.applicationVersion, - sessionSampleRate: init.config.sessionSampleRate + applicationId: config.applicationId, + region: config.region, + applicationVersion: config.applicationVersion, + sessionSampleRate: typeof rate === 'number' && rate >= 0 && rate <= 1 ? rate : undefined, + // Page views are recorded under the route template by + // RouteNavigationTelemetryService; automatic ones would send the + // resolved path (/customers/42). + disableAutoPageView: true }, - credentials: init.credentials + 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 index ba670d9..a464f78 100644 --- 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 @@ -9,17 +9,9 @@ */ declare module '@absaoss-cps/ngx-ui-watchtower' { interface UwtScenarioNames { - /** A router navigation, from click to activated route. */ + /** A router navigation, from click to activated route, under the final route template. */ 'route-navigation': true; } - - interface UwtScenarioSteps { - /** From the recognized URL to activation: guards, resolvers, lazy components. */ - 'resolve-route': true; - - /** Activating the routed component. */ - activate: true; - } } export {}; 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 index 78e04fb..0301ecf 100644 --- 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 @@ -1,12 +1,14 @@ -import { ApplicationConfig, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { ApplicationConfig, inject, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; import { provideRouter } 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 { 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 @@ -31,6 +33,15 @@ export const appConfig: ApplicationConfig = { // credentials come from GET /rum/init; a failed fetch only disables RUM // for the session. provideUwtTelemetryRumSink(), - { provide: UWT_RUM_CREDENTIALS_PROVIDER, useExisting: AppRumCredentialsProvider } + { 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/telemetry/route-navigation-telemetry.service.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts index 442d721..69c1d2c 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts @@ -7,9 +7,13 @@ import { UwtTelemetrySink, withScenarios } from '@absaoss-cps/ngx-ui-watchtower'; -import { RouteNavigationTelemetryService } from './route-navigation-telemetry.service'; +import { + RouteNavigationTelemetryService, + ROUTE_PAGE_VIEW_RECORDER +} from './route-navigation-telemetry.service'; const events: { eventType: string; payload: Record }[] = []; +const pageViews: string[] = []; /** Records everything handed to the destination, so tests assert on the wire. */ @Injectable() @@ -52,15 +56,12 @@ function scenarios(): Record[] { return events.filter((e) => e.eventType === 'com.uwt.scenario').map((e) => e.payload); } -function stepNames(record: Record): string[] { - return (record['steps'] as { name: string }[]).map((s) => s.name); -} - describe('RouteNavigationTelemetryService', () => { let router: Router; beforeEach(() => { events.length = 0; + pageViews.length = 0; TestBed.configureTestingModule({ providers: [ provideRouter(routes), @@ -68,7 +69,8 @@ describe('RouteNavigationTelemetryService', () => { { application: 'customer-portal-test', environment: 'test', version: '0.0.0' }, withScenarios({ defaultTimeoutMs: 0 }) ), - provideUwtTelemetryDestination(RecordingSink) + provideUwtTelemetryDestination(RecordingSink), + { provide: ROUTE_PAGE_VIEW_RECORDER, useValue: (route: string) => pageViews.push(route) } ] }); router = TestBed.inject(Router); @@ -76,30 +78,24 @@ describe('RouteNavigationTelemetryService', () => { }); it('reports a successful navigation under its route template, never the resolved id', async () => { - await router.navigateByUrl('/customers/42?tab=orders'); + await router.navigateByUrl('/customers/cust-SECRET-7?tab=orders'); expect(scenarios()).toEqual([ expect.objectContaining({ scenarioName: 'route-navigation', status: 'success', - route: '/customers/:customerId', - metadata: expect.objectContaining({ finalRoute: '/customers/:customerId' }) + route: '/customers/:customerId' }) ]); - expect(stepNames(scenarios()[0])).toEqual( - expect.arrayContaining(['resolve-route', 'activate']) - ); + expect(pageViews).toEqual(['/customers/:customerId']); + expect(JSON.stringify(events)).not.toContain('SECRET'); }); it('reports the redirect from the empty path under the target template', async () => { await router.navigateByUrl('/'); expect(scenarios()).toEqual([ - expect.objectContaining({ - status: 'success', - route: '/customers', - metadata: expect.objectContaining({ finalRoute: '/customers' }) - }) + expect.objectContaining({ status: 'success', route: '/customers' }) ]); }); @@ -129,11 +125,12 @@ describe('RouteNavigationTelemetryService', () => { ]); }); - it('reports an unknown URL under a placeholder template, not the raw URL', async () => { - await router.navigateByUrl('/nope/123').catch(() => undefined); + it('reports an unknown URL under a placeholder template, with no URL in the payload', async () => { + await router.navigateByUrl('/nope/cust-SECRET-7').catch(() => undefined); expect(scenarios()).toEqual([ expect.objectContaining({ status: 'failure', route: '(unrecognized)' }) ]); + expect(JSON.stringify(events)).not.toContain('SECRET'); }); }); 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 index 53c5975..6369843 100644 --- 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 @@ -1,4 +1,4 @@ -import { DestroyRef, inject, Injectable } from '@angular/core'; +import { DestroyRef, inject, Injectable, InjectionToken } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRouteSnapshot, @@ -21,19 +21,34 @@ import { // 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 only where AWS CloudWatch RUM is the destination, together with + * `disableAutoPageView: true` in the app-monitor config — automatic page + * views would report the resolved path (`/customers/42`): + * + * ```ts + * { + * provide: ROUTE_PAGE_VIEW_RECORDER, + * useFactory: () => { + * const rum = inject(UwtRumTelemetrySink); + * return (route: string) => rum.recordPageView(route); + * } + * } + * ``` + */ +export const ROUTE_PAGE_VIEW_RECORDER = new InjectionToken<(route: string) => void>( + 'ROUTE_PAGE_VIEW_RECORDER' +); + /** Name shared by every route-navigation scenario. */ 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; -/** - * Neither completed nor superseded within this window means a stuck chunk - * load, not a slow one. - */ -const NAVIGATION_TIMEOUT_MS = 30_000; - -/** `route` of a navigation whose URL matched no route. */ +/** `route` of a navigation that failed before its URL matched a route. */ const UNRECOGNIZED_ROUTE = '(unrecognized)'; /** @@ -44,8 +59,8 @@ const TELEMETRY_PATH_DATA_KEY = 'telemetryPath'; /** * Stable, low-cardinality causes, derived from the router's `code`. The - * router's `reason` text is dev-mode only — empty in a production build — so - * it can't be the grouping key. + * router's `reason` text is never sent: it is empty in production builds and + * can contain URLs in development builds. */ const CANCEL_REASON: Partial> = { [NavigationCancellationCode.Redirect]: 'redirect', @@ -81,7 +96,7 @@ function causeOf( /** * The matched route's template, from the `routeConfig` chain of the primary - * outlet: `/customers/42` → `/customers/:id`. Exact after redirects and lazy + * outlet: `/customers/42` → `/customers/:id`. Exact after `redirectTo` and lazy * `loadChildren`, because the router has done the matching. */ function routeTemplateOf(root: ActivatedRouteSnapshot): string { @@ -105,20 +120,40 @@ function routeTemplateOf(root: ActivatedRouteSnapshot): string { return `/${parts.join('/')}`; } +/** + * The router's error for a URL that matched nothing quotes the URL; replace it + * so no path segment reaches telemetry. Its name is kept for grouping. + */ +function unrecognizedError(error: unknown): Error { + const generic = new Error('Navigation failed before the URL matched a route'); + if (error instanceof Error) { + generic.name = error.name; + } + return generic; +} + /** One router navigation being tracked. */ interface TrackedNavigation { - /** Epoch ms the journey started: the click, or `NavigationStart`. */ + /** Epoch ms the journey started: the click, `NavigationStart`, or the first navigation of a redirect chain. */ startedAt: number; - /** Started once the URL is recognized — the route template is known only then. */ - scenario?: UwtScenario; + /** Template of the route a guard or resolver redirected away from. */ + redirectedFrom?: string; + /** The matched route template, known from `RoutesRecognized` on. */ + route?: string; + /** Epoch ms of `RoutesRecognized`: guards, resolvers and lazy components run after it. */ + recognizedAt?: number; } +/** What a guard or resolver redirect carries into the next navigation. */ +type RedirectCarry = Pick; + /** - * Measures every router navigation as a `route-navigation` scenario. + * Measures every router navigation as one `route-navigation` scenario. * - * The scenario starts when the router has recognized the URL, so `route` is - * the matched template (`/customers/:id`), and is backdated to the click or - * `NavigationStart`, so the duration covers the whole wait. + * The scenario is recorded when the navigation ends, so `route` is always the + * final matched template — after `redirectTo`, lazy routes, and guard or + * resolver redirects — and backdated to the click or the first + * `NavigationStart`, so its duration covers the whole wait. * * Call {@link start} once, from the root component's constructor. Call * {@link markNavigationIntent} from navigation-link click handlers. @@ -127,6 +162,7 @@ interface TrackedNavigation { 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); /** @@ -136,8 +172,8 @@ export class RouteNavigationTelemetryService { */ private readonly navigations = new Map(); - /** A redirected navigation's scenario, continued by the next navigation. */ - private pendingRedirectScenario?: UwtScenario; + /** A redirected navigation, continued by the next `NavigationStart`. */ + private pendingRedirect?: RedirectCarry; /** When the user last did something expected to start a navigation. */ private navigationIntentAt?: number; @@ -168,9 +204,11 @@ export class RouteNavigationTelemetryService { private onRouterEvent(event: RouterEvent): void { if (event instanceof NavigationStart) { + const startedAt = this.navigationStartedAt(); + const redirect = this.consumePendingRedirect(); this.navigations.set(event.id, { - startedAt: this.navigationStartedAt(), - scenario: this.consumePendingRedirectScenario() + startedAt: redirect?.startedAt ?? startedAt, + redirectedFrom: redirect?.redirectedFrom }); return; } @@ -178,85 +216,109 @@ export class RouteNavigationTelemetryService { if (event instanceof RoutesRecognized) { const navigation = this.navigations.get(event.id); if (navigation) { - navigation.scenario ??= this.startScenario( - navigation.startedAt, - routeTemplateOf(event.state.root) - ); - navigation.scenario.step('resolve-route'); + navigation.route = routeTemplateOf(event.state.root); + navigation.recognizedAt = Date.now(); } return; } if (event instanceof NavigationEnd) { - this.settle(event.id, (scenario) => { - scenario.step('activate'); - scenario.complete({ - metadata: { - finalRoute: routeTemplateOf(this.router.routerState.snapshot.root) - } - }); - }); + 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 scenario = this.navigations.get(event.id)?.scenario; - if (scenario && event.code === NavigationCancellationCode.Redirect) { - // A redirect restarts under a new id: continue this scenario on the - // next NavigationStart, so one journey stays one scenario. - this.navigations.delete(event.id); - this.pendingRedirectScenario = scenario; + const navigation = this.take(event.id); + if (!navigation) { return; } - - const outcome = { - reason: causeOf(CANCEL_REASON, event.code, 'navigation-cancelled'), - message: event.reason || undefined - }; - this.settle(event.id, (scenario) => + if (event.code === NavigationCancellationCode.Redirect) { + // A guard or resolver redirect restarts under a new id: carry the + // start time, so one journey stays one scenario under its final route. + this.pendingRedirect = { + startedAt: navigation.startedAt, + redirectedFrom: navigation.redirectedFrom ?? navigation.route + }; + 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(outcome) - : scenario.cancel(outcome) + ? scenario.incomplete({ reason, metadata }) + : scenario.cancel({ reason, metadata }) ); return; } if (event instanceof NavigationSkipped) { // A redirect back to the current URL ends in NavigationSkipped instead - // of the NavigationStart the stashed scenario waits for. Release it, and + // of the NavigationStart the carried redirect waits for. Release it, and // drop the click intent so it can't backdate an unrelated navigation. this.navigationIntentAt = undefined; - const outcome = { - reason: causeOf(SKIP_REASON, event.code, 'navigation-skipped'), - message: event.reason || undefined - }; - this.consumePendingRedirectScenario()?.cancel(outcome); - this.settle(event.id, (scenario) => scenario.cancel(outcome)); + 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) { - // An unmatched URL fails before recognition, so no scenario exists yet. - const navigation = this.navigations.get(event.id); - if (navigation && !navigation.scenario) { - navigation.scenario = this.startScenario( - navigation.startedAt, - UNRECOGNIZED_ROUTE + const navigation = this.take(event.id); + if (navigation) { + const recognized = navigation.route !== undefined; + this.record(navigation, navigation.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => + scenario.fail({ + error: recognized ? event.error : unrecognizedError(event.error), + metadata + }) ); } - this.settle(event.id, (scenario) => - scenario.fail({ error: event.error }) - ); } } - private startScenario(startedAt: number, route: string): UwtScenario { - return this.scenarioTelemetry.start({ - name: NAVIGATION_SCENARIO, - route, - startedAt, - timeoutMs: NAVIGATION_TIMEOUT_MS - }); + /** + * Starts and settles the scenario in one go, backdated to the journey's + * start, once the final route template is known. + */ + private record( + navigation: TrackedNavigation, + route: string, + settle: (scenario: UwtScenario, metadata: Record) => void + ): void { + 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 + ); } /** @@ -272,25 +334,15 @@ export class RouteNavigationTelemetryService { return at === undefined || now - at > INTENT_MAX_AGE_MS ? now : at; } - private consumePendingRedirectScenario(): UwtScenario | undefined { - const scenario = this.pendingRedirectScenario; - this.pendingRedirectScenario = undefined; - return scenario; + private consumePendingRedirect(): RedirectCarry | undefined { + const redirect = this.pendingRedirect; + this.pendingRedirect = undefined; + return redirect; } - /** - * Ends tracking of one navigation. One that never reached recognition - * (superseded or skipped first) has no scenario and records nothing — it - * would only spend the session's event budget. - */ - private settle( - navigationId: number, - apply: (scenario: UwtScenario) => void - ): void { - const scenario = this.navigations.get(navigationId)?.scenario; + private take(navigationId: number): TrackedNavigation | undefined { + const navigation = this.navigations.get(navigationId); this.navigations.delete(navigationId); - if (scenario) { - apply(scenario); - } + return navigation; } } 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 index 2af7065..2c924f8 100644 --- 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 @@ -5,33 +5,46 @@ import type { } from '@absaoss-cps/ngx-ui-watchtower/rum'; /** - * Endpoint of the backend broker that returns the RUM app-monitor settings - * and short-lived AWS credentials (`GET /rum/init`). + * Endpoint of the backend that returns the RUM app-monitor settings and + * short-lived AWS credentials. Adapt to the app's own broker. */ const RUM_BROKER_URL = '/rum/init'; /** - * Shape returned by `GET /rum/init`. + * Shape expected from the backend broker. Adapt to the real response; `load()` + * checks every required field before using it. * - * ASSUMPTION (unconfirmed): the broker answers with this shape. If it - * differs, adjust this interface and the mapping in `load()` — a response - * that doesn't match makes `load()` return `null`, which only disables RUM - * for the session; the app keeps working. + * ```json + * { + * "enabled": true, + * "config": { "applicationId": "…", "region": "eu-west-1", "applicationVersion": "1.4.0", + * "sessionSampleRate": 1 }, + * "credentials": { "accessKeyId": "…", "secretAccessKey": "…", "sessionToken": "…", + * "expiration": "2026-10-08T12:00:00Z" } + * } + * ``` */ -interface RumBrokerResponse { - enabled: boolean; - config?: { - applicationId: string; - region: string; - applicationVersion: string; - sessionSampleRate?: number; - }; - credentials?: { - accessKeyId: string; - secretAccessKey: string; - sessionToken: string; - expiration: string; - }; +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); +} + +/** Whether `value` is an object whose `keys` are all non-empty strings. */ +function hasStrings( + value: unknown, + keys: readonly K[] +): value is Record & Record { + return ( + isRecord(value) && + keys.every((key) => typeof value[key] === 'string' && value[key] !== '') + ); } /** @@ -46,7 +59,8 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { * Fetches the current app-monitor settings and credentials. * * @returns the bootstrap payload, or `null` — RUM off for this session — - * when the broker disables RUM, is unreachable, or answers incompletely + * when the broker disables RUM, is unreachable, or answers with anything + * missing or malformed */ async load(): Promise { let response: Response; @@ -65,27 +79,45 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { return null; } - let init: RumBrokerResponse; + let body: unknown; try { - init = (await response.json()) as RumBrokerResponse; + body = await response.json(); } catch { return null; } - // Credentials are required: without them the client would start and then - // fail every dispatch silently. - if (!init?.enabled || !init.config || !init.credentials) { + // Validate, don't cast: a half-filled answer must turn RUM off, not start + // a client that fails every dispatch silently. + if (!isRecord(body) || body['enabled'] !== true) { + return null; + } + const { config, credentials } = body; + if ( + !hasStrings(config, REQUIRED_CONFIG) || + !hasStrings(credentials, REQUIRED_CREDENTIALS) || + Number.isNaN(Date.parse(credentials.expiration)) + ) { return null; } + const rate = config['sessionSampleRate']; return { config: { - applicationId: init.config.applicationId, - region: init.config.region, - applicationVersion: init.config.applicationVersion, - sessionSampleRate: init.config.sessionSampleRate + applicationId: config.applicationId, + region: config.region, + applicationVersion: config.applicationVersion, + sessionSampleRate: typeof rate === 'number' && rate >= 0 && rate <= 1 ? rate : undefined, + // Page views are recorded under the route template by + // RouteNavigationTelemetryService; automatic ones would send the + // resolved path (/customers/42). + disableAutoPageView: true }, - credentials: init.credentials + 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 index e96c400..ea05ff9 100644 --- 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 @@ -9,7 +9,7 @@ */ declare module '@absaoss-cps/ngx-ui-watchtower' { interface UwtScenarioNames { - /** A router navigation, from click to activated route. */ + /** 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. */ @@ -17,12 +17,6 @@ declare module '@absaoss-cps/ngx-ui-watchtower' { } interface UwtScenarioSteps { - /** From the recognized URL to activation: guards, resolvers, lazy components. */ - 'resolve-route': true; - - /** Activating the routed component. */ - activate: true; - /** Fetching data from the API. */ fetch: true; } diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index e1ff04c..5fbfa29 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -10,9 +10,9 @@ description: > events (→ ui-watchtower-integration), dashboards, non-Angular apps. license: Apache-2.0 compatibility: > - Requires an Angular application within the peer range of the latest - @absaoss-cps/ngx-ui-watchtower release (checked at run time with npm view), and npm registry - access. + 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 @@ -184,8 +184,9 @@ export const appConfig: ApplicationConfig = { - Start with **no** `withScenarios` / `withBIEvents` / `withLogging` / `withRedaction` features. Add one only for a stated reason, and record the reason in a comment. - Credentials: copy `assets/rum-credentials.provider.ts`, point it at the broker endpoint from Step 1 - and map its response. `load()` returns `null` on **any** failure (that disables RUM for the - session; the app keeps working). `provideUwtTelemetryRumSink()` calls it from its own + and adapt the response check. `load()` validates every required field and returns `null` on + **any** failure or incomplete answer (that disables RUM for the session; the app keeps working). + It also sets `disableAutoPageView: true` — page views come from Step 6, under the route template. `provideUwtTelemetryRumSink()` calls it from its own non-blocking initializer — don't write one. The asset uses `fetch`, which bypasses `HttpClient` interceptors: if the broker needs auth that an interceptor adds, call it through `HttpClient` (`firstValueFrom`) instead. @@ -203,7 +204,7 @@ 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`, `resolve-route`); snake_case for BI +- 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. - Augmentation applies program-wide, but keep `import './telemetry/telemetry.schema';` in the files that own the wiring (provider config, the router telemetry service) so it never becomes orphaned. @@ -215,7 +216,9 @@ If Step 1 found the app sending RUM events itself, load `references/rum-migratio **before** Step 6. The library emits its own event types and payloads; replacing a hand-written `RumService` naively changes what reaches CloudWatch and breaks existing dashboards. Migrate first, diff the wire, delete last — and keep exactly one page-view recorder. If you can't run the app to -diff the wire, don't delete the old service; leave that as a follow-up. +diff the wire, don't delete the old service; leave that as a follow-up. One exception to "parity +first": personal data or raw free text the old code sends today is fixed immediately, even when the +migration is blocked — tell the owners of the affected dashboards. ### Step 6 · Proof — route-navigation tracking @@ -226,22 +229,48 @@ unchanged for static, `:param`, lazy (`loadChildren`) and redirected routes. Onl needs one thing: a `data: { telemetryPath: 'files/:path' }` entry naming its segment (otherwise it reports `(matcher)`). +**Page views** — in a realm that sends to RUM (standalone app or shell), bind the asset's +`ROUTE_PAGE_VIEW_RECORDER` so each completed navigation records one page view under its route +template; the credentials provider has turned automatic page views off, which would send the +resolved path (`/customers/42`) — a PII and cardinality problem, since path segments are not +redacted: + +```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); + } +} +``` + +With a `'noop'` destination, bind nothing; when you later switch to RUM, add this binding with the +sink. If an existing RUM integration records page views (Step 5), keep exactly one recorder. + 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. What the asset gets right, and why — keep these if you change it: -1. **Starts at `RoutesRecognized`, backdated** to `NavigationStart` or the click — before - recognition the router hasn't matched the URL, so the route template isn't known, and a - scenario's `route` can't change later. An unmatched URL fails as `(unrecognized)`. +1. **Recorded when the navigation ends, backdated** to the click or the first `NavigationStart` — + a scenario's `route` can't change after it starts, and only the end knows the final template. + The template comes from the matched route config, never the URL. 2. **Keyed by navigation id**, never one "current" field — one navigation can supersede another. -3. **Redirects continue the same scenario** (`NavigationCancel` with - `NavigationCancellationCode.Redirect` is followed by a new `NavigationStart`); - `NavigationSkipped` releases a stashed one, or it leaks into an unrelated navigation. +3. **Guard and resolver redirects are one journey** under the final template, with + `redirectedFrom` (the source template) in metadata; `NavigationSkipped` releases a carried + redirect, or it leaks into an unrelated navigation. 4. **Statuses:** a guard returning `false` or a resolver with no data is `incomplete`; superseded, - aborted or skipped is `abandoned`; an error is `failure`. Reasons come from the router's `code`, - never `event.reason` (empty in production builds). -5. **Click-intent backdating:** `markNavigationIntent()` from nav-link click handlers; a mark older + aborted or skipped is `abandoned`; an error is `failure`. A navigation superseded before its URL + was recognized records nothing. +5. **No URL ever reaches telemetry.** An unmatched URL fails as `(unrecognized)` with a generic + error — the router's own error quotes the URL. Reasons come from the router's `code`; its + `reason` text is never sent (empty in production, can contain URLs in development). +6. **Click-intent backdating:** `markNavigationIntent()` from nav-link click handlers; a mark older than 2 s is discarded. ### Step 7 · Tests @@ -264,8 +293,10 @@ The library ships no test doubles on purpose — the harness is a few lines. ``` - Add a spec for the route-navigation service with a **recording sink** (pattern in the API reference, "Testing"), using the app's real route shapes: a successful navigation records - `success` with the route **template** (`/customers/:id`, never a resolved id), and a guard - rejection records `incomplete` with reason `guard-rejected`, not `failure`. Check the test bites: + `success` with the route **template** (`/customers/:id`, never a resolved id — assert the id + appears nowhere in the payload), a guard rejection records `incomplete` with reason + `guard-rejected`, not `failure`, and, where the page-view recorder is bound, one page view per + completed navigation under the template. Check the test bites: remove the `complete()` call and confirm it fails, then restore it. - jsdom has no `BroadcastChannel`: the broadcast sink and host degrade to no-ops in tests. Expected — don't polyfill it. @@ -315,8 +346,9 @@ a shell, also hand over the fragment contract from `references/micro-frontends.m `aws-rum-web` into every consumer's build. No deep imports (`…/src/lib/…`) either. - **`route` is a metric dimension** — a template (`/customers/:id`), never a resolved URL; one series per customer is both a cardinality and a PII problem. -- **One page-view recorder.** The RUM client already records a page view per navigation; don't add a - second one. +- **One page-view recorder, under the template.** Automatic RUM page views use the resolved path — + keep `disableAutoPageView: true` with the Step 6 recorder, never both and never the automatic one + alone. - **Telemetry never throws into the app** — every entry point fails open. Don't wrap calls in try/catch. - **Redaction cannot be fully disabled**, and URL **path** segments are not scrubbed — only query diff --git a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts index 53c5975..6369843 100644 --- a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts @@ -1,4 +1,4 @@ -import { DestroyRef, inject, Injectable } from '@angular/core'; +import { DestroyRef, inject, Injectable, InjectionToken } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRouteSnapshot, @@ -21,19 +21,34 @@ import { // 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 only where AWS CloudWatch RUM is the destination, together with + * `disableAutoPageView: true` in the app-monitor config — automatic page + * views would report the resolved path (`/customers/42`): + * + * ```ts + * { + * provide: ROUTE_PAGE_VIEW_RECORDER, + * useFactory: () => { + * const rum = inject(UwtRumTelemetrySink); + * return (route: string) => rum.recordPageView(route); + * } + * } + * ``` + */ +export const ROUTE_PAGE_VIEW_RECORDER = new InjectionToken<(route: string) => void>( + 'ROUTE_PAGE_VIEW_RECORDER' +); + /** Name shared by every route-navigation scenario. */ 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; -/** - * Neither completed nor superseded within this window means a stuck chunk - * load, not a slow one. - */ -const NAVIGATION_TIMEOUT_MS = 30_000; - -/** `route` of a navigation whose URL matched no route. */ +/** `route` of a navigation that failed before its URL matched a route. */ const UNRECOGNIZED_ROUTE = '(unrecognized)'; /** @@ -44,8 +59,8 @@ const TELEMETRY_PATH_DATA_KEY = 'telemetryPath'; /** * Stable, low-cardinality causes, derived from the router's `code`. The - * router's `reason` text is dev-mode only — empty in a production build — so - * it can't be the grouping key. + * router's `reason` text is never sent: it is empty in production builds and + * can contain URLs in development builds. */ const CANCEL_REASON: Partial> = { [NavigationCancellationCode.Redirect]: 'redirect', @@ -81,7 +96,7 @@ function causeOf( /** * The matched route's template, from the `routeConfig` chain of the primary - * outlet: `/customers/42` → `/customers/:id`. Exact after redirects and lazy + * outlet: `/customers/42` → `/customers/:id`. Exact after `redirectTo` and lazy * `loadChildren`, because the router has done the matching. */ function routeTemplateOf(root: ActivatedRouteSnapshot): string { @@ -105,20 +120,40 @@ function routeTemplateOf(root: ActivatedRouteSnapshot): string { return `/${parts.join('/')}`; } +/** + * The router's error for a URL that matched nothing quotes the URL; replace it + * so no path segment reaches telemetry. Its name is kept for grouping. + */ +function unrecognizedError(error: unknown): Error { + const generic = new Error('Navigation failed before the URL matched a route'); + if (error instanceof Error) { + generic.name = error.name; + } + return generic; +} + /** One router navigation being tracked. */ interface TrackedNavigation { - /** Epoch ms the journey started: the click, or `NavigationStart`. */ + /** Epoch ms the journey started: the click, `NavigationStart`, or the first navigation of a redirect chain. */ startedAt: number; - /** Started once the URL is recognized — the route template is known only then. */ - scenario?: UwtScenario; + /** Template of the route a guard or resolver redirected away from. */ + redirectedFrom?: string; + /** The matched route template, known from `RoutesRecognized` on. */ + route?: string; + /** Epoch ms of `RoutesRecognized`: guards, resolvers and lazy components run after it. */ + recognizedAt?: number; } +/** What a guard or resolver redirect carries into the next navigation. */ +type RedirectCarry = Pick; + /** - * Measures every router navigation as a `route-navigation` scenario. + * Measures every router navigation as one `route-navigation` scenario. * - * The scenario starts when the router has recognized the URL, so `route` is - * the matched template (`/customers/:id`), and is backdated to the click or - * `NavigationStart`, so the duration covers the whole wait. + * The scenario is recorded when the navigation ends, so `route` is always the + * final matched template — after `redirectTo`, lazy routes, and guard or + * resolver redirects — and backdated to the click or the first + * `NavigationStart`, so its duration covers the whole wait. * * Call {@link start} once, from the root component's constructor. Call * {@link markNavigationIntent} from navigation-link click handlers. @@ -127,6 +162,7 @@ interface TrackedNavigation { 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); /** @@ -136,8 +172,8 @@ export class RouteNavigationTelemetryService { */ private readonly navigations = new Map(); - /** A redirected navigation's scenario, continued by the next navigation. */ - private pendingRedirectScenario?: UwtScenario; + /** A redirected navigation, continued by the next `NavigationStart`. */ + private pendingRedirect?: RedirectCarry; /** When the user last did something expected to start a navigation. */ private navigationIntentAt?: number; @@ -168,9 +204,11 @@ export class RouteNavigationTelemetryService { private onRouterEvent(event: RouterEvent): void { if (event instanceof NavigationStart) { + const startedAt = this.navigationStartedAt(); + const redirect = this.consumePendingRedirect(); this.navigations.set(event.id, { - startedAt: this.navigationStartedAt(), - scenario: this.consumePendingRedirectScenario() + startedAt: redirect?.startedAt ?? startedAt, + redirectedFrom: redirect?.redirectedFrom }); return; } @@ -178,85 +216,109 @@ export class RouteNavigationTelemetryService { if (event instanceof RoutesRecognized) { const navigation = this.navigations.get(event.id); if (navigation) { - navigation.scenario ??= this.startScenario( - navigation.startedAt, - routeTemplateOf(event.state.root) - ); - navigation.scenario.step('resolve-route'); + navigation.route = routeTemplateOf(event.state.root); + navigation.recognizedAt = Date.now(); } return; } if (event instanceof NavigationEnd) { - this.settle(event.id, (scenario) => { - scenario.step('activate'); - scenario.complete({ - metadata: { - finalRoute: routeTemplateOf(this.router.routerState.snapshot.root) - } - }); - }); + 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 scenario = this.navigations.get(event.id)?.scenario; - if (scenario && event.code === NavigationCancellationCode.Redirect) { - // A redirect restarts under a new id: continue this scenario on the - // next NavigationStart, so one journey stays one scenario. - this.navigations.delete(event.id); - this.pendingRedirectScenario = scenario; + const navigation = this.take(event.id); + if (!navigation) { return; } - - const outcome = { - reason: causeOf(CANCEL_REASON, event.code, 'navigation-cancelled'), - message: event.reason || undefined - }; - this.settle(event.id, (scenario) => + if (event.code === NavigationCancellationCode.Redirect) { + // A guard or resolver redirect restarts under a new id: carry the + // start time, so one journey stays one scenario under its final route. + this.pendingRedirect = { + startedAt: navigation.startedAt, + redirectedFrom: navigation.redirectedFrom ?? navigation.route + }; + 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(outcome) - : scenario.cancel(outcome) + ? scenario.incomplete({ reason, metadata }) + : scenario.cancel({ reason, metadata }) ); return; } if (event instanceof NavigationSkipped) { // A redirect back to the current URL ends in NavigationSkipped instead - // of the NavigationStart the stashed scenario waits for. Release it, and + // of the NavigationStart the carried redirect waits for. Release it, and // drop the click intent so it can't backdate an unrelated navigation. this.navigationIntentAt = undefined; - const outcome = { - reason: causeOf(SKIP_REASON, event.code, 'navigation-skipped'), - message: event.reason || undefined - }; - this.consumePendingRedirectScenario()?.cancel(outcome); - this.settle(event.id, (scenario) => scenario.cancel(outcome)); + 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) { - // An unmatched URL fails before recognition, so no scenario exists yet. - const navigation = this.navigations.get(event.id); - if (navigation && !navigation.scenario) { - navigation.scenario = this.startScenario( - navigation.startedAt, - UNRECOGNIZED_ROUTE + const navigation = this.take(event.id); + if (navigation) { + const recognized = navigation.route !== undefined; + this.record(navigation, navigation.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => + scenario.fail({ + error: recognized ? event.error : unrecognizedError(event.error), + metadata + }) ); } - this.settle(event.id, (scenario) => - scenario.fail({ error: event.error }) - ); } } - private startScenario(startedAt: number, route: string): UwtScenario { - return this.scenarioTelemetry.start({ - name: NAVIGATION_SCENARIO, - route, - startedAt, - timeoutMs: NAVIGATION_TIMEOUT_MS - }); + /** + * Starts and settles the scenario in one go, backdated to the journey's + * start, once the final route template is known. + */ + private record( + navigation: TrackedNavigation, + route: string, + settle: (scenario: UwtScenario, metadata: Record) => void + ): void { + 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 + ); } /** @@ -272,25 +334,15 @@ export class RouteNavigationTelemetryService { return at === undefined || now - at > INTENT_MAX_AGE_MS ? now : at; } - private consumePendingRedirectScenario(): UwtScenario | undefined { - const scenario = this.pendingRedirectScenario; - this.pendingRedirectScenario = undefined; - return scenario; + private consumePendingRedirect(): RedirectCarry | undefined { + const redirect = this.pendingRedirect; + this.pendingRedirect = undefined; + return redirect; } - /** - * Ends tracking of one navigation. One that never reached recognition - * (superseded or skipped first) has no scenario and records nothing — it - * would only spend the session's event budget. - */ - private settle( - navigationId: number, - apply: (scenario: UwtScenario) => void - ): void { - const scenario = this.navigations.get(navigationId)?.scenario; + private take(navigationId: number): TrackedNavigation | undefined { + const navigation = this.navigations.get(navigationId); this.navigations.delete(navigationId); - if (scenario) { - apply(scenario); - } + return navigation; } } diff --git a/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts index c958b0f..2c924f8 100644 --- a/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts +++ b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts @@ -11,23 +11,40 @@ import type { const RUM_BROKER_URL = '/rum/init'; /** - * Shape returned by the backend broker. Adapt to the real response; map it - * to `UwtRumBootstrap` in `load()`. + * Shape expected from the backend broker. Adapt to the real response; `load()` + * checks every required field before using it. + * + * ```json + * { + * "enabled": true, + * "config": { "applicationId": "…", "region": "eu-west-1", "applicationVersion": "1.4.0", + * "sessionSampleRate": 1 }, + * "credentials": { "accessKeyId": "…", "secretAccessKey": "…", "sessionToken": "…", + * "expiration": "2026-10-08T12:00:00Z" } + * } + * ``` */ -interface RumBrokerResponse { - enabled: boolean; - config?: { - applicationId: string; - region: string; - applicationVersion: string; - sessionSampleRate?: number; - }; - credentials?: { - accessKeyId: string; - secretAccessKey: string; - sessionToken: string; - expiration: string; - }; +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); +} + +/** Whether `value` is an object whose `keys` are all non-empty strings. */ +function hasStrings( + value: unknown, + keys: readonly K[] +): value is Record & Record { + return ( + isRecord(value) && + keys.every((key) => typeof value[key] === 'string' && value[key] !== '') + ); } /** @@ -42,7 +59,8 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { * Fetches the current app-monitor settings and credentials. * * @returns the bootstrap payload, or `null` — RUM off for this session — - * when the broker disables RUM, is unreachable, or answers incompletely + * when the broker disables RUM, is unreachable, or answers with anything + * missing or malformed */ async load(): Promise { let response: Response; @@ -61,27 +79,45 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { return null; } - let init: RumBrokerResponse; + let body: unknown; try { - init = (await response.json()) as RumBrokerResponse; + body = await response.json(); } catch { return null; } - // Credentials are required: without them the client would start and then - // fail every dispatch silently. - if (!init?.enabled || !init.config || !init.credentials) { + // Validate, don't cast: a half-filled answer must turn RUM off, not start + // a client that fails every dispatch silently. + if (!isRecord(body) || body['enabled'] !== true) { + return null; + } + const { config, credentials } = body; + if ( + !hasStrings(config, REQUIRED_CONFIG) || + !hasStrings(credentials, REQUIRED_CREDENTIALS) || + Number.isNaN(Date.parse(credentials.expiration)) + ) { return null; } + const rate = config['sessionSampleRate']; return { config: { - applicationId: init.config.applicationId, - region: init.config.region, - applicationVersion: init.config.applicationVersion, - sessionSampleRate: init.config.sessionSampleRate + applicationId: config.applicationId, + region: config.region, + applicationVersion: config.applicationVersion, + sessionSampleRate: typeof rate === 'number' && rate >= 0 && rate <= 1 ? rate : undefined, + // Page views are recorded under the route template by + // RouteNavigationTelemetryService; automatic ones would send the + // resolved path (/customers/42). + disableAutoPageView: true }, - credentials: init.credentials + 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 index c4ccf5f..15dae22 100644 --- a/skills/ui-watchtower-setup/assets/telemetry.schema.ts +++ b/skills/ui-watchtower-setup/assets/telemetry.schema.ts @@ -11,7 +11,7 @@ */ declare module '@absaoss-cps/ngx-ui-watchtower' { interface UwtScenarioNames { - /** A router navigation, from click to activated route. */ + /** 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: @@ -19,17 +19,12 @@ declare module '@absaoss-cps/ngx-ui-watchtower' { // 'customers-load': true; } - interface UwtScenarioSteps { - /** From the recognized URL to activation: guards, resolvers, lazy components. */ - 'resolve-route': true; - - /** Activating the routed component. */ - activate: true; - - // Steps and aggregates share this registry: - // /** Fetching data from the API. */ - // fetch: 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 { diff --git a/skills/ui-watchtower-setup/evals/evals.json b/skills/ui-watchtower-setup/evals/evals.json index 49c1a56..798f0a6 100644 --- a/skills/ui-watchtower-setup/evals/evals.json +++ b/skills/ui-watchtower-setup/evals/evals.json @@ -32,6 +32,7 @@ "Creates telemetry.schema.ts augmenting UwtScenarioNames and UwtScenarioSteps, and side-effect-imports it where the providers are configured", "Adds route-navigation tracking (the asset service, started from the root component) 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", "Adds telemetry providers to app.spec.ts so it does not fail with NG0201", "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" @@ -105,6 +106,7 @@ "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", "Adds telemetry providers to app.spec.ts so it does not fail with NG0201", "Explains how to switch to RUM later: replace the noop line with provideUwtTelemetryRumSink() plus a UWT_RUM_CREDENTIALS_PROVIDER, and install aws-rum-web", @@ -145,7 +147,7 @@ "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, removes per-keystroke search events in favour of a debounced event, and keeps exactly one page-view recorder.", + "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", @@ -160,7 +162,7 @@ "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 recorder: either drops the manual recordPageView, or keeps it via UwtRumTelemetrySink.recordPageView with disableAutoPageView: true", + "Keeps exactly one page-view recorder, under the route template: disableAutoPageView: true with the route service's page-view recorder, and the old manual recordPageView calls dropped", "Replaces the email session attribute with sink.setUserId(opaqueId) and calls out that emails must not be sent", "Replaces per-keystroke search tracking with a debounced BI event without the raw query text" ] diff --git a/skills/ui-watchtower-setup/references/micro-frontends.md b/skills/ui-watchtower-setup/references/micro-frontends.md index b561f94..ce084b5 100644 --- a/skills/ui-watchtower-setup/references/micro-frontends.md +++ b/skills/ui-watchtower-setup/references/micro-frontends.md @@ -13,6 +13,7 @@ realm — the shell — owns the RUM client; fragments forward** to it over a sa ## Shell ```ts +import { inject } from '@angular/core'; import { UWT_LOG_API_PROVIDER, UwtNoopLogApiProvider, @@ -21,8 +22,10 @@ import { } from '@absaoss-cps/ngx-ui-watchtower'; import { UWT_RUM_CREDENTIALS_PROVIDER, + UwtRumTelemetrySink, provideUwtTelemetryRumSink } from '@absaoss-cps/ngx-ui-watchtower/rum'; +import { ROUTE_PAGE_VIEW_RECORDER } from './telemetry/route-navigation-telemetry.service'; declare global { interface Window { @@ -42,6 +45,14 @@ providers: [ provideUwtTelemetry({ application: 'shell', environment, version }), provideUwtTelemetryRumSink(), { provide: UWT_RUM_CREDENTIALS_PROVIDER, useExisting: AppRumCredentialsProvider }, + // Page views under the route template (SKILL.md Step 6) + { + 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: diff --git a/skills/ui-watchtower-setup/references/rum-migration.md b/skills/ui-watchtower-setup/references/rum-migration.md index fa61411..2a85228 100644 --- a/skills/ui-watchtower-setup/references/rum-migration.md +++ b/skills/ui-watchtower-setup/references/rum-migration.md @@ -22,6 +22,15 @@ alone is not parity: 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** (for example on the credentials +broker, §3). If the inventory shows the old code sending personal data or raw free text today — the +text of a search box (worse, on every keystroke), an email as user id or session attribute, page ids +that are resolved URLs with query strings — fix it in the old code right away, before or without +the migration: debounce, send low-cardinality attributes instead (`queryLength`, `resultCount`), send +an opaque id or no user at all, strip query strings and fragments from page ids. This changes those +payloads before any wire diff; accept that, mark the rows in the inventory, and tell the owners of +the affected dashboards. Dashboard parity never outranks personal data. + ## 2. Pick a parity tool per row | Tool | Use when | Result | @@ -66,13 +75,14 @@ diff needs. ## Specific checks -- **Page views.** The RUM client records a page view on every `history.pushState`, which covers the - Angular router. If the old code recorded page views manually, drop that code — or keep it via - `UwtRumTelemetrySink.recordPageView(template)` **and** set `disableAutoPageView: true` in the app - monitor config. Never both. If routes carry ids, the automatic page id (with `pageIdFormat: - 'PATH'`) is the resolved path — a cardinality and PII problem; record templates manually instead. +- **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. - **Free-text and per-keystroke events** (a search box sending every keystroke): debounce them, and - don't port the raw text — it is unbounded cardinality and can contain personal data. Send a + don't port the raw text — it is unbounded cardinality and can contain personal data. Fix them in + the old code even when the migration itself has to wait (§1). Send a low-cardinality attribute instead (query length, result count, which filter was used). If a documented consumer genuinely needs the text, ask before porting it. - **`aws:` metadata keys** are reserved and dropped by the RUM client; the sink filters them out. Any From 98675847aa5897792b46437f0500c726b1d9835b Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 15:27:42 +0200 Subject: [PATCH 05/25] more feedback --- .../references/ngx-ui-watchtower-api.md | 4 +- skills/ui-watchtower-integration/SKILL.md | 18 ++++-- .../evals/evals.json | 4 +- .../route-navigation-telemetry.service.ts | 55 ++++++++++++++++++- .../app/telemetry/rum-credentials.provider.ts | 3 +- .../route-navigation-telemetry.service.ts | 55 ++++++++++++++++++- .../app/telemetry/rum-credentials.provider.ts | 3 +- .../references/instrumentation-patterns.md | 19 ++++--- skills/ui-watchtower-setup/SKILL.md | 5 +- .../route-navigation-telemetry.service.ts | 55 ++++++++++++++++++- .../assets/rum-credentials.provider.ts | 3 +- 11 files changed, 194 insertions(+), 30 deletions(-) diff --git a/skills/shared/references/ngx-ui-watchtower-api.md b/skills/shared/references/ngx-ui-watchtower-api.md index cce6e88..08fbcfc 100644 --- a/skills/shared/references/ngx-ui-watchtower-api.md +++ b/skills/shared/references/ngx-ui-watchtower-api.md @@ -141,7 +141,9 @@ low-cardinality grouping key (`'superseded'`, `'no-results'`). Settling is absor 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. +`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 { diff --git a/skills/ui-watchtower-integration/SKILL.md b/skills/ui-watchtower-integration/SKILL.md index 4c5c9f7..5881222 100644 --- a/skills/ui-watchtower-integration/SKILL.md +++ b/skills/ui-watchtower-integration/SKILL.md @@ -60,9 +60,12 @@ If only `telemetry.schema.ts` (the file augmenting the name registries) is missi create it in Step 6 — a `declare module '@absaoss-cps/ngx-ui-watchtower'` block with the confirmed names, side-effect-imported where the providers are configured. -Also read what already exists — the schema's declared names and every `scenarios.start(` / -`track(` call — so this pass only adds or changes, never duplicates. Note the test runner and the -package manager. +Also read what already exists, so this pass only adds or changes, never duplicates. The schema is +the inventory of names — every scenario, step and BI name in use must be declared there, or it +doesn't compile. Then find where each is used: search for every declared name, and for every +injection of `UwtScenarioTelemetryService` / `UwtBITelemetryService` (whatever the field is called, +including wrappers and the route-navigation service) and every `traceScenario(`. Note the test +runner and the package manager. ### Step 2 · Ask the user what they want to learn @@ -144,7 +147,9 @@ Follow `references/instrumentation-patterns.md`: expected dead end, e.g. no results), `fail` (a defect; pass `error` and `statusCode`), `cancel` (the user left or a newer request superseded it). Timeout is automatic. 3. Settle open scenarios when their component is destroyed; under `switchMap`, give - `traceScenario` a `cancelOutcome`; never put `take(1)`/`first()` upstream of `traceScenario`. + `traceScenario` a `cancelOutcome`. Nothing downstream of `traceScenario` may stop after the + first value — no `take(1)`/`first()` after it, no `firstValueFrom()` on it (it records + `abandoned`): limit upstream and await with `lastValueFrom()`. 4. BI events inside a journey pass `{ scenarioId: scenario.id }`. Debounce high-frequency sources (search boxes, sliders) before starting a scenario or sending a BI event — one per settled input, never one per keystroke. @@ -165,8 +170,9 @@ confirm it fails, restore. 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`, a double-click gives one BI -event. +scenario with the right status, leaving mid-journey gives `abandoned`, and a double-click on an +action whose events are identical (same name, `scenarioId`, feature and metadata) gives one BI +event — clicks that start separate journeys carry different scenario ids and correctly give two. Report what was instrumented (by source), what was proposed but not confirmed, and anything changed from the user's original request and why. diff --git a/skills/ui-watchtower-integration/evals/evals.json b/skills/ui-watchtower-integration/evals/evals.json index 6b73cc1..7371054 100644 --- a/skills/ui-watchtower-integration/evals/evals.json +++ b/skills/ui-watchtower-integration/evals/evals.json @@ -107,7 +107,7 @@ "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({ error, statusCode }) on HTTP error, and cancel on leaving; no take(1) upstream of traceScenario; names declared in telemetry.schema.ts; tests for each outcome.", + "expected_output": "Skill starts a customers-load scenario, settles complete on success, incomplete({ reason: 'no-results' }) on an empty list, fail({ error, statusCode }) 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", @@ -133,7 +133,7 @@ "An empty result settles as incomplete with a low-cardinality reason, not success or fail", "An HTTP error settles as fail with the error and statusCode", "Leaving the page mid-load settles the open scenario as abandoned with a stable reason (ngOnDestroy cancel, or traceScenario's cancelOutcome on teardown)", - "Does not put take(1) or first() upstream of traceScenario", + "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" ] 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 index 6369843..9be035b 100644 --- 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 @@ -1,4 +1,12 @@ -import { DestroyRef, inject, Injectable, InjectionToken } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { + DestroyRef, + inject, + Injectable, + InjectionToken, + NgZone, + PLATFORM_ID +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRouteSnapshot, @@ -48,6 +56,12 @@ 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; +/** + * A navigation with no end event within this window — a guard, resolver or + * lazy chunk that never settles — is recorded as `timeout`. + */ +const NAVIGATION_TIMEOUT_MS = 30_000; + /** `route` of a navigation that failed before its URL matched a route. */ const UNRECOGNIZED_ROUTE = '(unrecognized)'; @@ -142,6 +156,8 @@ interface TrackedNavigation { route?: string; /** Epoch ms of `RoutesRecognized`: guards, resolvers and lazy components run after it. */ recognizedAt?: number; + /** Records the navigation as `timeout` if no end event arrives in time. */ + timeoutHandle?: ReturnType; } /** What a guard or resolver redirect carries into the next navigation. */ @@ -164,6 +180,8 @@ export class RouteNavigationTelemetryService { 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)); /** * Keyed by navigation id, never a single "current" field: one navigation @@ -190,6 +208,11 @@ export class RouteNavigationTelemetryService { 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); + } + }); } /** @@ -206,10 +229,12 @@ export class RouteNavigationTelemetryService { if (event instanceof NavigationStart) { const startedAt = this.navigationStartedAt(); const redirect = this.consumePendingRedirect(); - this.navigations.set(event.id, { + const navigation: TrackedNavigation = { startedAt: redirect?.startedAt ?? startedAt, redirectedFrom: redirect?.redirectedFrom - }); + }; + this.navigations.set(event.id, navigation); + this.armTimeout(event.id, navigation); return; } @@ -340,9 +365,33 @@ export class RouteNavigationTelemetryService { return redirect; } + /** + * Records the navigation once as `timeout` if it hasn't ended within + * {@link NAVIGATION_TIMEOUT_MS} of the journey's start; a late end event then + * finds nothing to record. Browser only, 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.take(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; } } 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 index 2c924f8..2acac69 100644 --- 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 @@ -95,7 +95,8 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { if ( !hasStrings(config, REQUIRED_CONFIG) || !hasStrings(credentials, REQUIRED_CREDENTIALS) || - Number.isNaN(Date.parse(credentials.expiration)) + // Unparseable or already expired: RUM would reject every dispatch. + !(Date.parse(credentials.expiration) > Date.now()) ) { return null; } 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 index 6369843..9be035b 100644 --- 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 @@ -1,4 +1,12 @@ -import { DestroyRef, inject, Injectable, InjectionToken } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { + DestroyRef, + inject, + Injectable, + InjectionToken, + NgZone, + PLATFORM_ID +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRouteSnapshot, @@ -48,6 +56,12 @@ 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; +/** + * A navigation with no end event within this window — a guard, resolver or + * lazy chunk that never settles — is recorded as `timeout`. + */ +const NAVIGATION_TIMEOUT_MS = 30_000; + /** `route` of a navigation that failed before its URL matched a route. */ const UNRECOGNIZED_ROUTE = '(unrecognized)'; @@ -142,6 +156,8 @@ interface TrackedNavigation { route?: string; /** Epoch ms of `RoutesRecognized`: guards, resolvers and lazy components run after it. */ recognizedAt?: number; + /** Records the navigation as `timeout` if no end event arrives in time. */ + timeoutHandle?: ReturnType; } /** What a guard or resolver redirect carries into the next navigation. */ @@ -164,6 +180,8 @@ export class RouteNavigationTelemetryService { 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)); /** * Keyed by navigation id, never a single "current" field: one navigation @@ -190,6 +208,11 @@ export class RouteNavigationTelemetryService { 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); + } + }); } /** @@ -206,10 +229,12 @@ export class RouteNavigationTelemetryService { if (event instanceof NavigationStart) { const startedAt = this.navigationStartedAt(); const redirect = this.consumePendingRedirect(); - this.navigations.set(event.id, { + const navigation: TrackedNavigation = { startedAt: redirect?.startedAt ?? startedAt, redirectedFrom: redirect?.redirectedFrom - }); + }; + this.navigations.set(event.id, navigation); + this.armTimeout(event.id, navigation); return; } @@ -340,9 +365,33 @@ export class RouteNavigationTelemetryService { return redirect; } + /** + * Records the navigation once as `timeout` if it hasn't ended within + * {@link NAVIGATION_TIMEOUT_MS} of the journey's start; a late end event then + * finds nothing to record. Browser only, 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.take(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; } } 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 index 2c924f8..2acac69 100644 --- 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 @@ -95,7 +95,8 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { if ( !hasStrings(config, REQUIRED_CONFIG) || !hasStrings(credentials, REQUIRED_CREDENTIALS) || - Number.isNaN(Date.parse(credentials.expiration)) + // Unparseable or already expired: RUM would reject every dispatch. + !(Date.parse(credentials.expiration) > Date.now()) ) { return null; } diff --git a/skills/ui-watchtower-integration/references/instrumentation-patterns.md b/skills/ui-watchtower-integration/references/instrumentation-patterns.md index 2116a4e..4da783e 100644 --- a/skills/ui-watchtower-integration/references/instrumentation-patterns.md +++ b/skills/ui-watchtower-integration/references/instrumentation-patterns.md @@ -111,15 +111,20 @@ fragment (its frames belong to a hidden iframe) — settle with `complete()` the ## 4. RxJS traps -**`take(1)` / `first()` upstream of `traceScenario`** unsubscribes before the source's `complete` -reaches the operator, so the scenario never settles and records `timeout`. Settle explicitly instead: +**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 -return this.upload(file).pipe( - take(1), - map((result) => { scenario.complete({ metadata: { fileSize: file.size } }); return result; }), - catchError((error) => { scenario.fail({ error }); return of(null); }) -); +// 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**: `switchMap` unsubscribes the previous inner observable *before* the diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index 5fbfa29..25ccfa2 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -265,8 +265,9 @@ What the asset gets right, and why — keep these if you change it: `redirectedFrom` (the source template) in metadata; `NavigationSkipped` releases a carried redirect, or it leaks into an unrelated navigation. 4. **Statuses:** a guard returning `false` or a resolver with no data is `incomplete`; superseded, - aborted or skipped is `abandoned`; an error is `failure`. A navigation superseded before its URL - was recognized records nothing. + aborted or skipped is `abandoned`; an error is `failure`; no end event within 30 s (a guard, + resolver or lazy chunk that never settles) is `timeout`, recorded once from a browser-only timer + outside Angular. A navigation superseded before its URL was recognized records nothing. 5. **No URL ever reaches telemetry.** An unmatched URL fails as `(unrecognized)` with a generic error — the router's own error quotes the URL. Reasons come from the router's `code`; its `reason` text is never sent (empty in production, can contain URLs in development). diff --git a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts index 6369843..9be035b 100644 --- a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts @@ -1,4 +1,12 @@ -import { DestroyRef, inject, Injectable, InjectionToken } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { + DestroyRef, + inject, + Injectable, + InjectionToken, + NgZone, + PLATFORM_ID +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRouteSnapshot, @@ -48,6 +56,12 @@ 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; +/** + * A navigation with no end event within this window — a guard, resolver or + * lazy chunk that never settles — is recorded as `timeout`. + */ +const NAVIGATION_TIMEOUT_MS = 30_000; + /** `route` of a navigation that failed before its URL matched a route. */ const UNRECOGNIZED_ROUTE = '(unrecognized)'; @@ -142,6 +156,8 @@ interface TrackedNavigation { route?: string; /** Epoch ms of `RoutesRecognized`: guards, resolvers and lazy components run after it. */ recognizedAt?: number; + /** Records the navigation as `timeout` if no end event arrives in time. */ + timeoutHandle?: ReturnType; } /** What a guard or resolver redirect carries into the next navigation. */ @@ -164,6 +180,8 @@ export class RouteNavigationTelemetryService { 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)); /** * Keyed by navigation id, never a single "current" field: one navigation @@ -190,6 +208,11 @@ export class RouteNavigationTelemetryService { 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); + } + }); } /** @@ -206,10 +229,12 @@ export class RouteNavigationTelemetryService { if (event instanceof NavigationStart) { const startedAt = this.navigationStartedAt(); const redirect = this.consumePendingRedirect(); - this.navigations.set(event.id, { + const navigation: TrackedNavigation = { startedAt: redirect?.startedAt ?? startedAt, redirectedFrom: redirect?.redirectedFrom - }); + }; + this.navigations.set(event.id, navigation); + this.armTimeout(event.id, navigation); return; } @@ -340,9 +365,33 @@ export class RouteNavigationTelemetryService { return redirect; } + /** + * Records the navigation once as `timeout` if it hasn't ended within + * {@link NAVIGATION_TIMEOUT_MS} of the journey's start; a late end event then + * finds nothing to record. Browser only, 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.take(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; } } diff --git a/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts index 2c924f8..2acac69 100644 --- a/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts +++ b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts @@ -95,7 +95,8 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { if ( !hasStrings(config, REQUIRED_CONFIG) || !hasStrings(credentials, REQUIRED_CREDENTIALS) || - Number.isNaN(Date.parse(credentials.expiration)) + // Unparseable or already expired: RUM would reject every dispatch. + !(Date.parse(credentials.expiration) > Date.now()) ) { return null; } From fcb84091bfd838423c53d668dd3522454c9883ee Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 15:55:02 +0200 Subject: [PATCH 06/25] more to prev --- ...route-navigation-telemetry.service.spec.ts | 279 ++++++++++++++---- .../route-navigation-telemetry.service.ts | 29 +- ...route-navigation-telemetry.service.spec.ts | 279 ++++++++++++++---- .../route-navigation-telemetry.service.ts | 29 +- skills/ui-watchtower-setup/SKILL.md | 17 +- .../route-navigation-telemetry.service.ts | 29 +- .../references/rum-migration.md | 27 +- 7 files changed, 523 insertions(+), 166 deletions(-) diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts index 69c1d2c..d9d5e2d 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts @@ -1,6 +1,6 @@ -import { Component, Injectable } from '@angular/core'; +import { Component, inject, Injectable } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { provideRouter, Router, Routes } from '@angular/router'; +import { provideRouter, Router, UrlSegment } from '@angular/router'; import { provideUwtTelemetry, provideUwtTelemetryDestination, @@ -15,47 +15,32 @@ import { const events: { eventType: string; payload: Record }[] = []; const pageViews: string[] = []; -/** Records everything handed to the destination, so tests assert on the wire. */ @Injectable() class RecordingSink extends UwtTelemetrySink { - override record(eventType: string, payload: object): void { + 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 {} + recordError(): void {} + getSessionId(): string | undefined { return 'session-1'; } + setUserId(): void {} + getUserId(): string | undefined { return undefined; } + flush(): void {} } @Component({ template: '' }) -class Stub {} - -/** Mirrors the app's route shapes (redirect, static, `:param`) plus failure paths. */ -const routes: Routes = [ - { path: '', pathMatch: 'full', redirectTo: 'customers' }, - { path: 'customers', component: Stub }, - { path: 'customers/:customerId', component: Stub }, - { path: 'blocked', canActivate: [() => false], component: Stub }, - { - path: 'broken', - canActivate: [ - () => { - throw new Error('guard exploded'); - } - ], - component: Stub - } -]; +class Page {} -function scenarios(): Record[] { - return events.filter((e) => e.eventType === 'com.uwt.scenario').map((e) => e.payload); +let releaseSlowGuard: (value: boolean) => 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; @@ -64,7 +49,59 @@ describe('RouteNavigationTelemetryService', () => { pageViews.length = 0; TestBed.configureTestingModule({ providers: [ - provideRouter(routes), + 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: { paramMap: { get(k: string): string | null } }) => + 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: 'back-home', + component: Page, + canActivate: [() => inject(Router).parseUrl('/customers')] + }, + { + path: 'slow', + component: Page, + canActivate: [() => 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) } + ]), provideUwtTelemetry( { application: 'customer-portal-test', environment: 'test', version: '0.0.0' }, withScenarios({ defaultTimeoutMs: 0 }) @@ -73,64 +110,190 @@ describe('RouteNavigationTelemetryService', () => { { provide: ROUTE_PAGE_VIEW_RECORDER, useValue: (route: string) => pageViews.push(route) } ] }); - router = TestBed.inject(Router); TestBed.inject(RouteNavigationTelemetryService).start(); + router = TestBed.inject(Router); }); - it('reports a successful navigation under its route template, never the resolved id', async () => { - await router.navigateByUrl('/customers/cust-SECRET-7?tab=orders'); - - expect(scenarios()).toEqual([ + 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({ - scenarioName: 'route-navigation', - status: 'success', - route: '/customers/:customerId' + 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/:customerId']); + 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 the redirect from the empty path under the target template', async () => { - await router.navigateByUrl('/'); + 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('reports a guard rejection as incomplete, not as a failure', async () => { + 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 nothing for a navigation superseded before the URL was recognized', async () => { - const first = router.navigateByUrl('/customers/1'); - const second = router.navigateByUrl('/customers/2'); - await Promise.all([first, second]); + 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/:customerId' }) + expect.objectContaining({ + status: 'success', + route: '/customers/:id', + metadata: expect.objectContaining({ redirectedFrom: '/legacy/:id' }) + }) ]); + expect(JSON.stringify(events)).not.toContain('SECRET'); }); - it('reports a navigation error as a failure', async () => { - await router.navigateByUrl('/broken').catch(() => undefined); + 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('reports an unknown URL under a placeholder template, with no URL in the payload', async () => { - await router.navigateByUrl('/nope/cust-SECRET-7').catch(() => undefined); - + 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: '(unrecognized)' }) + expect.objectContaining({ status: 'failure', route: '/http-broken', statusCode: 503 }) ]); expect(JSON.stringify(events)).not.toContain('SECRET'); + expect(JSON.stringify(events)).toContain('HttpErrorResponse'); + }); + + 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('/back-home'); + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'abandoned', reason: 'same-url', route: '/back-home' }) + ]); + }); + + 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 a superseded navigation as abandoned, and the new one as success', async () => { + const first = router.navigateByUrl('/slow'); + await new Promise((r) => setTimeout(r)); + 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(); + }); + + it('keeps the start time across a guard redirect', async () => { + TestBed.inject(RouteNavigationTelemetryService).markNavigationIntent(); + await new Promise((r) => setTimeout(r, 60)); + await router.navigateByUrl('/old'); + expect((scenarios()[0] as { delta: number }).delta).toBeGreaterThanOrEqual(50); + }); + + it('backdates to a recent navigation intent', async () => { + TestBed.inject(RouteNavigationTelemetryService).markNavigationIntent(); + await new Promise((r) => setTimeout(r, 60)); + await router.navigateByUrl('/customers'); + expect((scenarios()[0] as { delta: number }).delta).toBeGreaterThanOrEqual(50); + }); + + 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 nothing 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' }) + ]); + releaseHangGuard(true); + await pending; + expect(scenarios()).toHaveLength(1); + expect(pageViews).toEqual([]); + }); + + 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'); + await vi.advanceTimersByTimeAsync(60_000); + expect(scenarios()).toEqual([expect.objectContaining({ status: 'success' })]); + }); }); }); 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 index 9be035b..d241ebd 100644 --- 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 @@ -135,15 +135,23 @@ function routeTemplateOf(root: ActivatedRouteSnapshot): string { } /** - * The router's error for a URL that matched nothing quotes the URL; replace it - * so no path segment reaches telemetry. Its name is kept for grouping. + * A navigation error, reduced to what is safe to send. Router errors can quote + * the URL (no matching route), a lazy chunk's URL, route parameters or user + * data from a resolver — and path segments are not redacted. Only the error's + * name (its class, e.g. `ChunkLoadError`) and an HTTP status are kept. */ -function unrecognizedError(error: unknown): Error { - const generic = new Error('Navigation failed before the URL matched a route'); - if (error instanceof Error) { - generic.name = error.name; +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' && name) { + safe.name = name; } - return generic; + return { error: safe, statusCode: typeof status === 'number' ? status : undefined }; } /** One router navigation being tracked. */ @@ -309,12 +317,9 @@ export class RouteNavigationTelemetryService { if (event instanceof NavigationError) { const navigation = this.take(event.id); if (navigation) { - const recognized = navigation.route !== undefined; + const failure = safeNavigationFailure(event.error, navigation.route !== undefined); this.record(navigation, navigation.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => - scenario.fail({ - error: recognized ? event.error : unrecognizedError(event.error), - metadata - }) + scenario.fail({ ...failure, metadata }) ); } } diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts index 69c1d2c..d9d5e2d 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts @@ -1,6 +1,6 @@ -import { Component, Injectable } from '@angular/core'; +import { Component, inject, Injectable } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { provideRouter, Router, Routes } from '@angular/router'; +import { provideRouter, Router, UrlSegment } from '@angular/router'; import { provideUwtTelemetry, provideUwtTelemetryDestination, @@ -15,47 +15,32 @@ import { const events: { eventType: string; payload: Record }[] = []; const pageViews: string[] = []; -/** Records everything handed to the destination, so tests assert on the wire. */ @Injectable() class RecordingSink extends UwtTelemetrySink { - override record(eventType: string, payload: object): void { + 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 {} + recordError(): void {} + getSessionId(): string | undefined { return 'session-1'; } + setUserId(): void {} + getUserId(): string | undefined { return undefined; } + flush(): void {} } @Component({ template: '' }) -class Stub {} - -/** Mirrors the app's route shapes (redirect, static, `:param`) plus failure paths. */ -const routes: Routes = [ - { path: '', pathMatch: 'full', redirectTo: 'customers' }, - { path: 'customers', component: Stub }, - { path: 'customers/:customerId', component: Stub }, - { path: 'blocked', canActivate: [() => false], component: Stub }, - { - path: 'broken', - canActivate: [ - () => { - throw new Error('guard exploded'); - } - ], - component: Stub - } -]; +class Page {} -function scenarios(): Record[] { - return events.filter((e) => e.eventType === 'com.uwt.scenario').map((e) => e.payload); +let releaseSlowGuard: (value: boolean) => 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; @@ -64,7 +49,59 @@ describe('RouteNavigationTelemetryService', () => { pageViews.length = 0; TestBed.configureTestingModule({ providers: [ - provideRouter(routes), + 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: { paramMap: { get(k: string): string | null } }) => + 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: 'back-home', + component: Page, + canActivate: [() => inject(Router).parseUrl('/customers')] + }, + { + path: 'slow', + component: Page, + canActivate: [() => 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) } + ]), provideUwtTelemetry( { application: 'customer-portal-test', environment: 'test', version: '0.0.0' }, withScenarios({ defaultTimeoutMs: 0 }) @@ -73,64 +110,190 @@ describe('RouteNavigationTelemetryService', () => { { provide: ROUTE_PAGE_VIEW_RECORDER, useValue: (route: string) => pageViews.push(route) } ] }); - router = TestBed.inject(Router); TestBed.inject(RouteNavigationTelemetryService).start(); + router = TestBed.inject(Router); }); - it('reports a successful navigation under its route template, never the resolved id', async () => { - await router.navigateByUrl('/customers/cust-SECRET-7?tab=orders'); - - expect(scenarios()).toEqual([ + 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({ - scenarioName: 'route-navigation', - status: 'success', - route: '/customers/:customerId' + 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/:customerId']); + 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 the redirect from the empty path under the target template', async () => { - await router.navigateByUrl('/'); + 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('reports a guard rejection as incomplete, not as a failure', async () => { + 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 nothing for a navigation superseded before the URL was recognized', async () => { - const first = router.navigateByUrl('/customers/1'); - const second = router.navigateByUrl('/customers/2'); - await Promise.all([first, second]); + 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/:customerId' }) + expect.objectContaining({ + status: 'success', + route: '/customers/:id', + metadata: expect.objectContaining({ redirectedFrom: '/legacy/:id' }) + }) ]); + expect(JSON.stringify(events)).not.toContain('SECRET'); }); - it('reports a navigation error as a failure', async () => { - await router.navigateByUrl('/broken').catch(() => undefined); + 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('reports an unknown URL under a placeholder template, with no URL in the payload', async () => { - await router.navigateByUrl('/nope/cust-SECRET-7').catch(() => undefined); - + 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: '(unrecognized)' }) + expect.objectContaining({ status: 'failure', route: '/http-broken', statusCode: 503 }) ]); expect(JSON.stringify(events)).not.toContain('SECRET'); + expect(JSON.stringify(events)).toContain('HttpErrorResponse'); + }); + + 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('/back-home'); + expect(scenarios()).toEqual([ + expect.objectContaining({ status: 'abandoned', reason: 'same-url', route: '/back-home' }) + ]); + }); + + 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 a superseded navigation as abandoned, and the new one as success', async () => { + const first = router.navigateByUrl('/slow'); + await new Promise((r) => setTimeout(r)); + 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(); + }); + + it('keeps the start time across a guard redirect', async () => { + TestBed.inject(RouteNavigationTelemetryService).markNavigationIntent(); + await new Promise((r) => setTimeout(r, 60)); + await router.navigateByUrl('/old'); + expect((scenarios()[0] as { delta: number }).delta).toBeGreaterThanOrEqual(50); + }); + + it('backdates to a recent navigation intent', async () => { + TestBed.inject(RouteNavigationTelemetryService).markNavigationIntent(); + await new Promise((r) => setTimeout(r, 60)); + await router.navigateByUrl('/customers'); + expect((scenarios()[0] as { delta: number }).delta).toBeGreaterThanOrEqual(50); + }); + + 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 nothing 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' }) + ]); + releaseHangGuard(true); + await pending; + expect(scenarios()).toHaveLength(1); + expect(pageViews).toEqual([]); + }); + + 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'); + await vi.advanceTimersByTimeAsync(60_000); + expect(scenarios()).toEqual([expect.objectContaining({ status: 'success' })]); + }); }); }); 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 index 9be035b..d241ebd 100644 --- 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 @@ -135,15 +135,23 @@ function routeTemplateOf(root: ActivatedRouteSnapshot): string { } /** - * The router's error for a URL that matched nothing quotes the URL; replace it - * so no path segment reaches telemetry. Its name is kept for grouping. + * A navigation error, reduced to what is safe to send. Router errors can quote + * the URL (no matching route), a lazy chunk's URL, route parameters or user + * data from a resolver — and path segments are not redacted. Only the error's + * name (its class, e.g. `ChunkLoadError`) and an HTTP status are kept. */ -function unrecognizedError(error: unknown): Error { - const generic = new Error('Navigation failed before the URL matched a route'); - if (error instanceof Error) { - generic.name = error.name; +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' && name) { + safe.name = name; } - return generic; + return { error: safe, statusCode: typeof status === 'number' ? status : undefined }; } /** One router navigation being tracked. */ @@ -309,12 +317,9 @@ export class RouteNavigationTelemetryService { if (event instanceof NavigationError) { const navigation = this.take(event.id); if (navigation) { - const recognized = navigation.route !== undefined; + const failure = safeNavigationFailure(event.error, navigation.route !== undefined); this.record(navigation, navigation.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => - scenario.fail({ - error: recognized ? event.error : unrecognizedError(event.error), - metadata - }) + scenario.fail({ ...failure, metadata }) ); } } diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index 25ccfa2..ba96af2 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -190,9 +190,12 @@ export const appConfig: ApplicationConfig = { non-blocking initializer — don't write one. The asset uses `fetch`, which bypasses `HttpClient` interceptors: if the broker needs auth that an interceptor adds, call it through `HttpClient` (`firstValueFrom`) instead. -- **User identity** (only when Step 1 found sign-in): where sign-in completes, call - `inject(UwtTelemetrySink).setUserId(opaqueId)`; on sign-out, `setUserId(undefined)` — that starts - a fresh RUM session. Never an email or username. In a fragment, leave it to the shell: a +- **User identity** (only when Step 1 found sign-in): in the service that completes sign-in, inject + the sink once as a field — `private readonly telemetrySink = inject(UwtTelemetrySink);` — and call + `this.telemetrySink.setUserId(opaqueId)` when sign-in completes and `setUserId(undefined)` on + sign-out, which starts a fresh RUM session. Never call `inject()` inside the callback itself: it + only works while Angular constructs the class, and throws `NG0203` there. Never an email or + username. In a fragment, leave it to the shell: a fragment's `setUserId` is forwarded and changes the shell's user. ### Step 4 · Declare the vocabulary @@ -268,9 +271,11 @@ What the asset gets right, and why — keep these if you change it: aborted or skipped is `abandoned`; an error is `failure`; no end event within 30 s (a guard, resolver or lazy chunk that never settles) is `timeout`, recorded once from a browser-only timer outside Angular. A navigation superseded before its URL was recognized records nothing. -5. **No URL ever reaches telemetry.** An unmatched URL fails as `(unrecognized)` with a generic - error — the router's own error quotes the URL. Reasons come from the router's `code`; its - `reason` text is never sent (empty in production, can contain URLs in development). +5. **No URL ever reaches telemetry.** Every navigation error is replaced by a generic one that + keeps only the error's name and an HTTP status — router, resolver and lazy-chunk errors can quote + URLs, parameters or user data. An unmatched URL fails as `(unrecognized)`. Reasons come from the + router's `code`; its `reason` text is never sent (empty in production, can contain URLs in + development). 6. **Click-intent backdating:** `markNavigationIntent()` from nav-link click handlers; a mark older than 2 s is discarded. diff --git a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts index 9be035b..d241ebd 100644 --- a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts @@ -135,15 +135,23 @@ function routeTemplateOf(root: ActivatedRouteSnapshot): string { } /** - * The router's error for a URL that matched nothing quotes the URL; replace it - * so no path segment reaches telemetry. Its name is kept for grouping. + * A navigation error, reduced to what is safe to send. Router errors can quote + * the URL (no matching route), a lazy chunk's URL, route parameters or user + * data from a resolver — and path segments are not redacted. Only the error's + * name (its class, e.g. `ChunkLoadError`) and an HTTP status are kept. */ -function unrecognizedError(error: unknown): Error { - const generic = new Error('Navigation failed before the URL matched a route'); - if (error instanceof Error) { - generic.name = error.name; +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' && name) { + safe.name = name; } - return generic; + return { error: safe, statusCode: typeof status === 'number' ? status : undefined }; } /** One router navigation being tracked. */ @@ -309,12 +317,9 @@ export class RouteNavigationTelemetryService { if (event instanceof NavigationError) { const navigation = this.take(event.id); if (navigation) { - const recognized = navigation.route !== undefined; + const failure = safeNavigationFailure(event.error, navigation.route !== undefined); this.record(navigation, navigation.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => - scenario.fail({ - error: recognized ? event.error : unrecognizedError(event.error), - metadata - }) + scenario.fail({ ...failure, metadata }) ); } } diff --git a/skills/ui-watchtower-setup/references/rum-migration.md b/skills/ui-watchtower-setup/references/rum-migration.md index 2a85228..0f0d7fa 100644 --- a/skills/ui-watchtower-setup/references/rum-migration.md +++ b/skills/ui-watchtower-setup/references/rum-migration.md @@ -37,7 +37,7 @@ the affected dashboards. Dashboard parity never outranks personal data. |--------------------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------| | `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 | -| `inject(UwtTelemetrySink).record(eventType, payload)` | Byte-exact payload parity is required | Raw passthrough — you own the shape. Use sparingly and comment why | +| `sink.record(eventType, payload)` (sink injected as a field) | Byte-exact payload parity is required | Raw passthrough — you own the shape. Use sparingly and comment why | 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. @@ -49,12 +49,22 @@ event types — record that decision in the table. 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. Migrate with both paths alive +## 4. Cut over without ever running two RUM clients -Move call sites one at a time. Keep `RumService` until the last row is migrated. Do not run two RUM -clients at once: once `provideUwtTelemetryRumSink()` is wired, the old service's remaining calls -should go through the library's sink (`inject(UwtTelemetrySink).record(...)`) or be migrated — two -`AwsRum` instances would mean two sessions per visitor. +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 @@ -90,8 +100,9 @@ diff needs. - **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 `inject(UwtTelemetrySink).setUserId(id)` / - `setUserId(undefined)`. Sign-out starts a fresh session with a new anonymous id; if the old code +- **User identity.** Replace `pinUserId` with `setUserId(id)` / `setUserId(undefined)` on the + `UwtTelemetrySink`, injected once as a field of the service that handles sign-in — never with + `inject()` inside the sign-in callback, which throws `NG0203`. Sign-out starts a fresh session with a new anonymous id; if the old code behaved differently, call out the change. - **Errors.** The RUM client still captures unhandled JS errors itself. Handled errors the old code sent with `recordError` become `scenario.fail({ error })`, or a log line with From c54404643df26b09bf66c4205a264d0c2516cc3f Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 16:07:48 +0200 Subject: [PATCH 07/25] more --- ...route-navigation-telemetry.service.spec.ts | 25 ++++++++++++++----- .../route-navigation-telemetry.service.ts | 12 ++++++--- ...route-navigation-telemetry.service.spec.ts | 25 ++++++++++++++----- .../route-navigation-telemetry.service.ts | 12 ++++++--- .../route-navigation-telemetry.service.ts | 12 ++++++--- skills/ui-watchtower-setup/evals/evals.json | 2 +- .../references/micro-frontends.md | 20 +++++++++++++-- 7 files changed, 84 insertions(+), 24 deletions(-) diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts index d9d5e2d..a5f2fc7 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts @@ -17,14 +17,14 @@ const pageViews: string[] = []; @Injectable() class RecordingSink extends UwtTelemetrySink { - record(eventType: string, payload: object): void { + override 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 {} + override recordError(): void {} + override getSessionId(): string | undefined { return 'session-1'; } + override setUserId(): void {} + override getUserId(): string | undefined { return undefined; } + override flush(): void {} } @Component({ template: '' }) @@ -84,6 +84,13 @@ describe('RouteNavigationTelemetryService', () => { 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: 'back-home', component: Page, @@ -214,6 +221,12 @@ describe('RouteNavigationTelemetryService', () => { 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'); 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 index d241ebd..4b1c9c8 100644 --- 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 @@ -134,11 +134,17 @@ function routeTemplateOf(root: ActivatedRouteSnapshot): string { return `/${parts.join('/')}`; } +/** + * A class-like error name (`ChunkLoadError`, `HttpErrorResponse`). Anything else + * — a thrown object can carry any `name` — could be free text and is dropped. + */ +const SAFE_ERROR_NAME = /^[A-Z][A-Za-z0-9]{0,63}$/; + /** * A navigation error, reduced to what is safe to send. Router errors can quote * the URL (no matching route), a lazy chunk's URL, route parameters or user - * data from a resolver — and path segments are not redacted. Only the error's - * name (its class, e.g. `ChunkLoadError`) and an HTTP status are kept. + * data from a resolver — and path segments are not redacted. Only a class-like + * name (e.g. `ChunkLoadError`) and an HTTP status are kept. */ function safeNavigationFailure( error: unknown, @@ -148,7 +154,7 @@ function safeNavigationFailure( recognized ? 'Navigation failed' : 'Navigation failed before the URL matched a route' ); const { name, status } = (error ?? {}) as { name?: unknown; status?: unknown }; - if (typeof name === 'string' && name) { + if (typeof name === 'string' && SAFE_ERROR_NAME.test(name)) { safe.name = name; } return { error: safe, statusCode: typeof status === 'number' ? status : undefined }; diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts index d9d5e2d..a5f2fc7 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts @@ -17,14 +17,14 @@ const pageViews: string[] = []; @Injectable() class RecordingSink extends UwtTelemetrySink { - record(eventType: string, payload: object): void { + override 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 {} + override recordError(): void {} + override getSessionId(): string | undefined { return 'session-1'; } + override setUserId(): void {} + override getUserId(): string | undefined { return undefined; } + override flush(): void {} } @Component({ template: '' }) @@ -84,6 +84,13 @@ describe('RouteNavigationTelemetryService', () => { 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: 'back-home', component: Page, @@ -214,6 +221,12 @@ describe('RouteNavigationTelemetryService', () => { 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'); 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 index d241ebd..4b1c9c8 100644 --- 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 @@ -134,11 +134,17 @@ function routeTemplateOf(root: ActivatedRouteSnapshot): string { return `/${parts.join('/')}`; } +/** + * A class-like error name (`ChunkLoadError`, `HttpErrorResponse`). Anything else + * — a thrown object can carry any `name` — could be free text and is dropped. + */ +const SAFE_ERROR_NAME = /^[A-Z][A-Za-z0-9]{0,63}$/; + /** * A navigation error, reduced to what is safe to send. Router errors can quote * the URL (no matching route), a lazy chunk's URL, route parameters or user - * data from a resolver — and path segments are not redacted. Only the error's - * name (its class, e.g. `ChunkLoadError`) and an HTTP status are kept. + * data from a resolver — and path segments are not redacted. Only a class-like + * name (e.g. `ChunkLoadError`) and an HTTP status are kept. */ function safeNavigationFailure( error: unknown, @@ -148,7 +154,7 @@ function safeNavigationFailure( recognized ? 'Navigation failed' : 'Navigation failed before the URL matched a route' ); const { name, status } = (error ?? {}) as { name?: unknown; status?: unknown }; - if (typeof name === 'string' && name) { + if (typeof name === 'string' && SAFE_ERROR_NAME.test(name)) { safe.name = name; } return { error: safe, statusCode: typeof status === 'number' ? status : undefined }; diff --git a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts index d241ebd..4b1c9c8 100644 --- a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts @@ -134,11 +134,17 @@ function routeTemplateOf(root: ActivatedRouteSnapshot): string { return `/${parts.join('/')}`; } +/** + * A class-like error name (`ChunkLoadError`, `HttpErrorResponse`). Anything else + * — a thrown object can carry any `name` — could be free text and is dropped. + */ +const SAFE_ERROR_NAME = /^[A-Z][A-Za-z0-9]{0,63}$/; + /** * A navigation error, reduced to what is safe to send. Router errors can quote * the URL (no matching route), a lazy chunk's URL, route parameters or user - * data from a resolver — and path segments are not redacted. Only the error's - * name (its class, e.g. `ChunkLoadError`) and an HTTP status are kept. + * data from a resolver — and path segments are not redacted. Only a class-like + * name (e.g. `ChunkLoadError`) and an HTTP status are kept. */ function safeNavigationFailure( error: unknown, @@ -148,7 +154,7 @@ function safeNavigationFailure( recognized ? 'Navigation failed' : 'Navigation failed before the URL matched a route' ); const { name, status } = (error ?? {}) as { name?: unknown; status?: unknown }; - if (typeof name === 'string' && name) { + if (typeof name === 'string' && SAFE_ERROR_NAME.test(name)) { safe.name = name; } return { error: safe, statusCode: typeof status === 'number' ? status : undefined }; diff --git a/skills/ui-watchtower-setup/evals/evals.json b/skills/ui-watchtower-setup/evals/evals.json index 798f0a6..5f25f26 100644 --- a/skills/ui-watchtower-setup/evals/evals.json +++ b/skills/ui-watchtower-setup/evals/evals.json @@ -55,7 +55,7 @@ "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 crypto.randomUUID()-based channel id generated once per page load and published as window.__uwtTelemetryChannel, guarded for server rendering", + "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", diff --git a/skills/ui-watchtower-setup/references/micro-frontends.md b/skills/ui-watchtower-setup/references/micro-frontends.md index ce084b5..af3dafd 100644 --- a/skills/ui-watchtower-setup/references/micro-frontends.md +++ b/skills/ui-watchtower-setup/references/micro-frontends.md @@ -33,10 +33,26 @@ declare global { } } +/** + * A random id for this page load. `crypto.randomUUID` exists only in secure contexts (HTTPS, + * localhost); `getRandomValues` works everywhere crypto does. Never throws — a failure here would + * stop the shell from booting. + */ +function newChannelId(): string { + const c = globalThis.crypto; + if (typeof c?.randomUUID === 'function') { + return `ngx-ui-watchtower-${c.randomUUID()}`; + } + if (typeof c?.getRandomValues === 'function') { + const bytes = c.getRandomValues(new Uint8Array(16)); + return `ngx-ui-watchtower-${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`; + } + return `ngx-ui-watchtower-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + // One channel per page load, at module scope so it exists before any fragment boots. Browser-only: // this module is also evaluated during server rendering, where the host is a no-op anyway. -const channelId = - typeof window === 'undefined' ? undefined : `ngx-ui-watchtower-${crypto.randomUUID()}`; +const channelId = typeof window === 'undefined' ? undefined : newChannelId(); if (channelId) { window.__uwtTelemetryChannel = channelId; } From be84adf5979d6adbd249b93e925a6302437966d5 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 16:39:29 +0200 Subject: [PATCH 08/25] more --- ...route-navigation-telemetry.service.spec.ts | 35 ++++-- .../rum-credentials.provider.spec.ts | 100 ++++++++++++++++++ .../app/telemetry/rum-credentials.provider.ts | 88 ++++++++++++++- ...route-navigation-telemetry.service.spec.ts | 35 ++++-- .../rum-credentials.provider.spec.ts | 100 ++++++++++++++++++ .../app/telemetry/rum-credentials.provider.ts | 88 ++++++++++++++- skills/ui-watchtower-setup/SKILL.md | 4 +- .../assets/rum-credentials.provider.ts | 88 ++++++++++++++- 8 files changed, 503 insertions(+), 35 deletions(-) create mode 100644 skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.spec.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.spec.ts diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts index a5f2fc7..5a4f28c 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts @@ -263,18 +263,31 @@ describe('RouteNavigationTelemetryService', () => { expect(scenarios()[0]['message']).toBeUndefined(); }); - it('keeps the start time across a guard redirect', async () => { - TestBed.inject(RouteNavigationTelemetryService).markNavigationIntent(); - await new Promise((r) => setTimeout(r, 60)); - await router.navigateByUrl('/old'); - expect((scenarios()[0] as { delta: number }).delta).toBeGreaterThanOrEqual(50); - }); + 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('backdates to a recent navigation intent', async () => { - TestBed.inject(RouteNavigationTelemetryService).markNavigationIntent(); - await new Promise((r) => setTimeout(r, 60)); - await router.navigateByUrl('/customers'); - expect((scenarios()[0] as { delta: number }).delta).toBeGreaterThanOrEqual(50); + 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', () => { diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.spec.ts new file mode 100644 index 0000000..bc3769d --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.spec.ts @@ -0,0 +1,100 @@ +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: '2030-01-01T00:00:00Z' + } +}; + +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('returns null for invalid JSON, an error status and a network failure', async () => { + answer('', 200); + await expect(load()).resolves.toBeNull(); + answer(valid, 503); + await expect(load()).resolves.toBeNull(); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('offline'))); + await expect(load()).resolves.toBeNull(); + }); + + 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('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-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 index 2acac69..2c35d8c 100644 --- 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 @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import type { + UwtRumAppMonitorConfig, UwtRumBootstrap, UwtRumCredentialsProvider } from '@absaoss-cps/ngx-ui-watchtower/rum'; @@ -18,7 +19,7 @@ const RUM_BROKER_URL = '/rum/init'; * { * "enabled": true, * "config": { "applicationId": "…", "region": "eu-west-1", "applicationVersion": "1.4.0", - * "sessionSampleRate": 1 }, + * "sessionSampleRate": 1, "telemetries": ["errors", "performance", "http"] }, * "credentials": { "accessKeyId": "…", "secretAccessKey": "…", "sessionToken": "…", * "expiration": "2026-10-08T12:00:00Z" } * } @@ -36,6 +37,86 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * Optional app-monitor settings a broker can send as JSON, each kept only with + * the right type. Functions, `RegExp`s and plugins (`fetchFunction`, + * `clientBuilder`, `pagesToInclude`/`pagesToExclude`, `eventPluginsToLoad`) + * can't travel as JSON — set them in code if the app needs them. + */ +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'; + +/** The optional settings in `config` that have the expected type; the rest are dropped. */ +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((t) => isString(t) || Array.isArray(t)) + ); + const compression = config['compressionStrategy']; + if (isRecord(compression) && typeof compression['enabled'] === 'boolean') { + settings['compressionStrategy'] = { enabled: compression['enabled'] }; + } + return settings as Partial; +} + /** Whether `value` is an object whose `keys` are all non-empty strings. */ function hasStrings( value: unknown, @@ -101,14 +182,13 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { return null; } - const rate = config['sessionSampleRate']; return { config: { + ...optionalSettings(config), applicationId: config.applicationId, region: config.region, applicationVersion: config.applicationVersion, - sessionSampleRate: typeof rate === 'number' && rate >= 0 && rate <= 1 ? rate : undefined, - // Page views are recorded under the route template by + // Always, whatever the broker says. Page views are recorded under the route template by // RouteNavigationTelemetryService; automatic ones would send the // resolved path (/customers/42). disableAutoPageView: true diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts index a5f2fc7..5a4f28c 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts @@ -263,18 +263,31 @@ describe('RouteNavigationTelemetryService', () => { expect(scenarios()[0]['message']).toBeUndefined(); }); - it('keeps the start time across a guard redirect', async () => { - TestBed.inject(RouteNavigationTelemetryService).markNavigationIntent(); - await new Promise((r) => setTimeout(r, 60)); - await router.navigateByUrl('/old'); - expect((scenarios()[0] as { delta: number }).delta).toBeGreaterThanOrEqual(50); - }); + 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('backdates to a recent navigation intent', async () => { - TestBed.inject(RouteNavigationTelemetryService).markNavigationIntent(); - await new Promise((r) => setTimeout(r, 60)); - await router.navigateByUrl('/customers'); - expect((scenarios()[0] as { delta: number }).delta).toBeGreaterThanOrEqual(50); + 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', () => { diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.spec.ts new file mode 100644 index 0000000..bc3769d --- /dev/null +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.spec.ts @@ -0,0 +1,100 @@ +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: '2030-01-01T00:00:00Z' + } +}; + +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('returns null for invalid JSON, an error status and a network failure', async () => { + answer('', 200); + await expect(load()).resolves.toBeNull(); + answer(valid, 503); + await expect(load()).resolves.toBeNull(); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('offline'))); + await expect(load()).resolves.toBeNull(); + }); + + 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('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-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 index 2acac69..2c35d8c 100644 --- 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 @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import type { + UwtRumAppMonitorConfig, UwtRumBootstrap, UwtRumCredentialsProvider } from '@absaoss-cps/ngx-ui-watchtower/rum'; @@ -18,7 +19,7 @@ const RUM_BROKER_URL = '/rum/init'; * { * "enabled": true, * "config": { "applicationId": "…", "region": "eu-west-1", "applicationVersion": "1.4.0", - * "sessionSampleRate": 1 }, + * "sessionSampleRate": 1, "telemetries": ["errors", "performance", "http"] }, * "credentials": { "accessKeyId": "…", "secretAccessKey": "…", "sessionToken": "…", * "expiration": "2026-10-08T12:00:00Z" } * } @@ -36,6 +37,86 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * Optional app-monitor settings a broker can send as JSON, each kept only with + * the right type. Functions, `RegExp`s and plugins (`fetchFunction`, + * `clientBuilder`, `pagesToInclude`/`pagesToExclude`, `eventPluginsToLoad`) + * can't travel as JSON — set them in code if the app needs them. + */ +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'; + +/** The optional settings in `config` that have the expected type; the rest are dropped. */ +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((t) => isString(t) || Array.isArray(t)) + ); + const compression = config['compressionStrategy']; + if (isRecord(compression) && typeof compression['enabled'] === 'boolean') { + settings['compressionStrategy'] = { enabled: compression['enabled'] }; + } + return settings as Partial; +} + /** Whether `value` is an object whose `keys` are all non-empty strings. */ function hasStrings( value: unknown, @@ -101,14 +182,13 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { return null; } - const rate = config['sessionSampleRate']; return { config: { + ...optionalSettings(config), applicationId: config.applicationId, region: config.region, applicationVersion: config.applicationVersion, - sessionSampleRate: typeof rate === 'number' && rate >= 0 && rate <= 1 ? rate : undefined, - // Page views are recorded under the route template by + // Always, whatever the broker says. Page views are recorded under the route template by // RouteNavigationTelemetryService; automatic ones would send the // resolved path (/customers/42). disableAutoPageView: true diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index ba96af2..07961c9 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -186,7 +186,9 @@ export const appConfig: ApplicationConfig = { - Credentials: copy `assets/rum-credentials.provider.ts`, point it at the broker endpoint from Step 1 and adapt the response check. `load()` validates every required field and returns `null` on **any** failure or incomplete answer (that disables RUM for the session; the app keeps working). - It also sets `disableAutoPageView: true` — page views come from Step 6, under the route template. `provideUwtTelemetryRumSink()` calls it from its own + It passes the broker's other app-monitor settings through when correctly typed (`telemetries`, + `sessionAttributes`, `allowCookies`, …) and always sets `disableAutoPageView: true` — page views + come from Step 6, under the route template. `provideUwtTelemetryRumSink()` calls it from its own non-blocking initializer — don't write one. The asset uses `fetch`, which bypasses `HttpClient` interceptors: if the broker needs auth that an interceptor adds, call it through `HttpClient` (`firstValueFrom`) instead. diff --git a/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts index 2acac69..2c35d8c 100644 --- a/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts +++ b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import type { + UwtRumAppMonitorConfig, UwtRumBootstrap, UwtRumCredentialsProvider } from '@absaoss-cps/ngx-ui-watchtower/rum'; @@ -18,7 +19,7 @@ const RUM_BROKER_URL = '/rum/init'; * { * "enabled": true, * "config": { "applicationId": "…", "region": "eu-west-1", "applicationVersion": "1.4.0", - * "sessionSampleRate": 1 }, + * "sessionSampleRate": 1, "telemetries": ["errors", "performance", "http"] }, * "credentials": { "accessKeyId": "…", "secretAccessKey": "…", "sessionToken": "…", * "expiration": "2026-10-08T12:00:00Z" } * } @@ -36,6 +37,86 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * Optional app-monitor settings a broker can send as JSON, each kept only with + * the right type. Functions, `RegExp`s and plugins (`fetchFunction`, + * `clientBuilder`, `pagesToInclude`/`pagesToExclude`, `eventPluginsToLoad`) + * can't travel as JSON — set them in code if the app needs them. + */ +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'; + +/** The optional settings in `config` that have the expected type; the rest are dropped. */ +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((t) => isString(t) || Array.isArray(t)) + ); + const compression = config['compressionStrategy']; + if (isRecord(compression) && typeof compression['enabled'] === 'boolean') { + settings['compressionStrategy'] = { enabled: compression['enabled'] }; + } + return settings as Partial; +} + /** Whether `value` is an object whose `keys` are all non-empty strings. */ function hasStrings( value: unknown, @@ -101,14 +182,13 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { return null; } - const rate = config['sessionSampleRate']; return { config: { + ...optionalSettings(config), applicationId: config.applicationId, region: config.region, applicationVersion: config.applicationVersion, - sessionSampleRate: typeof rate === 'number' && rate >= 0 && rate <= 1 ? rate : undefined, - // Page views are recorded under the route template by + // Always, whatever the broker says. Page views are recorded under the route template by // RouteNavigationTelemetryService; automatic ones would send the // resolved path (/customers/42). disableAutoPageView: true From f41d6e0bf07fd11e91257a4c6b015e9bb2751586 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 17:06:35 +0200 Subject: [PATCH 09/25] reduce --- .../evals/evals.json | 5 - .../evals/files/unwired-angular/angular.json | 76 ---- .../unwired-angular/src/app/app.routes.ts | 13 - .../files/unwired-angular/src/app/app.spec.ts | 16 - .../files/unwired-angular/src/app/app.ts | 12 - .../src/app/customers/customer-detail.ts | 9 - .../files/unwired-angular/src/index.html | 12 - .../evals/files/unwired-angular/src/main.ts | 5 - .../files/unwired-angular/src/styles.css | 1 - .../files/unwired-angular/tsconfig.app.json | 18 - .../evals/files/unwired-angular/tsconfig.json | 31 -- .../files/unwired-angular/tsconfig.spec.json | 16 - .../route-navigation-telemetry.service.ts | 122 ++----- .../app/telemetry/rum-credentials.provider.ts | 42 +-- ...route-navigation-telemetry.service.spec.ts | 325 ------------------ .../route-navigation-telemetry.service.ts | 122 ++----- .../rum-credentials.provider.spec.ts | 100 ------ .../app/telemetry/rum-credentials.provider.ts | 42 +-- skills/ui-watchtower-setup/SKILL.md | 41 +-- ...route-navigation-telemetry.service.spec.ts | 2 +- .../route-navigation-telemetry.service.ts | 122 ++----- .../assets}/rum-credentials.provider.spec.ts | 0 .../assets/rum-credentials.provider.ts | 42 +-- skills/ui-watchtower-setup/evals/evals.json | 13 +- .../evals/files/future-angular/angular.json | 76 ---- .../future-angular/src/app/app.routes.ts | 13 - .../files/future-angular/src/app/app.spec.ts | 16 - .../evals/files/future-angular/src/app/app.ts | 12 - .../src/app/customers/customer-detail.ts | 9 - .../src/app/customers/customers.ts | 36 -- .../evals/files/future-angular/src/index.html | 12 - .../evals/files/future-angular/src/styles.css | 1 - .../files/future-angular/tsconfig.app.json | 18 - .../evals/files/future-angular/tsconfig.json | 31 -- .../files/future-angular/tsconfig.spec.json | 16 - 35 files changed, 138 insertions(+), 1289 deletions(-) delete mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/angular.json delete mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.routes.ts delete mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.spec.ts delete mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.ts delete mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/customers/customer-detail.ts delete mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/index.html delete mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/main.ts delete mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/src/styles.css delete mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.app.json delete mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.json delete mode 100644 skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.spec.json delete mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts delete mode 100644 skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.spec.ts rename skills/{ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry => ui-watchtower-setup/assets}/route-navigation-telemetry.service.spec.ts (99%) rename skills/{ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry => ui-watchtower-setup/assets}/rum-credentials.provider.spec.ts (100%) delete mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/angular.json delete mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.routes.ts delete mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.spec.ts delete mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.ts delete mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/app/customers/customer-detail.ts delete mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/app/customers/customers.ts delete mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/index.html delete mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/src/styles.css delete mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.app.json delete mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.json delete mode 100644 skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.spec.json diff --git a/skills/ui-watchtower-integration/evals/evals.json b/skills/ui-watchtower-integration/evals/evals.json index 7371054..cf56006 100644 --- a/skills/ui-watchtower-integration/evals/evals.json +++ b/skills/ui-watchtower-integration/evals/evals.json @@ -15,7 +15,6 @@ "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.spec.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", @@ -50,7 +49,6 @@ "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.spec.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", @@ -84,7 +82,6 @@ "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.spec.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", @@ -117,7 +114,6 @@ "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.spec.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", @@ -153,7 +149,6 @@ "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.spec.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", diff --git a/skills/ui-watchtower-integration/evals/files/unwired-angular/angular.json b/skills/ui-watchtower-integration/evals/files/unwired-angular/angular.json deleted file mode 100644 index c269d3b..0000000 --- a/skills/ui-watchtower-integration/evals/files/unwired-angular/angular.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "$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", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], - "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/unwired-angular/src/app/app.routes.ts b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.routes.ts deleted file mode 100644 index 097aa25..0000000 --- a/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.routes.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/unwired-angular/src/app/app.spec.ts b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.spec.ts deleted file mode 100644 index 8e82882..0000000 --- a/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -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-integration/evals/files/unwired-angular/src/app/app.ts b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.ts deleted file mode 100644 index 241dab5..0000000 --- a/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/app.ts +++ /dev/null @@ -1,12 +0,0 @@ -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-integration/evals/files/unwired-angular/src/app/customers/customer-detail.ts b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/customers/customer-detail.ts deleted file mode 100644 index 8855ad4..0000000 --- a/skills/ui-watchtower-integration/evals/files/unwired-angular/src/app/customers/customer-detail.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/unwired-angular/src/index.html b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/index.html deleted file mode 100644 index 1e743a7..0000000 --- a/skills/ui-watchtower-integration/evals/files/unwired-angular/src/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - Customer portal - - - - - - - diff --git a/skills/ui-watchtower-integration/evals/files/unwired-angular/src/main.ts b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/main.ts deleted file mode 100644 index 190f341..0000000 --- a/skills/ui-watchtower-integration/evals/files/unwired-angular/src/main.ts +++ /dev/null @@ -1,5 +0,0 @@ -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/unwired-angular/src/styles.css b/skills/ui-watchtower-integration/evals/files/unwired-angular/src/styles.css deleted file mode 100644 index 4b438c4..0000000 --- a/skills/ui-watchtower-integration/evals/files/unwired-angular/src/styles.css +++ /dev/null @@ -1 +0,0 @@ -/* Global styles */ diff --git a/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.app.json b/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.app.json deleted file mode 100644 index d5d9680..0000000 --- a/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.app.json +++ /dev/null @@ -1,18 +0,0 @@ -/* 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/unwired-angular/tsconfig.json b/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.json deleted file mode 100644 index d2fbb9c..0000000 --- a/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -/* 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/unwired-angular/tsconfig.spec.json b/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.spec.json deleted file mode 100644 index 4c1202f..0000000 --- a/skills/ui-watchtower-integration/evals/files/unwired-angular/tsconfig.spec.json +++ /dev/null @@ -1,16 +0,0 @@ -/* 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-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 index 4b1c9c8..4cdef38 100644 --- 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 @@ -30,51 +30,31 @@ import { import './telemetry.schema'; /** - * Records one page view per completed navigation, under the route template. - * - * Bind it only where AWS CloudWatch RUM is the destination, together with - * `disableAutoPageView: true` in the app-monitor config — automatic page - * views would report the resolved path (`/customers/42`): - * - * ```ts - * { - * provide: ROUTE_PAGE_VIEW_RECORDER, - * useFactory: () => { - * const rum = inject(UwtRumTelemetrySink); - * return (route: string) => rum.recordPageView(route); - * } - * } - * ``` + * 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' ); -/** Name shared by every route-navigation scenario. */ 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; -/** - * A navigation with no end event within this window — a guard, resolver or - * lazy chunk that never settles — is recorded as `timeout`. - */ +/** 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 in the template — a - * matcher has no `path` to report. - */ +/** Route `data` key naming a `matcher` route's segment — a matcher has no `path`. */ const TELEMETRY_PATH_DATA_KEY = 'telemetryPath'; /** - * Stable, low-cardinality causes, derived from the router's `code`. The - * router's `reason` text is never sent: it is empty in production builds and - * can contain URLs in development builds. + * 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', @@ -84,10 +64,7 @@ const CANCEL_REASON: Partial> = { [NavigationCancellationCode.Aborted]: 'aborted' }; -/** - * Cancellations that are an expected dead end of the journey (`incomplete`), - * not the user leaving it (`abandoned`). - */ +/** Expected dead ends (`incomplete`), not the user leaving (`abandoned`). */ const INCOMPLETE_CODES = new Set([ NavigationCancellationCode.GuardRejected, NavigationCancellationCode.NoDataFromResolver @@ -108,11 +85,7 @@ function causeOf( return (code === undefined ? undefined : causes[code]) ?? fallback; } -/** - * The matched route's template, from the `routeConfig` chain of the primary - * outlet: `/customers/42` → `/customers/:id`. Exact after `redirectTo` and lazy - * `loadChildren`, because the router has done the matching. - */ +/** Matched template from the primary outlet's `routeConfig` chain: `/customers/:id`. */ function routeTemplateOf(root: ActivatedRouteSnapshot): string { const parts: string[] = []; for ( @@ -134,17 +107,12 @@ function routeTemplateOf(root: ActivatedRouteSnapshot): string { return `/${parts.join('/')}`; } -/** - * A class-like error name (`ChunkLoadError`, `HttpErrorResponse`). Anything else - * — a thrown object can carry any `name` — could be free text and is dropped. - */ +/** A class-like name (`ChunkLoadError`); any other `name` could be free text. */ const SAFE_ERROR_NAME = /^[A-Z][A-Za-z0-9]{0,63}$/; /** - * A navigation error, reduced to what is safe to send. Router errors can quote - * the URL (no matching route), a lazy chunk's URL, route parameters or user - * data from a resolver — and path segments are not redacted. Only a class-like - * name (e.g. `ChunkLoadError`) and an HTTP status are kept. + * 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, @@ -160,17 +128,15 @@ function safeNavigationFailure( return { error: safe, statusCode: typeof status === 'number' ? status : undefined }; } -/** One router navigation being tracked. */ interface TrackedNavigation { - /** Epoch ms the journey started: the click, `NavigationStart`, or the first navigation of a redirect chain. */ + /** Epoch ms: the click, `NavigationStart`, or the start of a redirect chain. */ startedAt: number; - /** Template of the route a guard or resolver redirected away from. */ + /** Template a guard or resolver redirected away from. */ redirectedFrom?: string; - /** The matched route template, known from `RoutesRecognized` on. */ + /** Matched template, known from `RoutesRecognized`. */ route?: string; - /** Epoch ms of `RoutesRecognized`: guards, resolvers and lazy components run after it. */ + /** `RoutesRecognized` time — guards, resolvers and lazy components run after it. */ recognizedAt?: number; - /** Records the navigation as `timeout` if no end event arrives in time. */ timeoutHandle?: ReturnType; } @@ -178,15 +144,10 @@ interface TrackedNavigation { type RedirectCarry = Pick; /** - * Measures every router navigation as one `route-navigation` scenario. - * - * The scenario is recorded when the navigation ends, so `route` is always the - * final matched template — after `redirectTo`, lazy routes, and guard or - * resolver redirects — and backdated to the click or the first - * `NavigationStart`, so its duration covers the whole wait. - * - * Call {@link start} once, from the root component's constructor. Call - * {@link markNavigationIntent} from navigation-link click handlers. + * 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 — and backdated to the click + * or the first `NavigationStart`. Call {@link start} once from the root component; call + * {@link markNavigationIntent} from nav-link click handlers. */ @Injectable({ providedIn: 'root' }) export class RouteNavigationTelemetryService { @@ -197,22 +158,15 @@ export class RouteNavigationTelemetryService { private readonly zone = inject(NgZone); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - /** - * Keyed by navigation id, never a single "current" field: one navigation - * can supersede another, and a single field would attribute the wrong - * duration. - */ + /** By navigation id, never one "current" field: navigations supersede each other. */ private readonly navigations = new Map(); - /** A redirected navigation, continued by the next `NavigationStart`. */ private pendingRedirect?: RedirectCarry; - - /** When the user last did something expected to start a navigation. */ private navigationIntentAt?: number; private started = false; - /** Begins tracking router navigations. Safe to call more than once. */ + /** Safe to call more than once. */ start(): void { if (this.started) { return; @@ -229,12 +183,7 @@ export class RouteNavigationTelemetryService { }); } - /** - * Records that the user just did something expected to start a - * navigation. The router raises `NavigationStart` after event handling, - * guards and change detection; measuring from the click keeps that wait - * inside the recorded duration. - */ + /** `NavigationStart` comes after event handling and change detection; count that wait too. */ markNavigationIntent(): void { this.navigationIntentAt = Date.now(); } @@ -278,8 +227,7 @@ export class RouteNavigationTelemetryService { return; } if (event.code === NavigationCancellationCode.Redirect) { - // A guard or resolver redirect restarts under a new id: carry the - // start time, so one journey stays one scenario under its final route. + // Restarts under a new id: carry the start so the journey stays one scenario. this.pendingRedirect = { startedAt: navigation.startedAt, redirectedFrom: navigation.redirectedFrom ?? navigation.route @@ -300,9 +248,8 @@ export class RouteNavigationTelemetryService { } if (event instanceof NavigationSkipped) { - // A redirect back to the current URL ends in NavigationSkipped instead - // of the NavigationStart the carried redirect waits for. Release it, and - // drop the click intent so it can't backdate an unrelated navigation. + // 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(); @@ -331,10 +278,7 @@ export class RouteNavigationTelemetryService { } } - /** - * Starts and settles the scenario in one go, backdated to the journey's - * start, once the final route template is known. - */ + /** Starts and settles the scenario at once, backdated to the journey's start. */ private record( navigation: TrackedNavigation, route: string, @@ -357,11 +301,7 @@ export class RouteNavigationTelemetryService { ); } - /** - * Consumes the click timestamp, if fresh enough. - * - * @returns epoch ms to backdate to: the click, or now - */ + /** The click time if fresh enough, else now; consumes the click. */ private navigationStartedAt(): number { const at = this.navigationIntentAt; this.navigationIntentAt = undefined; @@ -377,10 +317,8 @@ export class RouteNavigationTelemetryService { } /** - * Records the navigation once as `timeout` if it hasn't ended within - * {@link NAVIGATION_TIMEOUT_MS} of the journey's start; a late end event then - * finds nothing to record. Browser only, outside Angular, so it never delays - * app stability or server rendering. + * Records a hung navigation once as `timeout`; a late end event then finds nothing. Browser only + * and outside Angular, so it never delays app stability or server rendering. */ private armTimeout(navigationId: number, navigation: TrackedNavigation): void { if (!this.isBrowser) { 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 index 2c35d8c..cf81710 100644 --- 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 @@ -5,15 +5,11 @@ import type { UwtRumCredentialsProvider } from '@absaoss-cps/ngx-ui-watchtower/rum'; -/** - * Endpoint of the backend that returns the RUM app-monitor settings and - * short-lived AWS credentials. Adapt to the app's own broker. - */ +/** The app's broker: RUM app-monitor settings plus short-lived AWS credentials. */ const RUM_BROKER_URL = '/rum/init'; /** - * Shape expected from the backend broker. Adapt to the real response; `load()` - * checks every required field before using it. + * Expected answer — adapt to the real one: * * ```json * { @@ -38,10 +34,8 @@ function isRecord(value: unknown): value is Record { } /** - * Optional app-monitor settings a broker can send as JSON, each kept only with - * the right type. Functions, `RegExp`s and plugins (`fetchFunction`, - * `clientBuilder`, `pagesToInclude`/`pagesToExclude`, `eventPluginsToLoad`) - * can't travel as JSON — set them in code if the app needs them. + * 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', @@ -81,7 +75,6 @@ const isString = (v: unknown): v is string => typeof v === 'string'; const isStringOrBoolean = (v: unknown): v is string | boolean => typeof v === 'string' || typeof v === 'boolean'; -/** The optional settings in `config` that have the expected type; the rest are dropped. */ function optionalSettings(config: Record): Partial { const settings: Record = {}; const keep = (key: string, ok: boolean): void => { @@ -117,7 +110,6 @@ function optionalSettings(config: Record): Partial; } -/** Whether `value` is an object whose `keys` are all non-empty strings. */ function hasStrings( value: unknown, keys: readonly K[] @@ -129,30 +121,20 @@ function hasStrings( } /** - * Supplies CloudWatch RUM settings and credentials from this application's - * backend broker, so the browser never holds a long-lived AWS identity. - * - * Called at startup and again shortly before each credential expiry. + * 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 { - /** - * Fetches the current app-monitor settings and credentials. - * - * @returns the bootstrap payload, or `null` — RUM off for this session — - * when the broker disables RUM, is unreachable, or answers with anything - * missing or malformed - */ + /** `null` (RUM off for the session) unless the answer is complete, valid and unexpired. */ async load(): Promise { let response: Response; try { response = await fetch(RUM_BROKER_URL, { headers: { Accept: 'application/json' }, - // The response carries live, temporary AWS credentials. - cache: 'no-store' + cache: 'no-store' // live AWS credentials }); } catch { - // A network-level failure rejects before any response exists. return null; } @@ -167,8 +149,7 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { return null; } - // Validate, don't cast: a half-filled answer must turn RUM off, not start - // a client that fails every dispatch silently. + // Validate, don't cast: a half-filled answer would start a client that silently fails. if (!isRecord(body) || body['enabled'] !== true) { return null; } @@ -188,9 +169,8 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { applicationId: config.applicationId, region: config.region, applicationVersion: config.applicationVersion, - // Always, whatever the broker says. Page views are recorded under the route template by - // RouteNavigationTelemetryService; automatic ones would send the - // resolved path (/customers/42). + // Always: page views come from the route service, under the template — automatic ones + // would send the resolved path (/customers/42). disableAutoPageView: true }, credentials: { diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts deleted file mode 100644 index 5a4f28c..0000000 --- a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { Component, inject, Injectable } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { provideRouter, Router, UrlSegment } from '@angular/router'; -import { - provideUwtTelemetry, - provideUwtTelemetryDestination, - UwtTelemetrySink, - withScenarios -} from '@absaoss-cps/ngx-ui-watchtower'; -import { - 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 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: { paramMap: { get(k: string): string | null } }) => - 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: 'back-home', - component: Page, - canActivate: [() => inject(Router).parseUrl('/customers')] - }, - { - path: 'slow', - component: Page, - canActivate: [() => 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) } - ]), - provideUwtTelemetry( - { application: 'customer-portal-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('/back-home'); - expect(scenarios()).toEqual([ - expect.objectContaining({ status: 'abandoned', reason: 'same-url', route: '/back-home' }) - ]); - }); - - 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 a superseded navigation as abandoned, and the new one as success', async () => { - const first = router.navigateByUrl('/slow'); - await new Promise((r) => setTimeout(r)); - 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 nothing 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' }) - ]); - releaseHangGuard(true); - await pending; - expect(scenarios()).toHaveLength(1); - expect(pageViews).toEqual([]); - }); - - 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'); - await vi.advanceTimersByTimeAsync(60_000); - expect(scenarios()).toEqual([expect.objectContaining({ status: 'success' })]); - }); - }); -}); 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 index 4b1c9c8..4cdef38 100644 --- 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 @@ -30,51 +30,31 @@ import { import './telemetry.schema'; /** - * Records one page view per completed navigation, under the route template. - * - * Bind it only where AWS CloudWatch RUM is the destination, together with - * `disableAutoPageView: true` in the app-monitor config — automatic page - * views would report the resolved path (`/customers/42`): - * - * ```ts - * { - * provide: ROUTE_PAGE_VIEW_RECORDER, - * useFactory: () => { - * const rum = inject(UwtRumTelemetrySink); - * return (route: string) => rum.recordPageView(route); - * } - * } - * ``` + * 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' ); -/** Name shared by every route-navigation scenario. */ 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; -/** - * A navigation with no end event within this window — a guard, resolver or - * lazy chunk that never settles — is recorded as `timeout`. - */ +/** 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 in the template — a - * matcher has no `path` to report. - */ +/** Route `data` key naming a `matcher` route's segment — a matcher has no `path`. */ const TELEMETRY_PATH_DATA_KEY = 'telemetryPath'; /** - * Stable, low-cardinality causes, derived from the router's `code`. The - * router's `reason` text is never sent: it is empty in production builds and - * can contain URLs in development builds. + * 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', @@ -84,10 +64,7 @@ const CANCEL_REASON: Partial> = { [NavigationCancellationCode.Aborted]: 'aborted' }; -/** - * Cancellations that are an expected dead end of the journey (`incomplete`), - * not the user leaving it (`abandoned`). - */ +/** Expected dead ends (`incomplete`), not the user leaving (`abandoned`). */ const INCOMPLETE_CODES = new Set([ NavigationCancellationCode.GuardRejected, NavigationCancellationCode.NoDataFromResolver @@ -108,11 +85,7 @@ function causeOf( return (code === undefined ? undefined : causes[code]) ?? fallback; } -/** - * The matched route's template, from the `routeConfig` chain of the primary - * outlet: `/customers/42` → `/customers/:id`. Exact after `redirectTo` and lazy - * `loadChildren`, because the router has done the matching. - */ +/** Matched template from the primary outlet's `routeConfig` chain: `/customers/:id`. */ function routeTemplateOf(root: ActivatedRouteSnapshot): string { const parts: string[] = []; for ( @@ -134,17 +107,12 @@ function routeTemplateOf(root: ActivatedRouteSnapshot): string { return `/${parts.join('/')}`; } -/** - * A class-like error name (`ChunkLoadError`, `HttpErrorResponse`). Anything else - * — a thrown object can carry any `name` — could be free text and is dropped. - */ +/** A class-like name (`ChunkLoadError`); any other `name` could be free text. */ const SAFE_ERROR_NAME = /^[A-Z][A-Za-z0-9]{0,63}$/; /** - * A navigation error, reduced to what is safe to send. Router errors can quote - * the URL (no matching route), a lazy chunk's URL, route parameters or user - * data from a resolver — and path segments are not redacted. Only a class-like - * name (e.g. `ChunkLoadError`) and an HTTP status are kept. + * 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, @@ -160,17 +128,15 @@ function safeNavigationFailure( return { error: safe, statusCode: typeof status === 'number' ? status : undefined }; } -/** One router navigation being tracked. */ interface TrackedNavigation { - /** Epoch ms the journey started: the click, `NavigationStart`, or the first navigation of a redirect chain. */ + /** Epoch ms: the click, `NavigationStart`, or the start of a redirect chain. */ startedAt: number; - /** Template of the route a guard or resolver redirected away from. */ + /** Template a guard or resolver redirected away from. */ redirectedFrom?: string; - /** The matched route template, known from `RoutesRecognized` on. */ + /** Matched template, known from `RoutesRecognized`. */ route?: string; - /** Epoch ms of `RoutesRecognized`: guards, resolvers and lazy components run after it. */ + /** `RoutesRecognized` time — guards, resolvers and lazy components run after it. */ recognizedAt?: number; - /** Records the navigation as `timeout` if no end event arrives in time. */ timeoutHandle?: ReturnType; } @@ -178,15 +144,10 @@ interface TrackedNavigation { type RedirectCarry = Pick; /** - * Measures every router navigation as one `route-navigation` scenario. - * - * The scenario is recorded when the navigation ends, so `route` is always the - * final matched template — after `redirectTo`, lazy routes, and guard or - * resolver redirects — and backdated to the click or the first - * `NavigationStart`, so its duration covers the whole wait. - * - * Call {@link start} once, from the root component's constructor. Call - * {@link markNavigationIntent} from navigation-link click handlers. + * 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 — and backdated to the click + * or the first `NavigationStart`. Call {@link start} once from the root component; call + * {@link markNavigationIntent} from nav-link click handlers. */ @Injectable({ providedIn: 'root' }) export class RouteNavigationTelemetryService { @@ -197,22 +158,15 @@ export class RouteNavigationTelemetryService { private readonly zone = inject(NgZone); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - /** - * Keyed by navigation id, never a single "current" field: one navigation - * can supersede another, and a single field would attribute the wrong - * duration. - */ + /** By navigation id, never one "current" field: navigations supersede each other. */ private readonly navigations = new Map(); - /** A redirected navigation, continued by the next `NavigationStart`. */ private pendingRedirect?: RedirectCarry; - - /** When the user last did something expected to start a navigation. */ private navigationIntentAt?: number; private started = false; - /** Begins tracking router navigations. Safe to call more than once. */ + /** Safe to call more than once. */ start(): void { if (this.started) { return; @@ -229,12 +183,7 @@ export class RouteNavigationTelemetryService { }); } - /** - * Records that the user just did something expected to start a - * navigation. The router raises `NavigationStart` after event handling, - * guards and change detection; measuring from the click keeps that wait - * inside the recorded duration. - */ + /** `NavigationStart` comes after event handling and change detection; count that wait too. */ markNavigationIntent(): void { this.navigationIntentAt = Date.now(); } @@ -278,8 +227,7 @@ export class RouteNavigationTelemetryService { return; } if (event.code === NavigationCancellationCode.Redirect) { - // A guard or resolver redirect restarts under a new id: carry the - // start time, so one journey stays one scenario under its final route. + // Restarts under a new id: carry the start so the journey stays one scenario. this.pendingRedirect = { startedAt: navigation.startedAt, redirectedFrom: navigation.redirectedFrom ?? navigation.route @@ -300,9 +248,8 @@ export class RouteNavigationTelemetryService { } if (event instanceof NavigationSkipped) { - // A redirect back to the current URL ends in NavigationSkipped instead - // of the NavigationStart the carried redirect waits for. Release it, and - // drop the click intent so it can't backdate an unrelated navigation. + // 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(); @@ -331,10 +278,7 @@ export class RouteNavigationTelemetryService { } } - /** - * Starts and settles the scenario in one go, backdated to the journey's - * start, once the final route template is known. - */ + /** Starts and settles the scenario at once, backdated to the journey's start. */ private record( navigation: TrackedNavigation, route: string, @@ -357,11 +301,7 @@ export class RouteNavigationTelemetryService { ); } - /** - * Consumes the click timestamp, if fresh enough. - * - * @returns epoch ms to backdate to: the click, or now - */ + /** The click time if fresh enough, else now; consumes the click. */ private navigationStartedAt(): number { const at = this.navigationIntentAt; this.navigationIntentAt = undefined; @@ -377,10 +317,8 @@ export class RouteNavigationTelemetryService { } /** - * Records the navigation once as `timeout` if it hasn't ended within - * {@link NAVIGATION_TIMEOUT_MS} of the journey's start; a late end event then - * finds nothing to record. Browser only, outside Angular, so it never delays - * app stability or server rendering. + * Records a hung navigation once as `timeout`; a late end event then finds nothing. Browser only + * and outside Angular, so it never delays app stability or server rendering. */ private armTimeout(navigationId: number, navigation: TrackedNavigation): void { if (!this.isBrowser) { diff --git a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.spec.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.spec.ts deleted file mode 100644 index bc3769d..0000000 --- a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/rum-credentials.provider.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -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: '2030-01-01T00:00:00Z' - } -}; - -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('returns null for invalid JSON, an error status and a network failure', async () => { - answer('', 200); - await expect(load()).resolves.toBeNull(); - answer(valid, 503); - await expect(load()).resolves.toBeNull(); - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('offline'))); - await expect(load()).resolves.toBeNull(); - }); - - 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('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-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 index 2c35d8c..cf81710 100644 --- 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 @@ -5,15 +5,11 @@ import type { UwtRumCredentialsProvider } from '@absaoss-cps/ngx-ui-watchtower/rum'; -/** - * Endpoint of the backend that returns the RUM app-monitor settings and - * short-lived AWS credentials. Adapt to the app's own broker. - */ +/** The app's broker: RUM app-monitor settings plus short-lived AWS credentials. */ const RUM_BROKER_URL = '/rum/init'; /** - * Shape expected from the backend broker. Adapt to the real response; `load()` - * checks every required field before using it. + * Expected answer — adapt to the real one: * * ```json * { @@ -38,10 +34,8 @@ function isRecord(value: unknown): value is Record { } /** - * Optional app-monitor settings a broker can send as JSON, each kept only with - * the right type. Functions, `RegExp`s and plugins (`fetchFunction`, - * `clientBuilder`, `pagesToInclude`/`pagesToExclude`, `eventPluginsToLoad`) - * can't travel as JSON — set them in code if the app needs them. + * 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', @@ -81,7 +75,6 @@ const isString = (v: unknown): v is string => typeof v === 'string'; const isStringOrBoolean = (v: unknown): v is string | boolean => typeof v === 'string' || typeof v === 'boolean'; -/** The optional settings in `config` that have the expected type; the rest are dropped. */ function optionalSettings(config: Record): Partial { const settings: Record = {}; const keep = (key: string, ok: boolean): void => { @@ -117,7 +110,6 @@ function optionalSettings(config: Record): Partial; } -/** Whether `value` is an object whose `keys` are all non-empty strings. */ function hasStrings( value: unknown, keys: readonly K[] @@ -129,30 +121,20 @@ function hasStrings( } /** - * Supplies CloudWatch RUM settings and credentials from this application's - * backend broker, so the browser never holds a long-lived AWS identity. - * - * Called at startup and again shortly before each credential expiry. + * 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 { - /** - * Fetches the current app-monitor settings and credentials. - * - * @returns the bootstrap payload, or `null` — RUM off for this session — - * when the broker disables RUM, is unreachable, or answers with anything - * missing or malformed - */ + /** `null` (RUM off for the session) unless the answer is complete, valid and unexpired. */ async load(): Promise { let response: Response; try { response = await fetch(RUM_BROKER_URL, { headers: { Accept: 'application/json' }, - // The response carries live, temporary AWS credentials. - cache: 'no-store' + cache: 'no-store' // live AWS credentials }); } catch { - // A network-level failure rejects before any response exists. return null; } @@ -167,8 +149,7 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { return null; } - // Validate, don't cast: a half-filled answer must turn RUM off, not start - // a client that fails every dispatch silently. + // Validate, don't cast: a half-filled answer would start a client that silently fails. if (!isRecord(body) || body['enabled'] !== true) { return null; } @@ -188,9 +169,8 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { applicationId: config.applicationId, region: config.region, applicationVersion: config.applicationVersion, - // Always, whatever the broker says. Page views are recorded under the route template by - // RouteNavigationTelemetryService; automatic ones would send the - // resolved path (/customers/42). + // Always: page views come from the route service, under the template — automatic ones + // would send the resolved path (/customers/42). disableAutoPageView: true }, credentials: { diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index 07961c9..0d0e9ef 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -39,7 +39,8 @@ to the app's own log backend, never to the destination. | `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`. +`rum-credentials.provider.ts`, `log-api.provider.ts`, `route-navigation-telemetry.service.ts`, and the +specs for the last two (`*.spec.ts`). ## Workflow @@ -260,26 +261,11 @@ sink. If an existing RUM integration records page views (Step 5), keep exactly o 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. -What the asset gets right, and why — keep these if you change it: - -1. **Recorded when the navigation ends, backdated** to the click or the first `NavigationStart` — - a scenario's `route` can't change after it starts, and only the end knows the final template. - The template comes from the matched route config, never the URL. -2. **Keyed by navigation id**, never one "current" field — one navigation can supersede another. -3. **Guard and resolver redirects are one journey** under the final template, with - `redirectedFrom` (the source template) in metadata; `NavigationSkipped` releases a carried - redirect, or it leaks into an unrelated navigation. -4. **Statuses:** a guard returning `false` or a resolver with no data is `incomplete`; superseded, - aborted or skipped is `abandoned`; an error is `failure`; no end event within 30 s (a guard, - resolver or lazy chunk that never settles) is `timeout`, recorded once from a browser-only timer - outside Angular. A navigation superseded before its URL was recognized records nothing. -5. **No URL ever reaches telemetry.** Every navigation error is replaced by a generic one that - keeps only the error's name and an HTTP status — router, resolver and lazy-chunk errors can quote - URLs, parameters or user data. An unmatched URL fails as `(unrecognized)`. Reasons come from the - router's `code`; its `reason` text is never sent (empty in production, can contain URLs in - development). -6. **Click-intent backdating:** `markNavigationIntent()` from nav-link click handlers; a mark older - than 2 s is discarded. +What it records: one scenario per navigation under the final route template (redirects included), +backdated to the click (`markNavigationIntent()` from nav-link handlers); a guard returning `false` +is `incomplete`, supersession `abandoned`, an error `failure` (reduced to a class-like name and an +HTTP status — no URL), and no end within 30 s `timeout`. Keep that behaviour if you change the +asset; its comments say why each part is there. ### Step 7 · Tests @@ -299,13 +285,12 @@ The library ships no test doubles on purpose — the harness is a few lines. { provide: UWT_LOG_API_PROVIDER, useClass: UwtNoopLogApiProvider } ] ``` -- Add a spec for the route-navigation service with a **recording sink** (pattern in the API - reference, "Testing"), using the app's real route shapes: a successful navigation records - `success` with the route **template** (`/customers/:id`, never a resolved id — assert the id - appears nowhere in the payload), a guard rejection records `incomplete` with reason - `guard-rejected`, not `failure`, and, where the page-view recorder is bound, one page view per - completed navigation under the template. Check the test bites: - remove the `complete()` call and confirm it fails, then restore it. +- 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: + `doNotFake` instead of `toFake`), 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. diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts similarity index 99% rename from skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts rename to skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts index 5a4f28c..8942df4 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts @@ -110,7 +110,7 @@ describe('RouteNavigationTelemetryService', () => { { path: 'never-loads', loadChildren: () => new Promise(() => undefined) } ]), provideUwtTelemetry( - { application: 'customer-portal-test', environment: 'test', version: '0.0.0' }, + { application: 'my-app-test', environment: 'test', version: '0.0.0' }, withScenarios({ defaultTimeoutMs: 0 }) ), provideUwtTelemetryDestination(RecordingSink), diff --git a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts index 4b1c9c8..4cdef38 100644 --- a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts @@ -30,51 +30,31 @@ import { import './telemetry.schema'; /** - * Records one page view per completed navigation, under the route template. - * - * Bind it only where AWS CloudWatch RUM is the destination, together with - * `disableAutoPageView: true` in the app-monitor config — automatic page - * views would report the resolved path (`/customers/42`): - * - * ```ts - * { - * provide: ROUTE_PAGE_VIEW_RECORDER, - * useFactory: () => { - * const rum = inject(UwtRumTelemetrySink); - * return (route: string) => rum.recordPageView(route); - * } - * } - * ``` + * 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' ); -/** Name shared by every route-navigation scenario. */ 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; -/** - * A navigation with no end event within this window — a guard, resolver or - * lazy chunk that never settles — is recorded as `timeout`. - */ +/** 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 in the template — a - * matcher has no `path` to report. - */ +/** Route `data` key naming a `matcher` route's segment — a matcher has no `path`. */ const TELEMETRY_PATH_DATA_KEY = 'telemetryPath'; /** - * Stable, low-cardinality causes, derived from the router's `code`. The - * router's `reason` text is never sent: it is empty in production builds and - * can contain URLs in development builds. + * 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', @@ -84,10 +64,7 @@ const CANCEL_REASON: Partial> = { [NavigationCancellationCode.Aborted]: 'aborted' }; -/** - * Cancellations that are an expected dead end of the journey (`incomplete`), - * not the user leaving it (`abandoned`). - */ +/** Expected dead ends (`incomplete`), not the user leaving (`abandoned`). */ const INCOMPLETE_CODES = new Set([ NavigationCancellationCode.GuardRejected, NavigationCancellationCode.NoDataFromResolver @@ -108,11 +85,7 @@ function causeOf( return (code === undefined ? undefined : causes[code]) ?? fallback; } -/** - * The matched route's template, from the `routeConfig` chain of the primary - * outlet: `/customers/42` → `/customers/:id`. Exact after `redirectTo` and lazy - * `loadChildren`, because the router has done the matching. - */ +/** Matched template from the primary outlet's `routeConfig` chain: `/customers/:id`. */ function routeTemplateOf(root: ActivatedRouteSnapshot): string { const parts: string[] = []; for ( @@ -134,17 +107,12 @@ function routeTemplateOf(root: ActivatedRouteSnapshot): string { return `/${parts.join('/')}`; } -/** - * A class-like error name (`ChunkLoadError`, `HttpErrorResponse`). Anything else - * — a thrown object can carry any `name` — could be free text and is dropped. - */ +/** A class-like name (`ChunkLoadError`); any other `name` could be free text. */ const SAFE_ERROR_NAME = /^[A-Z][A-Za-z0-9]{0,63}$/; /** - * A navigation error, reduced to what is safe to send. Router errors can quote - * the URL (no matching route), a lazy chunk's URL, route parameters or user - * data from a resolver — and path segments are not redacted. Only a class-like - * name (e.g. `ChunkLoadError`) and an HTTP status are kept. + * 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, @@ -160,17 +128,15 @@ function safeNavigationFailure( return { error: safe, statusCode: typeof status === 'number' ? status : undefined }; } -/** One router navigation being tracked. */ interface TrackedNavigation { - /** Epoch ms the journey started: the click, `NavigationStart`, or the first navigation of a redirect chain. */ + /** Epoch ms: the click, `NavigationStart`, or the start of a redirect chain. */ startedAt: number; - /** Template of the route a guard or resolver redirected away from. */ + /** Template a guard or resolver redirected away from. */ redirectedFrom?: string; - /** The matched route template, known from `RoutesRecognized` on. */ + /** Matched template, known from `RoutesRecognized`. */ route?: string; - /** Epoch ms of `RoutesRecognized`: guards, resolvers and lazy components run after it. */ + /** `RoutesRecognized` time — guards, resolvers and lazy components run after it. */ recognizedAt?: number; - /** Records the navigation as `timeout` if no end event arrives in time. */ timeoutHandle?: ReturnType; } @@ -178,15 +144,10 @@ interface TrackedNavigation { type RedirectCarry = Pick; /** - * Measures every router navigation as one `route-navigation` scenario. - * - * The scenario is recorded when the navigation ends, so `route` is always the - * final matched template — after `redirectTo`, lazy routes, and guard or - * resolver redirects — and backdated to the click or the first - * `NavigationStart`, so its duration covers the whole wait. - * - * Call {@link start} once, from the root component's constructor. Call - * {@link markNavigationIntent} from navigation-link click handlers. + * 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 — and backdated to the click + * or the first `NavigationStart`. Call {@link start} once from the root component; call + * {@link markNavigationIntent} from nav-link click handlers. */ @Injectable({ providedIn: 'root' }) export class RouteNavigationTelemetryService { @@ -197,22 +158,15 @@ export class RouteNavigationTelemetryService { private readonly zone = inject(NgZone); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - /** - * Keyed by navigation id, never a single "current" field: one navigation - * can supersede another, and a single field would attribute the wrong - * duration. - */ + /** By navigation id, never one "current" field: navigations supersede each other. */ private readonly navigations = new Map(); - /** A redirected navigation, continued by the next `NavigationStart`. */ private pendingRedirect?: RedirectCarry; - - /** When the user last did something expected to start a navigation. */ private navigationIntentAt?: number; private started = false; - /** Begins tracking router navigations. Safe to call more than once. */ + /** Safe to call more than once. */ start(): void { if (this.started) { return; @@ -229,12 +183,7 @@ export class RouteNavigationTelemetryService { }); } - /** - * Records that the user just did something expected to start a - * navigation. The router raises `NavigationStart` after event handling, - * guards and change detection; measuring from the click keeps that wait - * inside the recorded duration. - */ + /** `NavigationStart` comes after event handling and change detection; count that wait too. */ markNavigationIntent(): void { this.navigationIntentAt = Date.now(); } @@ -278,8 +227,7 @@ export class RouteNavigationTelemetryService { return; } if (event.code === NavigationCancellationCode.Redirect) { - // A guard or resolver redirect restarts under a new id: carry the - // start time, so one journey stays one scenario under its final route. + // Restarts under a new id: carry the start so the journey stays one scenario. this.pendingRedirect = { startedAt: navigation.startedAt, redirectedFrom: navigation.redirectedFrom ?? navigation.route @@ -300,9 +248,8 @@ export class RouteNavigationTelemetryService { } if (event instanceof NavigationSkipped) { - // A redirect back to the current URL ends in NavigationSkipped instead - // of the NavigationStart the carried redirect waits for. Release it, and - // drop the click intent so it can't backdate an unrelated navigation. + // 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(); @@ -331,10 +278,7 @@ export class RouteNavigationTelemetryService { } } - /** - * Starts and settles the scenario in one go, backdated to the journey's - * start, once the final route template is known. - */ + /** Starts and settles the scenario at once, backdated to the journey's start. */ private record( navigation: TrackedNavigation, route: string, @@ -357,11 +301,7 @@ export class RouteNavigationTelemetryService { ); } - /** - * Consumes the click timestamp, if fresh enough. - * - * @returns epoch ms to backdate to: the click, or now - */ + /** The click time if fresh enough, else now; consumes the click. */ private navigationStartedAt(): number { const at = this.navigationIntentAt; this.navigationIntentAt = undefined; @@ -377,10 +317,8 @@ export class RouteNavigationTelemetryService { } /** - * Records the navigation once as `timeout` if it hasn't ended within - * {@link NAVIGATION_TIMEOUT_MS} of the journey's start; a late end event then - * finds nothing to record. Browser only, outside Angular, so it never delays - * app stability or server rendering. + * Records a hung navigation once as `timeout`; a late end event then finds nothing. Browser only + * and outside Angular, so it never delays app stability or server rendering. */ private armTimeout(navigationId: number, navigation: TrackedNavigation): void { if (!this.isBrowser) { diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.spec.ts b/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts similarity index 100% rename from skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/rum-credentials.provider.spec.ts rename to skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts diff --git a/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts index 2c35d8c..cf81710 100644 --- a/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts +++ b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts @@ -5,15 +5,11 @@ import type { UwtRumCredentialsProvider } from '@absaoss-cps/ngx-ui-watchtower/rum'; -/** - * Endpoint of the backend that returns the RUM app-monitor settings and - * short-lived AWS credentials. Adapt to the app's own broker. - */ +/** The app's broker: RUM app-monitor settings plus short-lived AWS credentials. */ const RUM_BROKER_URL = '/rum/init'; /** - * Shape expected from the backend broker. Adapt to the real response; `load()` - * checks every required field before using it. + * Expected answer — adapt to the real one: * * ```json * { @@ -38,10 +34,8 @@ function isRecord(value: unknown): value is Record { } /** - * Optional app-monitor settings a broker can send as JSON, each kept only with - * the right type. Functions, `RegExp`s and plugins (`fetchFunction`, - * `clientBuilder`, `pagesToInclude`/`pagesToExclude`, `eventPluginsToLoad`) - * can't travel as JSON — set them in code if the app needs them. + * 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', @@ -81,7 +75,6 @@ const isString = (v: unknown): v is string => typeof v === 'string'; const isStringOrBoolean = (v: unknown): v is string | boolean => typeof v === 'string' || typeof v === 'boolean'; -/** The optional settings in `config` that have the expected type; the rest are dropped. */ function optionalSettings(config: Record): Partial { const settings: Record = {}; const keep = (key: string, ok: boolean): void => { @@ -117,7 +110,6 @@ function optionalSettings(config: Record): Partial; } -/** Whether `value` is an object whose `keys` are all non-empty strings. */ function hasStrings( value: unknown, keys: readonly K[] @@ -129,30 +121,20 @@ function hasStrings( } /** - * Supplies CloudWatch RUM settings and credentials from this application's - * backend broker, so the browser never holds a long-lived AWS identity. - * - * Called at startup and again shortly before each credential expiry. + * 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 { - /** - * Fetches the current app-monitor settings and credentials. - * - * @returns the bootstrap payload, or `null` — RUM off for this session — - * when the broker disables RUM, is unreachable, or answers with anything - * missing or malformed - */ + /** `null` (RUM off for the session) unless the answer is complete, valid and unexpired. */ async load(): Promise { let response: Response; try { response = await fetch(RUM_BROKER_URL, { headers: { Accept: 'application/json' }, - // The response carries live, temporary AWS credentials. - cache: 'no-store' + cache: 'no-store' // live AWS credentials }); } catch { - // A network-level failure rejects before any response exists. return null; } @@ -167,8 +149,7 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { return null; } - // Validate, don't cast: a half-filled answer must turn RUM off, not start - // a client that fails every dispatch silently. + // Validate, don't cast: a half-filled answer would start a client that silently fails. if (!isRecord(body) || body['enabled'] !== true) { return null; } @@ -188,9 +169,8 @@ export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { applicationId: config.applicationId, region: config.region, applicationVersion: config.applicationVersion, - // Always, whatever the broker says. Page views are recorded under the route template by - // RouteNavigationTelemetryService; automatic ones would send the - // resolved path (/customers/42). + // Always: page views come from the route service, under the template — automatic ones + // would send the resolved path (/customers/42). disableAutoPageView: true }, credentials: { diff --git a/skills/ui-watchtower-setup/evals/evals.json b/skills/ui-watchtower-setup/evals/evals.json index 5f25f26..0ac90d6 100644 --- a/skills/ui-watchtower-setup/evals/evals.json +++ b/skills/ui-watchtower-setup/evals/evals.json @@ -189,19 +189,8 @@ "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/angular.json", - "evals/files/future-angular/tsconfig.json", - "evals/files/future-angular/tsconfig.app.json", - "evals/files/future-angular/tsconfig.spec.json", - "evals/files/future-angular/src/index.html", - "evals/files/future-angular/src/styles.css", "evals/files/future-angular/src/main.ts", - "evals/files/future-angular/src/app/app.config.ts", - "evals/files/future-angular/src/app/app.routes.ts", - "evals/files/future-angular/src/app/app.ts", - "evals/files/future-angular/src/app/app.spec.ts", - "evals/files/future-angular/src/app/customers/customers.ts", - "evals/files/future-angular/src/app/customers/customer-detail.ts" + "evals/files/future-angular/src/app/app.config.ts" ], "expectations": [ "Reads the app's @angular/core version (^24) from package.json", diff --git a/skills/ui-watchtower-setup/evals/files/future-angular/angular.json b/skills/ui-watchtower-setup/evals/files/future-angular/angular.json deleted file mode 100644 index c269d3b..0000000 --- a/skills/ui-watchtower-setup/evals/files/future-angular/angular.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "$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", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], - "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/future-angular/src/app/app.routes.ts b/skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.routes.ts deleted file mode 100644 index 097aa25..0000000 --- a/skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.routes.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/future-angular/src/app/app.spec.ts b/skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.spec.ts deleted file mode 100644 index 8e82882..0000000 --- a/skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -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/future-angular/src/app/app.ts b/skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.ts deleted file mode 100644 index 241dab5..0000000 --- a/skills/ui-watchtower-setup/evals/files/future-angular/src/app/app.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/future-angular/src/app/customers/customer-detail.ts b/skills/ui-watchtower-setup/evals/files/future-angular/src/app/customers/customer-detail.ts deleted file mode 100644 index 8855ad4..0000000 --- a/skills/ui-watchtower-setup/evals/files/future-angular/src/app/customers/customer-detail.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/future-angular/src/app/customers/customers.ts b/skills/ui-watchtower-setup/evals/files/future-angular/src/app/customers/customers.ts deleted file mode 100644 index b2b9cd7..0000000 --- a/skills/ui-watchtower-setup/evals/files/future-angular/src/app/customers/customers.ts +++ /dev/null @@ -1,36 +0,0 @@ -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/future-angular/src/index.html b/skills/ui-watchtower-setup/evals/files/future-angular/src/index.html deleted file mode 100644 index 1e743a7..0000000 --- a/skills/ui-watchtower-setup/evals/files/future-angular/src/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - Customer portal - - - - - - - diff --git a/skills/ui-watchtower-setup/evals/files/future-angular/src/styles.css b/skills/ui-watchtower-setup/evals/files/future-angular/src/styles.css deleted file mode 100644 index 4b438c4..0000000 --- a/skills/ui-watchtower-setup/evals/files/future-angular/src/styles.css +++ /dev/null @@ -1 +0,0 @@ -/* Global styles */ diff --git a/skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.app.json b/skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.app.json deleted file mode 100644 index d5d9680..0000000 --- a/skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.app.json +++ /dev/null @@ -1,18 +0,0 @@ -/* 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/future-angular/tsconfig.json b/skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.json deleted file mode 100644 index d2fbb9c..0000000 --- a/skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -/* 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/future-angular/tsconfig.spec.json b/skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.spec.json deleted file mode 100644 index 4c1202f..0000000 --- a/skills/ui-watchtower-setup/evals/files/future-angular/tsconfig.spec.json +++ /dev/null @@ -1,16 +0,0 @@ -/* 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" - ] -} From 8ec5c5b40f8b4038166587eb6d44b73effef9800 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 17:14:12 +0200 Subject: [PATCH 10/25] address more findings --- .../evals/files/wired-angular/angular.json | 6 ---- .../files/wired-angular/src/app/app.config.ts | 7 +++- .../evals/files/wired-angular/src/app/app.ts | 4 --- .../route-navigation-telemetry.service.ts | 15 ++++++-- .../app/telemetry/rum-credentials.provider.ts | 12 ++++--- .../wired-instrumented-angular/angular.json | 6 ---- .../src/app/app.config.ts | 7 +++- .../wired-instrumented-angular/src/app/app.ts | 4 --- .../route-navigation-telemetry.service.ts | 15 ++++++-- .../app/telemetry/rum-credentials.provider.ts | 12 ++++--- skills/ui-watchtower-setup/SKILL.md | 6 ++-- ...route-navigation-telemetry.service.spec.ts | 35 +++++++++++++++++-- .../route-navigation-telemetry.service.ts | 15 ++++++-- .../assets/rum-credentials.provider.spec.ts | 11 ++++++ .../assets/rum-credentials.provider.ts | 12 ++++--- skills/ui-watchtower-setup/evals/evals.json | 4 +-- .../partially-wired-angular/angular.json | 6 ---- .../files/standalone-angular/angular.json | 6 ---- .../references/micro-frontends.md | 29 ++++++++------- 19 files changed, 141 insertions(+), 71 deletions(-) diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/angular.json b/skills/ui-watchtower-integration/evals/files/wired-angular/angular.json index 4a53f26..28790e8 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-angular/angular.json +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/angular.json @@ -21,12 +21,6 @@ "allowedCommonJsDependencies": [ "shimmer" ], - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], "styles": [ "src/styles.css" ], 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 index 0301ecf..5054e05 100644 --- 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 @@ -8,7 +8,10 @@ import { provideUwtTelemetryRumSink } from '@absaoss-cps/ngx-ui-watchtower/rum'; import { routes } from './app.routes'; -import { ROUTE_PAGE_VIEW_RECORDER } from './telemetry/route-navigation-telemetry.service'; +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 @@ -19,6 +22,8 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), + // 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. 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 index 5a215f4..ab99d7a 100644 --- 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 @@ -13,10 +13,6 @@ import { RouteNavigationTelemetryService } from './telemetry/route-navigation-te export class App { private readonly routeTelemetry = inject(RouteNavigationTelemetryService); - constructor() { - this.routeTelemetry.start(); - } - /** 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/telemetry/route-navigation-telemetry.service.ts b/skills/ui-watchtower-integration/evals/files/wired-angular/src/app/telemetry/route-navigation-telemetry.service.ts index 4cdef38..0268cc7 100644 --- 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 @@ -1,11 +1,13 @@ import { isPlatformBrowser } from '@angular/common'; import { DestroyRef, + EnvironmentProviders, inject, Injectable, InjectionToken, NgZone, - PLATFORM_ID + PLATFORM_ID, + provideEnvironmentInitializer } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { @@ -146,7 +148,7 @@ 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 — and backdated to the click - * or the first `NavigationStart`. Call {@link start} once from the root component; call + * or the first `NavigationStart`. Start it with {@link provideRouteNavigationTelemetry}; call * {@link markNavigationIntent} from nav-link click handlers. */ @Injectable({ providedIn: 'root' }) @@ -344,3 +346,12 @@ export class RouteNavigationTelemetryService { 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 index cf81710..5e63de4 100644 --- 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 @@ -74,6 +74,13 @@ const isPrimitive = (v: unknown): v is string | number | boolean => 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 = {}; @@ -99,10 +106,7 @@ function optionalSettings(config: Record): Partial isString(t) || Array.isArray(t)) - ); + keep('telemetries', Array.isArray(telemetries) && telemetries.every(isTelemetry)); const compression = config['compressionStrategy']; if (isRecord(compression) && typeof compression['enabled'] === 'boolean') { settings['compressionStrategy'] = { enabled: compression['enabled'] }; 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 index 4a53f26..28790e8 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/angular.json +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/angular.json @@ -21,12 +21,6 @@ "allowedCommonJsDependencies": [ "shimmer" ], - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], "styles": [ "src/styles.css" ], 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 index 0301ecf..5054e05 100644 --- 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 @@ -8,7 +8,10 @@ import { provideUwtTelemetryRumSink } from '@absaoss-cps/ngx-ui-watchtower/rum'; import { routes } from './app.routes'; -import { ROUTE_PAGE_VIEW_RECORDER } from './telemetry/route-navigation-telemetry.service'; +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 @@ -19,6 +22,8 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), + // 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. 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 index 5a215f4..ab99d7a 100644 --- 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 @@ -13,10 +13,6 @@ import { RouteNavigationTelemetryService } from './telemetry/route-navigation-te export class App { private readonly routeTelemetry = inject(RouteNavigationTelemetryService); - constructor() { - this.routeTelemetry.start(); - } - /** 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/telemetry/route-navigation-telemetry.service.ts b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/src/app/telemetry/route-navigation-telemetry.service.ts index 4cdef38..0268cc7 100644 --- 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 @@ -1,11 +1,13 @@ import { isPlatformBrowser } from '@angular/common'; import { DestroyRef, + EnvironmentProviders, inject, Injectable, InjectionToken, NgZone, - PLATFORM_ID + PLATFORM_ID, + provideEnvironmentInitializer } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { @@ -146,7 +148,7 @@ 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 — and backdated to the click - * or the first `NavigationStart`. Call {@link start} once from the root component; call + * or the first `NavigationStart`. Start it with {@link provideRouteNavigationTelemetry}; call * {@link markNavigationIntent} from nav-link click handlers. */ @Injectable({ providedIn: 'root' }) @@ -344,3 +346,12 @@ export class RouteNavigationTelemetryService { 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 index cf81710..5e63de4 100644 --- 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 @@ -74,6 +74,13 @@ const isPrimitive = (v: unknown): v is string | number | boolean => 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 = {}; @@ -99,10 +106,7 @@ function optionalSettings(config: Record): Partial isString(t) || Array.isArray(t)) - ); + keep('telemetries', Array.isArray(telemetries) && telemetries.every(isTelemetry)); const compression = config['compressionStrategy']; if (isRecord(compression) && typeof compression['enabled'] === 'boolean') { settings['compressionStrategy'] = { enabled: compression['enabled'] }; diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index 0d0e9ef..f81a57a 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -229,8 +229,10 @@ migration is blocked — tell the owners of the affected dashboards. ### Step 6 · Proof — route-navigation tracking One real scenario proves the whole pipeline (providers, destination, vocabulary, tests) and is -useful in every app. Copy `assets/route-navigation-telemetry.service.ts` next to the schema and call -its `start()` once from the root component's constructor (or from an app initializer). It works +useful in every app. Copy `assets/route-navigation-telemetry.service.ts` next to the schema and add +`provideRouteNavigationTelemetry()` next to `provideRouter(...)` — not a `start()` call in the root +component, which misses a blocking initial navigation (`withEnabledBlockingInitialNavigation()`, +common with SSR). Nav-link click handlers may call `markNavigationIntent()`. It works unchanged for static, `:param`, lazy (`loadChildren`) and redirected routes. Only a `matcher` route needs one thing: a `data: { telemetryPath: 'files/:path' }` entry naming its segment (otherwise it reports `(matcher)`). 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 index 8942df4..92a0b05 100644 --- a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts @@ -1,6 +1,13 @@ -import { Component, inject, Injectable } from '@angular/core'; +import { Component, inject, Injectable, provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { provideRouter, Router, UrlSegment } from '@angular/router'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { + provideRouter, + Router, + RouterOutlet, + UrlSegment, + withEnabledBlockingInitialNavigation +} from '@angular/router'; import { provideUwtTelemetry, provideUwtTelemetryDestination, @@ -8,6 +15,7 @@ import { withScenarios } from '@absaoss-cps/ngx-ui-watchtower'; import { + provideRouteNavigationTelemetry, RouteNavigationTelemetryService, ROUTE_PAGE_VIEW_RECORDER } from './route-navigation-telemetry.service'; @@ -322,4 +330,27 @@ describe('RouteNavigationTelemetryService', () => { expect(scenarios()).toEqual([expect.objectContaining({ status: 'success' })]); }); }); + + 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 index 4cdef38..0268cc7 100644 --- a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts @@ -1,11 +1,13 @@ import { isPlatformBrowser } from '@angular/common'; import { DestroyRef, + EnvironmentProviders, inject, Injectable, InjectionToken, NgZone, - PLATFORM_ID + PLATFORM_ID, + provideEnvironmentInitializer } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { @@ -146,7 +148,7 @@ 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 — and backdated to the click - * or the first `NavigationStart`. Call {@link start} once from the root component; call + * or the first `NavigationStart`. Start it with {@link provideRouteNavigationTelemetry}; call * {@link markNavigationIntent} from nav-link click handlers. */ @Injectable({ providedIn: 'root' }) @@ -344,3 +346,12 @@ export class RouteNavigationTelemetryService { 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 index bc3769d..de436a8 100644 --- a/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts +++ b/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts @@ -93,6 +93,17 @@ describe('AppRumCredentialsProvider', () => { 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 index cf81710..5e63de4 100644 --- a/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts +++ b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts @@ -74,6 +74,13 @@ const isPrimitive = (v: unknown): v is string | number | boolean => 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 = {}; @@ -99,10 +106,7 @@ function optionalSettings(config: Record): Partial isString(t) || Array.isArray(t)) - ); + keep('telemetries', Array.isArray(telemetries) && telemetries.every(isTelemetry)); const compression = config['compressionStrategy']; if (isRecord(compression) && typeof compression['enabled'] === 'boolean') { settings['compressionStrategy'] = { enabled: compression['enabled'] }; diff --git a/skills/ui-watchtower-setup/evals/evals.json b/skills/ui-watchtower-setup/evals/evals.json index 0ac90d6..d8f01d5 100644 --- a/skills/ui-watchtower-setup/evals/evals.json +++ b/skills/ui-watchtower-setup/evals/evals.json @@ -30,7 +30,7 @@ "Binds UWT_RUM_CREDENTIALS_PROVIDER to a credentials provider fetching /rum/init that returns null on any failure", "Does NOT inject UwtLoggerService, pass withLogging, or declare UwtLoggerNames (no log backend)", "Creates telemetry.schema.ts augmenting UwtScenarioNames and UwtScenarioSteps, and side-effect-imports it where the providers are configured", - "Adds route-navigation tracking (the asset service, started from the root component) as the single proof scenario, with a recording-sink test, and does not instrument other journeys or BI events", + "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", "Adds telemetry providers to app.spec.ts so it does not fail with NG0201", @@ -76,7 +76,7 @@ "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, returning undefined", + "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" 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 index c269d3b..bbd3c48 100644 --- a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/angular.json +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/angular.json @@ -18,12 +18,6 @@ "options": { "browser": "src/main.ts", "tsConfig": "tsconfig.app.json", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], "styles": [ "src/styles.css" ], diff --git a/skills/ui-watchtower-setup/evals/files/standalone-angular/angular.json b/skills/ui-watchtower-setup/evals/files/standalone-angular/angular.json index c269d3b..bbd3c48 100644 --- a/skills/ui-watchtower-setup/evals/files/standalone-angular/angular.json +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/angular.json @@ -18,12 +18,6 @@ "options": { "browser": "src/main.ts", "tsConfig": "tsconfig.app.json", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], "styles": [ "src/styles.css" ], diff --git a/skills/ui-watchtower-setup/references/micro-frontends.md b/skills/ui-watchtower-setup/references/micro-frontends.md index af3dafd..8f28e74 100644 --- a/skills/ui-watchtower-setup/references/micro-frontends.md +++ b/skills/ui-watchtower-setup/references/micro-frontends.md @@ -45,7 +45,8 @@ function newChannelId(): string { } if (typeof c?.getRandomValues === 'function') { const bytes = c.getRandomValues(new Uint8Array(16)); - return `ngx-ui-watchtower-${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`; + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return `ngx-ui-watchtower-${hex}`; } return `ngx-ui-watchtower-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; } @@ -104,19 +105,21 @@ declare global { } /** - * The shell's channel. `undefined` (no shell, server rendering, cross-origin `top`) makes the sink - * fall back to the library's default channel `'ngx-ui-watchtower'` — with no host listening there, - * nothing ships and nothing fails. + * 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 | undefined { - if (typeof window === 'undefined') { - return undefined; - } +function shellChannelName(): string { try { - return window.top?.__uwtTelemetryChannel; + const shell = typeof window === 'undefined' ? undefined : window.top?.__uwtTelemetryChannel; + if (shell) { + return shell; + } } catch { - return undefined; + // cross-origin `top` } + const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + return `ngx-ui-watchtower-unhosted-${suffix}`; } providers: [ @@ -145,9 +148,9 @@ Inside a fragment: new session). The shell owns user identity. - Paint observation doesn't work (a hidden iframe never paints) — settle with `complete()`. - A fragment with no shell, **or on a wrong channel**, behaves identically: everything runs, nothing - ships, no error. An unresolved channel is not a no-op by itself — it falls back to the default - channel, so if some shell *is* hosting on the default channel (one that never adopted per-tab ids), - the fragment's events go there. Verify the channel deliberately (below). + ships, no error. Never let the channel resolve to `undefined` — that selects the library's shared + default channel, where a shell that never adopted per-tab ids would receive the fragment's events. + Verify the channel deliberately (below). - Correlation across realms is a string: forward `scenario.id` and start a child scenario with `parentScenarioId`. From c524474c15edcba3a8ded4bfa93b73e5742751a3 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 17:30:31 +0200 Subject: [PATCH 11/25] update rum-migration --- skills/ui-watchtower-setup/references/rum-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/ui-watchtower-setup/references/rum-migration.md b/skills/ui-watchtower-setup/references/rum-migration.md index 0f0d7fa..8579461 100644 --- a/skills/ui-watchtower-setup/references/rum-migration.md +++ b/skills/ui-watchtower-setup/references/rum-migration.md @@ -37,7 +37,7 @@ the affected dashboards. Dashboard parity never outranks personal data. |--------------------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------| | `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 — you own the shape. Use sparingly and comment why | +| `sink.record(eventType, payload)` (sink injected as a field) | Byte-exact payload parity is required | Raw passthrough — you own the shape *and* its privacy: the library's redaction doesn't run here. Normalize free text first (an error becomes its name or a code — never its message), per the privacy rule in §1. Use sparingly and comment why | 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. From 05c0aea7ff28674c8c8c965da6b0c2f6a67ee3e2 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 17:56:16 +0200 Subject: [PATCH 12/25] apply copilot suggestions --- skills/ui-watchtower-setup/SKILL.md | 8 +++++++- .../route-navigation-telemetry.service.spec.ts | 12 ++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index f81a57a..ded47f4 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -94,7 +94,8 @@ Read before writing anything — extend the app, don't restructure it. 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. -8. **Router** — `provideRouter` / `RouterModule.forRoot`; note any `matcher` routes (Step 6). +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). @@ -263,6 +264,11 @@ sink. If an existing RUM integration records page views (Step 5), keep exactly o 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** (a single-screen app): skip this step and the page-view binding, and remove +`disableAutoPageView: true` from the credentials provider — with no route changes, RUM's automatic +page view (one per page load) is the right one. Keep personal data out of that URL. The proof is +then the first scenario `ui-watchtower-integration` adds. + What it records: one scenario per navigation under the final route template (redirects included), backdated to the click (`markNavigationIntent()` from nav-link handlers); a guard returning `false` is `incomplete`, supersession `abandoned`, an error `failure` (reduced to a class-like name and an 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 index 92a0b05..ac8b305 100644 --- a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts @@ -39,6 +39,7 @@ class RecordingSink extends UwtTelemetrySink { class Page {} let releaseSlowGuard: (value: boolean) => void = () => undefined; +let slowGuardEntered: () => void = () => undefined; let releaseHangGuard: (value: boolean) => void = () => undefined; function filesMatcher(segments: UrlSegment[]) { @@ -107,7 +108,12 @@ describe('RouteNavigationTelemetryService', () => { { path: 'slow', component: Page, - canActivate: [() => new Promise((resolve) => (releaseSlowGuard = resolve))] + canActivate: [ + () => { + slowGuardEntered(); + return new Promise((resolve) => (releaseSlowGuard = resolve)); + } + ] }, { matcher: filesMatcher, component: Page, data: { telemetryPath: 'files/:path' } }, { @@ -258,8 +264,10 @@ describe('RouteNavigationTelemetryService', () => { }); 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 new Promise((r) => setTimeout(r)); + await entered; await router.navigateByUrl('/customers'); releaseSlowGuard(true); await first; From 363c4951f903c67e24ff5b1bf8bd2c0925a09bbd Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 18:10:08 +0200 Subject: [PATCH 13/25] cover more edge cases --- .../references/instrumentation-patterns.md | 13 ++++++++---- skills/ui-watchtower-setup/SKILL.md | 12 ++++++----- ...route-navigation-telemetry.service.spec.ts | 21 ++++++++++++++++++- .../references/micro-frontends.md | 15 ++++++++++--- 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/skills/ui-watchtower-integration/references/instrumentation-patterns.md b/skills/ui-watchtower-integration/references/instrumentation-patterns.md index 4da783e..a167770 100644 --- a/skills/ui-watchtower-integration/references/instrumentation-patterns.md +++ b/skills/ui-watchtower-integration/references/instrumentation-patterns.md @@ -70,12 +70,17 @@ async load(): Promise { } ``` -**Observable** — `traceScenario` settles on completion, error or early unsubscribe: +**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()`: ```ts -this.api.customers().pipe( - traceScenario(scenario, { outcome: (rows) => ({ metadata: { count: rows.length } }) }) -); +loadCustomers(): Observable { + const scenario = this.scenarios.start({ name: 'load-customers', feature: 'customers' }); + return this.api.customers().pipe( + traceScenario(scenario, { outcome: (rows) => ({ metadata: { count: rows.length } }) }) + ); +} ``` **Backdating** — the journey starts at the click, not when the async handler runs: diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index ded47f4..761aa8a 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -271,8 +271,9 @@ then the first scenario `ui-watchtower-integration` adds. What it records: one scenario per navigation under the final route template (redirects included), backdated to the click (`markNavigationIntent()` from nav-link handlers); a guard returning `false` -is `incomplete`, supersession `abandoned`, an error `failure` (reduced to a class-like name and an -HTTP status — no URL), and no end within 30 s `timeout`. Keep that behaviour if you change the +is `incomplete`, supersession `abandoned` (one superseded before its URL was recognized records +nothing — it has no template and would only spend the event budget), an error `failure` (reduced to +a class-like name and an HTTP status — no URL), and no end within 30 s `timeout`. Keep that behaviour if you change the asset; its comments say why each part is there. ### Step 7 · Tests @@ -352,9 +353,10 @@ a shell, also hand over the fragment contract from `references/micro-frontends.m alone. - **Telemetry never throws into the app** — every entry point fails open. Don't wrap calls in try/catch. -- **Redaction cannot be fully disabled**, and URL **path** segments are not scrubbed — only query - strings and fragments. Keep personal data out of URLs, or enable `withRedaction({ scanValuePatterns: - ['email'] })`. +- **Redaction cannot be fully disabled, and never scrubs URL path segments** — only query strings and + fragments. `withRedaction({ scanValuePatterns: ['email'] })` scrubs matching *values* in metadata + and messages; it does nothing for a path. Keep personal data out of URLs (redesign such routes), + and report route templates, never resolved paths. ## Out of scope 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 index ac8b305..13e2b50 100644 --- a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts @@ -40,6 +40,8 @@ class Page {} let releaseSlowGuard: (value: boolean) => void = () => undefined; let slowGuardEntered: () => void = () => undefined; +let lazyLoadEntered: () => void = () => undefined; +let releaseLazyLoad: (routes: unknown[]) => void = () => undefined; let releaseHangGuard: (value: boolean) => void = () => undefined; function filesMatcher(segments: UrlSegment[]) { @@ -121,7 +123,14 @@ describe('RouteNavigationTelemetryService', () => { component: Page, canActivate: [() => new Promise((resolve) => (releaseHangGuard = resolve))] }, - { path: 'never-loads', loadChildren: () => new Promise(() => undefined) } + { 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' }, @@ -263,6 +272,16 @@ describe('RouteNavigationTelemetryService', () => { ]); }); + 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)); diff --git a/skills/ui-watchtower-setup/references/micro-frontends.md b/skills/ui-watchtower-setup/references/micro-frontends.md index 8f28e74..c5e4859 100644 --- a/skills/ui-watchtower-setup/references/micro-frontends.md +++ b/skills/ui-watchtower-setup/references/micro-frontends.md @@ -130,8 +130,15 @@ providers: [ ``` Use the `useFactory` provider, not `provideUwtTelemetrySink('broadcast', { channelName: -shellChannelName() })`: the option is bound with `useValue`, read when the providers array is built — -possibly before the shell has published the id. The factory runs when the sink is first constructed. +shellChannelName() })`: the option is bound with `useValue` and read when the fragment's providers +array is built, while the factory runs only when the sink is first constructed. + +**Precondition: the shell publishes the id before any fragment boots.** The sink reads the channel +once and never re-reads it, so a fragment that boots first stays on its private `unhosted` channel +for the whole page — nothing ships, silently. The shell code above publishes at module scope of its +config, before the shell itself bootstraps, and Web Fragments loads fragments after the shell is +running, so this holds. If your setup can boot a fragment first (preloading, a fragment page opened +on its own and later embedded), it doesn't hold — verify it below rather than assume. | Must match the shell | `eventNamespace`; the channel name (read from `window.top`) | |----------------------|---------------------------------------------------------------------------------------------------------| @@ -156,7 +163,9 @@ Inside a fragment: ## Verifying -- In a fragment's realm, `window.top.__uwtTelemetryChannel` equals the id the shell generated. +- In a fragment's realm, `window.top.__uwtTelemetryChannel` equals the id the shell generated, and + the fragment's events reach the shell (`received` below grows) on a **first** page load, not only + after navigating. - Once fragments forward, the shell's `inject(UwtTelemetryBroadcastHost).received` counter increases. - Two tabs have two different channel ids. - With the debug flags on, lines are prefixed per realm: `[shell][scenario] …`, `[cart][bi] …`. From 4b3625a809b78f0c181fa447807f0efb5deda8f9 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 18:28:02 +0200 Subject: [PATCH 14/25] self-review --- .../references/ngx-ui-watchtower-api.md | 5 ++- skills/ui-watchtower-integration/SKILL.md | 21 +++++---- .../evals/evals.json | 6 +-- .../src/app/customers/customers.spec.ts | 2 + .../src/app/customers/customers.ts | 23 ++++++++-- .../references/instrumentation-patterns.md | 43 +++++++++++++++---- skills/ui-watchtower-setup/SKILL.md | 17 ++++---- .../references/micro-frontends.md | 8 +++- 8 files changed, 91 insertions(+), 34 deletions(-) diff --git a/skills/shared/references/ngx-ui-watchtower-api.md b/skills/shared/references/ngx-ui-watchtower-api.md index 08fbcfc..786e552 100644 --- a/skills/shared/references/ngx-ui-watchtower-api.md +++ b/skills/shared/references/ngx-ui-watchtower-api.md @@ -105,7 +105,9 @@ interface UwtTelemetryIdentity { 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**. +`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 @@ -181,6 +183,7 @@ private readonly logger = inject(UwtLoggerService).getLogger('checkout'); // dec 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() ``` diff --git a/skills/ui-watchtower-integration/SKILL.md b/skills/ui-watchtower-integration/SKILL.md index 5881222..ac0df9a 100644 --- a/skills/ui-watchtower-integration/SKILL.md +++ b/skills/ui-watchtower-integration/SKILL.md @@ -93,7 +93,7 @@ Load `references/instrumentation-patterns.md` (section 1). Within the scope from - **Every wait the user actually sits through** → a scenario (a fetch that gates a screen, a submit, an upload, an export). - **Every meaningful or irreversible click** → a BI event. -- **Every `catch` that ends a journey** → that scenario's `fail({ error })`. +- **Every `catch` that ends a journey** → that scenario's `fail(...)` (Step 6, rule 3). Map each user answer to concrete code: which component or service, which call starts the journey, which outcomes it can have. @@ -144,18 +144,23 @@ Follow `references/instrumentation-patterns.md`: 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. Each journey: start → steps → settle with the **right** status — `complete`, `incomplete` (an - expected dead end, e.g. no results), `fail` (a defect; pass `error` and `statusCode`), `cancel` - (the user left or a newer request superseded it). Timeout is automatic. -3. Settle open scenarios when their component is destroyed; under `switchMap`, give + expected dead end, e.g. no results), `fail` (a defect, with a `statusCode`), `cancel` (the user + left or a newer request superseded it). Timeout is automatic. +3. **Never hand a raw `HttpErrorResponse` to `fail()` or `logger.error()`**: its message quotes the + request URL, and redaction never scrubs path segments, so ids reach RUM. Use the patterns' + `safeHttpFailure(error)` (class and status only); with `traceScenario`, settle the failure first + in a `catchError` upstream of it — the operator itself passes the raw error and no status. +4. Settle open scenarios when their component is destroyed; under `switchMap`, give `traceScenario` a `cancelOutcome`. Nothing downstream of `traceScenario` may stop after the first value — no `take(1)`/`first()` after it, no `firstValueFrom()` on it (it records `abandoned`): limit upstream and await with `lastValueFrom()`. -4. BI events inside a journey pass `{ scenarioId: scenario.id }`. Debounce high-frequency sources +5. BI events inside a journey pass `{ scenarioId: scenario.id }`. Debounce high-frequency sources (search boxes, sliders) before starting a scenario or sending a BI event — one per settled input, never one per keystroke. -5. Metadata: flat primitives, low cardinality, no personal data. -6. Only if the app has a log backend (`UwtLoggerNames` declared): at a journey-ending error, also - `logger.error(message, { error, correlationId: scenario.id })` so log lines join the scenario. +6. Metadata: flat primitives, low cardinality, no personal data. +7. 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 diff --git a/skills/ui-watchtower-integration/evals/evals.json b/skills/ui-watchtower-integration/evals/evals.json index cf56006..1045ab4 100644 --- a/skills/ui-watchtower-integration/evals/evals.json +++ b/skills/ui-watchtower-integration/evals/evals.json @@ -63,7 +63,7 @@ "Does not re-ask what the prompt already answered (which journeys and interactions); asks only remaining open points, if any", "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 error and statusCode on HTTP error, and abandoned when the user leaves mid-load", + "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" ] @@ -104,7 +104,7 @@ "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({ error, statusCode }) 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.", + "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", @@ -127,7 +127,7 @@ "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 the error and statusCode", + "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", 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 index e02b965..d260d14 100644 --- 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 @@ -72,6 +72,8 @@ describe('Customers customers-load scenario', () => { 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', () => { 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 index 9401032..1af5171 100644 --- 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 @@ -1,8 +1,23 @@ -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'; -import { UwtScenario, UwtScenarioTelemetryService } from '@absaoss-cps/ngx-ui-watchtower'; +import { + UwtScenario, + UwtScenarioOutcome, + UwtScenarioTelemetryService +} from '@absaoss-cps/ngx-ui-watchtower'; import '../telemetry/telemetry.schema'; +/** HttpErrorResponse.message quotes the request URL, path ids included — keep class and status only. */ +function safeHttpFailure(error: unknown): UwtScenarioOutcome { + const status = (error as { status?: unknown } | null)?.status; + return typeof status === 'number' + ? { + error: Object.assign(new Error('HTTP request failed'), { name: 'HttpErrorResponse' }), + statusCode: status + } + : { error }; +} + interface Customer { id: string; name: string; @@ -41,9 +56,9 @@ export class Customers implements OnInit, OnDestroy { } scenario.complete({ metadata: { count: rows.length } }); }, - error: (error: HttpErrorResponse) => { + error: (error: unknown) => { this.error.set(true); - scenario.fail({ error, statusCode: error.status }); + scenario.fail(safeHttpFailure(error)); } }); } diff --git a/skills/ui-watchtower-integration/references/instrumentation-patterns.md b/skills/ui-watchtower-integration/references/instrumentation-patterns.md index a167770..2b40ffc 100644 --- a/skills/ui-watchtower-integration/references/instrumentation-patterns.md +++ b/skills/ui-watchtower-integration/references/instrumentation-patterns.md @@ -20,7 +20,7 @@ in an earlier integration. |------------------------------------------------------------------|-------------------------------------------------------------| | An `await` / subscription the user waits on before seeing a result | Scenario (route load, gating fetch, submit, upload, export) | | A meaningful or irreversible click; a setting change | BI event | -| A `catch` / `error` handler that ends a journey | That scenario's `fail({ error })` | +| A `catch` / `error` handler that ends a journey | That scenario's `fail(safeHttpFailure(error))` (§2) | | A guard, empty result, feature flag routing elsewhere | That scenario's `incomplete({ reason })` | | Component teardown, supersession by a newer request | That scenario's `cancel({ reason })` | @@ -32,7 +32,7 @@ and work the user never waits for. Every event spends the 200-event RUM session | Status | Call | Means | Response | |--------------|------------------------|-----------------------------------------------------------------------------|-----------------------------------| | `success` | `complete()` | The journey reached its goal | — | -| `failure` | `fail({ error })` | A defect: the journey broke | Investigate; alert on rate | +| `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 | @@ -47,6 +47,23 @@ Use a small, consistent set of `reason` strings — they are grouping keys: `'co Pass `statusCode` (HTTP status or business code) with `fail()` so failures group by category. +**Never pass a raw `HttpErrorResponse`** to `fail()` or `logger.error()`: its message quotes the +request URL (`Http failure response for /api/customers/42: 500`), and redaction strips only query +strings, never path segments — the id would reach RUM. Keep its class and status: + +```ts +/** HttpErrorResponse.message quotes the request URL, path ids included — keep class and status only. */ +function safeHttpFailure(error: unknown): UwtScenarioOutcome { + const status = (error as { status?: unknown } | null)?.status; + return typeof status === 'number' + ? { + error: Object.assign(new Error('HTTP request failed'), { name: 'HttpErrorResponse' }), + statusCode: status + } + : { error }; // not an HTTP error: the library normalizes and redacts it +} +``` + ## 3. Scenario shapes **Promise / async**: @@ -65,19 +82,25 @@ async load(): Promise { } scenario.complete({ metadata: { count: rows.length } }); } catch (error) { - scenario.fail({ error, statusCode: (error as { status?: number }).status }); + 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()`: +template's `async` pipe, a component's `subscribe()`), or await it with `lastValueFrom()`. On error +it 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 { 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 } }) }) ); } @@ -144,6 +167,10 @@ query$.pipe( 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' } }) ); }) @@ -200,10 +227,10 @@ tracked by `ui-watchtower-setup`. | Scenario | Steps | Settles | |-------------------|--------------------------------|---------------------------------------------------------------------------------------------| -| `-load` | `fetch`, `render` | `complete`; `incomplete({reason:'no-results'})`; `fail({error})`; cancel on destroy/supersede | -| `-submit` | `validate`, `submit` | `complete`; `incomplete({reason:'validation-failed'})`; `fail({error, statusCode})` | -| `file-upload` | `read`, `upload`, `process` | `complete({metadata:{fileSize}})`; `cancel({reason:'user-cancelled'})`; `fail({error})` | -| `export-download` | `request`, `transfer` | `complete`; `cancel({reason:'user-cancelled'})`; `fail({error})` | +| `-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 index 761aa8a..78be779 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -147,7 +147,7 @@ dashboard. Two different destinations also fail bootstrap. | App has a log backend? | Do | |---|---| -| Yes | Implement `UwtLogApiProvider` from `assets/log-api.provider.ts`, bind `{ provide: UWT_LOG_API_PROVIDER, useExisting: … }`, declare logger names in `UwtLoggerNames` | +| 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 @@ -193,14 +193,15 @@ export const appConfig: ApplicationConfig = { come from Step 6, under the route template. `provideUwtTelemetryRumSink()` calls it from its own non-blocking initializer — don't write one. The asset uses `fetch`, which bypasses `HttpClient` interceptors: if the broker needs auth that an interceptor adds, call it through `HttpClient` - (`firstValueFrom`) instead. + (`firstValueFrom`) instead. It requires credentials; for an app monitor that allows + unauthenticated access (a `{ config }`-only answer), relax that check. - **User identity** (only when Step 1 found sign-in): in the service that completes sign-in, inject the sink once as a field — `private readonly telemetrySink = inject(UwtTelemetrySink);` — and call `this.telemetrySink.setUserId(opaqueId)` when sign-in completes and `setUserId(undefined)` on sign-out, which starts a fresh RUM session. Never call `inject()` inside the callback itself: it only works while Angular constructs the class, and throws `NG0203` there. Never an email or - username. In a fragment, leave it to the shell: a - fragment's `setUserId` is forwarded and changes the shell's user. + username. In a fragment, leave it to the shell: a fragment's `setUserId` is forwarded and changes + the shell's user. ### Step 4 · Declare the vocabulary @@ -273,8 +274,8 @@ What it records: one scenario per navigation under the final route template (red backdated to the click (`markNavigationIntent()` from nav-link handlers); a guard returning `false` is `incomplete`, supersession `abandoned` (one superseded before its URL was recognized records nothing — it has no template and would only spend the event budget), an error `failure` (reduced to -a class-like name and an HTTP status — no URL), and no end within 30 s `timeout`. Keep that behaviour if you change the -asset; its comments say why each part is there. +a class-like name and an HTTP status — no URL), and no end within 30 s `timeout`. Keep that +behaviour if you change the asset; its comments say why each part is there. ### Step 7 · Tests @@ -349,8 +350,8 @@ a shell, also hand over the fragment contract from `references/micro-frontends.m - **`route` is a metric dimension** — a template (`/customers/:id`), never a resolved URL; one series per customer is both a cardinality and a PII problem. - **One page-view recorder, under the template.** Automatic RUM page views use the resolved path — - keep `disableAutoPageView: true` with the Step 6 recorder, never both and never the automatic one - alone. + keep `disableAutoPageView: true` with the Step 6 recorder; never both, and never the automatic one + alone — except in an app with no router (Step 6). - **Telemetry never throws into the app** — every entry point fails open. Don't wrap calls in try/catch. - **Redaction cannot be fully disabled, and never scrubs URL path segments** — only query strings and diff --git a/skills/ui-watchtower-setup/references/micro-frontends.md b/skills/ui-watchtower-setup/references/micro-frontends.md index c5e4859..7edb571 100644 --- a/skills/ui-watchtower-setup/references/micro-frontends.md +++ b/skills/ui-watchtower-setup/references/micro-frontends.md @@ -25,7 +25,10 @@ import { UwtRumTelemetrySink, provideUwtTelemetryRumSink } from '@absaoss-cps/ngx-ui-watchtower/rum'; -import { ROUTE_PAGE_VIEW_RECORDER } from './telemetry/route-navigation-telemetry.service'; +import { + provideRouteNavigationTelemetry, + ROUTE_PAGE_VIEW_RECORDER +} from './telemetry/route-navigation-telemetry.service'; declare global { interface Window { @@ -62,7 +65,8 @@ providers: [ provideUwtTelemetry({ application: 'shell', environment, version }), provideUwtTelemetryRumSink(), { provide: UWT_RUM_CREDENTIALS_PROVIDER, useExisting: AppRumCredentialsProvider }, - // Page views under the route template (SKILL.md Step 6) + // Route tracking and page views under the route template (SKILL.md Step 6) + provideRouteNavigationTelemetry(), { provide: ROUTE_PAGE_VIEW_RECORDER, useFactory: () => { From 5aab2346ebc2b4c501560294213e57d76dcb8229 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 18:41:11 +0200 Subject: [PATCH 15/25] simplify --- skills/ui-watchtower-integration/SKILL.md | 37 ++---- skills/ui-watchtower-setup/SKILL.md | 120 +++++------------- .../references/micro-frontends.md | 27 ++-- .../references/rum-migration.md | 14 +- 4 files changed, 59 insertions(+), 139 deletions(-) diff --git a/skills/ui-watchtower-integration/SKILL.md b/skills/ui-watchtower-integration/SKILL.md index ac0df9a..21a39ad 100644 --- a/skills/ui-watchtower-integration/SKILL.md +++ b/skills/ui-watchtower-integration/SKILL.md @@ -23,10 +23,8 @@ already in place: | **Scenarios** | Did this user journey succeed, and how long did it take? | `UwtScenarioTelemetryService.start()` → `UwtScenario` | | **BI events** | Did this meaningful thing happen? | `UwtBITelemetryService.track()` | -The people who own the app know which questions the data must answer; the code shows where those -journeys live. This skill starts from the first and uses the second — it asks before it proposes, -and marks which items came from the user and which it suggested. It can be re-run as the app grows: -each pass adds to what is already instrumented. +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 @@ -60,12 +58,9 @@ If only `telemetry.schema.ts` (the file augmenting the name registries) is missi create it in Step 6 — a `declare module '@absaoss-cps/ngx-ui-watchtower'` block with the confirmed names, side-effect-imported where the providers are configured. -Also read what already exists, so this pass only adds or changes, never duplicates. The schema is -the inventory of names — every scenario, step and BI name in use must be declared there, or it -doesn't compile. Then find where each is used: search for every declared name, and for every -injection of `UwtScenarioTelemetryService` / `UwtBITelemetryService` (whatever the field is called, -including wrappers and the route-navigation service) and every `traceScenario(`. Note the test -runner and the package manager. +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. ### Step 2 · Ask the user what they want to learn @@ -146,10 +141,9 @@ Follow `references/instrumentation-patterns.md`: 2. Each journey: start → steps → settle with the **right** status — `complete`, `incomplete` (an expected dead end, e.g. no results), `fail` (a defect, with a `statusCode`), `cancel` (the user left or a newer request superseded it). Timeout is automatic. -3. **Never hand a raw `HttpErrorResponse` to `fail()` or `logger.error()`**: its message quotes the - request URL, and redaction never scrubs path segments, so ids reach RUM. Use the patterns' - `safeHttpFailure(error)` (class and status only); with `traceScenario`, settle the failure first - in a `catchError` upstream of it — the operator itself passes the raw error and no status. +3. **Never pass a raw `HttpErrorResponse` to `fail()` or `logger.error()`** — its message quotes the + URL, ids included. Use the patterns' `safeHttpFailure(error)`; with `traceScenario`, settle it in a + `catchError` placed before the operator. 4. Settle open scenarios when their component is destroyed; under `switchMap`, give `traceScenario` a `cancelOutcome`. Nothing downstream of `traceScenario` may stop after the first value — no `take(1)`/`first()` after it, no `firstValueFrom()` on it (it records @@ -175,21 +169,18 @@ confirm it fails, restore. 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 double-click on an -action whose events are identical (same name, `scenarioId`, feature and metadata) gives one BI -event — clicks that start separate journeys carry different scenario ids and correctly give two. +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 -- **The right settle status is the whole point.** Leaving is `abandoned`, not `failure`; an empty - result is `incomplete`, not `success`. Collapsing them makes the failure rate useless for alerts. -- **Names and `route` are metric dimensions** — no ids, no resolved URLs. -- **Metadata is flat primitives**, at most 50 keys per scenario; objects and `undefined` are dropped. -- **The RUM session budget is 200 events**, page views and errors included — one event per settled - scenario, keep `emitLifecycleEvents` off, debounce chatty BI events. +- **The right settle status is the whole point** — collapsing `abandoned`/`incomplete` into + `failure` makes the failure rate useless for alerts. +- **The RUM session budget is 200 events**, page views and errors included — keep + `emitLifecycleEvents` off. - **Telemetry never throws into the app** — don't wrap calls in try/catch. - **Percentiles and dashboards are AWS-side.** The frontend emits raw `delta` and low-cardinality dimensions; it never computes metrics. diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index 78be779..7c4a832 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -62,35 +62,25 @@ Copy this checklist and track progress: Read before writing anything — extend the app, don't restructure it. -1. **Angular version vs the supported range.** Not Angular at all → stop. Otherwise read the - app's `@angular/core` and `rxjs` versions (installed, or else the ranges in `package.json`), and - the library's supported range from the registry — never from memory; it changes with releases: +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 the range** → install the latest version (Step 2). - - **Older than the range** → find the newest version whose peers fit - (`npm view @absaoss-cps/ngx-ui-watchtower versions --json`, then `npm view …@ - peerDependencies` per candidate). If one exists, offer it, pinned; the API reference describes - the latest version, so the installed typings win where they differ. If none fits, stop: an - Angular upgrade is out of scope. - - **Newer than the range** → the library doesn't support this Angular yet. Stop and tell the - user. Never install with `--force` or `--legacy-peer-deps`: a peer range is the library's - compatibility contract with Angular's compiler and runtime. + - Inside → install the latest version. + - Older → the newest release whose peers fit (`npm view … versions`), pinned; none → stop. + - Newer → stop and tell the user. Never `--force` or `--legacy-peer-deps`. 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 - (`makeEnvironmentProviders` works in both). + (`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, Gotchas); 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 already partly wired, keep what exists and run only the steps - for what is missing (destination and credentials, `telemetry.schema.ts`, user identity, the - route-navigation proof, spec providers). If nothing is missing, say so and offer - `ui-watchtower-integration`. + applies. If ngx-ui-watchtower is partly wired, run only the steps for what is missing; if + nothing is, offer `ui-watchtower-integration`. 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. @@ -116,11 +106,6 @@ npm install @absaoss-cps/ngx-ui-watchtower@latest # or the pinned version chos npm install aws-rum-web # only if THIS realm sends to RUM (standalone app or shell) ``` -If the install reports a peer conflict, stop and report it — don't override it. - -`aws-rum-web` is an optional peer: a fragment or a `'noop'` app never needs it, because everything -RUM-related lives in the separate entry point `@absaoss-cps/ngx-ui-watchtower/rum`. - 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 @@ -130,8 +115,8 @@ app tsconfig(s). `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, so a forgotten destination is caught on the first run, not on an empty -dashboard. Two different destinations also fail bootstrap. +`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:** @@ -185,23 +170,13 @@ export const appConfig: ApplicationConfig = { 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. -- Credentials: copy `assets/rum-credentials.provider.ts`, point it at the broker endpoint from Step 1 - and adapt the response check. `load()` validates every required field and returns `null` on - **any** failure or incomplete answer (that disables RUM for the session; the app keeps working). - It passes the broker's other app-monitor settings through when correctly typed (`telemetries`, - `sessionAttributes`, `allowCookies`, …) and always sets `disableAutoPageView: true` — page views - come from Step 6, under the route template. `provideUwtTelemetryRumSink()` calls it from its own - non-blocking initializer — don't write one. The asset uses `fetch`, which bypasses `HttpClient` - interceptors: if the broker needs auth that an interceptor adds, call it through `HttpClient` - (`firstValueFrom`) instead. It requires credentials; for an app monitor that allows - unauthenticated access (a `{ config }`-only answer), relax that check. -- **User identity** (only when Step 1 found sign-in): in the service that completes sign-in, inject - the sink once as a field — `private readonly telemetrySink = inject(UwtTelemetrySink);` — and call - `this.telemetrySink.setUserId(opaqueId)` when sign-in completes and `setUserId(undefined)` on - sign-out, which starts a fresh RUM session. Never call `inject()` inside the callback itself: it - only works while Angular constructs the class, and throws `NG0203` there. Never an email or - username. In a fragment, leave it to the shell: a fragment's `setUserId` is forwarded and changes - the shell's user. +- Credentials: copy `assets/rum-credentials.provider.ts`, point it at the broker endpoint and adapt + the response check. It returns `null` (RUM off, app unaffected) for anything incomplete, and 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 @@ -230,20 +205,14 @@ migration is blocked — tell the owners of the affected dashboards. ### Step 6 · Proof — route-navigation tracking -One real scenario proves the whole pipeline (providers, destination, vocabulary, tests) and is -useful in every app. Copy `assets/route-navigation-telemetry.service.ts` next to the schema and add -`provideRouteNavigationTelemetry()` next to `provideRouter(...)` — not a `start()` call in the root -component, which misses a blocking initial navigation (`withEnabledBlockingInitialNavigation()`, -common with SSR). Nav-link click handlers may call `markNavigationIntent()`. It works -unchanged for static, `:param`, lazy (`loadChildren`) and redirected routes. Only a `matcher` route -needs one thing: a `data: { telemetryPath: 'files/:path' }` entry naming its segment (otherwise it -reports `(matcher)`). - -**Page views** — in a realm that sends to RUM (standalone app or shell), bind the asset's -`ROUTE_PAGE_VIEW_RECORDER` so each completed navigation records one page view under its route -template; the credentials provider has turned automatic page views off, which would send the -resolved path (`/customers/42`) — a PII and cardinality problem, since path segments are not -redacted: +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. + +**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'; @@ -259,23 +228,11 @@ import { ROUTE_PAGE_VIEW_RECORDER } from './telemetry/route-navigation-telemetry } ``` -With a `'noop'` destination, bind nothing; when you later switch to RUM, add this binding with the -sink. If an existing RUM integration records page views (Step 5), keep exactly one recorder. - 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** (a single-screen app): skip this step and the page-view binding, and remove -`disableAutoPageView: true` from the credentials provider — with no route changes, RUM's automatic -page view (one per page load) is the right one. Keep personal data out of that URL. The proof is -then the first scenario `ui-watchtower-integration` adds. - -What it records: one scenario per navigation under the final route template (redirects included), -backdated to the click (`markNavigationIntent()` from nav-link handlers); a guard returning `false` -is `incomplete`, supersession `abandoned` (one superseded before its URL was recognized records -nothing — it has no template and would only spend the event budget), an error `failure` (reduced to -a class-like name and an HTTP status — no URL), and no end within 30 s `timeout`. Keep that -behaviour if you change the asset; its comments say why each part is there. +**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 @@ -310,10 +267,8 @@ 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** for real: `tsc --noEmit` against a solution-style root tsconfig (`"files": []` + - `"references"`) checks nothing — run `tsc -p tsconfig.app.json --noEmit` and the spec tsconfig, or - `tsc --build`. Passing tests prove nothing about types: Vitest (esbuild) never type-checks, and - Jest only does when ts-jest diagnostics are on. +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 @@ -339,25 +294,14 @@ a shell, also hand over the fragment contract from `references/micro-frontends.m ## Gotchas -- **No default destination** — `NG0201` at bootstrap means a destination (or, for the logger and the - broadcast host, a log provider) is missing. Fix the wiring; never add a fallback provider "to make - it start". -- **One destination per realm, never two.** Another destination *replaces* RUM; it never runs beside - it. A fragment never gets the RUM sink. - **`/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. -- **`route` is a metric dimension** — a template (`/customers/:id`), never a resolved URL; one series - per customer is both a cardinality and a PII problem. -- **One page-view recorder, under the template.** Automatic RUM page views use the resolved path — - keep `disableAutoPageView: true` with the Step 6 recorder; never both, and never the automatic one - alone — except in an app with no router (Step 6). - **Telemetry never throws into the app** — every entry point fails open. Don't wrap calls in try/catch. -- **Redaction cannot be fully disabled, and never scrubs URL path segments** — only query strings and - fragments. `withRedaction({ scanValuePatterns: ['email'] })` scrubs matching *values* in metadata - and messages; it does nothing for a path. Keep personal data out of URLs (redesign such routes), - and report route templates, never resolved paths. +- **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 diff --git a/skills/ui-watchtower-setup/references/micro-frontends.md b/skills/ui-watchtower-setup/references/micro-frontends.md index 7edb571..3e5d51a 100644 --- a/skills/ui-watchtower-setup/references/micro-frontends.md +++ b/skills/ui-watchtower-setup/references/micro-frontends.md @@ -87,11 +87,8 @@ providers: [ 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. -- **Why a `window` global** and not `sessionStorage`: browsers copy `sessionStorage` into a - duplicated tab, which would put both tabs back on one channel. A query parameter doesn't work - either: a bound fragment shares the shell's `window.location`. -- Per-tab channels give correct attribution, not a separate RUM session per tab — the session is a - cookie shared across tabs, by design. +- 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 @@ -137,12 +134,8 @@ Use the `useFactory` provider, not `provideUwtTelemetrySink('broadcast', { chann shellChannelName() })`: the option is bound with `useValue` and read when the fragment's providers array is built, while the factory runs only when the sink is first constructed. -**Precondition: the shell publishes the id before any fragment boots.** The sink reads the channel -once and never re-reads it, so a fragment that boots first stays on its private `unhosted` channel -for the whole page — nothing ships, silently. The shell code above publishes at module scope of its -config, before the shell itself bootstraps, and Web Fragments loads fragments after the shell is -running, so this holds. If your setup can boot a fragment first (preloading, a fragment page opened -on its own and later embedded), it doesn't hold — verify it below rather than assume. +**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`) | |----------------------|---------------------------------------------------------------------------------------------------------| @@ -158,19 +151,15 @@ Inside a fragment: - 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()`. -- A fragment with no shell, **or on a wrong channel**, behaves identically: everything runs, nothing - ships, no error. Never let the channel resolve to `undefined` — that selects the library's shared - default channel, where a shell that never adopted per-tab ids would receive the fragment's events. - Verify the channel deliberately (below). +- A fragment with no shell, or on a wrong channel, runs normally and ships nothing, silently — + verify the channel (below). - Correlation across realms is a string: forward `scenario.id` and start a child scenario with `parentScenarioId`. ## Verifying -- In a fragment's realm, `window.top.__uwtTelemetryChannel` equals the id the shell generated, and - the fragment's events reach the shell (`received` below grows) on a **first** page load, not only - after navigating. -- Once fragments forward, the shell's `inject(UwtTelemetryBroadcastHost).received` counter increases. +- 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, diff --git a/skills/ui-watchtower-setup/references/rum-migration.md b/skills/ui-watchtower-setup/references/rum-migration.md index 8579461..89019a7 100644 --- a/skills/ui-watchtower-setup/references/rum-migration.md +++ b/skills/ui-watchtower-setup/references/rum-migration.md @@ -22,14 +22,10 @@ alone is not parity: 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** (for example on the credentials -broker, §3). If the inventory shows the old code sending personal data or raw free text today — the -text of a search box (worse, on every keystroke), an email as user id or session attribute, page ids -that are resolved URLs with query strings — fix it in the old code right away, before or without -the migration: debounce, send low-cardinality attributes instead (`queryLength`, `resultCount`), send -an opaque id or no user at all, strip query strings and fragments from page ids. This changes those -payloads before any wire diff; accept that, mark the rows in the inventory, and tell the owners of -the affected dashboards. Dashboard parity never outranks personal data. +**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. ## 2. Pick a parity tool per row @@ -37,7 +33,7 @@ the affected dashboards. Dashboard parity never outranks personal data. |--------------------------------------------------------|------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------| | `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 — you own the shape *and* its privacy: the library's redaction doesn't run here. Normalize free text first (an error becomes its name or a code — never its message), per the privacy rule in §1. Use sparingly and comment why | +| `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. From e637b88c9e9a162d972856ff325024d9ec72a4be Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 19:32:42 +0200 Subject: [PATCH 16/25] more review outcomes --- docs/guides/ui-watchtower-setup.md | 5 ++- .../references/ngx-ui-watchtower-api.md | 10 ++---- skills/ui-watchtower-integration/SKILL.md | 13 ++----- .../evals/evals.json | 2 +- .../evals/files/wired-angular/package.json | 1 + .../files/wired-angular/src/app/app.config.ts | 4 +-- .../wired-instrumented-angular/package.json | 1 + .../src/app/app.config.ts | 4 +-- .../references/instrumentation-patterns.md | 25 +++++-------- skills/ui-watchtower-setup/SKILL.md | 31 +++++++--------- ...route-navigation-telemetry.service.spec.ts | 19 ++++------ .../assets/rum-credentials.provider.spec.ts | 14 ++++---- skills/ui-watchtower-setup/evals/evals.json | 10 +++--- .../partially-wired-angular/package.json | 1 + .../src/app/app.config.ts | 4 +-- .../files/standalone-angular/package.json | 1 + .../standalone-angular/src/app/app.config.ts | 4 +-- .../references/micro-frontends.md | 36 +++++-------------- .../references/rum-migration.md | 16 +++------ 19 files changed, 72 insertions(+), 129 deletions(-) diff --git a/docs/guides/ui-watchtower-setup.md b/docs/guides/ui-watchtower-setup.md index 6ac32be..cd943e9 100644 --- a/docs/guides/ui-watchtower-setup.md +++ b/docs/guides/ui-watchtower-setup.md @@ -51,9 +51,8 @@ If the library is already partly wired, only the missing pieces are added. ## Requirements -- An Angular version the library supports — its `peerDependencies`. The skill reads them from npm - at run time; for an older Angular it offers an older compatible release if one exists, and for a - newer Angular than the library supports yet it stops instead of forcing the install +- 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 diff --git a/skills/shared/references/ngx-ui-watchtower-api.md b/skills/shared/references/ngx-ui-watchtower-api.md index 786e552..a462976 100644 --- a/skills/shared/references/ngx-ui-watchtower-api.md +++ b/skills/shared/references/ngx-ui-watchtower-api.md @@ -1,11 +1,7 @@ # ngx-ui-watchtower API reference -The public surface of `@absaoss-cps/ngx-ui-watchtower` an app or a custom sink uses (config tokens and -redaction constants omitted). Install the version `ui-watchtower-setup` selects from peer -compatibility — normally the latest. This page describes the latest release; if the installed -typings differ, the typings win. Import only -from the two entry points below — never from `…/src/lib/…` paths, and never re-export the `/rum` -entry from a shared barrel. +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 @@ -331,5 +327,3 @@ providers: [ // payload: expect.objectContaining({ scenarioName: 'load-customers', status: 'success' }) }) // ]); ``` - -jsdom implements no `BroadcastChannel`, so broadcast sinks and hosts are no-ops in unit tests. diff --git a/skills/ui-watchtower-integration/SKILL.md b/skills/ui-watchtower-integration/SKILL.md index 21a39ad..053584f 100644 --- a/skills/ui-watchtower-integration/SKILL.md +++ b/skills/ui-watchtower-integration/SKILL.md @@ -54,9 +54,7 @@ Look for the package in `package.json`, and `provideUwtTelemetry(...)` plus exac 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` (the file augmenting the name registries) is missing, don't stop: -create it in Step 6 — a `declare module '@absaoss-cps/ngx-ui-watchtower'` block with the confirmed -names, side-effect-imported where the providers are configured. +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 @@ -140,7 +138,8 @@ Follow `references/instrumentation-patterns.md`: snake_case, one JSDoc line each). Never interpolate ids into names. 2. Each journey: start → steps → settle with the **right** status — `complete`, `incomplete` (an expected dead end, e.g. no results), `fail` (a defect, with a `statusCode`), `cancel` (the user - left or a newer request superseded it). Timeout is automatic. + left or a newer request superseded it). Timeout is automatic. Collapsing `abandoned` or + `incomplete` into `failure` makes the failure rate useless for alerts. 3. **Never pass a raw `HttpErrorResponse` to `fail()` or `logger.error()`** — its message quotes the URL, ids included. Use the patterns' `safeHttpFailure(error)`; with `traceScenario`, settle it in a `catchError` placed before the operator. @@ -177,13 +176,7 @@ from the user's original request and why. ## Gotchas -- **The right settle status is the whole point** — collapsing `abandoned`/`incomplete` into - `failure` makes the failure rate useless for alerts. -- **The RUM session budget is 200 events**, page views and errors included — keep - `emitLifecycleEvents` off. - **Telemetry never throws into the app** — don't wrap calls in try/catch. -- **Percentiles and dashboards are AWS-side.** The frontend emits raw `delta` and low-cardinality - dimensions; it never computes metrics. ## Out of scope diff --git a/skills/ui-watchtower-integration/evals/evals.json b/skills/ui-watchtower-integration/evals/evals.json index 1045ab4..7f98290 100644 --- a/skills/ui-watchtower-integration/evals/evals.json +++ b/skills/ui-watchtower-integration/evals/evals.json @@ -26,7 +26,7 @@ "evals/files/wired-angular/tsconfig.spec.json" ], "expectations": [ - "Checks that the library is installed and wired (providers, destination, schema) before anything else", + "Checks that the library is installed and wired (package, providers, destination) before anything else", "Asks the user, before proposing, what the telemetry should answer — 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)", diff --git a/skills/ui-watchtower-integration/evals/files/wired-angular/package.json b/skills/ui-watchtower-integration/evals/files/wired-angular/package.json index cb0d6d3..a84c3e7 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-angular/package.json +++ b/skills/ui-watchtower-integration/evals/files/wired-angular/package.json @@ -19,6 +19,7 @@ "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", 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 index 5054e05..eccc9a5 100644 --- 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 @@ -1,6 +1,6 @@ import { ApplicationConfig, inject, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; -import { provideRouter } from '@angular/router'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; import { provideUwtTelemetry } from '@absaoss-cps/ngx-ui-watchtower'; import { UWT_RUM_CREDENTIALS_PROVIDER, @@ -21,7 +21,7 @@ const APP_VERSION = '1.4.0'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes), + provideRouter(routes, withComponentInputBinding()), // Route-navigation telemetry, started before any navigation (incl. the initial one). provideRouteNavigationTelemetry(), provideHttpClient(), 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 index cb0d6d3..a84c3e7 100644 --- a/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/package.json +++ b/skills/ui-watchtower-integration/evals/files/wired-instrumented-angular/package.json @@ -19,6 +19,7 @@ "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", 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 index 5054e05..eccc9a5 100644 --- 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 @@ -1,6 +1,6 @@ import { ApplicationConfig, inject, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; -import { provideRouter } from '@angular/router'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; import { provideUwtTelemetry } from '@absaoss-cps/ngx-ui-watchtower'; import { UWT_RUM_CREDENTIALS_PROVIDER, @@ -21,7 +21,7 @@ const APP_VERSION = '1.4.0'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes), + provideRouter(routes, withComponentInputBinding()), // Route-navigation telemetry, started before any navigation (incl. the initial one). provideRouteNavigationTelemetry(), provideHttpClient(), diff --git a/skills/ui-watchtower-integration/references/instrumentation-patterns.md b/skills/ui-watchtower-integration/references/instrumentation-patterns.md index 2b40ffc..57d1d79 100644 --- a/skills/ui-watchtower-integration/references/instrumentation-patterns.md +++ b/skills/ui-watchtower-integration/references/instrumentation-patterns.md @@ -1,7 +1,6 @@ # Instrumentation patterns -How to choose, implement and settle scenarios and BI events. Each rule here cost real debugging time -in an earlier integration. +How to choose, implement and settle scenarios and BI events. ## Contents @@ -25,7 +24,7 @@ in an earlier integration. | Component teardown, supersession by a newer request | That scenario's `cancel({ reason })` | Skip: anything that fires continuously (scroll, mousemove, per-keystroke), purely decorative UI, -and work the user never waits for. Every event spends the 200-event RUM session budget. +and work the user never waits for. ## 2. Choosing the settle status @@ -45,11 +44,9 @@ failure. Use a small, consistent set of `reason` strings — they are grouping keys: `'component-destroyed'`, `'superseded'`, `'user-cancelled'`, `'no-results'`, `'guard-rejected'`. -Pass `statusCode` (HTTP status or business code) with `fail()` so failures group by category. - -**Never pass a raw `HttpErrorResponse`** to `fail()` or `logger.error()`: its message quotes the -request URL (`Http failure response for /api/customers/42: 500`), and redaction strips only query -strings, never path segments — the id would reach RUM. Keep its class and status: +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. Keep its class and status: ```ts /** HttpErrorResponse.message quotes the request URL, path ids included — keep class and status only. */ @@ -126,16 +123,12 @@ for (const row of rows) { } ``` -An unmatched `aggregateEnd`, or a second `aggregateStart` before its end, is ignored — an early -`return` can't corrupt the total. - **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()))`. Not inside a -fragment (its frames belong to a hidden iframe) — settle with `complete()` there. +`requestAnimationFrame(() => requestAnimationFrame(() => scenario.complete()))`. ## 4. RxJS traps @@ -155,10 +148,8 @@ await lastValueFrom(this.api.customers().pipe(traceScenario(scenario))); this.events$.pipe(take(1), traceScenario(scenario)); ``` -**`switchMap` supersession**: `switchMap` unsubscribes the previous inner observable *before* the -projector for the new value runs, so `previous.cancel({ reason: 'superseded' })` inside the new -projector is too late — the teardown already cancelled it without a reason. Give the reason up -front: +**`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( diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index 7c4a832..bac1ff6 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -74,7 +74,7 @@ Read before writing anything — extend the app, don't restructure it. 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, Gotchas); the library is SSR-safe. + (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`, @@ -136,8 +136,9 @@ different destinations also fail bootstrap. | 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. -Anything you compute here runs during server rendering too, so guard browser globals: +`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` @@ -189,19 +190,14 @@ app's own later. 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. -- Augmentation applies program-wide, but keep `import './telemetry/telemetry.schema';` in the files - that own the wiring (provider config, the router telemetry service) so it never becomes orphaned. -- Declare `UwtLoggerNames` only if the app has a log backend (Step 3). +- 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, load `references/rum-migration.md` and follow it -**before** Step 6. The library emits its own event types and payloads; replacing a hand-written -`RumService` naively changes what reaches CloudWatch and breaks existing dashboards. Migrate first, -diff the wire, delete last — and keep exactly one page-view recorder. If you can't run the app to -diff the wire, don't delete the old service; leave that as a follow-up. One exception to "parity -first": personal data or raw free text the old code sends today is fixed immediately, even when the -migration is blocked — tell the owners of the affected dashboards. +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 @@ -236,8 +232,6 @@ credentials provider — RUM's one automatic page view per load is then right. ### Step 7 · Tests -The library ships no test doubles on purpose — the harness is a few lines. - - Every existing spec that now (directly or through a component) injects a telemetry service needs providers, or it fails with `NG0201`: @@ -255,9 +249,10 @@ The library ships no test doubles on purpose — the harness is a few lines. - 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: - `doNotFake` instead of `toFake`), 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. + 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`); + 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. 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 index 13e2b50..4b5ada7 100644 --- a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts @@ -2,6 +2,7 @@ import { Component, inject, Injectable, provideZonelessChangeDetection } from '@ import { TestBed } from '@angular/core/testing'; import { bootstrapApplication } from '@angular/platform-browser'; import { + ActivatedRouteSnapshot, provideRouter, Router, RouterOutlet, @@ -41,7 +42,7 @@ class Page {} let releaseSlowGuard: (value: boolean) => void = () => undefined; let slowGuardEntered: () => void = () => undefined; let lazyLoadEntered: () => void = () => undefined; -let releaseLazyLoad: (routes: unknown[]) => void = () => undefined; +let releaseLazyLoad: () => void = () => undefined; let releaseHangGuard: (value: boolean) => void = () => undefined; function filesMatcher(segments: UrlSegment[]) { @@ -78,7 +79,7 @@ describe('RouteNavigationTelemetryService', () => { path: 'legacy/:id', component: Page, canActivate: [ - (route: { paramMap: { get(k: string): string | null } }) => + (route: ActivatedRouteSnapshot) => inject(Router).parseUrl(`/customers/${route.paramMap.get('id')}`) ] }, @@ -102,11 +103,6 @@ describe('RouteNavigationTelemetryService', () => { data: () => Promise.reject({ name: 'Lookup failed for cust-SECRET-7 at /x', message: 'm' }) } }, - { - path: 'back-home', - component: Page, - canActivate: [() => inject(Router).parseUrl('/customers')] - }, { path: 'slow', component: Page, @@ -259,9 +255,9 @@ describe('RouteNavigationTelemetryService', () => { 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('/back-home'); + await router.navigateByUrl('/old'); expect(scenarios()).toEqual([ - expect.objectContaining({ status: 'abandoned', reason: 'same-url', route: '/back-home' }) + expect.objectContaining({ status: 'abandoned', reason: 'same-url', route: '/old' }) ]); }); @@ -277,7 +273,7 @@ describe('RouteNavigationTelemetryService', () => { const first = router.navigateByUrl('/slow-lazy/x').catch(() => false); await entered; await router.navigateByUrl('/customers'); - releaseLazyLoad([]); + releaseLazyLoad(); await first; expect(scenarios()).toEqual([expect.objectContaining({ status: 'success', route: '/customers' })]); }); @@ -353,8 +349,7 @@ describe('RouteNavigationTelemetryService', () => { it('clears the timer when a navigation ends', async () => { await router.navigateByUrl('/customers'); - await vi.advanceTimersByTimeAsync(60_000); - expect(scenarios()).toEqual([expect.objectContaining({ status: 'success' })]); + expect(vi.getTimerCount()).toBe(0); }); }); diff --git a/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts b/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts index de436a8..ed21092 100644 --- a/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts +++ b/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts @@ -5,7 +5,7 @@ const valid = { config: { applicationId: 'app-1', region: 'eu-west-1', applicationVersion: '1.4.0', sessionSampleRate: 1 }, credentials: { accessKeyId: 'AKIA', secretAccessKey: 'secret', sessionToken: 'token', - expiration: '2030-01-01T00:00:00Z' + expiration: new Date(Date.now() + 3_600_000).toISOString() } }; @@ -46,12 +46,12 @@ describe('AppRumCredentialsProvider', () => { await expect(load()).resolves.toBeNull(); }); - it('returns null for invalid JSON, an error status and a network failure', async () => { - answer('', 200); - await expect(load()).resolves.toBeNull(); - answer(valid, 503); - await expect(load()).resolves.toBeNull(); - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('offline'))); + it.each([ + ['invalid JSON', () => answer('')], + ['an error status', () => answer(valid, 503)], + ['a network failure', () => vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('offline')))] + ])('returns null for %s', async (_label, arrange) => { + arrange(); await expect(load()).resolves.toBeNull(); }); diff --git a/skills/ui-watchtower-setup/evals/evals.json b/skills/ui-watchtower-setup/evals/evals.json index d8f01d5..99635da 100644 --- a/skills/ui-watchtower-setup/evals/evals.json +++ b/skills/ui-watchtower-setup/evals/evals.json @@ -5,7 +5,7 @@ "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, fixes app.spec.ts providers, validates, and hands over to ui-watchtower-integration for the app's own journeys.", + "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", @@ -29,11 +29,11 @@ "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 on any failure", "Does NOT inject UwtLoggerService, pass withLogging, or declare UwtLoggerNames (no log backend)", - "Creates telemetry.schema.ts augmenting UwtScenarioNames and UwtScenarioSteps, and side-effect-imports it where the providers are configured", + "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", - "Adds telemetry providers to app.spec.ts so it does not fail with NG0201", + "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" ] @@ -108,7 +108,7 @@ "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", - "Adds telemetry providers to app.spec.ts so it does not fail with NG0201", + "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, and install aws-rum-web", "Ends by pointing the user to ui-watchtower-integration" ] @@ -139,7 +139,7 @@ "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", + "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" ] }, 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 index 53a0792..dad7717 100644 --- a/skills/ui-watchtower-setup/evals/files/partially-wired-angular/package.json +++ b/skills/ui-watchtower-setup/evals/files/partially-wired-angular/package.json @@ -14,6 +14,7 @@ "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", 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 index 47cc454..69d22b2 100644 --- 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 @@ -1,13 +1,13 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; -import { provideRouter } from '@angular/router'; +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), + 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/standalone-angular/package.json b/skills/ui-watchtower-setup/evals/files/standalone-angular/package.json index cb25dc7..f6be4bb 100644 --- a/skills/ui-watchtower-setup/evals/files/standalone-angular/package.json +++ b/skills/ui-watchtower-setup/evals/files/standalone-angular/package.json @@ -13,6 +13,7 @@ "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", 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 index 9dba522..c25f261 100644 --- 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 @@ -1,8 +1,8 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; -import { provideRouter } from '@angular/router'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes), provideHttpClient()] + providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes, withComponentInputBinding()), provideHttpClient()] }; diff --git a/skills/ui-watchtower-setup/references/micro-frontends.md b/skills/ui-watchtower-setup/references/micro-frontends.md index 3e5d51a..cc2a73b 100644 --- a/skills/ui-watchtower-setup/references/micro-frontends.md +++ b/skills/ui-watchtower-setup/references/micro-frontends.md @@ -36,27 +36,12 @@ declare global { } } -/** - * A random id for this page load. `crypto.randomUUID` exists only in secure contexts (HTTPS, - * localhost); `getRandomValues` works everywhere crypto does. Never throws — a failure here would - * stop the shell from booting. - */ -function newChannelId(): string { - const c = globalThis.crypto; - if (typeof c?.randomUUID === 'function') { - return `ngx-ui-watchtower-${c.randomUUID()}`; - } - if (typeof c?.getRandomValues === 'function') { - const bytes = c.getRandomValues(new Uint8Array(16)); - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); - return `ngx-ui-watchtower-${hex}`; - } - return `ngx-ui-watchtower-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; -} - -// One channel per page load, at module scope so it exists before any fragment boots. Browser-only: -// this module is also evaluated during server rendering, where the host is a no-op anyway. -const channelId = typeof window === 'undefined' ? undefined : newChannelId(); +// 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; } @@ -130,9 +115,8 @@ providers: [ ]; ``` -Use the `useFactory` provider, not `provideUwtTelemetrySink('broadcast', { channelName: -shellChannelName() })`: the option is bound with `useValue` and read when the fragment's providers -array is built, while the factory runs only when the sink is first constructed. +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. @@ -151,10 +135,6 @@ Inside a fragment: - 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()`. -- A fragment with no shell, or on a wrong channel, runs normally and ships nothing, silently — - verify the channel (below). -- Correlation across realms is a string: forward `scenario.id` and start a child scenario with - `parentScenarioId`. ## Verifying diff --git a/skills/ui-watchtower-setup/references/rum-migration.md b/skills/ui-watchtower-setup/references/rum-migration.md index 89019a7..e3d993d 100644 --- a/skills/ui-watchtower-setup/references/rum-migration.md +++ b/skills/ui-watchtower-setup/references/rum-migration.md @@ -86,20 +86,12 @@ diff needs. 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. -- **Free-text and per-keystroke events** (a search box sending every keystroke): debounce them, and - don't port the raw text — it is unbounded cardinality and can contain personal data. Fix them in - the old code even when the migration itself has to wait (§1). Send a - low-cardinality attribute instead (query length, result count, which filter was used). If a - documented consumer genuinely needs the text, ask before porting it. -- **`aws:` metadata keys** are reserved and dropped by the RUM client; the sink filters them out. Any - the old code set were already being dropped — don't port them. - **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(id)` / `setUserId(undefined)` on the - `UwtTelemetrySink`, injected once as a field of the service that handles sign-in — never with - `inject()` inside the sign-in callback, which throws `NG0203`. Sign-out starts a fresh session with a new anonymous id; if the old code - behaved differently, call out the change. +- **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({ error })`, or a log line with + 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. From 4d8e118c44d923ad796711c400d000a390ffe16a Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 20:17:32 +0200 Subject: [PATCH 17/25] more to prev --- .../references/ngx-ui-watchtower-api.md | 1 + .../route-navigation-telemetry.service.ts | 33 ++++++++++++------- .../app/telemetry/rum-credentials.provider.ts | 32 ++++++++---------- .../route-navigation-telemetry.service.ts | 33 ++++++++++++------- .../app/telemetry/rum-credentials.provider.ts | 32 ++++++++---------- .../references/instrumentation-patterns.md | 25 +++++++------- skills/ui-watchtower-setup/SKILL.md | 16 ++++++--- ...route-navigation-telemetry.service.spec.ts | 5 +-- .../route-navigation-telemetry.service.ts | 33 ++++++++++++------- .../assets/rum-credentials.provider.spec.ts | 4 +-- .../assets/rum-credentials.provider.ts | 32 ++++++++---------- skills/ui-watchtower-setup/evals/evals.json | 2 +- .../references/micro-frontends.md | 4 ++- 13 files changed, 141 insertions(+), 111 deletions(-) diff --git a/skills/shared/references/ngx-ui-watchtower-api.md b/skills/shared/references/ngx-ui-watchtower-api.md index a462976..dbeb13e 100644 --- a/skills/shared/references/ngx-ui-watchtower-api.md +++ b/skills/shared/references/ngx-ui-watchtower-api.md @@ -220,6 +220,7 @@ 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 | 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 index 0268cc7..9b41eed 100644 --- 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 @@ -139,17 +139,20 @@ interface TrackedNavigation { 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; +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 — and backdated to the click - * or the first `NavigationStart`. Start it with {@link provideRouteNavigationTelemetry}; call - * {@link markNavigationIntent} from nav-link click handlers. + * 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 { @@ -196,10 +199,13 @@ export class RouteNavigationTelemetryService { const redirect = this.consumePendingRedirect(); const navigation: TrackedNavigation = { startedAt: redirect?.startedAt ?? startedAt, - redirectedFrom: redirect?.redirectedFrom + redirectedFrom: redirect?.redirectedFrom, + recorded: redirect?.recorded }; this.navigations.set(event.id, navigation); - this.armTimeout(event.id, navigation); + if (!navigation.recorded) { + this.armTimeout(event.id, navigation); + } return; } @@ -232,7 +238,8 @@ export class RouteNavigationTelemetryService { // Restarts under a new id: carry the start so the journey stays one scenario. this.pendingRedirect = { startedAt: navigation.startedAt, - redirectedFrom: navigation.redirectedFrom ?? navigation.route + redirectedFrom: navigation.redirectedFrom ?? navigation.route, + recorded: navigation.recorded }; return; } @@ -280,12 +287,16 @@ export class RouteNavigationTelemetryService { } } - /** Starts and settles the scenario at once, backdated to the journey's start. */ + /** 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; @@ -319,8 +330,8 @@ export class RouteNavigationTelemetryService { } /** - * Records a hung navigation once as `timeout`; a late end event then finds nothing. Browser only - * and outside Angular, so it never delays app stability or server rendering. + * 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) { @@ -329,7 +340,7 @@ export class RouteNavigationTelemetryService { const remainingMs = Math.max(0, navigation.startedAt + NAVIGATION_TIMEOUT_MS - Date.now()); navigation.timeoutHandle = this.zone.runOutsideAngular(() => setTimeout(() => { - const hung = this.take(navigationId); + const hung = this.navigations.get(navigationId); if (hung) { this.record(hung, hung.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => scenario.settle('timeout', { reason: 'navigation-timeout', metadata }) 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 index 5e63de4..7164cf2 100644 --- 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 @@ -130,28 +130,22 @@ function hasStrings( */ @Injectable({ providedIn: 'root' }) export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { - /** `null` (RUM off for the session) unless the answer is complete, valid and unexpired. */ + /** + * `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 { - let response: Response; - try { - response = await fetch(RUM_BROKER_URL, { - headers: { Accept: 'application/json' }, - cache: 'no-store' // live AWS credentials - }); - } catch { - return null; - } - + const response = await fetch(RUM_BROKER_URL, { + headers: { Accept: 'application/json' }, + cache: 'no-store' // live AWS credentials + }); if (!response.ok) { - return null; - } - - let body: unknown; - try { - body = await response.json(); - } catch { - return null; + 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) { 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 index 0268cc7..9b41eed 100644 --- 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 @@ -139,17 +139,20 @@ interface TrackedNavigation { 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; +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 — and backdated to the click - * or the first `NavigationStart`. Start it with {@link provideRouteNavigationTelemetry}; call - * {@link markNavigationIntent} from nav-link click handlers. + * 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 { @@ -196,10 +199,13 @@ export class RouteNavigationTelemetryService { const redirect = this.consumePendingRedirect(); const navigation: TrackedNavigation = { startedAt: redirect?.startedAt ?? startedAt, - redirectedFrom: redirect?.redirectedFrom + redirectedFrom: redirect?.redirectedFrom, + recorded: redirect?.recorded }; this.navigations.set(event.id, navigation); - this.armTimeout(event.id, navigation); + if (!navigation.recorded) { + this.armTimeout(event.id, navigation); + } return; } @@ -232,7 +238,8 @@ export class RouteNavigationTelemetryService { // Restarts under a new id: carry the start so the journey stays one scenario. this.pendingRedirect = { startedAt: navigation.startedAt, - redirectedFrom: navigation.redirectedFrom ?? navigation.route + redirectedFrom: navigation.redirectedFrom ?? navigation.route, + recorded: navigation.recorded }; return; } @@ -280,12 +287,16 @@ export class RouteNavigationTelemetryService { } } - /** Starts and settles the scenario at once, backdated to the journey's start. */ + /** 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; @@ -319,8 +330,8 @@ export class RouteNavigationTelemetryService { } /** - * Records a hung navigation once as `timeout`; a late end event then finds nothing. Browser only - * and outside Angular, so it never delays app stability or server rendering. + * 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) { @@ -329,7 +340,7 @@ export class RouteNavigationTelemetryService { const remainingMs = Math.max(0, navigation.startedAt + NAVIGATION_TIMEOUT_MS - Date.now()); navigation.timeoutHandle = this.zone.runOutsideAngular(() => setTimeout(() => { - const hung = this.take(navigationId); + const hung = this.navigations.get(navigationId); if (hung) { this.record(hung, hung.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => scenario.settle('timeout', { reason: 'navigation-timeout', metadata }) 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 index 5e63de4..7164cf2 100644 --- 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 @@ -130,28 +130,22 @@ function hasStrings( */ @Injectable({ providedIn: 'root' }) export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { - /** `null` (RUM off for the session) unless the answer is complete, valid and unexpired. */ + /** + * `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 { - let response: Response; - try { - response = await fetch(RUM_BROKER_URL, { - headers: { Accept: 'application/json' }, - cache: 'no-store' // live AWS credentials - }); - } catch { - return null; - } - + const response = await fetch(RUM_BROKER_URL, { + headers: { Accept: 'application/json' }, + cache: 'no-store' // live AWS credentials + }); if (!response.ok) { - return null; - } - - let body: unknown; - try { - body = await response.json(); - } catch { - return null; + 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) { diff --git a/skills/ui-watchtower-integration/references/instrumentation-patterns.md b/skills/ui-watchtower-integration/references/instrumentation-patterns.md index 57d1d79..7e242ae 100644 --- a/skills/ui-watchtower-integration/references/instrumentation-patterns.md +++ b/skills/ui-watchtower-integration/references/instrumentation-patterns.md @@ -86,20 +86,23 @@ async load(): Promise { **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()`. On error -it 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: +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 { - 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 } }) }) - ); + 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 } }) }) + ); + }); } ``` diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index bac1ff6..34a55cb 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -68,6 +68,7 @@ Read before writing anything — extend the app, don't restructure it. - Inside → install the latest version. - Older → the newest release whose peers fit (`npm view … versions`), 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. @@ -83,7 +84,9 @@ Read before writing anything — extend the app, don't restructure it. nothing is, offer `ui-watchtower-integration`. 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. + ``, 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 @@ -103,7 +106,7 @@ Ask the user only what the code cannot answer, in one message: ```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) +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 @@ -172,7 +175,8 @@ export const appConfig: ApplicationConfig = { - Start with **no** `withScenarios` / `withBIEvents` / `withLogging` / `withRedaction` features. Add one only for a stated reason, and record the reason in a comment. - Credentials: copy `assets/rum-credentials.provider.ts`, point it at the broker endpoint and adapt - the response check. It returns `null` (RUM off, app unaffected) for anything incomplete, and sets + 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 @@ -206,6 +210,9 @@ next to the schema and add `provideRouteNavigationTelemetry()` next to `provideR `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): @@ -250,7 +257,8 @@ credentials provider — RUM's one automatic page view per load is then right. (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`); + `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 — 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 index 4b5ada7..d6c3043 100644 --- a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.spec.ts @@ -325,7 +325,7 @@ describe('RouteNavigationTelemetryService', () => { beforeEach(() => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })); afterEach(() => vi.useRealTimers()); - it('records a hung guard once as timeout under its template, and nothing when it ends late', async () => { + 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([]); @@ -333,10 +333,11 @@ describe('RouteNavigationTelemetryService', () => { 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([]); + expect(pageViews).toEqual(['/hang']); }); it('records a lazy route that never loads as timeout under (unrecognized)', async () => { diff --git a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts index 0268cc7..9b41eed 100644 --- a/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts +++ b/skills/ui-watchtower-setup/assets/route-navigation-telemetry.service.ts @@ -139,17 +139,20 @@ interface TrackedNavigation { 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; +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 — and backdated to the click - * or the first `NavigationStart`. Start it with {@link provideRouteNavigationTelemetry}; call - * {@link markNavigationIntent} from nav-link click handlers. + * 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 { @@ -196,10 +199,13 @@ export class RouteNavigationTelemetryService { const redirect = this.consumePendingRedirect(); const navigation: TrackedNavigation = { startedAt: redirect?.startedAt ?? startedAt, - redirectedFrom: redirect?.redirectedFrom + redirectedFrom: redirect?.redirectedFrom, + recorded: redirect?.recorded }; this.navigations.set(event.id, navigation); - this.armTimeout(event.id, navigation); + if (!navigation.recorded) { + this.armTimeout(event.id, navigation); + } return; } @@ -232,7 +238,8 @@ export class RouteNavigationTelemetryService { // Restarts under a new id: carry the start so the journey stays one scenario. this.pendingRedirect = { startedAt: navigation.startedAt, - redirectedFrom: navigation.redirectedFrom ?? navigation.route + redirectedFrom: navigation.redirectedFrom ?? navigation.route, + recorded: navigation.recorded }; return; } @@ -280,12 +287,16 @@ export class RouteNavigationTelemetryService { } } - /** Starts and settles the scenario at once, backdated to the journey's start. */ + /** 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; @@ -319,8 +330,8 @@ export class RouteNavigationTelemetryService { } /** - * Records a hung navigation once as `timeout`; a late end event then finds nothing. Browser only - * and outside Angular, so it never delays app stability or server rendering. + * 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) { @@ -329,7 +340,7 @@ export class RouteNavigationTelemetryService { const remainingMs = Math.max(0, navigation.startedAt + NAVIGATION_TIMEOUT_MS - Date.now()); navigation.timeoutHandle = this.zone.runOutsideAngular(() => setTimeout(() => { - const hung = this.take(navigationId); + const hung = this.navigations.get(navigationId); if (hung) { this.record(hung, hung.route ?? UNRECOGNIZED_ROUTE, (scenario, metadata) => scenario.settle('timeout', { reason: 'navigation-timeout', metadata }) diff --git a/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts b/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts index ed21092..73ef009 100644 --- a/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts +++ b/skills/ui-watchtower-setup/assets/rum-credentials.provider.spec.ts @@ -50,9 +50,9 @@ describe('AppRumCredentialsProvider', () => { ['invalid JSON', () => answer('')], ['an error status', () => answer(valid, 503)], ['a network failure', () => vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('offline')))] - ])('returns null for %s', async (_label, arrange) => { + ])('rejects on %s', async (_label, arrange) => { arrange(); - await expect(load()).resolves.toBeNull(); + await expect(load()).rejects.toThrow(); }); it('carries the typed optional settings through', async () => { diff --git a/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts index 5e63de4..7164cf2 100644 --- a/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts +++ b/skills/ui-watchtower-setup/assets/rum-credentials.provider.ts @@ -130,28 +130,22 @@ function hasStrings( */ @Injectable({ providedIn: 'root' }) export class AppRumCredentialsProvider implements UwtRumCredentialsProvider { - /** `null` (RUM off for the session) unless the answer is complete, valid and unexpired. */ + /** + * `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 { - let response: Response; - try { - response = await fetch(RUM_BROKER_URL, { - headers: { Accept: 'application/json' }, - cache: 'no-store' // live AWS credentials - }); - } catch { - return null; - } - + const response = await fetch(RUM_BROKER_URL, { + headers: { Accept: 'application/json' }, + cache: 'no-store' // live AWS credentials + }); if (!response.ok) { - return null; - } - - let body: unknown; - try { - body = await response.json(); - } catch { - return null; + 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) { diff --git a/skills/ui-watchtower-setup/evals/evals.json b/skills/ui-watchtower-setup/evals/evals.json index 99635da..b1a2157 100644 --- a/skills/ui-watchtower-setup/evals/evals.json +++ b/skills/ui-watchtower-setup/evals/evals.json @@ -27,7 +27,7 @@ "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 on any failure", + "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", diff --git a/skills/ui-watchtower-setup/references/micro-frontends.md b/skills/ui-watchtower-setup/references/micro-frontends.md index cc2a73b..70588d5 100644 --- a/skills/ui-watchtower-setup/references/micro-frontends.md +++ b/skills/ui-watchtower-setup/references/micro-frontends.md @@ -1,7 +1,9 @@ # 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. +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 From a1442472b4b46a33adf2fc88da90f49f4d6c2a8c Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 8 Oct 2026 20:47:19 +0200 Subject: [PATCH 18/25] one more round --- docs/guides/ui-watchtower-integration.md | 2 ++ docs/guides/ui-watchtower-setup.md | 2 ++ .../src/app/customers/customers.ts | 24 +++++++++++------ .../references/instrumentation-patterns.md | 27 ++++++++++++------- skills/ui-watchtower-setup/SKILL.md | 9 +++++-- .../assets/log-api.provider.ts | 3 ++- skills/ui-watchtower-setup/evals/evals.json | 2 +- 7 files changed, 48 insertions(+), 21 deletions(-) diff --git a/docs/guides/ui-watchtower-integration.md b/docs/guides/ui-watchtower-integration.md index 548d129..1fe1185 100644 --- a/docs/guides/ui-watchtower-integration.md +++ b/docs/guides/ui-watchtower-integration.md @@ -66,6 +66,8 @@ then says it is based on the code only. ```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 index cd943e9..97d21bd 100644 --- a/docs/guides/ui-watchtower-setup.md +++ b/docs/guides/ui-watchtower-setup.md @@ -71,6 +71,8 @@ If the library is already partly wired, only the missing pieces are added. ```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/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 index 1af5171..f9b2e64 100644 --- 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 @@ -7,15 +7,23 @@ import { } from '@absaoss-cps/ngx-ui-watchtower'; import '../telemetry/telemetry.schema'; -/** HttpErrorResponse.message quotes the request URL, path ids included — keep class and status only. */ +/** 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 status = (error as { status?: unknown } | null)?.status; - return typeof status === 'number' - ? { - error: Object.assign(new Error('HTTP request failed'), { name: 'HttpErrorResponse' }), - statusCode: status - } - : { error }; + 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 { diff --git a/skills/ui-watchtower-integration/references/instrumentation-patterns.md b/skills/ui-watchtower-integration/references/instrumentation-patterns.md index 7e242ae..4814ff2 100644 --- a/skills/ui-watchtower-integration/references/instrumentation-patterns.md +++ b/skills/ui-watchtower-integration/references/instrumentation-patterns.md @@ -46,18 +46,27 @@ Use a small, consistent set of `reason` strings — they are grouping keys: `'co 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. Keep its class and status: +(`/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 -/** HttpErrorResponse.message quotes the request URL, path ids included — keep class and status only. */ +/** 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 status = (error as { status?: unknown } | null)?.status; - return typeof status === 'number' - ? { - error: Object.assign(new Error('HTTP request failed'), { name: 'HttpErrorResponse' }), - statusCode: status - } - : { error }; // not an HTTP error: the library normalizes and redacts it + 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 }; } ``` diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index 34a55cb..d2b0bce 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -66,7 +66,8 @@ Read before writing anything — extend the app, don't restructure it. 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`), pinned; none → stop. + - 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 @@ -128,7 +129,7 @@ different destinations also fail bootstrap. | 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; swap the one line later | +| RUM not available yet | `provideUwtTelemetrySink('noop')` | explicit opt-out; everything runs, nothing ships; switching to RUM later takes more than this line — Step 9 | | Own backend | `provideUwtTelemetryDestination(MySink)` (a class extending `UwtTelemetrySink`) | see the API reference, "Custom destination" | **Log provider:** @@ -291,6 +292,10 @@ app's version — never silence the error. 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. Then tell the user the next step: **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`. diff --git a/skills/ui-watchtower-setup/assets/log-api.provider.ts b/skills/ui-watchtower-setup/assets/log-api.provider.ts index a74df0f..befe3b0 100644 --- a/skills/ui-watchtower-setup/assets/log-api.provider.ts +++ b/skills/ui-watchtower-setup/assets/log-api.provider.ts @@ -33,7 +33,8 @@ export class AppLogApiProvider implements UwtLogApiProvider { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(record), - // Lets the request outlive a page that is being closed. + // 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); } diff --git a/skills/ui-watchtower-setup/evals/evals.json b/skills/ui-watchtower-setup/evals/evals.json index b1a2157..ab16ea6 100644 --- a/skills/ui-watchtower-setup/evals/evals.json +++ b/skills/ui-watchtower-setup/evals/evals.json @@ -109,7 +109,7 @@ "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, and install aws-rum-web", + "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" ] }, From 876eac9589061a06c0e93d003e3d617ff923aad2 Mon Sep 17 00:00:00 2001 From: fateeand Date: Fri, 9 Oct 2026 09:08:20 +0200 Subject: [PATCH 19/25] update evaluation --- skills/ui-watchtower-integration/SKILL.md | 7 +++++-- skills/ui-watchtower-setup/SKILL.md | 5 +++-- skills/ui-watchtower-setup/evals/evals.json | 6 +++--- .../ui-watchtower-setup/references/rum-migration.md | 13 ++++++++++--- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/skills/ui-watchtower-integration/SKILL.md b/skills/ui-watchtower-integration/SKILL.md index 053584f..0d9edac 100644 --- a/skills/ui-watchtower-integration/SKILL.md +++ b/skills/ui-watchtower-integration/SKILL.md @@ -93,7 +93,8 @@ which outcomes it can have. ### Step 4 · Present a merged proposal -Use this shape. `Source` says where each item came from — `you` (the user asked for it, or it answers +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 @@ -139,7 +140,9 @@ Follow `references/instrumentation-patterns.md`: 2. Each journey: start → steps → settle with the **right** status — `complete`, `incomplete` (an expected dead end, e.g. no results), `fail` (a defect, with a `statusCode`), `cancel` (the user left or a newer request superseded it). Timeout is automatic. Collapsing `abandoned` or - `incomplete` into `failure` makes the failure rate useless for alerts. + `incomplete` into `failure` makes the failure rate useless for alerts. 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. 3. **Never pass a raw `HttpErrorResponse` to `fail()` or `logger.error()`** — its message quotes the URL, ids included. Use the patterns' `safeHttpFailure(error)`; with `traceScenario`, settle it in a `catchError` placed before the operator. diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index d2b0bce..f7cf005 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -55,7 +55,7 @@ Copy this checklist and track progress: - [ ] 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 +- [ ] Step 9: Hand over (the report ends with the pointer to ui-watchtower-integration) ``` ### Step 1 · Detect (read-only) @@ -296,7 +296,8 @@ With `'noop'`, hand over the switch to RUM as a checklist — it is more than on `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. -Then tell the user the next step: **to measure the app's own journeys and interactions, use the +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`. diff --git a/skills/ui-watchtower-setup/evals/evals.json b/skills/ui-watchtower-setup/evals/evals.json index ab16ea6..f3119ca 100644 --- a/skills/ui-watchtower-setup/evals/evals.json +++ b/skills/ui-watchtower-setup/evals/evals.json @@ -162,9 +162,9 @@ "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 recorder, under the route template: disableAutoPageView: true with the route service's page-view recorder, and the old manual recordPageView calls dropped", - "Replaces the email session attribute with sink.setUserId(opaqueId) and calls out that emails must not be sent", - "Replaces per-keystroke search tracking with a debounced BI event without the raw query text" + "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)" ] }, { diff --git a/skills/ui-watchtower-setup/references/rum-migration.md b/skills/ui-watchtower-setup/references/rum-migration.md index e3d993d..b817222 100644 --- a/skills/ui-watchtower-setup/references/rum-migration.md +++ b/skills/ui-watchtower-setup/references/rum-migration.md @@ -15,9 +15,9 @@ rg -n "recordEvent|recordError|recordPageView|dispatch|pinUserId|addSessionAttri 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) | -|-----------|---------|------------|--------------|--------------------------------------| -| | | | | | +| 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. @@ -26,9 +26,16 @@ it handles sign-in/sign-out, and whether it records page views manually. 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 | From c5807f1900805bda0e43e49843a36c3b600006f5 Mon Sep 17 00:00:00 2001 From: fateeand Date: Fri, 9 Oct 2026 10:29:21 +0200 Subject: [PATCH 20/25] reference fix --- skills/shared/references/ngx-ui-watchtower-api.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/skills/shared/references/ngx-ui-watchtower-api.md b/skills/shared/references/ngx-ui-watchtower-api.md index dbeb13e..65eafde 100644 --- a/skills/shared/references/ngx-ui-watchtower-api.md +++ b/skills/shared/references/ngx-ui-watchtower-api.md @@ -165,6 +165,16 @@ inject(UwtBITelemetryService).track( ); ``` +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. A wrapper of your own should type its parameter `UwtBIEventName`, not `string`, or it stops compiling once the registry is augmented. From 545dab95ee51ef0fc631e0f7424ce8439df59b6e Mon Sep 17 00:00:00 2001 From: fateeand Date: Fri, 9 Oct 2026 17:39:42 +0200 Subject: [PATCH 21/25] make sure concepts are not merged in the consumer app --- docs/guides/ui-watchtower-integration.md | 2 +- .../references/ngx-ui-watchtower-api.md | 2 +- skills/ui-watchtower-integration/SKILL.md | 30 +- .../evals/evals.json | 37 +- .../files/wired-checkout-angular/README.md | 10 + .../files/wired-checkout-angular/angular.json | 73 ++++ .../files/wired-checkout-angular/package.json | 30 ++ .../src/app/app.config.ts | 52 +++ .../src/app/app.routes.ts | 14 + .../src/app/app.spec.ts | 29 ++ .../wired-checkout-angular/src/app/app.ts | 20 + .../src/app/checkout/cart.ts | 49 +++ .../src/app/checkout/order-confirmation.ts | 7 + .../src/app/checkout/order-summary.ts | 32 ++ .../route-navigation-telemetry.service.ts | 368 ++++++++++++++++++ .../app/telemetry/rum-credentials.provider.ts | 182 +++++++++ .../src/app/telemetry/telemetry.schema.ts | 17 + .../wired-checkout-angular/src/index.html | 12 + .../files/wired-checkout-angular/src/main.ts | 5 + .../wired-checkout-angular/src/styles.css | 1 + .../wired-checkout-angular/tsconfig.app.json | 18 + .../wired-checkout-angular/tsconfig.json | 31 ++ .../wired-checkout-angular/tsconfig.spec.json | 16 + .../references/instrumentation-patterns.md | 24 +- 24 files changed, 1046 insertions(+), 15 deletions(-) create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/README.md create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/angular.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/package.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.config.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.routes.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.spec.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/app.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/checkout/cart.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/checkout/order-confirmation.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/checkout/order-summary.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/telemetry/route-navigation-telemetry.service.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/telemetry/rum-credentials.provider.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/app/telemetry/telemetry.schema.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/index.html create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/main.ts create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/src/styles.css create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/tsconfig.app.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/tsconfig.json create mode 100644 skills/ui-watchtower-integration/evals/files/wired-checkout-angular/tsconfig.spec.json diff --git a/docs/guides/ui-watchtower-integration.md b/docs/guides/ui-watchtower-integration.md index 1fe1185..c9609f5 100644 --- a/docs/guides/ui-watchtower-integration.md +++ b/docs/guides/ui-watchtower-integration.md @@ -22,7 +22,7 @@ first. | 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 | +| 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 | --- diff --git a/skills/shared/references/ngx-ui-watchtower-api.md b/skills/shared/references/ngx-ui-watchtower-api.md index 65eafde..3e1ab9f 100644 --- a/skills/shared/references/ngx-ui-watchtower-api.md +++ b/skills/shared/references/ngx-ui-watchtower-api.md @@ -176,7 +176,7 @@ The name is in **`eventName`** (not `name`). A test with a recording sink assert `payload.eventName` and `payload.metadata`. Identical events (name + scenarioId + eventType + feature + metadata) within 400 ms collapse into -one — no click throttling needed. A wrapper of your own should type its parameter `UwtBIEventName`, +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 diff --git a/skills/ui-watchtower-integration/SKILL.md b/skills/ui-watchtower-integration/SKILL.md index 0d9edac..ef0ddeb 100644 --- a/skills/ui-watchtower-integration/SKILL.md +++ b/skills/ui-watchtower-integration/SKILL.md @@ -23,6 +23,13 @@ already in place: | **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. @@ -58,7 +65,10 @@ 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. +`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 @@ -88,6 +98,10 @@ Load `references/instrumentation-patterns.md` (section 1). Within the scope from - **Every meaningful or irreversible click** → a BI event. - **Every `catch` that ends a journey** → that scenario's `fail(...)` (Step 6, rule 3). +Decide for every candidate whether it is a scenario, a BI event, or both. A wait with no interesting +click is a scenario; a click with no wait is a BI event; a click inside a journey is both — a scenario +for the journey and a BI event linked to it with `{ scenarioId }`. + Map each user answer to concrete code: which component or service, which call starts the journey, which outcomes it can have. @@ -103,12 +117,12 @@ one of their questions) or `suggested` (found in the code). Put the user's items Answers: Already instrumented (unchanged): -### Scenarios +### 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 +### BI events (UwtBITelemetryService) | Source | Name | Where (file) | Metadata (flat, no PII) | |---|---|---|---| | suggested | export_clicked | customers/customers.ts | format | @@ -133,7 +147,13 @@ changed materially. Implement nothing before this. ### Step 6 · Implement what was confirmed -Follow `references/instrumentation-patterns.md`: +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. @@ -180,6 +200,8 @@ from the user's original request and why. ## Gotchas - **Telemetry never throws into the app** — don't wrap calls in try/catch. +- **Scenario and BI stay separate** — use `UwtScenarioTelemetryService` and `UwtBITelemetryService` + directly where needed; no merged service, facade or unified `track…` API unless the user asks. ## Out of scope diff --git a/skills/ui-watchtower-integration/evals/evals.json b/skills/ui-watchtower-integration/evals/evals.json index 7f98290..905c7d3 100644 --- a/skills/ui-watchtower-integration/evals/evals.json +++ b/skills/ui-watchtower-integration/evals/evals.json @@ -60,7 +60,7 @@ "evals/files/wired-angular/tsconfig.spec.json" ], "expectations": [ - "Does not re-ask what the prompt already answered (which journeys and interactions); asks only remaining open points, if any", + "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", @@ -184,6 +184,41 @@ "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)" + ] } ] } 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/references/instrumentation-patterns.md b/skills/ui-watchtower-integration/references/instrumentation-patterns.md index 4814ff2..244d94e 100644 --- a/skills/ui-watchtower-integration/references/instrumentation-patterns.md +++ b/skills/ui-watchtower-integration/references/instrumentation-patterns.md @@ -15,13 +15,19 @@ How to choose, implement and settle scenarios and BI events. ## 1. Finding candidates -| Look for | Becomes | -|------------------------------------------------------------------|-------------------------------------------------------------| -| An `await` / subscription the user waits on before seeing a result | Scenario (route load, gating fetch, submit, upload, export) | -| A meaningful or irreversible click; a setting change | BI event | -| A `catch` / `error` handler that ends a journey | That scenario's `fail(safeHttpFailure(error))` (§2) | -| A guard, empty result, feature flag routing elsewhere | That scenario's `incomplete({ reason })` | -| Component teardown, supersession by a newer request | That scenario's `cancel({ reason })` | +| 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. @@ -209,7 +215,9 @@ check. - Inside a journey, link it: `track('export_clicked', { format }, { scenarioId: scenario.id })`. - Debounce high-frequency sources (search boxes, sliders) before `track()` — e.g. `debounceTime(400)`. Repeated identical clicks within 400 ms are already collapsed by the library. -- A thin app wrapper is fine; type its name parameter `UwtBIEventName`. +- 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 From 3ef12c3b4f6a48c8b3071cf62527026a29d81c33 Mon Sep 17 00:00:00 2001 From: fateeand Date: Fri, 9 Oct 2026 20:17:14 +0200 Subject: [PATCH 22/25] update wiring proposals evaluation --- skills/ui-watchtower-integration/SKILL.md | 12 ++++-- .../evals/evals.json | 38 ++++++++++++++++++- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/skills/ui-watchtower-integration/SKILL.md b/skills/ui-watchtower-integration/SKILL.md index ef0ddeb..a7cbcc3 100644 --- a/skills/ui-watchtower-integration/SKILL.md +++ b/skills/ui-watchtower-integration/SKILL.md @@ -91,7 +91,10 @@ so in the proposal. ### Step 3 · Scan the code for candidates -Load `references/instrumentation-patterns.md` (section 1). Within the scope from Step 2, find: +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` (section 1), read the routes, the components and services +in scope, and the HTTP calls behind them. Within the scope from Step 2, find: - **Every wait the user actually sits through** → a scenario (a fetch that gates a screen, a submit, an upload, an export). @@ -102,8 +105,10 @@ Decide for every candidate whether it is a scenario, a BI event, or both. A wait click is a scenario; a click with no wait is a BI event; a click inside a journey is both — a scenario for the journey and a BI event linked to it with `{ scenarioId }`. -Map each user answer to concrete code: which component or service, which call starts the journey, -which outcomes it can have. +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 @@ -114,6 +119,7 @@ one of their questions) or `suggested` (found in the code). Put the user's items ```markdown ## Proposed telemetry for +Scanned: Answers: Already instrumented (unchanged): diff --git a/skills/ui-watchtower-integration/evals/evals.json b/skills/ui-watchtower-integration/evals/evals.json index 905c7d3..7354db6 100644 --- a/skills/ui-watchtower-integration/evals/evals.json +++ b/skills/ui-watchtower-integration/evals/evals.json @@ -27,7 +27,7 @@ ], "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 — covering at least the questions to answer, the important journeys and what counts as success, and anything that must not be tracked", + "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", @@ -138,7 +138,7 @@ "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) — each with a Source label — without a second load scenario or changes to the existing one; new names go into the existing schema, with tests.", + "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", @@ -219,6 +219,40 @@ "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" + ] } ] } From dbf5cf2fd13953b263213e9d3b36229e5858cdd7 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 14 Oct 2026 12:33:35 +0200 Subject: [PATCH 23/25] add withTelemetryEnabled info --- .../references/ngx-ui-watchtower-api.md | 18 +++++++++++ skills/ui-watchtower-integration/SKILL.md | 3 ++ .../evals/trigger-eval.json | 4 +++ skills/ui-watchtower-setup/SKILL.md | 27 +++++++++++++--- skills/ui-watchtower-setup/evals/evals.json | 31 +++++++++++++++++++ .../evals/trigger-eval.json | 4 +++ .../references/micro-frontends.md | 2 +- 7 files changed, 83 insertions(+), 6 deletions(-) diff --git a/skills/shared/references/ngx-ui-watchtower-api.md b/skills/shared/references/ngx-ui-watchtower-api.md index 3e1ab9f..142247f 100644 --- a/skills/shared/references/ngx-ui-watchtower-api.md +++ b/skills/shared/references/ngx-ui-watchtower-api.md @@ -24,6 +24,7 @@ differ, they win). Import only from the two entry points below — no deep impor 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 @@ -80,6 +81,23 @@ All return `EnvironmentProviders`; they work in `ApplicationConfig.providers` an 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 diff --git a/skills/ui-watchtower-integration/SKILL.md b/skills/ui-watchtower-integration/SKILL.md index a7cbcc3..8cd6da5 100644 --- a/skills/ui-watchtower-integration/SKILL.md +++ b/skills/ui-watchtower-integration/SKILL.md @@ -206,6 +206,9 @@ 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. - **Scenario and BI stay separate** — use `UwtScenarioTelemetryService` and `UwtBITelemetryService` directly where needed; no merged service, facade or unified `track…` API unless the user asks. diff --git a/skills/ui-watchtower-integration/evals/trigger-eval.json b/skills/ui-watchtower-integration/evals/trigger-eval.json index 171235b..2bac81b 100644 --- a/skills/ui-watchtower-integration/evals/trigger-eval.json +++ b/skills/ui-watchtower-integration/evals/trigger-eval.json @@ -46,5 +46,9 @@ { "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-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index f7cf005..62a1616 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -5,8 +5,8 @@ description: > 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, or - connect a micro-frontend shell or fragment. NOT for: instrumenting the app's own journeys or BI + 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: > @@ -82,7 +82,8 @@ Read before writing anything — extend the app, don't restructure it. 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`. + 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 @@ -129,7 +130,7 @@ different destinations also fail bootstrap. | 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 | +| 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:** @@ -175,6 +176,20 @@ export const appConfig: ApplicationConfig = { 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 @@ -282,7 +297,9 @@ Run, through the detected package manager, until all are green: 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. + 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 diff --git a/skills/ui-watchtower-setup/evals/evals.json b/skills/ui-watchtower-setup/evals/evals.json index f3119ca..a02a9ff 100644 --- a/skills/ui-watchtower-setup/evals/evals.json +++ b/skills/ui-watchtower-setup/evals/evals.json @@ -113,6 +113,37 @@ "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", diff --git a/skills/ui-watchtower-setup/evals/trigger-eval.json b/skills/ui-watchtower-setup/evals/trigger-eval.json index a9d0810..626be63 100644 --- a/skills/ui-watchtower-setup/evals/trigger-eval.json +++ b/skills/ui-watchtower-setup/evals/trigger-eval.json @@ -23,6 +23,10 @@ "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 diff --git a/skills/ui-watchtower-setup/references/micro-frontends.md b/skills/ui-watchtower-setup/references/micro-frontends.md index 70588d5..8e03a30 100644 --- a/skills/ui-watchtower-setup/references/micro-frontends.md +++ b/skills/ui-watchtower-setup/references/micro-frontends.md @@ -123,7 +123,7 @@ 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`) | +| 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) | From 5159fc0df56a71184830a87a71bd4f2d941645b0 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 14 Oct 2026 13:29:24 +0200 Subject: [PATCH 24/25] address feedback --- skills/ui-watchtower-integration/SKILL.md | 39 ++++--------------- .../references/instrumentation-patterns.md | 8 +++- 2 files changed, 14 insertions(+), 33 deletions(-) diff --git a/skills/ui-watchtower-integration/SKILL.md b/skills/ui-watchtower-integration/SKILL.md index 8cd6da5..593c60b 100644 --- a/skills/ui-watchtower-integration/SKILL.md +++ b/skills/ui-watchtower-integration/SKILL.md @@ -93,17 +93,9 @@ so in the proposal. 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` (section 1), read the routes, the components and services -in scope, and the HTTP calls behind them. Within the scope from Step 2, find: - -- **Every wait the user actually sits through** → a scenario (a fetch that gates a screen, a submit, - an upload, an export). -- **Every meaningful or irreversible click** → a BI event. -- **Every `catch` that ends a journey** → that scenario's `fail(...)` (Step 6, rule 3). - -Decide for every candidate whether it is a scenario, a BI event, or both. A wait with no interesting -click is a scenario; a click with no wait is a BI event; a click inside a journey is both — a scenario -for the journey and a BI event linked to it with `{ scenarioId }`. +`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 @@ -163,24 +155,11 @@ both, and do not move the calls into a shared service. Single-purpose helpers ar 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. Each journey: start → steps → settle with the **right** status — `complete`, `incomplete` (an - expected dead end, e.g. no results), `fail` (a defect, with a `statusCode`), `cancel` (the user - left or a newer request superseded it). Timeout is automatic. Collapsing `abandoned` or - `incomplete` into `failure` makes the failure rate useless for alerts. 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. -3. **Never pass a raw `HttpErrorResponse` to `fail()` or `logger.error()`** — its message quotes the - URL, ids included. Use the patterns' `safeHttpFailure(error)`; with `traceScenario`, settle it in a - `catchError` placed before the operator. -4. Settle open scenarios when their component is destroyed; under `switchMap`, give - `traceScenario` a `cancelOutcome`. Nothing downstream of `traceScenario` may stop after the - first value — no `take(1)`/`first()` after it, no `firstValueFrom()` on it (it records - `abandoned`): limit upstream and await with `lastValueFrom()`. -5. BI events inside a journey pass `{ scenarioId: scenario.id }`. Debounce high-frequency sources - (search boxes, sliders) before starting a scenario or sending a BI event — one per settled - input, never one per keystroke. -6. Metadata: flat primitives, low cardinality, no personal data. -7. Only if the app has a log backend (`UwtLoggerNames` declared): at a journey-ending error, also +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. @@ -209,8 +188,6 @@ from the user's original request and why. - **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. -- **Scenario and BI stay separate** — use `UwtScenarioTelemetryService` and `UwtBITelemetryService` - directly where needed; no merged service, facade or unified `track…` API unless the user asks. ## Out of scope diff --git a/skills/ui-watchtower-integration/references/instrumentation-patterns.md b/skills/ui-watchtower-integration/references/instrumentation-patterns.md index 244d94e..3d50117 100644 --- a/skills/ui-watchtower-integration/references/instrumentation-patterns.md +++ b/skills/ui-watchtower-integration/references/instrumentation-patterns.md @@ -47,6 +47,9 @@ over `settle(status, …)` so every failure path is findable by searching for `. 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'`. @@ -213,8 +216,9 @@ check. - 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()` — e.g. - `debounceTime(400)`. Repeated identical clicks within 400 ms are already collapsed by the library. +- 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`. From 95ee852355be6c27aaa858d131d20c7fac6f836f Mon Sep 17 00:00:00 2001 From: fateeand Date: Fri, 16 Oct 2026 21:46:35 +0200 Subject: [PATCH 25/25] ask for the shell eventNamespace when setting up a fragment --- skills/ui-watchtower-setup/SKILL.md | 5 ++++- skills/ui-watchtower-setup/evals/evals.json | 3 ++- .../evals/files/fragment/README.md | 1 + .../ui-watchtower-setup/references/micro-frontends.md | 11 +++++++++-- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/skills/ui-watchtower-setup/SKILL.md b/skills/ui-watchtower-setup/SKILL.md index 62a1616..5af80ea 100644 --- a/skills/ui-watchtower-setup/SKILL.md +++ b/skills/ui-watchtower-setup/SKILL.md @@ -102,6 +102,8 @@ Ask the user only what the code cannot answer, in one message: - **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. +- **Fragment**: the shell's `eventNamespace` (default `com.uwt`), unless the shell's code or + contract shows it. ### Step 2 · Install @@ -141,7 +143,8 @@ different destinations also fail bootstrap. | 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; +`com.uwt`; leave it unless Step 5 needs it, or this is a fragment: then use the shell's — +`references/micro-frontends.md`). 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: diff --git a/skills/ui-watchtower-setup/evals/evals.json b/skills/ui-watchtower-setup/evals/evals.json index a02a9ff..65d1851 100644 --- a/skills/ui-watchtower-setup/evals/evals.json +++ b/skills/ui-watchtower-setup/evals/evals.json @@ -58,7 +58,7 @@ "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", + "Provides the fragment contract (broadcast sink, lazy UWT_BROADCAST_CHANNEL factory reading window.top, distinct application name, the shell's eventNamespace (com.uwt by default), never the RUM sink) as a hand-off rather than editing fragment code", "Does not install aws-rum-web into any fragment" ] }, @@ -78,6 +78,7 @@ "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')", + "Sets eventNamespace: 'com.workspace', the shell's value from the README", "Does not add provideUwtTelemetryRumSink, UWT_RUM_CREDENTIALS_PROVIDER, provideUwtTelemetryBroadcastHost or UWT_LOG_API_PROVIDER", "Does not install aws-rum-web" ] diff --git a/skills/ui-watchtower-setup/evals/files/fragment/README.md b/skills/ui-watchtower-setup/evals/files/fragment/README.md index d43eed6..028affc 100644 --- a/skills/ui-watchtower-setup/evals/files/fragment/README.md +++ b/skills/ui-watchtower-setup/evals/files/fragment/README.md @@ -2,3 +2,4 @@ Embedded as `` inside the workspace shell, which already sends telemetry to CloudWatch RUM with ngx-ui-watchtower and hosts the broadcast channel. +The shell's telemetry uses `eventNamespace: 'com.workspace'`. diff --git a/skills/ui-watchtower-setup/references/micro-frontends.md b/skills/ui-watchtower-setup/references/micro-frontends.md index 8e03a30..1f2c3d0 100644 --- a/skills/ui-watchtower-setup/references/micro-frontends.md +++ b/skills/ui-watchtower-setup/references/micro-frontends.md @@ -76,6 +76,8 @@ providers: [ fragment or per navigation. - Publish it on `window`, not `sessionStorage` (copied into duplicated tabs) or a query parameter (fragments share the shell's `location`). +- The contract you hand to fragment teams states the shell's `eventNamespace` (`com.uwt` when it + sets none) — fragments must use the same one. ## Fragment — the contract to hand to fragment teams @@ -111,7 +113,12 @@ function shellChannelName(): string { } providers: [ - provideUwtTelemetry({ application: 'cart', environment, version }), // must differ from the shell + provideUwtTelemetry({ + application: 'cart', // must differ from the shell + environment, + version, + eventNamespace: 'com.uwt' // the shell's — must match + }), provideUwtTelemetrySink('broadcast'), { provide: UWT_BROADCAST_CHANNEL, useFactory: shellChannelName } // lazy — see below ]; @@ -123,7 +130,7 @@ 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 match the shell | `eventNamespace` (ask for the shell's value if the shell isn't in this repo; default `com.uwt`); 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) |