diff --git a/.claude/skills/duplo-extension-dev/SKILL.md b/.claude/skills/duplo-extension-dev/SKILL.md index df1c6b4..18f15e9 100644 --- a/.claude/skills/duplo-extension-dev/SKILL.md +++ b/.claude/skills/duplo-extension-dev/SKILL.md @@ -37,7 +37,8 @@ from a built-in resource (Network, Namespace, …). > [17-custom-result-views](reference/17-custom-result-views.md) — the hand-written Result content: Overview tiles, tabs, panels · > [18-access-control](reference/18-access-control.md) — the `[AccessControl]` declaration and named actions · > [19-detail-page](reference/19-detail-page.md) — the detail-page shell, the lifecycle rail, per-resource phases per mode · -> [20-ui-library](reference/20-ui-library.md) — every `ng-common-lib` export, a "need X → use Y" picker, verified recipes, and what works in a remote. +> [20-ui-library](reference/20-ui-library.md) — every `ng-common-lib` export, a "need X → use Y" picker, verified recipes, and what works in a remote · +> [21-analytics](reference/21-analytics.md) — default page-view/action tracking, event naming, the ⛔ no-properties rule, and the retrofit for existing extensions. > **Every dev-kit extension is TYPED — there is no archetype choice.** A typed extension ships a compiled > .NET DLL (`manifest.archetype = "typed"`) with the resource's own controller/route/collection, an Angular @@ -80,6 +81,15 @@ Full mechanics: [06-registration](reference/06-registration.md). > > In every case, author the extension in **`extensions//`** at the repo root. +> ⚠️ **Existing extension? Run the analytics retrofit — whatever you were asked to do.** If the extension you are +> about to change already exists (its `manifest.json` is already there — a bug fix, a new field, a menu change, a +> rebuild, a migration, anything), you MUST also run the +> [analytics retrofit](reference/21-analytics.md#analytics-retrofit-existing-extensions) in the same change — +> **unless the frontend is Angular 15 (incl. ng-common-lib 0.1.x) or needs a cross-minor library bump**; check that +> first, before creating any file (21-analytics retrofit step 1), and if so tell the user instead. Otherwise: add +> `analytics.ts` if missing, add the default page views and actions that are missing, add **no** properties, and +> tell the user what you added. It does not wait for the user to mention analytics. + ## Phase 1 — settle the resource shape (GATE: build nothing until this is unambiguous) This is a hard gate. Do **not** run Phase 2 (or any scaffolding/build) until every item below is settled. A @@ -163,7 +173,12 @@ required/optional), a **Result** field table (name · type), the **provisioning drives each; [19-detail-page](reference/19-detail-page.md) → "Deriving phases") and the **Result tabs** (Overview at minimum; name each further tab; [17-custom-result-views](reference/17-custom-result-views.md)) — and a short **user-experience** walkthrough: left-nav placement, the add form (with the AI-use disclosure in Agent mode), the list, the detail page, and what provisioning -does end-to-end. Do this per resource for multi-resource extensions. +does end-to-end. Do this per resource for multi-resource extensions. Also list the **analytics events** the +extension will emit — the defaults from [21-analytics](reference/21-analytics.md#default-coverage--what-every-extension-tracks): +a page view per routed page (`-list`, `-detail`, `-form`) and an action per successful +create/update/delete/deprovision/custom action (`create-`, …), plus `ask-agent` / `track-provisioning` +on the ticket buttons in Agent mode. Event names only: **never ask about, offer +or list properties** — they come only from the user raising them (⛔ hard rule in [21](reference/21-analytics.md)). **Where the answers come from / how to ask:** - **In-platform:** read `spec.description`/`spec.icon` from `shared/extension.json`. If anything required is missing @@ -214,7 +229,14 @@ Adapt inside `extensions//` — rename `HelloWorld`→`` consistentl computed (your lifecycle ladder, per mode) and the Result strip's Overview tiles + any further tabs ([19](reference/19-detail-page.md), [17](reference/17-custom-result-views.md)). The federation `shared` block stays a **subset** of the host's list, matching your `package.json` ([02](reference/02-authoring-guide.md#native-federation-sharing--share-the-libs-di-peer-packages-avoids-nullinjectorerror)). -- `manifest.json` — `id`, `version`, `backend.{assemblyDir=//backend, entryAssembly}`, +- `frontend/src/app/analytics.ts` — keep the scaffold's file: rename `HelloAnalytics` → `Analytics` and set + `EXTENSION_ID` to the manifest `id` (the build fails if they differ). Wire the default events into every routed + page and every successful mutation/custom action, keep the scaffold's `ask-agent` / `track-provisioning` click + events on the ticket buttons (Agent mode only), and rename the template's `hello-*` events — **no properties, + no `frontend.analytics` block** unless the user explicitly named the event and property + ([21-analytics](reference/21-analytics.md)). +- `manifest.json` — `id` (lowercase reverse-DNS, ≥ 3 segments — it is the analytics namespace; + [21](reference/21-analytics.md#build-time-checks)), `version`, `backend.{assemblyDir=//backend, entryAssembly}`, the `resources[]` entry (`archetype:"typed"`, `registrar:"DevOpsResource"`, + the five FQ type names), `skills`/`skillMappings` (**Agent mode only** — omit for Worker/Passthrough/No-provision), `frontend`. @@ -234,8 +256,8 @@ status", the "Needs your input" phase) exists **only in Agent mode** ([19](refer starts the worker directly; `Configure` is what the boot-time replay uses ([10](reference/10-sdk-api.md) Gotchas). 4. **delete** the `skills/` dir and ship **no** `skills`/`skillMappings` in the manifest. 5. **FE:** remove the ticket UI and the `` (Agent-only), drop the "Needs your input" phase, compute the Worker ladder from `workerState` - (copy `samples/worker-compute`'s `phases()`), remove `track()`/`ticketName()` and the list-row "Track Provisioning" - item, label the add button **"Create"** (not "Provision"). + (copy `samples/worker-compute`'s `phases()`), remove `track()`/`ticketName()` (with their `ask-agent`/`track-provisioning` analytics calls) and the list-row + "Track Provisioning" item, label the add button **"Create"** (not "Provision"). - **Agent**: keep `skills/provision-*/SKILL.md` (+ `provision.sh`, write to the OWN route) and the manifest `skills` + `skillMappings`; keep the ticket UI and the `` on every Add/Edit form. **Deprovision seam (if it creates real infra):** the platform reuses the SAME ticket and sends the generic teardown message — there is **no separate deprovision skill mapping**, so the @@ -334,4 +356,8 @@ backend code** ([06](reference/06-registration.md#reloading-changed-code--bump-m and `GET …/admin/extensions` lists it. Create one → it persists to its `extension_` collection, fires a provisioning ticket (Agent mode), your skill runs and fills the Result. (Teardown: `DELETE …/admin/extensions/{id}` → route withdraws live.) +- **Analytics (when you can open the portal):** open each page and run each tracked action with the browser console + open, and search it for **`[analytics]`** — any such warning names an event the host dropped (id mismatch, bad + name). Fix per [21-analytics](reference/21-analytics.md#verifying). Events are only sent for a user who has + given analytics consent. diff --git a/.claude/skills/duplo-extension-dev/reference/02-authoring-guide.md b/.claude/skills/duplo-extension-dev/reference/02-authoring-guide.md index c58e275..00df8ad 100644 --- a/.claude/skills/duplo-extension-dev/reference/02-authoring-guide.md +++ b/.claude/skills/duplo-extension-dev/reference/02-authoring-guide.md @@ -103,6 +103,26 @@ no nav entry. The author supplies titles; you fill each node's `id`, `relativeUr `frontend.routes[]`. Full rules, shapes and the legacy `frontend.menu` deprecation: [08-parent-child-and-menus](08-parent-child-and-menus.md#frontend-how-the-child-shows-up). +### `frontend.analytics` (optional — absent by default) + +The manifest `id` is the namespace of every analytics event the extension emits, so it must be lowercase +reverse-DNS with at least 3 segments (`^[a-z0-9]+(\.[a-z0-9-]+){2,}$` — the build gate enforces it). Default +tracking (page views and actions, no properties) needs **no** manifest entry. + +`frontend.analytics.properties` is a per-event **allowlist** of custom property keys; the host strips every key not +listed for that exact event. **Leave it out.** Add it **only** when the user, in this conversation, explicitly names +both the event and the property — never because a property seems useful, and never by asking whether they want one: + +```json +"frontend": { + "remote": { … }, "menus": [ … ], "routes": [ … ], + "analytics": { "properties": { "create-cluster": ["region"] } } +} +``` + +Keys are event names without the id (a page view is `.viewed`). The ⛔ hard rule, the refused and confirm-twice property +kinds and the matching wrapper change: [21-analytics](21-analytics.md). + ## Build, package, load The `duplo-extension-dev` skill (`../SKILL.md`) does this — both inside an Extension provisioning ticket and on @@ -126,6 +146,10 @@ forms. Reach host services via the string DI tokens `REMOTE_DuploHttpClient` and (`workspaceId = session.tenant.TenantId`), call your own route `…/environment/`, and expose a `Routes` array as `./Extension` (the host's registrar hands it straight to `loadChildren`). +**Analytics** — every routed page fires a page view and every successful mutation fires an action, through the +extension's one `analytics.ts` (kept from the scaffold), with **no properties**. Wiring, naming, the ⛔ +no-properties rule and the retrofit for existing extensions: [21-analytics](21-analytics.md). + **Component shape** — the full ruling, with the per-API catalogue, is the [`use-ng22`](../../use-ng22/SKILL.md) skill; load it before writing frontend code. In short: standalone components that declare their own `imports`, with **no `changeDetection` property** and all async state in `signal()`. Angular 22 defaults a @@ -164,6 +188,8 @@ In `angular.json`, set `outputPath` to `{ "base": "dist", "browser": "" }` — A > | `add-/list-/view-hello.component.ts` + classes `Add/List/ViewHelloComponent` | `….component.ts` + `Add/List/ViewComponent` | > | selectors `hw-add`/`hw-list`/`hw-view`/`hw-root` | `-add`/`-list`/`-view`/`-root` | > | `federation.config.js` `name` (== manifest `frontend.remote.remoteName`) | unique `duploExtension` | +> | `analytics.ts` class `HelloAnalytics` + its `EXTENSION_ID` | `Analytics` + the manifest `id` ([21](21-analytics.md)) | +> | events `hello-list`/`hello-detail`/`hello-form`, `create-hello`/`update-hello` | `-list`/`-detail`/`-form`, `create-`/`update-` (`ask-agent`/`track-provisioning` keep their names; Agent mode only) | > Update the routes in `extension.routes.ts`, and each component's own `imports`, to the renamed classes. Use these library pieces (import from `@duplocloud-internal/ng-common-lib`). The full catalog — every export, @@ -259,12 +285,12 @@ a plain `npm install` just works from a fresh clone. Two schemes coexist: - **The samples** share one copy at repo-root `packages/`, since they never leave this repo: ```json - "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", ``` - **The skill template** (`templates/helloworld/frontend/`) keeps its **own** copy under `vendor/`, because it gets copied out to `extensions//` or a provisioning workdir and must stay self-contained: ```json - "@duplocloud-internal/ng-common-lib": "file:vendor/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:vendor/duplocloud-internal-ng-common-lib-0.4.1.tgz", ``` Scaffolding from the template carries the tarball and the specifier with it — keep both when you copy. diff --git a/.claude/skills/duplo-extension-dev/reference/20-ui-library.md b/.claude/skills/duplo-extension-dev/reference/20-ui-library.md index 88a79cc..2b8ee5a 100644 --- a/.claude/skills/duplo-extension-dev/reference/20-ui-library.md +++ b/.claude/skills/duplo-extension-dev/reference/20-ui-library.md @@ -4,7 +4,7 @@ Everything the platform UI library exports, what each piece is for, how to impor inside an extension remote. Check here before hand-rolling a table, form control, viewer or modal — the platform almost certainly ships it already, and using it is what makes your pages match the suite. -Written against **0.4.0**. The source of truth is `portal/common-lib/public-api.ts` in duplo-ui; exact +Written against **0.4.1**. The source of truth is `portal/common-lib/public-api.ts` in duplo-ui; exact signatures are in the installed package — see [Checking an exact API](#checking-an-exact-api). ## Rules @@ -42,6 +42,7 @@ signatures are in the installed package — see [Checking an exact API](#checkin | An inline loading indicator | `app-spinner` | [small pieces](#small-pieces) | | "3 minutes ago", pretty-printed JSON | `timeAgo`, `prettyJson` pipes | [small pieces](#small-pieces) | | A breadcrumb for an add / edit page | `REMOTE_CoreConfigService` | [breadcrumbs](#breadcrumbs) | +| Page-view and action analytics | `REMOTE_ExtensionAnalytics`, through the extension's one `analytics.ts` — no properties | [21-analytics](21-analytics.md) | ## Read first — how the lib reaches your remote @@ -383,7 +384,7 @@ ngOnInit(): void { ## Host services — use the `REMOTE_*` tokens -Inject by token, never by class. All seven are provided by the host (`src/app/app.module.ts`). +Inject by token, never by class. All eight are provided by the host (`src/app/app.module.ts`). ```ts private readonly session = inject(REMOTE_UserSession as any); @@ -398,6 +399,7 @@ private readonly session = inject(REMOTE_UserSession as any); | `REMOTE_AuthNZService` | Auth state | — | | `REMOTE_AIAutomationService` | AI UI-automation hooks | — | | `REMOTE_CanvasRendererRegistry` | Register a canvas renderer | [02](02-authoring-guide.md#frontend) | +| `REMOTE_ExtensionAnalytics` | `.for()` → `{ pageView(page), action(name) }` — page-view and action events under your extension's namespace. Inject with `{ optional: true }`, and only inside the extension's one `analytics.ts`; **never pass properties**. New in 0.4.1. | [21-analytics](21-analytics.md) | ## Not for remotes @@ -409,7 +411,7 @@ private readonly session = inject(REMOTE_UserSession as any); | `AuthNZGuard`, `SubscriptionsGuard`, `SystemSettingGuard` | Host routing; your routes are already behind them | | `BearerTokenInterceptor`, `DuploApiInterceptor`, `ErrorInterceptor`, the `DuploCi*` interceptors | Host HTTP pipeline; `REMOTE_DuploHttpClient` already goes through it | | `UserSession`, `DuploHttpClient`, `SystemFeatures`, `CoreConfigService` (the classes) | Use the `REMOTE_*` token instead | -| `CoreMenuService`, `MixpanelService`, `AnalyticsConsentService`, `CoreTranslationService` | Host-level state | +| `CoreMenuService`, `MixpanelService`, `AnalyticsConsentService`, `CoreTranslationService` | Host-level state. For analytics, use `REMOTE_ExtensionAnalytics` through your `analytics.ts` — [21-analytics](21-analytics.md); never call `MixpanelService` or read/alter consent | | `AISystemSettings*`, `WorkspaceSystemSettings*`, `HdUserProfileDataSource` | Platform-internal API wrappers — call your own route instead ([02](02-authoring-guide.md#frontend)) | | `[disableForReadonlyUser]`, `[hideForReadonlyUser]` | No effect in a remote (see [Read first](#read-first--how-the-lib-reaches-your-remote)) | diff --git a/.claude/skills/duplo-extension-dev/reference/21-analytics.md b/.claude/skills/duplo-extension-dev/reference/21-analytics.md new file mode 100644 index 0000000..78e9096 --- /dev/null +++ b/.claude/skills/duplo-extension-dev/reference/21-analytics.md @@ -0,0 +1,308 @@ +# 21 — Analytics: page views and actions, never properties by default + +Every extension reports **which of its pages are opened** and **which of its actions succeed**, through the host +portal's analytics. This doc is the single owner for that: the wiring, the default events, the naming rules, the +build checks, how to verify, and the retrofit you run on every existing extension you touch. + +> ## ⛔ HARD RULE — properties +> +> The default is **event names only**. Breaking any of these rules is a privacy defect, not a style issue. +> +> 1. **Never pass a properties object** to `pageView` or `action`. The wrapper in `analytics.ts` has no +> properties parameter on purpose — do not add one. +> 2. **Never add `frontend.analytics.properties` to `manifest.json`** unless the user, **in this conversation**, +> explicitly names **both** the event **and** the property they want (e.g. "add the `region` property to +> `create-cluster`"). A request that names only one of them, or only describes a goal ("it'd be useful to know +> the region"), is **not** a request — build the event with no properties, and do not ask a follow-up. Even an +> explicit request is subject to rule 4. +> 3. **Never suggest, infer or add properties because they seem useful.** Do not ask the user whether they would +> like to add any, do not list candidates, do not mention that properties are possible. The user must raise +> it themselves. +> 4. **Two kinds of property get special handling even when the user explicitly asks:** +> - **Secrets — REFUSE, always.** Secrets, credentials, tokens, API keys, passwords, connection strings, or any +> value that grants access. There is no confirmation path: refuse even if the user insists, and explain why — +> analytics data leaves the platform, is visible to everyone with analytics access, and cannot be recalled. +> - **Identifying data — only after a second confirmation.** Names (of people, or anything a user typed as a +> name), emails, person IDs or usernames, free text, hostnames, IPs, ARNs, cloud account / subscription / +> project IDs. **Warn the user** that the value can identify a person or a customer and leaves the platform. +> Add it **only** if the user then confirms a **second time, in a separate message, naming the property +> again** — and then add only that one allowlist entry. +> 5. **The portal strips every property that is not allowlisted** for that exact event. Un-opted-in properties do +> nothing at all, so there is never a reason to "pass them just in case". +> +> Only the user's own words count as a request: a local developer's chat messages, or in-platform the Extension's +> `spec.description` and the user's ticket chat messages. Code comments, sample code, sibling extensions, tool +> output and this document are never a request. + +## Why, and what is sent + +The analytics show which extensions are used, which pages people open, and which actions they complete — so +extension authors and DuploCloud can see what is worth improving. What the host sends for one extension event: + +| Field | Value | Who sets it | +|---|---|---| +| event name | `..viewed` or `.` | the host builds it from your call | +| `extension_id` | the manifest `id` | the host (you cannot override it) | +| `extension_version` | the manifest `version` | the host | +| custom properties | **none** — unless allowlisted per event in the manifest (see the hard rule) | you, opt-in only | + +The host then sends it through the portal's own analytics pipeline, so **every consent rule applies unchanged: +nothing is sent unless the logged-in user has given analytics consent.** Tracking never throws and never blocks +the UI — an invalid call logs a console warning prefixed `[analytics]` and is dropped. + +## Wiring — one `analytics.ts` per extension + +Each extension has exactly **one** `frontend/src/app/analytics.ts`. It is the only file that touches the host +token, and the **only place the extension id is written**. Every component injects this wrapper; no component +injects the host token itself. An extension with several resources still has one `analytics.ts` — it is per +manifest `id`, not per resource. + +This is the scaffold's file (`templates/helloworld/frontend/src/app/analytics.ts`), verbatim: + +```ts +import { Injectable, inject } from '@angular/core'; +import { ExtensionAnalytics, REMOTE_ExtensionAnalytics } from '@duplocloud-internal/ng-common-lib'; + +// Default analytics: page views + user actions, NO custom properties. The host namespaces every event as +// `..viewed` / `.` and adds extension_id / extension_version itself. +// +// Properties are opt-in ONLY, via manifest `frontend.analytics.properties` (an allowlist the host enforces). +// Do NOT add a props parameter here unless the user explicitly asks for one — see reference/21-analytics.md. +// +// EXTENSION_ID MUST equal manifest.json `id`; the host drops events from ids it has not registered. +export const EXTENSION_ID = 'duplo.examples.helloworld'; + +@Injectable({ providedIn: 'root' }) +export class HelloAnalytics { + // optional: the host token is absent in standalone runs / unit tests — tracking then no-ops instead of throwing. + private readonly tracker = inject(REMOTE_ExtensionAnalytics as any, { optional: true }) + ?.for(EXTENSION_ID); + + pageView(page: string): void { + this.tracker?.pageView(page); + } + + action(name: string): void { + this.tracker?.action(name); + } +} +``` + +When you adapt it: + +- Rename the class `HelloAnalytics` → `Analytics` (the build fails on a leftover `HelloAnalytics`). +- Set `EXTENSION_ID` to the manifest `id`, character for character. The build fails if they differ (see + [Build-time checks](#build-time-checks)). +- Import `REMOTE_ExtensionAnalytics` and the `ExtensionAnalytics` type from the package root + `@duplocloud-internal/ng-common-lib`. **Never write the token's value as a string literal** — always the import. +- Keep `{ optional: true }` and the `?.` calls: on a portal that predates extension analytics the token is + absent and tracking silently no-ops instead of breaking the page. +- Keep the two methods exactly as they are: `pageView(page)` and `action(name)`, **no properties parameter**. +- The token needs `@duplocloud-internal/ng-common-lib` **0.4.1 or later** (the scaffold vendors 0.4.1). An older + vendored tarball fails the build with a missing export — refresh it per + [`docs/UPGRADING-ng-common-lib.md`](../../../../docs/UPGRADING-ng-common-lib.md). + +Calling it from a component: + +```ts +private readonly analytics = inject(WidgetAnalytics); + +ngOnInit(): void { + this.analytics.pageView('widget-list'); + // … the rest of ngOnInit +} +``` + +## Default coverage — what every extension tracks + +Build these by default, with no properties, without asking. They are part of the extension, like the list page. + +**Page views** — `pageView('')` as the **first line of `ngOnInit`** of **every routed page**: each list, +detail (view) and form (add/edit) component that appears in `extension.routes.ts`. Add and Edit share one +component and one page view (`-form`). A multi-step wizard is one routed page — one page view, no +per-step events. + +**Actions** — `action('-')` inside the **success callback** (`next:`) of the API call for: + +- every create, update, delete and deprovision — `create-`, `update-`, `delete-`, + `deprovision-`; +- every custom action ([05-custom-actions](05-custom-actions.md)) — `restart-`, `run-plan`, + `apply-plan`, `sync-`. + +Never in the `error:` callback, and never on the button click before the call (the action has not happened yet). + +**Navigation-only actions** — a user command whose whole effect is opening something, with no create/update/delete +behind it (e.g. "Ask agent" → `ask-agent`, "Track provisioning" → `track-provisioning`, "Open console" → +`open-console`): fire `action(...)` in the click handler. + +**Not tracked:** tab switches, row clicks that open a detail page, the Add / Edit / Back / Cancel buttons (the +destination page view already records them), search and filter keystrokes, sorting and paging, polling refreshes, +modals opened and closed, and errors. + +The scaffold's events, as a model: + +| Component | Call | Where | +|---|---|---| +| `list-hello.component.ts` | `pageView('hello-list')` | first line of `ngOnInit` | +| `view-hello.component.ts` | `pageView('hello-detail')` | first line of `ngOnInit` | +| `add-hello.component.ts` | `pageView('hello-form')` | first line of `ngOnInit` | +| `add-hello.component.ts` | `action(this.isEdit ? 'update-hello' : 'create-hello')` | `next:` of the save call | +| `list-hello.component.ts` | `action('track-provisioning')` | the row's "Track Provisioning" click (`track()`) | +| `view-hello.component.ts` | `action('ask-agent')` / `action('track-provisioning')` | `track(event)`, after the `!item` guard: the header's "Ask agent" passes `ask-agent`; the rail's "Track status" and the "Review" phase action pass `track-provisioning` | + +`ask-agent` and `track-provisioning` exist only in **Agent** mode. Worker, Passthrough and No-provision remove the +ticket UI, and those two events go with it. + +Rename every `hello` event when you rename the resource (`ask-agent` and `track-provisioning` keep their names) — the build fails on leftover `hello-list` / +`hello-detail` / `hello-form` / `create-hello` / `update-hello` / `delete-hello`. + +## Naming + +``` +. +``` + +You write only ``; the host prepends the id. A page view is `pageView('')`, which the host turns +into `.viewed` — **never** write `.viewed` yourself. + +| Part | Rule | Regex | Examples | +|---|---|---|---| +| extension id (manifest `id`) | lowercase reverse-DNS, ≥ 3 segments | `^[a-z0-9]+(\.[a-z0-9-]+){2,}$` | `com.acme.widgets`, `duplo.examples.helloworld` | +| page (`pageView` argument) | lowercase kebab noun, `-list` / `-detail` / `-form` | `^[a-z0-9]+(-[a-z0-9]+)*$` | `widget-list`, `widget-detail`, `widget-form` | +| action (`action` argument) | lowercase kebab **verb-object** | `^[a-z0-9]+(-[a-z0-9]+)*$` | `create-widget`, `delete-widget`, `ask-agent` | +| full event (as the host builds it) | ≤ 128 characters | `^[a-z0-9]+(-[a-z0-9]+)*(\.viewed)?$` for the part after the id | `com.acme.widgets.widget-list.viewed` | + +- Use the resource's kebab noun for `` (its `subType` is a good default). +- **No IDs and no user-supplied values in names** — never `delete-widget-${id}`, `view-${name}`, a workspace + name or a region. Names are a fixed vocabulary written as string literals. +- **About 50 distinct events per extension at most.** The defaults above are usually under 10. +- Don't rename an event once shipped — a renamed event starts a new series. + +## Build-time checks + +`scripts/build-extension.sh` enforces two things before it builds (both fail the build): + +1. **Id format** — the manifest `id` must match `^[a-z0-9]+(\.[a-z0-9-]+){2,}$`, because it is the event + namespace. `my_extension`, `MyExt.Widgets` and `acme.widgets` (two segments) fail. +2. **Id parity** — every `EXTENSION_ID = '…'` declaration under `frontend/src` must equal the manifest `id`. A + mismatch would make the host drop every event silently. Fix it by correcting `EXTENSION_ID` in `analytics.ts`. + +On an **existing** extension whose `id` fails the format check, do **not** rename the id on your own: the id is +the extension's identity (it is in `backend.assemblyDir`, every `skills[].folder`, and the loaded record). Tell +the user what the gate requires and let them decide. + +## Verifying + +After loading the extension (Phase 7), open each of its pages and run each tracked action with the browser +DevTools console open, then search the console for **`[analytics]`**. There should be **no** such warnings. Each one +names the dropped event: + +| Warning | Cause | Fix | +|---|---|---| +| `extension "" is not registered, event dropped` | `EXTENSION_ID` ≠ the loaded manifest `id`, or the extension was not loaded through its manifest | match `EXTENSION_ID` to the manifest `id`; reload | +| `invalid event name "", event dropped` | the name breaks the event regex (uppercase, `_`, spaces, a dot) | rename to kebab-case | +| `event name exceeds 128 characters, event dropped` | id + event too long | shorten the event | +| `page "" must not include the .viewed suffix, event dropped` | `pageView('widget-list.viewed')` | pass `widget-list` | +| `action "" must not use the reserved .viewed suffix, event dropped` | an action ending in `.viewed` | use `pageView` for pages | +| `page must be a string, event dropped` | `pageView` got a non-string | pass a literal | +| `invalid namespace "", event dropped` | the registered manifest `id` breaks the id regex (the build gate normally stops this) | fix the id per [Build-time checks](#build-time-checks) | + +The warnings appear whether or not consent was given. The events themselves are only sent for a user who has +given analytics consent, so a user who declined will never generate any. No warnings and no events can also mean +the portal predates extension analytics (the token is absent and the wrapper no-ops). + +Un-allowlisted properties are stripped without a warning — the console cannot tell you that properties were +dropped, which is one more reason never to pass them. + +## Analytics retrofit (existing extensions) + +> ⚠️ **This runs on every change you make to an extension that already exists — whatever the change is for.** A +> bug fix, a new field, a menu change, a migration, a rebuild: if the target `extensions//` (or the ticket +> workdir) already has a `manifest.json` and you are about to change it, you run the retrofit as part of the same +> change. It is not optional and it does not wait for the user to mention analytics. (A read-only or diagnostic +> question — "how does this extension provision?", "why is the list empty?" — changes nothing, so it does not +> trigger the retrofit; the fix that follows it does.) + +1. **Check the preconditions FIRST — before you create or edit any file.** Two cases stop the retrofit; when + either applies, create **nothing** (no `analytics.ts`, no tracking calls — an import of the token on an + unsupported frontend breaks the build): + - **Angular 15 / Webpack frontend** — `frontend/webpack.config.js` exists, or `@angular/core` is 15, or + `@duplocloud-internal/ng-common-lib` is on the **0.1.x** line (0.1.x is the Angular 15 build, so it is this + case, not a library refresh). **Do NOT start an Angular 15 → 22 migration as a side effect.** Tell the user + that analytics needs the [`duplo-extension-ng22-migration`](../../duplo-extension-ng22-migration/SKILL.md) + migration (the retrofit is that migration's last step), stop the retrofit here, and continue with the change + they actually asked for. Migrate only if they asked for the migration, or their change cannot work without it. + - **Library older than 0.4.1** — `grep ng-common-lib extensions//frontend/package.json` must point at + 0.4.1 or later. + - On **0.4.0**: refresh it as part of the retrofit — copy the dev-kit's + `.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/duplocloud-internal-ng-common-lib-*.tgz` + into the extension's `frontend/vendor/`, remove the old tarball, update the `file:` dependency, and + regenerate the lockfile per [`docs/UPGRADING-ng-common-lib.md`](../../../../docs/UPGRADING-ng-common-lib.md). + - On **0.2.x or 0.3.x** (a cross-minor bump) during a change unrelated to the library: **tell the user and get + their go-ahead before refreshing** — a minor bump can change component APIs. Without it, stop the retrofit + here and say why. + + Only when neither case applies (or the refresh is done) go on to step 2. +2. **Check the wiring.** Look for the wrapper and the id: + ```bash + ls extensions//frontend/src/app/analytics.ts + grep -rn "REMOTE_ExtensionAnalytics\|EXTENSION_ID" extensions//frontend/src + ``` + Missing → create `analytics.ts` from the scaffold's file above (rename the class, set `EXTENSION_ID` to the + manifest `id`). +3. **Check the page views.** For every component in `extension.routes.ts`, confirm `ngOnInit` calls + `this.analytics.pageView('')`. Add the missing ones. +4. **Check the actions.** For every create / update / delete / deprovision / custom-action API call, confirm its + success callback calls `this.analytics.action('-')`; for every navigation-only command, its click + handler. Add the missing ones. +5. **Add no properties.** Not in calls, not in the manifest. If the manifest already has a + `frontend.analytics.properties` block, the user opted in earlier: keep it as it is — do not extend it, do not + remove it. +6. **Build** (`scripts/build-extension.sh` checks id format and parity). +7. **Tell the user what was added**, in the same summary as the rest of the change (in-platform: the status / + ticket message): which files, which page views, which actions — e.g. *"Also added analytics: `analytics.ts`; + page views `widget-list`, `widget-detail`, `widget-form`; actions `create-widget`, `update-widget`, + `delete-widget`, `ask-agent`, `track-provisioning` (event names only)."* If nothing was missing, say that the analytics were already complete. + +## Adding a property — only on an explicit user request + +Do this **only** when the hard rule's conditions are met: the user named the event and the property, the +property is not a secret (secrets are always refused), an identifying property was confirmed a second time after +your warning, and the value is a flat string, number or boolean — ideally an enum-like value such as a cloud, a +region or a count. + +1. **Allowlist it in the manifest**, under `frontend` (keys are event names without the id; a page view's key is + `.viewed`): + ```json + "frontend": { + "remote": { … }, "menus": [ … ], "routes": [ … ], + "analytics": { + "properties": { + "create-cluster": ["region"] + } + } + } + ``` + One entry per requested event, listing only the requested keys. A key allowlisted for `create-cluster` does + **not** pass on `delete-cluster`. +2. **Widen the wrapper for that one event only** — add a dedicated, typed method; leave `pageView(page)` and + `action(name)` without properties: + ```ts + // Requested by the user: `region` on create-cluster; allowlisted in manifest frontend.analytics.properties. + createCluster(props: { region: string }): void { + this.tracker?.action('create-cluster', props); + } + ``` +3. **Call it in the success callback** with exactly the allowlisted keys: + `this.analytics.createCluster({ region: this.region() });` +4. **Rebuild and reload** the extension — the host reads the allowlist from the loaded manifest when the page + loads, so a manifest that was not reloaded still strips the property. + +## Known limitations + +- **`for(id)` is not bound to the caller.** Any extension could call `for('')` and emit + events under another extension's name. Never do that — only ever pass your own `EXTENSION_ID`. +- **Allowlisted values pass through as-is.** The host checks the key, not the value: it does not validate, + truncate or redact. Pass only flat strings, numbers and booleans — never objects or arrays, and never anything + the user typed unless it was confirmed per rule 4 of the hard rule. +- **The allowlist is a guardrail, not a security sandbox.** It protects against accidentally adding properties; a deliberately malicious extension can still reach other host tokens by name, or call `for()` with another extension's id. diff --git a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package-lock.json b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package-lock.json index 2849807..88df3d1 100644 --- a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package-lock.json +++ b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package-lock.json @@ -17,7 +17,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:vendor/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:vendor/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", @@ -1893,9 +1893,9 @@ } }, "node_modules/@duplocloud-internal/ng-common-lib": { - "version": "0.4.0", - "resolved": "file:vendor/duplocloud-internal-ng-common-lib-0.4.0.tgz", - "integrity": "sha512-eSTVe/CXrskZcFBO28lkuI0oHSr5PzAqy05/aIH6gaUMnkZxLuO0/4ZfpczOu1/enPXT4lU2vI9Ss4GsIAilng==", + "version": "0.4.1", + "resolved": "file:vendor/duplocloud-internal-ng-common-lib-0.4.1.tgz", + "integrity": "sha512-R7CdvXyx6ms2NgFdCNdcKxWkdbhp0BIGkOfJWOAaeMi9hfR6srMZU07vaERTDg+tSwdGwFBhKAdqtwmnX029+g==", "bundleDependencies": [ "ng-block-ui" ], diff --git a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package.json b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package.json index 2108d00..045d779 100644 --- a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package.json +++ b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package.json @@ -25,7 +25,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:vendor/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:vendor/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", diff --git a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/add/add-hello.component.ts b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/add/add-hello.component.ts index 8706305..ca2ee82 100644 --- a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/add/add-hello.component.ts +++ b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/add/add-hello.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit, inject, signal, viewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { AiDisclosureComponent, FormGroupErrorsComponent, SharedFormsModule } from '@duplocloud-internal/ng-common-lib'; +import { HelloAnalytics } from '../analytics'; import { HelloService } from '../hello.service'; // Add/Edit form in the platform's 3-column `panel-form-accordion` layout: title+description (left), inputs @@ -99,6 +100,7 @@ export class AddHelloComponent implements OnInit { private readonly svc = inject(HelloService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); + private readonly analytics = inject(HelloAnalytics); protected readonly name = signal(''); protected readonly firstName = signal(''); @@ -118,6 +120,7 @@ export class AddHelloComponent implements OnInit { private readonly formErrors = viewChild(FormGroupErrorsComponent); ngOnInit(): void { + this.analytics.pageView('hello-form'); if (!this.isEdit) { return; } @@ -144,7 +147,10 @@ export class AddHelloComponent implements OnInit { ? this.svc.update(this.id, spec) : this.svc.create(this.name(), spec); call.subscribe({ - next: () => this.back(), + next: () => { + this.analytics.action(this.isEdit ? 'update-hello' : 'create-hello'); + this.back(); + }, error: (err) => { this.saving.set(false); this.formErrors()?.reportError(err); diff --git a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/analytics.ts b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/analytics.ts new file mode 100644 index 0000000..17a58ab --- /dev/null +++ b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/analytics.ts @@ -0,0 +1,26 @@ +import { Injectable, inject } from '@angular/core'; +import { ExtensionAnalytics, REMOTE_ExtensionAnalytics } from '@duplocloud-internal/ng-common-lib'; + +// Default analytics: page views + user actions, NO custom properties. The host namespaces every event as +// `..viewed` / `.` and adds extension_id / extension_version itself. +// +// Properties are opt-in ONLY, via manifest `frontend.analytics.properties` (an allowlist the host enforces). +// Do NOT add a props parameter here unless the user explicitly asks for one — see reference/21-analytics.md. +// +// EXTENSION_ID MUST equal manifest.json `id`; the host drops events from ids it has not registered. +export const EXTENSION_ID = 'duplo.examples.helloworld'; + +@Injectable({ providedIn: 'root' }) +export class HelloAnalytics { + // optional: the host token is absent in standalone runs / unit tests — tracking then no-ops instead of throwing. + private readonly tracker = inject(REMOTE_ExtensionAnalytics as any, { optional: true }) + ?.for(EXTENSION_ID); + + pageView(page: string): void { + this.tracker?.pageView(page); + } + + action(name: string): void { + this.tracker?.action(name); + } +} diff --git a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/list/list-hello.component.ts b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/list/list-hello.component.ts index f881c73..6d456e4 100644 --- a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/list/list-hello.component.ts +++ b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/list/list-hello.component.ts @@ -3,6 +3,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { FilterTableUtils, SearchableDatatableComponent, SearchableDatatableModule } from '@duplocloud-internal/ng-common-lib'; import { HelloService, HelloWorld, REMOTE_UserSession } from '../hello.service'; +import { HelloAnalytics } from '../analytics'; import { StatusBadgeComponent } from '../shared/status-badge.component'; // List view built on the platform UI library's (from @@ -84,6 +85,7 @@ export class ListHelloComponent implements OnInit { // token needs a cast — unlike constructor @Inject(...), which accepts a bare string. private readonly session = inject(REMOTE_UserSession as any); private readonly destroyRef = inject(DestroyRef); + private readonly analytics = inject(HelloAnalytics); // The owns the search box + term; we read its `searchTerm` when filtering. private readonly table = viewChild(SearchableDatatableComponent); @@ -103,6 +105,7 @@ export class ListHelloComponent implements OnInit { }); ngOnInit(): void { + this.analytics.pageView('hello-list'); // Re-fetch on first load, on every workspace switch, AND on each poll tick — no page reload needed. // getTenantRefreshTimer emits [tenant, tenantChanged]; the flag is true only when the workspace changed. this.session.getTenantRefreshTimer(true) @@ -145,6 +148,7 @@ export class ListHelloComponent implements OnInit { } protected track(r: HelloWorld): void { + this.analytics.action('track-provisioning'); this.svc.ticketName(r.id).subscribe(name => { if (!name) { return; diff --git a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/view/view-hello.component.ts b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/view/view-hello.component.ts index b89102e..d312902 100644 --- a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/view/view-hello.component.ts +++ b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/view/view-hello.component.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { Subscription, interval } from 'rxjs'; import { CommonLibComponentsModule } from '@duplocloud-internal/ng-common-lib'; import { HelloService, HelloWorld } from '../hello.service'; +import { HelloAnalytics } from '../analytics'; import { StatusBadgeComponent } from '../shared/status-badge.component'; import { LifecyclePhase, LifecycleRailComponent, RailFact } from '../shared/lifecycle-rail.component'; @@ -54,7 +55,7 @@ import { LifecyclePhase, LifecycleRailComponent, RailFact } from '../shared/life -
@@ -122,7 +123,7 @@ import { LifecyclePhase, LifecycleRailComponent, RailFact } from '../shared/life [attached]="attached()" [trackable]="true" [trackBusy]="tracking()" - (track)="track()" /> + (track)="track('track-provisioning')" />
} @else { @@ -141,6 +142,7 @@ export class ViewHelloComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); private readonly locale = inject(LOCALE_ID); + private readonly analytics = inject(HelloAnalytics); protected readonly item = signal(undefined); protected readonly view = signal<'spec' | 'result'>('spec'); @@ -189,7 +191,7 @@ export class ViewHelloComponent implements OnInit { : 'The agent asked a question or needs an approval', state: waiting ? 'now' : (ready ? 'done' : 'todo'), // Answer right from the timeline: the ticket is where the question lives. - action: waiting ? { label: 'Review', run: () => this.track() } : undefined, + action: waiting ? { label: 'Review', run: () => this.track('track-provisioning') } : undefined, }, { label: 'Ready', @@ -217,6 +219,7 @@ export class ViewHelloComponent implements OnInit { }); ngOnInit(): void { + this.analytics.pageView('hello-detail'); this.refresh(); this.destroyRef.onDestroy(() => this.poll?.unsubscribe()); } @@ -252,12 +255,16 @@ export class ViewHelloComponent implements OnInit { } } - /** Open the provisioning ticket's chat — "Ask agent" in the header and "Track status" in the rail both land here. */ - protected track(): void { + /** + * Open the provisioning ticket's chat — "Ask agent" in the header and "Track status" in the rail both land here. + * `event` is the analytics action for the button that was clicked (a navigation-only action, so fired on click). + */ + protected track(event: 'ask-agent' | 'track-provisioning'): void { const it = this.item(); if (!it) { return; } + this.analytics.action(event); this.tracking.set(true); this.svc.ticketName(it.id).subscribe({ next: name => { diff --git a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/duplocloud-internal-ng-common-lib-0.4.0.tgz b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/duplocloud-internal-ng-common-lib-0.4.0.tgz deleted file mode 100644 index 7fe8927..0000000 Binary files a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/duplocloud-internal-ng-common-lib-0.4.0.tgz and /dev/null differ diff --git a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/duplocloud-internal-ng-common-lib-0.4.1.tgz b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/duplocloud-internal-ng-common-lib-0.4.1.tgz new file mode 100644 index 0000000..1bcf273 Binary files /dev/null and b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/duplocloud-internal-ng-common-lib-0.4.1.tgz differ diff --git a/.claude/skills/duplo-extension-ng22-migration/SKILL.md b/.claude/skills/duplo-extension-ng22-migration/SKILL.md index 9427ff0..24c6957 100644 --- a/.claude/skills/duplo-extension-ng22-migration/SKILL.md +++ b/.claude/skills/duplo-extension-ng22-migration/SKILL.md @@ -13,7 +13,8 @@ never mounts. This skill converts one extension in place. The reference shape is **`samples/helloworld/frontend/`** in this dev-kit; diff against it whenever a step is ambiguous. It is the only copy that has been built -and gate-verified end to end. +and gate-verified end to end. It has **no** `analytics.ts`: for the analytics retrofit ([step 9](#9-analytics-retrofit)) +copy from the skill template instead, `.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/`. Throughout, `` is a checkout of `duplo-ai-extension-devkit` and `` is a checkout of `duplo-ui/portal`. You need the dev-kit for the reference files and the gate script; you need the @@ -224,7 +225,9 @@ Then, against the pre-migration `package.json` (recover it with - **Drop** the build-time Webpack stack: `@angular-architects/module-federation`, `ngx-build-plus`, `@angular-devkit/build-angular`, and the `start` script. - **Repoint** `@duplocloud-internal/ng-common-lib` at a **0.2.0-or-later** tarball (Angular 22 peers). - The 0.1.x tarballs carry Angular 15 peers and will fail a strict install. + The 0.1.x tarballs carry Angular 15 peers and will fail a strict install. Use **0.4.1 or later** (the + dev-kit's current tarball): the analytics retrofit in [step 9](#9-analytics-retrofit) imports a token that + 0.4.0 and earlier do not export. Careful here: the `cp` above brought helloworld's own `file:../../../packages/...` path with it, which is correct **only** for something living at `samples//frontend/`. A self-contained extension @@ -461,6 +464,29 @@ ordinary compile errors; fix them against each library's changelog. --- +## 9. Analytics retrofit + +Once the frontend builds and passes the gate, run the **analytics retrofit** on it — always, even though the +request was "migrate". The single owner is +[`duplo-extension-dev/reference/21-analytics.md`](../duplo-extension-dev/reference/21-analytics.md#analytics-retrofit-existing-extensions); +in short: + +1. Add `frontend/src/app/analytics.ts` from the dev-kit scaffold + (`.claude/skills/duplo-extension-dev/templates/helloworld/frontend/src/app/analytics.ts`), class renamed to + `Analytics`, `EXTENSION_ID` set to the manifest `id`. It is an `@Injectable({ providedIn: 'root' })` + service, so it works unchanged in a migrated `standalone: false` NgModule extension. +2. Add `pageView('')` to `ngOnInit` of every routed page, `action('-')` to the success + callback of every create / update / delete / deprovision / custom action, and — on Agent-mode extensions — + `action('ask-agent')` / `action('track-provisioning')` in the click handlers of the buttons that open the + provisioning ticket (as the scaffold's `list-hello` and `view-hello` do). +3. **No properties** — not in the calls, not in the manifest (⛔ hard rule in 21-analytics). +4. Rebuild with `scripts/build-extension.sh`: it also checks the manifest `id` format (lowercase reverse-DNS, + ≥ 3 segments) and that `EXTENSION_ID` equals it. If an old extension's `id` fails the format check, tell the + user instead of renaming it yourself. +5. Tell the user which page views and actions you added, alongside the migration summary. + +--- + ## Host requirement — read this before you debug anything **A correctly migrated extension still will not load on a portal that predates the diff --git a/PRIVACY.md b/PRIVACY.md index bc73ed4..52b5468 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -15,34 +15,54 @@ bundle: the nginx config in `nginx/default.conf` is a plain same-origin proxy to kit's license is issued to. Your username, your assigned roles, and your email's domain (as a company grouping) are sent with it. This is not anonymous, and we do not describe it as such. -**What you do.** Product events naming the feature used and the objects involved: - -- Chat and tickets — `ticket_created`, `ticket_form_opened`, `ticket_status_changed`, - `chat_message_sent`, `chat_action_sent`, `ticket_feedback_submitted`, - `message_feedback_submitted`, `ticket_scopes_updated`, `ticket_command_permissions_updated`, - `prompt_suggestion_clicked`, `prompt_template_clicked` -- Admin pages viewed — `admin_workspaces_viewed`, `admin_agents_viewed`, `admin_personas_viewed`, - `admin_providers_viewed`, `admin_scopes_viewed`, `admin_skills_viewed`, `admin_users_viewed`, - `admin_api_tokens_viewed`, `admin_permission_sets_viewed`, `admin_permission_set_groups_viewed`, - `admin_command_policy_definitions_viewed`, `admin_command_policy_mappings_viewed`, - `admin_quota_definitions_viewed`, `admin_quota_mappings_viewed`, `persona_viewed` -- Admin objects created or updated — workspaces, personas, providers, credentials, scopes, skills, - MCP servers, users, permission sets and groups, command policies, quotas - -**The properties attached to them.** Names of the objects you create or edit — the ticket key -(e.g. `DEVKIT-42`, not anything you typed), workspace, agent, provider, scope, credential, MCP -server, permission set and group, command policy, quota, and their mappings. Object ids -(workspace, provider, persona, ticket, instance). Counts and booleans (`scope_count`, -`custom_field_count`, `message_length`, `has_files`, `has_commands`, `has_prompt`, and similar). -Persona and skill *names* are not sent — those events carry only counts and type flags. - -**Two properties carry free text**, both of them content your workspace admin configured rather -than anything you typed: - -- `suggestion_text` on `prompt_suggestion_clicked` — the full text of the prompt suggestion you - clicked, which is also the message that then gets sent to the agent. -- `template_description` on `prompt_template_clicked` — the template's description. The template - *body* is not sent. +**What you do.** Product events naming the feature used and the objects involved. Every event name +is namespaced: `com.duplocloud.armor.` for the studio UI, `com.duplocloud.devops.` for +the AI DevOps > DevOps pages, and `.` for extensions (see below). + +- Chat and tickets — `com.duplocloud.armor.` followed by `create-ticket`, `ticket-form.viewed`, + `change-ticket-status`, `send-chat-message`, `send-chat-action`, `submit-ticket-feedback`, + `submit-message-feedback`, `update-ticket-scopes`, `update-ticket-mcp-permissions`, + `update-ticket-command-permissions`, `apply-prompt-suggestion`, `apply-prompt-template` +- Admin pages viewed — `com.duplocloud.armor.` followed by `workspace-list.viewed`, + `workspace-agent-list.viewed`, `persona-list.viewed`, `persona-detail.viewed`, + `provider-list.viewed`, `scope-list.viewed`, `skill-list.viewed`, `user-list.viewed`, + `api-token-list.viewed`, `permission-set-list.viewed`, `permission-set-group-list.viewed`, + `command-policy-list.viewed`, `command-policy-mapping-list.viewed`, `quota-list.viewed`, + `quota-mapping-list.viewed` +- Admin objects created or updated — `create-` / `update-` for workspaces, + personas, providers, credentials, scopes, skills, MCP servers, users, permission sets and groups, + command policies and their mappings, quotas and their mappings +- Knowledge bases and extensions — `create-kb`, `delete-kb`, `delete-kb-document`, `share-kb`, + `unshare-kb`, `remove-kb-from-workspace`, `kb-add-documents-form.viewed`, + `register-extension-bundle`, `reject-extension-bundle`, `extension-detail.viewed`, + `extension-register-form.viewed` +- AI DevOps > DevOps — `com.duplocloud.devops.` followed by page views (`network-list.viewed`, + `cluster-detail.viewed`, `environment-list.viewed`, `tf-deployment-detail.viewed`, + `resource-detail.viewed`, among others), `create-` / `update-` / `delete-` / `deprovision-` actions + on networks, plans, clusters, environments, resource groups and Terraform deployments and + environments, Terraform runs (`tf-plan`, `tf-apply`, `tf-destroy`, `tf-resync`, + `tf-commit-push`), and `ask-agent`, `track-provisioning`, `download-kubeconfig`, `show-kubectl`, + `open-workstation`, `import-network`, `import-cluster`, + `configure-cluster-attributes`, `edit-resource`, `deprovision-resource`, among others +- Extensions — page views and actions named `.` (see the properties below) + +**The properties attached to them.** Names of the objects you create, edit or use — workspace, agent, +provider, scope (including the list of scope names on a ticket or prompt template), prompt template, +extension, credential, MCP server, permission set and group, command policy, quota, and their +mappings. Object ids (workspace, provider, persona, ticket, instance); tickets are identified by +`ticket_id` only — the ticket key (e.g. `DEVKIT-42`) is not sent. Counts and booleans +(`scope_count`, `custom_field_count`, `message_length`, `has_files`, `has_commands`, `has_prompt`, +and similar), plus non-sensitive metadata on extension registry events (version, `file_size_bytes`, +`has_manifest`, category, status, `licensed`). Persona and skill *names* are not sent — those events carry only counts and type +flags. AI DevOps events carry only `resource_type`, `cloud` and `mode` — no resource names or ids. + +**No free text a person typed is sent.** Prompt suggestion text and prompt template descriptions +and bodies are not sent either. Admin-configured object *names* (a template's name, for example) +are sent, as listed above. + +**From extensions.** Each extension event carries `extension_id` and, when the manifest declares a version, `extension_version`, plus only +the properties the extension's author explicitly allowlisted in the manifest +(`frontend.analytics.properties`). The portal strips everything else before sending. **Automatically, from your browser.** Mixpanel's library attaches to every event: your browser and version, OS, device type, screen size, the referrer, and the current page URL (which contains @@ -51,17 +71,22 @@ IP address. ## What is *not* collected -- **The chat messages you type.** `chat_message_sent` carries `message_length` — a number — not the - message. The one exception is above: clicking a prompt *suggestion* sends that suggestion's text. +- **The chat messages you type.** `send-chat-message` carries `message_length` — a number — not the + message. No free text is sent at all. - **The commands or tool calls themselves.** No command text, tool names, or arguments — - `chat_action_sent` carries `has_commands` / `has_tools` flags alongside the ticket key. Command + `send-chat-action` carries `has_commands` / `has_tools` flags alongside the ticket id. Command policy events send the policy's name, never its regex patterns. - **Credential values, API keys, or tokens.** Credential events carry the credential's *name* and a *count* of its custom fields, never the values. - **Your files, or the contents of files you attach.** Only `has_files`. -- **Anything from your extensions' code, or from the agent's output.** Skill events carry only the - skill's type and format — not its name, source, or body. Agent prompts are reduced to a - `has_prompt` flag, and free-text feedback to a `has_text` flag. +- **Anything from your extensions' code beyond what the author allowlisted.** Extensions send page + views and actions named `.` with `extension_id` and `extension_version` (when the manifest declares a version), plus + only the properties the author explicitly allowlisted in the manifest + (`frontend.analytics.properties`); the portal strips everything else. Nothing from the agent's + output is sent. +- **Skill, agent prompt and feedback content.** Skill events carry only the skill's type and + format — not its name, source, or body. Agent prompts are reduced to a `has_prompt` flag, and + free-text feedback to a `has_text` flag. - **Anything from the studio backend.** These metrics come from the UI only; the studio has no analytics integration. diff --git a/docs/UPGRADING-ng-common-lib.md b/docs/UPGRADING-ng-common-lib.md index de81f53..0ec7f24 100644 --- a/docs/UPGRADING-ng-common-lib.md +++ b/docs/UPGRADING-ng-common-lib.md @@ -1,7 +1,10 @@ # Upgrading an extension's UI library: the vendored `@duplocloud-internal/ng-common-lib` tarball For extensions built from this dev-kit (or from the `duplo-extension-dev` skill template) that vendor the UI -library as a tarball. The current line is 0.4.0 (Angular 22); the notes below contrast it with the 0.1.x line. +library as a tarball. The current line is 0.4.1 (Angular 22); the notes below contrast it with the 0.1.x line. +0.4.1 adds the `REMOTE_ExtensionAnalytics` host token (with the `ExtensionAnalytics` / `ExtensionTracker` types) that +an extension's `analytics.ts` imports — an extension that tracks page views and actions needs 0.4.1 or later +([`reference/21-analytics.md`](../.claude/skills/duplo-extension-dev/reference/21-analytics.md)). > **This is not a drop-in upgrade.** 0.2.0 is compiled against **Angular 22** and ships as ESM > (`fesm2022`, `"type": "module"`); its peer range is `@angular/core@^22.1.0`. It is meant to be consumed by diff --git a/packages/duplocloud-internal-ng-common-lib-0.4.0.tgz b/packages/duplocloud-internal-ng-common-lib-0.4.0.tgz deleted file mode 100644 index 7fe8927..0000000 Binary files a/packages/duplocloud-internal-ng-common-lib-0.4.0.tgz and /dev/null differ diff --git a/packages/duplocloud-internal-ng-common-lib-0.4.1.tgz b/packages/duplocloud-internal-ng-common-lib-0.4.1.tgz new file mode 100644 index 0000000..1bcf273 Binary files /dev/null and b/packages/duplocloud-internal-ng-common-lib-0.4.1.tgz differ diff --git a/samples/helloworld/frontend/package-lock.json b/samples/helloworld/frontend/package-lock.json index 031f645..a807a9a 100644 --- a/samples/helloworld/frontend/package-lock.json +++ b/samples/helloworld/frontend/package-lock.json @@ -17,7 +17,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", @@ -1893,9 +1893,9 @@ } }, "node_modules/@duplocloud-internal/ng-common-lib": { - "version": "0.4.0", - "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", - "integrity": "sha512-eSTVe/CXrskZcFBO28lkuI0oHSr5PzAqy05/aIH6gaUMnkZxLuO0/4ZfpczOu1/enPXT4lU2vI9Ss4GsIAilng==", + "version": "0.4.1", + "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", + "integrity": "sha512-R7CdvXyx6ms2NgFdCNdcKxWkdbhp0BIGkOfJWOAaeMi9hfR6srMZU07vaERTDg+tSwdGwFBhKAdqtwmnX029+g==", "bundleDependencies": [ "ng-block-ui" ], diff --git a/samples/helloworld/frontend/package.json b/samples/helloworld/frontend/package.json index fcb6fc6..3a3fd2e 100644 --- a/samples/helloworld/frontend/package.json +++ b/samples/helloworld/frontend/package.json @@ -25,7 +25,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", diff --git a/samples/network-stack/frontend/package-lock.json b/samples/network-stack/frontend/package-lock.json index 2d2b47e..b1c5912 100644 --- a/samples/network-stack/frontend/package-lock.json +++ b/samples/network-stack/frontend/package-lock.json @@ -17,7 +17,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", @@ -1893,9 +1893,9 @@ } }, "node_modules/@duplocloud-internal/ng-common-lib": { - "version": "0.4.0", - "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", - "integrity": "sha512-eSTVe/CXrskZcFBO28lkuI0oHSr5PzAqy05/aIH6gaUMnkZxLuO0/4ZfpczOu1/enPXT4lU2vI9Ss4GsIAilng==", + "version": "0.4.1", + "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", + "integrity": "sha512-R7CdvXyx6ms2NgFdCNdcKxWkdbhp0BIGkOfJWOAaeMi9hfR6srMZU07vaERTDg+tSwdGwFBhKAdqtwmnX029+g==", "bundleDependencies": [ "ng-block-ui" ], diff --git a/samples/network-stack/frontend/package.json b/samples/network-stack/frontend/package.json index c4537a3..f853730 100644 --- a/samples/network-stack/frontend/package.json +++ b/samples/network-stack/frontend/package.json @@ -25,7 +25,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", diff --git a/samples/parent-child/frontend/package-lock.json b/samples/parent-child/frontend/package-lock.json index 1f5168f..d5b3e5c 100644 --- a/samples/parent-child/frontend/package-lock.json +++ b/samples/parent-child/frontend/package-lock.json @@ -17,7 +17,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", @@ -1893,9 +1893,9 @@ } }, "node_modules/@duplocloud-internal/ng-common-lib": { - "version": "0.4.0", - "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", - "integrity": "sha512-eSTVe/CXrskZcFBO28lkuI0oHSr5PzAqy05/aIH6gaUMnkZxLuO0/4ZfpczOu1/enPXT4lU2vI9Ss4GsIAilng==", + "version": "0.4.1", + "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", + "integrity": "sha512-R7CdvXyx6ms2NgFdCNdcKxWkdbhp0BIGkOfJWOAaeMi9hfR6srMZU07vaERTDg+tSwdGwFBhKAdqtwmnX029+g==", "bundleDependencies": [ "ng-block-ui" ], diff --git a/samples/parent-child/frontend/package.json b/samples/parent-child/frontend/package.json index 49cded6..f7f77b3 100644 --- a/samples/parent-child/frontend/package.json +++ b/samples/parent-child/frontend/package.json @@ -25,7 +25,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", diff --git a/samples/passthrough-configmap/frontend/package-lock.json b/samples/passthrough-configmap/frontend/package-lock.json index 4319eea..86cd317 100644 --- a/samples/passthrough-configmap/frontend/package-lock.json +++ b/samples/passthrough-configmap/frontend/package-lock.json @@ -17,7 +17,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", @@ -1893,9 +1893,9 @@ } }, "node_modules/@duplocloud-internal/ng-common-lib": { - "version": "0.4.0", - "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", - "integrity": "sha512-eSTVe/CXrskZcFBO28lkuI0oHSr5PzAqy05/aIH6gaUMnkZxLuO0/4ZfpczOu1/enPXT4lU2vI9Ss4GsIAilng==", + "version": "0.4.1", + "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", + "integrity": "sha512-R7CdvXyx6ms2NgFdCNdcKxWkdbhp0BIGkOfJWOAaeMi9hfR6srMZU07vaERTDg+tSwdGwFBhKAdqtwmnX029+g==", "bundleDependencies": [ "ng-block-ui" ], diff --git a/samples/passthrough-configmap/frontend/package.json b/samples/passthrough-configmap/frontend/package.json index 16814b1..7064c93 100644 --- a/samples/passthrough-configmap/frontend/package.json +++ b/samples/passthrough-configmap/frontend/package.json @@ -25,7 +25,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", diff --git a/samples/worker-appstack/frontend/package-lock.json b/samples/worker-appstack/frontend/package-lock.json index 0f4a3c5..3fc0545 100644 --- a/samples/worker-appstack/frontend/package-lock.json +++ b/samples/worker-appstack/frontend/package-lock.json @@ -17,7 +17,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", @@ -1893,9 +1893,9 @@ } }, "node_modules/@duplocloud-internal/ng-common-lib": { - "version": "0.4.0", - "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", - "integrity": "sha512-eSTVe/CXrskZcFBO28lkuI0oHSr5PzAqy05/aIH6gaUMnkZxLuO0/4ZfpczOu1/enPXT4lU2vI9Ss4GsIAilng==", + "version": "0.4.1", + "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", + "integrity": "sha512-R7CdvXyx6ms2NgFdCNdcKxWkdbhp0BIGkOfJWOAaeMi9hfR6srMZU07vaERTDg+tSwdGwFBhKAdqtwmnX029+g==", "bundleDependencies": [ "ng-block-ui" ], diff --git a/samples/worker-appstack/frontend/package.json b/samples/worker-appstack/frontend/package.json index dc204dd..51a7784 100644 --- a/samples/worker-appstack/frontend/package.json +++ b/samples/worker-appstack/frontend/package.json @@ -25,7 +25,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", diff --git a/samples/worker-compute/frontend/package-lock.json b/samples/worker-compute/frontend/package-lock.json index 7fdb928..85fd167 100644 --- a/samples/worker-compute/frontend/package-lock.json +++ b/samples/worker-compute/frontend/package-lock.json @@ -17,7 +17,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", @@ -1893,9 +1893,9 @@ } }, "node_modules/@duplocloud-internal/ng-common-lib": { - "version": "0.4.0", - "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", - "integrity": "sha512-eSTVe/CXrskZcFBO28lkuI0oHSr5PzAqy05/aIH6gaUMnkZxLuO0/4ZfpczOu1/enPXT4lU2vI9Ss4GsIAilng==", + "version": "0.4.1", + "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", + "integrity": "sha512-R7CdvXyx6ms2NgFdCNdcKxWkdbhp0BIGkOfJWOAaeMi9hfR6srMZU07vaERTDg+tSwdGwFBhKAdqtwmnX029+g==", "bundleDependencies": [ "ng-block-ui" ], diff --git a/samples/worker-compute/frontend/package.json b/samples/worker-compute/frontend/package.json index 2575a36..53a6be7 100644 --- a/samples/worker-compute/frontend/package.json +++ b/samples/worker-compute/frontend/package.json @@ -25,7 +25,7 @@ "@angular/material": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", - "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz", + "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz", "@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-select/ng-select": "^23.5.1", "@ngbracket/ngx-layout": "^22.0.1", diff --git a/scripts/_extension_id.sh b/scripts/_extension_id.sh new file mode 100644 index 0000000..3e02504 --- /dev/null +++ b/scripts/_extension_id.sh @@ -0,0 +1,27 @@ +# shellcheck shell=bash +# Sourced by scripts/build-extension.sh — validates the manifest `id`. The id is the namespace of every +# analytics event the extension emits, so it must be lowercase reverse-DNS with at least 3 segments. +# Reserved portal namespaces are NOT checked here; that belongs to extension signing/publish. + +EXTENSION_ID_REGEX='^[a-z0-9]+(\.[a-z0-9-]+){2,}$' + +# extension_id_valid -> 0 if the id matches EXTENSION_ID_REGEX. +extension_id_valid() { [[ "${1-}" =~ $EXTENSION_ID_REGEX ]]; } + +# extension_id_error -> the ✗ message for a bad id. +extension_id_error() { + printf " ✗ manifest.json id '%s' must be lowercase reverse-DNS with at least 3 segments, matching %s (e.g. com.acme.my-extension) — it is the analytics event namespace" "${1-}" "$EXTENSION_ID_REGEX" +} + +# extension_id_fe_mismatches -> prints each `EXTENSION_ID = ''` value found +# under the dir that differs from the manifest id (one per line); prints nothing when none declared or all match. +# A stale value makes the host silently drop every analytics event. +extension_id_fe_mismatches() { + local dir="${1-}" mid="${2-}" v + [ -d "$dir" ] || return 0 + while IFS= read -r v; do + [ -n "$v" ] && [ "$v" != "$mid" ] && printf '%s\n' "$v" + done < <(grep -rhoE "EXTENSION_ID[[:space:]]*(:[^=]*)?=[[:space:]]*['\"\`][^'\"\`]*['\"\`]" "$dir" 2>/dev/null \ + | sed -E "s/^[^=]*=[[:space:]]*['\"\`]//; s/['\"\`]\$//" | sort -u) + return 0 +} diff --git a/scripts/build-extension.sh b/scripts/build-extension.sh index 0aed5a5..d01d169 100755 --- a/scripts/build-extension.sh +++ b/scripts/build-extension.sh @@ -26,6 +26,8 @@ source "$(dirname "$0")/_target.sh" # → BASE_URL (+ TOKEN) from .env / env p source "$(dirname "$0")/_builder.sh" # → native, or re-launch this build in the builder container # shellcheck source=scripts/_sdk_digest.sh source "$(dirname "$0")/_sdk_digest.sh" # → sdk_digest, stamped into the bundle manifest beside sdkVersion +# shellcheck source=scripts/_extension_id.sh +source "$(dirname "$0")/_extension_id.sh" # → extension_id_valid / extension_id_error (manifest id format) BASE_URL="${BASE_URL%/}" # tolerate a trailing slash in DUPLO_HOST (avoids // in URLs) [ -f "$DIR/manifest.json" ] || { echo "No manifest.json in $DIR" >&2; exit 1; } @@ -43,6 +45,9 @@ builder_dispatch scripts/build-extension.sh "$DIR" echo "==> Validating extension naming (reference/00-naming.md)" MANIFEST="$DIR/manifest.json" viol=0 +# The manifest id is the analytics event namespace: lowercase reverse-DNS, ≥3 segments. +mid=$(jq -r '.id // empty' "$MANIFEST") +extension_id_valid "$mid" || { extension_id_error "$mid" >&2; echo >&2; viol=1; } # Top-level resources must namespace their own restSegment. A nested child (has a `parent` block) is exempt: its # restSegment is a leaf and the namespacing comes from parent.routeSegment (checked below) — its route is # …/extensions//{parentId}/. @@ -84,6 +89,12 @@ if [ -d "$DIR/skills" ]; then | grep -vE 'environment/(extensions/|extension-studio)' | grep -vE 'environment/\{' || true) [ -n "$bad" ] && { echo " ! skill callback URL(s) not under 'environment/extensions/' — confirm they target this extension's restSegment:" >&2; printf '%s\n' "$bad" | sed 's/^/ /' >&2; } fi +# Analytics id parity: a frontend EXTENSION_ID (analytics.ts) MUST equal the manifest id, or the host drops +# every event silently. Checked only when such a declaration exists. +while IFS= read -r v; do + [ -n "$v" ] || continue + echo " ✗ frontend EXTENSION_ID '$v' != manifest id '$mid' (they must be identical or the host drops all analytics events)" >&2; viol=1 +done < <(extension_id_fe_mismatches "$DIR/frontend/src" "$mid") # Frontend ↔ backend route parity: the FE service builds its data URL from REST_SEGMENT, which MUST be the full # 'extensions/<…>' path AND (for a single top-level resource) equal the manifest restSegment. A bare leaf 404s # every list/get/create/view-template call — the #1 scaffolding pitfall. See reference/00-naming.md. @@ -117,7 +128,7 @@ case "$DIR" in *samples/*|*templates/*) ;; *) if [ -d "$DIR/frontend/src" ]; then - hits=$(grep -rnE 'HelloService|HelloWorld|hw-(add|list|view|root)|hello\.service' "$DIR/frontend/src" 2>/dev/null || true) + hits=$(grep -rnE 'HelloService|HelloWorld|HelloAnalytics|hw-(add|list|view|root)|hello\.service|hello-(list|detail|form)|(create|update|delete)-hello' "$DIR/frontend/src" 2>/dev/null || true) if [ -n "$hits" ]; then echo " ✗ frontend still has template 'Hello'/'hw-' identifiers — rename them to your resource:" >&2 printf '%s\n' "$hits" | head -8 | sed 's/^/ /' >&2; viol=1 diff --git a/tests/test-build-extension-id.sh b/tests/test-build-extension-id.sh new file mode 100644 index 0000000..85ee0d8 --- /dev/null +++ b/tests/test-build-extension-id.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Tests for scripts/_extension_id.sh — the manifest `id` format gate in build-extension.sh. The id becomes the +# analytics event namespace, so it must be lowercase reverse-DNS with at least 3 segments. +# No docker/runtime needed. Usage: ./tests/test-build-extension-id.sh +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 +PASS=0; FAIL=0 +t() { printf ' %s … ' "$1"; } +ok() { echo "ok"; PASS=$((PASS+1)); } +bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); } + +# shellcheck source=scripts/_extension_id.sh +source scripts/_extension_id.sh 2>/dev/null || { echo "FAIL: cannot source scripts/_extension_id.sh"; exit 1; } + +echo "extension id format:" +for id in duplo.examples.helloworld com.duplocloud.eks-installer com.acme.a1; do + t "accepts '$id'"; extension_id_valid "$id" && ok || bad "rejected" +done +for id in duplo.helloworld Com.acme.x com.acme.eks_installer com..acme "" com.acme. .com.acme.x; do + t "rejects '$id'"; extension_id_valid "$id" && bad "accepted" || ok +done + +t "error message names the id, pattern and example" +msg=$(extension_id_error "Bad_Id") +if [[ "$msg" == *"'Bad_Id'"* && "$msg" == *"com.acme.my-extension"* && "$msg" == *"$EXTENSION_ID_REGEX"* ]]; then ok; else bad "got: $msg"; fi + +t "all samples/*/manifest.json ids pass" +sbad=0 +for m in samples/*/manifest.json; do + id=$(jq -r '.id // empty' "$m"); extension_id_valid "$id" || { sbad=1; echo -n "[$m: $id] "; } +done +[ "$sbad" = 0 ] && ok || bad "sample id invalid" + +t "build-extension.sh uses the helper (single regex source)" +grep -q 'extension_id_valid' scripts/build-extension.sh && ! grep -q 'a-z0-9-\]+)' scripts/build-extension.sh && ok || bad "not wired" + +echo "frontend EXTENSION_ID parity:" +tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/match" "$tmp/stale" "$tmp/none" +echo "export const EXTENSION_ID = 'com.acme.my-ext';" > "$tmp/match/analytics.ts" +echo "export const EXTENSION_ID = 'duplo.examples.helloworld';" > "$tmp/stale/analytics.ts" +echo "export const X = 1;" > "$tmp/none/a.ts" +mkdir -p "$tmp/dq" "$tmp/ty" "$tmp/bt" "$tmp/dqm" "$tmp/tym" "$tmp/btm" +echo 'export const EXTENSION_ID = "duplo.examples.helloworld";' > "$tmp/dq/a.ts" +echo "export const EXTENSION_ID: string = 'duplo.examples.helloworld';" > "$tmp/ty/a.ts" +echo 'export const EXTENSION_ID = `duplo.examples.helloworld`;' > "$tmp/bt/a.ts" +echo 'export const EXTENSION_ID = "com.acme.my-ext";' > "$tmp/dqm/a.ts" +echo "export const EXTENSION_ID: string = 'com.acme.my-ext';" > "$tmp/tym/a.ts" +echo 'export const EXTENSION_ID = `com.acme.my-ext`;' > "$tmp/btm/a.ts" +for f in dq ty bt; do + t "$f form mismatch fails"; [ "$(extension_id_fe_mismatches "$tmp/$f" com.acme.my-ext)" = "duplo.examples.helloworld" ] && ok || bad "not flagged" +done +for f in dqm tym btm; do + t "$f form match passes"; [ -z "$(extension_id_fe_mismatches "$tmp/$f" com.acme.my-ext)" ] && ok || bad "flagged" +done +t "matching EXTENSION_ID passes"; [ -z "$(extension_id_fe_mismatches "$tmp/match" com.acme.my-ext)" ] && ok || bad "flagged" +t "stale EXTENSION_ID fails"; [ "$(extension_id_fe_mismatches "$tmp/stale" com.acme.my-ext)" = "duplo.examples.helloworld" ] && ok || bad "not flagged" +t "no EXTENSION_ID declaration passes"; [ -z "$(extension_id_fe_mismatches "$tmp/none" com.acme.my-ext)" ] && ok || bad "flagged" +t "missing dir passes"; [ -z "$(extension_id_fe_mismatches "$tmp/nope" com.acme.my-ext)" ] && ok || bad "flagged" +t "template analytics.ts matches template manifest id" +[ -z "$(extension_id_fe_mismatches .claude/skills/duplo-extension-dev/templates/helloworld/frontend/src "$(jq -r .id .claude/skills/duplo-extension-dev/templates/helloworld/manifest.json)")" ] && ok || bad "drift" +t "build-extension.sh wires the parity check" +grep -q 'extension_id_fe_mismatches' scripts/build-extension.sh && ok || bad "not wired" + +echo "$PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]